diff --git a/PROGRESS.md b/PROGRESS.md index 2b39340..cd57460 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-15(sandbox 补 olefile/xlrd/extract-msg 老格式解析依赖,bump 0.58.23) +最后更新:2026-07-15(畸形 tool_call arguments 就地抢救,省一次非流式重试,bump 0.58.24) --- @@ -23,6 +23,8 @@ ### 2026-07 +- **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。 +- **07-15 / 0.58.24**:**畸形 tool_call arguments 就地抢救(salvage),命中即省一次非流式重试**(失败聚集面板同形态跨 4 个工具复发:`write` 21 次/`edit` 8 次/`run_python` 5 次/`shell` 5 次(24h),逐条核对形态一致:`Expecting value: line 1 column 1 (char 0)`——char-0 是 provider 在 wire 上把别处正文字节错标成该 tool_call 的 arguments delta 粘的垃圾前缀,尾部真 JSON 完好并跑到串尾;定层已证 provider-wire 脏、拼接层无辜,见 memory)。机制**工具无关**:`allowed_keys` 每工具从 `executor.schemas()` 的 properties 现取(write/edit/run_python/shell 的 key 集各异但同一套逻辑覆盖,新增工具自动生效)。此前 `_stream_llm` 对畸形轮整轮丢弃 + 降级非流式重 roll(自愈无终态失败,但 ~9% 轮次几十秒延迟 + 重复大生成烧 token)。现在 `_malformed_tool_calls` 命中后**先试就地抢救**:纯函数 `core/salvage.py::salvage_tool_arguments(raw, allowed_keys)` 左→右扫每个 `{` 试 `json.loads(raw[i:])`,第一个满足 (a) parse-to-end 成功 (b) 非空 dict (c) 顶层 key ⊆ 该工具 schema 参数名 的即采纳;双护栏:(a) 挡尾部截断的半个 JSON(拒→回落重试,不冒险写残缺),(c) 挡前缀里自洽的旁支 JSON。`AgentLoop._try_salvage_response` 全有或全无:本轮所有畸形 tool_call 都能抠出才就地改写 `.arguments` 当轮继续,任一抠不出则一个都不动、原样走既有 `_log_malformed_args`+ 非流式重试(零回退风险)。抢救成功记 `usage_events` kind=`tool_salvaged`(`record_salvaged_tool_call`,units={tool,len,salvaged_len,head},cost 0)——与 tool_malformed 成对区分「真丢弃」vs「抢救回」;toolfail 巡检 `where kind='tool_malformed'/'run_error'` 精确匹配,salvage 是恢复不算失败、自然不进面板/告警。纯函数单列 `core/salvage.py`(不 import litellm)是刻意的:loop.py 顶层 import litellm 在本机导入期卡死,把守卫逻辑拆出来 `tests/test_salvage.py` 才能无 litellm 依赖跑(含线上真样本夹具 + 截断/多键/旁支 JSON/尾残渣边界 10 用例)。盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测 + 生产灰度观察。additive 内部机制,不碰对外契约。 - **07-15 / 0.58.23**:**sandbox 补齐老格式(OLE 复合文档)解析依赖**(toolfail 巡检:`run_python/exit` `ModuleNotFoundError: No module named 'olefile'` 3 用户/3 任务,模型在 run_python 里 `import olefile, struct` 手撸解析老 Office/Outlook 二进制件)。`markitdown[...]` 只吃现代 `xlsx/docx/pptx`,盖不到 OLE;建材院高频收到老 `.doc/.xls/.msg` 转发件。按 Dockerfile:46「装齐免 ModuleNotFoundError 摩擦」策略(同 `file` 命令准入线),`requirements.txt` 补 `olefile`(OLE 底层读)+ `xlrd`(老 .xls BIFF,openpyxl 读不了)+ `extract-msg`(Outlook .msg,省手撸)。纯 Python、无重依赖,镜像膨胀忽略;要生效需重建 sandbox 镜像(pip 大层重跑)。additive 内部依赖,不碰对外契约。 - **07-13 / 0.58.22**:**run_python script_path 宿主预检 + 路径容错 + code 落盘回退**(失败聚集面板 `run_python/exit`「can't open file」15 次/7 天/13 task,逐条 DB 核对出三种形态):① 形态 1(13/15,没写就跑):模型调 `script_path` 但前文从未 write 过该文件(如 90c8bba9 #29),Docker 执行器原本不预检直接 `docker exec python `,模型拿裸 stderr 自愈慢——现宿主侧 `is_file()` 预检(bind mount 免进容器),返带纠偏提示的 `[Error] script_path not found`(与 host 执行器行为对齐)。② 形态 2(双重拼接,0d7dd9d9 #74):根因是**展示/解析不对称**——fs 工具输出渲染 user_root 相对路径(`tools/base.py::_display` → `吴中伟科技奖/scripts/x.py`),模型原样回传,而 `_container_script_path` 按 workdir 相对拼 → `/workspace///…`;现 workdir 相对不存在时按 user_root 相对再试一次。③ 形态 3(a395ee0c #134,code+script_path 同给):原本 script_path 优先、文件不存在报错,整段完好 code 被扔掉;现文件不存在且 code 非空 → code 落盘到 script_path 再执行(正合工具描述「先写后跑」引导,schema 描述同步注明)。`DockerExecutor._container_script_path` 重写为 `_prepare_script`(前台+background 两路共用,code 回写在宿主侧故加 user_root 越界防护);`tools/run_python.py` host 路径同语义(`_prepare_script` helper,execute/_execute_background 共用)。`tests/test_executor_docker.py` +4 用例(not found 不起 docker / 前缀容错 / code 落盘 / 越界拒),`tests/test_run_python_script_path.py` +4(host 侧同形态 + 已存在文件不被 code 覆盖)。诊断脚本 `scripts/diag_run_python_noent{,2}.py` 留档。 - **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()` → `renderMessages` 先 `innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。 diff --git a/core/__init__.py b/core/__init__.py index 2ba4262..ca7e6f3 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.23" +__version__ = "0.58.24" diff --git a/core/loop.py b/core/loop.py index b2c3dc0..e5d8698 100644 --- a/core/loop.py +++ b/core/loop.py @@ -25,8 +25,13 @@ from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_wi from .context_fold import maybe_fold from .executor import ExecCtx, Executor from .llm import LLM +from .salvage import salvage_tool_arguments from .session import Session -from .storage import record_chat_usage, record_malformed_tool_call +from .storage import ( + record_chat_usage, + record_malformed_tool_call, + record_salvaged_tool_call, +) from . import pptx_guard # 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义) @@ -527,6 +532,11 @@ class AgentLoop: bad = _malformed_tool_calls(response) if not bad: return response, False + # 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire), + # 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试; + # 任一抠不出则一个都不动,原样走下面的丢弃 + 重试(零回退风险)。 + if self._try_salvage_response(response): + return response, False _log_malformed_args( self.session.task_id, self.user_id, f"{self.caps.family}.{self.caps.variant}", response, @@ -543,6 +553,73 @@ class AgentLoop: # 优雅返错给模型,而非在此死循环。 return response, False + def _try_salvage_response(self, response: Any) -> bool: + """尝试就地抢救本轮所有畸形 tool_call 的 arguments;成功才改写并返回 True。 + + 全有或全无:任一畸形 tool_call 抠不出干净 JSON 就一个都不改、返回 False,让调用方 + 原样走丢弃 + 非流式重试(半抢救 = 一部分执行一部分丢弃,状态不一致,不如整轮重来)。 + allowed_keys 从各工具 schema 的 properties 现取(单一事实源,新增工具自动生效)。 + 改写用 ensure_ascii=False 保留中文原字节,_execute_tool_call 会正常 json.loads 回来。 + 每条成功抢救记一行 kind=tool_salvaged 留痕 + 发 warn 让前端重建占位段。 + """ + try: + msg = response.choices[0].message + tool_calls = list(getattr(msg, "tool_calls", None) or []) + except Exception: + return False + allowed_by_tool = { + sc["function"]["name"]: set( + (sc["function"].get("parameters") or {}).get("properties", {}).keys() + ) + for sc in self.executor.schemas() + } + # 第一遍:只算,不改。任一失败即整轮放弃。 + # pending 项:(tc, new_json, orig_len, salvaged_len, head) —— head=原始损坏前 300 字 + # 过 ascii 转义(消费端编码不可控),留痕用于事后核验前缀形态是否仍是 char-0 型。 + pending: List[Tuple[Any, str, int, int, str]] = [] + for tc in tool_calls: + raw = (getattr(tc.function, "arguments", None) or "").strip() + if not raw: + continue + try: + json.loads(raw) + continue # 本就合法,不动 + except (json.JSONDecodeError, ValueError): + pass + allowed = allowed_by_tool.get(tc.function.name) + if not allowed: + return False # 未知工具无白名单可校验,不冒险 + obj = salvage_tool_arguments(raw, allowed) + if obj is None: + return False # 抠不出 → 整轮放弃,回落重试 + new_json = json.dumps(obj, ensure_ascii=False) + pending.append((tc, new_json, len(raw), len(new_json), ascii(raw[:300]))) + if not pending: + return False # 无可抢救的畸形(理论上不会到这:调用前已确认 bad 非空) + # 第二遍:全部确认可抢救,统一改写 + 留痕。 + for tc, new_json, orig_len, salvaged_len, head in pending: + tc.function.arguments = new_json + try: + record_salvaged_tool_call( + task_id=self.session.task_id, + user_id=self.user_id, + model_profile=f"{self.caps.family}.{self.caps.variant}", + tool=tc.function.name, + arg_len=orig_len, + salvaged_len=salvaged_len, + head=head, + ) + except Exception: + pass # 留痕失败绝不能打断:arguments 已改好,当轮照常执行 + self._emit({ + "type": "warn", + "msg": ( + f"工具调用参数损坏但已就地抢救 " + f"{[f'{tc.function.name}({o}->{s})' for tc, _, o, s, _h in pending]},本轮继续" + ), + }) + return True + def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]: """跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。 返回 (response, cancelled_mid_stream)。""" diff --git a/core/salvage.py b/core/salvage.py new file mode 100644 index 0000000..d0f53ce --- /dev/null +++ b/core/salvage.py @@ -0,0 +1,43 @@ +"""从损坏的 tool_call arguments 里抢救完好 JSON 的纯逻辑。 + +单独成模块(不 import litellm)是刻意的:core/loop.py 顶层 import litellm 在部分 +开发机导入期会拉 GitHub cost map 卡死(见 memory),使 import core.loop 无法跑;而 +本机制的正确性盲区(线上真垃圾前缀本机复现不出)恰恰只能靠单测兜底。把纯函数放这里, +tests 可无 litellm 依赖直接 import 校验守卫逻辑。 + +定层结论(见 project_deepseek_malformed_toolcall_salvage / DESIGN):畸形 arguments 是 +provider 在 wire 上把别处正文字节错标成本 tool_call 的 arguments delta,粘在**开头** +(char-0 垃圾前缀),尾部真 JSON 完好并跑到串尾。 +""" +from __future__ import annotations + +import json +from typing import Optional + + +def salvage_tool_arguments(raw: str, allowed_keys: set) -> Optional[dict]: + """从损坏的 arguments 里抢救出完好的 JSON 对象;抢不出返回 None。 + + 左→右扫每个 `{`,试 `json.loads(raw[i:])`——第一个满足三条的即采纳: + (a) parse-to-end 成功(json.loads 要求整个后缀是合法 JSON,尾部有残渣就失败); + (b) 结果是非空 dict; + (c) 顶层 key ⊆ 该工具 schema 的参数名。 + 双护栏缺一不可:(a) 挡尾部被截断的半个 JSON(截断则拒绝、回落既有重试,不冒险执行 + 残缺写入);(c) 挡垃圾前缀里恰好自洽的旁支 JSON(如模型正文里引用的 `{...}` 片段)。 + 都不满足返回 None。纯函数、无副作用,便于单测(线上真前缀样本当夹具)。 + """ + # 只可能出现在 '{' 处;非 '{' 直接跳过,省下大部分 json.loads 尝试。 + start = 0 + while True: + i = raw.find("{", start) + if i < 0: + return None + try: + obj = json.loads(raw[i:]) + except (json.JSONDecodeError, ValueError): + start = i + 1 + continue + if isinstance(obj, dict) and obj and set(obj.keys()) <= allowed_keys: + return obj + # 解析成功但不是想要的 dict(如前缀里一段自洽 JSON):继续往后找,真 JSON 可能在更后面。 + start = i + 1 diff --git a/core/storage/__init__.py b/core/storage/__init__.py index 4a1f228..f08481e 100644 --- a/core/storage/__init__.py +++ b/core/storage/__init__.py @@ -12,7 +12,11 @@ from .engine import ( get_engine, session_scope, ) -from .usage import record_chat_usage, record_malformed_tool_call +from .usage import ( + record_chat_usage, + record_malformed_tool_call, + record_salvaged_tool_call, +) from .utils import ( NoSubtaskError, check_no_subtask, @@ -30,6 +34,7 @@ __all__ = [ "get_task", "record_chat_usage", "record_malformed_tool_call", + "record_salvaged_tool_call", "session_scope", "update_task", "upsert_task", diff --git a/core/storage/usage.py b/core/storage/usage.py index d5f92e8..d79f643 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -190,6 +190,41 @@ def record_malformed_tool_call( )) +def record_salvaged_tool_call( + *, + task_id: UUID, + user_id: UUID, + model_profile: str, + tool: str, + arg_len: int, + salvaged_len: int, + head: str, +) -> None: + """记一次从畸形 arguments 里抢救成功的 tool_call(kind=tool_salvaged)。 + + 与 record_malformed_tool_call 是一对:畸形轮本会整轮丢弃 + 非流式重 roll,salvage + 命中时改成就地抠出尾部完好 JSON、当轮照常执行,省掉一次重试。此行是留痕(cost 恒 0, + tokens 不入 kind=chat 汇总),供「工具失败聚集」面板 / 事后核对区分「真丢弃」与「抢救回」: + arg_len=损坏前缀+JSON 的总长,salvaged_len=抠出 JSON 的长,差值即被丢弃的 wire 垃圾前缀长。 + head 存前 300 字(过 ascii 转义,消费端编码不可控)供核验前缀形态是否仍是 char-0 型。 + """ + with session_scope() as s: + s.add(UsageEvent( + user_id=user_id, + task_id=task_id, + message_id=None, # 抢救出的 tool_call 正常执行并入 messages,但此留痕行不与之关联 + kind="tool_salvaged", + model_profile=model_profile, + units={ + "tool": tool, + "len": int(arg_len), + "salvaged_len": int(salvaged_len), + "head": head, + }, + cost_cny=Decimal("0"), + )) + + def record_run_error( *, task_id: UUID, diff --git a/tests/test_salvage.py b/tests/test_salvage.py new file mode 100644 index 0000000..633a120 --- /dev/null +++ b/tests/test_salvage.py @@ -0,0 +1,118 @@ +"""salvage_tool_arguments 守卫逻辑单测。 + +盲区:线上真垃圾前缀本机复现不出(定层为 provider-wire 抖动),/verify 端到端跑不了 —— +这份单测是该机制的主要正确性保障。夹具含 admin「工具失败聚集」面板里的真样本形态 +(2026-07-15 scheduled 简报 write:char-0 中文正文前缀 + 尾部完好 {"path","content"})。 +""" +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core.salvage import salvage_tool_arguments # noqa: E402 + +WRITE_KEYS = {"path", "content"} +# 与各工具 schema properties 一致(tools/fs.py / run_python.py / shell.py):salvage 线上 +# 由 executor.schemas() 现取,这里手抄一份钉死"多工具 key 集各异但机制通用"。 +EDIT_KEYS = {"path", "old_str", "new_str"} +RUN_PYTHON_KEYS = {"code", "script_path", "timeout", "background"} +SHELL_KEYS = {"command", "timeout", "background"} + + +class TestSalvage(unittest.TestCase): + def test_real_char0_prefix_sample(self): + """线上真形态:一段中文正文垃圾前缀 + 尾部完好 write JSON → 抠出干净 dict。""" + good = {"path": "/workspace/scheduled-f57075f9/2026-07-15-AI重点新闻简报.txt", + "content": "═══ AI 重点新闻简报 ═══\n[1] OpenAI 发布 GPT-5。"} + raw = ("到轻量场景。\n\n[2] Claude Opus 4-7-thinking登顶代码榜 | IT之家 | 07/13\n" + " Arena平台" + json.dumps(good, ensure_ascii=False)) + # 前置条件:整串确实解析不了(char-0 是中文,不是 '{') + with self.assertRaises(json.JSONDecodeError): + json.loads(raw) + self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good) + + def test_edit_char0_prefix_sample(self): + """同形态发生在 edit(2026-07-15 面板真前缀):key 集 {path,old_str,new_str} 也命中。""" + good = {"path": "/workspace/十五五KGJ项目/sections/03_关键技术研究内容及技术路线.md", + "old_str": "补充实验的迭代执行机制:分三批滚动执行。", + "new_str": "补充实验的迭代执行机制:序贯实验设计,分三批滚动执行。"} + raw = '描述符向量,为后续正向预测模型提供"宏观性能' + json.dumps(good, ensure_ascii=False) + self.assertEqual(salvage_tool_arguments(raw, EDIT_KEYS), good) + + def test_run_python_char0_prefix_sample(self): + """同形态发生在 run_python(真前缀是 python list repr 碎片):key 集 {code,...} 命中。 + code 值里含 import/换行/引号,parse-to-end 照样抠回(全在 JSON 字符串转义内)。""" + good = {"code": "import olefile, struct\n\npath = '/workspace/GB16911/水泥.doc'\n" + "ole = olefile.OleFileIO(path)\nprint(ole.listdir())"} + raw = "性质', '选择除尘', '测试孔', 'HJ 1045', '合理选择']\n" + json.dumps(good, ensure_ascii=False) + self.assertEqual(salvage_tool_arguments(raw, RUN_PYTHON_KEYS), good) + + def test_shell_char0_prefix_sample(self): + """同形态发生在 shell(真前缀含上下标/全角括号):key 集 {command,...} 命中。 + command 值里含 heredoc `<< 'PYEOF'` + 多行 python,全在 JSON 字符串内不破坏解析。""" + good = {"command": "cd /workspace/申报书 && python3 << 'PYEOF'\n" + "from docx import Document\ndoc = Document('x.docx')\nPYEOF"} + raw = "变参数——对于d⁹和d⁷构型的离子(如Cu²⁺、" + json.dumps(good, ensure_ascii=False) + self.assertEqual(salvage_tool_arguments(raw, SHELL_KEYS), good) + + def test_wrong_tool_keys_dont_crosstalk(self): + """key 白名单按工具隔离:write 的 {path,content} 拿 shell 白名单校验 → 拒(content 不在)。 + 证明 _try_salvage_response 必须用"该 tool_call 自己工具"的白名单,不能混用。""" + raw = '前缀{"path": "/a", "content": "x"}' + self.assertIsNone(salvage_tool_arguments(raw, SHELL_KEYS)) + + def test_truncated_tail_rejected(self): + """尾部 JSON 被截断(content 未闭合)→ parse-to-end 失败 → None,回落重试不冒险写残缺。""" + raw = '垃圾前缀{"path": "/a.txt", "content": "只写了一半没有结束引号' + self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS)) + + def test_prefix_has_selfconsistent_wrongkey_json(self): + """垃圾前缀里恰好有一段自洽但 key 不对的 JSON → 跳过,继续找到后面真 JSON。""" + good = {"path": "/b.txt", "content": "正文"} + raw = ('模型正文里引用了 {"foo": 1, "bar": 2} 这段配置,然后' + + json.dumps(good, ensure_ascii=False)) + self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good) + + def test_only_wrongkey_json_returns_none(self): + """通篇只有 key 不在白名单的 JSON(无真 tool 参数)→ None,绝不误采。""" + raw = '前缀{"foo": 1, "bar": {"path": "x"}}' + self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS)) + + def test_superset_keys_rejected(self): + """顶层多出 schema 外的 key(非纯 tool 参数)→ 拒绝(key ⊆ allowed 不成立)。""" + raw = '前缀{"path": "/a", "content": "x", "unexpected": 1}' + self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS)) + + def test_subset_keys_accepted(self): + """只给了部分参数(path 无 content)但都在白名单 → 采纳;缺必填交 executor 报错。""" + raw = '前缀{"path": "/a"}' + self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), {"path": "/a"}) + + def test_trailing_garbage_after_json_rejected(self): + """已知局限:JSON 后面还挂垃圾 → parse-to-end 失败 → None(观测形态为 JSON 在尾部, + 本例是防御性记录:宁可回落重试也不用 raw_decode 容忍尾残渣误采半个对象)。""" + raw = '前缀{"path": "/a", "content": "x"}尾部残渣' + self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS)) + + def test_clean_json_at_char0(self): + """char-0 就是完好 JSON(理论上不会进 salvage,但函数应幂等返回它)。""" + raw = '{"path": "/a", "content": "x"}' + self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), + {"path": "/a", "content": "x"}) + + def test_no_brace_returns_none(self): + """整串无 '{' → None。""" + self.assertIsNone(salvage_tool_arguments("纯文本没有大括号", WRITE_KEYS)) + + def test_empty_dict_rejected(self): + """抠出的是空 {} → 视为无内容,不采纳(非空 dict 才算)。""" + raw = '前缀{}' + self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS)) + + +if __name__ == "__main__": + unittest.main()