feat(web): tool_call 标题行改显中文活动描述 + 修 args 字段 bug

dev.html 实时流读 ev.data.arguments 但后端 emit 的是 args,字段名对不上
导致参数永远为空、连带 artifact 路径提取失效。新增 toolActivityLabel 按
12 个工具的关键参数套中文动词(执行命令/运行 Python/读写编辑文件/查找搜索
/联网搜索/抓取网页/加载技能/生成图像视频),实时流与历史回放两处同步;完整
参数仍在折叠 <pre> 里。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
caoqianming 2026-05-29 12:20:33 +08:00
parent aab1da3296
commit d94189d25f
2 changed files with 38 additions and 5 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-29(删 docker exec argv 里的 setsid 修延迟 stdout 丢失 + `_run_subprocess` 重写修 communicate poll loop bug + 3 个 SKILL.md sandbox 凭证可用性校准)
最后更新:2026-05-29(删 docker exec argv 里的 setsid 修延迟 stdout 丢失 + `_run_subprocess` 重写修 communicate poll loop bug + 3 个 SKILL.md sandbox 凭证可用性校准 + web 端 tool_call 标题行改显中文活动描述并修 args 字段 bug)
---
@ -23,6 +23,7 @@
### 2026-05-29
- **web 端 tool_call 标题行改显中文活动描述(`dev.html`)**:用户反馈 web 端工具调用只显示 `工具调用:run_python` / `shell` 等工具名,看不出"在干啥"。**根因有个真 bug**:`dev.html` 实时流分支(`tool_call`)读 `ev.data.arguments`,但后端 `core/loop.py::_execute_tool_call` emit 的字段叫 `args`(已解析 dict)+ `args_preview`(截 200 字),字段名对不上 → 前端拿到的永远是空串,`<pre>` 是空的、连带 `extractArtifactRels(argsStr,...)` 也抽不到产物路径。**改法**:① 新增 `toolActivityLabel(name, args)` helper(挨着 `_workingDirName`),按 12 个工具的"关键代表参数"套中文动词 + 截断值:read/write/edit→`读取/写入/编辑文件: {path}`、glob→`查找文件: {pattern}`、grep→`搜索内容: {pattern}`、shell→`执行命令: {command}`、run_python→`运行 Python: {code}`、web_fetch→`抓取网页: {url}`、web_search→`联网搜索: {query}`、load_skill→`加载技能: {name}`、seedream/seedance→`生成图像/视频: {prompt}`,未知工具回退到 `name {JSON截断}``工具调用: name`;clip 把空白压一行 + 超长加 `…`。② 实时流分支 `ev.data.arguments``ev.data.args`(修字段 bug)并把 `<summary>` 文案换成 label。③ 历史消息回放分支(`p.tool_calls`,LiteLLM 格式 `tc.function.arguments` 是 JSON 字符串)同步:先 `JSON.parse``argsObj` 再生成 label,保持实时 / 历史一致。完整参数仍在折叠 `<pre>` 里,展开可看。**选型**:用前端静态模板(零成本 / 不走模型 / 立即生效),否决"让模型每次调用前产出一句中文意图"(改 prompt + 每调用烧 token + 依赖模型配合)。**纯前端改**,`DESIGN.md` / `RUN.md` / `SKILL_LIST.md` 不动(无架构 / CLI / env / skill 变化)。**生效**:刷新 web 页面即可,无需重启后端。
- **删 `_exec_shell` / `_exec_python` argv 里的 `setsid` 修 docker exec 延迟 stdout 丢失**:上一条 `_run_subprocess` 重写后用户实测 LLM 拿 `cement energy efficiency` 跑 paper_server 检索仍返空 `[exit 0]` 8 字符,且 `print(f"共命中 {len(papers)} 条结果\n")` 这种必有输出的代码也丢。**根因 = `setsid`,不是 `_run_subprocess` 的 poll loop**(上一条修对了一个独立的 bug,但不是用户当下症状的元凶)。**实证差异**:`docker exec ... setsid python -c "import time; time.sleep(2); print('hello')"` 等满 2.06s 但输出空;同条件去掉 setsid `docker exec ... python -c "..."` 等满 2.08s + 输出 `hello`。`setsid` 调 `setsid()` syscall 把进程变 new session leader without controlling terminal **之后** execvp(python),docker exec / runc 的 stdio attach 对"调用方变 session leader"敏感(具体哪一层没深挖到 runc 源码,经验上 docker exec + setsid + 延迟输出 = stdout 数据被截断,业界踩过的坑)。短输出(`print('hello')` 瞬时完成)能在窗口内漏出去,延迟输出(`search()` 等 httpx 1-2s)就全丢 —— 完美解释为什么 LLM 简单 `print(version)` 测试时部分输出能回但真业务调用全空。**为什么之前 host 侧 `docker exec ... python -c "from skills.research.paper import search; ..."` 2.94s 拿 10 条成功**:那条**没有** setsid,docker exec 等的就是 python 本身,3s 全程都阻塞读 stdout 不会丢。`setsid` 历史上是 §7.5 Stage C **Step 3b PGID kill 协议**铺路用的(PROGRESS 上有"延后到外部用户开放前"这条 work item),**协议没实现的当下 setsid 是空头载荷 + 副作用**。**改法**:`core/executor_docker.py:141` 和 `:177` 各删 1 行的 `"setsid"`,argv 形态变 `docker exec ... <container> bash -c <cmd>` / `... python <script>`。**否决**:(a) `setsid --wait <prog>` —— 强制 setsid 等子退 + 透传 exit code,但需要 util-linux 2.32+ 且增加一层依赖;PGID kill 协议没做的当下没必要;(b) 包一层自己写的 wait wrapper —— 同上,过度工程;(c) 改 docker exec 加 `-t` 强制 tty —— 引入 \r\n 转换 + 影响输出清晰度,非生产做法。**测试**(`tests/test_executor_docker.py`):① 更新 `test_shell_invokes_docker_exec` 断言 `argv[container_idx+1:] == ["bash", "-c", "echo hello"]`(原 `["setsid", "bash", "-c", ...]`);② 更新 `test_run_python_tmp_script``argv[-3] == "setsid"` 断言;③ **新加 `test_run_subprocess_delayed_output_not_lost`**:`sys.executable, "-c", "import time; time.sleep(1); print('LATE')"` 真子进程跑,断 LATE 在 stdout 里 + `[exit 0]`(Windows skip);④ **新加 `test_argv_does_not_contain_setsid`**:patch Popen 抓两次 call(shell + run_python)的 argv,断 `"setsid" not in argv` 防回潮。**全套 19/19 PASS**(老 17 + 2 新)。**未来加 PGID kill 协议时**:不能裸 setsid 回潮 —— `test_argv_does_not_contain_setsid` 会挂,会强制实现者去查 PROGRESS 这条把 setsid 用对(`setsid --wait` 或 wrapper)。**部署生效**:`git pull && systemctl restart zcbot`,sandbox 容器不用 rm。**经验值更新**:这次诊断走了弯路 —— 上一条 `_run_subprocess` 重写虽然修了一个真 bug(communicate poll loop 违反 API + bash block-buffered chunk 丢),但**不是用户当下症状的元凶**,setsid 才是。两个 bug 都贡献"8 字符 `[exit 0]`"的部分场景:poll loop 影响多 chunk 输出收集,setsid 影响延迟输出收集,各自独立,都得修。`DESIGN.md` 不动(纯实现 bug 修,§7.5 Step 3b PGID kill 协议状态没变,只是落实时改用 `setsid --wait` 而非裸 setsid,这是个实现细节不是架构变化);`RUN.md` 不动;`SKILL_LIST.md` 不动。
- **`core/executor_docker.py::_run_subprocess` 重写修 docker exec stdout 多 chunk 静默丢失 bug**:用户实测 LLM 在容器里调 `from skills.research.paper import search` / `shell echo "test"; ...; echo "done"` 拿到空 `[exit 0]` 8 字符,而 host 侧 `docker exec ... python -c "from skills.research.paper import search; r=search(...)"` 同 query 2.94s 拿 10 条切题结果 —— 网络 / DNS / paper_server / helper / httpx 全清白,**问题在 tool wrapper 自己**。**根因**:旧实现 `while True: try: proc.communicate(input=stdin, timeout=0.5) except TimeoutExpired: ...` 在 poll loop 里反复调 `communicate()`,**违反 `subprocess` API 假设**(`communicate` 文档明说 "should be called only once") + 配合 `setsid bash -c "..."` 的 block-buffered stdout(pipe 而非 tty 触发 4K block buffering)在多 chunk 输出时序下 chunk 静默丢失。具体路径:`echo "test"` 第一个 token 几乎 buffer 还没装就到 `setsid bash` 收尾 flush 那一刻被读到 ; `timeout 3 python3 ...` 子进程 + 后续 `echo "done"` 走的是 setsid 子会话的二级 buffer,communicate 在 0.5s 轮询窗口里要么完全读不到要么读半截,内部 `self._fileobj2output` 状态在反复 TO 后某些 Python 版本下不连续。**重写实现(D 候选,4 候选里选最小补丁)**:① 入口 inline 查一次 `cancel_check`,True 立即返不起 Popen(同步快路径 + 消除单测 race);② 单次 `proc.communicate(input=stdin, timeout=timeout)`,违反 API 的 poll loop 彻底删;③ cancel 检查移到侧 daemon 线程,周期 `_CANCEL_POLL_INTERVAL_S=0.2`(模块常量,单测 patch 0.02 加速)poll `cancel_check`,命中即 `cancel_hit.set() + proc.kill()`;④ TimeoutExpired 分支 `kill + 二次 communicate()``self._fileobj2output` 续读累积 chunks 不丢已读;⑤ cancel 优先于 timeout(canceller 设了 hit 即使 communicate 也抛 TO 时优先返 cancelled)。**否决**:(A) 2 reader 线程裸 drain stdout/stderr.read() —— ~40 行,可选但 D 最小;(B) `selectors.PollSelector` 非阻塞 read —— ~50 行,Windows caveat(咱们 Linux 部署不踩,但代码上多一层平台分支);(C) 整链路 sync→async —— 大手术,不值。**回归测试**(`tests/test_executor_docker.py`):① `test_shell_cancel``test_shell_cancel_inline_fastpath`(入口快路径,Popen 不起)+ `test_shell_cancel_via_canceller_thread`(cancel_check 第 1 次 False 让 inline 放行第 2 次起 True 让侧线程触发,threading.Event 同步 mock kill);② **新加 `test_run_subprocess_collects_multi_chunk_output`** 起真子进程 `bash -c 'echo A; sleep 0.6; echo B; sleep 0.6; echo C'` 断 A/B/C 全在结果里(Windows skip,Linux CI/部署跑),这条 case 在旧实现下必挂、新实现必过 —— 直接锁死本 bug 回归;③ `test_shell_timeout` / `test_fs_tool_timeout` 移除已死的 `time.monotonic` patch(新代码不用 time.monotonic 跟踪 elapsed,改靠 `communicate(timeout=N)` 自带累计)。**结果 17/17 PASS**(老 15 + 拆 1 加 2 = 17,multi-chunk 那条 Windows skip 计入)。**部署生效**:重启 web 进程 + 老 sandbox 容器无需 rm(代码改在 host 侧 executor,容器本身行为不变,新请求进 new wrapper 即用)。**行为变化**:cancel 响应延迟 ~500ms → ~200ms(侧线程 wait 0.2s 改善);timeout 错误文案不变;线程开销 per call 多 1 个 daemon thread 只在 `cancel_check is not None` 时起,忽略不计。`DESIGN.md` 不动(无架构变化,纯实现 bug 修;§7.5 Stage C Step 3b "PGID kill 协议" 跟本 bug 正交,该 work item 延后到外部用户开放前的状态没变);`RUN.md` 不动(无 CLI / env / 文件布局变化);`SKILL_LIST.md` 不动(skill 列表无变化)。
- **3 个 SKILL.md 校准 sandbox 下外部凭证可用性**:用户实测 LLM 报 documents 缺 `DOCUMENT_SEARCH_API_KEY`,追到 `tools/run_python.py::_SENSITIVE_PATTERNS = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY")` 在 subprocess 起前删所有名含这些字面的 host env —— 设计是挡 prompt 注入 `print(os.environ)` 抽 ARK / JWT_SECRET 等(JWT_SECRET 一旦露=任意身份伪造),**误伤**了 skill 端从 env 读 key 的 `pymatgen.materials.mp_rester()``skills.documents.client._api_key()`(docker backend 下 host env 根本不入容器,问题更彻底)。**连带发现**:`research` SKILL.md 排版跟两者太像(都"准备段写 import + env 说明"),LLM 看 documents 失败**类推**也放弃 research —— 可 `research/paper.py:10` `PAPER_SERVER_URL` 是 URL 有默认值 `http://paper.xxhhcty.xyz:8080`、过滤器根本不碰;被用户逼试又用 `urllib.request` 钻反模式行 128 只禁 `httpx/requests` 的字面空子,跳过 helper 后 SKILL.md 教的 search filter / 中文转英文术语全丢,`search=cement+based` 字符级模糊匹配返 6809 条横跨无人机 / 锂电池 / 热界面 LLM 还以为搜对了。**改 3 个 SKILL.md**:① `pymatgen` H1 后插 WARNING,明示 mp 联网不可用 + 列离线 5 能力(`Structure.from_file` / `SpacegroupAnalyzer` / `XRDCalculator` / `CEMENT_PHASES` / VASP 输入)+ 禁脑补晶格;② `documents` H1 后插 WARNING,标整体不可用 + 降级到 research / 用户自导出;③ `research` 在 "准备"段后加一段 callout 明示**不持 secret + sandbox 任何模式都能用 + documents 不可用时是降级首选**,反模式行 128 扩成"任何 HTTP 客户端(httpx/requests/urllib/aiohttp/curl)裸调"并说清裸调代价(SKILL.md 教学全丢)。`SKILL_LIST.md` 速览表 documents / pymatgen 加 ⚠️ 状态标 + 最后更新 2026-05-29;`RUN.md` env 段 `DOCUMENT_SEARCH_API_KEY` / `MP_API_KEY` 行下加 ⚠️ 注脚说 sandbox 被过滤器拦。**架构方向(下轮做)**:不取消过滤器(会把 ARK / JWT_SECRET / BOCHA / ZCBOT_ADMIN_TOKEN 全暴露,prompt 注入面爆开),不为每 service 包 host tool(线性增长 + 拆 query / 后处理割裂 LLM 体验),走业界 2025-2026 主流的 **credential broker / credential proxy**(Infisical Agent Vault / NVIDIA agentic workflow 指南推):一个外发代理持所有 key,sandbox 出站 HTTP 经它按目标域名 / URL 前缀注入 auth header,新增 service 加一行 broker 配置即可,key 永不入 sandbox + prompt 注入抗性同 host tool 方案,代价是 ~100 行 fastapi 小服务 + Dockerfile env 加 `MP_BASE_URL=http://broker:8080/mp` 这类 URL(URL 不是 secret,过滤器不碰)。两种落地形态:**A forward proxy** (`HTTPS_PROXY`,需自签 CA 装容器) / **B URL-rewriting reverse proxy**(broker 暴露 `/mp/*` `/docs/*` 路由,skill 改 BASE_URL,不用 MITM)—— 倾向 B,工程量更小。`DESIGN.md` 本轮不动 —— broker 实际落地时再加 §7.X "外部凭证代理范式"段,避免 DESIGN 描述未实现内容。

