From 3c60c5c73697a1c295cb97dcfd8c4dd64bcb443f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 17 Jul 2026 08:22:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(loop):=20=E5=A0=B5=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=81=9A=E9=9B=86=E4=B8=A4=E8=A3=82=E7=BC=9D=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=BF=85=E5=A1=ABkey=E8=A2=AB=E5=90=9E=E7=95=B8=E5=BD=A2?= =?UTF-8?q?=E8=B5=B0=E9=9D=9E=E6=B5=81=E5=BC=8F=E9=87=8D=E8=AF=95=20+=20Re?= =?UTF-8?q?peatGuard=E8=BF=9E=E7=BB=AD=E5=90=8C=E9=94=99streak=E6=8B=A6(bu?= =?UTF-8?q?mp=200.58.33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扫近14天工具失败面板 top13 定位两条反复烧 token 的裂缝: ① 必填 key 被吞畸形(面板 #3 edit 缺 path,14次/13task/9用户,最广): 定层坐实是流式 wire 乱序(path 值碎片被吞进 old_str 尾部,独立 path 键消失), 跨 provider,非模型漏参。关键区别是 JSON parse 成功 → 既不命中 _malformed_tool_calls(只抓 parse 失败)、salvage 也救不了,一路漏到 executor 才报错反复烧。新增 _toolcalls_partial_args(非空 dict 且 0<缺失必填<全部必填) 接进 _stream_llm attempt 循环走非流式重试(服务端一次拼好绕开乱序), _log_partial_args 落 usage_events(kind=tool_malformed,签名 missing required keys) 纳入面板可观测(此前伪装成普通 [Error] 完全是盲区)。 ② 换参数撞同一堵墙(面板 #2 edit old_str not found,单task 9次/24h 7次): 模型微调 old_str 重试,精确 args 指纹每次不同 → RepeatGuard arg 判据不累计。 补第二道判据:按工具的连续同类错误 streak(_norm_err 抹平路径/数字后签名相同、 跨≥2 不同 args),SOFT 注入定向提示、HARD 拦截一次并重置到 SOFT(非永久封死), 任一成功立即清零。 测试 test_loop_repeat_guard.py +12(err-streak 5 + partial-args 7),20/20 通过; 相邻 salvage/toolfail_malformed 23/23 通过,未碰坏既有行为。additive 内部机制, 不碰对外 API/DB 契约。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 1 + core/__init__.py | 2 +- core/loop.py | 194 ++++++++++++++++++++++++++++++++ tests/test_loop_repeat_guard.py | 124 +++++++++++++++++++- 4 files changed, 319 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index a23f8b0..d54ff1f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-17 / 0.58.33**:**堵工具失败聚集里两条反复烧 token 的裂缝**(扫近 14 天失败面板 top13 定位,`scripts/standalone` 复现口径)。**裂缝①「必填 key 被吞」畸形(面板 #3 `edit 缺少必填参数 ['path']`,14 次 / 13 task / 9 用户,最广)**:定层查一条原始 tool_call 坐实是**流式 wire 乱序**——某键的值碎片被瞬移拼进相邻字符串(实证:path 的 `.../gen_final_report_v2.py` 被吞进 `old_str` 尾部,独立的 `"path"` 键随之消失),跨 provider(`call_00_` 网关档 + `toolu_` Anthropic 系),非模型漏参。这类与既有 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls`(只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路漏到 executor 才在语义层报错、回 `[Error]` 喂回模型 → 再拼再乱序反复烧。**修**:`_toolcalls_partial_args`(解析为非空 dict 且 `0]()`,`renderMd` 出 ``,但 `chat.js` 的 `#chat-stream` 点击委托无 `` 拦截 → 原生跳转打到 API 不存在路由返 FastAPI 默认 `{"detail":"Not Found"}`。加拦截 `.msg .body a[href]`:外链开新标签,内部相对路径 `openFilePreview(rel)`(复用预览 modal,pptx 转 PDF),`#` 锚点走默认。纯前端修复,不碰对外契约。 diff --git a/core/__init__.py b/core/__init__.py index 674afc0..2601ba4 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.32" +__version__ = "0.58.33" diff --git a/core/loop.py b/core/loop.py index 7e0d852..ef751ec 100644 --- a/core/loop.py +++ b/core/loop.py @@ -11,6 +11,7 @@ from __future__ import annotations import hashlib import json +import re import threading import time from pathlib import Path @@ -42,6 +43,21 @@ _PPTX_GUARD_TOOLS = ("shell", "run_python") _CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]" +# 错误签名归一:同一类工具报错在不同参数/路径/数字下抹平,让「反复撞同一堵墙」 +# 能被识别成同一签名(如 old_str 每次微调但结果始终 "old_str not found in ")。 +# 与 core/toolfail._normalize 同源思路,此处保持 loop 自包含(不反向依赖 toolfail)。 +_ERR_PATH_RE = re.compile(r"(?:[A-Za-z]:)?(?:[/\\][\w.\-一-鿿*]+){2,}") +_ERR_NUM_RE = re.compile(r"\d+") + + +def _norm_err(result: str) -> str: + """取 [Error] 结果首行、抹平路径/数字,得到稳定的错误签名(截断 120)。""" + line = (result.lstrip().splitlines() or [""])[0] + line = _ERR_PATH_RE.sub("", line) + line = _ERR_NUM_RE.sub("N", line) + return line.strip()[:120] + + class _RepeatGuard: """检测「同名同参 + 无产出」的病理性重复调用,断掉死循环。 @@ -59,6 +75,13 @@ class _RepeatGuard: 顺带堵掉 `_malformed_tool_calls` 的洞:大参数畸形退化成合法空 `{}` 时,executor 每次 返回同一句「缺少必填参数」→ 走 dup 分支被这同一机制拦下,无需单独特判空 `{}`。 + 第二道判据(2026-07,失败面板 #2:edit `old_str not found` 单 task 反复撞墙):模型每次 + **微调参数**(old_str 改一点)重试同一操作,精确 args 指纹每次不同 → 上面的 arg 判据不累计, + 只能等 _STALL_LIMIT 慢慢兜。补一条**按工具 + 归一化错误签名的连续 streak**:同一工具连续返回 + 同一类错误(路径/数字抹平后相同)且跨 >= 2 个不同 args → 认定「换法子撞同一堵墙」,SOFT 注入 + 定向提示、HARD 拦截一次(拦后重置到 SOFT,非永久封死,给换路后的重试留活口)。任一非错误结果 + (真推进了)立即清零该工具的 streak。 + 状态活在单次 task run 内(AgentLoop 实例持有),不跨 task。 """ @@ -68,6 +91,8 @@ class _RepeatGuard: def __init__(self) -> None: # key -> {"hashes": set[str], "unproductive": int, "n": int, "blocked": int} self._h: Dict[str, dict] = {} + # name -> {"esig": str, "count": int, "args": set[str]}:按工具的连续同类错误 streak + self._err_streak: Dict[str, dict] = {} @staticmethod def _key(name: str, args: Any) -> str: @@ -112,8 +137,41 @@ class _RepeatGuard: st["unproductive"] = 0 st["hashes"].add(h) st["n"] += 1 + # 第二道判据:按工具的连续同类错误 streak(跨不同 args 撞同一堵墙) + if is_err: + esig = _norm_err(result) + s = self._err_streak.get(name) + if s and s["esig"] == esig: + s["count"] += 1 + s["args"].add(self._key(name, args)) + else: + self._err_streak[name] = {"esig": esig, "count": 1, "args": {self._key(name, args)}} + else: + self._err_streak.pop(name, None) # 真推进了 → 清零该工具 streak return st["unproductive"], not (is_err or dup) + def err_streak(self, name: str) -> Tuple[int, int, str]: + """返回 (连续同类错误次数, 涉及的不同 args 数, 错误签名);无则 (0,0,'')。""" + s = self._err_streak.get(name) + if not s: + return 0, 0, "" + return s["count"], len(s["args"]), s["esig"] + + def should_block_err(self, name: str) -> bool: + """执行前:该工具已连续 >= HARD 次同类错误且跨 >= 2 个不同 args → 拦截一次。""" + cnt, n_args, _ = self.err_streak(name) + return cnt >= self.HARD and n_args >= 2 + + def register_err_block(self, name: str) -> Tuple[int, str]: + """记一次 err-streak 拦截,返回 (拦截时的连续次数, 错误签名);拦后重置到 SOFT + (保留 esig/args)—— 非永久封死,让模型换路后的下一次重试还能进来。""" + s = self._err_streak.get(name) + if not s: + return 0, "" + cnt, esig = s["count"], s["esig"] + s["count"] = self.SOFT + return cnt, esig + def _extract_delta_content(chunk: Any) -> Optional[str]: """从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream: @@ -178,6 +236,89 @@ def _malformed_tool_calls(response: Any) -> List[str]: return bad +def _toolcalls_partial_args( + response: Any, required_by_tool: Dict[str, List[str]] +) -> List[Tuple[Any, str, List[str]]]: + """检出「JSON 能解析、但必填 key 被吞掉」的畸形 tool_call。 + + 背景(2026-07,失败面板 #3:edit `缺少必填参数 ['path']` 跨 13 task/9 用户):流式 + arguments delta 乱序把某个键的值碎片瞬移拼进相邻字符串(实证 [1]:path 的 + `.../gen_final_report_v2.py` 被吞进 old_str 尾部),独立的 `"path"` 键随之消失。这类 + 与 char-0 前缀畸形的关键区别是 **JSON parse 成功** → 既不命中 `_malformed_tool_calls` + (只抓 parse 失败)、salvage 也救不了(parse-to-end/key 白名单对合法但错位无能),一路 + 漏到 executor 才在语义层报「缺必填参数」,回 [Error] 喂回模型 → 再拼再乱序,反复烧。 + + 判据(窄,避免误伤模型真漏参):解析为**非空 dict** 且 **至少一个必填 key 在场**同时 + **至少一个必填 key 缺失**(即 0 < len(missing) < len(required))。空 `{}` / 必填全缺 + (纯垃圾/无关键)不算 —— 交给 executor + _RepeatGuard 现状处理。 + + 返回 [(tc, name, missing_keys), ...]。 + """ + try: + msg = response.choices[0].message + except Exception: + return [] + out: List[Tuple[Any, str, List[str]]] = [] + for tc in (getattr(msg, "tool_calls", None) or []): + try: + name = tc.function.name + raw = (getattr(tc.function, "arguments", None) or "").strip() + except Exception: + continue + if not raw: + continue + try: + obj = json.loads(raw) + except (json.JSONDecodeError, ValueError): + continue # parse 失败归 _malformed_tool_calls,不重复处理 + if not isinstance(obj, dict) or not obj: + continue + required = required_by_tool.get(name) or [] + if not required: + continue + missing = [k for k in required if k not in obj] + if 0 < len(missing) < len(required): + out.append((tc, name, missing)) + return out + + +def _log_partial_args( + task_id: Any, user_id: Any, model_profile: str, + partial: List[Tuple[Any, str, List[str]]], response: Any, +) -> None: + """必填 key 被吞的畸形留痕:与 _log_malformed_args 对称,进 usage_events(kind= + tool_malformed),error 签名固定为 `missing required keys [...]` —— 在失败面板里和 + char-0 型(`Expecting value`)、executor 的「缺必填参数」区分开,便于统计这条新裂缝。 + 任何一路失败都静默,绝不打断重试主路径。""" + try: + usage = _extract_usage_details(getattr(response, "usage", None)) + for tc, name, missing in partial: + raw = (getattr(tc.function, "arguments", None) or "") + err = f"missing required keys {missing}" + print( + f"[malformed:partial] task={task_id} tool={name} len={len(raw)} " + f"{err} head={ascii(raw[:300])} tail={ascii(raw[-300:])}", + flush=True, + ) + try: + record_malformed_tool_call( + task_id=task_id, + user_id=user_id, + model_profile=model_profile, + tool=name, + arg_len=len(raw), + error=err, + head=raw[:300], + tail=raw[-300:], + tokens_in=usage["tokens_in"], + tokens_out=usage["tokens_out"], + ) + except Exception: + pass + except Exception: + pass + + def _is_empty_response(response: Any) -> bool: """检出「空响应」:assistant 轮既无 tool_calls 又无正文(去空白后为空)。 @@ -575,6 +716,11 @@ class AgentLoop: # 50% 进压缩区、85% 触发折叠(context_fold)。 "context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN), } + # 各工具必填参数(单一事实源:executor schema),供「必填 key 被吞」畸形检测。 + required_by_tool = { + sc["function"]["name"]: (sc["function"].get("parameters") or {}).get("required") or [] + for sc in self.executor.schemas() + } for attempt in range(self._MAX_MALFORMED_ATTEMPTS): use_nonstream = attempt > 0 # 每个 attempt 重发 llm_start(stats 同一份):非流式重试完成前零 delta 事件, @@ -609,6 +755,25 @@ class AgentLoop: ), }) continue + # 必填 key 被吞的畸形(parse 成功、salvage 无能):非流式重试(服务端一次拼好, + # 绕开流式 delta 乱序)。耗尽尝试仍缺 → 落下面 return,交 executor 返「缺必填参数」 + # 给模型(多为模型真漏参,不再空转)。 + partial = _toolcalls_partial_args(response, required_by_tool) + if partial: + _log_partial_args( + self.session.task_id, self.user_id, + f"{self.caps.family}.{self.caps.variant}", partial, response, + ) + names = ", ".join(f"{n}(missing={m})" for _, n, m in partial) + self._emit({ + "type": "warn", + "msg": ( + f"工具调用必填参数被吞 {names},丢弃本轮" + f"{'重试' if use_nonstream else ',改非流式重试'}" + f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})" + ), + }) + continue return response, False # 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire), # 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试; @@ -824,6 +989,26 @@ class AgentLoop: }) return result, False + # err-streak 拦截:换着参数撞同一堵墙(如 edit 反复 old_str not found)。拦一次逼换路, + # 拦后 streak 重置到 SOFT(非永久封死)。 + if self._repeat_guard.should_block_err(name): + cnt, esig = self._repeat_guard.register_err_block(name) + result = ( + f"[已拦截重复调用] {name} 已连续 {cnt} 次撞同一个错误「{esig}」(每次只微调了参数)。" + "再这么试下去不会有新结果。换个做法:① 先 read 目标文件/用 grep 看确切内容" + "(old_str 必须逐字匹配,含空白与缩进);② 或换工具/换思路;③ 实在推进不了就停下来" + "如实告诉用户卡在哪。" + ) + self._emit({"type": "warn", "msg": f"拦截撞墙调用 {name}(连续同错第 {cnt} 次)"}) + self._emit({ + "type": "tool_result", + "name": name, + "result": result, + "preview": result, + "truncated": False, + }) + return result, False + ctx = ExecCtx( user_id=self.user_id, task_id=self.session.task_id, @@ -879,6 +1064,15 @@ class AgentLoop: f"\n\n[重复调用警告] 你已用完全相同的参数调用 {name} {unproductive + 1} 次、结果没有变化。" "再原样重调不会有新结果——换参数/换工具/换思路,或停下来向用户说明卡在哪。" ) + else: + # err-streak SOFT 提示:参数每次不同但连撞同一个错(arg 判据不累计,单独提示)。 + cnt, n_args, esig = self._repeat_guard.err_streak(name) + if cnt == _RepeatGuard.SOFT and n_args >= 2: + self._emit({"type": "warn", "msg": f"{name} 连续 {cnt} 次同错「{esig[:40]}」,已提示模型换路"}) + result += ( + f"\n\n[撞墙警告] 你换着参数调用 {name} 已连续 {cnt} 次撞同一个错误「{esig}」。" + "光微调参数没用——先 read/grep 看清目标的确切内容再动手,或换工具/换思路。" + ) # 平台层产物机检(0.35.1 复发后落地):本步 shell/run_python 新产出的 .pptx # 若命中「整页贴图」伪导出特征,把 ERROR 注入 tool 结果逼模型当场返工。 diff --git a/tests/test_loop_repeat_guard.py b/tests/test_loop_repeat_guard.py index 71c8802..94bce72 100644 --- a/tests/test_loop_repeat_guard.py +++ b/tests/test_loop_repeat_guard.py @@ -3,10 +3,11 @@ 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 _RepeatGuard # noqa: E402 +from core.loop import _RepeatGuard, _toolcalls_partial_args # noqa: E402 def _simulate(guard: _RepeatGuard, name: str, args, results: list[str]) -> list[str]: @@ -98,5 +99,126 @@ class TestRepeatGuard(unittest.TestCase): self.assertFalse(g.should_block("document_search", {"queries": ["b"]})) +class TestErrStreak(unittest.TestCase): + """第二道判据:换着参数撞同一堵墙(如 edit 反复 old_str not found)。""" + + def _run_varargs(self, g, name, errs): + """每次用不同 args 调用,结果为 errs[i]。返回是否曾被 err-block。""" + blocked = False + for i, r in enumerate(errs): + if g.should_block_err(name): + g.register_err_block(name) + blocked = True + continue + g.record(name, {"old_str": f"variant-{i}", "path": "/x/y.py"}, r) + return blocked + + def test_varied_args_same_error_blocks(self): + """参数每次不同、错误签名相同(路径抹平)→ err-streak 累计到 HARD 后拦一次。""" + g = _RepeatGuard() + errs = [f"[Error] old_str not found in /proj/file{i}.py" for i in range(8)] + blocked = self._run_varargs(g, "edit", errs) + self.assertTrue(blocked) + + def test_register_err_block_resets_to_soft(self): + """拦截后 streak 重置到 SOFT(非永久封死),留活口。""" + g = _RepeatGuard() + for i in range(_RepeatGuard.HARD + 1): + g.record("edit", {"old_str": f"v{i}", "path": "/a/b.py"}, "[Error] old_str not found in /a/b.py") + self.assertTrue(g.should_block_err("edit")) + cnt, esig = g.register_err_block("edit") + self.assertGreaterEqual(cnt, _RepeatGuard.HARD) + # 重置后不再处于拦截态 + self.assertFalse(g.should_block_err("edit")) + + def test_different_errors_not_blocked(self): + """每次撞的是不同错误 → 不算撞同一堵墙,不拦。""" + g = _RepeatGuard() + errs = [ + "[Error] old_str not found in /a.py", + "[Error] old_str appears 3 times in /a.py", + "[Error] permission denied", + "[Error] file not found", + ] * 2 + blocked = self._run_varargs(g, "edit", errs) + self.assertFalse(blocked) + + def test_productive_result_resets_streak(self): + """撞墙几次后成功一次(非错误)→ streak 清零,不进拦截态。""" + g = _RepeatGuard() + for i in range(3): + g.record("edit", {"old_str": f"v{i}", "path": "/a.py"}, "[Error] old_str not found in /a.py") + g.record("edit", {"old_str": "vok", "path": "/a.py"}, "edited /a.py") # 成功 + cnt, n_args, _ = g.err_streak("edit") + self.assertEqual(cnt, 0) + self.assertFalse(g.should_block_err("edit")) + + def test_single_arg_not_err_blocked(self): + """同一 args 反复撞同错 → 由 arg 判据管(err-block 要求 >=2 个不同 args)。""" + g = _RepeatGuard() + for _ in range(_RepeatGuard.HARD + 2): + g.record("edit", {"old_str": "same", "path": "/a.py"}, "[Error] old_str not found in /a.py") + cnt, n_args, _ = g.err_streak("edit") + self.assertGreaterEqual(cnt, _RepeatGuard.HARD) + self.assertEqual(n_args, 1) + self.assertFalse(g.should_block_err("edit")) # 单 arg 不走 err-block + + +def _resp_with_toolcalls(*calls): + """calls: (name, arguments_str) → 构造带 tool_calls 的假 response。""" + tcs = [ + SimpleNamespace(function=SimpleNamespace(name=n, arguments=a)) + for n, a in calls + ] + msg = SimpleNamespace(tool_calls=tcs) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) + + +class TestPartialArgs(unittest.TestCase): + """③ 必填 key 被吞的畸形检测(parse 成功、salvage 无能)。""" + + REQ = {"edit": ["path", "old_str", "new_str"], "write": ["path", "content"]} + + def test_missing_path_detected(self): + """#3 实证形态:new_str/old_str 在、path 被吞 → 检出 missing=['path']。""" + r = _resp_with_toolcalls(("edit", '{"new_str": "b", "old_str": "a...gen.py"}')) + out = _toolcalls_partial_args(r, self.REQ) + self.assertEqual(len(out), 1) + _, name, missing = out[0] + self.assertEqual(name, "edit") + self.assertEqual(missing, ["path"]) + + def test_path_only_detected(self): + """#4 反向形态:只有 path、其余被挤掉 → 检出(0<2<3)。""" + r = _resp_with_toolcalls(("edit", '{"path": "/x/y.docx\\nTASK=1"}')) + out = _toolcalls_partial_args(r, self.REQ) + self.assertEqual(len(out), 1) + self.assertEqual(sorted(out[0][2]), ["new_str", "old_str"]) + + def test_full_args_not_flagged(self): + r = _resp_with_toolcalls(("edit", '{"path": "a", "old_str": "b", "new_str": "c"}')) + self.assertEqual(_toolcalls_partial_args(r, self.REQ), []) + + def test_empty_obj_not_flagged(self): + """空 {} 不算(交给 executor + _RepeatGuard 现状)。""" + r = _resp_with_toolcalls(("edit", "{}")) + self.assertEqual(_toolcalls_partial_args(r, self.REQ), []) + + def test_all_required_missing_not_flagged(self): + """必填全缺(纯无关键)→ 不算 partial(len(missing)==len(required))。""" + r = _resp_with_toolcalls(("edit", '{"foo": 1}')) + self.assertEqual(_toolcalls_partial_args(r, self.REQ), []) + + def test_parse_fail_skipped(self): + """parse 失败归 _malformed_tool_calls,这里不重复处理。""" + r = _resp_with_toolcalls(("edit", 'garbage{"path":')) + self.assertEqual(_toolcalls_partial_args(r, self.REQ), []) + + def test_write_missing_content(self): + r = _resp_with_toolcalls(("write", '{"path": "a.txt"}')) + out = _toolcalls_partial_args(r, self.REQ) + self.assertEqual(out[0][2], ["content"]) + + if __name__ == "__main__": unittest.main()