fix(loop): 畸形 tool_call 首败即降级非流式 + 损坏原文留痕(bump 0.58.18)

task 716ed3be 实测:deepseek-v4-pro 大参数中文 write(3~4k 字符)流式重 roll
同轮连挂 3 次全畸形,失败强相关而非独立随机,每章拖 3 分钟+,最后总靠非流式
兜底救场,3 次流式重试纯烧 token。

- _MAX_MALFORMED_RETRIES=3(3 流式+1 非流式)→ _MAX_MALFORMED_ATTEMPTS=3
  (1 流式+2 非流式),首败即降级;修掉旧 warn 计数可显示 "4/3" 的 off-by-one
- 新增 _log_malformed_args:丢弃前把损坏 arguments 首尾各 300 字符(ascii()
  转义)+ JSON 报错位置打 stdout [malformed] 标签 —— 畸形轮不入库,这是唯一
  留痕,供定性 provider delta 错位 vs 本地拼接 bug 及报 case 取证
- 新增 tests/test_loop_malformed_retry.py 4 用例;RUN.md 故障兜底同步
  (v4-pro 也中招 + [malformed] 取证口径);CHANGELOG/PROGRESS 更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-13 11:02:27 +08:00
parent 4a591e1a90
commit 3c714b34c4
6 changed files with 142 additions and 14 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.58.18 — 2026-07-13
- 修复撰写长文档(方案 / 申报书逐章写入)时反复出现「工具调用参数损坏,丢弃本轮重试」、每章要等 3 分钟以上的问题:遇到损坏现在一次就切换到更稳的通道重试,长文写入明显变快,重试提示也大幅减少。
## 0.58.17 — 2026-07-13 ## 0.58.17 — 2026-07-13
- 修复打开历史对话时图片预览 / 文件按钮不显示的问题:此前生成的图片只在生成当下可见,切换对话或刷新后就"消失"(文件其实还在,右侧文件面板能找到),现在重新打开对话也能直接看到;另外中文回复里紧贴文字的文件路径(如"已保存到xxx.png")此前点不了,现在也会正常挂出可点的文件按钮。 - 修复打开历史对话时图片预览 / 文件按钮不显示的问题:此前生成的图片只在生成当下可见,切换对话或刷新后就"消失"(文件其实还在,右侧文件面板能找到),现在重新打开对话也能直接看到;另外中文回复里紧贴文字的文件路径(如"已保存到xxx.png")此前点不了,现在也会正常挂出可点的文件按钮。

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-13 / 0.58.18**:**畸形 tool_call 首败即降级非流式 + 损坏原文留痕**(用户报 task 716ed3be 芜湖零碳园区方案逐章 write 反复「工具调用参数损坏」1/3→3/3):DB 实测 deepseek-v4-pro 也踩 v4-flash 老病(流式 delta 错位粘坏大参数 arguments),且**同轮流式重 roll 失败强相关**——3~4k 字符中文 write 连挂 3 次全畸形、每章拖 3 分钟+,最后总靠非流式兜底救场,3 次流式重试纯烧 token(每次一整段 2k+ token 生成,不记账但 provider 照计费)。改法(`core/loop.py`):① `_MAX_MALFORMED_RETRIES=3`(3 流式+1 非流式)→ `_MAX_MALFORMED_ATTEMPTS=3`(1 流式+2 非流式),首败即降级;顺手修掉旧版 warn 计数可显示 "4/3" 的 off-by-one。② 新增 `_log_malformed_args`:丢弃前把损坏 arguments 首尾各 300 字符(ascii() 转义防 GBK 控制台崩)+ JSON 报错位置打 stdout `[malformed]` 标签——畸形轮不入库,这是唯一留痕,供定性 provider delta 错位 vs 本地拼接 bug 及报 case 取证(toolfail 扫描器看不见此类失败:无 tool 消息)。新增 `tests/test_loop_malformed_retry.py` 4 用例(object.__new__ 绕构造器打桩双通道):干净流式不重试/首败即非流式/耗尽返最后 response 交 executor 返错/中途 cancel 短路。RUN.md 故障兜底 909 行同步(v4-pro 也中招 + `[malformed]` 日志取证口径)。
- **07-13 / 0.58.17**:**历史消息渲染丢图片预览/chip 两根因修复**(用户报"对话里看不到预览图,有时也不挂 chip"):① 确定性回归(0.12.16 `82feece` 并发化引入):`selectTask` 的 `Promise.all``loadMessages()` 内部就调 `renderMessages()`,而 `state.taskMeta = meta` 要等 Promise.all 整体完成——首屏渲染时 taskMeta 必然是上个 task 的/null → `_workingDirName` 算出的 wd 错/空 → `extractArtifactRels``[]`,chip+inline 图整体丢失(直播 SSE 期间可见、切走再切回/刷新就"消失"的体感即此)。修法:`loadMessages` 加 `{render}` 开关,selectTask 传 `render:false` 保持并发取数,`renderMessages` 延后到 taskMeta 赋值后(零额外 RTT,只等 meta/messages 较慢者)。② 中文语境抽取正则漏挂:media.js lead 边界原要求空格/英文标点,"已保存到gpt生图/xxx.png"路径前紧贴汉字不命中;且路径后紧贴",请查看"时 tail 把中文标点连后文吞进文件名产生 404 坏 chip。修法:lead 改"行首或任意非 `[A-Za-z0-9._-]`"(汉字算边界,仍防 mygpt生图 前缀误挂),tail 排除集抽成 `_TAIL_CLS` 常量并补常见中文标点(主规则+legacy figures/videos 双处)。node 离线回归 12 用例全绿(CJK 前邻/后邻标点/前缀防护/中文文件名/legacy 不重复等)。 - **07-13 / 0.58.17**:**历史消息渲染丢图片预览/chip 两根因修复**(用户报"对话里看不到预览图,有时也不挂 chip"):① 确定性回归(0.12.16 `82feece` 并发化引入):`selectTask` 的 `Promise.all``loadMessages()` 内部就调 `renderMessages()`,而 `state.taskMeta = meta` 要等 Promise.all 整体完成——首屏渲染时 taskMeta 必然是上个 task 的/null → `_workingDirName` 算出的 wd 错/空 → `extractArtifactRels``[]`,chip+inline 图整体丢失(直播 SSE 期间可见、切走再切回/刷新就"消失"的体感即此)。修法:`loadMessages` 加 `{render}` 开关,selectTask 传 `render:false` 保持并发取数,`renderMessages` 延后到 taskMeta 赋值后(零额外 RTT,只等 meta/messages 较慢者)。② 中文语境抽取正则漏挂:media.js lead 边界原要求空格/英文标点,"已保存到gpt生图/xxx.png"路径前紧贴汉字不命中;且路径后紧贴",请查看"时 tail 把中文标点连后文吞进文件名产生 404 坏 chip。修法:lead 改"行首或任意非 `[A-Za-z0-9._-]`"(汉字算边界,仍防 mygpt生图 前缀误挂),tail 排除集抽成 `_TAIL_CLS` 常量并补常见中文标点(主规则+legacy figures/videos 双处)。node 离线回归 12 用例全绿(CJK 前邻/后邻标点/前缀防护/中文文件名/legacy 不重复等)。
- **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models``is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default``_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。 - **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models``is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default``_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。
- **07-13 / 0.58.15**:**结构图 mermaid vs 生图:改为 ask_user 让用户明确点选**:用户反馈"画流程图/架构图时模型直奔 mermaid,想用生图(gpt image)没入口"。根因两处:①岔路引导只写在 imagegen SKILL.md 里,而 mermaid 路径根本不触发 load_skill,模型全程看不到岔路口;②SKILL.md 岔路段把"画个流程图/做个架构图"硬性划给 mermaid。改法(口径:结构图选型交给用户点选,不做信号猜测):`core/agent_builder.py` 新增 `_MEDIA_DIAGRAM_FORK_SEG`(挂 seedream 或 gpt_image 任一 variant 时追加进媒体段)——用户要流程图/架构图/技术路线/时序图时先 `ask_user` 二选一(mermaid 矢量:文字准可编辑零成本 vs 生图视觉版:有质感但中文标签易乱码/位图不可改字),免问例外=用户已点名工具/本对话已选过路线/paper·proposal skill 管线内部要求 mermaid;SKILL.md 岔路段同口径重写,并补"生图画结构图取舍"段(乱码/不可改字提前说清,prompt 按风格化示意少文字装配,想两全建议 mermaid 出结构+生图出底图),反模式条同步;SKILL_LIST.md imagegen 岔路段同步。纯 prompt 调整。 - **07-13 / 0.58.15**:**结构图 mermaid vs 生图:改为 ask_user 让用户明确点选**:用户反馈"画流程图/架构图时模型直奔 mermaid,想用生图(gpt image)没入口"。根因两处:①岔路引导只写在 imagegen SKILL.md 里,而 mermaid 路径根本不触发 load_skill,模型全程看不到岔路口;②SKILL.md 岔路段把"画个流程图/做个架构图"硬性划给 mermaid。改法(口径:结构图选型交给用户点选,不做信号猜测):`core/agent_builder.py` 新增 `_MEDIA_DIAGRAM_FORK_SEG`(挂 seedream 或 gpt_image 任一 variant 时追加进媒体段)——用户要流程图/架构图/技术路线/时序图时先 `ask_user` 二选一(mermaid 矢量:文字准可编辑零成本 vs 生图视觉版:有质感但中文标签易乱码/位图不可改字),免问例外=用户已点名工具/本对话已选过路线/paper·proposal skill 管线内部要求 mermaid;SKILL.md 岔路段同口径重写,并补"生图画结构图取舍"段(乱码/不可改字提前说清,prompt 按风格化示意少文字装配,想两全建议 mermaid 出结构+生图出底图),反模式条同步;SKILL_LIST.md imagegen 岔路段同步。纯 prompt 调整。

2
RUN.md
View File

@ -906,7 +906,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 `fonts-noto-cjk fonts-wqy-microhei` + `fc-cache`,但**改了 Dockerfile 必须重 build 镜像 + 清旧容器**才生效(旧容器仍跑老镜像):`docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) .` → `docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)``systemctl restart zcbot`。验证:`docker run --rm zcbot-sandbox:latest fc-list :lang=zh` 应列出 Noto/WenQuanYi | | matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 `fonts-noto-cjk fonts-wqy-microhei` + `fc-cache`,但**改了 Dockerfile 必须重 build 镜像 + 清旧容器**才生效(旧容器仍跑老镜像):`docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) .` → `docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)``systemctl restart zcbot`。验证:`docker run --rm zcbot-sandbox:latest fc-list :lang=zh` 应列出 Noto/WenQuanYi |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 | | dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) | | dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
| 对话里偶发 `[Error] invalid JSON arguments` / `[Error] bad arguments to write: ... missing required` | deepseek-v4-flash **大参数工具调用(大 write/run_python,≈7K+ 字符)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮重 roll(≤3 次)+ 最后一次降级非流式。仍频繁撞 → 引导模型**把大文件拆小 / 用 run_python 分块写**,或换 `deepseek_v4.pro`。前端看到 warn「工具调用参数损坏…重试」即此机制在生效 | | 对话里偶发 `[Error] invalid JSON arguments` / warn「工具调用参数损坏…重试」 | deepseek v4 系(flash/**pro 也踩过**,2026-07 task 716ed3be)**大参数工具调用(大 write/run_python,≈3K+ 字符中文长文)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动,同轮流式重 roll 连挂强相关)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮,**首败即降级非流式重试**(≤3 次总尝试),非流式历史上从未再畸形。每次丢弃会打服务端日志 `[malformed] task=... tool=... head=... tail=...`(损坏原文唯一留痕,journalctl 里 grep `\[malformed\]` 取证/报 provider case)。仍频繁撞 → 引导模型把大文件拆小分块写 |
| 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` | | 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` |
| 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 | | 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 |
| 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 | | 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.17" __version__ = "0.58.18"

View File

@ -171,6 +171,34 @@ def _malformed_tool_calls(response: Any) -> List[str]:
return bad return bad
def _log_malformed_args(task_id: Any, response: Any) -> None:
"""畸形 arguments 的首尾片段 + JSON 报错位置打服务端日志(stdout)。
畸形轮不 append/不记账,这里是唯一留痕 用于事后定性损坏形态(provider 流式
delta 错位 vs 本地 stream_chunk_builder 拼接 bug),以及向 provider case 取证
片段过 ascii() 转义:日志消费端编码不可控(Windows dev 控制台 GBK emoji 会崩),
转义后 grep '\\[malformed\\]' 拿到的内容可无损还原日志失败静默 留痕绝不能
反过来打断重试主路径
"""
try:
msg = response.choices[0].message
for tc in (getattr(msg, "tool_calls", None) or []):
raw = (getattr(tc.function, "arguments", None) or "").strip()
if not raw:
continue
try:
json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
print(
f"[malformed] task={task_id} tool={tc.function.name} "
f"len={len(raw)} err={e} "
f"head={ascii(raw[:300])} tail={ascii(raw[-300:])}",
flush=True,
)
except Exception:
pass
def _usage_to_dict(usage: Any) -> dict: def _usage_to_dict(usage: Any) -> dict:
if not usage: if not usage:
return {} return {}
@ -390,10 +418,12 @@ class AgentLoop:
self._emit({"type": "done"}) self._emit({"type": "done"})
return "[reached max iterations]" return "[reached max iterations]"
# 工具参数畸形时,丢弃整轮重 roll 的最大次数;第 _MAX_MALFORMED_RETRIES 次(即最后 # 工具参数畸形时的总尝试次数(首次流式 + 之后全部非流式)。流式只试一次:实测
# 一次)降级走非流式(provider 服务端拼 tool_calls,绕开流式 delta 错位)。实测大参数 # (2026-07,task 716ed3be,deepseek-v4-pro)3~4k 字符中文长文 write 的流式重 roll
# 工具调用偶发连续两次畸形,故留够重试余量。 # 同轮连挂 3 次——同轮失败强相关而非独立随机,多试流式纯烧 token 拖时间(每次都是
_MAX_MALFORMED_RETRIES = 3 # 一整段 2k+ token 生成),首败即降级非流式(provider 服务端拼 tool_calls,绕开
# 流式 delta 错位),历史数据里非流式兜底从未再畸形。
_MAX_MALFORMED_ATTEMPTS = 3
# 连续多少步「整步无净产出」(全是 [Error]/重复结果/被拦)就判定空转、主动停。 # 连续多少步「整步无净产出」(全是 [Error]/重复结果/被拦)就判定空转、主动停。
# 比 max_iterations 早得多掐死死循环(第 8 步 vs 第 120 步),同时放正经长任务自由跑。 # 比 max_iterations 早得多掐死死循环(第 8 步 vs 第 120 步),同时放正经长任务自由跑。
@ -435,10 +465,12 @@ class AgentLoop:
(choices[0].message + usage) (choices[0].message + usage)
- 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接 - 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接
畸形重试:deepseek-v4-flash 大参数工具调用偶发把内容碎片错位粘进 arguments,拼回 畸形重试:deepseek v4 (flash/pro 均实测踩过)大参数工具调用偶发把内容碎片
JSON 解析失败这种损坏一旦入库会被每轮重发诱导模型继续学坏(投毒级联) 错位粘进 arguments,拼回后 JSON 解析失败这种损坏一旦入库会被每轮重发诱导
故拼回后先校验 tool_call arguments 能否解析:不能 丢弃整轮( append/不记账) 模型继续学坏(投毒级联)故拼回后先校验 tool_call arguments 能否解析:不能
roll;连续失败到最后一次降级非流式兜底重试消耗的 token 不单独记账(罕见路径) 丢弃整轮( append/不记账,原始损坏片段打服务端日志留痕)并立刻降级非流式重试
(同轮流式失败强相关,重试不再走流式);全部尝试耗尽仍畸形则交给 executor
invalid-JSON 分支返错给模型重试消耗的 token 不单独记账
""" """
# 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。 # 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。
compact_threshold = int( compact_threshold = int(
@ -455,8 +487,8 @@ class AgentLoop:
# 50% 进压缩区、85% 触发折叠(context_fold)。 # 50% 进压缩区、85% 触发折叠(context_fold)。
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN), "context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
}) })
for attempt in range(self._MAX_MALFORMED_RETRIES + 1): for attempt in range(self._MAX_MALFORMED_ATTEMPTS):
use_nonstream = attempt == self._MAX_MALFORMED_RETRIES use_nonstream = attempt > 0
if use_nonstream: if use_nonstream:
response = self._nonstream_once(llm_messages) response = self._nonstream_once(llm_messages)
else: else:
@ -467,11 +499,16 @@ class AgentLoop:
bad = _malformed_tool_calls(response) bad = _malformed_tool_calls(response)
if not bad: if not bad:
return response, False return response, False
_log_malformed_args(self.session.task_id, response)
self._emit({ self._emit({
"type": "warn", "type": "warn",
"msg": f"工具调用参数损坏 {bad},丢弃本轮重试 ({attempt + 1}/{self._MAX_MALFORMED_RETRIES})", "msg": (
f"工具调用参数损坏 {bad},丢弃本轮"
f"{'重试' if use_nonstream else ',改非流式重试'}"
f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
),
}) })
# 非流式兜底仍畸形(理论极罕见):交还给 _execute_tool_call 的 invalid-JSON 分支 # 非流式重试仍畸形(理论极罕见):交还给 _execute_tool_call 的 invalid-JSON 分支
# 优雅返错给模型,而非在此死循环。 # 优雅返错给模型,而非在此死循环。
return response, False return response, False

View File

@ -0,0 +1,86 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.loop import AgentLoop, _malformed_tool_calls # noqa: E402
def _resp(arguments: str):
"""最小 response 桩:单条 tool_call,arguments 由测试指定。"""
tc = SimpleNamespace(function=SimpleNamespace(name="write", arguments=arguments))
msg = SimpleNamespace(tool_calls=[tc], content=None)
return SimpleNamespace(choices=[SimpleNamespace(message=msg)])
GOOD = '{"path": "a.md", "content": "ok"}'
BAD = '].cells[1].merge({"path": "a.md"' # 流式 delta 错位的典型形态
def _make_loop(stream_results, nonstream_results):
"""绕开构造器搭 AgentLoop:只装 _stream_llm 用到的属性,打桩两条取流路径。"""
loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(reliable_context=64_000)
loop.session = SimpleNamespace(messages=[], task_id="test-task")
loop.events = []
loop._emit = loop.events.append
calls = {"stream": 0, "nonstream": 0}
def collect_stream_once(llm_messages):
calls["stream"] += 1
return stream_results.pop(0), False
def nonstream_once(llm_messages):
calls["nonstream"] += 1
return nonstream_results.pop(0)
loop._collect_stream_once = collect_stream_once
loop._nonstream_once = nonstream_once
return loop, calls
class TestMalformedRetry(unittest.TestCase):
def test_clean_stream_no_retry(self):
loop, calls = _make_loop([_resp(GOOD)], [])
resp, cancelled = loop._stream_llm()
self.assertFalse(cancelled)
self.assertEqual(_malformed_tool_calls(resp), [])
self.assertEqual(calls, {"stream": 1, "nonstream": 0})
def test_first_failure_falls_back_to_nonstream(self):
"""核心断言:流式只试 1 次,首败即降级非流式(不再流式重 roll)。"""
loop, calls = _make_loop([_resp(BAD)], [_resp(GOOD)])
resp, cancelled = loop._stream_llm()
self.assertFalse(cancelled)
self.assertEqual(_malformed_tool_calls(resp), [])
self.assertEqual(calls, {"stream": 1, "nonstream": 1})
warns = [e for e in loop.events if e.get("type") == "warn"]
self.assertEqual(len(warns), 1)
self.assertIn("非流式", warns[0]["msg"])
def test_all_attempts_exhausted_returns_last(self):
"""全部尝试耗尽仍畸形 → 返回最后一次 response(交 executor 返错),不死循环。"""
n_nonstream = AgentLoop._MAX_MALFORMED_ATTEMPTS - 1
loop, calls = _make_loop([_resp(BAD)], [_resp(BAD)] * n_nonstream)
resp, cancelled = loop._stream_llm()
self.assertFalse(cancelled)
self.assertTrue(_malformed_tool_calls(resp))
self.assertEqual(calls, {"stream": 1, "nonstream": n_nonstream})
warns = [e for e in loop.events if e.get("type") == "warn"]
self.assertEqual(len(warns), AgentLoop._MAX_MALFORMED_ATTEMPTS)
def test_cancel_mid_stream_short_circuits(self):
loop, calls = _make_loop([], [])
loop._collect_stream_once = lambda m: (None, True)
resp, cancelled = loop._stream_llm()
self.assertTrue(cancelled)
self.assertIsNone(resp)
self.assertEqual(calls["nonstream"], 0)
if __name__ == "__main__":
unittest.main()