View File

@ -2015,14 +2015,17 @@ function renderMessages(msgs) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let argsObj = {};
let args = "";
try {
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
args = JSON.stringify(argsObj, null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
const label = toolActivityLabel(fn, argsObj);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)}
`;
}
@ -2429,11 +2432,12 @@ function handleSseEvent(ev, asstCard, ctx) {
}
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || "";
const args = (ev.data && ev.data.args) || "";
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
const label = toolActivityLabel(fn, args);
const det = document.createElement("details");
det.className = "tool-call";
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
det.innerHTML = `<summary>${escapeHtml(label)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
@ -2906,6 +2910,34 @@ async function renameFile(rel, name, isDir) {
// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下
// 一级子目录名(同 filesPath 的 wdName 语义)。外部 --working-dir 是绝对路径,
// 文件不在 user_root,backend files API 拒访问 → 不挂 chip。
// 把一次 tool_call 翻成一句中文活动描述(展示在折叠标题行,不展开就能看懂在干啥)。
// args 是后端 _execute_tool_call 解析后的参数 dict;取每个工具最能代表"在干啥"的字段。
function toolActivityLabel(name, args) {
const a = (args && typeof args === "object") ? args : {};
const clip = (v, n) => {
const s = String(v == null ? "" : v).replace(/\s+/g, " ").trim();
return s.length > n ? s.slice(0, n) + "…" : s;
};
switch (name) {
case "read": return `读取文件: ${clip(a.path, 80)}`;
case "write": return `写入文件: ${clip(a.path, 80)}`;
case "edit": return `编辑文件: ${clip(a.path, 80)}`;
case "glob": return `查找文件: ${clip(a.pattern, 60)}`;
case "grep": return `搜索内容: ${clip(a.pattern, 60)}`;
case "shell": return `执行命令: ${clip(a.command, 80)}`;
case "run_python": return `运行 Python: ${clip(a.code, 80)}`;
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
default: {
const p = clip(JSON.stringify(a), 80);
return p && p !== "{}" ? `${name} ${p}` : `工具调用: ${name}`;
}
}
}
function _workingDirName(workingDir) {
if (!workingDir) return "";
const wd = String(workingDir).replace(/\\+/g, "/");