feat(loop): 畸形 tool_call arguments 就地抢救(salvage)省一次非流式重试(bump 0.58.24)
失败聚集面板同形态跨 write/edit/run_python/shell 复发(err=Expecting value char 0):
char-0 是 provider 在 wire 上把别处正文字节错标成该 tool_call 的 arguments delta 粘的
垃圾前缀,尾部真 JSON 完好并跑到串尾(定层已证 provider-wire 脏、拼接层无辜)。
命中畸形后先试就地抢救,而非直接整轮丢弃 + 降级非流式重 roll:
- core/salvage.py::salvage_tool_arguments(raw, allowed_keys) 纯函数,左→右扫每个 '{'
试 json.loads(raw[i:]),第一个 parse-to-end 成功 + 非空 dict + 顶层 key ⊆ 该工具
schema 参数名的即采纳。双护栏:parse-to-end 挡尾部截断的半个 JSON,key 白名单挡前缀
里自洽的旁支 JSON。工具无关——allowed_keys 每工具从 executor.schemas() 现取。
- AgentLoop._try_salvage_response 全有或全无:本轮所有畸形都能抠出才就地改写 .arguments
当轮继续,任一抠不出则一个都不动、原样走既有 _log_malformed_args + 非流式重试(零回退)。
- 抢救成功记 usage_events kind=tool_salvaged(record_salvaged_tool_call,cost 0),与
tool_malformed 成对;toolfail 精确匹配 tool_malformed/run_error,salvaged 不进失败面板。
纯函数单列 core/salvage.py(不 import litellm)以便 tests/test_salvage.py 无依赖本机可跑
(loop.py 顶层 import litellm 本机导入期卡死);14 用例含 write/edit/run_python/shell 四工具
真前缀 + 截断/多键/旁支/尾残渣/空{}/跨工具白名单隔离等边界。盲区:provider-wire 抖动本机
复现不出、/verify 端到端跑不了,靠单测 + 生产灰度观察 tool_salvaged/tool_malformed 比。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72418a5321
commit
e1d5bf6788
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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-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 <path>`,模型拿裸 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/<dir>/<dir>/…`;现 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.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 <path>`,模型拿裸 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/<dir>/<dir>/…`;现 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 分流补第三段。
|
- **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 分流补第三段。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.58.23"
|
__version__ = "0.58.24"
|
||||||
|
|
|
||||||
79
core/loop.py
79
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 .context_fold import maybe_fold
|
||||||
from .executor import ExecCtx, Executor
|
from .executor import ExecCtx, Executor
|
||||||
from .llm import LLM
|
from .llm import LLM
|
||||||
|
from .salvage import salvage_tool_arguments
|
||||||
from .session import Session
|
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
|
from . import pptx_guard
|
||||||
|
|
||||||
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
|
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
|
||||||
|
|
@ -527,6 +532,11 @@ class AgentLoop:
|
||||||
bad = _malformed_tool_calls(response)
|
bad = _malformed_tool_calls(response)
|
||||||
if not bad:
|
if not bad:
|
||||||
return response, False
|
return response, False
|
||||||
|
# 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
|
||||||
|
# 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
|
||||||
|
# 任一抠不出则一个都不动,原样走下面的丢弃 + 重试(零回退风险)。
|
||||||
|
if self._try_salvage_response(response):
|
||||||
|
return response, False
|
||||||
_log_malformed_args(
|
_log_malformed_args(
|
||||||
self.session.task_id, self.user_id,
|
self.session.task_id, self.user_id,
|
||||||
f"{self.caps.family}.{self.caps.variant}", response,
|
f"{self.caps.family}.{self.caps.variant}", response,
|
||||||
|
|
@ -543,6 +553,73 @@ class AgentLoop:
|
||||||
# 优雅返错给模型,而非在此死循环。
|
# 优雅返错给模型,而非在此死循环。
|
||||||
return response, False
|
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]:
|
def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]:
|
||||||
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
|
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
|
||||||
返回 (response, cancelled_mid_stream)。"""
|
返回 (response, cancelled_mid_stream)。"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -12,7 +12,11 @@ from .engine import (
|
||||||
get_engine,
|
get_engine,
|
||||||
session_scope,
|
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 (
|
from .utils import (
|
||||||
NoSubtaskError,
|
NoSubtaskError,
|
||||||
check_no_subtask,
|
check_no_subtask,
|
||||||
|
|
@ -30,6 +34,7 @@ __all__ = [
|
||||||
"get_task",
|
"get_task",
|
||||||
"record_chat_usage",
|
"record_chat_usage",
|
||||||
"record_malformed_tool_call",
|
"record_malformed_tool_call",
|
||||||
|
"record_salvaged_tool_call",
|
||||||
"session_scope",
|
"session_scope",
|
||||||
"update_task",
|
"update_task",
|
||||||
"upsert_task",
|
"upsert_task",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
def record_run_error(
|
||||||
*,
|
*,
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue