fix(loop): tool message append 补 name 字段 + backfill 历史 — 修历史 task 重开后 seedream banner/chip 不展示
session.append 的 tool 消息只存 role/tool_call_id/content,没 name;前端历史渲染依赖 payload.name 判断产物工具白名单 + 抽 elapsed banner,刷新后两者全黑(流式正常因为 SSE event 单独带 name)。scripts/backfill_tool_message_name.py 按 task 走 assistant.tool_calls 建 tool_call_id→name map 回填,dry-run 默认,--apply 真写,幂等。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e4548dd0c
commit
972f36db20
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
||||
|
||||
最后更新:2026-05-20(dev SPA 输入区移除"⬆ 上传"按钮 + 加"✨ 润色"按钮 + 后端 `POST /v1/tasks/{id}/optimize_prompt` 辅助 LLM 调用,usage_events 走新 kind="prompt_optimize")
|
||||
最后更新:2026-05-21(loop.py tool message append 补 `name` 字段 + 一次性 backfill 脚本回填历史 17 条 tool 消息 → 修历史 task 重开后 seedream banner/chip 不显示的 bug)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-05-21
|
||||
|
||||
- **loop.py tool message append 补 `name` 字段 + backfill 历史**:用户报"重开历史 task,seedream 生成的图既没有 elapsed banner 也没挂 chip"。根因:`core/loop.py` 第 161-167 行 append tool 消息时只写 `role/tool_call_id/content`,没存 `name`;前端 `dev.html` 历史渲染依赖 `payload.name` 判断是否产物工具(`ARTIFACT_PRODUCING_TOOLS.has(p.name)`)+ 抽 elapsed banner(`extractMediaBanner(p.name, ...)`),刷新后两者全黑。流式时正常 — SSE event 单独带 `name`(`_emit("tool_result", name=...)`),但 SSE 数据不入 DB,所以只有"刚生成那一刻"能看到。**修法**:loop.py 一行加 `"name": tc.function.name` 进 session.append 的 dict(OpenAI tool message spec 本来就有这字段,LiteLLM 接受);cancelled 占位那处(第 96-99 行)不动 —— 它的 content 是 `[cancelled by user]` 占位串,banner 正则匹配不上、chip 抽不出路径,挂 name 也无效果。**对比方案**:① 前端按 tool_call_id 反查上一条 assistant 的 tool_calls[].id → name(纯前端,不动后端 / DB)— 但每条 tool 消息渲染时都得线性扫之前所有 assistant 消息,O(n²) + 散在 5 个渲染点;② 用户提议的"路径带 user_id 前缀作为产物信号"— 不解决历史数据(已存内容里没 user_id 串)、判别力不够(grep/read echo 老图也会带)、违背 commit 9a7620f+5ff09b9 的 user_root-relative 简化方向。一行 fix + backfill 是改动最小同时彻底的方案。**Backfill 脚本** `scripts/backfill_tool_message_name.py`:按 task 分组扫 assistant.tool_calls 建 `tool_call_id → name` map,再扫该 task 的 tool 消息按 `tool_call_id` 查 map 补 name,`flag_modified` 标 JSONB 变更;默认 dry-run,`--apply` 真写;幂等(已有 name 跳过)。本地 17 条 tool 消息全部填上(seedream/glob/shell/read/write/load_skill 等),0 unresolved。**没动**:DB schema(payload 是 jsonb,加 key 无需 migration)、前端(已经在读 `p.name`,本来就支持只是历史数据没填)、其他 tool 调用路径、DESIGN(纯实现 bug,无架构变化)、RUN(无对外行为变化)。
|
||||
|
||||
### 2026-05-20
|
||||
|
||||
- **dev SPA chip 抽取改"产物工具白名单"门控(根因消 grep/read 类工具误挂无关文件 chip + 图片误 inline 预览)**:用户报"生成的图正常预览,但 grep 类工具的结果里 figures/ 下另一张老图也被 inline 出来了"。范围其实更大 — `extractArtifactRels` + `renderArtifactBarHtml` 是**通用产物展示**(image/video → inline,其他扩展名 → 可点 chip),所以 grep/read/shell/glob 等通用工具结果里 echo 的任何带扩展名路径(`.py`/`.md`/`.png`...)都会被当产物挂出来,图片只是其中最扎眼的一种;`seenRels` 只能去重同路径,挡不住"figures/ 下别的老图第一次出现"。**修法**:`web/static/dev.html` 新加 `ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"])` 白名单(产物维度,与 `extractMediaBanner` 的"媒体 banner 维度"解耦 — 将来若加"生成 docx 的工具",入这里但不入 banner 白名单),4 处工具 I/O 调用点全部用 `ARTIFACT_PRODUCING_TOOLS.has(toolName)` 三元短路:① `renderMessages` 的 `role==="tool"` 历史卡(行 1395-1404)② `renderMessages` 的 assistant `tool_calls` args(行 1422-1437)③ SSE `handleSseEvent` 的 `tool_call`(行 1692-1707)④ SSE `tool_result`(行 1714-1729);**assistant 正文(行 1417)不门控**,沿用 seenRels 兜底(助手主动 echo "刚生成的 xxx.png" 仍能挂 chip,seenRels 防同图重复)。**对比方案**:② 目录限制(regex 只匹配 `<wd>/figures/`)— 把通用 chip 系统降级为只服务 seedream,未来非媒体产物(pdf/docx/zip)就被锁死;③ 后端 tool_result 元信息带 `produced_files` 显式列表 — 最干净但 SSE / 历史回放 / seedream 都要改,改动量最大。**chip 系统的本意是"这次工具调用新产出的东西"**,grep/read 输出里的路径是"引用"不是"产物",white-list 在工具级过滤是正确语义,改动也最小。**Tradeoff**:`read figures/foo.png` 后老图不再挂 chip — 但这就是对的(读 ≠ 产);未来加新的产物生成工具需补白名单一行(成本极低,且本就该明确登记)。**没动**:`extractArtifactRels` / `renderArtifactBarHtml` 实现(它们仍 generic,只是调用入口被门控)、`_workingDirName` / 媒体 blob 缓存 / chip 点击委托、后端、DESIGN(纯前端 UX 修复,无架构/schema 变化)、RUN(无对外行为变化)。
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ class AgentLoop:
|
|||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"content": result,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
"""Backfill tool 消息 payload 缺失的 `name` 字段。
|
||||
|
||||
背景:loop.py 早期 append tool 消息时只写了 role/tool_call_id/content,
|
||||
没存 name。前端依赖 `payload.name` 判断是不是产物工具(seedream/seedance)
|
||||
→ 历史 task 重新打开时 banner/chip 不显示。本脚本一次性回填。
|
||||
|
||||
策略:按 task 走,先把每条 assistant 消息里 tool_calls[].id → name 收集
|
||||
成 map;再扫该 task 内 role=tool 的消息,按 tool_call_id 查 map 补 name。
|
||||
查不到的(罕见:LLM 给的 id 跨 task 不对齐 / 历史脏数据)打 warn 跳过。
|
||||
|
||||
跑法: .venv/Scripts/python.exe scripts/backfill_tool_message_name.py
|
||||
默认 dry-run,加 --apply 真写。
|
||||
|
||||
幂等:已经有 name 的消息跳过;再跑一遍 0 改动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
env_file = ROOT / ".env"
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from core.storage import session_scope
|
||||
from core.storage.models import Message
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="真写;默认 dry-run 只打印")
|
||||
args = ap.parse_args()
|
||||
|
||||
n_tool = 0 # 扫到的 tool 消息总数
|
||||
n_already = 0 # 已有 name 跳过
|
||||
n_filled = 0 # 本次补上
|
||||
n_unresolved = 0 # 查不到 name 的(打 warn)
|
||||
|
||||
with session_scope() as s:
|
||||
rows = s.execute(select(Message).order_by(Message.task_id, Message.idx)).scalars().all()
|
||||
|
||||
# tool_call_id → name(按 task 分组,避免跨 task 撞 id)
|
||||
by_task: dict[str, dict[str, str]] = defaultdict(dict)
|
||||
for m in rows:
|
||||
p = m.payload or {}
|
||||
if p.get("role") != "assistant":
|
||||
continue
|
||||
tcs = p.get("tool_calls") or []
|
||||
for tc in tcs:
|
||||
tcid = tc.get("id")
|
||||
fn = (tc.get("function") or {}).get("name")
|
||||
if tcid and fn:
|
||||
by_task[str(m.task_id)][tcid] = fn
|
||||
|
||||
for m in rows:
|
||||
p = m.payload or {}
|
||||
if p.get("role") != "tool":
|
||||
continue
|
||||
n_tool += 1
|
||||
if p.get("name"):
|
||||
n_already += 1
|
||||
continue
|
||||
tcid = p.get("tool_call_id")
|
||||
name = by_task.get(str(m.task_id), {}).get(tcid or "")
|
||||
if not name:
|
||||
n_unresolved += 1
|
||||
print(f" [WARN] task={m.task_id} idx={m.idx} tool_call_id={tcid!r} 找不到对应 assistant tool_call,跳过")
|
||||
continue
|
||||
print(f" [FILL] task={m.task_id} idx={m.idx} <- name={name!r}")
|
||||
p["name"] = name
|
||||
m.payload = p
|
||||
flag_modified(m, "payload")
|
||||
n_filled += 1
|
||||
|
||||
if args.apply:
|
||||
s.commit()
|
||||
else:
|
||||
s.rollback()
|
||||
|
||||
print()
|
||||
print(f"[summary] tool messages={n_tool} | already={n_already} | "
|
||||
f"filled={n_filled} | unresolved={n_unresolved}")
|
||||
print(f"[mode] {'APPLIED (committed)' if args.apply else 'DRY-RUN (no commit, rerun with --apply)'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue