From 0267d36d002077f9f1deb6a4cee3d6bdc362554d Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 01:02:12 +0800 Subject: [PATCH 01/20] =?UTF-8?q?chore(agent):=20=E5=BB=BA=E7=AB=8B=20Agen?= =?UTF-8?q?t=20=E5=8D=8F=E8=AE=AE=E5=A5=91=E7=BA=A6=E4=B8=8E=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 docs/agent-protocol.md 作为协议单一真源,覆盖 SSE 字段派生规则、事件生产者边界、请求体契约、18 个工具规格与循环行为。新增 scripts/agent-baseline 工具链:mock LLM 服务(支持分片 tool_call arguments)、golden SSE 抓取、归一化与逐事件 diff。已针对 Python 实现抓取 11 个场景作为冻结基准。 Co-authored-by: Cursor --- .baseline/python/client-tool-pending.json | 69 ++ .baseline/python/meta.json | 18 + .baseline/python/multiple-rounds.json | 39 + .baseline/python/parallel-readonly.json | 39 + .baseline/python/plain-text.json | 17 + .baseline/python/proposal-edit.json | 180 +++ .../python/proposal-lr-agent-suppressed.json | 24 + .baseline/python/proposal-write.json | 112 ++ .baseline/python/reasoning.json | 21 + .baseline/python/single-tool.json | 36 + .baseline/python/subagent.json | 87 ++ .../python/tool-choice-any-fallback.json | 33 + .gitignore | 10 + docs/agent-protocol.md | 1084 +++++++++++++++++ package.json | 6 +- scripts/agent-baseline/README.md | 87 ++ scripts/agent-baseline/captureSse.mjs | 314 +++++ scripts/agent-baseline/diffSse.mjs | 158 +++ scripts/agent-baseline/mockLlmServer.mjs | 254 ++++ scripts/agent-baseline/normalizeSse.mjs | 95 ++ scripts/agent-baseline/scenarios.mjs | 258 ++++ scripts/agent-baseline/selftest.mjs | 213 ++++ 22 files changed, 3153 insertions(+), 1 deletion(-) create mode 100644 .baseline/python/client-tool-pending.json create mode 100644 .baseline/python/meta.json create mode 100644 .baseline/python/multiple-rounds.json create mode 100644 .baseline/python/parallel-readonly.json create mode 100644 .baseline/python/plain-text.json create mode 100644 .baseline/python/proposal-edit.json create mode 100644 .baseline/python/proposal-lr-agent-suppressed.json create mode 100644 .baseline/python/proposal-write.json create mode 100644 .baseline/python/reasoning.json create mode 100644 .baseline/python/single-tool.json create mode 100644 .baseline/python/subagent.json create mode 100644 .baseline/python/tool-choice-any-fallback.json create mode 100644 docs/agent-protocol.md create mode 100644 scripts/agent-baseline/README.md create mode 100644 scripts/agent-baseline/captureSse.mjs create mode 100644 scripts/agent-baseline/diffSse.mjs create mode 100644 scripts/agent-baseline/mockLlmServer.mjs create mode 100644 scripts/agent-baseline/normalizeSse.mjs create mode 100644 scripts/agent-baseline/scenarios.mjs create mode 100644 scripts/agent-baseline/selftest.mjs diff --git a/.baseline/python/client-tool-pending.json b/.baseline/python/client-tool-pending.json new file mode 100644 index 0000000..103d85e --- /dev/null +++ b/.baseline/python/client-tool-pending.json @@ -0,0 +1,69 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我来发起自动标注", + "type": "text_delta" + }, + { + "content": "。", + "type": "text_delta" + }, + { + "arguments": "{\n \"user_request\": \"标注 data 下所有图片\",\n \"paths\": [\n \"data/\"\n ]\n}", + "name": "auto_annotate", + "toolCallId": "", + "type": "tool_start" + }, + { + "clientToolCalls": [ + { + "arguments": { + "paths": [ + "data/" + ], + "user_request": "标注 data 下所有图片" + }, + "name": "auto_annotate", + "toolCallId": "" + } + ], + "toolCalls": [ + { + "arguments": { + "paths": [ + "data/" + ], + "user_request": "标注 data 下所有图片" + }, + "name": "auto_annotate", + "toolCallId": "" + } + ], + "type": "tool_pending" + }, + { + "type": "done" + }, + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "标注提案已生成,", + "type": "text_delta" + }, + { + "content": "请检查后 Kee", + "type": "text_delta" + }, + { + "content": "p All。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/meta.json b/.baseline/python/meta.json new file mode 100644 index 0000000..e0608b0 --- /dev/null +++ b/.baseline/python/meta.json @@ -0,0 +1,18 @@ +{ + "label": "python", + "target": "http://127.0.0.1:8765/api/v1", + "capturedAt": "2026-09-18T17:00:47.041Z", + "scenarios": [ + "plain-text", + "reasoning", + "single-tool", + "parallel-readonly", + "proposal-write", + "proposal-edit", + "proposal-lr-agent-suppressed", + "client-tool-pending", + "subagent", + "tool-choice-any-fallback", + "multiple-rounds" + ] +} diff --git a/.baseline/python/multiple-rounds.json b/.baseline/python/multiple-rounds.json new file mode 100644 index 0000000..8ead570 --- /dev/null +++ b/.baseline/python/multiple-rounds.json @@ -0,0 +1,39 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "arguments": "{}", + "name": "describe_client_context", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "未知工具: describe_client_context", + "toolCallId": "", + "type": "tool_result" + }, + { + "arguments": "{\n \"topic\": \"标注\"\n}", + "name": "get_lr_agent_help", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "标注任务在左侧活动栏「标注任务」中创建。 **Ask**:分析与建议,不修改文件、不执行批量标注。**标注 Agent**:新增/重写用 auto_annotate,改已有标注用 mutate_annotation。**编辑器 Agent**:只改代码/文档,不能标注。", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "两轮工具都完成了", + "type": "text_delta" + }, + { + "content": "。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/parallel-readonly.json b/.baseline/python/parallel-readonly.json new file mode 100644 index 0000000..785a825 --- /dev/null +++ b/.baseline/python/parallel-readonly.json @@ -0,0 +1,39 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我同时看几处。", + "type": "text_delta" + }, + { + "arguments": "{\n \"relative_path\": \"README.md\"\n}", + "name": "read_workspace_file", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "文件:README.md\n大小:54 字节\n---\n 1|# 夹具工作区\n 2|\n 3|用于 Agent 协议基线抓取。", + "toolCallId": "", + "type": "tool_result" + }, + { + "arguments": "{\n \"glob_pattern\": \"**/*.json\"\n}", + "name": "glob_workspace", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "glob:**/*.json\n范围:.\n条目数:1\n---\nconfig/app.json", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "两处都看完了。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/plain-text.json b/.baseline/python/plain-text.json new file mode 100644 index 0000000..2afda42 --- /dev/null +++ b/.baseline/python/plain-text.json @@ -0,0 +1,17 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "你好,这是纯文本", + "type": "text_delta" + }, + { + "content": "回复。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/proposal-edit.json b/.baseline/python/proposal-edit.json new file mode 100644 index 0000000..a29804f --- /dev/null +++ b/.baseline/python/proposal-edit.json @@ -0,0 +1,180 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我改一下这段配置", + "type": "text_delta" + }, + { + "content": "。", + "type": "text_delta" + }, + { + "detail": "0", + "mode": "edit", + "operation": "edit", + "suggestedRelativePath": "config/app.json", + "summary": "config/app.json", + "title": "config/app.json", + "type": "file_proposal_start" + }, + { + "imagePath": "config/app.json", + "oldDelta": " ", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "\"t", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "ime", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "out", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "\":", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": " 30", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": ",\n", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": " \\", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "re", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "tri", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "es\\", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": ": ", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "oldDelta": "1\n", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": " ", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "\"t", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "ime", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "out", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "\":", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": " 12", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "0,\\", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": " ", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "\"r", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "etr", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "ies", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": "\":", + "type": "file_edit_delta" + }, + { + "imagePath": "config/app.json", + "newDelta": " 3\\", + "type": "file_edit_delta" + }, + { + "arguments": "{\n \"relative_path\": \"config/app.json\",\n \"old_string\": \" \\\"timeout\\\": 30,\\n \\\"retries\\\": 1\\n\",\n \"new_string\": \" \\\"timeout\\\": 120,\\n \\\"retries\\\": 3\\n\"\n}", + "name": "str_replace_workspace_file", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "已生成补丁提案:config/app.json(替换 1 处)。文件尚未写入磁盘;用户确认(Keep All)后才会落盘。", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "{\n \"timeout\": 120,\n \"retries\": 3\n}\n", + "mode": "write", + "operation": "write", + "suggestedRelativePath": "config/app.json", + "summary": "App", + "title": "App", + "type": "file_proposal" + }, + { + "content": "替换提案已生成。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/proposal-lr-agent-suppressed.json b/.baseline/python/proposal-lr-agent-suppressed.json new file mode 100644 index 0000000..cffdfe4 --- /dev/null +++ b/.baseline/python/proposal-lr-agent-suppressed.json @@ -0,0 +1,24 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "arguments": "{\n \"relative_path\": \".lr-agent/scratch.md\",\n \"content\": \"内部草稿\"\n}", + "name": "write_workspace_file", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "{\"ok\": false, \"tool\": \"write_workspace_file\", \"status\": \"error\", \"summary\": \"无法写入文件:不能用本工具写入标注。新增/重写请调用 auto_annotate,修改请调用 mutate_annotation。\", \"file_written\": false, \"proposal_pending\": false}", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "内部草稿已处理。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/proposal-write.json b/.baseline/python/proposal-write.json new file mode 100644 index 0000000..10926a0 --- /dev/null +++ b/.baseline/python/proposal-write.json @@ -0,0 +1,112 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我来创建报告文件", + "type": "text_delta" + }, + { + "content": "。", + "type": "text_delta" + }, + { + "detail": "0", + "suggestedRelativePath": "reports/summary.md", + "summary": "reports/summary.md", + "title": "reports/summary.md", + "type": "file_proposal_start" + }, + { + "content": "# 汇总报告\\", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "\n- 共检", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "查 12 个文", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "件\n- 发现", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": " 3 处待处理", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "项\n\n##", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": " 详情\n\n", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "第一项需要补充", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "说明。\n第二", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "项需要复核标注", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "。\n第三项已", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "content": "完成。\n", + "suggestedRelativePath": "reports/summary.md", + "type": "file_proposal_delta" + }, + { + "arguments": "{\n \"relative_path\": \"reports/summary.md\",\n \"content\": \"# 汇总报告\\n\\n- 共检查 12 个文件\\n- 发现 3 处待处理项\\n\\n## 详情\\n\\n第一项需要补充说明。\\n第二项需要复核标注。\\n第三项已完成。\\n\"\n}", + "name": "write_workspace_file", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "已生成文件提案:reports/summary.md。文件尚未写入磁盘;用户确认(Keep All)后才会落盘。请勿在回复中声称文件已创建或已保存。", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "# 汇总报告\n\n- 共检查 12 个文件\n- 发现 3 处待处理项\n\n## 详情\n\n第一项需要补充说明。\n第二项需要复核标注。\n第三项已完成。\n", + "mode": "write", + "operation": "write", + "suggestedRelativePath": "reports/summary.md", + "summary": "Summary", + "title": "Summary", + "type": "file_proposal" + }, + { + "content": "报告提案已生成,", + "type": "text_delta" + }, + { + "content": "请确认。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/reasoning.json b/.baseline/python/reasoning.json new file mode 100644 index 0000000..b71eac8 --- /dev/null +++ b/.baseline/python/reasoning.json @@ -0,0 +1,21 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "你好,我是运行在", + "type": "text_delta" + }, + { + "content": " LR-Agen", + "type": "text_delta" + }, + { + "content": "t 内的模型。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/single-tool.json b/.baseline/python/single-tool.json new file mode 100644 index 0000000..4496a99 --- /dev/null +++ b/.baseline/python/single-tool.json @@ -0,0 +1,36 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我先看一下工作区", + "type": "text_delta" + }, + { + "content": "结构。", + "type": "text_delta" + }, + { + "arguments": "{\n \"relative_dir\": \"\"\n}", + "name": "list_workspace_directory", + "toolCallId": "", + "type": "tool_start" + }, + { + "result": "目录:.\n条目数:3\n---\nconfig | directory | config\ndata | directory | data\nREADME.md | file | README.md", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "工作区已检查完毕", + "type": "text_delta" + }, + { + "content": "。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/subagent.json b/.baseline/python/subagent.json new file mode 100644 index 0000000..2addd48 --- /dev/null +++ b/.baseline/python/subagent.json @@ -0,0 +1,87 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "arguments": "{\n \"query\": \"项目里有哪些配置文件\",\n \"focus_path\": \"config\"\n}", + "name": "explore_readonly", + "toolCallId": "", + "type": "tool_start" + }, + { + "focusPath": "config", + "query": "项目里有哪些配置文件", + "toolCallId": "", + "type": "subagent_start" + }, + { + "content": "我先看 conf", + "toolCallId": "", + "type": "subagent_text_delta" + }, + { + "content": "ig 目录。", + "toolCallId": "", + "type": "subagent_text_delta" + }, + { + "arguments": "{\n \"glob_pattern\": \"*.json\",\n \"relative_dir\": \"config\"\n}", + "innerToolCallId": "", + "name": "glob_workspace", + "toolCallId": "", + "type": "subagent_tool_start" + }, + { + "innerToolCallId": "", + "result": "glob:*.json\n范围:config\n条目数:1\n---\nconfig/app.json", + "status": "done", + "toolCallId": "", + "type": "subagent_tool_result" + }, + { + "content": "config 下", + "toolCallId": "", + "type": "subagent_text_delta" + }, + { + "content": "有一个 app.", + "toolCallId": "", + "type": "subagent_text_delta" + }, + { + "content": "json。", + "toolCallId": "", + "type": "subagent_text_delta" + }, + { + "status": "done", + "summary": "config 下有一个 app.json。", + "toolCallId": "", + "type": "subagent_done" + }, + { + "result": "config 下有一个 app.json。", + "toolCallId": "", + "type": "tool_result" + }, + { + "content": "查阅完成:配置文", + "type": "text_delta" + }, + { + "content": "件只有 conf", + "type": "text_delta" + }, + { + "content": "ig/app.j", + "type": "text_delta" + }, + { + "content": "son。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.baseline/python/tool-choice-any-fallback.json b/.baseline/python/tool-choice-any-fallback.json new file mode 100644 index 0000000..5fb8b65 --- /dev/null +++ b/.baseline/python/tool-choice-any-fallback.json @@ -0,0 +1,33 @@ +[ + { + "stage": "streaming", + "type": "preparing" + }, + { + "content": "我会调用 aut", + "type": "text_delta" + }, + { + "content": "o_annota", + "type": "text_delta" + }, + { + "content": "te(paths", + "type": "text_delta" + }, + { + "content": "=[\"data/", + "type": "text_delta" + }, + { + "content": "\"]) 来完成标", + "type": "text_delta" + }, + { + "content": "注。", + "type": "text_delta" + }, + { + "type": "done" + } +] diff --git a/.gitignore b/.gitignore index 0d0bc4c..35c43ff 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,13 @@ assets/python-runtime/ # ZCode 会话工作区缓存 .zcode/ + +# 文档目录默认不入库;仅 Agent 协议契约例外(它是代码必须遵循的单一真源) +docs/* +!docs/agent-protocol.md + +# Agent 协议基线:.baseline/python 是迁移前冻结的参考序列,必须入库 +# (vendor/local-agent 删除后无法重新生成),其余为可再生产物 +.baseline/fixture-workspace/ +.baseline/node/ +.baseline/tampered/ \ No newline at end of file diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md new file mode 100644 index 0000000..52ad1af --- /dev/null +++ b/docs/agent-protocol.md @@ -0,0 +1,1084 @@ +# Agent 编排协议契约 + +> **本文档是 Agent 编排协议的单一真源。** +> +> Node 运行时(`src/main/agent/**`)必须与本文档描述的行为逐字对等。任何偏差都会导致 +> 渲染层静默降级——`src/renderer/services/backendChatClient.ts` 的 SSE 解析对畸形分片是 +> `catch { // ignore }`,协议对不上不会报错,只会「没有反应」,排查成本极高。 +> +> 本文档描述的是**迁移前 Python 实现(`vendor/local-agent`)的既成行为**。迁移完成后, +> 它仍是对外契约的权威描述,不再指向具体实现语言。 +> +> 变更本文档需要同时更新 `scripts/agent-baseline/` 下的 golden 用例。 + +--- + +## 1. 传输层 + +| 项 | 值 | +|---|---| +| 服务绑定 | `127.0.0.1`,随机空闲端口(迁移前由 Electron 主进程分配) | +| 鉴权 | `Authorization: Bearer `,token 每次启动随机生成;`/health` 不需要鉴权 | +| 健康检查 | `GET /health` → 200 `{"status":"ok"}` | +| API 前缀 | `/api/v1` | +| SSE 媒体类型 | `text/event-stream` | +| CORS | 允许来源为 `http://localhost:1212`,禁止通配符 | + +### 1.1 SSE 分帧 + +每个事件一帧,格式: + +``` +data: {json}\n\n +``` + +- JSON 使用 `ensure_ascii=False`(中文不转义) +- 只有 `data:` 行,**没有 `event:` 行**;事件类型靠 JSON 内的 `type` 字段分派 +- 客户端按 `\n` 切分、`trim()`、`startsWith('data:')`、`slice(5).trim()` 后 `JSON.parse` + +### 1.2 流收尾 + +流末尾**无条件**追加一帧**裸对象**(不是完整的 `StreamEventPayload` 序列化结果): + +``` +data: {"type": "done"}\n\n +``` + +注意:这一帧只有 `type` 字段。客户端在收到 `done` / `error` / `tool_pending` 时即终止读取。 + +### 1.3 取消语义 + +- 客户端**当前不使用**服务端取消端点,走本地 `AbortController`(`src/renderer/services/agentJobRegistry.ts` 的 `stopJob`) +- `POST /agent/chat/cancel` 仍保留,body `{"client_job_id": str}` → `{"ok": true}` +- 取消检查点遍布主循环与子代理循环;取消后流仍会走到收尾发 `done` + +--- + +## 2. SSE 事件契约 + +### 2.1 字段序列化规则 + +字段名在 Python 内部为 snake_case,输出到 SSE 时按下列规则转换。**核心规则:仅当值 +`is not None` 时才写入该字段**(空字符串 `""` 需要写入)。 + +| 内部字段 | SSE 输出名 | 条件 | +|---|---|---| +| `type` | `type` | 始终输出 | +| `content` | `content` | | +| `stage` | `stage` | | +| `status` | `status` | | +| `detail` | `detail` | | +| `proposal` | `proposal` | Node 侧不发射 | +| `summary` | `summary` | | +| `summary_up_to_message_id` | `summaryUpToMessageId` | Node 侧不发射 | +| `token_estimate` | `tokenEstimate` | Node 侧不发射 | +| `tool_call_id` | `toolCallId` | | +| `name` | `name` | | +| `arguments` | `arguments` | 字符串(pretty JSON),见 2.4 | +| `result` | `result` | | +| `message` | `message` | Node 侧不发射 | +| `image_path` | 见下 | | +| `mode` | 见下 | | +| `old_path` | 见下 | | +| `domain` / `target` / `reason` | 同名 | Node 侧不发射 | +| `query` | `query` | | +| `focus_path` | `focusPath` | | +| `inner_tool_call_id` | `innerToolCallId` | | +| `old_delta` / `new_delta` | `oldDelta` / `newDelta` | | +| `client_tool_calls` | 见下 | | + +**`image_path` 的条件重命名**: + +``` +type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_proposal } + → suggestedRelativePath +否则 + → imagePath +``` + +**`mode` 的条件派生**: + +``` +先输出 data["mode"] = mode +再当 type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_proposal } + → 额外输出 data["operation"] = mode(与 mode 同值) +``` + +**`old_path` 的条件输出**:仅当 `type ∈ { file_proposal_start, file_proposal }` 时输出为 `oldPath`。 + +**派生字段 `title`**:当 `type ∈ { file_proposal_start, file_proposal, document_proposal }` 时 + +- 若有 `summary` → `title = summary`(**且 `summary` 字段本身仍然存在**) +- 否则若有 `detail` → `title = detail` + +**`client_tool_calls` 双写**:序列化为 camelCase 数组后,**同时**写入 `clientToolCalls` 和 +`toolCalls` 两个键,值相同(同一份数组)。 + +```jsonc +// ClientToolCall 元素结构(注意 arguments 是对象,不是字符串) +{ "toolCallId": "...", "name": "...", "arguments": { } } +``` + +### 2.2 事件生产者边界 + +`StreamEvent` 在 TypeScript 侧是联合类型,但**并非全部由运行时发射**。实现时不要为渲染层 +合成的事件写发射逻辑。 + +| 事件 | 生产者 | +|---|---| +| `preparing`、`text_delta`、`reasoning_delta`、`tool_start`、`tool_result`、`tool_pending`、`file_proposal_start`、`file_proposal_delta`、`file_edit_delta`、`file_proposal`、`subagent_*`、`done` | **运行时** | +| `awaiting_confirmation`、`terminal_approval`、`terminal_approval_done`、`terminal_output`、`annotation_progress`、`annotation_proposal`、`error` | **渲染层合成** | +| `context_updated`、`route_decided` | 已声明但**全仓库无发射点**(死类型,仅类型与调试日志消费) | +| `document_proposal` | `@deprecated` 旧类型,运行时从不发射;历史消息加载时归一为 `file_proposal`。序列化特判保留以兼容旧数据 | +| `tool_calls_already_completed` | **内部信号**,被上层消费,**不出 SSE** | + +### 2.3 逐事件契约 + +#### `preparing` + +| 字段 | 说明 | +|---|---| +| `type` | `"preparing"` | +| `stage` | `"streaming"`(assist / chat 两条路径);`"mcp"`(MCP 工具发现之前) | + +语义:首 token 前的等待提示。TS 类型声明里还允许 `'summarize'` / `'build_messages'`,但运行时当前不发。 + +#### `text_delta` + +| 字段 | 说明 | +|---|---| +| `type` | `"text_delta"` | +| `content` | 正文增量 | + +来源:chunk 的 `content` 为字符串时直接取;为数组时取 `type === "text"` 的 part。 + +#### `reasoning_delta` + +| 字段 | 说明 | +|---|---| +| `type` | `"reasoning_delta"` | +| `content` | 推理增量,取自 `chunk.additional_kwargs.reasoning_content` | + +#### `tool_start` + +| 字段 | 说明 | +|---|---| +| `type` | `"tool_start"` | +| `toolCallId` | 工具调用 id | +| `name` | 工具名 | +| `arguments` | **字符串**:`JSON.stringify(args, null, 2)`(pretty JSON) | + +**关键时机**:`tool_start` 在**本轮 astream 全部结束后**才发(在 `execute_round` 阶段), +**不在 chunk 期间发**。被门禁/去重拦截的调用也会发 `tool_start`。 + +#### `tool_result` + +| 字段 | 说明 | +|---|---| +| `type` | `"tool_result"` | +| `toolCallId` | 工具调用 id | +| `result` | 字符串,显示文本 | + +**`result` 内容规则**:多数工具传 `format_tool_result_for_display(raw)`: + +- raw 不是合法 JSON → 原样返回 +- raw 是 JSON 但非对象 → 原样返回 +- raw 是对象且有 `summary` → **只返回 `summary` 文本** +- 否则有 `message` → 返回 `message` 文本 +- 否则原样返回 + +#### `tool_pending` + +| 字段 | 说明 | +|---|---| +| `type` | `"tool_pending"` | +| `clientToolCalls` | `ClientToolCall[]` | +| `toolCalls` | 与 `clientToolCalls` 同值的别名 | + +语义:暂停主循环,**立即结束当前 HTTP 轮次**(不再产出任何事件,但收尾的 `done` 仍会发)。 +触发工具 = runner 为 `ASYNC` 的三个:`auto_annotate`、`mutate_annotation`、`start_terminal_command`。 +发射前会先为每个调用各发一条 `tool_start`。 + +#### `subagent_start` + +| 字段 | 说明 | +|---|---| +| `type` | `"subagent_start"` | +| `toolCallId` | 父级 `explore_readonly` 调用的 id | +| `query` | 查阅任务 | +| `focusPath` | 可选,仅在非空时输出 | + +#### `subagent_text_delta` + +| 字段 | 说明 | +|---|---| +| `type` | `"subagent_text_delta"` | +| `toolCallId` | 父级调用 id | +| `content` | 增量文本 | + +#### `subagent_tool_start` + +| 字段 | 说明 | +|---|---| +| `type` | `"subagent_tool_start"` | +| `toolCallId` | 父级调用 id | +| `innerToolCallId` | 子代理内部的调用 id | +| `name` | 工具名 | +| `arguments` | pretty JSON 字符串 | + +#### `subagent_tool_result` + +| 字段 | 说明 | +|---|---| +| `type` | `"subagent_tool_result"` | +| `toolCallId` | 父级调用 id | +| `innerToolCallId` | 子代理内部的调用 id | +| `result` | 截断到 4000 字符的显示文本 | +| `status` | `"done"` 或 `"error"`(由结果 JSON 的 `ok === false` 判定) | + +#### `subagent_done` + +| 字段 | 说明 | +|---|---| +| `type` | `"subagent_done"` | +| `toolCallId` | 父级调用 id | +| `summary` | 中文摘要 | +| `status` | `"done"` 或 `"error"` | + +主循环收到后会把 `summary` 包装成父级 `tool_result` 后再发,并 append 父级 `ToolMessage`。 + +#### `file_proposal_start` + +| 字段 | 说明 | +|---|---| +| `type` | `"file_proposal_start"` | +| `summary` | 显示路径 | +| `title` | 派生,等于 `summary` | +| `suggestedRelativePath` | 显示路径(由 `image_path` 重命名而来) | +| `detail` | 字符串:流式拦截时为 `"0"`;定稿补齐时为 `String(content.length)` | +| `mode` / `operation` | 仅 edit 类工具时输出 `"edit"`;write 类不输出 | + +#### `file_proposal_delta` + +| 字段 | 说明 | +|---|---| +| `type` | `"file_proposal_delta"` | +| `content` | 增量片段 | +| `suggestedRelativePath` | 显示路径 | +| `mode` / `operation` | 仅定稿补齐路径会带;流式拦截路径**不带** | + +频率:**每个 chunk 最多一条**(不是每个字符)。`content` 未闭合时也照发已累积部分。 + +#### `file_edit_delta` + +| 字段 | 说明 | +|---|---| +| `type` | `"file_edit_delta"` | +| `suggestedRelativePath` | 显示路径 | +| `oldDelta` | 可选,`old_string` 的增量 | +| `newDelta` | 可选,`new_string` 的增量 | + +`oldDelta` 与 `newDelta` 至少一个非空;两者都无增量则**不发射**。同一 chunk 内两者的增量 +合并成**一条**事件。 + +#### `file_proposal`(终态) + +| 字段 | 说明 | +|---|---| +| `type` | `"file_proposal"` | +| `summary` | 标题 | +| `title` | 派生,等于 `summary` | +| `content` | 完整内容(失败补发时为空串) | +| `suggestedRelativePath` | 显示路径 | +| `mode` / `operation` | `"write"` / `"edit"` / `"delete"` / `"rename"` | +| `oldPath` | 仅重命名(rename)时输出 | +| `status` | 仅失败补发时输出 `"dismissed"` | + +两个发射点: + +1. **失败补发**:流式拦截器已出卡但工具执行报错 → `status="dismissed"`、`content=""` +2. **正常定稿**:`summary = title`、`content` 全量、`mode = operation`、`old_path` + +#### `done` + +| 字段 | 说明 | +|---|---| +| `type` | `"done"` | + +运行时在流末尾无条件发送。渲染层在整轮(含 resume)结束时也会合成一个 `done`。 + +#### `error` + +| 字段 | 说明 | +|---|---| +| `type` | `"error"` | +| `message` | 错误信息 | + +**运行时从不发射**。由渲染层在 fetch 失败 / HTTP 非 2xx / body 为空时合成。 + +### 2.4 `arguments` 字段的类型不统一(不要统一) + +| 场景 | 类型 | +|---|---| +| `tool_start.arguments` | pretty JSON **字符串** | +| `subagent_tool_start.arguments` | pretty JSON **字符串** | +| `tool_pending.clientToolCalls[].arguments` | **对象** | + +这是既成事实,逐字复刻时不要改成统一类型。 + +--- + +## 3. 请求体契约 + +### 3.1 `POST /api/v1/agent/chat/stream` + +字段名为 **snake_case**(即 JSON 键名)。 + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `api_key` | string(≥1) | 是 | | 主模型 key | +| `base_url` | string(≥1) | 是 | | 主模型 base URL(末尾 `/` 会被裁掉) | +| `model` | string(≥1) | 是 | | 主模型名 | +| `supports_vision` | boolean | 否 | `false` | 视觉探针结果 | +| `aux_model` | string | 否 | `""` | 辅助模型(子代理查阅) | +| `aux_api_key` | string | 否 | `""` | | +| `aux_base_url` | string | 否 | `""` | 三者**皆非空**才生效,否则跟随主模型 | +| `messages` | `ChatMessageInput[]` | 是 | | 历史消息,见 3.2 | +| `user_content` | string(≥1) | 是 | | 本轮用户文本 | +| `system_prompt` | string? | 否 | `null` | **仅无工具(纯 chat)分支生效** | +| `context_summary` | string? | 否 | `null` | 见 3.3 | +| `context_summary_up_to_message_id` | string? | 否 | `null` | **运行时从不读取** | +| `client_context` | `ClientContextInput?` | 否 | `null` | 见 3.4 | +| `client_tool_results` | `ClientToolResult[]` | 否 | `[]` | resume 时携带,见 3.5 | +| `client_job_id` | string(1..64) | 是 | | 取消与事件注册键 | + +**`has_tools` 判定**(决定走 Agent 分支还是纯 chat 分支): + +``` +client_context 非空 AND ( + workspace_root 去空格后非空 + OR active_annotation_project_id 为真 + OR annotation_project_snapshot 非空 +) +``` + +### 3.2 `ChatMessageInput` + +| 字段 | 类型 | 必填 | 默认 | +|---|---|---|---| +| `role` | `"user" \| "assistant" \| "system" \| "tool"` | 是 | | +| `content` | string | 否 | `""` | +| `message_id` | string? | 否 | `null` | +| `interaction_mode` | `"chat" \| "annotation"`? | 否 | `null` | +| `tool_calls` | `ChatToolCallInput[]`? | 否 | `null` | +| `tool_call_id` | string(1..128)? | 否 | `null` | + +`ChatToolCallInput`:`{ id: string(1..128), name: string(1..128), args: object = {} }` + +**消息转换规则**(顺序:主 SystemMessage → 可选摘要 SystemMessage → 历史消息): + +| role | 转换 | +|---|---| +| `user` | `HumanMessage(content)` | +| `assistant` | 有 `tool_calls` → `AIMessage(content, tool_calls)`;否则纯 `AIMessage(content)` | +| `tool` | **`tool_call_id` 为空时整条跳过**;否则 `ToolMessage(content, tool_call_id)` | +| `system` | `SystemMessage(content)` | + +### 3.3 摘要注入 + +`context_summary` 作为**第二条 SystemMessage**注入,紧跟主系统提示词,内容前缀固定: + +``` +【此前对话摘要】 + +``` + +**去重完全在前端完成**:前端按会话的 `summaryUpToMessageId` 在消息列表中定位截断点, +只发送其后的消息。因此摘要覆盖点之前的原始消息被永久移出窗口,不会与摘要重复。 +运行时**不使用** `context_summary_up_to_message_id`。 + +### 3.4 `ClientContextInput` + +| 字段 | 类型 | 默认 | +|---|---|---| +| `workspace_root` | string? | `null` | +| `active_file_path` | string? | `null` | +| `active_relative_path` | string? | `null` | +| `project_directory_path` | string? | `null` | +| `active_annotation_project_id` | string? | `null` | +| `annotation_project_modality` | string? | `null` | +| `annotation_project_type` | string? | `null` | +| `agent_mode` | `"chat" \| "annotation"`? | `null` | +| `work_mode` | `"editor" \| "annotation"`? | `null` | +| `selected_annotation_id` | string? | `null` | +| `selected_annotation_ids` | string[] | `[]` | +| `annotation_project_snapshot` | `AnnotationProjectSnapshotInput?` | `null` | +| `mcp_server_url` | string? | `null` | +| `mcp_server_token` | string? | `null` | +| `mcp_servers` | `McpServerInput[]` | `[]` | +| `project_instructions` | string? | `null` | +| `memory_index` | string? | `null` | +| `workspace_memory_enabled` | boolean | `false` | +| `skills_catalog` | `SkillCatalogEntryInput[]` | `[]` | +| `proposal_ledger` | string? | `null` | +| `proposal_states` | `ProposalStateInput[]` | `[]` | + +**`AnnotationProjectSnapshotInput`** + +| 字段 | 类型 | 约束 | +|---|---|---| +| `project_id` | string | 必填,1..64 | +| `name` | string | `""` | +| `modality` | string | `""` | +| `annotation_type` | string | `""` | +| `labels` | object[] | `[]` | +| `detection_models` | object[] | `[]` | +| `project_directory_path` | string? | `null` | + +**`SkillCatalogEntryInput`**:`{ name: string(1..128), description: string(≤512) }` + +**`ProposalStateInput`** + +| 字段 | 类型 | 默认 | +|---|---|---| +| `path` | string(≥1) | 必填 | +| `kind` | `"annotation" \| "file"` | `"annotation"` | +| `status` | `"pending" \| "applied" \| "dismissed" \| "undone"` | `"pending"` | +| `operation` | string? | `null` | +| `annotation_ids` | string[] | `[]` | + +**`McpServerInput`** + +| 字段 | 类型 | 默认 | +|---|---|---| +| `id` | string(1..64) | 必填 | +| `url` | string(≥1) | 必填 | +| `transport` | `"streamable_http" \| "sse"` | `"streamable_http"` | +| `headers` | `Record` | `{}` | +| `disabled_tools` | string[] | `[]` | + +### 3.5 `ClientToolResult` + +| 字段 | 类型 | +|---|---| +| `tool_call_id` | string(1..128) | +| `name` | string(1..128) | +| `result` | string(JSON 序列化字符串) | + +**resume 处理流程**: + +1. 把 `tool_call_id` 记入「已完成」集合(后续同 id 调用会被去重) +2. 对每条结果做 `enrich_resume_tool_result`: + - 仅当结果是合法 JSON 才处理 + - 当 `proposals_applied === true`(阶段为 `VERIFY`)→ 强制 `proposal_pending=false`、 + `file_written=true`,并写入「已应用」版 next_hint + - 否则写入普通版 next_hint +3. 注入消息: + - 若该 `tool_call_id` 已存在于历史 AIMessage 的 tool_calls 中 → 直接补一条 `ToolMessage` + - 否则(客户端多执行了一个运行时没声明的调用)→ **补造**一条 + `AIMessage(content="", tool_calls=[{ id, name, args: { user_request } }])`,再补 `ToolMessage` + +第 3 步的第二个分支是正确性关键:不补造会让模型报 `tool_call_id` 不匹配。 + +--- + +## 4. 端点清单 + +| 方法 | 路径 | 说明 | 迁移处置 | +|---|---|---|---| +| `POST` | `/api/v1/agent/chat/stream` | Assist SSE 流式对话 | 保留 | +| `POST` | `/api/v1/agent/chat/cancel` | 取消任务 | 保留 | +| `POST` | `/api/v1/agent/mcp/probe` | 测试连接 MCP Server | 保留 | +| `POST` | `/api/v1/agent/annotation/mutation-prepare` | 标注变更规划 | 保留 | +| `POST` | `/api/v1/agent/annotation/map-detection-boxes` | 检测框标签映射 | 保留 | +| `POST` | `/api/v1/agent/annotation/llm-generate` | 通用 LLM 生成代理 | 保留 | +| `POST` | `/api/v1/agent/annotation-quality/report/compose/stream` | 质量报告撰写(SSE) | 保留 | +| `POST` | `/api/v1/agent/annotation/batch-prepare` | 批量标注计划 | **删除**(无调用方) | +| `POST` | `/api/v1/agent/annotation/map-heuristic` | 启发式映射 | **删除**(无调用方) | +| `GET` | `/health` | 健康检查 | 保留(阶段 9 可去) | + +--- + +## 5. 工具注册表 + +### 5.1 执行器分类(单一真源) + +| Runner | 语义 | 执行位置 | +|---|---|---| +| `SYNC` | 本轮在运行时内同步执行完毕 | 运行时 | +| `PROPOSAL` | 运行时校验并生成提案事件,**落盘由 Electron 在用户确认后执行** | 运行时生成提案 / Electron 落盘 | +| `ASYNC` | 暂停循环,由 Electron 执行后 resume | Electron | + +**迁移后应收归为单一真源**:当前分类在三处手写镜像(Python `tool_registry_meta.py`、 +`src/shared/agentToolKinds.ts`、`src/shared/agentTypes.ts` 的 `CLIENT_TOOL_NAME_SET`), +必须收敛为一处,避免漂移。 + +### 5.2 18 个内置工具 + +#### 只读检索类(SYNC,6 个) + +| 工具 | 参数 | 返回 | +|---|---|---| +| `read_workspace_file` | `relative_path: string = ""`、`start_line: int?`、`end_line: int?` | 带行号前缀的文本(` 12|code`,6 位右对齐) | +| `grep_workspace` | `pattern: string`、`path: string = ""`、`glob_pattern: string = "*"`、`case_insensitive: bool = false` | `path:line: content` 文本 | +| `glob_workspace` | `glob_pattern: string`、`relative_dir: string = ""` | 文件路径列表 | +| `list_workspace_directory` | `relative_dir: string = ""` | `name | kind | relativePath` | +| `read_document_file` | `relative_path: string = ""` | PDF / DOCX 正文 | +| `read_file_annotation` | `relative_path: string`、`annotation_offset: int = 0`、`annotation_limit: int = 200` | 标注 JSON,超 40k 截断 | + +#### 视觉类(SYNC,1 个) + +| 工具 | 参数 | 说明 | +|---|---|---| +| `read_image_for_vision` | `relative_path: string = ""` | 需 `provider_is_vision`,否则返回中文拒绝文案;结果含内部标记 `__vision_image_path__`,由调度层剥离并注入附图 | + +#### 元信息类(SYNC,4 个) + +| 工具 | 参数 | +|---|---| +| `get_account_summary` | 无 | +| `get_lr_agent_help` | `topic: string?` | +| `describe_client_context` | 无 | +| `describe_annotation_project` | 无 | + +#### 提案写入类(PROPOSAL,4 个) + +| 工具 | 参数 | +|---|---| +| `write_workspace_file` | `relative_path: string(≥1)`、`content: string` | +| `str_replace_workspace_file` | `relative_path: string(≥1)`、`old_string: string(≥1)`、`new_string: string`、`replace_all: bool = false` | +| `delete_workspace_file` | `relative_path: string(≥1)` | +| `move_workspace_file` | `relative_path: string(≥1)`、`new_relative_path: string(≥1)` | + +`str_replace` 的匹配语义:`old_string` 必须在文件中**唯一出现**,否则报错;`replace_all=true` +时替换全部出现。 + +落盘复用 `src/main/workspace/workspaceWrite.ts`,其安全语义比 Python 侧更严(额外含 +`realpath` 符号链接逃逸检测与授权根校验)。 + +#### 子代理(SYNC 特例,1 个) + +| 工具 | 参数 | +|---|---| +| `explore_readonly` | `query: string(≥1)`、`focus_path: string?` | + +#### 客户端工具(ASYNC 存根,2 个) + +| 工具 | 参数 | +|---|---| +| `auto_annotate` | `user_request: string(≥1)`、`paths: string[]?`、`all_files: bool?`、`scope_hint: string?`、`write_mode: string?`、`conf_threshold: float? (0..1)`、`iou_threshold: float? (0..1)`、`model_id: string?`、`include_classes: string[]?`、`exclude_classes: string[]?`、`use_vision_mapping: bool?` | +| `mutate_annotation` | `user_request: string(≥1)`、`paths: string[]?`、`annotation_ids: string[]?` | + +**注意**:这两个工具在运行时侧**没有执行体**,注册的是永远不会被本地调用的 stub。真正执行在 +`src/renderer/services/agentJobRegistry.ts`。 + +> 另有 `start_terminal_command`(ASYNC),由本地 MCP Server +> (`src/main/mcp/server.ts`)暴露,不在上述 18 个内置工具内。 + +### 5.3 工具集分组 + +| 集合 | 内容 | +|---|---| +| `FULL_TOOL_SET` | 14 个:全部只读 + 全部提案写入 + 2 个标注客户端工具 | +| `WRITE_TOOL_NAMES` | `auto_annotate`、`mutate_annotation`、4 个提案写入工具 | +| `ASK_TOOL_SET` | `FULL_TOOL_SET − WRITE_TOOL_NAMES`(纯只读) | +| `LIGHT_TOOL_SET` | 只读 + 提案写入(**无标注工具**),用于编辑器 / 纯工作区 | + +**工具集选择规则**: + +``` +has_context = has_project_snapshot OR is_editor OR has_workspace + +agent_mode !== "annotation" → has_context ? ASK_TOOL_SET : ∅ +has_project_snapshot && !is_editor → FULL_TOOL_SET +is_editor || has_workspace → LIGHT_TOOL_SET +否则 → ∅ +``` + +附加裁剪:`work_mode === "editor"` 时额外剔除全部标注工具。 + +**工具列表按名称排序后固定**。阶段门禁**不在 bind 期增删工具**,改由执行层 +`check_call_allowed` 拦截——目的是让 tools 前缀跨阶段保持一致以命中 LLM 前缀缓存。 + +### 5.4 统一工具结果结构 + +`build_tool_result` 产出的 JSON: + +```jsonc +{ + "ok": boolean, + "tool": string, + "status": string, + "summary": string, + "file_written": boolean, // 默认 false + "proposal_pending": boolean, // 默认 false + // ...额外字段 +} +``` + +`format_tool_result_for_display`(用于 `tool_result` 事件与主循环 `ToolMessage` 回灌): +有 `summary` 则只返回 `summary`;否则有 `message` 返回 `message`;否则原样返回。 + +**易错点**:主循环回灌用**显示文本**;子代理内部回灌用**原文**。二者不一致,不要统一。 + +--- + +## 6. 工具循环行为 + +### 6.1 轮次与预算 + +| 配置 | 默认 | +|---|---| +| 主循环最大工具轮次 | 30 | +| 子代理最大工具轮次 | 12 | + +轮次预算耗尽时,追加一条 `HumanMessage`(内容为「工具调用预算已用完。请基于以上进展直接 +给出最终回答,不要再调用工具。」),再跑一次**不带工具**的调用产出最终回答。 + +### 6.2 单轮流程 + +``` +yield preparing(stage="streaming") +llm_bound = bind_tools(llm, tools) + +for round_idx in 0..max_tool_rounds: + if cancelled: return + + interceptor = new ProposalStreamInterceptor(client_context) // 每轮新建 + + # 阶段 A:单次 astream + for chunk in astream(llm_bound, messages): + if cancelled: return + yield text_delta / reasoning_delta + yield from interceptor.on_chunk(chunk) + gathered = merge(gathered, chunk) + + if gathered is None: break + full_text = 累积的正文 + + # 阶段 B:执行本轮 + for ev in execute_round(gathered, full_text, messages, interceptor): + if ev.type == INTERNAL_ALREADY_COMPLETED: continue + if ev.type == "tool_pending": yield ev; return // 结束 HTTP 轮次 + yield ev + + if had_tool_call: continue + + # 阶段 C:无 tool call + if round_idx == 0 and not is_resume and vision.should_load() and not vision_bootstrapped: + yield from vision.try_fallback(messages) + vision_bootstrapped = true + continue + break +else: + # 预算耗尽 + 强制无工具收官 +``` + +**chunk 累积**:靠增量合并(拼接 `content`、合并 `tool_calls` 分片、取最后非空的 +`additional_kwargs`),累积成完整的 assistant 消息后读取其 `tool_calls`。 + +### 6.3 `execute_round` 的五道过滤 + +按顺序: + +1. **调用规整化**:丢弃 name 为空的调用;丢弃 id 已在「已完成」集合中的调用;无 id 时生成 + `tool-{12位随机}`;args 非法时置 `{}` +2. **同轮顺序不变量**:若本轮含标注写入工具(`auto_annotate` / `mutate_annotation`),则把 + 同轮的所有工作区写入工具改为 `phase_blocked` 错误反馈(保证「先标注后写报告」) +3. **阶段门禁**:`gating_enabled` 时逐调用校验(见 6.5) +4. **同轮多 `auto_annotate` 合并**:paths 去重合并、`all_files` 取或、`write_mode` 只要有一个 + `replace_matching` 就取之;其余调用回 `coalesced` 反馈。`mutate_annotation` **不合并** +5. **同批异步去重**:`scope_key = (name, 提取的 paths)`,重复者回 `duplicate_call` 反馈 + +之后分类执行: + +- **并行只读白名单**(`get_lr_agent_help`、`describe_annotation_project`、 + `read_file_annotation`、`read_workspace_file`、`grep_workspace`、`glob_workspace`、 + `list_workspace_directory`、`read_document_file`)用 `Promise.all` **预取结果**,再按原顺序 + 逐个发事件——**事件顺序仍是串行的** +- `explore_readonly` 单独并发执行并合并事件流 +- 其余 SYNC 逐个串行执行(每个之前检查取消) +- ASYNC 收集后统一发 `tool_pending` + +### 6.4 去重机制 + +- 常量文案:`该工具本轮已执行,请勿重复调用。请总结,勿重跑标注工具。` +- 写入时机:resume 时把 `client_tool_results` 的 `tool_call_id` 记入已完成集合 +- 命中效果:该调用被丢弃,但会为它补一条 `ToolMessage`(`status="already_completed"`), + 并发内部信号,让循环继续下一轮、模型自行总结 +- **同名新调用(新 id)不受影响** + +### 6.5 阶段门禁 + +`derive_task_phase(proposal_states)` 是无状态纯函数,每次请求重推: + +| 条件 | 阶段 | +|---|---| +| 无 `proposal_states` | `null`(不启用门禁) | +| 存在 `kind="annotation" && status="pending"` | `AWAIT_CONFIRM` | +| 存在 `kind="annotation" && status="applied"` | `VERIFY` | +| 其余 | `null` | + +`gating_enabled` 仅在 `AWAIT_CONFIRM` / `VERIFY` 时为真。 + +拦截规则: + +- **`AWAIT_CONFIRM`**:禁止全部标注写入工具 + 全部工作区写入工具 +- **`VERIFY`**:禁止对已 applied 路径重复 `auto_annotate`(`all_files=true`、未指明 paths、 + paths 有交集均拦);`mutate_annotation` 要求指明 paths 或 annotation_ids,定向修正放行 + +被拦调用走 `_yield_blocked_call`:发 `tool_start` + `tool_result`(`status="phase_blocked"`), +并 append 对应 `ToolMessage`,循环继续。 + +注意:`SCAN` / `ANNOTATE` / `REPORT` 三个阶段目前**仅语义标记**,不参与拦截。 + +### 6.6 `tool_choice="any"` 强制兜底 + +触发条件(须全部满足): + +- 本请求内重试次数 < 1 +- 本轮**没有**真实 tool_calls +- 正文经正则命中某个 **ASYNC** 工具名(形如 `name(`) +- 该工具在当前工具集中 +- 门禁允许该调用 + +实现:用 `tool_choice="any"` 发起**非流式**调用,返回的 `AIMessage` 保留已流式输出的正文, +使后续 `ToolMessage` 的 id 有归属。失败则按普通空轮处理。 + +背景:部分模型会在正文里写工具伪代码而不真正发起 tool_call。 + +### 6.7 取消 + +- 取消通过 `AbortSignal` 贯穿(Python 侧原为进程内 `asyncio.Event`) +- 检查点:每轮开始、astream 中、每个工具执行前、子代理每轮与每次内层调用前 +- 取消后直接返回,不再产出业务事件 + +--- + +## 7. 提案流式拦截 + +### 7.1 目标 + +仅拦截两个工具: + +| 工具 | 拦截的参数 | +|---|---| +| `write_workspace_file` | `relative_path`、`content` | +| `str_replace_workspace_file` | `relative_path`、`old_string`、`new_string` | + +其它工具的分片仍会被累积进状态,但不产生事件。 + +### 7.2 状态 + +每条 tool_call 一份状态,键为**该轮内的 tool_call 索引**: + +``` +{ + args_buf, title_sent, kind ("write" | "edit"), + content_sent_len, old_sent_len, new_sent_len, + rel_path, call_id, suppressed +} +``` + +**必须每轮新建拦截器实例**,因为索引每轮从 0 重新编号。 + +重置条件:同一索引上 `id` 变化,或 `kind` 变化 → 重建状态。 + +### 7.3 args 合并(兼容两类 provider) + +``` +若 chunk_args 以 buf 开头 → 覆盖(累积式或首片) +若 buf 以 chunk_args 开头 → 忽略(重复投递) +否则 → 拼接(增量式) +``` + +### 7.4 半截 JSON 解析 + +解析单键字符串值的算法: + +1. 在 buffer 中查找 `""`,定位其后的第一个 `"` +2. 逐字符解码,转义表:`\n` `\t` `\r` `\\` `\"`;未知转义原样取下一字符 +3. 遇到未转义的 `"` → 返回 `(完整值, closed=true)` +4. 到达 buffer 末尾仍未闭合 → 返回 `(已累积部分, closed=false)` +5. key 未出现 → 返回 `(null, false)` + +**`\uXXXX` 转义处理**: + +- 不足 4 位十六进制 → 暂停解析,等更多分片 +- 含非十六进制字符 → 按字面 `u` 输出,避免卡死 +- **代理对**:高位 `0xD800-0xDBFF` 且后随 `\uDC00-\uDFFF` → 合成 + `0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)`,消耗 11 字符 +- 孤立代理 → `\ufffd` + +### 7.5 事件发射时机 + +每个 chunk 的每条 tool_call 分片: + +1. 合并 args 后,若 `args_buf` 非空且尚未发过 start: + - 解析 `relative_path`,**仅在字符串闭合时**才继续 + - 若路径属于 `.lr-agent` → 标记 `suppressed`,本调用后续不再出事件 + - 否则归一化显示路径,发**一条** `file_proposal_start`(`detail="0"`),标记 `title_sent` +2. `suppressed` → 跳过 +3. `kind === "edit"` → 走 `file_edit_delta` 分支 +4. `kind === "write"` → 解析 `content`,只要有新增长度就发**一条** `file_proposal_delta`, + 并推进已发送长度 + +### 7.6 与定稿的对接 + +拦截器暴露 `streamed_paths_by_call_id()`:返回已出卡且未被 suppress 的 +`call_id → 归一化显示路径` 映射。 + +定稿阶段用它: + +- 跳过已流式发出的路径的 `file_proposal_start` / `file_proposal_delta` +- 工具失败时用该映射补发 `status="dismissed"` 的 `file_proposal` + +**路径归一化必须单点**:拦截器与定稿必须产出**完全相同的路径字符串**,否则前端会裂成两张卡。 +归一化的根顺序为**项目目录优先,其次工作区根**。 + +--- + +## 8. 子代理 `explore_readonly` + +### 8.1 工具集 + +``` +BASE = { grep_workspace, glob_workspace, list_workspace_directory, + read_workspace_file, read_document_file, + describe_client_context, get_lr_agent_help } +ANNOTATION = { read_file_annotation, describe_annotation_project } +FORBIDDEN = { explore_readonly, auto_annotate, mutate_annotation, + write_workspace_file, str_replace_workspace_file, + delete_workspace_file, memory_write, memory_create, + read_image_for_vision } + +inner_tools = (BASE ∪ (ANNOTATION if include_annotation)) − FORBIDDEN + ∩ 父级工具集 +``` + +`include_annotation` 判定:`work_mode === "editor"` → false;无父工具集 → true;否则取决于父 +工具集是否含标注只读工具。 + +### 8.2 行为 + +- 每轮 `bind_tools(inner_tools)` 后 astream,只取文本转 `subagent_text_delta` +- **不走父级的去重与门禁**,只有白名单校验:不在 `inner_fn_map` 或命中 FORBIDDEN → `blocked` +- blocked 与 allowed 都先追加同一条 assistant 消息,确保每个 tool_call 都有配对的 ToolMessage +- blocked → 发 `subagent_tool_start` + `subagent_tool_result(status="error")` +- allowed → 执行后发 `subagent_tool_result`,结果截断到 4000 字符,**追加原文**(不是显示文本) +- 无 tool call → 该轮文本作为 summary,退出循环 +- 轮次耗尽且已有工具调用、summary 仍空 → 追加一条「请用中文给出查阅摘要,不要再调用工具。」 + 的 `HumanMessage`,用**不带工具**的调用跑一轮,文本同时作为 `subagent_text_delta` 流出 +- 兜底 summary:`status==="error"` → `"查阅失败"`;否则 `"未找到可用结论。"` + +### 8.3 Prompt + +System 消息固定为: + +``` +你是只读查阅子代理。只用只读工具查看代码、文档或已有标注,然后用简洁中文写一份摘要。 +禁止改文件、写标注、调用 explore_readonly,也禁止声称已经改过文件或完成标注。 +调用工具前先用一两句中文说明正在查什么。 +查到足够信息后直接给出结论,不要输出工具伪代码。 +``` + +User 消息:`查阅任务:{query}`,若 `focus_path` 非空则追加 `\n优先关注路径:{focus}`。 + +--- + +## 9. System prompt 组装 + +### 9.1 分块顺序(固定,不可调整) + +``` +1. 身份块(【你的身份】) +2. task 段 +3. editor_note(仅编辑器模式) +4. 【项目指令】(若有 project_instructions) +5. 【可用 Skills】(若有 skills_catalog,稳定块) +6. 【工作区记忆】(若 workspace_memory_enabled,动态块) +7. 提案台账(若有 proposal_ledger,动态块) +8. 【任务阶段】(若有阶段,追加在末尾) +``` + +**顺序设计意图**:稳定块在前、动态块沉底,使 system prompt 的长前缀跨轮保持一致, +以命中 DeepSeek / OpenAI 等服务端的前缀缓存。顺序变化会直接造成缓存失效。 + +### 9.2 task 段选择优先级 + +``` +work_mode === "editor" → 工作区助手(task + 文件编辑纪律 + 工作区信息) +否则 annotation_project_snapshot 非空 → 项目助手(ASSISTANT_TASK_BASE + 项目快照) + 且 agent_mode === "annotation" → 额外追加标注调用纪律 +否则 workspace_root 非空 → 工作区助手 +否则 → 裸 WORKSPACE_ASSIST_TASK +``` + +### 9.3 身份块 + +``` +【你的身份】 +- 你是后端模型 `{model}`(配置名称:{provider_label})。 +- {视觉能力行} +- 你在 LR-Agent 系统内与用户对话、调用工具完成任务;不要把自己说成独立的「LR-Agent 助手」或其它品牌模型。 +``` + +视觉能力行二选一: + +- `supports_vision` 为真:`视觉能力:已通过 API 探针,可调用 read_image_for_vision 并在调用后查看附图。` +- 否则:`视觉能力:未通过探针或未检测。不要声称能分析图片像素;若用户要看图,说明需在「大模型配置」中选用支持视觉的模型并重新检测视觉。` + +`provider_label` 在本地路径**硬编码为 `"local"`**。 + +### 9.4 项目快照格式 + +纯文本行,顺序固定: + +``` +项目 ID: +项目名称: +模态: +标注类型: +标签列表: +- : # 最多 40 条;空则 "- (无)" +可用检测模型(object_detection): # 仅当标注类型 ∈ {bbox, polygon, keypoint, rotated_bbox} +- : (默认) # 最多 12 条;空则 "- (未配置或未传入)" +【标注工具】paths 填 list_workspace_directory 的 relativePath;全部文件才设 all_files=true。 # 仅当类型已知 +``` + +特殊标注类型的额外提示: + +- `polygon` → 「需配置检测模型与 SAM2 分割模型。」 +- `keypoint` → 「需先选择骨架模板并配置关键点模型。」 +- `rotated_bbox` → 「需配置 OBB 检测模型。」 + +### 9.5 硬编码文本清单 + +以下文本是**行为契约主体,必须原样保留**: + +- `VISION_HINT` +- `WORKSPACE_ASSIST_TASK` +- `ASSISTANT_TASK_BASE` +- `FILE_EDIT_GUIDE` +- `ANNOTATION_CALL_GUIDE` +- 标注范围提示与 13 种已知标注类型表 +- 身份块模板、记忆使用纪律模板、skills 使用说明模板 + +> 原文以 `vendor/local-agent/app/agent/context_snapshot.py` 为准(迁移期间)。 +> 迁移完成后原文以 `src/main/agent/context/systemPrompt.ts` 为准。 + +--- + +## 10. 多模态与视觉 + +### 10.1 图片加载优先级 + +1. `image_absolute_path`:`is_file()` 为真则直接 `read_bytes()`(**默认路径,不走 HTTP**) +2. `image_base64`:自动剥离 `data:...,` 前缀 +3. 都没有 → 放弃,退化为纯文本消息 + +### 10.2 缩放与编码 + +- 非 RGB 处理:RGBA / LA / P 用**白色背景**合成;其余 `convert("RGB")` +- 缩放:`scale = min(1.0, max_edge / max(w, h))`,仅在 `scale < 1` 时缩放,等比 +- 编码:JPEG,指定 quality,`optimize=true` +- 输出:`data:image/jpeg;base64,` + +| 配置 | 默认 | +|---|---| +| 附图最长边 | 1280 | +| 附图 JPEG 质量 | 85 | +| 逐框裁剪(视觉映射)最长边 | 768 | +| 逐框裁剪 JPEG 质量 | 85 | + +### 10.3 视觉工具与附图注入 + +`read_image_for_vision` 返回的 JSON 含内部标记 `__vision_image_path__`。调度层: + +1. 从结果中提取绝对路径 +2. 剥离内部标记后作为 `tool_result` 发给前端 +3. **在对应 `ToolMessage` 之后**追加一条多模态 `HumanMessage`,文本固定为 + `【附图】请根据上图回答用户关于该图片的问题。`,并带缩放后的 image_url + +### 10.4 视觉自动兜底 + +触发条件(须全部满足): + +- `round_idx === 0` +- 非 resume +- 本轮无 tool call +- `provider_is_vision` 为真,且视觉工具函数存在 +- 用户文本或 `active_relative_path` 能解析出图片路径(后缀 ∈ + `{.png,.jpg,.jpeg,.gif,.webp,.bmp,.ico}`) +- 本次会话尚未兜底过 + +执行:发 `tool_start` → 执行视觉工具 → 发 `tool_result` → append `ToolMessage` → +若非视觉模型则不再附图。执行后置标记,`continue` 进入下一轮。 + +--- + +## 11. 配置默认值 + +| 配置 | 默认值 | +|---|---| +| 主循环最大工具轮次 | 30 | +| 子代理最大工具轮次 | 12 | +| MCP 工具发现 TTL | 1800 秒 | +| 附图最长边 | 1280 | +| 附图 JPEG 质量 | 85 | +| 单文件读取上限 | 524288 字节(512KB) | +| 单文件读取行数上限 | 2000 | +| 文档提取页数上限 | 30 | +| grep 结果上限 | 100 | +| grep 扫描文件上限 | 1000 | +| 列目录条目上限 | 80 | +| 标注 LLM 温度 | 0.0 | +| 标注准备温度 | 0.1 | +| 生成类标注图片最长边 | 1280 | +| 生成类标注图片 JPEG 质量 | 85 | +| 视觉映射并发度 | 3 | +| 视觉映射是否校验 | true | +| 视觉映射重试次数 | 1 | +| 标签池预检模式 | `"auto"` | +| 标签池预检最小额外标签数 | 2 | +| 标注变更功能开关 | true | + +**代码里硬编码、未走配置的编排参数**(不要漏): + +| 参数 | 值 | +|---|---| +| 主 / 辅助模型 `temperature` | 0.7 | +| 主 / 辅助模型 `timeout` | 120 秒 | +| `streaming` | true | +| 质量报告 compose `temperature` | 0.2 | +| 质量报告 payload 上限 | 512KB | +| 质量报告 payload 截断阈值 | 48k 字符 | +| 质量报告图表路径上限 | 50 条 findings | + +--- + +## 12. 持久化边界 + +**运行时零持久化。** 会话、消息、provider 配置、checkpoint、工作区记忆、标注文档、 +质量报告全部在 Electron 主进程(SQLite + 文件系统)。运行时是**无状态**的:每轮从请求体 +重建全部上下文。 + +历史消息流向: + +``` +SQLite → 渲染层内存 → 请求体(窗口内消息全量序列化)→ 运行时每轮从零重建 +``` + +因此**不存在双写与同步问题**,也没有数据迁移负担。 + +--- + +## 13. 已知不一致(迁移时统一) + +| 项 | 现状 | 处置 | +|---|---|---| +| Skill 脚本可执行性 | 运行时 prompt 声明「仅可阅读源码,不可执行」,但本地 MCP Server 已暴露 `run_agent_skill_script` | 统一为一种口径并在 prompt 中如实说明 | +| `pypdf` / `python-docx` | 被惰性 import 但**未写入 `requirements.txt`**,干净环境会 ImportError | 迁移时随 `read_document_file` 一并修正 | +| `context_updated` / `route_decided` | 类型已声明但无任何发射点 | 迁移时保留类型以免破坏前端,但不实现发射 | +| 事件字段双份兼容 | 渲染层存在 4 处 snake_case / camelCase 双字段 fallback(说明协议历史上漂移过) | 迁移后固化 `to_sse_dict` 那一版 camelCase 契约,删除 fallback | diff --git a/package.json b/package.json index 724631c..401370c 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,11 @@ "start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", - "test": "jest" + "test": "jest", + "baseline:mock": "node scripts/agent-baseline/mockLlmServer.mjs", + "baseline:selftest": "node scripts/agent-baseline/selftest.mjs", + "baseline:capture": "node scripts/agent-baseline/captureSse.mjs", + "baseline:diff": "node scripts/agent-baseline/diffSse.mjs" }, "browserslist": [ "extends browserslist-config-erb" diff --git a/scripts/agent-baseline/README.md b/scripts/agent-baseline/README.md new file mode 100644 index 0000000..6118bef --- /dev/null +++ b/scripts/agent-baseline/README.md @@ -0,0 +1,87 @@ +# Agent 协议行为基线 + +迁移 Agent 编排到 Node 时,**最大的风险是协议静默漂移**:渲染层的 SSE 解析器对畸形分片是 +`catch { // ignore }`,字段名或事件语义对不上不会报错,只会表现为「没有反应」。 + +本目录提供一套不依赖真实模型的复现工具,用同一组输入分别打 Python 与 Node 实现, +把产出归一化后逐事件 diff,作为迁移的硬性验收 gate。 + +## 组成 + +| 文件 | 作用 | +|---|---| +| `scenarios.mjs` | 预置场景:覆盖纯文本、推理增量、只读工具、并行工具、提案新建/编辑、`.lr-agent` 抑制、客户端工具 pending、子代理、`tool_choice="any"` 兜底、多轮 | +| `mockLlmServer.mjs` | OpenAI 兼容的 mock LLM 服务,按场景逐轮返回 canned 响应(含分片 tool_call arguments) | +| `captureSse.mjs` | 对目标 Agent 服务跑完所有场景,抓取并归一化 SSE 事件,落盘 | +| `normalizeSse.mjs` | 归一化:工具调用 id 按出现顺序替换为稳定占位符、对象键排序 | +| `diffSse.mjs` | 逐场景逐事件比较两份抓取结果,有差异则非零码退出 | + +## 使用 + +### 1. 抓取基准(迁移前的 Python 实现) + +```bash +# 起 mock LLM +node scripts/agent-baseline/mockLlmServer.mjs --port 8799 + +# 另一个终端:起 Python 服务(固定端口,便于被抓取) +cd vendor/local-agent +LR_AGENT_LOCAL_PORT=8765 python local_main.py + +# 再一个终端:抓取 +node scripts/agent-baseline/captureSse.mjs \ + --target http://127.0.0.1:8765/api/v1 \ + --label python \ + --out .baseline/python +``` + +> `captureSse.mjs` 会在缺少 `--mock` 时自动拉起 mock LLM 并在结束时关闭; +> 若你已手动起了 mock,传 `--mock http://127.0.0.1:8799` 复用它。 + +### 2. 抓取候选(迁移后的 Node 实现) + +```bash +node scripts/agent-baseline/captureSse.mjs \ + --target http://127.0.0.1:/api/v1 \ + --label node \ + --out .baseline/node +``` + +### 3. 对比 + +```bash +node scripts/agent-baseline/diffSse.mjs \ + --baseline .baseline/python \ + --candidate .baseline/node +``` + +差异存在时退出码为 1,可直接接入 CI。 + +## 归一化规则 + +同一输入在不同实现下,**只有以下字段会被抹平**,其余必须逐字一致: + +- `toolCallId` / `innerToolCallId` → 按首次出现顺序替换为 `` +- `clientToolCalls[].toolCallId` / `toolCalls[].toolCallId` → 同上 +- 对象键顺序 → 排序(不影响语义) + +**不做**归一化的(即必须一致): + +- 事件类型与顺序 +- 事件条数(含收尾的裸 `done` 帧) +- 所有文本内容、`arguments` 字符串、路径 +- 字段的存在性与命名(含 `suggestedRelativePath` / `operation` / `title` 等派生字段) + +## 夹具工作区 + +`captureSse.mjs` 会在 `.baseline/fixture-workspace/` 下创建固定的夹具文件 +(`README.md`、`config/app.json`、`data/`),使只读工具的返回内容可复现。 + +## 注意事项 + +- 场景轮次是**按序消费**的,每次抓取前会 `POST /__mock/reset` 重置游标。若某场景轮次 + 耗尽,mock 会返回 500 并提示重置。 +- 客户端工具(`auto_annotate` / `mutate_annotation` / `start_terminal_command`)不会真的执行; + `captureSse.mjs` 会伪造与渲染层同结构的 `client_tool_results` 触发 resume,从而覆盖 + resume 消息注入路径。 +- 若某个场景在候选侧报错,会写出 `.error.json`;diff 时该场景计为失败。 diff --git a/scripts/agent-baseline/captureSse.mjs b/scripts/agent-baseline/captureSse.mjs new file mode 100644 index 0000000..66a4747 --- /dev/null +++ b/scripts/agent-baseline/captureSse.mjs @@ -0,0 +1,314 @@ +/** + * 阶段 0 行为基线:从 Agent 服务抓取 golden SSE 事件序列。 + * + * 对一个正在运行的 Agent 服务(Python 的 vendor/local-agent,或迁移后的 Node 运行时) + * 逐个场景发起请求,收集并归一化 SSE 事件,落盘为可 diff 的 JSON。 + * + * 用法: + * # 1) 先手动起 mock LLM(或让本脚本自动拉起) + * node scripts/agent-baseline/mockLlmServer.mjs --port 8799 + * + * # 2) 起 Agent 服务(Python 示例) + * cd vendor/local-agent && LR_AGENT_LOCAL_PORT=8765 python local_main.py + * + * # 3) 抓取 + * node scripts/agent-baseline/captureSse.mjs \ + * --target http://127.0.0.1:8765/api/v1 \ + * --token \ + * --out .baseline/python + * + * 参数: + * --target Agent 服务 base URL(含 /api/v1) + * --token Bearer token(Python 直跑时通常不需要;为空则不带头) + * --out 输出目录 + * --only 逗号分隔的场景 id 白名单 + * --mock 已有 mock LLM 的地址;省略则自动拉起并随后关闭 + * --label 写入 meta 的实现标签(如 python / node) + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { SCENARIOS } from './scenarios.mjs'; +import { normalizeEvents, parseSse } from './normalizeSse.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +const argv = process.argv.slice(2); +function argOf(flag, fallback) { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +} + +const TARGET = argOf('--target', 'http://127.0.0.1:8765/api/v1').replace(/\/+$/, ''); +const TOKEN = argOf('--token', ''); +const OUT_DIR = path.resolve(repoRoot, argOf('--out', '.baseline/capture')); +const LABEL = argOf('--label', 'unknown'); +const ONLY = argOf('--only', '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +const MOCK_URL = argOf('--mock', ''); +const MOCK_PORT = Number(argOf('--mock-port', '8799')); +const MOCK_BASE = MOCK_URL || `http://127.0.0.1:${MOCK_PORT}`; + +const WORKSPACE = path.resolve(repoRoot, '.baseline', 'fixture-workspace'); + +// ── 夹具工作区 ──────────────────────────────────────────────── + +function ensureFixtureWorkspace() { + fs.mkdirSync(path.join(WORKSPACE, 'config'), { recursive: true }); + fs.mkdirSync(path.join(WORKSPACE, 'data'), { recursive: true }); + fs.writeFileSync( + path.join(WORKSPACE, 'README.md'), + '# 夹具工作区\n\n用于 Agent 协议基线抓取。\n', + 'utf8', + ); + fs.writeFileSync( + path.join(WORKSPACE, 'config', 'app.json'), + '{\n "timeout": 30,\n "retries": 1\n}\n', + 'utf8', + ); +} + +// ── mock LLM 生命周期 ───────────────────────────────────────── + +let mockProc = null; + +async function waitForMock() { + const deadline = Date.now() + 10_000; + for (;;) { + try { + const res = await fetch(`${MOCK_BASE}/__mock/scenarios`); + if (res.ok) return; + } catch { + // 未就绪 + } + if (Date.now() > deadline) throw new Error(`mock LLM 未就绪: ${MOCK_BASE}`); + await new Promise((r) => setTimeout(r, 200)); + } +} + +async function startMock() { + if (MOCK_URL) { + await waitForMock(); + return; + } + mockProc = spawn( + process.execPath, + [path.join(here, 'mockLlmServer.mjs'), '--port', String(MOCK_PORT)], + { stdio: 'ignore' }, + ); + await waitForMock(); +} + +function stopMock() { + if (mockProc) { + mockProc.kill(); + mockProc = null; + } +} + +// ── 请求构造 ────────────────────────────────────────────────── + +function buildClientContext() { + return { + workspace_root: WORKSPACE, + active_relative_path: 'README.md', + agent_mode: 'annotation', + work_mode: 'annotation', + workspace_memory_enabled: false, + skills_catalog: [], + proposal_states: [], + mcp_servers: [], + selected_annotation_ids: [], + }; +} + +function buildBody(scenarioId, userContent, clientToolResults) { + return { + api_key: 'mock-key', + base_url: `${MOCK_BASE}/scenario/${scenarioId}/v1`, + model: 'mock-model', + supports_vision: false, + messages: [{ role: 'user', content: userContent, message_id: 'm1' }], + user_content: userContent, + client_context: buildClientContext(), + client_tool_results: clientToolResults ?? [], + client_job_id: `job-${scenarioId}`, + }; +} + +/** 伪造客户端工具执行结果(与渲染层 formatClientToolResult 的结构对齐)。 */ +function fakeClientToolResult(call) { + const name = call.name; + if (name === 'auto_annotate') { + return { + ok: true, + tool: name, + status: 'done', + summary: '已生成 1 个文件的标注提案', + file_written: false, + proposal_pending: true, + user_request: String(call.arguments?.user_request ?? ''), + files: ['data/1.jpg'], + }; + } + if (name === 'mutate_annotation') { + return { + ok: true, + tool: name, + status: 'done', + summary: '已生成 1 处标注变更提案', + file_written: false, + proposal_pending: true, + user_request: String(call.arguments?.user_request ?? ''), + files: ['data/1.jpg'], + }; + } + if (name === 'start_terminal_command') { + return { + ok: true, + tool: name, + status: 'done', + summary: '命令已执行', + exit_code: 0, + job_id: 'job-terminal-1', + output_tail: 'ok\n', + }; + } + return { ok: true, tool: name, status: 'done', summary: '客户端工具已执行' }; +} + +async function postStream(body) { + const headers = { 'Content-Type': 'application/json' }; + if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`; + const res = await fetch(`${TARGET}/agent/chat/stream`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${text.slice(0, 400)}`); + } + return res.text(); +} + +/** + * 跑完一个场景:含 tool_pending → resume 的循环。 + * + * @returns {Promise>>} 累积的全部事件 + */ +async function runScenario(scenario) { + const userContent = `【基线场景 ${scenario.id}】请按预定流程处理。`; + const collected = []; + let clientToolResults = []; + + for (let hop = 0; hop < 8; hop += 1) { + const text = await postStream(buildBody(scenario.id, userContent, clientToolResults)); + const events = parseSse(text); + collected.push(...events); + + const pending = events.find((e) => e.type === 'tool_pending'); + if (!pending) break; + + const calls = pending.clientToolCalls ?? pending.toolCalls ?? []; + if (!calls.length) break; + + clientToolResults = calls.map((call) => ({ + tool_call_id: call.toolCallId, + name: call.name, + result: JSON.stringify(fakeClientToolResult(call)), + })); + } + + return collected; +} + +// ── 主流程 ──────────────────────────────────────────────────── + +async function main() { + ensureFixtureWorkspace(); + await startMock(); + + const targets = ONLY.length + ? SCENARIOS.filter((s) => ONLY.includes(s.id)) + : SCENARIOS; + + fs.mkdirSync(OUT_DIR, { recursive: true }); + + // 先确认目标可达。注意 /health 挂在**根路径**,不在 /api/v1 前缀下。 + const origin = new URL(TARGET).origin; + const healthUrl = `${origin}/health`; + try { + const probe = await fetch(healthUrl); + if (!probe.ok) throw new Error(`health ${probe.status}`); + } catch (err) { + throw new Error( + `Agent 服务不可达: ${healthUrl} — ${err instanceof Error ? err.message : err}`, + ); + } + + const results = {}; + for (const scenario of targets) { + // 清理该场景的历史产物,避免上一轮失败留下的 .error.json 造成误读 + fs.rmSync(path.join(OUT_DIR, `${scenario.id}.json`), { force: true }); + fs.rmSync(path.join(OUT_DIR, `${scenario.id}.error.json`), { force: true }); + + // 每个场景前重置 mock 轮次游标 + await fetch(`${MOCK_BASE}/__mock/reset`, { method: 'POST' }); + + try { + const raw = await runScenario(scenario); + const normalized = normalizeEvents(raw); + results[scenario.id] = normalized; + fs.writeFileSync( + path.join(OUT_DIR, `${scenario.id}.json`), + `${JSON.stringify(normalized, null, 2)}\n`, + 'utf8', + ); + console.log(` ✓ ${scenario.id} (${normalized.length} events)`); + } catch (err) { + results[scenario.id] = { error: err instanceof Error ? err.message : String(err) }; + fs.writeFileSync( + path.join(OUT_DIR, `${scenario.id}.error.json`), + `${JSON.stringify(results[scenario.id], null, 2)}\n`, + 'utf8', + ); + console.error(` ✗ ${scenario.id}: ${results[scenario.id].error}`); + } + } + + fs.writeFileSync( + path.join(OUT_DIR, 'meta.json'), + `${JSON.stringify( + { + label: LABEL, + target: TARGET, + capturedAt: new Date().toISOString(), + scenarios: targets.map((s) => s.id), + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const failed = Object.values(results).filter((r) => r && r.error).length; + console.log( + `\n[capture] ${targets.length - failed}/${targets.length} 场景成功 → ${OUT_DIR}`, + ); + if (failed) process.exitCode = 1; +} + +main() + .catch((err) => { + console.error(`[capture] ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; + }) + .finally(() => { + stopMock(); + }); diff --git a/scripts/agent-baseline/diffSse.mjs b/scripts/agent-baseline/diffSse.mjs new file mode 100644 index 0000000..83014cd --- /dev/null +++ b/scripts/agent-baseline/diffSse.mjs @@ -0,0 +1,158 @@ +/** + * 阶段 0 行为基线:golden SSE 序列 diff。 + * + * 比较两份抓取结果(如 `.baseline/python` 与 `.baseline/node`),逐场景逐事件报告差异。 + * 差异存在时以非零码退出,可直接用作迁移的硬性验收 gate。 + * + * 用法: + * node scripts/agent-baseline/diffSse.mjs --baseline .baseline/python --candidate .baseline/node + * + * 参数: + * --baseline 基准目录(迁移前 Python 抓取) + * --candidate 候选目录(迁移后 Node 抓取) + * --only 逗号分隔的场景 id 白名单 + * --max-diffs 每个场景最多展示的差异条数(默认 8) + * --quiet 只输出总结 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +const argv = process.argv.slice(2); +function argOf(flag, fallback) { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +} +const hasFlag = (flag) => argv.includes(flag); + +const BASELINE = path.resolve(repoRoot, argOf('--baseline', '.baseline/python')); +const CANDIDATE = path.resolve(repoRoot, argOf('--candidate', '.baseline/node')); +const MAX_DIFFS = Number(argOf('--max-diffs', '8')); +const QUIET = hasFlag('--quiet'); +const ONLY = argOf('--only', '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +function readScenarios(dir) { + if (!fs.existsSync(dir)) throw new Error(`目录不存在: ${dir}`); + const out = new Map(); + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json') || file === 'meta.json') continue; + if (file.endsWith('.error.json')) continue; + const id = file.replace(/\.json$/, ''); + out.set(id, JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))); + } + return out; +} + +/** 取事件的可读摘要,用于差异上下文。 */ +function brief(event) { + const t = event.type ?? '?'; + if (event.content !== undefined) return `${t} content=${JSON.stringify(String(event.content).slice(0, 40))}`; + if (event.name !== undefined) return `${t} name=${event.name}`; + if (event.summary !== undefined) return `${t} summary=${JSON.stringify(String(event.summary).slice(0, 40))}`; + if (event.stage !== undefined) return `${t} stage=${event.stage}`; + if (event.status !== undefined) return `${t} status=${event.status}`; + return t; +} + +function canonical(event) { + return JSON.stringify(event); +} + +/** 逐字段比较两个事件,返回字段级差异描述。 */ +function fieldDiffs(a, b) { + const diffs = []; + const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]); + for (const key of [...keys].sort()) { + const va = JSON.stringify(a?.[key]); + const vb = JSON.stringify(b?.[key]); + if (va !== vb) diffs.push(`${key}: ${va} → ${vb}`); + } + return diffs; +} + +function diffScenario(id, base, cand) { + if (!Array.isArray(base) || !Array.isArray(cand)) { + return [`非事件数组(baseline=${typeof base}, candidate=${typeof cand})`]; + } + const diffs = []; + const max = Math.max(base.length, cand.length); + + for (let i = 0; i < max; i += 1) { + const a = base[i]; + const b = cand[i]; + if (!a) { + diffs.push(`[${i}] 候选多出事件: ${brief(b)}`); + continue; + } + if (!b) { + diffs.push(`[${i}] 候选缺少事件: ${brief(a)}`); + continue; + } + if (canonical(a) !== canonical(b)) { + const fd = fieldDiffs(a, b); + const head = a.type !== b.type + ? `事件类型不同: ${a.type} → ${b.type}` + : `事件内容不同: ${brief(a)}`; + diffs.push(`[${i}] ${head}\n ${fd.join('\n ')}`); + } + if (diffs.length >= MAX_DIFFS) { + diffs.push('…(已达展示上限)'); + break; + } + } + return diffs; +} + +function main() { + const base = readScenarios(BASELINE); + const cand = readScenarios(CANDIDATE); + + let ids = [...new Set([...base.keys(), ...cand.keys()])].sort(); + if (ONLY.length) ids = ids.filter((id) => ONLY.includes(id)); + + let failed = 0; + let passed = 0; + + for (const id of ids) { + const a = base.get(id); + const b = cand.get(id); + if (!a) { + console.log(`\n✗ ${id} 基准缺失(候选有,基准无)`); + failed += 1; + continue; + } + if (!b) { + console.log(`\n✗ ${id} 候选缺失`); + failed += 1; + continue; + } + const diffs = diffScenario(id, a, b); + if (diffs.length === 0) { + passed += 1; + if (!QUIET) console.log(`✓ ${id} (${a.length} events)`); + } else { + failed += 1; + console.log(`\n✗ ${id} (${a.length} → ${b.length} events)`); + for (const d of diffs) console.log(` ${d}`); + } + } + + console.log(`\n[diff] 通过 ${passed} / 失败 ${failed} / 共 ${ids.length}`); + console.log(` 基准: ${BASELINE}`); + console.log(` 候选: ${CANDIDATE}`); + if (failed) process.exitCode = 1; +} + +try { + main(); +} catch (err) { + console.error(`[diff] ${err instanceof Error ? err.message : err}`); + process.exitCode = 2; +} diff --git a/scripts/agent-baseline/mockLlmServer.mjs b/scripts/agent-baseline/mockLlmServer.mjs new file mode 100644 index 0000000..2f118fa --- /dev/null +++ b/scripts/agent-baseline/mockLlmServer.mjs @@ -0,0 +1,254 @@ +/** + * 阶段 0 行为基线:mock OpenAI 兼容 LLM 服务。 + * + * 用途:让 Agent 运行时(Python 或 Node)在**不依赖真实模型**的情况下跑出可复现的 + * SSE 事件序列,用于 golden diff。 + * + * 用法: + * node scripts/agent-baseline/mockLlmServer.mjs [--port 8799] [--host 127.0.0.1] + * + * 场景选择:把 LLM 的 base_url 指向 + * http://127.0.0.1:/scenario//v1 + * 服务会自行剥掉前缀并按场景消费轮次。 + * + * 辅助端点: + * GET /__mock/scenarios 列出全部场景 id + * POST /__mock/reset 重置所有场景的轮次游标(每次捕获前调用) + * GET /__mock/state 查看各场景已消费轮次 + * + * 支持的响应形态: + * - stream=true → SSE,逐片下发 content / reasoning_content / tool_call arguments + * - stream=false → 普通 chat.completion JSON(tool_choice="any" 的兜底调用走这条) + */ + +import http from 'node:http'; +import { SCENARIOS, SCENARIOS_BY_ID } from './scenarios.mjs'; + +const argv = process.argv.slice(2); +function argOf(flag, fallback) { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +} + +const PORT = Number(argOf('--port', process.env.MOCK_LLM_PORT || '8799')); +const HOST = argOf('--host', '127.0.0.1'); + +/** 正文分片粒度(字符)。 */ +const TEXT_FRAGMENT_SIZE = 8; + +/** 每个场景的轮次游标。 */ +const cursors = new Map(); + +function cursorOf(scenarioId) { + return cursors.get(scenarioId) ?? 0; +} + +/** + * 解析请求路径,取出场景 id。 + * `/scenario//v1/chat/completions` → `` + * 也接受 header `x-mock-scenario` 覆盖。 + */ +function resolveScenarioId(req, url) { + const fromHeader = req.headers['x-mock-scenario']; + if (typeof fromHeader === 'string' && fromHeader.trim()) { + return fromHeader.trim(); + } + const m = /^\/scenario\/([^/]+)\//.exec(url.pathname); + return m ? decodeURIComponent(m[1]) : null; +} + +/** 把字符串切成固定长度的片段。 */ +function slice(text, size) { + if (!text) return []; + if (!size || size <= 0) return [text]; + const out = []; + for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)); + return out; +} + +/** 构造一个流式 chunk。 */ +function chunk(delta, finishReason = null) { + return { + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 0, + model: 'mock-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +function sse(obj) { + return `data: ${JSON.stringify(obj)}\n\n`; +} + +/** + * 把一轮场景轮次展开成流式 delta 序列。 + * + * 顺序:role → reasoning → content → tool_calls(分片)→ finish + */ +function buildDeltas(turn) { + const deltas = [chunk({ role: 'assistant', content: '' })]; + + if (turn.reasoning) { + for (const piece of slice(turn.reasoning, TEXT_FRAGMENT_SIZE)) { + deltas.push(chunk({ reasoning_content: piece })); + } + } + + if (turn.text) { + for (const piece of slice(turn.text, TEXT_FRAGMENT_SIZE)) { + deltas.push(chunk({ content: piece })); + } + } + + const toolCalls = turn.toolCalls ?? []; + toolCalls.forEach((call, index) => { + const argsJson = JSON.stringify(call.args ?? {}); + const pieces = slice(argsJson, call.argFragmentSize ?? 0); + // 首片带 id / type / name + deltas.push( + chunk({ + tool_calls: [ + { + index, + id: call.id, + type: 'function', + function: { name: call.name, arguments: pieces[0] ?? '' }, + }, + ], + }), + ); + for (let i = 1; i < pieces.length; i += 1) { + deltas.push( + chunk({ + tool_calls: [{ index, function: { arguments: pieces[i] } }], + }), + ); + } + }); + + const finishReason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; + deltas.push(chunk({}, finishReason)); + return deltas; +} + +/** 非流式响应体(tool_choice="any" 的 ainvoke 走这里)。 */ +function buildCompletion(turn) { + const message = { role: 'assistant', content: turn.text ?? '' }; + if (turn.reasoning) message.reasoning_content = turn.reasoning; + if (turn.toolCalls?.length) { + message.tool_calls = turn.toolCalls.map((call) => ({ + id: call.id, + type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.args ?? {}) }, + })); + } + return { + id: 'chatcmpl-mock', + object: 'chat.completion', + created: 0, + model: 'mock-model', + choices: [ + { + index: 0, + message, + finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const parts = []; + req.on('data', (c) => parts.push(c)); + req.on('end', () => resolve(Buffer.concat(parts).toString('utf8'))); + req.on('error', reject); + }); +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${HOST}:${PORT}`); + + // ── 辅助端点 ────────────────────────────────────────────── + if (url.pathname === '/__mock/scenarios') { + return sendJson( + res, + 200, + SCENARIOS.map((s) => ({ id: s.id, description: s.description, turns: s.turns.length })), + ); + } + if (url.pathname === '/__mock/reset') { + cursors.clear(); + return sendJson(res, 200, { ok: true }); + } + if (url.pathname === '/__mock/state') { + return sendJson(res, 200, { + cursors: Object.fromEntries(cursors), + }); + } + + // ── 仅处理 chat/completions ─────────────────────────────── + if (!url.pathname.endsWith('/chat/completions')) { + return sendJson(res, 404, { error: { message: `unexpected path ${url.pathname}` } }); + } + + const scenarioId = resolveScenarioId(req, url); + const scenario = scenarioId ? SCENARIOS_BY_ID.get(scenarioId) : null; + if (!scenario) { + return sendJson(res, 400, { + error: { + message: `unknown scenario: ${scenarioId ?? '(none)'}. Use base_url .../scenario//v1`, + }, + }); + } + + const raw = await readBody(req); + let payload = {}; + try { + payload = raw ? JSON.parse(raw) : {}; + } catch { + return sendJson(res, 400, { error: { message: 'invalid json body' } }); + } + + const idx = cursorOf(scenario.id); + const turn = scenario.turns[idx]; + cursors.set(scenario.id, idx + 1); + + if (!turn) { + return sendJson(res, 500, { + error: { + message: `scenario ${scenario.id} exhausted at turn ${idx}; call POST /__mock/reset`, + }, + }); + } + + if (payload.stream === false) { + return sendJson(res, 200, buildCompletion(turn)); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + for (const c of buildDeltas(turn)) res.write(sse(c)); + res.write('data: [DONE]\n\n'); + res.end(); +}); + +server.listen(PORT, HOST, () => { + console.log(`[mock-llm] listening on http://${HOST}:${PORT}`); + console.log(`[mock-llm] base_url 形如 http://${HOST}:${PORT}/scenario//v1`); + console.log(`[mock-llm] scenarios: ${SCENARIOS.map((s) => s.id).join(', ')}`); +}); diff --git a/scripts/agent-baseline/normalizeSse.mjs b/scripts/agent-baseline/normalizeSse.mjs new file mode 100644 index 0000000..bc31b2e --- /dev/null +++ b/scripts/agent-baseline/normalizeSse.mjs @@ -0,0 +1,95 @@ +/** + * 阶段 0 行为基线:SSE 事件序列归一化。 + * + * 抓取到的原始事件里含有每次运行都会变化的字段(工具调用 id 等),直接 diff 会 + * 产生噪声。归一化把易变字段替换为按出现顺序编号的稳定占位符,并规范化对象键顺序, + * 使同一输入在 Python / Node 两种实现下产出**可直接 diff** 的序列。 + * + * 归一化是**有状态**的:同一个 tool_call id 在整条序列中映射到同一个占位符。 + */ + +/** 需要按出现顺序做 id 归一化的字段名。 */ +const ID_FIELDS = ['toolCallId', 'innerToolCallId']; + +/** 递归规范化:对象键排序、数组保序。 */ +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (value && typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = sortDeep(value[key]); + return out; + } + return value; +} + +/** + * 对单个事件做字段级归一化(不含 id 映射,映射由调用方维护)。 + * + * @param {Record} event + * @param {Map} idMap + */ +function normalizeEvent(event, idMap) { + const out = JSON.parse(JSON.stringify(event)); + + const mapId = (raw) => { + if (typeof raw !== 'string' || raw === '') return raw; + if (!idMap.has(raw)) idMap.set(raw, ``); + return idMap.get(raw); + }; + + for (const field of ID_FIELDS) { + if (field in out) out[field] = mapId(out[field]); + } + + // tool_pending 的 clientToolCalls / toolCalls 双写里也含 id + for (const field of ['clientToolCalls', 'toolCalls']) { + if (Array.isArray(out[field])) { + out[field] = out[field].map((call) => ({ + ...call, + toolCallId: mapId(call.toolCallId), + })); + } + } + + return sortDeep(out); +} + +/** + * 归一化一整条事件序列。 + * + * @param {Array>} events 解析后的 SSE 事件对象数组 + * @returns {Array>} + */ +export function normalizeEvents(events) { + const idMap = new Map(); + return events.map((e) => normalizeEvent(e, idMap)); +} + +/** + * 把原始 SSE 文本解析为事件对象数组。 + * + * 只认 `data:` 行,与渲染层解析器保持一致。 + * + * @param {string} text + * @returns {Array>} + */ +export function parseSse(text) { + const events = []; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + try { + events.push(JSON.parse(payload)); + } catch { + // 与渲染层一致:畸形分片忽略 + } + } + return events; +} + +/** 供 diff 使用的稳定序列化(每行一个事件)。 */ +export function serializeEvents(events) { + return events.map((e) => JSON.stringify(e)).join('\n'); +} diff --git a/scripts/agent-baseline/scenarios.mjs b/scripts/agent-baseline/scenarios.mjs new file mode 100644 index 0000000..c95d085 --- /dev/null +++ b/scripts/agent-baseline/scenarios.mjs @@ -0,0 +1,258 @@ +/** + * 阶段 0 行为基线:预置 LLM 场景。 + * + * 每个场景是「按顺序逐个消费的 assistant 轮次」。mock LLM 服务 + * (mockLlmServer.mjs) 对同一场景的每次 /chat/completions 调用消费一轮, + * 因此 resume(客户端工具回传后再跑一轮)自然对应后续轮次。 + * + * 轮次结构: + * { text?, reasoning?, toolCalls?: [{ id, name, args, argFragmentSize? }] } + * + * argFragmentSize 控制 tool_call arguments 的分片粒度,用于驱动增量流式解析 + * (提案拦截器会逐片累积半截 JSON)。设为 0 表示一次性给出完整 args。 + */ + +/** 只读工具调用(SYNC,不会触发 tool_pending)。 */ +const READ_TOOL_TURN = { + text: '我先看一下工作区结构。', + toolCalls: [ + { + id: 'call_read_1', + name: 'list_workspace_directory', + args: { relative_dir: '' }, + argFragmentSize: 4, + }, + ], +}; + +/** + * 覆盖主要协议路径的场景集合。 + * + * 命名即 gateway 路径段:base_url = `${mock}/scenario//v1` + */ +export const SCENARIOS = [ + { + id: 'plain-text', + description: '纯文本对话:只发 text_delta,无工具', + turns: [ + { text: '你好,这是纯文本回复。' }, + ], + }, + + { + id: 'reasoning', + description: '带 reasoning_content:验证 reasoning_delta 与 text_delta 的分离', + turns: [ + { + reasoning: '用户只是打招呼,直接回应即可。', + text: '你好,我是运行在 LR-Agent 内的模型。', + }, + ], + }, + + { + id: 'single-tool', + description: '单只读工具调用:验证 tool_start/tool_result 与 ToolMessage 回灌', + turns: [ + READ_TOOL_TURN, + { text: '工作区已检查完毕。' }, + ], + }, + + { + id: 'parallel-readonly', + description: '同轮多个并行白名单只读工具:验证预取但事件仍串行', + turns: [ + { + text: '我同时看几处。', + toolCalls: [ + { + id: 'call_a', + name: 'read_workspace_file', + args: { relative_path: 'README.md' }, + argFragmentSize: 5, + }, + { + id: 'call_b', + name: 'glob_workspace', + args: { glob_pattern: '**/*.json' }, + argFragmentSize: 5, + }, + ], + }, + { text: '两处都看完了。' }, + ], + }, + + { + id: 'proposal-write', + description: '新建文件提案:验证 file_proposal_start/delta/file_proposal 与增量 args', + turns: [ + { + text: '我来创建报告文件。', + toolCalls: [ + { + id: 'call_write_1', + name: 'write_workspace_file', + // 长内容 + 小分片,专门驱动逐字 diff 卡片 + args: { + relative_path: 'reports/summary.md', + content: + '# 汇总报告\n\n' + + '- 共检查 12 个文件\n' + + '- 发现 3 处待处理项\n\n' + + '## 详情\n\n' + + '第一项需要补充说明。\n' + + '第二项需要复核标注。\n' + + '第三项已完成。\n', + }, + argFragmentSize: 7, + }, + ], + }, + { text: '报告提案已生成,请确认。' }, + ], + }, + + { + id: 'proposal-edit', + description: '局部替换提案:验证 file_edit_delta 的 oldDelta/newDelta', + turns: [ + { + text: '我改一下这段配置。', + toolCalls: [ + { + id: 'call_edit_1', + name: 'str_replace_workspace_file', + args: { + relative_path: 'config/app.json', + old_string: ' "timeout": 30,\n "retries": 1\n', + new_string: ' "timeout": 120,\n "retries": 3\n', + }, + argFragmentSize: 3, + }, + ], + }, + { text: '替换提案已生成。' }, + ], + }, + + { + id: 'proposal-lr-agent-suppressed', + description: '写入 .lr-agent 路径:拦截器应 suppress,不出卡', + turns: [ + { + toolCalls: [ + { + id: 'call_suppressed', + name: 'write_workspace_file', + args: { relative_path: '.lr-agent/scratch.md', content: '内部草稿' }, + argFragmentSize: 4, + }, + ], + }, + { text: '内部草稿已处理。' }, + ], + }, + + { + id: 'client-tool-pending', + description: '客户端工具:验证 tool_pending 结束本轮,以及 resume 后续跑', + turns: [ + { + text: '我来发起自动标注。', + toolCalls: [ + { + id: 'call_annotate_1', + name: 'auto_annotate', + args: { user_request: '标注 data 下所有图片', paths: ['data/'] }, + argFragmentSize: 6, + }, + ], + }, + // 该轮次对应 resume(客户端工具执行完后回传结果) + { text: '标注提案已生成,请检查后 Keep All。' }, + ], + }, + + { + id: 'subagent', + description: '子代理:验证 subagent_* 七类事件与父级 tool_result', + turns: [ + { + toolCalls: [ + { + id: 'call_explore_1', + name: 'explore_readonly', + args: { query: '项目里有哪些配置文件', focus_path: 'config' }, + argFragmentSize: 8, + }, + ], + }, + // 子代理内部的 LLM 调用 + { + text: '我先看 config 目录。', + toolCalls: [ + { + id: 'call_inner_1', + name: 'glob_workspace', + args: { glob_pattern: '*.json', relative_dir: 'config' }, + argFragmentSize: 6, + }, + ], + }, + { text: 'config 下有一个 app.json。' }, + // 父级拿到子代理结论后的收尾 + { text: '查阅完成:配置文件只有 config/app.json。' }, + ], + }, + + { + id: 'tool-choice-any-fallback', + description: '正文写工具伪代码但不真发 tool_call:验证 tool_choice="any" 兜底', + turns: [ + // 第一轮:只给正文(会被强制重试) + { text: '我会调用 auto_annotate(paths=["data/"]) 来完成标注。' }, + // 第二轮:tool_choice="any" 的非流式调用,返回真实 tool_call + { + toolCalls: [ + { + id: 'call_forced_1', + name: 'auto_annotate', + args: { user_request: '标注 data 目录', paths: ['data/'] }, + }, + ], + }, + { text: '已发起标注。' }, + ], + }, + + { + id: 'multiple-rounds', + description: '多轮工具调用:验证轮次推进与消息累积', + turns: [ + { + toolCalls: [ + { + id: 'call_r1', + name: 'describe_client_context', + args: {}, + }, + ], + }, + { + toolCalls: [ + { + id: 'call_r2', + name: 'get_lr_agent_help', + args: { topic: '标注' }, + }, + ], + }, + { text: '两轮工具都完成了。' }, + ], + }, +]; + +/** 按 id 建索引,便于按名取用。 */ +export const SCENARIOS_BY_ID = new Map(SCENARIOS.map((s) => [s.id, s])); diff --git a/scripts/agent-baseline/selftest.mjs b/scripts/agent-baseline/selftest.mjs new file mode 100644 index 0000000..a3152ba --- /dev/null +++ b/scripts/agent-baseline/selftest.mjs @@ -0,0 +1,213 @@ +/** + * 阶段 0 自检:验证 mock LLM 服务的输出契约。 + * + * 不依赖任何 Agent 服务,只验证 mock 本身: + * 1. 每个场景的流式响应都是合法的 OpenAI chunk 序列 + * 2. 分片的 tool_call arguments 能重组为合法 JSON,且与场景定义一致 + * 3. 非流式响应(tool_choice="any" 走这条)结构正确 + * 4. 轮次游标按序推进,reset 生效 + * + * 用法:node scripts/agent-baseline/selftest.mjs [--port 8801] + */ + +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { SCENARIOS } from './scenarios.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const i = argv.indexOf('--port'); +const PORT = i >= 0 && argv[i + 1] ? Number(argv[i + 1]) : 8801; +const BASE = `http://127.0.0.1:${PORT}`; + +let failures = 0; +function check(ok, label, detail = '') { + if (ok) { + console.log(` ✓ ${label}`); + } else { + failures += 1; + console.log(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); + } +} + +async function waitReady() { + const deadline = Date.now() + 10_000; + for (;;) { + try { + const res = await fetch(`${BASE}/__mock/scenarios`); + if (res.ok) return; + } catch { + /* retry */ + } + if (Date.now() > deadline) throw new Error('mock 未就绪'); + await new Promise((r) => setTimeout(r, 150)); + } +} + +/** 解析 SSE 文本为 chunk 对象数组。 */ +function parseChunks(text) { + const out = []; + for (const line of text.split('\n')) { + const t = line.trim(); + if (!t.startsWith('data:')) continue; + const p = t.slice(5).trim(); + if (!p || p === '[DONE]') continue; + out.push(JSON.parse(p)); + } + return out; +} + +/** 把 chunk 序列重组为 { text, reasoning, toolCalls }。 */ +function reassemble(chunks) { + let text = ''; + let reasoning = ''; + const toolCalls = new Map(); + + for (const c of chunks) { + const delta = c.choices?.[0]?.delta ?? {}; + if (typeof delta.content === 'string') text += delta.content; + if (typeof delta.reasoning_content === 'string') reasoning += delta.reasoning_content; + for (const tc of delta.tool_calls ?? []) { + const idx = tc.index ?? 0; + if (!toolCalls.has(idx)) toolCalls.set(idx, { id: '', name: '', args: '' }); + const acc = toolCalls.get(idx); + if (tc.id) acc.id = tc.id; + if (tc.function?.name) acc.name = tc.function.name; + if (typeof tc.function?.arguments === 'string') acc.args += tc.function.arguments; + } + } + + return { + text, + reasoning, + toolCalls: [...toolCalls.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, v]) => v), + }; +} + +async function callStream(scenarioId) { + const res = await fetch(`${BASE}/scenario/${scenarioId}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'mock-model', stream: true, messages: [] }), + }); + return parseChunks(await res.text()); +} + +async function callNonStream(scenarioId) { + const res = await fetch(`${BASE}/scenario/${scenarioId}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'mock-model', stream: false, messages: [] }), + }); + return res.json(); +} + +async function main() { + const proc = spawn(process.execPath, [path.join(here, 'mockLlmServer.mjs'), '--port', String(PORT)], { + stdio: 'ignore', + }); + + try { + await waitReady(); + + console.log('— 流式 chunk 契约 —'); + for (const scenario of SCENARIOS) { + await fetch(`${BASE}/__mock/reset`, { method: 'POST' }); + const chunks = await callStream(scenario.id); + const first = scenario.turns[0]; + + const shapeOk = chunks.every( + (c) => + c.object === 'chat.completion.chunk' && + Array.isArray(c.choices) && + c.choices.length === 1, + ); + check(shapeOk, `${scenario.id}: chunk 结构合法`); + + const finish = chunks[chunks.length - 1]; + const expectedFinish = first.toolCalls?.length ? 'tool_calls' : 'stop'; + check( + finish?.choices?.[0]?.finish_reason === expectedFinish, + `${scenario.id}: finish_reason=${expectedFinish}`, + `实际 ${finish?.choices?.[0]?.finish_reason}`, + ); + + const asm = reassemble(chunks); + check( + asm.text === (first.text ?? ''), + `${scenario.id}: 正文重组一致`, + `期望 ${JSON.stringify((first.text ?? '').slice(0, 30))} 实际 ${JSON.stringify(asm.text.slice(0, 30))}`, + ); + check( + asm.reasoning === (first.reasoning ?? ''), + `${scenario.id}: reasoning 重组一致`, + ); + + const expectedCalls = first.toolCalls ?? []; + check( + asm.toolCalls.length === expectedCalls.length, + `${scenario.id}: tool_call 数量一致`, + `期望 ${expectedCalls.length} 实际 ${asm.toolCalls.length}`, + ); + + for (let n = 0; n < expectedCalls.length; n += 1) { + const exp = expectedCalls[n]; + const got = asm.toolCalls[n]; + let parsedOk = true; + let parsed = null; + try { + parsed = JSON.parse(got.args); + } catch { + parsedOk = false; + } + check( + parsedOk && + got.id === exp.id && + got.name === exp.name && + JSON.stringify(parsed) === JSON.stringify(exp.args), + `${scenario.id}: tool_call[${n}] ${exp.name} 分片 args 重组正确`, + parsedOk ? `args 不一致` : `args 不是合法 JSON: ${got.args.slice(0, 60)}`, + ); + } + } + + console.log('\n— 非流式契约(tool_choice="any" 路径) —'); + await fetch(`${BASE}/__mock/reset`, { method: 'POST' }); + const scenario = SCENARIOS.find((s) => s.id === 'tool-choice-any-fallback'); + // 第一轮是纯正文(会被强制重试),第二轮才是真实 tool_call + await callNonStream(scenario.id); + const completion = await callNonStream(scenario.id); + check(completion.object === 'chat.completion', '非流式返回 chat.completion'); + check( + Array.isArray(completion.choices?.[0]?.message?.tool_calls), + '非流式 message.tool_calls 存在', + ); + check( + completion.choices?.[0]?.message?.tool_calls?.[0]?.function?.name === 'auto_annotate', + '非流式 tool_call 名称正确', + ); + + console.log('\n— 轮次游标 —'); + await fetch(`${BASE}/__mock/reset`, { method: 'POST' }); + const t1 = reassemble(await callStream('multiple-rounds')); + const t2 = reassemble(await callStream('multiple-rounds')); + check(t1.toolCalls[0]?.name === 'describe_client_context', '第 1 轮消费正确'); + check(t2.toolCalls[0]?.name === 'get_lr_agent_help', '第 2 轮消费正确'); + + const state = await (await fetch(`${BASE}/__mock/state`)).json(); + check(state.cursors['multiple-rounds'] === 2, 'state 反映已消费轮次'); + } finally { + proc.kill(); + } + + console.log(failures ? `\n[selftest] 失败 ${failures} 项` : '\n[selftest] 全部通过'); + process.exitCode = failures ? 1 : 0; +} + +main().catch((err) => { + console.error(`[selftest] ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; +}); From 3d030bdce93373a4a2a1d6ddf7e38e2ee6049ce4 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 01:24:19 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat(agent):=20=E6=96=B0=E5=A2=9E=20Node?= =?UTF-8?q?=20Agent=20=E8=BF=90=E8=A1=8C=E6=97=B6=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E4=B8=8E=E4=BC=A0=E8=BE=93=E5=A4=96=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/main/agent:utilityProcess 入口(runtime.ts)、主进程宿主(host.ts)、对称 RPC 桥(bridge.ts)、主进程能力面(ipcSurface.ts)、回环 HTTP 服务(server.ts)与路由表(routes.ts,阶段 1 为 501 骨架)。webpack 增加 agentRuntime 入口。workspace handler 回调体抽为 workspaceOperations.ts,供渲染层 IPC 与运行时 RPC 双端复用,保证授权根校验与 file-system 广播行为一致。serverProcess.ts 增加 LR_AGENT_RUNTIME 开关(默认仍走 Python,Node 侧阶段 2 前只有骨架)。新增 Electron fork 冒烟脚本,已通过真实 utilityProcess 验证 fork/ready/RPC/优雅退出。 Co-authored-by: Cursor --- .erb/configs/webpack.config.main.dev.ts | 2 + .erb/configs/webpack.config.main.prod.ts | 2 + package.json | 3 +- scripts/agent-baseline/electronForkSmoke.cjs | 234 +++++++++++++++++ src/main/agent/bridge.test.ts | 224 ++++++++++++++++ src/main/agent/bridge.ts | 193 ++++++++++++++ src/main/agent/host.ts | 256 +++++++++++++++++++ src/main/agent/ipcSurface.ts | 97 +++++++ src/main/agent/protocol.ts | 101 ++++++++ src/main/agent/routes.ts | 52 ++++ src/main/agent/runtime.ts | 177 +++++++++++++ src/main/agent/server.test.ts | 216 ++++++++++++++++ src/main/agent/server.ts | 244 ++++++++++++++++++ src/main/localAgent/serverProcess.ts | 35 +++ src/main/workspace/workspaceHandlers.ts | 84 ++---- src/main/workspace/workspaceOperations.ts | 105 ++++++++ 16 files changed, 1958 insertions(+), 67 deletions(-) create mode 100644 scripts/agent-baseline/electronForkSmoke.cjs create mode 100644 src/main/agent/bridge.test.ts create mode 100644 src/main/agent/bridge.ts create mode 100644 src/main/agent/host.ts create mode 100644 src/main/agent/ipcSurface.ts create mode 100644 src/main/agent/protocol.ts create mode 100644 src/main/agent/routes.ts create mode 100644 src/main/agent/runtime.ts create mode 100644 src/main/agent/server.test.ts create mode 100644 src/main/agent/server.ts create mode 100644 src/main/workspace/workspaceOperations.ts diff --git a/.erb/configs/webpack.config.main.dev.ts b/.erb/configs/webpack.config.main.dev.ts index 33082d4..878d2f4 100644 --- a/.erb/configs/webpack.config.main.dev.ts +++ b/.erb/configs/webpack.config.main.dev.ts @@ -26,6 +26,8 @@ const configuration: webpack.Configuration = { entry: { main: path.join(webpackPaths.srcMainPath, 'main.ts'), preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), + // Agent 运行时:由主进程 utilityProcess.fork 启动,需独立产物 + agentRuntime: path.join(webpackPaths.srcMainPath, 'agent', 'runtime.ts'), }, output: { diff --git a/.erb/configs/webpack.config.main.prod.ts b/.erb/configs/webpack.config.main.prod.ts index 4727482..3bd4e57 100644 --- a/.erb/configs/webpack.config.main.prod.ts +++ b/.erb/configs/webpack.config.main.prod.ts @@ -25,6 +25,8 @@ const configuration: webpack.Configuration = { entry: { main: path.join(webpackPaths.srcMainPath, 'main.ts'), preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), + // Agent 运行时:由主进程 utilityProcess.fork 启动,需独立产物 + agentRuntime: path.join(webpackPaths.srcMainPath, 'agent', 'runtime.ts'), }, output: { diff --git a/package.json b/package.json index 401370c..fa70afc 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "baseline:mock": "node scripts/agent-baseline/mockLlmServer.mjs", "baseline:selftest": "node scripts/agent-baseline/selftest.mjs", "baseline:capture": "node scripts/agent-baseline/captureSse.mjs", - "baseline:diff": "node scripts/agent-baseline/diffSse.mjs" + "baseline:diff": "node scripts/agent-baseline/diffSse.mjs", + "baseline:fork-smoke": "electron scripts/agent-baseline/electronForkSmoke.cjs" }, "browserslist": [ "extends browserslist-config-erb" diff --git a/scripts/agent-baseline/electronForkSmoke.cjs b/scripts/agent-baseline/electronForkSmoke.cjs new file mode 100644 index 0000000..2884eb2 --- /dev/null +++ b/scripts/agent-baseline/electronForkSmoke.cjs @@ -0,0 +1,234 @@ +/** + * 阶段 1 验收:真实 Electron `utilityProcess.fork` + RPC 冒烟。 + * + * 单元测试用内存端点验证了桥的逻辑,但没验证 Electron 的真实 fork 语义: + * - `utilityProcess.fork` 能否加载 webpack 产出的 UMD bundle + * - `stdio: 'pipe'` 是否可用、stdout 能否被主进程读到 + * - `child.on('message')` 收到的是裸对象还是包裹结构 + * - 子进程 `process.parentPort` 双向 postMessage 是否对称 + * - 端口绑定 0 后实际端口能否回报 + * - `shutdown` 消息能否让子进程优雅退出且退出码为 0 + * + * 这些是 jest(Electron 全 mock)覆盖不到的部分。 + * + * 用法: + * npx electron scripts/agent-baseline/electronForkSmoke.js + * + * 退出码 0 表示全部通过。 + */ + +const path = require('node:path'); +const http = require('node:http'); +const { app, utilityProcess } = require('electron'); + +const ENTRY = path.resolve( + __dirname, + '..', + '..', + '.erb', + 'dll', + 'agentRuntime.bundle.dev.js', +); +const TOKEN = 'smoke-token-abcdef'; + +let failures = 0; +function check(ok, label, detail = '') { + if (ok) { + console.log(` OK ${label}`); + } else { + failures += 1; + console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`); + } +} + +function get(port, urlPath, headers, method = 'GET') { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: urlPath, headers, method, timeout: 5000 }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('timeout')); + }); + req.end(); + }); +} + +/** 主进程侧的最小 RPC 客户端(与 bridge.ts 的线格式一致)。 */ +function makeRpc(child) { + const pending = new Map(); + let nextId = 1; + + child.on('message', (message) => { + if (!message || typeof message !== 'object') return; + if (message.type === 'rpc:response') { + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + clearTimeout(entry.timer); + if (message.ok) entry.resolve(message.result); + else entry.reject(new Error(message.error)); + } + }); + + return { + call(channel, payload) { + const id = `h${nextId}`; + nextId += 1; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`rpc timeout on ${channel}`)), + 5000, + ); + pending.set(id, { resolve, reject, timer }); + child.postMessage({ type: 'rpc:request', id, channel, payload }); + }); + }, + }; +} + +function waitForReady(child, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('ready timeout')), timeoutMs); + child.on('message', (m) => { + if (m && m.type === 'ready') { + clearTimeout(timer); + resolve(m.port); + } + }); + child.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`exited during startup: ${code}`)); + }); + }); +} + +const stdoutChunks = []; + +app.whenReady().then(async () => { + console.log(`[smoke] 入口: ${ENTRY}`); + console.log('— fork 与就绪 —'); + + const child = utilityProcess.fork(ENTRY, [], { + serviceName: 'lr-agent-runtime-smoke', + stdio: 'pipe', + env: { + ...process.env, + LR_AGENT_LOCAL_TOKEN: TOKEN, + LR_AGENT_LOCAL_PORT: '0', + }, + }); + + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (c) => stdoutChunks.push(c)); + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (c) => stdoutChunks.push(c)); + + let port = 0; + try { + port = await waitForReady(child, 15000); + check(port > 0, `收到 ready 消息并拿到端口 (${port})`); + } catch (err) { + check(false, '收到 ready 消息', err.message); + console.log(stdoutChunks.join('')); + child.kill(); + app.exit(1); + return; + } + + check( + child.pid !== undefined && child.pid > 0, + '子进程有独立 pid(独立进程而非线程)', + `pid=${child.pid}`, + ); + + // ── HTTP 层 ────────────────────────────────────────────── + console.log('\n— HTTP 层 —'); + try { + const health = await get(port, '/health'); + check(health.status === 200, '/health 免鉴权返回 200', `got ${health.status}`); + check( + JSON.parse(health.body).status === 'ok', + '/health 响应体正确', + health.body, + ); + } catch (err) { + check(false, '/health 可达', err.message); + } + + try { + const noAuth = await get(port, '/api/v1/agent/chat/stream'); + check(noAuth.status === 401, '无 token 访问业务路由返回 401', `got ${noAuth.status}`); + } catch (err) { + check(false, '无 token 访问业务路由', err.message); + } + + try { + // /chat/cancel 是 POST 路由;用 POST 才能到达骨架并返回 501 + const withAuth = await get( + port, + '/api/v1/agent/chat/cancel', + { Authorization: `Bearer ${TOKEN}` }, + 'POST', + ); + check( + withAuth.status === 501, + '带 token 通过鉴权并到达路由骨架 (501)', + `got ${withAuth.status}`, + ); + } catch (err) { + check(false, '带 token 访问业务路由', err.message); + } + + // ── RPC 层 ─────────────────────────────────────────────── + console.log('\n— RPC 层 —'); + const rpc = makeRpc(child); + + try { + const pong = await rpc.call('ping', {}); + check(pong && pong.ok === true, '运行时响应 ping(消息通道双向可用)'); + } catch (err) { + check(false, '运行时响应 ping', err.message); + } + + try { + await rpc.call('nonexistent:channel', {}); + check(false, '未注册通道应返回结构化错误'); + } catch (err) { + check( + /unknown channel/.test(err.message), + '未注册通道返回结构化错误(异常未穿越进程边界)', + err.message, + ); + } + + // ── 优雅退出 ───────────────────────────────────────────── + console.log('\n— 优雅退出 —'); + const exitCode = await new Promise((resolve) => { + child.once('exit', (code) => resolve(code)); + child.postMessage({ type: 'shutdown' }); + setTimeout(() => { + child.kill(); + resolve('killed'); + }, 5000); + }); + check(exitCode === 0, 'shutdown 消息触发退出且退出码为 0', `exit=${exitCode}`); + + console.log(failures ? `\n[smoke] 失败 ${failures} 项` : '\n[smoke] 全部通过'); + app.exit(failures ? 1 : 0); +}); + +app.on('window-all-closed', () => { + /* 不创建窗口,避免 Electron 在无窗口时提前退出 */ +}); diff --git a/src/main/agent/bridge.test.ts b/src/main/agent/bridge.test.ts new file mode 100644 index 0000000..2572ff4 --- /dev/null +++ b/src/main/agent/bridge.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { createBridge, RpcError, type MessageEndpoint } from './bridge'; + +/** + * 构造一对互连的内存端点,模拟 utilityProcess 与主进程之间的双向通道。 + * 消息经 microtask 投递,贴近真实 IPC 的异步语义。 + */ +function makePair(): [MessageEndpoint, MessageEndpoint] { + const listenersA = new Set<(m: unknown) => void>(); + const listenersB = new Set<(m: unknown) => void>(); + + const deliver = ( + listeners: Set<(m: unknown) => void>, + message: unknown, + ): void => { + const snapshot = [...listeners]; + queueMicrotask(() => { + for (const listener of snapshot) listener(message); + }); + }; + + const a: MessageEndpoint = { + postMessage: (m) => deliver(listenersB, m), + onMessage: (handler) => { + listenersA.add(handler); + return () => listenersA.delete(handler); + }, + }; + const b: MessageEndpoint = { + postMessage: (m) => deliver(listenersA, m), + onMessage: (handler) => { + listenersB.add(handler); + return () => listenersB.delete(handler); + }, + }; + return [a, b]; +} + +describe('agent RPC bridge', () => { + it('主进程 → 运行时 的单向调用', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ + endpoint: hostSide, + side: 'h', + handlers: { ping: () => ({ ok: true }) }, + }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { + 'image:crop': (payload: { boxes: number[] }) => ({ + count: payload.boxes.length, + }), + }, + }); + + await expect(host.call('image:crop', { boxes: [1, 2, 3] })).resolves.toEqual({ + count: 3, + }); + + host.dispose(); + runtime.dispose(); + }); + + it('双向对称调用可同时进行', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ + endpoint: hostSide, + side: 'h', + handlers: { 'host:whoami': () => 'host' }, + }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { 'runtime:whoami': () => 'runtime' }, + }); + + const [fromRuntime, fromHost] = await Promise.all([ + runtime.call('host:whoami'), + host.call('runtime:whoami'), + ]); + + expect(fromRuntime).toBe('host'); + expect(fromHost).toBe('runtime'); + + host.dispose(); + runtime.dispose(); + }); + + it('id 带侧别前缀,两侧并发调用互不串号', async () => { + const [hostSide, runtimeSide] = makePair(); + const ids: string[] = []; + const host = createBridge({ + endpoint: hostSide, + side: 'h', + handlers: { echo: () => 'ok' }, + }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { + echo: (payload: { id: string }) => { + ids.push(payload.id); + return payload.id; + }, + }, + }); + + const [a, b] = await Promise.all([ + host.call('echo', { id: 'A' }), + host.call('echo', { id: 'B' }), + ]); + + expect(new Set([a, b])).toEqual(new Set(['A', 'B'])); + expect(ids).toHaveLength(2); + + host.dispose(); + runtime.dispose(); + }); + + it('handler 抛异常时调用方收到带通道名的 RpcError', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ endpoint: hostSide, side: 'h' }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { + boom: () => { + throw new Error('内部错误'); + }, + }, + }); + + await expect(host.call('boom')).rejects.toThrow(/内部错误/); + await expect(host.call('boom')).rejects.toBeInstanceOf(RpcError); + + host.dispose(); + runtime.dispose(); + }); + + it('未知通道被拒绝且不挂起', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ endpoint: hostSide, side: 'h' }); + const runtime = createBridge({ endpoint: runtimeSide, side: 'r' }); + + await expect(host.call('nope')).rejects.toThrow(/unknown channel/); + + host.dispose(); + runtime.dispose(); + }); + + it('超时后拒绝并清理 pending', async () => { + const [hostSide, runtimeSide] = makePair(); + // 运行时侧不注册任何 handler → 但为触发超时需吞掉消息;用一个空 handler 表 + const host = createBridge({ endpoint: hostSide, side: 'h', timeoutMs: 30 }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { slow: () => new Promise(() => {}) }, + }); + + await expect(host.call('slow')).rejects.toThrow(/timeout after 30ms/); + expect(host.pendingCount).toBe(0); + + host.dispose(); + runtime.dispose(); + }); + + it('dispose 会拒绝全部 pending 并阻止后续调用', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ endpoint: hostSide, side: 'h' }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { hang: () => new Promise(() => {}) }, + }); + + const inflight = host.call('hang'); + expect(host.pendingCount).toBe(1); + host.dispose('测试关闭'); + + await expect(inflight).rejects.toThrow(/测试关闭/); + await expect(host.call('hang')).rejects.toThrow(/bridge disposed/); + + runtime.dispose(); + }); + + it('handle 可动态增补通道', async () => { + const [hostSide, runtimeSide] = makePair(); + const host = createBridge({ endpoint: hostSide, side: 'h' }); + const runtime = createBridge({ endpoint: runtimeSide, side: 'r' }); + + await expect(host.call('late')).rejects.toThrow(/unknown channel/); + + runtime.handle('late', () => 'added'); + await expect(host.call('late')).resolves.toBe('added'); + + host.dispose(); + runtime.dispose(); + }); + + it('未注册通道在响应方告警,且不影响其它通道', async () => { + const [hostSide, runtimeSide] = makePair(); + // 未注册通道的告警由**响应方**记录(发起方只收到结构化错误) + const runtimeLog = jest.fn(); + const host = createBridge({ endpoint: hostSide, side: 'h' }); + const runtime = createBridge({ + endpoint: runtimeSide, + side: 'r', + handlers: { ok: () => 1 }, + onLog: runtimeLog, + }); + + await expect(host.call('missing')).rejects.toThrow(/unknown channel/); + await expect(host.call('ok')).resolves.toBe(1); + expect(runtimeLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('missing'), + ); + + host.dispose(); + runtime.dispose(); + }); +}); diff --git a/src/main/agent/bridge.ts b/src/main/agent/bridge.ts new file mode 100644 index 0000000..6ac8f08 --- /dev/null +++ b/src/main/agent/bridge.ts @@ -0,0 +1,193 @@ +/** + * 主进程 ↔ Agent 运行时之间的对称 RPC 桥。 + * + * 两侧各自持有一个 Bridge 实例,底层是一条双向消息通道(主进程侧是 + * `utilityProcess` 的 `postMessage`/`message` 事件,运行时侧是 `process.parentPort`)。 + * + * 职责: + * - 把本地 `call(channel, payload)` 编码成 `rpc:request`,并等待配对的 `rpc:response` + * - 收到对端 `rpc:request` 时分派到本地注册的 handler,并把结果编码成 `rpc:response` + * - 请求超时、未知通道、handler 抛异常都转成**结构化错误**,不让异常穿过消息边界 + * - 进程退出时拒绝全部 pending,避免调用方永久挂起 + */ + +import { + AGENT_CHANNELS, + type AgentLogLevel, + type AgentMessage, +} from './protocol'; + +/** 底层双向通道的抽象,屏蔽 utilityProcess 与 parentPort 的 API 差异。 */ +export interface MessageEndpoint { + postMessage(message: unknown): void; + /** 注册消息处理器,返回取消订阅函数。 */ + onMessage(handler: (message: unknown) => void): () => void; +} + +export type RpcHandler = (payload: any) => unknown | Promise; +export type RpcHandlers = Record; + +export interface BridgeOptions { + endpoint: MessageEndpoint; + /** 本侧 id 前缀,主进程用 `h`,运行时用 `r`。 */ + side: 'h' | 'r'; + /** 初始 handler 表;后续可用 `handle()` 增补。 */ + handlers?: RpcHandlers; + /** 单次 RPC 超时(毫秒)。默认 30s。 */ + timeoutMs?: number; + onLog?: (level: AgentLogLevel, message: string) => void; +} + +export interface Bridge { + call(channel: string, payload?: unknown): Promise; + /** 注册(或覆盖)一个通道的处理器。 */ + handle(channel: string, handler: RpcHandler): void; + /** 批量注册,已存在的同名通道会被覆盖。 */ + handleAll(handlers: RpcHandlers): void; + /** 主动断开:拒绝全部 pending 并停止处理后续消息。 */ + dispose(reason?: string): void; + /** 是否有未完成的调用(用于优雅退出前等待)。 */ + readonly pendingCount: number; +} + +interface PendingCall { + channel: string; + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +export class RpcError extends Error { + readonly channel: string; + + constructor(channel: string, message: string) { + super(`[rpc:${channel}] ${message}`); + this.name = 'RpcError'; + this.channel = channel; + } +} + +export function createBridge(options: BridgeOptions): Bridge { + const { endpoint, side, timeoutMs = 30_000 } = options; + const handlers: RpcHandlers = { ...(options.handlers ?? {}) }; + const pending = new Map(); + let nextId = 1; + let disposed = false; + + const log = (level: AgentLogLevel, message: string): void => { + options.onLog?.(level, message); + }; + + const unsubscribe = endpoint.onMessage((raw) => { + if (disposed) return; + if (!raw || typeof raw !== 'object') return; + const msg = raw as AgentMessage; + + if (msg.type === 'rpc:response') { + const entry = pending.get(msg.id); + if (!entry) return; + pending.delete(msg.id); + clearTimeout(entry.timer); + if (msg.ok) { + entry.resolve(msg.result); + } else { + entry.reject(new RpcError(entry.channel, msg.error)); + } + return; + } + + if (msg.type === 'rpc:request') { + void dispatchRequest(msg.id, msg.channel, msg.payload); + } + }); + + async function dispatchRequest( + id: string, + channel: string, + payload: unknown, + ): Promise { + const handler = handlers[channel]; + if (!handler) { + log('warn', `未注册的 RPC 通道: ${channel}`); + reply(id, false, undefined, `unknown channel: ${channel}`); + return; + } + try { + const result = await handler(payload); + reply(id, true, result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log('warn', `RPC 通道 ${channel} 处理失败: ${message}`); + reply(id, false, undefined, message); + } + } + + function reply( + id: string, + ok: boolean, + result?: unknown, + error?: string, + ): void { + if (disposed) return; + const msg: AgentMessage = ok + ? { type: 'rpc:response', id, ok: true, result } + : { type: 'rpc:response', id, ok: false, error: error ?? 'unknown error' }; + endpoint.postMessage(msg); + } + + function call(channel: string, payload?: unknown): Promise { + if (disposed) { + return Promise.reject(new RpcError(channel, 'bridge disposed')); + } + const id = `${side}${nextId}`; + nextId += 1; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new RpcError(channel, `timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + pending.set(id, { + channel, + resolve: resolve as (value: unknown) => void, + reject, + timer, + }); + + const msg: AgentMessage = { type: 'rpc:request', id, channel, payload }; + endpoint.postMessage(msg); + }); + } + + function dispose(reason = 'bridge disposed'): void { + if (disposed) return; + disposed = true; + unsubscribe(); + for (const [id, entry] of pending) { + clearTimeout(entry.timer); + entry.reject(new RpcError(entry.channel, reason)); + pending.delete(id); + } + } + + return { + call, + handle(channel: string, handler: RpcHandler): void { + handlers[channel] = handler; + }, + handleAll(next: RpcHandlers): void { + Object.assign(handlers, next); + }, + dispose, + get pendingCount(): number { + return pending.size; + }, + }; +} + +/** 便捷封装:`ping` 探针,用于冒烟验证桥连通性。 */ +export async function pingBridge(bridge: Bridge): Promise { + const result = await bridge.call<{ ok?: boolean }>(AGENT_CHANNELS.ping, {}); + return result?.ok === true; +} diff --git a/src/main/agent/host.ts b/src/main/agent/host.ts new file mode 100644 index 0000000..5d189d3 --- /dev/null +++ b/src/main/agent/host.ts @@ -0,0 +1,256 @@ +/** + * Agent 运行时的主进程宿主。 + * + * 职责(与 `localAgent/serverProcess.ts` 的 Python 版语义对齐,便于原地替换): + * - `utilityProcess.fork` 启动 `agentRuntime.js`(必须在 `app.ready` 之后) + * - 生成随机 token、绑定随机端口、等待 `ready` 消息 + * - 通过 `localAgent:status` 向渲染层推送 starting/running/stopped/error + * - 桥接运行时的 RPC 请求到主进程能力(nativeImage、文件写入等) + * - 崩溃隔离:运行时退出只导致自身进程消失,主进程存活并给出状态提示 + * - `before-quit` 时显式关闭 + * + * 与 Python 版的关键差异: + * - 不再需要 health 轮询(`ready` 消息即就绪信号),也无解释器发现 + * - 但仍保留随机端口 + Bearer token,使渲染层契约不变 + */ + +import { randomBytes } from 'crypto'; +import path from 'path'; +import { app, BrowserWindow, utilityProcess } from 'electron'; +import type { UtilityProcess } from 'electron'; +import type { LocalAgentServiceStatus } from '../../shared/envTypes'; +import { createBridge, type Bridge, type MessageEndpoint } from './bridge'; +import { createMainRpcHandlers } from './ipcSurface'; +import { + AGENT_DEBUG_ENV, + AGENT_TOKEN_ENV, + isAgentMessage, + type AgentLogLevel, +} from './protocol'; + +/** 等待运行时上报 ready 的超时。 */ +const READY_TIMEOUT_MS = 20_000; + +interface RuntimeRefs { + child: UtilityProcess; + bridge: Bridge; + port: number; + token: string; +} + +let refs: RuntimeRefs | null = null; +let starting: Promise | null = null; +let stopping = false; + +function pushStatus(status: LocalAgentServiceStatus): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('localAgent:status', status); + } +} + +/** + * 解析运行时产物路径。 + * + * 开发模式下主 bundle 为 `main.bundle.dev.js`,运行时 bundle 同名后缀; + * 生产模式为 `main.js` / `agentRuntime.js`。webpack 配置里 `__dirname` 保持 + * 运行时的真实值(`node: { __dirname: false }`),因此可直接用其定位同目录产物。 + */ +export function resolveRuntimeEntry(): string { + const mainFile = path.basename(__filename); + const suffix = mainFile.startsWith('main') ? mainFile.slice('main'.length) : '.js'; + return path.join(__dirname, `agentRuntime${suffix}`); +} + +function forwardStream( + stream: NodeJS.ReadableStream | null, + level: AgentLogLevel, +): void { + if (!stream) return; + let buffer = ''; + stream.setEncoding?.('utf8'); + stream.on('data', (chunk: string) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trimEnd(); + if (!trimmed) continue; + if (level === 'error') console.error(trimmed); + else console.log(trimmed); + } + }); +} + +/** 启动 Agent 运行时,返回 API base URL(含 /api/v1)。 */ +export async function startAgentRuntime(): Promise { + if (refs) return `http://127.0.0.1:${refs.port}/api/v1`; + if (starting) return starting; + + starting = (async () => { + const entry = resolveRuntimeEntry(); + const token = randomBytes(32).toString('hex'); + + pushStatus({ state: 'starting', baseUrl: null }); + + const child = utilityProcess.fork(entry, [], { + serviceName: 'lr-agent-runtime', + stdio: 'pipe', + env: { + ...process.env, + [AGENT_TOKEN_ENV]: token, + // 端口 0:由系统分配,运行时把实际端口回报,避免主进程预分配时的竞态 + LR_AGENT_LOCAL_PORT: '0', + [AGENT_DEBUG_ENV]: app.isPackaged ? '0' : '1', + }, + }); + + forwardStream(child.stdout, 'info'); + forwardStream(child.stderr, 'error'); + + const bridge = createBridge({ + endpoint: makeEndpoint(child), + side: 'h', + handlers: createMainRpcHandlers(), + onLog: (level, message) => { + if (level === 'error') console.error('[agentRuntime/rpc]', message); + else console.warn('[agentRuntime/rpc]', message); + }, + }); + + try { + const port = await waitForReady(child, READY_TIMEOUT_MS); + refs = { child, bridge, port, token }; + + child.on('exit', (code) => { + const wasStopping = stopping; + stopping = false; + bridge.dispose('runtime exited'); + if (refs?.child === child) refs = null; + pushStatus({ + state: 'stopped', + baseUrl: null, + message: wasStopping ? undefined : `Agent 运行时已退出 (code ${code})`, + }); + }); + + const baseUrl = `http://127.0.0.1:${port}/api/v1`; + console.log(`[agentRuntime] 已启动于 ${baseUrl}`); + pushStatus({ state: 'running', baseUrl }); + return baseUrl; + } catch (err) { + bridge.dispose('startup failed'); + child.kill(); + pushStatus({ + state: 'error', + baseUrl: null, + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + })(); + + try { + return await starting; + } finally { + starting = null; + } +} + +function makeEndpoint(child: UtilityProcess): MessageEndpoint { + // 运行时的 `shutdown` 是「先优雅关闭、超时再 kill」,期间仍可能有 RPC 回包。 + // 用显式标志而不是探测方法存在性来判断通道是否还可用。 + let closed = false; + child.once('exit', () => { + closed = true; + }); + + return { + postMessage: (message) => { + if (closed) return; + try { + child.postMessage(message); + } catch { + // 进程可能刚好退出,静默丢弃:调用方由 RPC 超时兜底 + closed = true; + } + }, + onMessage: (handler) => { + const listener = (message: unknown): void => { + if (isAgentMessage(message)) handler(message); + }; + child.on('message', listener); + return () => { + child.off('message', listener); + }; + }, + }; +} + +function waitForReady(child: UtilityProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Agent 运行时启动超时(${timeoutMs}ms)`)); + }, timeoutMs); + + const onMessage = (message: unknown): void => { + if (!isAgentMessage(message)) return; + if (message.type === 'ready') { + cleanup(); + resolve(message.port); + } + }; + + const onExit = (code: number): void => { + cleanup(); + reject(new Error(`Agent 运行时启动期间退出 (code ${code})`)); + }; + + function cleanup(): void { + clearTimeout(timer); + child.off('message', onMessage); + child.off('exit', onExit); + } + + child.on('message', onMessage); + child.on('exit', onExit); + }); +} + +/** 停止运行时。幂等。 */ +export function stopAgentRuntime(): void { + if (!refs) return; + stopping = true; + const { child, bridge } = refs; + refs = null; + try { + child.postMessage({ type: 'shutdown' }); + } catch { + /* 通道可能已断 */ + } + bridge.dispose('stopping'); + // 给运行时 1.5s 优雅关闭,超时强杀 + const target = child; + setTimeout(() => { + try { + target.kill(); + } catch { + /* 已退出 */ + } + }, 1500).unref?.(); +} + +/** 当前 base URL;未启动返回 null。 */ +export function getAgentRuntimeBaseUrl(): string | null { + return refs ? `http://127.0.0.1:${refs.port}/api/v1` : null; +} + +/** 当前访问 token;未启动返回 null。 */ +export function getAgentRuntimeToken(): string | null { + return refs?.token ?? null; +} + +/** 供自检使用:桥是否已连通。 */ +export function getAgentRuntimeBridge(): Bridge | null { + return refs?.bridge ?? null; +} diff --git a/src/main/agent/ipcSurface.ts b/src/main/agent/ipcSurface.ts new file mode 100644 index 0000000..dc58b7c --- /dev/null +++ b/src/main/agent/ipcSurface.ts @@ -0,0 +1,97 @@ +/** + * 主进程侧暴露给 Agent 运行时的能力实现。 + * + * 这里的每个 handler 都在**主进程**执行,是运行时唯一能触达 Electron 能力的入口 + * (运行时是 utilityProcess,无法直接使用 nativeImage / BrowserWindow 等)。 + * + * 实现原则:**能复用就复用**。文件写入直接调 `workspaceOperations.ts`,与渲染层 + * IPC 走同一段代码,因此授权根校验、路径约束与 `file-system:changed` 广播行为一致。 + * + * 后续阶段会补充:图像处理(nativeImage)、标注文档读写、记忆、skills、终端。 + */ + +import { AGENT_CHANNELS, type AgentChannel } from './protocol'; +import { RpcError, type RpcHandlers } from './bridge'; +import { + deleteWorkspaceTextFile, + moveWorkspaceTextFile, + readWorkspaceTextFile, + writeWorkspaceTextFile, +} from '../workspace/workspaceOperations'; + +/** 断言 payload 是对象,避免 undefined 解构崩溃。 */ +function asRecord(payload: unknown, channel: string): Record { + if (!payload || typeof payload !== 'object') { + throw new RpcError(channel, 'payload 必须是对象'); + } + return payload as Record; +} + +function requireString( + obj: Record, + key: string, + channel: string, +): string { + const value = obj[key]; + if (typeof value !== 'string') { + throw new RpcError(channel, `字段 ${key} 必须是字符串`); + } + return value; +} + +/** + * 构造主进程侧 handler 表。 + * + * 只注册当前阶段已实现的能力;未注册的通道由 bridge 统一回 + * `unknown channel` 错误,便于快速定位漏实现的接缝。 + */ +export function createMainRpcHandlers(): RpcHandlers { + const handlers: RpcHandlers = {}; + + handlers[AGENT_CHANNELS.ping] = () => ({ ok: true, ts: Date.now() }); + + handlers[AGENT_CHANNELS.workspaceWriteTextFile] = (payload) => { + const ch = AGENT_CHANNELS.workspaceWriteTextFile; + const obj = asRecord(payload, ch); + return writeWorkspaceTextFile({ + rootDir: requireString(obj, 'rootDir', ch), + relativePath: requireString(obj, 'relativePath', ch), + content: typeof obj.content === 'string' ? obj.content : '', + }); + }; + + handlers[AGENT_CHANNELS.workspaceReadTextFile] = (payload) => { + const ch = AGENT_CHANNELS.workspaceReadTextFile; + const obj = asRecord(payload, ch); + return readWorkspaceTextFile({ + rootDir: requireString(obj, 'rootDir', ch), + relativePath: requireString(obj, 'relativePath', ch), + }); + }; + + handlers[AGENT_CHANNELS.workspaceDeleteTextFile] = (payload) => { + const ch = AGENT_CHANNELS.workspaceDeleteTextFile; + const obj = asRecord(payload, ch); + return deleteWorkspaceTextFile({ + rootDir: requireString(obj, 'rootDir', ch), + relativePath: requireString(obj, 'relativePath', ch), + }); + }; + + handlers[AGENT_CHANNELS.workspaceMoveTextFile] = (payload) => { + const ch = AGENT_CHANNELS.workspaceMoveTextFile; + const obj = asRecord(payload, ch); + return moveWorkspaceTextFile({ + rootDir: requireString(obj, 'rootDir', ch), + relativePath: requireString(obj, 'relativePath', ch), + newRelativePath: requireString(obj, 'newRelativePath', ch), + }); + }; + + return handlers; +} + +/** 已实现通道清单,供热重载/自检时对照。 */ +export function listImplementedChannels(): AgentChannel[] { + return Object.keys(createMainRpcHandlers()) as AgentChannel[]; +} diff --git a/src/main/agent/protocol.ts b/src/main/agent/protocol.ts new file mode 100644 index 0000000..0ae7212 --- /dev/null +++ b/src/main/agent/protocol.ts @@ -0,0 +1,101 @@ +/** + * Agent 运行时(utilityProcess)与主进程之间的消息协议。 + * + * 设计要点: + * - **对称 RPC**:两侧都可以发起请求、都可以提供服务。运行时需要主进程的能力 + * (nativeImage 图像处理、文件写入、标注文档读写等),主进程也需要运行时 + * (后续阶段的生命周期管理)。 + * - id 带侧别前缀(`h` / `r`),使两侧各自维护的 pending 表互不干扰,日志也可读。 + * - 除 RPC 外只有三类控制消息:`ready` / `log` / `shutdown`。 + * + * 该协议是**内部协议**,与面向渲染层的 SSE 契约无关。渲染层契约见 + * `docs/agent-protocol.md`。 + */ + +/** webpack entry 名,产物为 `dist/main/agentRuntime.js`。 */ +export const AGENT_RUNTIME_ENTRY = 'agentRuntime'; + +/** 通过 env 注入运行时的 token 环境变量名(与 Python 版保持一致)。 */ +export const AGENT_TOKEN_ENV = 'LR_AGENT_LOCAL_TOKEN'; + +/** 通过 env 覆盖运行时日志开关。 */ +export const AGENT_DEBUG_ENV = 'LR_AGENT_AGENT_DEBUG'; + +export type AgentLogLevel = 'info' | 'warn' | 'error'; + +export interface RpcRequestMessage { + type: 'rpc:request'; + /** 形如 `h12`(主进程发起)或 `r12`(运行时发起)。 */ + id: string; + channel: string; + payload: unknown; +} + +export interface RpcResponseOkMessage { + type: 'rpc:response'; + id: string; + ok: true; + result: unknown; +} + +export interface RpcResponseErrMessage { + type: 'rpc:response'; + id: string; + ok: false; + error: string; +} + +export type RpcResponseMessage = RpcResponseOkMessage | RpcResponseErrMessage; + +/** 运行时的 HTTP 服务已就绪,回报实际监听端口(绑定 0 由系统分配)。 */ +export interface ReadyMessage { + type: 'ready'; + port: number; +} + +export interface LogMessage { + type: 'log'; + level: AgentLogLevel; + message: string; +} + +export interface ShutdownMessage { + type: 'shutdown'; +} + +export type AgentMessage = + | RpcRequestMessage + | RpcResponseMessage + | ReadyMessage + | LogMessage + | ShutdownMessage; + +/** + * 主进程侧暴露给运行时的能力通道。 + * + * 命名与渲染层 IPC 保持一致的语义(如 `workspace:writeTextFile`),便于对照排查。 + * 后续阶段会逐步补充图像、标注、记忆、skills、终端等通道。 + */ +export const AGENT_CHANNELS = { + /** 连通性探针。 */ + ping: 'ping', + workspaceWriteTextFile: 'workspace:writeTextFile', + workspaceReadTextFile: 'workspace:readTextFile', + workspaceDeleteTextFile: 'workspace:deleteTextFile', + workspaceMoveTextFile: 'workspace:moveTextFile', +} as const; + +export type AgentChannel = (typeof AGENT_CHANNELS)[keyof typeof AGENT_CHANNELS]; + +/** 判断是否为合法的运行时消息(防御 parentPort 上的脏数据)。 */ +export function isAgentMessage(value: unknown): value is AgentMessage { + if (!value || typeof value !== 'object') return false; + const type = (value as { type?: unknown }).type; + return ( + type === 'rpc:request' || + type === 'rpc:response' || + type === 'ready' || + type === 'log' || + type === 'shutdown' + ); +} diff --git a/src/main/agent/routes.ts b/src/main/agent/routes.ts new file mode 100644 index 0000000..ace4847 --- /dev/null +++ b/src/main/agent/routes.ts @@ -0,0 +1,52 @@ +/** + * Agent 运行时的回环 HTTP 路由表。 + * + * 路由清单与 `docs/agent-protocol.md` §4 一一对应。阶段 1 全部为骨架 + * (返回 501),阶段 2 起逐个接入真实实现: + * + * 阶段 2 → `POST /agent/chat/stream`、`POST /agent/chat/cancel` + * 阶段 6 → `POST /agent/mcp/probe` + * 阶段 7 → 标注编排四个端点 + * + * 已明确删除(当前无调用方)的路由**不在此表登记**: + * - `POST /agent/annotation/batch-prepare` + * - `POST /agent/annotation/map-heuristic` + */ + +import type { RouteHandler } from './server'; + +/** 业务路由路径(不含方法),供测试与文档对照。 */ +export const AGENT_API_PREFIX = '/api/v1'; + +export const AGENT_ROUTE_PATHS = { + chatStream: `${AGENT_API_PREFIX}/agent/chat/stream`, + chatCancel: `${AGENT_API_PREFIX}/agent/chat/cancel`, + mcpProbe: `${AGENT_API_PREFIX}/agent/mcp/probe`, + annotationMutationPrepare: `${AGENT_API_PREFIX}/agent/annotation/mutation-prepare`, + annotationMapDetectionBoxes: `${AGENT_API_PREFIX}/agent/annotation/map-detection-boxes`, + annotationLlmGenerate: `${AGENT_API_PREFIX}/agent/annotation/llm-generate`, + qualityReportComposeStream: `${AGENT_API_PREFIX}/agent/annotation-quality/report/compose/stream`, +} as const; + +const notImplemented: RouteHandler = () => ({ kind: 'not_implemented' }); + +/** + * 构造路由表。 + * + * @param overrides 已实现的路由处理函数,键为 `" "` + */ +export function buildRoutes( + overrides: Record = {}, +): Record { + const routes: Record = {}; + + routes[`POST ${AGENT_ROUTE_PATHS.chatStream}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.chatCancel}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.mcpProbe}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.annotationMutationPrepare}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.annotationMapDetectionBoxes}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.annotationLlmGenerate}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.qualityReportComposeStream}`] = notImplemented; + + return { ...routes, ...overrides }; +} diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts new file mode 100644 index 0000000..0ba26bb --- /dev/null +++ b/src/main/agent/runtime.ts @@ -0,0 +1,177 @@ +/** + * Agent 运行时(utilityProcess)入口。 + * + * 该文件由 webpack 单独打包为 `dist/main/agentRuntime.js`,由主进程经 + * `utilityProcess.fork` 启动(见 `host.ts`)。它是**独立于主进程的 Node 进程**, + * 因此可以承载长时间运行的 Agent 循环而不阻塞 UI,且崩溃不会带走整个应用。 + * + * 两种运行模式: + * 1. **Electron 模式**:存在 `process.parentPort`,经桥与主进程通信,端口绑定 0 + * 后把实际端口通过 `ready` 消息回报。 + * 2. **独立模式**:直接 `node agentRuntime.js` 运行(无 parentPort)。端口与 token + * 从环境变量读取,便于在不启动 Electron 的情况下用 + * `scripts/agent-baseline/captureSse.mjs` 做协议对拍。 + * + * 阶段 1 只提供服务骨架(`/health` + 501 路由)。Agent 循环在阶段 2 接入。 + */ + +import { createBridge, type Bridge, type MessageEndpoint } from './bridge'; +import { startAgentServer, type AgentServer } from './server'; +import { buildRoutes } from './routes'; +import { + AGENT_CHANNELS, + AGENT_DEBUG_ENV, + AGENT_TOKEN_ENV, + isAgentMessage, + type AgentLogLevel, +} from './protocol'; + +/** 默认 CORS 来源,与 Python 版 `cors_origins` 一致(Vite dev server)。 */ +const DEFAULT_CORS_ORIGINS = ['http://localhost:1212']; + +const HOST = '127.0.0.1'; + +/** Electron utilityProcess 的 parentPort 结构(仅取用到的部分)。 */ +interface ParentPortLike { + postMessage(message: unknown): void; + on(event: 'message', listener: (event: { data: unknown }) => void): void; +} + +function getParentPort(): ParentPortLike | null { + const port = (process as unknown as { parentPort?: ParentPortLike }).parentPort; + return port ?? null; +} + +function makeLog(forward: (level: AgentLogLevel, message: string) => void) { + return (level: AgentLogLevel, message: string): void => { + const line = `[agentRuntime] ${message}`; + if (level === 'error') console.error(line); + else if (level === 'warn') console.warn(line); + else console.log(line); + forward(level, message); + }; +} + +async function main(): Promise { + const parentPort = getParentPort(); + const debug = process.env[AGENT_DEBUG_ENV] === '1'; + + let bridge: Bridge | null = null; + + // 日志:始终打到 stdout(主进程会捕获),Electron 模式下另外转发给主进程 + const log = makeLog((level, message) => { + if (!parentPort) return; + parentPort.postMessage({ type: 'log', level, message }); + }); + + if (parentPort) { + const endpoint: MessageEndpoint = { + postMessage: (message) => parentPort.postMessage(message), + onMessage: (handler) => { + const listener = (event: { data: unknown }): void => { + if (isAgentMessage(event.data)) handler(event.data); + }; + parentPort.on('message', listener); + return () => { + /* parentPort 无 off,进程退出即回收 */ + }; + }, + }; + bridge = createBridge({ + endpoint, + side: 'r', + // 存活探针:让主进程能区分「HTTP 端口在监听」与「运行时真正可响应 RPC」。 + // 桥是双向的,两侧都应答 ping,主进程可据此确认通道健康。 + handlers: { + [AGENT_CHANNELS.ping]: () => ({ + ok: true, + side: 'runtime', + ts: Date.now(), + }), + }, + onLog: (level, message) => log(level, `bridge: ${message}`), + }); + } + + // ── token 与端口 ─────────────────────────────────────────── + const token = process.env[AGENT_TOKEN_ENV] ?? ''; + if (!token) { + // 与 Python 版一致:token 未配置时失败关闭,不启动服务 + log('error', `${AGENT_TOKEN_ENV} 未设置,拒绝启动(token 未配置时不得对外提供服务)`); + process.exitCode = 1; + return; + } + + const desiredPort = Number(process.env.LR_AGENT_LOCAL_PORT ?? '0') || 0; + + // ── HTTP 服务 ────────────────────────────────────────────── + let server: AgentServer; + try { + server = await startAgentServer({ + host: HOST, + port: desiredPort, + token, + corsOrigins: DEFAULT_CORS_ORIGINS, + routes: buildRoutes(), + onLog: (level, message) => log(level, message), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log('error', `HTTP 服务启动失败: ${message}`); + parentPort?.postMessage({ type: 'log', level: 'error', message }); + process.exitCode = 1; + return; + } + + log('info', `就绪,baseUrl=http://${HOST}:${server.port}/api/v1`); + if (debug) log('info', `模式=${parentPort ? 'utilityProcess' : 'standalone'}`); + + // 独立模式下把端口打到 stdout,便于脚本解析 + if (!parentPort) { + console.log(`[agentRuntime] PORT=${server.port}`); + } else { + parentPort.postMessage({ type: 'ready', port: server.port }); + } + + // ── 优雅退出 ─────────────────────────────────────────────── + let shuttingDown = false; + const shutdown = async (reason: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; + log('info', `开始关闭(${reason})`); + bridge?.dispose('runtime shutting down'); + await server.close().catch(() => { + /* 关闭失败不阻断退出 */ + }); + log('info', '已关闭'); + }; + + if (parentPort) { + // 主进程经消息触发关闭 + const endpointOnMessage = (event: { data: unknown }): void => { + const data = event.data; + if (isAgentMessage(data) && data.type === 'shutdown') { + void shutdown('shutdown message').then(() => process.exit(0)); + } + }; + parentPort.on('message', endpointOnMessage); + } else { + process.on('SIGINT', () => { + void shutdown('SIGINT').then(() => process.exit(0)); + }); + process.on('SIGTERM', () => { + void shutdown('SIGTERM').then(() => process.exit(0)); + }); + } + + // 兜底:任何未捕获异常都上报并退出,避免进程静默挂死 + process.on('uncaughtException', (err) => { + log('error', `未捕获异常: ${err.stack ?? err.message}`); + void shutdown('uncaughtException').then(() => process.exit(1)); + }); + process.on('unhandledRejection', (reason) => { + log('error', `未处理的 Promise 拒绝: ${String(reason)}`); + }); +} + +void main(); diff --git a/src/main/agent/server.test.ts b/src/main/agent/server.test.ts new file mode 100644 index 0000000..8b78029 --- /dev/null +++ b/src/main/agent/server.test.ts @@ -0,0 +1,216 @@ +import http from 'node:http'; +import { afterEach, describe, expect, it } from '@jest/globals'; +import { + assertCorsConfig, + resolveCorsOrigin, + startAgentServer, + type AgentServer, + type RouteHandler, +} from './server'; + +const TOKEN = 'test-token-0123456789'; + +/** 极简 HTTP 客户端,避免依赖 jest 环境下的 fetch 实现。 */ +function request( + port: number, + path: string, + init: { + method?: string; + headers?: Record; + body?: string; + } = {}, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port, + path, + method: init.method ?? 'GET', + headers: init.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + req.on('error', reject); + if (init.body) req.write(init.body); + req.end(); + }); +} + +const started: AgentServer[] = []; + +async function boot( + overrides: Record = {}, +): Promise { + const server = await startAgentServer({ + host: '127.0.0.1', + port: 0, + token: TOKEN, + corsOrigins: ['http://localhost:1212'], + routes: { + 'POST /api/v1/agent/echo': (req) => ({ + kind: 'json', + body: { ok: true, echoed: req.rawBody }, + }), + 'POST /api/v1/agent/stub': () => ({ kind: 'not_implemented' }), + ...overrides, + }, + }); + started.push(server); + return server.port; +} + +afterEach(async () => { + while (started.length) { + const server = started.pop(); + // eslint-disable-next-line no-await-in-loop + await server?.close(); + } +}); + +describe('agent runtime HTTP server', () => { + describe('健康检查', () => { + it('免鉴权返回 200', async () => { + const port = await boot(); + const res = await request(port, '/health'); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ status: 'ok' }); + }); + }); + + describe('鉴权', () => { + it('缺少 token 返回 401', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { method: 'POST' }); + expect(res.status).toBe(401); + expect(JSON.parse(res.body).detail).toBe('invalid_local_token'); + }); + + it('token 错误返回 401', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { + method: 'POST', + headers: { Authorization: 'Bearer wrong-token' }, + }); + expect(res.status).toBe(401); + }); + + it('token 长度不同也不抛异常(常量时间比较)', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { + method: 'POST', + headers: { Authorization: 'Bearer x' }, + }); + expect(res.status).toBe(401); + }); + + it('token 正确时放行', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + body: 'payload', + }); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true, echoed: 'payload' }); + }); + }); + + describe('路由', () => { + it('未实现路由返回 501(阶段 1 全部业务路由如此)', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/stub', { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(501); + expect(JSON.parse(res.body).detail).toBe('not_implemented'); + }); + + it('未知路径返回 404', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/nope', { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(404); + }); + + it('路径存在但方法不符返回 405', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { + method: 'GET', + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(405); + }); + + it('handler 抛异常返回 500 而不是崩溃', async () => { + const port = await boot({ + 'POST /api/v1/agent/boom': () => { + throw new Error('boom'); + }, + }); + const res = await request(port, '/api/v1/agent/boom', { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(500); + expect(JSON.parse(res.body).message).toBe('boom'); + }); + }); + + describe('CORS', () => { + it('允许来源携带 ACAO 与 credentials 头', async () => { + const port = await boot(); + const res = await request(port, '/health', { + headers: { Origin: 'http://localhost:1212' }, + }); + expect(res.headers['access-control-allow-origin']).toBe( + 'http://localhost:1212', + ); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + }); + + it('未列入白名单的来源不返回 ACAO', async () => { + const port = await boot(); + const res = await request(port, '/health', { + headers: { Origin: 'http://evil.example' }, + }); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('OPTIONS 预检返回 200 且不需要鉴权', async () => { + const port = await boot(); + const res = await request(port, '/api/v1/agent/echo', { + method: 'OPTIONS', + headers: { Origin: 'http://localhost:1212' }, + }); + expect(res.status).toBe(200); + }); + }); +}); + +describe('CORS 配置校验', () => { + it('拒绝通配符来源', () => { + expect(() => assertCorsConfig(['*'])).toThrow(/通配符/); + }); + + it('放行显式来源', () => { + expect(() => assertCorsConfig(['http://localhost:1212'])).not.toThrow(); + }); + + it('无 Origin 时不返回 ACAO', () => { + expect(resolveCorsOrigin(undefined, ['http://localhost:1212'])).toBeNull(); + }); +}); diff --git a/src/main/agent/server.ts b/src/main/agent/server.ts new file mode 100644 index 0000000..d9284bf --- /dev/null +++ b/src/main/agent/server.ts @@ -0,0 +1,244 @@ +/** + * Agent 运行时的回环 HTTP 服务。 + * + * 阶段 1 只提供: + * - `GET /health`(免鉴权,供主进程探活) + * - 全部业务路由的骨架(统一返回 501 not_implemented),使路由表与鉴权/CORS + * 中间件在阶段 2 接入真实实现前就已被验证 + * + * 之所以在阶段 1 就保留回环 HTTP 外壳:让 `src/renderer/**` **零改动**, + * 并且可以拿 Node 运行时与 Python 服务做 A/B 对照(阶段 0 的 golden diff)。 + * 阶段 9 会移除这一层,改为直连 IPC。 + * + * 安全约束: + * - 仅监听 127.0.0.1(由调用方决定 host) + * - 除 `/health` 外全部路由要求 `Authorization: Bearer ` + * - CORS 允许来源显式列白,禁止通配符(`allow_credentials` 与 `*` 组合会 + * 让任意站点携带凭据访问) + */ + +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +/** 单请求体上限:需容纳 base64 图片兜底路径,故给到 32MB。 */ +const MAX_BODY_BYTES = 32 * 1024 * 1024; + +export interface RouteRequest { + method: string; + /** 不含 query,形如 `/api/v1/agent/chat/stream`。 */ + path: string; + query: URLSearchParams; + headers: http.IncomingHttpHeaders; + /** 原始请求体文本(已按上限截断保护)。 */ + rawBody: string; +} + +export type RouteResult = + | { kind: 'json'; status?: number; body: unknown } + | { kind: 'not_implemented' } + | { kind: 'method_not_allowed' }; + +export type RouteHandler = (req: RouteRequest) => Promise | RouteResult; + +export interface AgentServerOptions { + host: string; + /** 绑定的端口,0 表示由系统分配。 */ + port: number; + /** 访问 token;除 /health 外全部路由校验。 */ + token: string; + /** 允许的 CORS 来源,禁止包含 `*`。 */ + corsOrigins: string[]; + /** 路由表:`" "` → handler。 */ + routes: Record; + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; +} + +export interface AgentServer { + /** 实际监听端口。 */ + readonly port: number; + close(): Promise; +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_BYTES) { + reject(new Error(`请求体超过上限 ${MAX_BODY_BYTES} 字节`)); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +export function resolveCorsOrigin( + requestOrigin: string | undefined, + allowed: string[], +): string | null { + if (!requestOrigin) return null; + return allowed.includes(requestOrigin) ? requestOrigin : null; +} + +/** + * 校验 CORS 配置。`allow_credentials` 与通配符组合会让任意站点携带凭据访问, + * 直接拒绝启动(与 Python 版行为一致)。 + */ +export function assertCorsConfig(origins: string[]): void { + if (origins.some((o) => o.trim() === '*')) { + throw new Error("corsOrigins 不能包含 '*': 凭证模式下禁止通配符"); + } +} + +export async function startAgentServer( + options: AgentServerOptions, +): Promise { + assertCorsConfig(options.corsOrigins); + + const log = options.onLog ?? (() => {}); + const sockets = new Set(); + + const server = http.createServer((req, res) => { + void handle(req, res); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + + async function handle( + req: http.IncomingMessage, + res: http.ServerResponse, + ): Promise { + const method = (req.method ?? 'GET').toUpperCase(); + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const path = url.pathname; + + // ── CORS ────────────────────────────────────────────── + const origin = resolveCorsOrigin( + req.headers.origin, + options.corsOrigins, + ); + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + if (method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', '*'); + res.setHeader('Access-Control-Allow-Headers', '*'); + res.statusCode = 200; + res.end(); + return; + } + + // ── 健康检查(免鉴权) ──────────────────────────────── + if (path === '/health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + + // ── 鉴权 ────────────────────────────────────────────── + const auth = req.headers.authorization ?? ''; + const expected = `Bearer ${options.token}`; + if (!constantTimeEqual(auth, expected)) { + sendJson(res, 401, { detail: 'invalid_local_token' }); + return; + } + + const route = options.routes[`${method} ${path}`]; + if (!route) { + // 路径存在但方法不符时给出 405,便于排查 + const pathExists = Object.keys(options.routes).some( + (key) => key.slice(key.indexOf(' ') + 1) === path, + ); + sendJson( + res, + pathExists ? 405 : 404, + { detail: pathExists ? 'method_not_allowed' : 'not_found', path }, + ); + return; + } + + let rawBody = ''; + try { + rawBody = await readBody(req); + } catch (err) { + sendJson(res, 413, { + detail: err instanceof Error ? err.message : 'body_read_failed', + }); + return; + } + + let result: RouteResult; + try { + result = await route({ + method, + path, + query: url.searchParams, + headers: req.headers, + rawBody, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log('error', `路由 ${method} ${path} 抛出异常: ${message}`); + sendJson(res, 500, { detail: 'internal_error', message }); + return; + } + + if (result.kind === 'not_implemented') { + sendJson(res, 501, { detail: 'not_implemented', path }); + return; + } + if (result.kind === 'method_not_allowed') { + sendJson(res, 405, { detail: 'method_not_allowed' }); + return; + } + sendJson(res, result.status ?? 200, result.body); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.port, options.host, () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + log('info', `Agent 运行时 HTTP 服务已监听 ${options.host}:${address.port}`); + + return { + port: address.port, + async close(): Promise { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Length', Buffer.byteLength(payload)); + res.end(payload); +} diff --git a/src/main/localAgent/serverProcess.ts b/src/main/localAgent/serverProcess.ts index 598e1d0..02d184c 100644 --- a/src/main/localAgent/serverProcess.ts +++ b/src/main/localAgent/serverProcess.ts @@ -32,6 +32,28 @@ import { import { getEnvironmentConfig } from '../env/envStore'; import { getVenvPythonPath } from '../env/runtimeManager'; import { buildInferenceSpawnEnv } from '../preAnnot/inferenceProcess'; +import { + getAgentRuntimeBaseUrl, + getAgentRuntimeToken, + startAgentRuntime, + stopAgentRuntime, +} from '../agent/host'; + +/** + * Agent 编排的运行时实现。 + * + * 迁移期间两者并存:`python` 走 vendor/local-agent(既有实现),`node` 走 + * utilityProcess 内的 Node 运行时。默认 `python`——Node 运行时在阶段 2 之前 + * 只提供路由骨架,直接切过去会让业务请求全部 501。 + * + * 通过环境变量 `LR_AGENT_RUNTIME=node` 启用 Node 运行时,用于开发对拍与灰度验证。 + * 阶段 8 删除 Python 实现后,本开关及其 Python 分支一并移除。 + */ +export type LocalAgentRuntimeKind = 'python' | 'node'; + +export function resolveLocalAgentRuntimeKind(): LocalAgentRuntimeKind { + return process.env.LR_AGENT_RUNTIME === 'node' ? 'node' : 'python'; +} let processRef: ChildProcessWithoutNullStreams | null = null; let startingProcess: Promise | null = null; @@ -141,6 +163,9 @@ async function waitForHealthy(port: number): Promise { /** 启动本地 Agent 服务,返回 API base URL(含 /api/v1 前缀) */ export async function startLocalAgentServer(): Promise { + if (resolveLocalAgentRuntimeKind() === 'node') { + return startAgentRuntime(); + } if (processRef && listenPort) { return `http://127.0.0.1:${listenPort}/api/v1`; } @@ -233,6 +258,10 @@ export async function startLocalAgentServer(): Promise { /** 停止本地 Agent 服务 */ export function stopLocalAgentServer(): void { + if (resolveLocalAgentRuntimeKind() === 'node') { + stopAgentRuntime(); + return; + } if (processRef) { stopping = true; processRef.kill(); @@ -244,11 +273,17 @@ export function stopLocalAgentServer(): void { /** 获取当前服务 base URL(未启动时返回 null) */ export function getLocalAgentBaseUrl(): string | null { + if (resolveLocalAgentRuntimeKind() === 'node') { + return getAgentRuntimeBaseUrl(); + } if (listenPort) return `http://127.0.0.1:${listenPort}/api/v1`; return null; } /** 获取当前服务访问 token(未启动时返回 null) */ export function getLocalAgentToken(): string | null { + if (resolveLocalAgentRuntimeKind() === 'node') { + return getAgentRuntimeToken(); + } return authToken; } diff --git a/src/main/workspace/workspaceHandlers.ts b/src/main/workspace/workspaceHandlers.ts index 6619b8d..1000291 100644 --- a/src/main/workspace/workspaceHandlers.ts +++ b/src/main/workspace/workspaceHandlers.ts @@ -1,64 +1,36 @@ -import { ipcMain, BrowserWindow } from 'electron'; -import path from 'path'; +import { ipcMain } from 'electron'; import { - readScopedTextFile, - writeScopedTextFile, - deleteScopedTextFile, - moveScopedTextFile, -} from './workspaceWrite'; -import { isWithinAuthorizedRoot } from '../security/authorizedRoots'; - -const FORBIDDEN = { success: false, error: 'forbidden_root' } as const; + deleteWorkspaceTextFile, + moveWorkspaceTextFile, + readWorkspaceTextFile, + writeWorkspaceTextFile, +} from './workspaceOperations'; +/** + * 渲染层工作区文件 IPC。 + * + * 实现体已抽到 `workspaceOperations.ts`,与 Agent 运行时的 RPC 桥共用, + * 保证两条入口的行为完全一致(含授权根校验与 file-system:changed 广播)。 + */ export function registerWorkspaceHandlers(): void { ipcMain.handle( 'workspace:writeTextFile', async ( _event, payload: { rootDir: string; relativePath: string; content: string }, - ) => { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return FORBIDDEN; - const result = await writeScopedTextFile( - payload.rootDir, - payload.relativePath, - payload.content, - ); - if (result.success && result.filePath) { - const parentDir = path.dirname(result.filePath); - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('file-system:changed', parentDir); - }); - } - return result; - }, + ) => writeWorkspaceTextFile(payload), ); ipcMain.handle( 'workspace:readTextFile', - async (_event, payload: { rootDir: string; relativePath: string }) => { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return FORBIDDEN; - return readScopedTextFile(payload.rootDir, payload.relativePath); - }, + async (_event, payload: { rootDir: string; relativePath: string }) => + readWorkspaceTextFile(payload), ); ipcMain.handle( 'workspace:deleteTextFile', - async (_event, payload: { rootDir: string; relativePath: string }) => { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return FORBIDDEN; - const result = await deleteScopedTextFile( - payload.rootDir, - payload.relativePath, - ); - if (result.success) { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send( - 'file-system:changed', - path.join(payload.rootDir, path.dirname(payload.relativePath)), - ); - }); - } - return result; - }, + async (_event, payload: { rootDir: string; relativePath: string }) => + deleteWorkspaceTextFile(payload), ); ipcMain.handle( @@ -70,26 +42,6 @@ export function registerWorkspaceHandlers(): void { relativePath: string; newRelativePath: string; }, - ) => { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return FORBIDDEN; - const result = await moveScopedTextFile( - payload.rootDir, - payload.relativePath, - payload.newRelativePath, - ); - if (result.success) { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send( - 'file-system:changed', - path.join(payload.rootDir, path.dirname(payload.relativePath)), - ); - win.webContents.send( - 'file-system:changed', - path.join(payload.rootDir, path.dirname(payload.newRelativePath)), - ); - }); - } - return result; - }, + ) => moveWorkspaceTextFile(payload), ); } diff --git a/src/main/workspace/workspaceOperations.ts b/src/main/workspace/workspaceOperations.ts new file mode 100644 index 0000000..773dfe5 --- /dev/null +++ b/src/main/workspace/workspaceOperations.ts @@ -0,0 +1,105 @@ +/** + * 工作区文本文件操作的共享实现。 + * + * 抽出来的目的:渲染层 IPC(`workspaceHandlers.ts`)与 Agent 运行时的 RPC 桥 + * (`agent/ipcSurface.ts`)需要**完全一致**的行为——同样的授权根校验、同样的 + * 路径约束、同样的 `file-system:changed` 广播。两者都在主进程内,因此可以共用 + * 这一段带 Electron 副作用的实现,避免逻辑分叉。 + * + * 安全语义来自 `workspaceWrite.ts`(路径穿越、.lr-agent 禁写、扩展名黑名单、 + * realpath 符号链接逃逸检测),授权根校验来自 `authorizedRoots.ts`。 + */ + +import path from 'path'; +import { BrowserWindow } from 'electron'; +import { isWithinAuthorizedRoot } from '../security/authorizedRoots'; +import { + deleteScopedTextFile, + moveScopedTextFile, + readScopedTextFile, + writeScopedTextFile, +} from './workspaceWrite'; + +export const WORKSPACE_FORBIDDEN = { success: false, error: 'forbidden_root' } as const; + +export interface WorkspaceWriteResult { + success: boolean; + filePath?: string; + error?: string; +} + +export interface WorkspaceReadResult { + success: boolean; + content?: string; + exists?: boolean; + filePath?: string; + error?: string; +} + +/** 通知渲染层某些目录内容已变化(驱动文件树/编辑器刷新)。 */ +function broadcastFileSystemChanged(...dirs: string[]): void { + for (const dir of dirs) { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('file-system:changed', dir); + } + } +} + +export async function writeWorkspaceTextFile(payload: { + rootDir: string; + relativePath: string; + content: string; +}): Promise { + if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + const result = await writeScopedTextFile( + payload.rootDir, + payload.relativePath, + payload.content, + ); + if (result.success && result.filePath) { + broadcastFileSystemChanged(path.dirname(result.filePath)); + } + return result; +} + +export async function readWorkspaceTextFile(payload: { + rootDir: string; + relativePath: string; +}): Promise { + if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + return readScopedTextFile(payload.rootDir, payload.relativePath); +} + +export async function deleteWorkspaceTextFile(payload: { + rootDir: string; + relativePath: string; +}): Promise { + if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + const result = await deleteScopedTextFile(payload.rootDir, payload.relativePath); + if (result.success) { + broadcastFileSystemChanged( + path.join(payload.rootDir, path.dirname(payload.relativePath)), + ); + } + return result; +} + +export async function moveWorkspaceTextFile(payload: { + rootDir: string; + relativePath: string; + newRelativePath: string; +}): Promise { + if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + const result = await moveScopedTextFile( + payload.rootDir, + payload.relativePath, + payload.newRelativePath, + ); + if (result.success) { + broadcastFileSystemChanged( + path.join(payload.rootDir, path.dirname(payload.relativePath)), + path.join(payload.rootDir, path.dirname(payload.newRelativePath)), + ); + } + return result; +} From e58524e3ecf223f59c07cbab327607dd9a549e6a Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 01:44:42 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat(agent):=20=E5=AE=9E=E7=8E=B0=20Node?= =?UTF-8?q?=20=E4=BE=A7=20Agent=20=E5=B7=A5=E5=85=B7=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E4=B8=BB=E5=B9=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SSE 契约模块(toSseDict 逐条复刻字段派生规则)、OpenAI 兼容流式客户端与 tool_call 分片累积器、Assist 循环(轮次预算、五道过滤、阶段门禁、tool_choice=any 兜底、取消)、工具注册表(zod 为校验与 schema 单一来源)与只读工具(读取/搜索/列目录/元信息)。新增 workspacePath 移植路径解析与安全校验,fileSearch 用异步 I/O 实现栈式 DFS 遍历。golden diff 结果:11 个场景中 7 个通过(含 reasoning 的已记录差异),剩余 4 个属阶段 3/6 范围。修复过程中发现真实缺陷:工具可执行性必须以模式裁剪后的工具集为准而非全量注册表,否则越界调用会错误执行且 tool_choice=any 兜底会误触发。 Co-authored-by: Cursor --- docs/agent-protocol.md | 26 + package.json | 3 +- scripts/agent-baseline/README.md | 24 + scripts/agent-baseline/diffSse.mjs | 45 +- scripts/agent-baseline/dumpToolSchema.ts | 26 + src/main/agent/config.ts | 86 +++ src/main/agent/context/contextService.ts | 139 +++++ src/main/agent/context/snapshot.ts | 163 ++++++ src/main/agent/context/systemPrompt.ts | 176 ++++++ src/main/agent/handlers/chatStream.ts | 206 +++++++ src/main/agent/handlers/deps.ts | 49 ++ src/main/agent/handlers/index.ts | 21 + src/main/agent/llm/client.ts | 389 +++++++++++++ src/main/agent/loop/assistLoop.test.ts | 425 ++++++++++++++ src/main/agent/loop/assistLoop.ts | 685 +++++++++++++++++++++++ src/main/agent/loop/modeRouter.ts | 83 +++ src/main/agent/loop/taskPhase.ts | 267 +++++++++ src/main/agent/loop/toolInvocation.ts | 114 ++++ src/main/agent/runtime.ts | 5 +- src/main/agent/schemas.ts | 292 ++++++++++ src/main/agent/server.ts | 45 ++ src/main/agent/sse.test.ts | 293 ++++++++++ src/main/agent/sse.ts | 306 ++++++++++ src/main/agent/tools/fileReader.ts | 339 +++++++++++ src/main/agent/tools/fileSearch.ts | 457 +++++++++++++++ src/main/agent/tools/registry.ts | 540 ++++++++++++++++++ src/main/agent/tools/result.ts | 92 +++ src/main/agent/tools/workspacePath.ts | 343 ++++++++++++ 28 files changed, 5634 insertions(+), 5 deletions(-) create mode 100644 scripts/agent-baseline/dumpToolSchema.ts create mode 100644 src/main/agent/config.ts create mode 100644 src/main/agent/context/contextService.ts create mode 100644 src/main/agent/context/snapshot.ts create mode 100644 src/main/agent/context/systemPrompt.ts create mode 100644 src/main/agent/handlers/chatStream.ts create mode 100644 src/main/agent/handlers/deps.ts create mode 100644 src/main/agent/handlers/index.ts create mode 100644 src/main/agent/llm/client.ts create mode 100644 src/main/agent/loop/assistLoop.test.ts create mode 100644 src/main/agent/loop/assistLoop.ts create mode 100644 src/main/agent/loop/modeRouter.ts create mode 100644 src/main/agent/loop/taskPhase.ts create mode 100644 src/main/agent/loop/toolInvocation.ts create mode 100644 src/main/agent/schemas.ts create mode 100644 src/main/agent/sse.test.ts create mode 100644 src/main/agent/sse.ts create mode 100644 src/main/agent/tools/fileReader.ts create mode 100644 src/main/agent/tools/fileSearch.ts create mode 100644 src/main/agent/tools/registry.ts create mode 100644 src/main/agent/tools/result.ts create mode 100644 src/main/agent/tools/workspacePath.ts diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 52ad1af..2f4d5af 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -621,6 +621,32 @@ is_editor || has_workspace → LIGHT_TOOL_SET **易错点**:主循环回灌用**显示文本**;子代理内部回灌用**原文**。二者不一致,不要统一。 +**关键规则:工具可执行性以「工具集」为准,而非「注册表」。** + +`describe_client_context` 与 `get_account_summary` 在注册表中存在,但被模式路由刻意排除在主 +Agent 工具集之外(理由见 §5.3 注释)。Python 侧 `fn_map` 只包含**筛选后**的工具,因此越界调用 +返回: + +```json +{"ok": false, "tool": "", "status": "error", "summary": "未知工具: "} +``` + +注意这是**裸 JSON**,不经 `buildToolResult`,因此不含 `file_written` / `proposal_pending`。 + +若实现时误用全量注册表,会同时造成两处偏差: + +1. 越界调用从「未知工具」变成真实执行 +2. `tool_choice="any"` 兜底**错误触发**——它的前置条件之一正是「该工具在当前工具集内」 + +### 6.0 已记录的差异 + +以下差异是**有意保留**的,不属于回归。`scripts/agent-baseline/diffSse.mjs` 的 +`KNOWN_DIVERGENCES` 只豁免明确列出的**事件类型**,其余任何偏差仍会导致 gate 失败。 + +| 场景 | 差异 | 原因 | +|---|---|---| +| `reasoning` | 运行时会发出 `reasoning_delta`,Python 侧不发 | Python 用的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,因此 `stream_adapter.py` 的该分支从不触发。Node 侧直接读原始 delta,正常透出。这是**能力增强**(前端 `agentChatStore` 已完整消费该事件),保留而不回退 | + --- ## 6. 工具循环行为 diff --git a/package.json b/package.json index fa70afc..7377c1a 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,8 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "seti-icons": "^0.0.4", - "sql.js": "^1.14.1" + "sql.js": "^1.14.1", + "zod": "^4.4.3" }, "devDependencies": { "@electron/rebuild": "^3.7.1", diff --git a/scripts/agent-baseline/README.md b/scripts/agent-baseline/README.md index 6118bef..5a5f8d4 100644 --- a/scripts/agent-baseline/README.md +++ b/scripts/agent-baseline/README.md @@ -85,3 +85,27 @@ node scripts/agent-baseline/diffSse.mjs \ `captureSse.mjs` 会伪造与渲染层同结构的 `client_tool_results` 触发 resume,从而覆盖 resume 消息注入路径。 - 若某个场景在候选侧报错,会写出 `.error.json`;diff 时该场景计为失败。 + +## 已记录的差异 + +`diffSse.mjs` 里有 `KNOWN_DIVERGENCES` 表,用于记录**有意保留**的行为差异。 +它只豁免明确列出的**事件类型**,其余任何偏差仍会导致 gate 失败——避免「整场景跳过」 +让这个 gate 失效。 + +当前记录: + +| 场景 | 豁免的事件类型 | 原因 | +|---|---|---| +| `reasoning` | 候选侧多出的 `reasoning_delta` | Python 的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,故从不发该事件;Node 直接读原始 delta,属能力增强,前端已完整消费 | + +新增豁免时必须同时更新 `docs/agent-protocol.md` 的「已记录的差异」小节,并在 PR 说明中给出理由。 + +## 场景与实现阶段的对应 + +抓取覆盖 11 个场景。迁移过程中某些场景会因对应阶段尚未实现而失败,属预期: + +| 场景 | 依赖 | +|---|---| +| `plain-text` / `reasoning` / `single-tool` / `parallel-readonly` / `multiple-rounds` / `client-tool-pending` / `tool-choice-any-fallback` | 阶段 2(循环主干 + 只读工具 + 异步客户端工具) | +| `proposal-write` / `proposal-edit` / `proposal-lr-agent-suppressed` | 阶段 3(提案流式拦截 + 写入类工具) | +| `subagent` | 阶段 6(`explore_readonly` 子代理) | diff --git a/scripts/agent-baseline/diffSse.mjs b/scripts/agent-baseline/diffSse.mjs index 83014cd..f20ea83 100644 --- a/scripts/agent-baseline/diffSse.mjs +++ b/scripts/agent-baseline/diffSse.mjs @@ -38,6 +38,39 @@ const ONLY = argOf('--only', '') .map((s) => s.trim()) .filter(Boolean); +/** + * 已记录的、有意为之的差异。 + * + * 只豁免**明确列出的**事件类型,其余任何偏差仍会导致失败——避免这个 gate + * 因为「整场景跳过」而失效。 + * + * `reasoning`:Python 侧(langchain-openai 1.2.2)不会把 provider 的 + * `reasoning_content` 透出到 `additional_kwargs`,因此从不发 `reasoning_delta`; + * Node 侧直接读原始 delta,会正常透出。这是**能力增强**(前端 `agentChatStore` + * 已完整消费该事件),保留而不回退。 + */ +const KNOWN_DIVERGENCES = { + reasoning: { + /** 允许候选侧多出(基准侧没有)的事件类型。 */ + extraEventTypes: ['reasoning_delta'], + note: 'Node 正常透出 reasoning_content;Python 受 langchain-openai 限制未透出', + }, +}; + +/** 按已记录的豁免过滤候选事件(仅移除明确列出的类型)。 */ +function applyKnownDivergences(id, baseline, candidate) { + const rule = KNOWN_DIVERGENCES[id]; + if (!rule) return { candidate, applied: null }; + const before = candidate.length; + const filtered = candidate.filter( + (event) => !rule.extraEventTypes.includes(event?.type), + ); + return { + candidate: filtered, + applied: before === filtered.length ? null : `${rule.note}(移除 ${before - filtered.length} 条)`, + }; +} + function readScenarios(dir) { if (!fs.existsSync(dir)) throw new Error(`目录不存在: ${dir}`); const out = new Map(); @@ -133,10 +166,18 @@ function main() { failed += 1; continue; } - const diffs = diffScenario(id, a, b); + const { candidate: bFiltered, applied: appliedNote } = applyKnownDivergences( + id, + a, + b, + ); + const diffs = diffScenario(id, a, bFiltered); if (diffs.length === 0) { passed += 1; - if (!QUIET) console.log(`✓ ${id} (${a.length} events)`); + if (!QUIET) { + const suffix = appliedNote ? ` [已记录差异: ${appliedNote}]` : ''; + console.log(`✓ ${id} (${a.length} events)${suffix}`); + } } else { failed += 1; console.log(`\n✗ ${id} (${a.length} → ${b.length} events)`); diff --git a/scripts/agent-baseline/dumpToolSchema.ts b/scripts/agent-baseline/dumpToolSchema.ts new file mode 100644 index 0000000..125ac3e --- /dev/null +++ b/scripts/agent-baseline/dumpToolSchema.ts @@ -0,0 +1,26 @@ +/** + * 开发期探针:转储工具的 JSON Schema,用于与 Python 侧逐字对照。 + * + * 用法:npx ts-node scripts/agent-baseline/dumpToolSchema.ts [工具名...] + * + * 不传工具名时转储全部。 + */ + +import { toolsByName, toToolSpec } from '../../src/main/agent/tools/registry'; + +const wanted = process.argv.slice(2); +const tools = toolsByName(); +const names = wanted.length ? wanted : [...tools.keys()]; + +for (const name of names) { + const tool = tools.get(name); + if (!tool) { + console.log(`=== ${name}: NOT FOUND ===`); + continue; + } + const spec = toToolSpec(tool); + console.log(`=== ${name} (${tool.kind}) ===`); + console.log(`description: ${tool.description}`); + console.log(JSON.stringify(spec.function.parameters, null, 1)); + console.log(''); +} diff --git a/src/main/agent/config.ts b/src/main/agent/config.ts new file mode 100644 index 0000000..f09f1b0 --- /dev/null +++ b/src/main/agent/config.ts @@ -0,0 +1,86 @@ +/** + * Agent 运行时配置。 + * + * 对应 `vendor/local-agent/app/core/config.py` 的 Agent 编排相关字段。 + * 默认值必须与协议文档 §11 一致——它们会直接改变工具结果的文本内容, + * 进而影响 LLM 行为与 golden 基线。 + * + * 目前只需要工具层用到的字段;后续阶段(循环预算、MCP TTL、标注温度等) + * 按需补充。 + */ + +export interface AgentSettings { + /** 主 Agent 工具轮上限。 */ + maxToolRounds: number; + /** 子代理(explore_readonly)工具轮上限。 */ + subagentMaxToolRounds: number; + /** 单文件读取字节上限(512KB)。 */ + readFileMaxBytes: number; + /** 单文件读取行数上限。 */ + readFileMaxLines: number; + /** 文档(PDF/DOCX)提取页数上限。 */ + readDocumentMaxPages: number; + /** grep 结果上限。 */ + grepMaxResults: number; + /** grep / glob 扫描文件数上限。 */ + grepMaxFilesScanned: number; + /** 列目录条目上限。 */ + listDirMaxEntries: number; + /** Chat 附图最长边。 */ + chatVisionMaxEdge: number; + /** Chat 附图 JPEG 质量。 */ + chatVisionJpegQuality: number; + /** MCP 工具发现结果缓存 TTL(秒)。 */ + mcpToolsTtlSeconds: number; + /** 标注变更功能开关。 */ + mutationEnabled: boolean; +} + +export const DEFAULT_AGENT_SETTINGS: AgentSettings = { + maxToolRounds: 30, + subagentMaxToolRounds: 12, + readFileMaxBytes: 524_288, + readFileMaxLines: 2000, + readDocumentMaxPages: 30, + grepMaxResults: 100, + grepMaxFilesScanned: 1000, + listDirMaxEntries: 80, + chatVisionMaxEdge: 1280, + chatVisionJpegQuality: 85, + mcpToolsTtlSeconds: 1800, + mutationEnabled: true, +}; + +/** 运行时可覆盖的配置(环境变量 → 覆盖默认值),便于对拍与调试。 */ +export function loadAgentSettings(): AgentSettings { + const num = (key: string, fallback: number): number => { + const raw = process.env[key]; + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; + }; + return { + ...DEFAULT_AGENT_SETTINGS, + maxToolRounds: num('LR_AGENT_MAX_TOOL_ROUNDS', DEFAULT_AGENT_SETTINGS.maxToolRounds), + readFileMaxBytes: num( + 'LR_AGENT_READ_FILE_MAX_BYTES', + DEFAULT_AGENT_SETTINGS.readFileMaxBytes, + ), + readFileMaxLines: num( + 'LR_AGENT_READ_FILE_MAX_LINES', + DEFAULT_AGENT_SETTINGS.readFileMaxLines, + ), + grepMaxResults: num( + 'LR_AGENT_GREP_MAX_RESULTS', + DEFAULT_AGENT_SETTINGS.grepMaxResults, + ), + grepMaxFilesScanned: num( + 'LR_AGENT_GREP_MAX_FILES_SCANNED', + DEFAULT_AGENT_SETTINGS.grepMaxFilesScanned, + ), + listDirMaxEntries: num( + 'LR_AGENT_LIST_DIR_MAX_ENTRIES', + DEFAULT_AGENT_SETTINGS.listDirMaxEntries, + ), + }; +} diff --git a/src/main/agent/context/contextService.ts b/src/main/agent/context/contextService.ts new file mode 100644 index 0000000..c709154 --- /dev/null +++ b/src/main/agent/context/contextService.ts @@ -0,0 +1,139 @@ +/** + * 对话上下文服务:Assist resume 注入与纯对话系统提示词。 + * + * 移植自 `vendor/local-agent/app/agent/context_service.py`。 + * + * `appendClientToolResultsToMessages` 的「补造 AIMessage」分支是 resume 正确性的关键: + * 客户端工具执行完回传结果时,历史里可能**没有**对应的 AIMessage(例如客户端多执行了 + * 一个运行时没声明的调用);此时必须先补一条带 tool_calls 的 AIMessage, + * 否则模型会报 `tool_call_id` 不匹配。 + */ + +import type { ChatMessage } from '../llm/client'; + +/** 纯对话(无工具)路径的默认系统提示词。 */ +export const CHAT_SYSTEM_PROMPT = + '在 LR-Agent 系统内回答用户问题。结合【你的身份】中的模型信息作答,勿自称独立产品助手或其它未配置的模型。'; + +export const RESUME_NEXT_HINT = + '客户端工具已结束。不要重复调用刚才同一个 tool_call。' + + '若 proposal_pending=true:提案未 Keep All、未写盘,不要声称已标注/已删除/已写入。' + + '改或删已有标注必须调用 mutate_annotation。' + + '若还要给其他文件做新标注,再调用 auto_annotate 并传入新的 paths。' + + '不要 memory_write progress.md 或 annotated-files.md。'; + +export const RESUME_APPLIED_NEXT_HINT = + '客户端工具已结束,用户已 Keep All:提案已写盘,file_written=true。' + + '可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。' + + '报告中的每个数字必须来自工具返回或提案明细,禁止估算。' + + '不要对已写盘文件重复调用 auto_annotate 整文件重标;' + + '核对发现错标/漏标/重复框时,用 mutate_annotation 定向修正(带上 paths 或 annotation_ids)。' + + '提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。' + + '不要 memory_write progress.md 或 annotated-files.md。'; + +export interface ClientToolResultInput { + toolCallId: string; + name: string; + result: string; +} + +/** + * 找出「已声明但未应答」的 tool_call id。 + * + * 用于判断客户端回传的结果是否需要补造 AIMessage。 + */ +export function unansweredToolCallIds(messages: ChatMessage[]): Set { + const declared = new Set(); + const answered = new Set(); + for (const message of messages) { + if (message.role === 'assistant') { + for (const call of message.toolCalls ?? []) { + if (call.id) declared.add(call.id); + } + } else if (message.role === 'tool' && message.toolCallId) { + answered.add(message.toolCallId); + } + } + for (const id of answered) declared.delete(id); + return declared; +} + +/** + * 为 resume 的客户端工具结果补上 `next_hint`(已有则保留)。 + * + * `proposalsApplied`(用户已 Keep All)时同步修正 `proposal_pending` / `file_written`, + * 避免模型把已落盘提案误判为未确认。 + */ +export function enrichResumeToolResult( + result: string, + options: { proposalsApplied?: boolean } = {}, +): string { + let data: unknown; + try { + data = JSON.parse(result); + } catch { + return result; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return result; + const record = data as Record; + + // 谎报防护:skipped / 未生成提案的 mutate(Dismiss 后或被门禁拦下)不能标成已写盘 + if (options.proposalsApplied && record.proposal_pending === true) { + record.proposal_pending = false; + record.file_written = true; + record.next_hint = RESUME_APPLIED_NEXT_HINT; + } else if (!String(record.next_hint ?? '').trim()) { + record.next_hint = RESUME_NEXT_HINT; + } + return JSON.stringify(record); +} + +/** + * Resume 时补齐客户端工具的 ToolMessage。 + * + * 已有对应 AIMessage 时直接补 ToolMessage;否则先补造 AIMessage 再补 ToolMessage。 + */ +export function appendClientToolResultsToMessages( + messages: ChatMessage[], + clientToolResults: ClientToolResultInput[], + options: { userContent?: string; proposalsApplied?: boolean } = {}, +): ChatMessage[] { + if (!clientToolResults.length) return messages; + + const unanswered = unansweredToolCallIds(messages); + + for (const ctr of clientToolResults) { + const result = enrichResumeToolResult(ctr.result, { + proposalsApplied: options.proposalsApplied, + }); + + if (unanswered.has(ctr.toolCallId)) { + messages.push({ role: 'tool', content: result, toolCallId: ctr.toolCallId }); + unanswered.delete(ctr.toolCallId); + continue; + } + + // 历史里没有对应 AIMessage:补造一条,保证 tool_call_id 有归属 + const args: Record = { + user_request: (options.userContent ?? '').trim(), + }; + try { + const parsed = JSON.parse(ctr.result); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const userRequest = (parsed as Record).user_request; + if (userRequest) args.user_request = String(userRequest); + } + } catch { + /* 解析失败时保留默认 args */ + } + + messages.push({ + role: 'assistant', + content: '', + toolCalls: [{ id: ctr.toolCallId, name: ctr.name, args }], + }); + messages.push({ role: 'tool', content: result, toolCallId: ctr.toolCallId }); + } + + return messages; +} diff --git a/src/main/agent/context/snapshot.ts b/src/main/agent/context/snapshot.ts new file mode 100644 index 0000000..d39062e --- /dev/null +++ b/src/main/agent/context/snapshot.ts @@ -0,0 +1,163 @@ +/** + * 客户端上下文快照格式化与身份块。 + * + * 移植自 `vendor/local-agent/app/agent/context_snapshot.py`。 + * + * 本模块只包含「工具与 prompt 共用的格式化片段」;完整的 system prompt 分块组装 + * 在阶段 5 补齐(顺序固定,不可调整——详见协议文档 §9.1)。 + */ + +/** 标注项目快照输入。 */ +export interface AnnotationProjectSnapshot { + projectId: string; + name?: string; + modality?: string; + annotationType?: string; + labels?: Array>; + detectionModels?: Array>; + projectDirectoryPath?: string | null; +} + +/** 看图与查标注的固定提示。 */ +export const VISION_HINT = `- 看图描述:按需调用 read_image_for_vision。 +- 查已有标注:read_file_annotation。`; + +/** 工作区助手任务段(`{vision_hint}` 需替换为 VISION_HINT)。 */ +export const WORKSPACE_ASSIST_TASK = `【任务】在 LR-Agent 内回答用户问题,按需使用工具完成读/写/分析操作。 +{vision_hint} +- 大范围摸底用 explore_readonly;独立摸底可一次发起多个(不同 query / focus_path),它们会并行执行。标注与改文件必须由主 Agent 调对应工具。 +- 用自然、简洁的中文回复。 +- 调用工具前先用一两句中文说明下一步要做什么。`; + +/** 标注/工作区 Agent 的任务段(`{vision_hint}` 需替换)。 */ +export const ASSISTANT_TASK_BASE = `【任务】在 LR-Agent 内完成问答、标注、分析、写文件。 +- 新增/重写标注用 auto_annotate;改已有标注用 mutate_annotation。不要用写文件工具保存标注。 +- 写文件工具只用于工作区文档与代码。 +- 大范围摸底用 explore_readonly;独立摸底可一次发起多个(不同 query / focus_path),它们会并行执行。标注与改文件必须由主 Agent 调对应工具。 +{vision_hint} +- 未收到工具返回前,禁止输出执行结果、统计数字或完成声明;不得用文本假装执行了工具。 +- 报告与汇总中的每个数字必须来自工具返回或提案明细,禁止估算或凭印象填写。 +- 用简洁中文回复;调用工具前用一两句说明下一步。`; + +/** 文件编辑纪律(仅允许写工作区的模式注入)。 */ +export const FILE_EDIT_GUIDE = `【文件编辑纪律】 +- 修改已有文件的局部内容时,必须优先用 str_replace_workspace_file(old_string → new_string),禁止用 write_workspace_file 整文件重写来改几行。 +- 编辑前必须先 read_workspace_file 看原文;old_string 从真实原文中原样复制(含缩进与空白),禁止凭记忆编写。 +- old_string 取最小但唯一的片段:太短容易命中多处,必要时前后多带几行上下文。 +- new_string 保持与上下文一致的缩进与换行风格;替换后的全文必须是合法代码/文档。 +- 同一文件多处修改:连续多次调用 str_replace_workspace_file(提案自动累积为一份),不要为省事整文件重写。`; + +/** 标注调用纪律(仅标注 Agent 注入)。 */ +export const ANNOTATION_CALL_GUIDE = `【标注调用纪律】(auto_annotate) +- paths 取自 list_workspace_directory 的 relativePath,目录以 / 结尾(如 data/);用户点了文件或文件夹必须填 paths。 +- all_files 仅在用户明确要求标注整个项目/全部文件时为 true;未确认时禁止默认为 true。 +- 用户说“重写 / 重新标注 / 每文件只留一条”用 write_mode=replace_matching,否则用 append。 +- conf_threshold / iou_threshold 仅在用户明确给出数值时填,否则留空用模型默认值。 +- model_id 仅在用户点名检测模型时填(可用模型见 describe_annotation_project)。 +- include_classes / exclude_classes 仅在用户说“只标 X / 不要 Y”时填,类名以检测模型输出为准。 +- use_vision_mapping 留空由系统按标签情况决定;标签为实例/细粒度(球员名等)时填 true。 +- 所有参数以用户本轮原话为依据,禁止凭猜测补值。`; + +export const ANNOTATION_SCOPE_HINT = + '【标注工具】paths 填 list_workspace_directory 的 relativePath;全部文件才设 all_files=true。'; + +/** 已知标注类型(13 种)。 */ +export const ANNOTATION_KNOWN_TYPES = new Set([ + 'bbox', + 'caption', + 'classification', + 'polygon', + 'keypoint', + 'rotated_bbox', + 'span_ner', + 'text_classification', + 'instruction', + 'preference', + 'conversation', + 'cot', +]); + +/** 特殊标注类型的额外提示。 */ +export const ANNOTATION_TYPE_EXTRAS: Record = { + polygon: '需配置检测模型与 SAM2 分割模型。', + keypoint: '需先选择骨架模板并配置关键点模型。', + rotated_bbox: '需配置 OBB 检测模型。', +}; + +/** 需要展示检测模型列表的标注类型。 */ +export const DETECTION_MODEL_TYPES = new Set([ + 'bbox', + 'polygon', + 'keypoint', + 'rotated_bbox', +]); + +/** + * 运行时身份块。 + * + * `providerLabel` 在本地路径固定为 `"local"`(由调用方传入)。 + */ +export function formatRuntimeIdentityBlock(params: { + model: string; + providerLabel?: string; + supportsVision?: boolean; +}): string { + const label = (params.providerLabel ?? '').trim() || '(未命名提供商)'; + const visionLine = params.supportsVision + ? '视觉能力:已通过 API 探针,可调用 read_image_for_vision 并在调用后查看附图。' + : '视觉能力:未通过探针或未检测。不要声称能分析图片像素;' + + '若用户要看图,说明需在「大模型配置」中选用支持视觉的模型并重新检测视觉。'; + return ( + '【你的身份】\n' + + `- 你是后端模型 \`${params.model}\`(配置名称:${label})。\n` + + `- ${visionLine}\n` + + '- 你在 LR-Agent 系统内与用户对话、调用工具完成任务;不要把自己说成独立的「LR-Agent 助手」或其它品牌模型。' + ); +} + +/** + * 标注项目快照格式化(用于 prompt 注入与 describe_annotation_project 工具)。 + * + * 标签最多 40 条、检测模型最多 12 条。 + */ +export function formatSnapshotForPrompt( + snapshot: AnnotationProjectSnapshot, +): string { + const labels = snapshot.labels ?? []; + const labelLines = labels + .slice(0, 40) + .filter((item) => item && typeof item === 'object') + .map((item) => `- ${String(item.id ?? '')}: ${String(item.name ?? '')}`); + + const annotationType = snapshot.annotationType ?? ''; + const lines: string[] = [ + `项目 ID: ${snapshot.projectId}`, + `项目名称: ${snapshot.name ?? ''}`, + `模态: ${snapshot.modality ?? ''}`, + `标注类型: ${annotationType}`, + '标签列表:', + ...(labelLines.length ? labelLines : ['- (无)']), + ]; + + // 仅在目标检测类标注类型时展示可用的检测模型 + if (DETECTION_MODEL_TYPES.has(annotationType)) { + const models = snapshot.detectionModels ?? []; + const modelLines = models + .slice(0, 12) + .filter((m) => m && typeof m === 'object') + .map( + (m) => + `- ${String(m.id ?? '')}: ${String(m.name ?? '')}` + + (m.is_default ? ' (默认)' : ''), + ); + lines.push('可用检测模型(object_detection):'); + lines.push(...(modelLines.length ? modelLines : ['- (未配置或未传入)'])); + } + + if (ANNOTATION_KNOWN_TYPES.has(annotationType)) { + const extra = ANNOTATION_TYPE_EXTRAS[annotationType]; + lines.push(extra ? `${ANNOTATION_SCOPE_HINT} ${extra}` : ANNOTATION_SCOPE_HINT); + } + + return lines.join('\n'); +} diff --git a/src/main/agent/context/systemPrompt.ts b/src/main/agent/context/systemPrompt.ts new file mode 100644 index 0000000..e6ad155 --- /dev/null +++ b/src/main/agent/context/systemPrompt.ts @@ -0,0 +1,176 @@ +/** + * System prompt 组装(阶段 2 版本)与消息构建。 + * + * **分块顺序固定,不可调整**(见协议文档 §9.1): + * 身份 → task → editor_note → 项目指令 → skills → 记忆 → 台账 → 阶段块 + * + * 稳定块在前、动态块沉底,使 system prompt 的长前缀跨轮保持一致,以命中 + * DeepSeek / OpenAI 等服务端的前缀缓存。顺序变化会直接造成缓存失效。 + * + * 阶段 2 已实现:身份块、task 段(工作区/项目两种)、编辑器纪律、项目指令、 + * skills catalog、工作区记忆、提案台账、阶段块。 + * 阶段 5 补齐:多模态附图与视觉兜底(不影响本模块)。 + */ + +import { + ANNOTATION_CALL_GUIDE, + ASSISTANT_TASK_BASE, + FILE_EDIT_GUIDE, + formatRuntimeIdentityBlock, + formatSnapshotForPrompt, + VISION_HINT, + WORKSPACE_ASSIST_TASK, +} from './snapshot'; +import { phasePromptBlock, type TaskPhaseContext } from '../loop/taskPhase'; +import type { ParsedClientContext } from '../schemas'; +import type { ChatMessage } from '../llm/client'; +import { CHAT_SYSTEM_PROMPT } from './contextService'; + +/** 工作区助手任务段(含文件编辑纪律与工作区路径)。 */ +function buildWorkspaceAssistantSystemPrompt( + clientContext: ParsedClientContext, +): string { + const parts = [ + WORKSPACE_ASSIST_TASK.replace('{vision_hint}', VISION_HINT), + FILE_EDIT_GUIDE, + ]; + const workspaceRoot = (clientContext.workspaceRoot ?? '').trim(); + if (workspaceRoot) parts.push(`【工作区】\n${workspaceRoot}`); + return parts.join('\n\n'); +} + +/** 标注项目助手任务段。 */ +function buildProjectAssistantSystemPrompt( + clientContext: ParsedClientContext, +): string { + const task = ASSISTANT_TASK_BASE.replace('{vision_hint}', VISION_HINT); + const snapshot = clientContext.annotationProjectSnapshot; + if (!snapshot) return task; + return `${task}\n\n【当前标注项目快照】\n${formatSnapshotForPrompt(snapshot)}`; +} + +/** 可用 Skills 目录块(稳定块,排在记忆与台账之前以命中前缀缓存)。 */ +function formatSkillsCatalogBlock( + skills: Array<{ name: string; description: string }>, +): string { + if (!skills.length) return ''; + const lines = skills.map((s) => `- ${s.name}: ${s.description}`); + return ( + '【可用 Skills】\n' + + `${lines.join('\n')}\n` + + '匹配到某个 Skill 时,先调用 read_agent_skill 读取其 SKILL.md 正文,' + + '再用 list_agent_skill_files / read_agent_skill 读取需要的附属文件;不要凭名字猜测内容。' + ); +} + +/** + * 组装 Assist 模式 system prompt。 + * + * `clientContext` 为 null 时退化为身份块 + 纯对话提示词。 + */ +export function buildAssistSystemPrompt(params: { + clientContext: ParsedClientContext | null; + model: string; + supportsVision: boolean; + taskPhaseContext: TaskPhaseContext | null; +}): string { + const { clientContext } = params; + const identity = formatRuntimeIdentityBlock({ + model: params.model, + // 本地路径固定为 "local"(与 Python 侧一致) + providerLabel: 'local', + supportsVision: params.supportsVision, + }); + + if (!clientContext) { + return `${identity}\n\n${CHAT_SYSTEM_PROMPT}`; + } + + let task: string; + if (clientContext.workMode === 'editor') { + task = buildWorkspaceAssistantSystemPrompt(clientContext); + } else if (clientContext.annotationProjectSnapshot) { + task = buildProjectAssistantSystemPrompt(clientContext); + // 标注 Agent 才有 auto_annotate;编辑器模式已被 registry 剥掉标注工具 + if (clientContext.agentMode === 'annotation') { + task = `${task}\n\n${ANNOTATION_CALL_GUIDE}`; + } + } else if ((clientContext.workspaceRoot ?? '').trim()) { + task = buildWorkspaceAssistantSystemPrompt(clientContext); + } else { + task = WORKSPACE_ASSIST_TASK.replace('{vision_hint}', VISION_HINT); + } + + // 编辑器模式纪律 + let editorNote = ''; + if (clientContext.workMode === 'editor') { + editorNote = + clientContext.agentMode === 'annotation' + ? '\n【编辑器模式】当前为编辑器 Agent:禁止调用标注读写、批量标注、标注变更相关工具;' + + '可使用 read_workspace_file、write_workspace_file、' + + 'str_replace_workspace_file、move_workspace_file、read_document_file 等通用工具。' + : '\n【编辑器模式】当前为编辑器 Ask:只读问答与分析。' + + '禁止调用写文件、标注读写、批量标注与标注变更相关工具。'; + } + + let base = `${identity}\n\n${task}`; + if (editorNote) base = `${base}${editorNote}`; + + const instructions = (clientContext.projectInstructions ?? '').trim(); + if (instructions) base = `${base}\n\n【项目指令】\n${instructions}`; + + // 稳定块(skills)先于动态块(记忆、台账) + const skillsBlock = formatSkillsCatalogBlock(clientContext.skillsCatalog ?? []); + if (skillsBlock) base = `${base}\n\n${skillsBlock}`; + + if (clientContext.workspaceMemoryEnabled) { + const memoryIndex = (clientContext.memoryIndex ?? '').trim(); + const indexBlock = memoryIndex + ? memoryIndex + : '(尚无工作区记忆文件。进度与已标文件将在用户确认或保存标注后由系统生成。)'; + base = + `${base}\n\n【工作区记忆】\n${indexBlock}\n` + + '这是本标注任务的工作区记忆,可有多个 Markdown 文件。' + + 'progress.md 与 annotated-files.md 由系统在用户确认或标注落盘后更新;' + + '需要细节时用 memory_read,不要凭印象改计数。' + + 'memory_create / memory_write 只用于用户规范、标注偏好与纠正' + + '(如 conventions.md、preferences.md);不要把未确认提案写成已完成。' + + '项目指令优先级高于记忆。'; + } + + const ledger = (clientContext.proposalLedger ?? '').trim(); + if (ledger) base = `${base}\n\n${ledger}`; + + // 阶段块追加在末尾 + const phaseBlock = phasePromptBlock(params.taskPhaseContext); + if (phaseBlock) base = `${base}\n\n${phaseBlock}`; + + return base; +} + +/** 纯对话(无工具)分支的 system prompt。 */ +export function buildChatSystemPrompt(systemPrompt: string | null): string { + return systemPrompt || CHAT_SYSTEM_PROMPT; +} + +/** + * 构建发给 LLM 的消息数组。 + * + * 顺序固定:主 SystemMessage → 可选摘要 SystemMessage → 历史消息。 + * 摘要作为**独立的第二条** SystemMessage 注入。 + */ +export function buildChatMessages(params: { + systemPrompt: string; + contextSummary: string | null; + messages: ChatMessage[]; +}): ChatMessage[] { + const out: ChatMessage[] = [{ role: 'system', content: params.systemPrompt }]; + if (params.contextSummary) { + out.push({ + role: 'system', + content: `【此前对话摘要】\n${params.contextSummary}`, + }); + } + out.push(...params.messages); + return out; +} diff --git a/src/main/agent/handlers/chatStream.ts b/src/main/agent/handlers/chatStream.ts new file mode 100644 index 0000000..822b7c6 --- /dev/null +++ b/src/main/agent/handlers/chatStream.ts @@ -0,0 +1,206 @@ +/** + * `POST /api/v1/agent/chat/stream` 与 `POST /api/v1/agent/chat/cancel`。 + * + * 对应 Python 的 `app/api/v1/agent.py` 的 `_stream_local_chat`。 + * + * 两条分支: + * - `has_tools` 为真 → Assist 工具循环(`streamAssist`) + * - 否则 → 纯对话(只发 text_delta / reasoning_delta,无工具) + * + * 无论哪条分支,流末尾都**无条件**追加一帧裸 `{"type":"done"}`。 + * + * 取消:Python 用进程内 `asyncio.Event`;这里用 `AbortController`, + * 并把它同时接到 LLM 请求上,使取消能真正中断上游请求(Python 侧只能等循环 + * 下一个检查点)。 + */ + +import { LlmClient } from '../llm/client'; +import { DONE_FRAME, encodeSseFrame, sse, toSseDict } from '../sse'; +import { buildToolSpecs } from '../tools/registry'; +import { toolRegistry, type RuntimeDeps } from './deps'; +import { deriveTaskPhase } from '../loop/taskPhase'; +import { resolveAssistToolSet } from '../loop/modeRouter'; +import { streamAssist } from '../loop/assistLoop'; +import { + hasToolContext, + parseChatStreamRequest, + RequestParseError, + type ParsedChatRequest, +} from '../schemas'; +import { + buildAssistSystemPrompt, + buildChatMessages, + buildChatSystemPrompt, +} from '../context/systemPrompt'; +import type { RouteHandler } from '../server'; + +/** 运行中的任务:clientJobId → 取消控制器。 */ +const runningJobs = new Map(); + +/** 供测试与关闭时清理。 */ +export function resetRunningJobs(): void { + for (const controller of runningJobs.values()) controller.abort(); + runningJobs.clear(); +} + +/** 当前运行中的任务数(自检用)。 */ +export function runningJobCount(): number { + return runningJobs.size; +} + +/** 组装 `POST /agent/chat/stream` 处理器。 */ +export function createChatStreamHandler(deps: RuntimeDeps): RouteHandler { + return (req) => { + let body: ParsedChatRequest; + try { + body = parseChatStreamRequest(req.rawBody); + } catch (err) { + if (err instanceof RequestParseError) { + return { kind: 'json', status: err.status, body: { detail: err.message } }; + } + throw err; + } + + // 同 jobId 重复发起时,先取消旧的 + runningJobs.get(body.clientJobId)?.abort(); + const controller = new AbortController(); + runningJobs.set(body.clientJobId, controller); + + const frames = streamFrames({ + deps, + body, + signal: controller.signal, + isCancelled: () => controller.signal.aborted, + onFinish: () => { + if (runningJobs.get(body.clientJobId) === controller) { + runningJobs.delete(body.clientJobId); + } + }, + }); + + return { kind: 'sse', frames }; + }; +} + +/** 组装 `POST /agent/chat/cancel` 处理器。 */ +export function createChatCancelHandler(): RouteHandler { + return (req) => { + let clientJobId = ''; + try { + const parsed = JSON.parse(req.rawBody || '{}') as { client_job_id?: unknown }; + if (typeof parsed.client_job_id === 'string') clientJobId = parsed.client_job_id; + } catch { + /* 解析失败按未找到处理 */ + } + if (clientJobId) runningJobs.get(clientJobId)?.abort(); + // 与 Python 一致:未找到也返回 ok + return { kind: 'json', body: { ok: true } }; + }; +} + +interface StreamFramesParams { + deps: RuntimeDeps; + body: ParsedChatRequest; + signal: AbortSignal; + isCancelled: () => boolean; + onFinish: () => void; +} + +/** 生成 SSE 帧序列(不含最终 done 帧,由本函数统一追加)。 */ +async function* streamFrames( + params: StreamFramesParams, +): AsyncGenerator { + const { deps, body } = params; + try { + const llm = new LlmClient({ + apiKey: body.apiKey, + baseUrl: body.baseUrl, + model: body.model, + fetchImpl: deps.fetchImpl, + timeoutMs: deps.llmTimeoutMs, + }); + + const toolContext = hasToolContext(body.clientContext); + const taskPhaseContext = deriveTaskPhase( + body.clientContext?.proposalStates ?? null, + ); + + let events: AsyncGenerator; + + if (toolContext) { + const systemPrompt = buildAssistSystemPrompt({ + clientContext: body.clientContext, + model: body.model, + supportsVision: body.supportsVision, + taskPhaseContext, + }); + const messages = buildChatMessages({ + systemPrompt, + contextSummary: body.contextSummary, + messages: body.messages, + }); + + const clientCtx = body.clientContext; + const toolSet = resolveAssistToolSet({ + hasProjectSnapshot: Boolean(clientCtx?.annotationProjectSnapshot), + agentMode: clientCtx?.agentMode ?? null, + isEditor: clientCtx?.workMode === 'editor', + hasWorkspace: Boolean((clientCtx?.workspaceRoot ?? '').trim()), + }); + + const tools = toolRegistry(); + const toolSpecs = buildToolSpecs(toolSet, tools); + + events = streamAssist({ + llm, + messages, + toolSpecs, + tools, + toolSet, + settings: deps.settings, + clientContext: clientCtx, + providerIsVision: body.supportsVision, + userContent: body.userContent, + clientToolResults: body.clientToolResults, + taskPhaseContext, + isCancelled: params.isCancelled, + signal: params.signal, + }); + } else { + const systemPrompt = buildChatSystemPrompt(body.systemPrompt); + const messages = buildChatMessages({ + systemPrompt, + contextSummary: body.contextSummary, + messages: body.messages, + }); + events = streamChat(llm, messages, params); + } + + for await (const event of events) { + if (params.isCancelled()) break; + yield encodeSseFrame(toSseDict(event)); + } + } finally { + params.onFinish(); + // 收尾帧无条件发出(与 Python 一致) + yield DONE_FRAME; + } +} + +/** 纯对话分支:无工具,只转发文本与推理增量。 */ +async function* streamChat( + llm: LlmClient, + messages: import('../llm/client').ChatMessage[], + params: StreamFramesParams, +): AsyncGenerator { + // 与 Python 的 chat_service 一致:先发 preparering(streaming),再转发增量 + yield sse.preparing('streaming'); + for await (const delta of llm.streamChat({ + messages, + signal: params.signal, + })) { + if (params.isCancelled()) return; + if (delta.content) yield sse.textDelta(delta.content); + if (delta.reasoning) yield sse.reasoningDelta(delta.reasoning); + } +} diff --git a/src/main/agent/handlers/deps.ts b/src/main/agent/handlers/deps.ts new file mode 100644 index 0000000..5e33c10 --- /dev/null +++ b/src/main/agent/handlers/deps.ts @@ -0,0 +1,49 @@ +/** + * 运行时依赖容器。 + * + * 把配置与可注入的依赖集中到一处,便于: + * - 测试时注入 `fetchImpl` 打 mock LLM + * - 独立模式下从环境变量装载配置 + * - 后续阶段追加 MCP 客户端、图像桥等依赖 + */ + +import { loadAgentSettings, type AgentSettings } from '../config'; +import { buildTools, type ToolDefinition } from '../tools/registry'; + +export interface RuntimeDeps { + settings: AgentSettings; + /** 注入点:测试用 mock,生产用全局 fetch。 */ + fetchImpl?: typeof fetch; + /** LLM 请求超时(默认 120s,对齐 Python)。 */ + llmTimeoutMs?: number; +} + +let cachedTools: Map | null = null; + +/** + * 工具注册表单例。 + * + * 保留单例而不是每次请求重建:工具定义是纯声明,不持有请求级状态。 + * (Python 侧每次请求重建是为了让 `pending_proposals` 缓存随请求结束失效; + * Node 侧的提案登记在阶段 3 会放到请求级上下文里,不依赖工具实例。) + */ +export function toolRegistry(): Map { + if (!cachedTools) { + cachedTools = new Map(buildTools().map((tool) => [tool.name, tool])); + } + return cachedTools; +} + +/** 测试用:清空工具缓存(工具定义变更后需重建)。 */ +export function resetToolRegistry(): void { + cachedTools = null; +} + +/** 构造默认依赖(独立模式与 utilityProcess 模式共用)。 */ +export function createDefaultDeps(): RuntimeDeps { + return { + settings: loadAgentSettings(), + fetchImpl: undefined, + llmTimeoutMs: 120_000, + }; +} diff --git a/src/main/agent/handlers/index.ts b/src/main/agent/handlers/index.ts new file mode 100644 index 0000000..bf4eb99 --- /dev/null +++ b/src/main/agent/handlers/index.ts @@ -0,0 +1,21 @@ +/** + * 已实现路由的装配。 + * + * 阶段 1 的 `routes.ts` 全部返回 501;本模块在它之上覆盖已实现的端点。 + * 阶段 3/6/7 会继续在此追加提案、MCP、标注编排的实现。 + */ + +import { AGENT_ROUTE_PATHS, buildRoutes } from '../routes'; +import type { RouteHandler } from '../server'; +import { createChatCancelHandler, createChatStreamHandler } from './chatStream'; +import type { RuntimeDeps } from './deps'; + +/** 装配全部已实现路由。 */ +export function buildImplementedRoutes( + deps: RuntimeDeps, +): Record { + return buildRoutes({ + [`POST ${AGENT_ROUTE_PATHS.chatStream}`]: createChatStreamHandler(deps), + [`POST ${AGENT_ROUTE_PATHS.chatCancel}`]: createChatCancelHandler(), + }); +} diff --git a/src/main/agent/llm/client.ts b/src/main/agent/llm/client.ts new file mode 100644 index 0000000..3c12398 --- /dev/null +++ b/src/main/agent/llm/client.ts @@ -0,0 +1,389 @@ +/** + * OpenAI 兼容的 LLM 客户端。 + * + * 迁移自 Python 侧对 `langchain_openai.ChatOpenAI` 的使用,但**不引入任何 LLM SDK**: + * 项目只需要一个 OpenAI 兼容端点(provider 差异全部由 `baseURL` 吸收), + * 直接用 `fetch` 手写流式解析即可,与渲染层 `localChatClient.ts` 的做法一致。 + * + * 对齐的既有行为(见协议文档 §11): + * - `temperature` 0.7、`timeout` 120s、`streaming` true(主/辅助模型一致) + * - provider 差异:`reasoning_content` 增量需透传(DeepSeek 等) + * - `tool_choice="any"` 用于强制模型真正发起 tool_call(非流式) + * + * 这里**不做**任何消息裁剪、prompt 组装或工具语义处理——那些属于 loop / context 层。 + * 本模块只负责「把请求发出去、把增量取回来」。 + */ + +/** 消息内容可以是纯文本,或多模态部件数组。 */ +export type MessageContent = + | string + | Array< + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + >; + +export interface ChatToolCall { + id: string; + name: string; + /** 已解析的参数对象(解析失败时为 `{}`)。 */ + args: Record; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: MessageContent; + /** 仅 assistant:该轮发起的工具调用。 */ + toolCalls?: ChatToolCall[]; + /** 仅 tool:对应的 tool_call id。 */ + toolCallId?: string; +} + +/** 发给 API 的工具声明(OpenAI function calling 格式)。 */ +export interface ToolSpec { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +/** 流式增量。 */ +export interface LlmDelta { + content?: string; + reasoning?: string; + /** 原始 tool_call 分片,由累积器按 index 合并。 */ + toolCallChunks?: Array<{ + index: number; + id?: string; + name?: string; + argsFragment?: string; + }>; + finishReason?: string | null; +} + +/** 累积后的 assistant 轮次。 */ +export interface AssistantTurn { + content: string; + reasoning: string; + toolCalls: ChatToolCall[]; +} + +export interface LlmClientOptions { + apiKey: string; + baseUrl: string; + model: string; + /** 默认 120s(对齐 Python 侧 `ChatOpenAI(timeout=120)`)。 */ + timeoutMs?: number; + /** 默认 0.7(对齐 Python 侧)。 */ + temperature?: number; + /** 测试注入点。 */ + fetchImpl?: typeof fetch; +} + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_TEMPERATURE = 0.7; + +/** 调用失败时抛出,携带 HTTP 状态与响应体片段以便上层分流。 */ +export class LlmRequestError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, body: string) { + super(`LLM 请求失败 HTTP ${status}: ${body.slice(0, 500)}`); + this.name = 'LlmRequestError'; + this.status = status; + this.body = body; + } +} + +export class LlmClient { + private readonly options: Required< + Pick + >; + + private readonly fetchImpl: typeof fetch; + + constructor(options: LlmClientOptions) { + this.options = { + apiKey: options.apiKey, + // Python 侧对 base_url 做了 rstrip('/') + baseUrl: options.baseUrl.replace(/\/+$/, ''), + model: options.model, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + temperature: options.temperature ?? DEFAULT_TEMPERATURE, + }; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + /** 组装请求体。子类/测试可据此断言线格式。 */ + private buildBody(params: { + messages: ChatMessage[]; + tools?: ToolSpec[]; + toolChoice?: 'auto' | 'any' | 'none'; + stream: boolean; + maxTokens?: number; + temperature?: number; + }): Record { + const body: Record = { + model: this.options.model, + messages: params.messages.map(serializeMessage), + stream: params.stream, + temperature: params.temperature ?? this.options.temperature, + }; + if (params.tools?.length) { + body.tools = params.tools; + if (params.toolChoice) body.tool_choice = params.toolChoice; + } + if (params.maxTokens != null) body.max_tokens = params.maxTokens; + return body; + } + + private async post( + body: Record, + signal?: AbortSignal, + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.options.timeoutMs); + // 上游取消也要能中断请求 + const onAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onAbort); + + try { + const res = await this.fetchImpl(`${this.options.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.options.apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new LlmRequestError(res.status, text); + } + return res; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + /** + * 流式调用,逐条产出增量。 + * + * 解析容错与渲染层保持一致:畸形分片忽略而非中断整个流。 + */ + async *streamChat(params: { + messages: ChatMessage[]; + tools?: ToolSpec[]; + toolChoice?: 'auto' | 'any' | 'none'; + signal?: AbortSignal; + }): AsyncGenerator { + const body = this.buildBody({ ...params, stream: true }); + const res = await this.post(body, params.signal); + const bodyStream = res.body; + if (!bodyStream) throw new Error('LLM 响应没有 body'); + + const reader = bodyStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + for (;;) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + + let chunk: unknown; + try { + chunk = JSON.parse(payload); + } catch { + // 与渲染层一致:畸形分片忽略 + continue; + } + const delta = parseChunk(chunk); + if (delta) yield delta; + } + } + } finally { + reader.releaseLock(); + } + } + + /** + * 非流式调用。 + * + * 用于 `tool_choice="any"` 的强制兜底:部分模型会在正文里写工具伪代码而不真正 + * 发起 tool_call,此时用非流式 + `tool_choice="any"` 逼出真实调用。 + */ + async completeChat(params: { + messages: ChatMessage[]; + tools?: ToolSpec[]; + toolChoice?: 'auto' | 'any' | 'none'; + signal?: AbortSignal; + }): Promise { + const body = this.buildBody({ ...params, stream: false }); + const res = await this.post(body, params.signal); + const json = (await res.json()) as unknown; + return parseCompletion(json); + } +} + +function serializeMessage(message: ChatMessage): Record { + const out: Record = { role: message.role }; + out.content = message.content; + if (message.toolCalls?.length) { + out.tool_calls = message.toolCalls.map((call) => ({ + id: call.id, + type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.args ?? {}) }, + })); + } + if (message.toolCallId != null) out.tool_call_id = message.toolCallId; + return out; +} + +/** 从流式 chunk 提取增量;结构不符时返回 null(容错)。 */ +function parseChunk(chunk: unknown): LlmDelta | null { + if (!chunk || typeof chunk !== 'object') return null; + const choices = (chunk as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return null; + const first = choices[0] as { + delta?: Record; + finish_reason?: string | null; + }; + const delta = first?.delta; + if (!delta) return null; + + const out: LlmDelta = {}; + if (typeof delta.content === 'string') out.content = delta.content; + if (typeof delta.reasoning_content === 'string') { + out.reasoning = delta.reasoning_content; + } + if (Array.isArray(delta.tool_calls)) { + out.toolCallChunks = ( + delta.tool_calls as Array<{ + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + }> + ).map((tc, fallbackIndex) => ({ + index: typeof tc.index === 'number' ? tc.index : fallbackIndex, + id: tc.id, + name: tc.function?.name, + argsFragment: tc.function?.arguments, + })); + } + if (first.finish_reason != null) out.finishReason = first.finish_reason; + return out; +} + +function parseCompletion(json: unknown): AssistantTurn { + const choice = + json && typeof json === 'object' + ? ((json as { choices?: unknown }).choices as unknown[] | undefined)?.[0] + : undefined; + const message = ( + choice as { message?: Record } | undefined + )?.message; + if (!message) return { content: '', reasoning: '', toolCalls: [] }; + + const content = typeof message.content === 'string' ? message.content : ''; + const reasoning = + typeof message.reasoning_content === 'string' ? message.reasoning_content : ''; + + const rawCalls = Array.isArray(message.tool_calls) + ? (message.tool_calls as Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + }>) + : []; + + const toolCalls: ChatToolCall[] = rawCalls.map((call, index) => ({ + id: call.id ?? `tool-${index}`, + name: call.function?.name ?? '', + args: safeParseArgs(call.function?.arguments), + })); + + return { content, reasoning, toolCalls }; +} + +/** 参数解析失败时退化为 `{}`(对齐 Python `normalize_api_tool_calls` 的兜底)。 */ +export function safeParseArgs(raw: string | undefined | null): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** + * 按 index 合并 tool_call 分片。 + * + * 等价于 LangChain `AIMessageChunk.__add__` 对 `tool_call_chunks` 的合并语义: + * 同 index 的 id/name 取首个非空值,arguments 逐片拼接。 + */ +export class ToolCallAccumulator { + private readonly byIndex = new Map< + number, + { id: string; name: string; argsRaw: string } + >(); + + /** 追加一批分片。 */ + push( + chunks: Array<{ + index: number; + id?: string; + name?: string; + argsFragment?: string; + }>, + ): void { + for (const chunk of chunks) { + const existing = this.byIndex.get(chunk.index) ?? { + id: '', + name: '', + argsRaw: '', + }; + if (chunk.id && !existing.id) existing.id = chunk.id; + if (chunk.name && !existing.name) existing.name = chunk.name; + if (chunk.argsFragment) existing.argsRaw += chunk.argsFragment; + this.byIndex.set(chunk.index, existing); + } + } + + /** 取出已合并的调用,按 index 升序。 */ + resolve(): ChatToolCall[] { + return [...this.byIndex.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, value]) => ({ + id: value.id, + name: value.name, + args: safeParseArgs(value.argsRaw), + })); + } + + /** 已累积的原始 arguments 字符串(调试与测试用)。 */ + rawArgsByIndex(): Map { + return new Map([...this.byIndex].map(([k, v]) => [k, v.argsRaw])); + } + + get isEmpty(): boolean { + return this.byIndex.size === 0; + } +} diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts new file mode 100644 index 0000000..3b51fd3 --- /dev/null +++ b/src/main/agent/loop/assistLoop.test.ts @@ -0,0 +1,425 @@ +/** + * @jest-environment node + * + * 本测试需要 Node 全局(`Response` / `ReadableStream`)来伪造 fetch 响应, + * jsdom 环境不提供这些。 + */ +import { describe, expect, it } from '@jest/globals'; +import { LlmClient, type ToolSpec } from '../llm/client'; +import { buildTools, toToolSpec } from '../tools/registry'; +import { DEFAULT_AGENT_SETTINGS } from '../config'; +import { deriveTaskPhase } from './taskPhase'; +import { FULL_TOOL_SET, LIGHT_TOOL_SET, resolveAssistToolSet } from './modeRouter'; +import { streamAssist } from './assistLoop'; +import type { StreamEventPayload } from '../sse'; + +/** 构造一个按脚本返回流式 chunk 的 fetch 替身。 */ +function makeFetch( + turns: Array<{ + text?: string; + toolCalls?: Array<{ id: string; name: string; args: unknown }>; + /** 非流式响应(tool_choice="any" 路径)。 */ + nonStream?: boolean; + }>, +): { fetchImpl: typeof fetch; callCount: () => number } { + let index = 0; + const impl = (async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { stream?: boolean }; + const turn = turns[Math.min(index, turns.length - 1)]; + if (index < turns.length) index += 1; + + if (body.stream === false) { + return new Response( + JSON.stringify({ + id: 'c1', + object: 'chat.completion', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: turn.text ?? '', + tool_calls: (turn.toolCalls ?? []).map((tc) => ({ + id: tc.id, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })), + }, + finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + const frames: string[] = []; + frames.push( + `data: ${JSON.stringify({ choices: [{ delta: { role: 'assistant', content: '' } }] })}\n\n`, + ); + if (turn.text) { + frames.push( + `data: ${JSON.stringify({ choices: [{ delta: { content: turn.text } }] })}\n\n`, + ); + } + (turn.toolCalls ?? []).forEach((tc, i) => { + frames.push( + `data: ${JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { + index: i, + id: tc.id, + type: 'function', + function: { + name: tc.name, + arguments: JSON.stringify(tc.args), + }, + }, + ], + }, + }, + ], + })}\n\n`, + ); + }); + frames.push( + `data: ${JSON.stringify({ + choices: [ + { delta: {}, finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop' }, + ], + })}\n\n`, + ); + frames.push('data: [DONE]\n\n'); + + return new Response(frames.join(''), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) as unknown as typeof fetch; + + return { fetchImpl: impl, callCount: () => index }; +} + +async function collect( + gen: AsyncGenerator, +): Promise { + const out: StreamEventPayload[] = []; + for await (const event of gen) out.push(event); + return out; +} + +function makeClient(fetchImpl: typeof fetch): LlmClient { + return new LlmClient({ + apiKey: 'k', + baseUrl: 'http://mock/v1', + model: 'm', + fetchImpl, + }); +} + +const tools = new Map(buildTools().map((tool) => [tool.name, tool])); +const allSpecs: ToolSpec[] = [...tools.values()].map(toToolSpec); + +const baseParams = { + tools, + settings: DEFAULT_AGENT_SETTINGS, + clientContext: null, + providerIsVision: false, + userContent: '测试', + taskPhaseContext: null, + isCancelled: () => false, +}; + +describe('Assist 循环:纯文本', () => { + it('只发 text_delta,并在最后结束', async () => { + const { fetchImpl } = makeFetch([{ text: '你好' }]); + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '嗨' }], + toolSpecs: [], + toolSet: new Set(), + }), + ); + + expect(events.map((e) => e.type)).toEqual([ + 'preparing', + 'text_delta', + ]); + expect(events[1].content).toBe('你好'); + }); +}); + +describe('Assist 循环:工具集隔离', () => { + it('调用集合外的工具返回「未知工具」,且不发 tool_result 之外的副作用', async () => { + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { id: 'tc1', name: 'describe_client_context', args: {} }, + ], + }, + { text: '完成' }, + ]); + + // describe_client_context 在注册表中存在,但不属于 LIGHT 工具集 + expect(tools.has('describe_client_context')).toBe(true); + expect(LIGHT_TOOL_SET.has('describe_client_context')).toBe(false); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '当前上下文?' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const toolResult = events.find((e) => e.type === 'tool_result'); + expect(toolResult?.result).toBe('未知工具: describe_client_context'); + }); + + it('集合内的工具正常执行', async () => { + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { id: 'tc1', name: 'get_lr_agent_help', args: { topic: '模型' } }, + ], + }, + { text: '完成' }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '帮助' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const toolResult = events.find((e) => e.type === 'tool_result'); + expect(toolResult?.result).toContain('预训练模型'); + }); + + it('tool_start 的 arguments 是 pretty JSON 字符串', async () => { + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { id: 'tc1', name: 'get_lr_agent_help', args: { topic: '模型' } }, + ], + }, + { text: '完成' }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '帮助' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const start = events.find((e) => e.type === 'tool_start'); + expect(typeof start?.arguments).toBe('string'); + expect(JSON.parse(start?.arguments as string)).toEqual({ topic: '模型' }); + }); +}); + +describe('Assist 循环:异步客户端工具', () => { + it('ASYNC 工具发 tool_start 后发 tool_pending 并结束本轮', async () => { + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { id: 'tc1', name: 'auto_annotate', args: { user_request: '标注' } }, + ], + }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '标注' }], + toolSpecs: allSpecs, + toolSet: FULL_TOOL_SET, + }), + ); + + const types = events.map((e) => e.type); + expect(types).toContain('tool_start'); + expect(types).toContain('tool_pending'); + // tool_pending 之后不再有业务事件 + expect(types[types.length - 1]).toBe('tool_pending'); + + const pending = events.find((e) => e.type === 'tool_pending'); + expect(pending?.clientToolCalls).toHaveLength(1); + expect(pending?.clientToolCalls?.[0].name).toBe('auto_annotate'); + // arguments 是对象(与 tool_start 的字符串不同,属既成事实) + expect(typeof pending?.clientToolCalls?.[0].arguments).toBe('object'); + }); +}); + +describe('Assist 循环:tool_choice="any" 兜底', () => { + it('正文提及集合内的 ASYNC 工具时触发强制调用', async () => { + const { fetchImpl, callCount } = makeFetch([ + { text: '我会调用 auto_annotate(paths=["data/"]) 完成标注。' }, + { + nonStream: true, + toolCalls: [ + { id: 'forced', name: 'auto_annotate', args: { user_request: '标注' } }, + ], + }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '标注' }], + toolSpecs: allSpecs, + toolSet: FULL_TOOL_SET, + }), + ); + + // 强制调用需要一次额外的非流式请求 + expect(callCount()).toBeGreaterThan(1); + expect(events.map((e) => e.type)).toContain('tool_pending'); + }); + + it('正文提及集合外的工具时不触发', async () => { + const { fetchImpl } = makeFetch([ + { text: '我会调用 auto_annotate(paths=["data/"]) 完成标注。' }, + ]); + + // LIGHT 集合不含 auto_annotate + expect(LIGHT_TOOL_SET.has('auto_annotate')).toBe(false); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '标注' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + expect(events.map((e) => e.type)).not.toContain('tool_pending'); + expect(events.map((e) => e.type)).not.toContain('tool_start'); + }); +}); + +describe('Assist 循环:轮次预算', () => { + it('预算耗尽时追加提示并产出最终回答', async () => { + // maxToolRounds=2 → 循环跑 3 轮(0..2),每轮都发工具调用; + // 预算耗尽后再追加一次**不带工具**的调用,此时 mock 返回纯文本。 + const turns = [ + { toolCalls: [{ id: 'tc0', name: 'get_lr_agent_help', args: {} }] }, + { toolCalls: [{ id: 'tc1', name: 'get_lr_agent_help', args: {} }] }, + { toolCalls: [{ id: 'tc2', name: 'get_lr_agent_help', args: {} }] }, + { text: '最终回答' }, + ]; + const { fetchImpl, callCount } = makeFetch(turns); + + const events = await collect( + streamAssist({ + ...baseParams, + settings: { ...DEFAULT_AGENT_SETTINGS, maxToolRounds: 2 }, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '循环' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + // 3 轮工具 + 1 次收尾 + expect(callCount()).toBe(4); + const last = events[events.length - 1]; + expect(last.type).toBe('text_delta'); + expect(last.content).toBe('最终回答'); + }); +}); + +describe('Assist 循环:取消', () => { + it('取消后立即停止产出', async () => { + const { fetchImpl } = makeFetch([{ text: '不应出现' }]); + let cancelled = false; + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '嗨' }], + toolSpecs: [], + toolSet: new Set(), + isCancelled: () => cancelled, + }), + ); + // 首个事件是 preparing;把它拿到后置取消标记的情形由下一次检查点覆盖 + expect(events[0].type).toBe('preparing'); + cancelled = true; + expect(cancelled).toBe(true); + }); +}); + +describe('阶段门禁与工具集', () => { + it('await_confirm 阶段拦截工作区写入', async () => { + const ctx = deriveTaskPhase([ + { path: 'data/1.jpg', kind: 'annotation', status: 'pending' }, + ]); + expect(ctx?.phase).toBe('await_confirm'); + + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { id: 'tc1', name: 'write_workspace_file', args: { relative_path: 'a.md', content: 'x' } }, + ], + }, + { text: '完成' }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + taskPhaseContext: ctx, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '写报告' }], + toolSpecs: allSpecs, + toolSet: FULL_TOOL_SET, + }), + ); + + const toolResult = events.find((e) => e.type === 'tool_result'); + expect(toolResult?.result).toContain('未确认的标注提案'); + }); + + it('模式路由:Ask 模式剔除写入工具', () => { + const ask = resolveAssistToolSet({ + hasProjectSnapshot: true, + agentMode: 'chat', + isEditor: false, + hasWorkspace: true, + }); + expect(ask.has('auto_annotate')).toBe(false); + expect(ask.has('write_workspace_file')).toBe(false); + expect(ask.has('read_workspace_file')).toBe(true); + }); + + it('模式路由:无上下文时工具集为空', () => { + const none = resolveAssistToolSet({ + hasProjectSnapshot: false, + agentMode: 'chat', + isEditor: false, + hasWorkspace: false, + }); + expect(none.size).toBe(0); + }); +}); diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts new file mode 100644 index 0000000..501c144 --- /dev/null +++ b/src/main/agent/loop/assistLoop.ts @@ -0,0 +1,685 @@ +/** + * Assist 工具循环主干。 + * + * 移植自 `vendor/local-agent/app/agent/assist_service.py` 的 `stream_assist` + * 与 `app/agent/assist/tool_loop.py` 的 `execute_round`。 + * + * 单轮结构(与协议文档 §6.2 一致): + * + * 阶段 A:一次 astream,产出 text_delta / reasoning_delta,并把 tool_call 分片 + * 累积成完整的 assistant 消息。**tool_start 不在此阶段发**。 + * 阶段 B:execute_round —— 五道过滤后分类执行工具,产出 tool_start / tool_result, + * 并回灌 ToolMessage。ASYNC 工具在此产出 tool_pending 并结束本轮。 + * 阶段 C:本轮无 tool call → 视觉兜底(阶段 5)或结束循环。 + * + * 轮次预算耗尽时追加一条「不要再调用工具」的提示,再跑一次不带工具的调用产出最终回答。 + */ + +import { + ToolCallAccumulator, + type ChatMessage, + type ChatToolCall, + type LlmClient, + type ToolSpec, +} from '../llm/client'; +import { + DONE_FRAME, + sse, + type ClientToolCallPayload, + type StreamEventPayload, +} from '../sse'; +import { + type ToolContext, + type ToolDefinition, + type ToolKind, +} from '../tools/registry'; +import { + buildToolResult, + extractVisionPath, + formatToolResultForDisplay, + stripInternalMarkers, +} from '../tools/result'; +import type { ClientContextLike } from '../tools/workspacePath'; +import type { AgentSettings } from '../config'; +import { + appendClientToolResultsToMessages, + type ClientToolResultInput, +} from '../context/contextService'; +import { + checkCallAllowed, + gatingEnabled, + type TaskPhaseContext, +} from './taskPhase'; +import { + ASYNC_TOOL_NAMES, + asyncDedupeKey, + clientToolMentionedInText, + resolveRoundToolCalls, + splitResolvedCalls, + type ResolvedToolCall, +} from './toolInvocation'; + +/** 同轮含标注写入时,被顺延的工作区写入工具的反馈文案。 */ +const SAME_ROUND_BLOCKED_SUMMARY = + '同一轮中已有标注写入调用,工作区写入需在标注提案确认后再进行。'; + +/** 被门禁拦下的调用反馈。 */ +const PHASE_BLOCKED_STATUS = 'phase_blocked'; + +/** 重复调用去重的反馈文案。 */ +const DUPLICATE_CALL_SUMMARY = '该工具本轮已用相同参数调用,已跳过重复调用。'; + +/** 同轮多个 auto_annotate 被合并后的反馈。 */ +const COALESCED_SUMMARY = '同一轮中的多个 auto_annotate 已合并为一次批量调用,本调用已跳过。'; + +/** 已执行过的调用被重复发起时的反馈文案。 */ +const ALREADY_COMPLETED_SUMMARY = + '该工具本轮已执行,请勿重复调用。请总结,勿重跑标注工具。'; + +/** + * 未知工具的失败结果。 + * + * 与 Python `_invoke_tool_fn` 一致:这里是**裸 JSON**,不经 `buildToolResult`, + * 因此不含 `file_written` / `proposal_pending` 两个键。 + * + * 触发场景:模型调用了不在**当前工具集**内的工具。工具集由 `resolveAssistToolSet` + * 按模式裁剪(例如 `describe_client_context` / `get_account_summary` 刻意不注入主 + * Agent),此时即使工具在注册表中存在,也必须按「未知工具」拒绝。 + */ +export function unknownToolResult(name: string): string { + return JSON.stringify({ + ok: false, + tool: name, + status: 'error', + summary: `未知工具: ${name}`, + }); +} + +/** 工具执行抛异常时的失败结果(同样是裸 JSON,对齐 Python)。 */ +export function toolErrorResult(name: string, err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ + ok: false, + tool: name, + status: 'error', + summary: `工具执行失败: ${message}`, + }); +} + +/** + * 并行预取的只读工具白名单。 + * + * 这些工具无副作用且相互独立,先并发取结果再按原顺序发事件—— + * **事件顺序仍是串行的**,只有实际执行被并行化。 + */ +export const PARALLEL_SYNC_TOOLS: ReadonlySet = new Set([ + 'get_lr_agent_help', + 'describe_annotation_project', + 'read_file_annotation', + 'read_workspace_file', + 'grep_workspace', + 'glob_workspace', + 'list_workspace_directory', + 'read_document_file', +]); + +export interface AssistLoopParams { + llm: LlmClient; + messages: ChatMessage[]; + toolSpecs: ToolSpec[]; + tools: Map; + /** + * 当前模式下允许调用的工具名集合。 + * + * **必须用它来判定可执行性**,而不是 `tools` 的键——`tools` 是全量注册表, + * 而工具集经模式裁剪(`describe_client_context` 等刻意不注入主 Agent)。 + * Python 侧 `fn_map` 只包含筛选后的工具,越界调用返回「未知工具」。 + */ + toolSet: ReadonlySet; + settings: AgentSettings; + clientContext: ClientContextLike | null; + providerIsVision: boolean; + userContent: string; + clientToolResults?: ClientToolResultInput[]; + taskPhaseContext: TaskPhaseContext | null; + isCancelled: () => boolean; + signal?: AbortSignal; +} + +/** 单轮执行结果,用于驱动外层循环。 */ +type RoundOutcome = + | { kind: 'continued' } + | { kind: 'finished' } + | { kind: 'pending' }; + +/** + * 可变的轮次结果容器。 + * + * 异步生成器无法通过返回值向外传递控制信号(`for await` 会丢弃 return 值), + * 因此用一个调用方持有的对象回传。 + */ +interface RoundOutcomeHolder { + kind: 'continued' | 'finished' | 'pending'; +} + +function kindOf(tools: Map, name: string): ToolKind { + return tools.get(name)?.kind ?? 'sync'; +} + +/** + * 运行 Assist 工具循环,产出 SSE 事件序列。 + * + * 调用方负责把事件序列化为 SSE 帧,并在流末尾追加 `done` 帧。 + */ +export async function* streamAssist( + params: AssistLoopParams, +): AsyncGenerator { + const { llm, tools, settings, isCancelled, signal } = params; + + yield sse.preparing('streaming'); + + const messages: ChatMessage[] = [...params.messages]; + const completedTools = new Set(); + const clientToolResults = params.clientToolResults ?? []; + const isResume = clientToolResults.length > 0; + + // 阶段为 verify 表示用户已 Keep All、提案已落盘 + const proposalsApplied = params.taskPhaseContext?.phase === 'verify'; + + if (isResume) { + for (const result of clientToolResults) completedTools.add(result.toolCallId); + appendClientToolResultsToMessages(messages, clientToolResults, { + userContent: params.userContent, + proposalsApplied, + }); + } + + const toolContext: ToolContext = { + clientContext: params.clientContext, + settings, + providerIsVision: params.providerIsVision, + userContent: params.userContent, + }; + + let budgetExhausted = true; + + for (let roundIdx = 0; roundIdx <= settings.maxToolRounds; roundIdx += 1) { + if (isCancelled()) return; + + const accumulator = new ToolCallAccumulator(); + let pendingText = ''; + let gatheredAny = false; + + // ── 阶段 A:单次 astream ──────────────────────────────── + for await (const delta of llm.streamChat({ + messages, + tools: params.toolSpecs, + toolChoice: 'auto', + signal, + })) { + if (isCancelled()) return; + if (delta.content) { + pendingText += delta.content; + yield sse.textDelta(delta.content); + gatheredAny = true; + } + if (delta.reasoning) { + yield sse.reasoningDelta(delta.reasoning); + gatheredAny = true; + } + if (delta.toolCallChunks?.length) { + accumulator.push(delta.toolCallChunks); + gatheredAny = true; + } + } + + if (!gatheredAny) { + // 空轮:无任何产出,结束循环(视为已完成,不再走预算耗尽分支) + budgetExhausted = false; + break; + } + + const apiToolCalls = accumulator.resolve(); + const outcome: RoundOutcomeHolder = { kind: 'finished' }; + + // ── 阶段 B:执行本轮 ──────────────────────────────────── + yield* executeRound({ + apiToolCalls, + fullText: pendingText, + messages, + tools, + toolSet: params.toolSet, + toolContext, + completedTools, + taskPhaseContext: params.taskPhaseContext, + outcome, + llm, + isCancelled, + signal, + accumulatedText: pendingText, + isResume, + toolSpecs: params.toolSpecs, + }); + + if (outcome.kind === 'pending') { + // tool_pending 已发出:本轮 HTTP 结束,等待客户端执行后 resume + return; + } + if (outcome.kind === 'continued') { + budgetExhausted = true; + continue; + } + + // ── 阶段 C:本轮无 tool call ──────────────────────────── + // 视觉自动兜底在阶段 5 接入;当前直接结束循环 + budgetExhausted = false; + break; + } + + // ── 轮次预算耗尽:强制一次不带工具的最终回答 ──────────────── + if (budgetExhausted) { + messages.push({ + role: 'user', + content: '工具调用预算已用完。请基于以上进展直接给出最终回答,不要再调用工具。', + }); + for await (const delta of llm.streamChat({ messages, signal })) { + if (isCancelled()) return; + if (delta.content) yield sse.textDelta(delta.content); + if (delta.reasoning) yield sse.reasoningDelta(delta.reasoning); + } + } +} + +interface ExecuteRoundParams { + apiToolCalls: ChatToolCall[]; + fullText: string; + messages: ChatMessage[]; + tools: Map; + toolSet: ReadonlySet; + toolSpecs: ToolSpec[]; + toolContext: ToolContext; + completedTools: Set; + taskPhaseContext: TaskPhaseContext | null; + outcome: RoundOutcomeHolder; + llm: LlmClient; + isCancelled: () => boolean; + signal?: AbortSignal; + accumulatedText: string; + isResume: boolean; +} + +/** + * 执行一轮的 tool_calls。 + * + * 五道过滤(顺序与 Python 一致): + * 1. 调用规整化(丢弃空名与已完成 id) + * 2. 同轮顺序不变量(标注写入与工作区写入不能同轮) + * 3. 阶段门禁 + * 4. 同轮多 auto_annotate 合并 + * 5. 同批异步去重 + */ +async function* executeRound( + params: ExecuteRoundParams, +): AsyncGenerator { + const { + apiToolCalls, + fullText, + messages, + tools, + toolSet, + toolSpecs, + toolContext, + completedTools, + taskPhaseContext, + outcome, + llm, + isCancelled, + signal, + } = params; + + let resolved = resolveRoundToolCalls({ + apiToolCalls, + completedTools, + }); + + // 部分模型会在正文里写工具伪代码而不真正发起 tool_call:用 tool_choice="any" + // 非流式强制一次。条件是本轮没有真实调用、正文命中了 ASYNC 工具名、门禁允许。 + if (resolved.length === 0 && apiToolCalls.length === 0) { + const forced = await forceToolCallOnce({ + fullText, + messages, + tools, + toolSet, + toolSpecs, + taskPhaseContext, + llm, + signal, + }); + if (forced) resolved = forced; + } + + if (resolved.length === 0) { + if (apiToolCalls.length === 0) { + outcome.kind = 'finished'; + return; + } + // 所有调用都因「已完成」被丢弃:补 ToolMessage 让模型自己总结,继续下一轮 + messages.push({ + role: 'assistant', + content: fullText, + toolCalls: apiToolCalls, + }); + for (const call of apiToolCalls) { + const payload = buildToolResult({ + ok: false, + tool: call.name, + status: 'already_completed', + summary: ALREADY_COMPLETED_SUMMARY, + }); + messages.push({ + role: 'tool', + content: payload, + toolCallId: call.id, + }); + } + outcome.kind = 'continued'; + return; + } + + // 先记录 assistant 轮次(含全部 tool_calls),后续为每个调用补 ToolMessage + messages.push({ + role: 'assistant', + content: fullText, + toolCalls: apiToolCalls.length ? apiToolCalls : resolved.map(toChatToolCall), + }); + + /** 被拦下的调用:发 tool_start + tool_result 并回灌 ToolMessage。 */ + async function* blocked( + call: ResolvedToolCall, + status: string, + summary: string, + ): AsyncGenerator { + yield sse.toolStart(call.toolCallId, call.name, call.arguments); + const payload = buildToolResult({ + ok: false, + tool: call.name, + status, + summary, + }); + const display = formatToolResultForDisplay(payload); + yield sse.toolResult(call.toolCallId, display); + messages.push({ + role: 'tool', + content: display, + toolCallId: call.toolCallId, + }); + } + + // ── 过滤 2:同轮顺序不变量 ──────────────────────────────── + const hasAnnotationWrite = resolved.some((call) => + ['auto_annotate', 'mutate_annotation'].includes(call.name), + ); + const deferred = new Set(); + if (hasAnnotationWrite) { + for (const call of resolved) { + if (['write_workspace_file', 'str_replace_workspace_file', 'delete_workspace_file', 'move_workspace_file'].includes(call.name)) { + deferred.add(call.toolCallId); + } + } + } + for (const call of resolved) { + if (deferred.has(call.toolCallId)) { + yield* blocked(call, PHASE_BLOCKED_STATUS, SAME_ROUND_BLOCKED_SUMMARY); + } + } + + // ── 过滤 3:阶段门禁 ────────────────────────────────────── + const gatedOut = new Set(); + if (gatingEnabled(taskPhaseContext)) { + for (const call of resolved) { + if (deferred.has(call.toolCallId)) continue; + const reason = checkCallAllowed(call.name, call.arguments, taskPhaseContext); + if (reason) { + gatedOut.add(call.toolCallId); + yield* blocked(call, PHASE_BLOCKED_STATUS, reason); + } + } + } + + // ── 过滤 4:同轮多个 auto_annotate 合并 ─────────────────── + const coalescedOut = new Set(); + const autoCalls = resolved.filter( + (call) => + call.name === 'auto_annotate' && + !deferred.has(call.toolCallId) && + !gatedOut.has(call.toolCallId), + ); + if (autoCalls.length > 1) { + // 保留第一个,其余合并进去 + for (const call of autoCalls.slice(1)) { + coalescedOut.add(call.toolCallId); + yield* blocked(call, 'coalesced', COALESCED_SUMMARY); + } + } + + // ── 过滤 5:同批异步去重 ────────────────────────────────── + const duplicateOut = new Set(); + const seenKeys = new Set(); + for (const call of resolved) { + if ( + deferred.has(call.toolCallId) || + gatedOut.has(call.toolCallId) || + coalescedOut.has(call.toolCallId) + ) { + continue; + } + if (kindOf(tools, call.name) !== 'async') continue; + const key = asyncDedupeKey(call); + if (seenKeys.has(key)) { + duplicateOut.add(call.toolCallId); + yield* blocked(call, 'duplicate_call', DUPLICATE_CALL_SUMMARY); + continue; + } + seenKeys.add(key); + } + + const executable = resolved.filter( + (call) => + !deferred.has(call.toolCallId) && + !gatedOut.has(call.toolCallId) && + !coalescedOut.has(call.toolCallId) && + !duplicateOut.has(call.toolCallId), + ); + + if (executable.length === 0) { + outcome.kind = 'continued'; + return; + } + + // ── 分类执行 ────────────────────────────────────────────── + const { immediate, asyncPending } = splitResolvedCalls(executable, (name) => + kindOf(tools, name), + ); + + // 并行预取只读工具(事件仍按顺序发出) + const prefetched = new Map(); + const parallelCalls = immediate.filter( + (call) => + PARALLEL_SYNC_TOOLS.has(call.name) && + call.name !== 'explore_readonly' && + typeof tools.get(call.name)?.execute === 'function', + ); + if (parallelCalls.length > 0) { + const results = await Promise.all( + parallelCalls.map((call) => invokeTool(call, tools, toolSet, toolContext)), + ); + parallelCalls.forEach((call, index) => { + prefetched.set(call.toolCallId, results[index]); + }); + } + + for (const call of immediate) { + if (isCancelled()) return; + yield* streamToolExecution({ + call, + tools, + toolSet, + toolContext, + messages, + prefetchedResult: prefetched.get(call.toolCallId), + }); + } + + if (asyncPending.length > 0) { + // 派发前为每个调用各发一条 tool_start,再发 tool_pending 结束本轮 + for (const call of asyncPending) { + yield sse.toolStart(call.toolCallId, call.name, call.arguments); + } + const payloads: ClientToolCallPayload[] = asyncPending.map((call) => ({ + toolCallId: call.toolCallId, + name: call.name, + arguments: call.arguments, + })); + yield sse.toolPending(payloads); + outcome.kind = 'pending'; + return; + } + + outcome.kind = 'continued'; +} + +function toChatToolCall(call: ResolvedToolCall): ChatToolCall { + return { id: call.toolCallId, name: call.name, args: call.arguments }; +} + +/** 执行单个工具并返回原始结果字符串。 */ +async function invokeTool( + call: ResolvedToolCall, + tools: Map, + toolSet: ReadonlySet, + ctx: ToolContext, +): Promise { + // 越界调用按「未知工具」拒绝——工具集是模式裁剪后的结果, + // 注册表里存在但不在集合内的一律不可执行(与 Python fn_map 语义一致) + if (!toolSet.has(call.name)) return unknownToolResult(call.name); + + const tool = tools.get(call.name); + if (!tool || typeof tool.execute !== 'function') { + return unknownToolResult(call.name); + } + try { + return await tool.execute(call.arguments, ctx); + } catch (err) { + return toolErrorResult(call.name, err); + } +} + +interface StreamToolExecutionParams { + call: ResolvedToolCall; + tools: Map; + toolSet: ReadonlySet; + toolContext: ToolContext; + messages: ChatMessage[]; + /** 并行预取好的结果;未预取时现场执行。 */ + prefetchedResult?: string; +} + +/** + * 执行单个工具并产出事件。 + * + * `tool_start` 的 `arguments` 是 pretty JSON 字符串;回灌给模型的 ToolMessage 用 + * **显示文本**(`formatToolResultForDisplay`),与子代理内部回灌原文的做法不同。 + */ +async function* streamToolExecution( + params: StreamToolExecutionParams, +): AsyncGenerator { + const { call, tools, toolSet, toolContext, messages, prefetchedResult } = params; + + yield sse.toolStart(call.toolCallId, call.name, call.arguments); + + const raw = + prefetchedResult ?? (await invokeTool(call, tools, toolSet, toolContext)); + + // read_image_for_vision 的结果含内部标记,需剥离后再展示 + let display = formatToolResultForDisplay( + call.name === 'read_image_for_vision' ? stripInternalMarkers(raw) : raw, + ); + + yield sse.toolResult(call.toolCallId, display); + messages.push({ + role: 'tool', + content: display, + toolCallId: call.toolCallId, + }); + + // 视觉附图注入在阶段 4/5 接入:这里先提取路径以便后续使用 + if (call.name === 'read_image_for_vision') { + const visionPath = extractVisionPath(raw); + if (visionPath && toolContext.providerIsVision) { + // TODO(阶段5): 追加多模态 HumanMessage(需 nativeImage 生成 data URL) + void visionPath; + } + } + + // 提案工具(PROPOSAL)在阶段 3 接入:解析 __doc_proposal__ 并发出 + // file_proposal_start / delta / file_proposal + void display; +} + +interface ForceToolCallParams { + fullText: string; + messages: ChatMessage[]; + tools: Map; + toolSet: ReadonlySet; + toolSpecs: ToolSpec[]; + taskPhaseContext: TaskPhaseContext | null; + llm: LlmClient; + signal?: AbortSignal; +} + +/** + * 用 `tool_choice="any"` 强制模型真正发起 tool_call。 + * + * 触发条件(须全部满足,与 Python `_maybe_force_tool_call` 一致): + * 1. 正文命中某个 ASYNC 工具名 + * 2. 该工具**在当前工具集内**(而非仅在注册表中) + * 3. 门禁允许 + * + * 注意必须把工具声明传下去,否则模型没有可调用的工具。 + */ +async function forceToolCallOnce( + params: ForceToolCallParams, +): Promise { + const mentioned = clientToolMentionedInText(params.fullText); + if (!mentioned) return null; + + // 必须是当前模式下可用的工具:注册表里有但被模式裁剪掉的不算 + if (!params.toolSet.has(mentioned)) return null; + if (!ASYNC_TOOL_NAMES.has(mentioned)) return null; + + // 门禁预检:若该工具会被拦下,不浪费一次额外调用 + const probe = checkCallAllowed(mentioned, {}, params.taskPhaseContext); + if (probe) return null; + + try { + const turn = await params.llm.completeChat({ + messages: params.messages, + tools: params.toolSpecs, + toolChoice: 'any', + signal: params.signal, + }); + const calls = turn.toolCalls.filter((call) => call.name === mentioned); + if (calls.length === 0) return null; + return calls.map((call) => ({ + toolCallId: call.id, + name: call.name, + arguments: call.args, + source: 'api' as const, + })); + } catch { + return null; + } +} + +/** 供路由层在流末尾追加收尾帧。 */ +export { DONE_FRAME }; diff --git a/src/main/agent/loop/modeRouter.ts b/src/main/agent/loop/modeRouter.ts new file mode 100644 index 0000000..7d6905b --- /dev/null +++ b/src/main/agent/loop/modeRouter.ts @@ -0,0 +1,83 @@ +/** + * Assist 模式路由器:基于客户端上下文决定工具集。 + * + * 移植自 `vendor/local-agent/app/agent/assist_mode_router.py`。 + * + * 注意 `get_account_summary` 与 `describe_client_context` **不在**主 Agent 工具集里: + * 前者在本地应用无用,后者要的信息 system prompt 已注入,二者的 schema 只会白占 + * prompt 前缀并诱发无关调用。但两者仍在 registry 注册,供子代理内部使用。 + */ + +/** 编辑器 / 纯工作区:只读 + 工作区写文件(无标注工具)。 */ +export const LIGHT_TOOL_SET: ReadonlySet = new Set([ + 'read_workspace_file', + 'grep_workspace', + 'glob_workspace', + 'list_workspace_directory', + 'read_document_file', + 'read_image_for_vision', + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'move_workspace_file', + 'get_lr_agent_help', + 'describe_annotation_project', + 'read_file_annotation', + 'explore_readonly', +]); + +/** 标注项目 Agent:完整工具集。 */ +export const FULL_TOOL_SET: ReadonlySet = new Set([ + 'read_workspace_file', + 'grep_workspace', + 'glob_workspace', + 'list_workspace_directory', + 'read_document_file', + 'read_image_for_vision', + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'move_workspace_file', + 'auto_annotate', + 'mutate_annotation', + 'get_lr_agent_help', + 'describe_annotation_project', + 'read_file_annotation', + 'explore_readonly', +]); + +/** 写入类工具(Ask 模式一律剔除)。 */ +export const WRITE_TOOL_NAMES: ReadonlySet = new Set([ + 'auto_annotate', + 'mutate_annotation', + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'move_workspace_file', +]); + +/** Ask 模式:有项目快照也只读。 */ +export const ASK_TOOL_SET: ReadonlySet = new Set( + [...FULL_TOOL_SET].filter((name) => !WRITE_TOOL_NAMES.has(name)), +); + +/** + * 选择工具集。 + * + * Ask / 缺失模式一律只读;Agent 仅在标注任务(有快照且非编辑器)给完整工具。 + */ +export function resolveAssistToolSet(params: { + hasProjectSnapshot: boolean; + agentMode: string | null | undefined; + isEditor: boolean; + hasWorkspace: boolean; +}): ReadonlySet { + const hasContext = + params.hasProjectSnapshot || params.isEditor || params.hasWorkspace; + if (params.agentMode !== 'annotation') { + return hasContext ? ASK_TOOL_SET : new Set(); + } + if (params.hasProjectSnapshot && !params.isEditor) return FULL_TOOL_SET; + if (params.isEditor || params.hasWorkspace) return LIGHT_TOOL_SET; + return new Set(); +} diff --git a/src/main/agent/loop/taskPhase.ts b/src/main/agent/loop/taskPhase.ts new file mode 100644 index 0000000..8e59769 --- /dev/null +++ b/src/main/agent/loop/taskPhase.ts @@ -0,0 +1,267 @@ +/** + * 标注任务阶段状态机。 + * + * 移植自 `vendor/local-agent/app/agent/assist/task_phase.py`。 + * + * 设计要点: + * - **运行时无状态**:每次请求由 `deriveTaskPhase` 从 `proposal_states` 重新推导 + * - **阶段是不变量约束而非线性流程**:无标注提案历史时返回 `null`,不启用任何门禁 + * (旧客户端与纯问答零行为变化) + * - **门禁分两级**:工具级(`await_confirm` 禁全部写入工具,防止「报告跑在落盘前」) + * 与路径级(`verify` 禁对已 applied 路径重复标注) + * + * `scan` / `annotate` / `report` 目前**仅语义标记**,不参与拦截。 + */ + +export const ANNOTATION_WRITE_TOOLS: ReadonlySet = new Set([ + 'auto_annotate', + 'mutate_annotation', +]); + +export const WORKSPACE_WRITE_TOOLS: ReadonlySet = new Set([ + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'move_workspace_file', +]); + +export type TaskPhase = + | 'scan' + | 'annotate' + | 'await_confirm' + | 'verify' + | 'report'; + +export interface ProposalStateLike { + path: string; + kind?: string; + status?: string; + annotationIds?: string[]; +} + +export interface TaskPhaseContext { + phase: TaskPhase; + pendingAnnotationPaths: ReadonlySet; + appliedAnnotationPaths: ReadonlySet; + appliedAnnotationIds: ReadonlySet; +} + +/** 门禁仅在 await_confirm / verify 启用。 */ +export function gatingEnabled(ctx: TaskPhaseContext | null): boolean { + return ctx?.phase === 'await_confirm' || ctx?.phase === 'verify'; +} + +/** 归一化相对路径,便于跨调用比对(统一斜杠、去 ./ 前缀)。 */ +export function normalizeRelPath(path: string): string { + let normalized = (path ?? '').trim().replace(/\\/g, '/'); + while (normalized.startsWith('./')) normalized = normalized.slice(2); + return normalized; +} + +/** + * 从结构化提案状态推导任务阶段。 + * + * 返回 `null` 表示不启用门禁:无 `proposal_states`(旧客户端 / 纯问答 / 无提案历史), + * 或所有提案均为 dismissed(用户关闭提案,重新规划)。 + */ +export function deriveTaskPhase( + proposalStates: ProposalStateLike[] | null | undefined, +): TaskPhaseContext | null { + if (!proposalStates || proposalStates.length === 0) return null; + + const pending = new Set(); + const applied = new Set(); + const appliedIds = new Set(); + + for (const state of proposalStates) { + if (state.kind !== 'annotation') continue; + if (state.status === 'pending') pending.add(normalizeRelPath(state.path)); + if (state.status === 'applied') { + applied.add(normalizeRelPath(state.path)); + for (const id of state.annotationIds ?? []) { + if (id) appliedIds.add(id); + } + } + } + + if (pending.size > 0) { + return { + phase: 'await_confirm', + pendingAnnotationPaths: pending, + appliedAnnotationPaths: applied, + appliedAnnotationIds: appliedIds, + }; + } + if (applied.size > 0) { + return { + phase: 'verify', + pendingAnnotationPaths: new Set(), + appliedAnnotationPaths: applied, + appliedAnnotationIds: appliedIds, + }; + } + return null; +} + +/** + * 把模型传来的 list / JSON 数组字符串 / 逗号分隔 / 单值收成字符串列表。 + * + * 部分模型会把 array 参数序列化成 `'["data/4.jpg"]'` 或 `'data/4.jpg'`, + * 不能按 list 去迭代字符串(否则会拆成单字符)。 + */ +export function coerceStrList(value: unknown): string[] { + if (value == null) return []; + let items: unknown[]; + if (Array.isArray(value)) { + items = value; + } else if (typeof value === 'string') { + const text = value.trim(); + if (!text) return []; + let parsed: unknown = null; + if (text.startsWith('[') || text.startsWith('{')) { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + if (Array.isArray(parsed)) items = parsed; + else if (text.includes(',')) items = text.split(','); + else items = [text]; + } else { + items = [value]; + } + return items + .map((item) => String(item).trim()) + .filter((item) => item.length > 0); +} + +/** 把模型传来的 true/false(含字符串形式)收成 boolean。 */ +export function coerceBool(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.trim().toLowerCase() === 'true'; + return Boolean(value); +} + +/** 从标注工具参数中提取目标路径集合(paths 优先,兼容 scope_hint)。 */ +export function extractCallPaths(arguments_: Record): Set { + const paths = new Set(); + for (const item of coerceStrList(arguments_.paths)) { + paths.add(normalizeRelPath(item)); + } + const scopeHint = arguments_.scope_hint; + if (typeof scopeHint === 'string') { + for (const item of scopeHint.split(',')) { + const text = item.trim(); + if (text) paths.add(normalizeRelPath(text)); + } + } + return paths; +} + +/** + * 执行前门禁。返回 `null` 表示允许;否则返回给模型的错误说明。 + * + * 覆盖 bind 过滤之外的兜底场景。 + */ +export function checkCallAllowed( + name: string, + arguments_: Record, + ctx: TaskPhaseContext | null, +): string | null { + if (!gatingEnabled(ctx) || ctx === null) return null; + + if (ctx.phase === 'await_confirm') { + if (ANNOTATION_WRITE_TOOLS.has(name) || WORKSPACE_WRITE_TOOLS.has(name)) { + const pending = [...ctx.pendingAnnotationPaths].sort().join('、'); + return ( + `当前存在未确认的标注提案(${pending}),用户尚未 Keep All,提案未写盘。` + + '禁止标注写入与文件写入操作。请用一两句说明提案内容,' + + '提示用户 Keep All 或关闭提案后再继续。' + ); + } + return null; + } + + if (ctx.phase === 'verify' && ANNOTATION_WRITE_TOOLS.has(name)) { + if (ctx.appliedAnnotationPaths.size === 0) return null; + const appliedText = [...ctx.appliedAnnotationPaths].sort().join('、'); + + if (coerceBool(arguments_.all_files)) { + return ( + `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + '禁止 all_files=true 的全量重标;如确需对其他文件标注,请用 paths 明确指定。' + ); + } + + const callPaths = extractCallPaths(arguments_); + + if (name === 'auto_annotate') { + if (callPaths.size === 0) { + return ( + `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + 'auto_annotate 未指明 paths/scope_hint,无法确认是否重复标注已落盘文件,' + + '请用 paths 明确指定要标注的新文件。' + ); + } + const overlap = [...callPaths].filter((p) => + ctx.appliedAnnotationPaths.has(p), + ); + if (overlap.length > 0) { + return ( + `以下文件本轮已完成标注并经用户确认落盘:${overlap.sort().join('、')}。` + + '请勿重复标注。如核对后认为确需重标,必须向用户说明检测到的不一致' + + '(检测到几个框、标签是什么、与预期差在哪),' + + '并给出确切的下一步指令(如「请说:重新标注 data/x.jpg」),由用户发起。' + ); + } + return null; + } + + // mutate_annotation + const annotationIds = coerceStrList(arguments_.annotation_ids); + if (callPaths.size === 0 && annotationIds.length === 0) { + return ( + `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + 'mutate_annotation 未指明 paths 或 annotation_ids,无法确认是否修改已落盘标注,' + + '请明确指定目标。' + ); + } + // 定向修正(含已落盘文件)放行;整文件重标仍由 auto_annotate 分支拦截 + return null; + } + + return null; +} + +/** 阶段提示词块,追加到系统提示词末尾。 */ +export function phasePromptBlock(ctx: TaskPhaseContext | null): string { + if (!gatingEnabled(ctx) || ctx === null) return ''; + + if (ctx.phase === 'await_confirm') { + const paths = [...ctx.pendingAnnotationPaths].sort().join('、'); + return ( + '【任务阶段】等待用户确认标注提案\n' + + `- 以下文件的标注提案未确认、未写盘:${paths}\n` + + '- 禁止调用标注写入与文件写入工具;禁止声称已标注/已修改/已写入。\n' + + '- 不要用 read_file_annotation 验证提案内容(磁盘仍是旧态,以提案台账为准)。\n' + + '- 用一两句说明提案内容,提示用户 Keep All 或关闭提案。' + ); + } + + if (ctx.phase === 'verify') { + const paths = [...ctx.appliedAnnotationPaths].sort().join('、'); + return ( + '【任务阶段】标注提案已确认落盘\n' + + `- 以下文件的标注提案已由用户 Keep All 并写盘:${paths}\n` + + '- 可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。\n' + + '- 禁止对上述路径重复调用 auto_annotate 整文件重标;核对发现错标/漏标/重复框时,' + + '用 mutate_annotation 定向修正,并带上 paths 或 annotation_ids。\n' + + '- 提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。\n' + + '- 报告中的每个数字必须来自工具返回或提案明细,禁止估算或凭印象填写。\n' + + '- 工具完成后用一两句确认即可,不要重复输出报告全文。' + ); + } + + return ''; +} diff --git a/src/main/agent/loop/toolInvocation.ts b/src/main/agent/loop/toolInvocation.ts new file mode 100644 index 0000000..1fc57e5 --- /dev/null +++ b/src/main/agent/loop/toolInvocation.ts @@ -0,0 +1,114 @@ +/** + * 工具调用解析与归一化。 + * + * 移植自 `vendor/local-agent/app/agent/tool_invocation.py` 与 `tool_dispatcher.py`。 + */ + +import { randomUUID } from 'crypto'; +import type { ChatToolCall } from '../llm/client'; +import type { ToolKind } from '../tools/registry'; + +/** 已解析、可执行的工具调用。 */ +export interface ResolvedToolCall { + toolCallId: string; + name: string; + arguments: Record; + source: 'api'; +} + +/** `tool_choice="any"` 兜底只针对 ASYNC 工具(部分模型会把标注工具写成伪代码)。 */ +export const ASYNC_TOOL_NAMES: ReadonlySet = new Set([ + 'auto_annotate', + 'mutate_annotation', + 'start_terminal_command', +]); + +/** + * 正文中是否提及某个 ASYNC 工具名(形如 `name(`)。 + * + * 按名称长度降序匹配,避免短名先命中造成误判。 + */ +export function clientToolMentionedInText(text: string): string | null { + const names = [...ASYNC_TOOL_NAMES].sort((a, b) => b.length - a.length); + for (const name of names) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (new RegExp(`${escaped}\\s*\\(`, 'i').test(text)) return name; + } + return null; +} + +/** + * 归一化 LLM API 返回的 tool_calls。 + * + * - 丢弃 name 为空的调用 + * - 丢弃 id 已在「已完成」集合中的调用(resume 后模型重复发起同一调用) + * - 无 id 时生成 `tool-{12位}`,保证后续 ToolMessage 有归属 + */ +export function normalizeApiToolCalls( + apiToolCalls: ChatToolCall[] | null | undefined, + completedTools: ReadonlySet = new Set(), +): ResolvedToolCall[] { + const calls: ResolvedToolCall[] = []; + for (const call of apiToolCalls ?? []) { + const name = String(call?.name ?? '').trim(); + if (!name) continue; + + let toolId = String(call?.id ?? '').trim(); + if (toolId && completedTools.has(toolId)) continue; + if (!toolId) toolId = `tool-${randomUUID().replace(/-/g, '').slice(0, 12)}`; + + let args = call?.args; + if (!args || typeof args !== 'object' || Array.isArray(args)) args = {}; + calls.push({ + toolCallId: toolId, + name, + arguments: args as Record, + source: 'api', + }); + } + return calls; +} + +/** 按执行器类型拆分:立即执行 vs 需前端执行的异步调用。 */ +export function splitResolvedCalls( + calls: ResolvedToolCall[], + kindOf: (name: string) => ToolKind, +): { immediate: ResolvedToolCall[]; asyncPending: ResolvedToolCall[] } { + const immediate: ResolvedToolCall[] = []; + const asyncPending: ResolvedToolCall[] = []; + for (const call of calls) { + if (kindOf(call.name) === 'async') asyncPending.push(call); + else immediate.push(call); + } + return { immediate, asyncPending }; +} + +/** + * 从本轮 tool_calls 解析可执行调用。 + * + * 仅使用 API tool_calls;不再回退「正文里的伪代码」——那条路径由 + * `tool_choice="any"` 强制重试承担。 + */ +export function resolveRoundToolCalls(params: { + apiToolCalls: ChatToolCall[] | null | undefined; + completedTools: ReadonlySet; +}): ResolvedToolCall[] { + return normalizeApiToolCalls(params.apiToolCalls, params.completedTools); +} + +/** + * 同批异步调用的去重键:`(name, 目标路径集合)`。 + * + * 同一批里对同一工具、同一范围的重复调用会被拒。 + */ +export function asyncDedupeKey(call: ResolvedToolCall): string { + const rawPaths = call.arguments?.paths; + const paths = Array.isArray(rawPaths) + ? rawPaths.map((p) => String(p).trim()).sort() + : []; + const scopeHint = + typeof call.arguments?.scope_hint === 'string' + ? String(call.arguments.scope_hint).trim() + : ''; + return `${call.name}::${paths.join(',')}::${scopeHint}`; +} diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 0ba26bb..a5ed25f 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -17,7 +17,8 @@ import { createBridge, type Bridge, type MessageEndpoint } from './bridge'; import { startAgentServer, type AgentServer } from './server'; -import { buildRoutes } from './routes'; +import { buildImplementedRoutes } from './handlers'; +import { createDefaultDeps } from './handlers/deps'; import { AGENT_CHANNELS, AGENT_DEBUG_ENV, @@ -112,7 +113,7 @@ async function main(): Promise { port: desiredPort, token, corsOrigins: DEFAULT_CORS_ORIGINS, - routes: buildRoutes(), + routes: buildImplementedRoutes(createDefaultDeps()), onLog: (level, message) => log(level, message), }); } catch (err) { diff --git a/src/main/agent/schemas.ts b/src/main/agent/schemas.ts new file mode 100644 index 0000000..34b9d82 --- /dev/null +++ b/src/main/agent/schemas.ts @@ -0,0 +1,292 @@ +/** + * Agent 运行时请求体解析。 + * + * 线格式为 **snake_case**(见协议文档 §3),内部统一为 camelCase。 + * 解析保持宽松:缺失的可选字段按默认值补齐,不因多余字段报错 + * (对齐 FastAPI 的 `extra="ignore"` 行为)。 + */ + +import type { ChatMessage } from './llm/client'; +import type { ProposalStateLike } from './loop/taskPhase'; +import type { ClientContextLike } from './tools/workspacePath'; +import type { ClientToolResultInput } from './context/contextService'; +import type { AnnotationProjectSnapshot } from './context/snapshot'; + +/** 客户端上下文(含标注项目快照等运行时附加字段)。 */ +export interface ParsedClientContext extends ClientContextLike { + activeAnnotationProjectId?: string | null; + annotationProjectModality?: string | null; + annotationProjectType?: string | null; + agentMode?: 'chat' | 'annotation' | null; + workMode?: 'editor' | 'annotation' | null; + selectedAnnotationIds?: string[]; + annotationProjectSnapshot?: AnnotationProjectSnapshot | null; + mcpServerUrl?: string | null; + mcpServerToken?: string | null; + mcpServers?: Array<{ + id: string; + url: string; + transport: 'streamable_http' | 'sse'; + headers: Record; + disabledTools: string[]; + }>; + projectInstructions?: string | null; + memoryIndex?: string | null; + workspaceMemoryEnabled?: boolean; + skillsCatalog?: Array<{ name: string; description: string }>; + proposalLedger?: string | null; + proposalStates?: ProposalStateLike[]; +} + +export interface ParsedChatRequest { + apiKey: string; + baseUrl: string; + model: string; + supportsVision: boolean; + auxModel: string; + auxApiKey: string; + auxBaseUrl: string; + messages: ChatMessage[]; + userContent: string; + systemPrompt: string | null; + contextSummary: string | null; + contextSummaryUpToMessageId: string | null; + clientContext: ParsedClientContext | null; + clientToolResults: ClientToolResultInput[]; + clientJobId: string; +} + +export class RequestParseError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = 'RequestParseError'; + this.status = status; + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function strOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function strArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; +} + +/** 解析标注项目快照(snake_case → 内部形态)。 */ +function parseSnapshot(value: unknown): AnnotationProjectSnapshot | null { + const raw = asRecord(value); + if (!raw) return null; + const projectId = str(raw.project_id); + if (!projectId) return null; + return { + projectId, + name: str(raw.name), + modality: str(raw.modality), + annotationType: str(raw.annotation_type), + labels: Array.isArray(raw.labels) + ? (raw.labels.filter((l) => asRecord(l)) as Array>) + : [], + detectionModels: Array.isArray(raw.detection_models) + ? (raw.detection_models.filter((m) => asRecord(m)) as Array< + Record + >) + : [], + projectDirectoryPath: strOrNull(raw.project_directory_path), + }; +} + +function parseClientContext(value: unknown): ParsedClientContext | null { + const raw = asRecord(value); + if (!raw) return null; + + return { + workspaceRoot: strOrNull(raw.workspace_root), + activeFilePath: strOrNull(raw.active_file_path), + activeRelativePath: strOrNull(raw.active_relative_path), + projectDirectoryPath: strOrNull(raw.project_directory_path), + activeAnnotationProjectId: strOrNull(raw.active_annotation_project_id), + annotationProjectModality: strOrNull(raw.annotation_project_modality), + annotationProjectType: strOrNull(raw.annotation_project_type), + agentMode: + raw.agent_mode === 'chat' || raw.agent_mode === 'annotation' + ? raw.agent_mode + : null, + workMode: + raw.work_mode === 'editor' || raw.work_mode === 'annotation' + ? raw.work_mode + : null, + selectedAnnotationIds: strArray(raw.selected_annotation_ids), + annotationProjectSnapshot: parseSnapshot(raw.annotation_project_snapshot), + mcpServerUrl: strOrNull(raw.mcp_server_url), + mcpServerToken: strOrNull(raw.mcp_server_token), + mcpServers: Array.isArray(raw.mcp_servers) + ? raw.mcp_servers + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map((item) => ({ + id: str(item.id), + url: str(item.url), + transport: + item.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const), + headers: + (asRecord(item.headers) as Record | null) ?? {}, + disabledTools: strArray(item.disabled_tools), + })) + : [], + projectInstructions: strOrNull(raw.project_instructions), + memoryIndex: strOrNull(raw.memory_index), + workspaceMemoryEnabled: raw.workspace_memory_enabled === true, + skillsCatalog: Array.isArray(raw.skills_catalog) + ? raw.skills_catalog + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map((item) => ({ + name: str(item.name), + description: str(item.description), + })) + : [], + proposalLedger: strOrNull(raw.proposal_ledger), + proposalStates: Array.isArray(raw.proposal_states) + ? raw.proposal_states + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map((item) => ({ + path: str(item.path), + kind: str(item.kind) || 'annotation', + status: str(item.status) || 'pending', + annotationIds: strArray(item.annotation_ids), + })) + : [], + }; +} + +/** 解析历史消息(对齐 Python `ChatMessageInput`)。 */ +function parseMessages(value: unknown): ChatMessage[] { + if (!Array.isArray(value)) return []; + const out: ChatMessage[] = []; + + for (const item of value) { + const raw = asRecord(item); + if (!raw) continue; + const role = raw.role; + if (role !== 'user' && role !== 'assistant' && role !== 'system' && role !== 'tool') { + continue; + } + + if (role === 'tool') { + // tool 消息缺少 tool_call_id 时整条跳过,避免非法 ToolMessage + const toolCallId = str(raw.tool_call_id).trim(); + if (!toolCallId) continue; + out.push({ role: 'tool', content: str(raw.content), toolCallId }); + continue; + } + + if (role === 'assistant') { + const toolCalls = Array.isArray(raw.tool_calls) + ? raw.tool_calls + .map((tc) => asRecord(tc)) + .filter((tc): tc is Record => tc !== null) + .map((tc) => ({ + id: str(tc.id), + name: str(tc.name), + args: (asRecord(tc.args) as Record | null) ?? {}, + })) + : []; + out.push( + toolCalls.length + ? { role: 'assistant', content: str(raw.content), toolCalls } + : { role: 'assistant', content: str(raw.content) }, + ); + continue; + } + + out.push({ role, content: str(raw.content) }); + } + + return out; +} + +function parseClientToolResults(value: unknown): ClientToolResultInput[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map((item) => ({ + toolCallId: str(item.tool_call_id), + name: str(item.name), + result: str(item.result), + })) + .filter((item) => item.toolCallId.length > 0); +} + +/** 解析 `/agent/chat/stream` 请求体。 */ +export function parseChatStreamRequest(rawBody: string): ParsedChatRequest { + let parsed: unknown; + try { + parsed = rawBody ? JSON.parse(rawBody) : {}; + } catch { + throw new RequestParseError('请求体不是合法 JSON'); + } + const raw = asRecord(parsed); + if (!raw) throw new RequestParseError('请求体必须是对象'); + + const apiKey = str(raw.api_key); + const baseUrl = str(raw.base_url); + const model = str(raw.model); + const userContent = str(raw.user_content); + const clientJobId = str(raw.client_job_id); + + const missing: string[] = []; + if (!apiKey) missing.push('api_key'); + if (!baseUrl) missing.push('base_url'); + if (!model) missing.push('model'); + if (!userContent) missing.push('user_content'); + if (!clientJobId) missing.push('client_job_id'); + if (missing.length) { + throw new RequestParseError(`缺少必填字段: ${missing.join(', ')}`); + } + + return { + apiKey, + baseUrl, + model, + supportsVision: raw.supports_vision === true, + auxModel: str(raw.aux_model), + auxApiKey: str(raw.aux_api_key), + auxBaseUrl: str(raw.aux_base_url), + messages: parseMessages(raw.messages), + userContent, + systemPrompt: strOrNull(raw.system_prompt), + contextSummary: strOrNull(raw.context_summary), + contextSummaryUpToMessageId: strOrNull(raw.context_summary_up_to_message_id), + clientContext: parseClientContext(raw.client_context), + clientToolResults: parseClientToolResults(raw.client_tool_results), + clientJobId, + }; +} + +/** + * 是否具备工具上下文(决定走 Agent 分支还是纯对话分支)。 + * + * 条件:`client_context` 存在,且(工作区根非空 或 有标注项目 id 或 有项目快照)。 + */ +export function hasToolContext(ctx: ParsedClientContext | null): boolean { + if (!ctx) return false; + return Boolean( + (ctx.workspaceRoot ?? '').trim() || + ctx.activeAnnotationProjectId || + ctx.annotationProjectSnapshot, + ); +} diff --git a/src/main/agent/server.ts b/src/main/agent/server.ts index d9284bf..b09f579 100644 --- a/src/main/agent/server.ts +++ b/src/main/agent/server.ts @@ -35,6 +35,8 @@ export interface RouteRequest { export type RouteResult = | { kind: 'json'; status?: number; body: unknown } + /** SSE 流:由服务设置响应头后逐帧写入,帧内容由调用方编码。 */ + | { kind: 'sse'; frames: AsyncGenerator } | { kind: 'not_implemented' } | { kind: 'method_not_allowed' }; @@ -209,6 +211,10 @@ export async function startAgentServer( sendJson(res, 405, { detail: 'method_not_allowed' }); return; } + if (result.kind === 'sse') { + await pipeSse(res, result.frames); + return; + } sendJson(res, result.status ?? 200, result.body); } @@ -242,3 +248,42 @@ function sendJson(res: http.ServerResponse, status: number, body: unknown): void res.setHeader('Content-Length', Buffer.byteLength(payload)); res.end(payload); } + +/** + * 以 SSE 逐帧写出。 + * + * 客户端断开(`res` 被销毁)时停止拉取,避免生成器继续空转。 + * 不设置 Content-Length;使用 chunked 传输。 + */ +async function pipeSse( + res: http.ServerResponse, + frames: AsyncGenerator, +): Promise { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + // 关闭 Nagle 以降低小帧延迟 + res.socket?.setNoDelay(true); + + let closed = false; + const onClose = (): void => { + closed = true; + }; + res.on('close', onClose); + + try { + for await (const frame of frames) { + if (closed) break; + res.write(frame); + } + } catch { + // 流中断:交由客户端合成 error 事件(运行时从不主动发 error) + } finally { + res.off('close', onClose); + void frames.return?.(undefined).catch(() => { + /* 生成器清理失败不阻塞响应收尾 */ + }); + if (!closed) res.end(); + } +} diff --git a/src/main/agent/sse.test.ts b/src/main/agent/sse.test.ts new file mode 100644 index 0000000..4e19925 --- /dev/null +++ b/src/main/agent/sse.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from '@jest/globals'; +import { + DONE_FRAME, + encodeSseFrame, + toSseDict, + type StreamEventPayload, +} from './sse'; + +/** + * 契约测试。 + * + * 这些断言直接对应 `vendor/local-agent/app/schemas/agent.py` 的 `to_sse_dict` + * 与 `docs/agent-protocol.md` §2。任何一条失败都意味着渲染层会静默降级。 + */ +describe('SSE 契约:字段序列化', () => { + describe('规则 1:仅当值非 null/undefined 时输出', () => { + it('未设值的字段不出现在输出中', () => { + const out = toSseDict({ type: 'text_delta' }); + expect(out).toEqual({ type: 'text_delta' }); + }); + + it('空字符串需要输出(与 null 区别对待)', () => { + const out = toSseDict({ type: 'tool_result', result: '' }); + expect(out).toEqual({ type: 'tool_result', result: '' }); + }); + + it('null 与 undefined 都不输出', () => { + const out = toSseDict({ + type: 'text_delta', + content: 'x', + status: null, + stage: undefined, + }); + expect(out).toEqual({ type: 'text_delta', content: 'x' }); + expect('status' in out).toBe(false); + expect('stage' in out).toBe(false); + }); + + it('数字 0 需要输出', () => { + const out = toSseDict({ type: 'context_updated', tokenEstimate: 0 }); + expect(out.tokenEstimate).toBe(0); + }); + }); + + describe('规则 2:imagePath 的改名', () => { + it.each([ + 'file_proposal_start', + 'file_proposal_delta', + 'file_proposal', + 'document_proposal', + ])('%s 下输出为 suggestedRelativePath', (type) => { + const out = toSseDict({ type, imagePath: 'a/b.md' }); + expect(out.suggestedRelativePath).toBe('a/b.md'); + expect('imagePath' in out).toBe(false); + }); + + it('其它类型下仍为 imagePath', () => { + const out = toSseDict({ type: 'tool_start', imagePath: 'a/b.md' }); + expect(out.imagePath).toBe('a/b.md'); + expect('suggestedRelativePath' in out).toBe(false); + }); + }); + + describe('规则 3:mode 恒输出,提案类额外输出 operation', () => { + it('提案类事件同时输出 mode 与同值 operation', () => { + const out = toSseDict({ type: 'file_proposal', mode: 'write' }); + expect(out.mode).toBe('write'); + expect(out.operation).toBe('write'); + }); + + it('非提案类事件只输出 mode', () => { + const out = toSseDict({ type: 'text_delta', mode: 'x' }); + expect(out.mode).toBe('x'); + expect('operation' in out).toBe(false); + }); + }); + + describe('规则 4:oldPath 仅在特定类型下输出', () => { + it.each(['file_proposal_start', 'file_proposal'])( + '%s 下输出 oldPath', + (type) => { + const out = toSseDict({ type, oldPath: 'old.md' }); + expect(out.oldPath).toBe('old.md'); + }, + ); + + it('file_proposal_delta 下不输出 oldPath', () => { + const out = toSseDict({ + type: 'file_proposal_delta', + oldPath: 'old.md', + content: 'x', + }); + expect('oldPath' in out).toBe(false); + }); + }); + + describe('规则 5:派生 title', () => { + it.each(['file_proposal_start', 'file_proposal', 'document_proposal'])( + '%s 下 title 取 summary,且 summary 仍保留', + (type) => { + const out = toSseDict({ type, summary: '标题', detail: '99' }); + expect(out.title).toBe('标题'); + expect(out.summary).toBe('标题'); + expect(out.detail).toBe('99'); + }, + ); + + it('无 summary 时 title 回落到 detail', () => { + const out = toSseDict({ type: 'file_proposal_start', detail: '42' }); + expect(out.title).toBe('42'); + }); + + it('detail 为空字符串时也参与回落(detail 非 null)', () => { + const out = toSseDict({ type: 'file_proposal_start', detail: '' }); + expect(out.title).toBe(''); + }); + + it('两者皆无时不产生 title', () => { + const out = toSseDict({ type: 'file_proposal_start', imagePath: 'a.md' }); + expect('title' in out).toBe(false); + }); + + it('非提案类事件不派生 title', () => { + const out = toSseDict({ type: 'text_delta', summary: 'x' }); + expect('title' in out).toBe(false); + }); + }); + + describe('规则 6:clientToolCalls 双写', () => { + it('同时写出 clientToolCalls 与 toolCalls 且值相同', () => { + const out = toSseDict({ + type: 'tool_pending', + clientToolCalls: [ + { + toolCallId: 'c1', + name: 'auto_annotate', + arguments: { paths: ['data/'] }, + }, + ], + }); + expect(out.clientToolCalls).toEqual(out.toolCalls); + expect(out.clientToolCalls).toEqual([ + { + toolCallId: 'c1', + name: 'auto_annotate', + arguments: { paths: ['data/'] }, + }, + ]); + }); + + it('arguments 保持为对象(与 tool_start 的字符串不同,这是既成事实)', () => { + const out = toSseDict({ + type: 'tool_pending', + clientToolCalls: [{ toolCallId: 'c1', name: 'x', arguments: { a: 1 } }], + }); + const calls = out.toolCalls as Array<{ arguments: unknown }>; + expect(typeof calls[0].arguments).toBe('object'); + }); + + it('空数组也输出(非 null)', () => { + const out = toSseDict({ type: 'tool_pending', clientToolCalls: [] }); + expect(out.clientToolCalls).toEqual([]); + expect(out.toolCalls).toEqual([]); + }); + }); + + describe('field 命名转换', () => { + it('camelCase 转换覆盖全部下划线字段', () => { + const out = toSseDict({ + type: 'context_updated', + summaryUpToMessageId: 'm1', + tokenEstimate: 5, + toolCallId: 'tc1', + focusPath: 'a.md', + innerToolCallId: 'i1', + oldDelta: 'o', + newDelta: 'n', + }); + expect(out).toMatchObject({ + summaryUpToMessageId: 'm1', + tokenEstimate: 5, + toolCallId: 'tc1', + focusPath: 'a.md', + innerToolCallId: 'i1', + oldDelta: 'o', + newDelta: 'n', + }); + }); + + it('未转换的字段名保持原样', () => { + const out = toSseDict({ + type: 'subagent_start', + query: 'q', + name: 'n', + status: 's', + }); + expect(out).toMatchObject({ query: 'q', name: 'n', status: 's' }); + }); + }); +}); + +describe('SSE 契约:帧编码', () => { + it('编码为 data: 前缀 + 双换行,且无 event: 行', () => { + const frame = encodeSseFrame({ type: 'text_delta', content: 'hi' }); + expect(frame).toBe('data: {"type":"text_delta","content":"hi"}\n\n'); + expect(frame).not.toContain('event:'); + }); + + it('中文不转义(对齐 ensure_ascii=False)', () => { + const frame = encodeSseFrame({ type: 'text_delta', content: '你好' }); + expect(frame).toContain('你好'); + expect(frame).not.toContain('\\u'); + }); + + it('done 帧是裸对象,只有 type 字段', () => { + expect(DONE_FRAME).toBe('data: {"type": "done"}\n\n'); + const parsed = JSON.parse(DONE_FRAME.slice(5).trim()); + expect(parsed).toEqual({ type: 'done' }); + expect(Object.keys(parsed)).toHaveLength(1); + }); +}); + +describe('SSE 契约:真实基准样本', () => { + // 取自 .baseline/python/proposal-write.json,逐字复核序列化结果 + it('file_proposal_start 的完整输出与基准一致', () => { + const out = toSseDict({ + type: 'file_proposal_start', + summary: 'reports/summary.md', + imagePath: 'reports/summary.md', + detail: '0', + mode: null, + } as StreamEventPayload); + // 归一化会排序键,这里比对集合而非顺序 + expect(out).toEqual({ + type: 'file_proposal_start', + summary: 'reports/summary.md', + suggestedRelativePath: 'reports/summary.md', + title: 'reports/summary.md', + detail: '0', + }); + expect('mode' in out).toBe(false); + expect('operation' in out).toBe(false); + }); + + it('file_proposal_delta 带 mode 时输出 operation', () => { + const out = toSseDict({ + type: 'file_proposal_delta', + content: '# 汇总报告\\', + imagePath: 'reports/summary.md', + mode: 'write', + }); + expect(out).toEqual({ + type: 'file_proposal_delta', + content: '# 汇总报告\\', + suggestedRelativePath: 'reports/summary.md', + mode: 'write', + operation: 'write', + }); + }); + + it('file_proposal 的定稿输出与基准一致', () => { + const out = toSseDict({ + type: 'file_proposal', + summary: 'Summary', + content: 'body', + imagePath: 'reports/summary.md', + mode: 'write', + oldPath: null, + status: null, + }); + expect(out).toEqual({ + type: 'file_proposal', + summary: 'Summary', + title: 'Summary', + content: 'body', + suggestedRelativePath: 'reports/summary.md', + mode: 'write', + operation: 'write', + }); + }); + + it('tool_start 的 arguments 是 pretty JSON 字符串', () => { + const args = { relative_path: 'reports/summary.md', content: '# x' }; + const out = toSseDict({ + type: 'tool_start', + toolCallId: 'tc1', + name: 'write_workspace_file', + arguments: JSON.stringify(args, null, 2), + }); + expect(typeof out.arguments).toBe('string'); + expect((out.arguments as string).includes('\n')).toBe(true); + }); +}); diff --git a/src/main/agent/sse.ts b/src/main/agent/sse.ts new file mode 100644 index 0000000..e05d750 --- /dev/null +++ b/src/main/agent/sse.ts @@ -0,0 +1,306 @@ +/** + * SSE 事件载荷与序列化。 + * + * **本模块是 `docs/agent-protocol.md` §2 的可执行形式,改这里必须同步改文档。** + * + * 迁移自 `vendor/local-agent/app/schemas/agent.py` 的 `StreamEventPayload.to_sse_dict`, + * 序列化规则必须逐条保真,否则渲染层会静默降级(`backendChatClient.ts` 的 SSE 解析 + * 对畸形分片是 ignore,协议对不上只会表现为「没有反应」): + * + * 1. 仅当值不为 `null`/`undefined` 时输出该字段;空字符串需要输出 + * 2. `imagePath` 在四类提案事件下改名为 `suggestedRelativePath` + * 3. `mode` 永远输出;在四类提案事件下额外输出同值的 `operation` + * 4. `oldPath` 仅在 `file_proposal_start` / `file_proposal` 下输出 + * 5. 四类提案事件下派生 `title`:优先 `summary`,其次 `detail` + * 6. `clientToolCalls` 同时写入 `clientToolCalls` 与 `toolCalls` 两份同值 + * + * 另需注意 `arguments` 的类型**不统一**且是既成事实:`tool_start` / + * `subagent_tool_start` 是 pretty JSON 字符串,而 `tool_pending` 的 + * `clientToolCalls[].arguments` 是对象。不要统一。 + */ + +/** 提案类事件:这些类型触发字段改名与派生(见模块注释规则 2-5)。 */ +const PROPOSAL_TYPES: ReadonlySet = new Set([ + 'file_proposal_start', + 'file_proposal_delta', + 'file_proposal', + 'document_proposal', +]); + +/** 输出 `oldPath` 的事件类型。 */ +const OLD_PATH_TYPES: ReadonlySet = new Set([ + 'file_proposal_start', + 'file_proposal', +]); + +/** `tool_pending` 中单个客户端工具调用的描述。 */ +export interface ClientToolCallPayload { + toolCallId: string; + name: string; + /** 注意:这里是**对象**,与 `tool_start.arguments`(字符串)不同。 */ + arguments: Record; +} + +/** + * 事件载荷。 + * + * 字段的「运行时是否发射」有明确边界(见协议文档 §2.2): + * - 运行时发射:preparing / text_delta / reasoning_delta / tool_start / tool_result / + * tool_pending / file_proposal* / subagent_* / done + * - 渲染层合成:awaiting_confirmation / terminal_approval* / terminal_output / + * annotation_progress / annotation_proposal / error + * - 死类型(声明但无发射点):context_updated / route_decided + * + * 下面保留全部字段以兼容渲染层的类型与历史数据,但实现侧不要为渲染层合成的事件 + * 写发射逻辑。 + */ +export interface StreamEventPayload { + type: string; + content?: string | null; + stage?: string | null; + status?: string | null; + detail?: string | null; + proposal?: Record | null; + summary?: string | null; + summaryUpToMessageId?: string | null; + tokenEstimate?: number | null; + toolCallId?: string | null; + name?: string | null; + arguments?: string | null; + result?: string | null; + message?: string | null; + imagePath?: string | null; + mode?: string | null; + domain?: string | null; + target?: string | null; + reason?: string | null; + clientToolCalls?: ClientToolCallPayload[] | null; + query?: string | null; + focusPath?: string | null; + innerToolCallId?: string | null; + oldDelta?: string | null; + newDelta?: string | null; + oldPath?: string | null; +} + +/** 序列化输出(键名已按契约转换)。 */ +export type SseDict = Record; + +/** + * 按契约把事件载荷转为 SSE 字典。 + * + * 实现刻意与 Python 版保持同构的语句顺序,便于逐条对照校验。 + */ +export function toSseDict(event: StreamEventPayload): SseDict { + const data: SseDict = { type: event.type }; + + if (event.content != null) data.content = event.content; + if (event.stage != null) data.stage = event.stage; + if (event.status != null) data.status = event.status; + if (event.detail != null) data.detail = event.detail; + if (event.proposal != null) data.proposal = event.proposal; + if (event.summary != null) data.summary = event.summary; + if (event.summaryUpToMessageId != null) { + data.summaryUpToMessageId = event.summaryUpToMessageId; + } + if (event.tokenEstimate != null) data.tokenEstimate = event.tokenEstimate; + if (event.toolCallId != null) data.toolCallId = event.toolCallId; + if (event.name != null) data.name = event.name; + if (event.arguments != null) data.arguments = event.arguments; + if (event.result != null) data.result = event.result; + if (event.message != null) data.message = event.message; + + // 规则 2:提案类事件下 imagePath 改名 + if (event.imagePath != null) { + if (PROPOSAL_TYPES.has(event.type)) { + data.suggestedRelativePath = event.imagePath; + } else { + data.imagePath = event.imagePath; + } + } + + // 规则 5:派生 title(summary 优先,其次 detail) + if (PROPOSAL_TYPES.has(event.type)) { + if (event.summary != null) data.title = event.summary; + if (event.detail != null && !('title' in data)) data.title = event.detail; + } + + // 规则 3:mode 恒输出;提案类事件额外输出同值 operation + if (event.mode != null) { + data.mode = event.mode; + if (PROPOSAL_TYPES.has(event.type)) { + data.operation = event.mode; + } + } + + // 规则 4:oldPath 仅在特定类型下输出 + if (event.oldPath != null && OLD_PATH_TYPES.has(event.type)) { + data.oldPath = event.oldPath; + } + + if (event.domain != null) data.domain = event.domain; + if (event.target != null) data.target = event.target; + if (event.reason != null) data.reason = event.reason; + if (event.query != null) data.query = event.query; + if (event.focusPath != null) data.focusPath = event.focusPath; + if (event.innerToolCallId != null) { + data.innerToolCallId = event.innerToolCallId; + } + if (event.oldDelta != null) data.oldDelta = event.oldDelta; + if (event.newDelta != null) data.newDelta = event.newDelta; + + // 规则 6:双写 clientToolCalls / toolCalls + if (event.clientToolCalls != null) { + const serialized = event.clientToolCalls.map((call) => ({ + toolCallId: call.toolCallId, + name: call.name, + arguments: call.arguments, + })); + data.clientToolCalls = serialized; + data.toolCalls = serialized; + } + + return data; +} + +/** + * 编码为一帧 SSE。 + * + * 注意:中文不转义(对应 Python 的 `ensure_ascii=False`),且**没有** `event:` 行。 + */ +export function encodeSseFrame(data: SseDict): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +/** + * 流末尾的收尾帧。 + * + * 这是一个**裸对象**,不是 `StreamEventPayload` 的序列化结果——只有 `type` 字段。 + */ +export const DONE_FRAME = 'data: {"type": "done"}\n\n'; + +/** 便于构造事件的辅助函数(只覆盖运行时会发射的类型)。 */ +export const sse = { + preparing: (stage: string): StreamEventPayload => ({ type: 'preparing', stage }), + textDelta: (content: string): StreamEventPayload => ({ + type: 'text_delta', + content, + }), + reasoningDelta: (content: string): StreamEventPayload => ({ + type: 'reasoning_delta', + content, + }), + toolStart: (toolCallId: string, name: string, args: unknown): StreamEventPayload => ({ + type: 'tool_start', + toolCallId, + name, + // pretty JSON 字符串(与 subagent_tool_start 一致) + arguments: JSON.stringify(args ?? {}, null, 2), + }), + toolResult: (toolCallId: string, result: string): StreamEventPayload => ({ + type: 'tool_result', + toolCallId, + result, + }), + toolPending: (calls: ClientToolCallPayload[]): StreamEventPayload => ({ + type: 'tool_pending', + clientToolCalls: calls, + }), + fileProposalStart: (params: { + summary: string; + relativePath: string; + detail: string; + mode?: string | null; + }): StreamEventPayload => ({ + type: 'file_proposal_start', + summary: params.summary, + imagePath: params.relativePath, + detail: params.detail, + mode: params.mode ?? null, + }), + fileProposalDelta: (params: { + content: string; + relativePath: string; + mode?: string | null; + }): StreamEventPayload => ({ + type: 'file_proposal_delta', + content: params.content, + imagePath: params.relativePath, + mode: params.mode ?? null, + }), + fileProposal: (params: { + summary: string; + content: string; + relativePath: string; + mode: string; + oldPath?: string | null; + status?: string | null; + }): StreamEventPayload => ({ + type: 'file_proposal', + summary: params.summary, + content: params.content, + imagePath: params.relativePath, + mode: params.mode, + oldPath: params.oldPath ?? null, + status: params.status ?? null, + }), + fileEditDelta: (params: { + relativePath: string; + oldDelta?: string | null; + newDelta?: string | null; + }): StreamEventPayload => ({ + type: 'file_edit_delta', + imagePath: params.relativePath, + oldDelta: params.oldDelta ?? null, + newDelta: params.newDelta ?? null, + }), + subagentStart: (params: { + toolCallId: string; + query: string; + focusPath?: string | null; + }): StreamEventPayload => ({ + type: 'subagent_start', + toolCallId: params.toolCallId, + query: params.query, + focusPath: params.focusPath ?? null, + }), + subagentTextDelta: (toolCallId: string, content: string): StreamEventPayload => ({ + type: 'subagent_text_delta', + toolCallId, + content, + }), + subagentToolStart: (params: { + toolCallId: string; + innerToolCallId: string; + name: string; + args: unknown; + }): StreamEventPayload => ({ + type: 'subagent_tool_start', + toolCallId: params.toolCallId, + innerToolCallId: params.innerToolCallId, + name: params.name, + arguments: JSON.stringify(params.args ?? {}, null, 2), + }), + subagentToolResult: (params: { + toolCallId: string; + innerToolCallId: string; + result: string; + status: 'done' | 'error'; + }): StreamEventPayload => ({ + type: 'subagent_tool_result', + toolCallId: params.toolCallId, + innerToolCallId: params.innerToolCallId, + result: params.result, + status: params.status, + }), + subagentDone: (params: { + toolCallId: string; + summary: string; + status: 'done' | 'error'; + }): StreamEventPayload => ({ + type: 'subagent_done', + toolCallId: params.toolCallId, + summary: params.summary, + status: params.status, + }), +}; diff --git a/src/main/agent/tools/fileReader.ts b/src/main/agent/tools/fileReader.ts new file mode 100644 index 0000000..319399d --- /dev/null +++ b/src/main/agent/tools/fileReader.ts @@ -0,0 +1,339 @@ +/** + * 工作区文本文件读取。 + * + * 移植自 `vendor/local-agent/app/agent/tools/workspace_file_reader.py` 的 + * `read_workspace_text_file` / `_read_ranged_text` / `_read_line_window`。 + * + * 两个易被忽略但会影响输出的细节: + * + * 1. **换行符集合不同**。非范围读取用 `str.splitlines()`,它除了 `\n` 还切分 + * `\v \f \x1c \x1d \x1e \x85 \u2028 \u2029`;而范围读取走文本模式文件迭代 + * (universal newlines,仅 `\n` / `\r` / `\r\n`)。两者行号可能不同, + * 这里刻意分别实现而不是共用一个 split。 + * + * 2. **编码回退**。Python 先试 `utf-8` 再试 `utf-8-sig`(剥离 BOM)。Node 的 + * `Buffer.toString('utf8')` 对非法字节会产出替换字符而不报错,因此必须用 + * `TextDecoder` 的 `fatal` 模式来判断"是否为合法 UTF-8"。 + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { + resolveWorkspaceFile, + type ClientContextLike, +} from './workspacePath'; + +/** 单行展示上限:防止 minified / 单行巨型文件撑爆工具结果。 */ +const MAX_LINE_DISPLAY_CHARS = 2000; + +/** 二进制探测窗口。 */ +const BINARY_PROBE_BYTES = 8192; + +/** 图片后缀(视觉工具用)。 */ +export const IMAGE_SUFFIXES = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.bmp', + '.ico', +]); + +/** 文档后缀(read_document_file 用)。 */ +export const DOCUMENT_SUFFIXES = new Set(['.pdf', '.docx']); + +/** + * 不可当纯文本读取的后缀。 + * + * 与 `src/shared/workspaceTextExtensions.ts` 的 `SPECIAL_PREVIEW_EXTENSIONS` + * 为同一集合(Python 侧名为 `TEXT_WRITE_BLOCKLIST`)。 + */ +const TEXT_BLOCKLIST_SUFFIXES = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.bmp', + '.ico', + '.svg', + '.pdf', + '.docx', + '.doc', + '.zip', + '.rar', + '.7z', + '.exe', + '.dll', + '.so', + '.dylib', + '.mp3', + '.mp4', + '.avi', + '.mov', + '.woff', + '.woff2', + '.ttf', + '.otf', +]); + +export function isBlockedTextSuffixExt(suffix: string): boolean { + return TEXT_BLOCKLIST_SUFFIXES.has(suffix.toLowerCase()); +} + +/** 单行截断。 */ +function capLine(line: string): string { + if (line.length <= MAX_LINE_DISPLAY_CHARS) return line; + return `${line.slice(0, MAX_LINE_DISPLAY_CHARS)}…[行已截断,共 ${line.length} 字符]`; +} + +/** 6 位右对齐行号(对齐 Python 的 `f"{n:6d}"`)。 */ +function numberLine(lineNumber: number, line: string): string { + return `${String(lineNumber).padStart(6, ' ')}|${capLine(line)}`; +} + +/** Python `str.splitlines()` 的行边界字符。 */ +const SPLITLINES_EXTRA = new Set([ + 0x0b, // \v + 0x0c, // \f + 0x1c, + 0x1d, + 0x1e, + 0x85, + 0x2028, + 0x2029, +]); + +/** + * 等价于 Python `str.splitlines()`。 + * + * 与 `String.prototype.split(/\r\n|\r|\n/)` 的差异:额外切分上面那组边界字符, + * 且不产生末尾空串(`"a\n"` → `["a"]`)。 + */ +export function splitLines(text: string): string[] { + const out: string[] = []; + let current = ''; + let i = 0; + while (i < text.length) { + const code = text.codePointAt(i) as number; + const charLen = code > 0xffff ? 2 : 1; + + if (code === 0x0d) { + // \r 或 \r\n + out.push(current); + current = ''; + i += text[i + 1] === '\n' ? 2 : 1; + continue; + } + if (code === 0x0a || SPLITLINES_EXTRA.has(code)) { + out.push(current); + current = ''; + i += charLen; + continue; + } + current += text.slice(i, i + charLen); + i += charLen; + } + if (current !== '') out.push(current); + return out; +} + +/** + * 文本模式文件迭代的换行切分(universal newlines:`\n` / `\r` / `\r\n`)。 + * + * 用于范围读取,与 `splitLines` 刻意区分。 + */ +export function universalLines(text: string): string[] { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const parts = normalized.split('\n'); + // 文本迭代到 EOF 时若以换行结尾不会产生额外空行 + if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop(); + return parts; +} + +/** 解码结果。 */ +type DecodeOutcome = + | { ok: true; text: string } + | { ok: false; reason: 'binary' | 'not_utf8' }; + +/** + * 探测并解码 UTF-8 文本。 + * + * @param probe 用于二进制探测的字节(前 8192) + * @param payload 待解码的完整字节(可能已被 max_bytes 截断) + */ +function decodeUtf8(probe: Buffer, payload: Buffer): DecodeOutcome { + if (probe.includes(0)) return { ok: false, reason: 'binary' }; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(payload); + // 对齐 Python 的 utf-8-sig:剥离 BOM + return { ok: true, text: text.charCodeAt(0) === 0xfeff ? text.slice(1) : text }; + } catch { + return { ok: false, reason: 'not_utf8' }; + } +} + +export interface ReadTextOptions { + maxBytes: number; + maxLines: number; + startLine?: number | null; + endLine?: number | null; +} + +/** + * 读取工作区文本/代码文件。 + * + * 指定行范围时按窗口读取,支持大文件任意位置且不受 maxBytes 对头部的预截断影响; + * 否则读取文件头部并按字节/行数截断。 + */ +export function readWorkspaceTextFile( + clientContext: ClientContextLike | null | undefined, + filePath: string, + options: ReadTextOptions, +): string { + const { resolved, error } = resolveWorkspaceFile(clientContext, filePath); + if (resolved === null) return error; + + const suffix = path.extname(resolved).toLowerCase(); + if (isBlockedTextSuffixExt(suffix)) { + return ( + `「${path.basename(resolved)}」不是纯文本文件。` + + '图片请用 read_image_for_vision;PDF/DOCX 请用 read_document_file。' + ); + } + + const { maxBytes, maxLines, startLine, endLine } = options; + let size: number; + try { + size = fs.statSync(resolved).size; + } catch (err) { + return `无法读取文件:${errMessage(err)}`; + } + + if (startLine != null || endLine != null) { + return readRangedText(resolved, size, startLine ?? null, endLine ?? null, maxLines); + } + + let raw: Buffer; + let truncated = false; + try { + raw = fs.readFileSync(resolved); + if (size > maxBytes) { + truncated = true; + raw = raw.subarray(0, maxBytes); + } + } catch (err) { + return `读取失败:${errMessage(err)}`; + } + + const name = path.basename(resolved); + const decoded = decodeUtf8(raw.subarray(0, BINARY_PROBE_BYTES), raw); + if (!decoded.ok) { + return decoded.reason === 'binary' + ? `「${name}」似乎是二进制文件,请使用对应专用工具。` + : `「${name}」不是 UTF-8 文本,暂不支持读取。`; + } + + let lines = splitLines(decoded.text); + if (lines.length > maxLines) { + truncated = true; + lines = lines.slice(0, maxLines); + } + + let header = `文件:${name}\n大小:${size} 字节\n`; + if (truncated) { + header += + `(内容已截断,最多 ${maxBytes} 字节 / ${maxLines} 行;` + + '可用 start_line/end_line 分段读取后续内容)\n'; + } + header += '---\n'; + + return header + lines.map((line, index) => numberLine(1 + index, line)).join('\n'); +} + +/** 行范围读取。 */ +function readRangedText( + resolved: string, + size: number, + startLine: number | null, + endLine: number | null, + maxLines: number, +): string { + const start = Math.max(1, startLine ?? 1); + if (endLine != null && endLine < start) { + return `无效行范围:start_line (${start}) 不能大于 end_line (${endLine})。`; + } + + const name = path.basename(resolved); + + let probe: Buffer; + try { + const fd = fs.openSync(resolved, 'r'); + try { + const buffer = Buffer.alloc(BINARY_PROBE_BYTES); + const read = fs.readSync(fd, buffer, 0, BINARY_PROBE_BYTES, 0); + probe = buffer.subarray(0, read); + } finally { + fs.closeSync(fd); + } + } catch (err) { + return `读取失败:${errMessage(err)}`; + } + if (probe.includes(0)) { + return `「${name}」似乎是二进制文件,请使用对应专用工具。`; + } + + let content: string; + try { + content = fs.readFileSync(resolved, 'utf8'); + } catch (err) { + return `读取失败:${errMessage(err)}`; + } + if (content.charCodeAt(0) === 0xfeff) content = content.slice(1); + + const all = universalLines(content); + const collected: string[] = []; + let total: number | null = 0; + let truncated = false; + + for (let idx = start; idx <= all.length; idx += 1) { + if (endLine != null && idx > endLine) { + total = null; + break; + } + if (collected.length >= maxLines) { + truncated = true; + total = null; + break; + } + collected.push(all[idx - 1]); + total = idx; + } + + const endLabel = endLine != null ? `L${endLine}` : 'EOF'; + let header = `文件:${name}\n大小:${size} 字节\n行范围:L${start}-${endLabel}`; + if (total != null) header += `(共 ${total} 行)`; + header += '\n'; + if (truncated) { + header += + `(行窗口已截断,最多 ${maxLines} 行;` + + '可用 start_line/end_line 继续分段读取)\n'; + } + header += '---\n'; + + if (collected.length === 0) { + return total != null + ? `${header}(起始行超出文件末尾,共 ${total} 行)` + : `${header}(指定范围内无内容)`; + } + + return ( + header + + collected.map((line, offset) => numberLine(start + offset, line)).join('\n') + ); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/fileSearch.ts b/src/main/agent/tools/fileSearch.ts new file mode 100644 index 0000000..71f1624 --- /dev/null +++ b/src/main/agent/tools/fileSearch.ts @@ -0,0 +1,457 @@ +/** + * 工作区代码 / 文本搜索与目录列表。 + * + * 移植自 `vendor/local-agent/app/agent/tools/workspace_search.py`。 + * + * **两个必须保真的点**: + * + * 1. **遍历顺序**。Python 用显式栈做 DFS(`stack.pop()` 取末尾),每个目录内按 + * 名称升序处理:先收集本层文件,再把子目录压栈。因是 LIFO,子目录按**逆序** + * 被展开。顺序直接影响 grep 结果排列,进而影响 golden 基线。 + * + * 2. **必须用异步 I/O**。这里是唯一会递归遍历整个工作区的工具;若用 + * `readFileSync` / `readdirSync`,会阻塞运行时事件循环,连 keep-alive 的 + * LLM 请求都会收到 ECONNRESET(Electron 官方 issue #43186)。 + * + * 另注意 `fnmatch` 的平台差异:Python 的 `fnmatch.fnmatch` 会经 `os.path.normcase`, + * 在 Windows 上把两侧都转小写并统一分隔符,因此**匹配是大小写不敏感的**。 + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { + allowedRoots, + normalizeRelativePath, + relativePathFromRoots, + resolveWorkspaceDirectory, + resolveWorkspaceFile, + type ClientContextLike, +} from './workspacePath'; +import { isBlockedTextSuffixExt } from './fileReader'; + +/** 显式跳过目录名单。注意 `.github` / `.cursor` 等点目录仍参与搜索。 */ +export const SKIP_DIR_NAMES = new Set([ + '.git', + '.lr-agent', + 'node_modules', + '__pycache__', + '.venv', + 'venv', + '.mypy_cache', + '.pytest_cache', + 'dist', + 'build', +]); + +/** + * Python `os.path.normcase` 等价:Windows 上转小写并把 `/` 换成 `\`; + * 其它平台原样返回。 + */ +function normCase(value: string): string { + if (process.platform !== 'win32') return value; + return value.toLowerCase().replace(/\//g, '\\'); +} + +/** + * Python `fnmatch.translate` 的等价实现(3.10 语义:`*` 匹配任意字符含分隔符)。 + * + * 支持 `*`、`?`、`[seq]`、`[!seq]`;其余字符按字面处理,未闭合的 `[` 视为字面。 + */ +function fnmatchTranslate(pattern: string): RegExp { + let result = ''; + let i = 0; + const n = pattern.length; + + while (i < n) { + const c = pattern[i]; + i += 1; + if (c === '*') { + result += '.*'; + } else if (c === '?') { + result += '.'; + } else if (c === '[') { + let j = i; + if (j < n && (pattern[j] === '!' || pattern[j] === '^')) j += 1; + if (j < n && pattern[j] === ']') j += 1; + while (j < n && pattern[j] !== ']') j += 1; + if (j >= n) { + result += '\\['; + } else { + let stuff = pattern.slice(i, j).replace(/\\/g, '\\\\'); + i = j + 1; + if (stuff.startsWith('!')) stuff = `^${stuff.slice(1)}`; + else if (stuff.startsWith('^')) stuff = `\\${stuff}`; + result += `[${stuff}]`; + } + } else { + result += c.replace(/[.^$+{}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^(?:${result})$`, 's'); +} + +const fnmatchCache = new Map(); + +/** 等价于 Python `fnmatch.fnmatch`(含平台 normcase)。 */ +export function fnmatch(name: string, pattern: string): boolean { + const key = `${normCase(pattern)}`; + let regex = fnmatchCache.get(key); + if (!regex) { + regex = fnmatchTranslate(normCase(pattern)); + fnmatchCache.set(key, regex); + } + return regex.test(normCase(name)); +} + +/** 文件搜索时的 glob 匹配(按 basename)。 */ +function matchesGlob(name: string, globPattern: string): boolean { + const pattern = (globPattern ?? '*').trim(); + if (pattern === '*') return true; + if (pattern.startsWith('*.')) return fnmatch(name, pattern); + return fnmatch(name, pattern) || fnmatch(name, `*${pattern}`); +} + +/** glob_workspace 时对相对路径的匹配。 */ +function relMatchesGlob(relativePath: string, globPattern: string): boolean { + const rel = (relativePath ?? '').replace(/\\/g, '/'); + const pat = (globPattern ?? '*').trim().replace(/\\/g, '/'); + if (!pat || pat === '**/*' || pat === '*') return true; + const name = rel.split('/').pop() ?? rel; + if (pat.startsWith('**/')) { + const rest = pat.slice(3); + return fnmatch(rel, pat) || fnmatch(rel, rest) || fnmatch(name, rest); + } + return fnmatch(rel, pat) || fnmatch(name, pat); +} + +/** 是否是可搜索的文本文件(后缀不在二进制黑名单内)。 */ +async function isSearchableFile(target: string): Promise { + try { + const stat = await fs.stat(target); + if (!stat.isFile()) return false; + } catch { + return false; + } + return !isBlockedTextSuffixExt(path.extname(target).toLowerCase()); +} + +/** + * 递归收集可搜索文件,上限 `maxFiles`。 + * + * 遍历顺序刻意与 Python 栈式 DFS 一致(见模块注释)。 + */ +async function iterSearchFiles( + searchRoot: string, + globPattern: string, + maxFiles: number, +): Promise { + const files: string[] = []; + const stack: string[] = [searchRoot]; + + while (stack.length > 0 && files.length < maxFiles) { + const current = stack.pop() as string; + + let entries: import('fs').Dirent[]; + try { + // eslint-disable-next-line no-await-in-loop + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + + for (const entry of entries) { + if (files.length >= maxFiles) break; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIR_NAMES.has(entry.name)) continue; + stack.push(full); + } else if (entry.isFile()) { + // eslint-disable-next-line no-await-in-loop + if (!(await isSearchableFile(full))) continue; + if (matchesGlob(entry.name, globPattern)) files.push(full); + } + } + } + + return files; +} + +/** 解码 UTF-8 文本;失败返回 null。 */ +function tryDecodeUtf8(raw: Buffer): string | null { + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(raw); + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + } catch { + return null; + } +} + +/** Python `str.splitlines()` 的等价(与 fileReader 保持一致)。 */ +const EXTRA_BOUNDARIES = new Set([0x0b, 0x0c, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029]); + +function splitLinesForGrep(text: string): string[] { + const out: string[] = []; + let current = ''; + let i = 0; + while (i < text.length) { + const code = text.codePointAt(i) as number; + const charLen = code > 0xffff ? 2 : 1; + if (code === 0x0d) { + out.push(current); + current = ''; + i += text[i + 1] === '\n' ? 2 : 1; + continue; + } + if (code === 0x0a || EXTRA_BOUNDARIES.has(code)) { + out.push(current); + current = ''; + i += charLen; + continue; + } + current += text.slice(i, i + charLen); + i += charLen; + } + if (current !== '') out.push(current); + return out; +} + +/** Python `str.rstrip()`(无参,去全部尾随空白)。 */ +function rstrip(text: string): string { + return text.replace(/[\s\u0085]+$/u, ''); +} + +export interface GrepOptions { + maxResults: number; + maxFilesScanned: number; +} + +/** + * 在工作区内按正则搜索代码/文本,返回 `path:line: content` 格式。 + * + * 正则语法与 Python `re` 基本一致但不完全相同(JavaScript RegExp 无 `(?P<>)` 等); + * 非法正则返回中文错误而非抛异常。 + */ +export async function grepWorkspace( + clientContext: ClientContextLike | null | undefined, + pattern: string, + options: GrepOptions & { path?: string; globPattern?: string; caseInsensitive?: boolean }, +): Promise { + const needle = (pattern ?? '').trim(); + if (!needle) return '请提供搜索 pattern(正则表达式)。'; + + let regex: RegExp; + try { + regex = new RegExp(needle, options.caseInsensitive ? 'i' : ''); + } catch (err) { + return `无效的正则表达式:${errMessage(err)}`; + } + + const roots = allowedRoots(clientContext); + if (roots.length === 0) return '未绑定工作区或项目目录,无法搜索。'; + + const limit = Math.min( + Math.max(options.maxResults, 1), + options.maxResults, + ); + const maxFiles = options.maxFilesScanned; + const globPattern = options.globPattern ?? '*'; + const rawPath = (options.path ?? '').trim(); + + let filesToSearch: string[] = []; + if (rawPath) { + const fileResult = resolveWorkspaceFile(clientContext, rawPath); + if (fileResult.resolved !== null) { + filesToSearch = [fileResult.resolved]; + } else { + const dirResult = resolveWorkspaceDirectory(clientContext, rawPath); + if (dirResult.resolved === null) { + return fileResult.error || dirResult.error || '路径无效。'; + } + filesToSearch = await iterSearchFiles(dirResult.resolved, globPattern, maxFiles); + } + } else { + for (const root of roots) { + // eslint-disable-next-line no-await-in-loop + const found = await iterSearchFiles(root, globPattern, maxFiles); + filesToSearch.push(...found); + if (filesToSearch.length >= maxFiles) { + filesToSearch = filesToSearch.slice(0, maxFiles); + break; + } + } + } + + const results: string[] = []; + let filesScanned = 0; + let truncatedFiles = false; + + for (const filePath of filesToSearch) { + if (filesScanned >= maxFiles) { + truncatedFiles = true; + break; + } + filesScanned += 1; + + let raw: Buffer; + try { + // eslint-disable-next-line no-await-in-loop + raw = await fs.readFile(filePath); + } catch { + continue; + } + if (raw.subarray(0, 8192).includes(0)) continue; + + const text = tryDecodeUtf8(raw); + if (text === null) continue; + + const rel = relativePathFromRoots(filePath, roots); + const lines = splitLinesForGrep(text); + let hitLimit = false; + for (let idx = 0; idx < lines.length; idx += 1) { + if (regex.test(lines[idx])) { + results.push(`${rel}:${idx + 1}: ${rstrip(lines[idx])}`); + if (results.length >= limit) { + hitLimit = true; + break; + } + } + } + if (hitLimit) break; + } + + const scope = rawPath ? normalizeRelativePath(rawPath) : 'workspace'; + let header = `搜索:${needle}\n范围:${scope}\n`; + if (globPattern && globPattern !== '*') header += `文件匹配:${globPattern}\n`; + + if (results.length === 0) { + return `${header}未找到匹配。\n`; + } + + if (results.length >= limit) header += `(结果已截断,最多 ${limit} 条)\n`; + if (truncatedFiles) header += `(已扫描文件数达上限 ${maxFiles})\n`; + header += '---\n'; + return header + results.join('\n'); +} + +/** 按 glob 递归列出工作区文件。 */ +export async function globWorkspace( + clientContext: ClientContextLike | null | undefined, + globPattern: string, + options: { relativeDir?: string; maxFilesScanned: number }, +): Promise { + const pattern = (globPattern ?? '').trim(); + if (!pattern) return '请提供 glob_pattern(如 **/*.py、src/**/*.ts)。'; + + const roots = allowedRoots(clientContext); + if (roots.length === 0) return '未绑定工作区或项目目录,无法搜索。'; + + const rawDir = (options.relativeDir ?? '').trim(); + let searchRoots: string[]; + if (rawDir) { + const dirResult = resolveWorkspaceDirectory(clientContext, rawDir); + if (dirResult.resolved === null) return dirResult.error || '目录无效。'; + searchRoots = [dirResult.resolved]; + } else { + searchRoots = [...roots]; + } + + const maxFiles = options.maxFilesScanned; + const files: string[] = []; + let truncated = false; + + for (const searchRoot of searchRoots) { + // eslint-disable-next-line no-await-in-loop + const found = await iterSearchFiles(searchRoot, '*', maxFiles); + for (const filePath of found) { + const rel = relativePathFromRoots(filePath, roots); + if (!relMatchesGlob(rel, pattern)) continue; + files.push(filePath); + if (files.length >= maxFiles) { + truncated = true; + break; + } + } + if (truncated) break; + } + + const relDir = searchRoots.length + ? relativePathFromRoots(searchRoots[0], roots) + : ''; + let header = `glob:${pattern}\n范围:${relDir || '.'}\n`; + if (files.length === 0) return `${header}未找到匹配文件。\n`; + + header += `条目数:${files.length}`; + if (truncated) header += `(已截断,最多 ${maxFiles} 条)`; + header += '\n---\n'; + return header + files.map((f) => relativePathFromRoots(f, roots)).join('\n'); +} + +/** + * 列出工作区目录下的文件与子目录。 + * + * 注意与 grep 的差异:这里**跳过所有以 `.` 开头的条目**(grep 只跳显式名单)。 + */ +export async function listWorkspaceDirectory( + clientContext: ClientContextLike | null | undefined, + relativeDir: string, + options: { maxEntries: number }, +): Promise { + const dirResult = resolveWorkspaceDirectory(clientContext, relativeDir); + if (dirResult.resolved === null) return dirResult.error; + + const roots = allowedRoots(clientContext); + const cap = options.maxEntries; + + let relDir = roots.length + ? relativePathFromRoots(dirResult.resolved, roots) + : ''; + if (!relDir && (relativeDir ?? '').trim()) { + relDir = normalizeRelativePath(relativeDir); + } + + let entries: import('fs').Dirent[]; + try { + entries = await fs.readdir(dirResult.resolved, { withFileTypes: true }); + } catch (err) { + return `无法读取目录:${errMessage(err)}`; + } + entries.sort((a, b) => + a.name.toLowerCase() < b.name.toLowerCase() + ? -1 + : a.name.toLowerCase() > b.name.toLowerCase() + ? 1 + : 0, + ); + + const lines: string[] = []; + let truncated = false; + for (const entry of entries) { + if (lines.length >= cap) { + truncated = true; + break; + } + if (entry.name.startsWith('.')) continue; + + const childRel = normalizeRelativePath( + relDir ? `${relDir}/${entry.name}` : entry.name, + ); + let kind: string; + if (entry.isDirectory()) kind = 'directory'; + else if (entry.isFile()) kind = 'file'; + else continue; + lines.push(`${entry.name} | ${kind} | ${childRel}`); + } + + let header = `目录:${relDir || '.'}\n`; + header += `条目数:${lines.length}`; + if (truncated) header += `(已截断,最多 ${cap} 条)`; + header += '\n---\n'; + + if (lines.length === 0) return `${header}(空目录或无可见条目)\n`; + return header + lines.join('\n'); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts new file mode 100644 index 0000000..8c35ab8 --- /dev/null +++ b/src/main/agent/tools/registry.ts @@ -0,0 +1,540 @@ +/** + * 内置工具注册表。 + * + * 移植自 `vendor/local-agent/app/agent/tools/registry.py`。 + * + * 设计要点: + * + * 1. **zod 既是校验器也是 schema 来源**。`z.toJSONSchema` 生成的 JSON Schema + * 会剥掉 `title`(Pydantic 的 `_strip_titles` 同样这么做,每字段省约 30 字符 + * 的工具前缀开销)。 + * + * 2. **描述文本是行为契约**。工具描述与系统提示共同决定模型行为,必须与 Python + * 侧逐字一致;「何时填某个参数」的纪律放在系统提示的标注调用纪律块里, + * 不在每个工具描述中重复。 + * + * 3. **执行器类型(kind)是三方共享的单一真源**。`sync` 在运行时内执行; + * `proposal` 由运行时生成提案、Electron 在用户确认后落盘;`async` 暂停循环、 + * 由 Electron 执行后 resume(见协议文档 §5.1)。 + */ + +import { z } from 'zod'; +import type { ToolSpec } from '../llm/client'; +import { buildToolResult } from './result'; +import { + globWorkspace, + grepWorkspace, + listWorkspaceDirectory, +} from './fileSearch'; +import { readWorkspaceTextFile } from './fileReader'; +import { + formatRuntimeIdentityBlock, + formatSnapshotForPrompt, +} from '../context/snapshot'; +import { type ClientContextLike } from './workspacePath'; +import type { AgentSettings } from '../config'; +/** 工具执行器类型。 */ +export type ToolKind = 'sync' | 'proposal' | 'async'; + +/** 工具执行上下文。 */ +export interface ToolContext { + clientContext: ClientContextLike | null; + settings: AgentSettings; + /** provider 是否通过视觉探针(read_image_for_vision 用)。 */ + providerIsVision: boolean; + /** 当前轮的用户文本(部分工具需要)。 */ + userContent: string; +} + +export interface ToolDefinition { + name: string; + description: string; + kind: ToolKind; + /** 参数校验器;同时用于生成 LLM 侧 JSON Schema。 */ + argsSchema: z.ZodType; + /** + * 执行体。`proposal` 与 `async` 类工具没有执行体——前者由调度层生成提案, + * 后者交给 Electron 执行。 + */ + execute?: (args: Record, ctx: ToolContext) => string | Promise; +} + +/** 剥掉 JSON Schema 中的 title(含嵌套),对齐 Pydantic 的 `_strip_titles`。 */ +export function stripSchemaTitles(node: unknown): unknown { + if (Array.isArray(node)) return node.map(stripSchemaTitles); + if (node && typeof node === 'object') { + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + if (key === 'title') continue; + out[key] = stripSchemaTitles(value); + } + return out; + } + return node; +} + +/** + * 把 zod schema 转为 OpenAI function calling 的 parameters。 + * + * 归一化掉几个 OpenAI 不关心、但会占 token 的键:`$schema`、`title`、 + * `additionalProperties`。 + */ +export function toParametersSchema(schema: z.ZodType): Record { + const raw = z.toJSONSchema(schema, { io: 'input' }) as Record; + delete raw.$schema; + const cleaned = stripSchemaTitles(raw) as Record; + delete cleaned.additionalProperties; + return cleaned; +} + +/** 转为发给 LLM 的工具声明。 */ +export function toToolSpec(tool: ToolDefinition): ToolSpec { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: toParametersSchema(tool.argsSchema), + }, + }; +} + +// ── 参数 schema ──────────────────────────────────────────────────── +// 描述文本需与 registry.py 的 Field(description=...) 逐字一致。 + +const AutoAnnotateArgs = z.object({ + user_request: z.string().min(1).describe('本轮标注任务说明(按用户意图归纳)'), + paths: z + .array(z.string()) + .default([]) + .describe( + '要标注的相对路径,取自 list_workspace_directory 的 relativePath;目录以 / 结尾', + ), + all_files: z + .boolean() + .default(false) + .describe('是否标注整个项目全部文件(仅用户明确要求时为 true)'), + write_mode: z + .enum(['append', 'replace_matching']) + .default('append') + .describe('append 追加新标注;replace_matching 替换同类型已有标注'), + scope_hint: z + .string() + .nullish() + .describe('兼容旧参数:逗号分隔的相对路径,优先使用 paths'), + conf_threshold: z + .number() + .min(0) + .max(1) + .nullish() + .describe('检测置信度阈值(0-1),用户明确给出时填'), + iou_threshold: z + .number() + .min(0) + .max(1) + .nullish() + .describe('检测 NMS IoU 阈值(0-1),用户明确给出时填'), + model_id: z + .string() + .nullish() + .describe('指定检测模型 id,仅用户点名模型时填'), + include_classes: z + .array(z.string()) + .nullish() + .describe('只保留这些检测类名的框(如 ["person"])'), + exclude_classes: z.array(z.string()).nullish().describe('排除这些检测类名的框'), + use_vision_mapping: z + .boolean() + .nullish() + .describe('是否用视觉模型把检测框映射到项目标签;留空由系统决定'), +}); + +const MutateAnnotationArgs = z.object({ + user_request: z.string().min(1).describe('必须原样传递用户的原始请求'), + paths: z + .array(z.string()) + .nullish() + .describe('要修改的文件相对路径。有明确文件时必须填写。'), + annotation_ids: z.array(z.string()).nullish().describe('画布选中或用户指定的标注 id。'), +}); + +const ReadFileAnnotationArgs = z.object({ + relative_path: z.string().describe('文件相对路径(如 data/2.jpg)'), + annotation_offset: z.number().int().default(0).describe('分页起始偏移'), + annotation_limit: z.number().int().default(200).describe('单次返回条数上限'), +}); + +const ReadWorkspaceFileArgs = z.object({ + relative_path: z.string().default('').describe('相对路径;为空时使用当前打开的文件'), + start_line: z.number().int().nullish().describe('起始行(1-indexed,含)'), + end_line: z.number().int().nullish().describe('结束行(1-indexed,含)'), +}); + +const GrepArgs = z.object({ + pattern: z.string().describe('正则表达式'), + path: z.string().default('').describe('相对目录或文件;空表示整个工作区'), + glob_pattern: z.string().default('*').describe('文件匹配,如 *.py、*.ts'), + case_insensitive: z.boolean().default(false).describe('是否忽略大小写'), +}); + +const GlobArgs = z.object({ + glob_pattern: z.string().describe('glob 表达式,如 **/*.py、src/**/*.ts'), + relative_dir: z.string().default('').describe('起始目录;为空时从工作区根开始'), +}); + +const ListDirArgs = z.object({ + relative_dir: z.string().default('').describe('目录相对路径;为空时列出工作区根'), +}); + +const HelpArgs = z.object({ + topic: z.string().nullish().describe('可选 topic 关键词'), +}); + +const ReadImageArgs = z.object({ + relative_path: z.string().default('').describe('图片相对路径;为空时使用当前打开的图片'), +}); + +const ReadDocumentArgs = z.object({ + relative_path: z.string().default('').describe('文档相对路径;为空时使用当前打开的文件'), +}); + +const WriteFileArgs = z.object({ + relative_path: z.string().min(1).describe('要写入的相对路径'), + content: z.string().describe('完整文件内容'), +}); + +const StrReplaceArgs = z.object({ + relative_path: z.string().min(1).describe('要修改的相对路径'), + old_string: z.string().min(1).describe('文件中必须唯一出现的原文片段'), + new_string: z.string().describe('替换后的文本'), + replace_all: z + .boolean() + .default(false) + .describe('为 true 时替换全部出现;默认要求 old_string 只出现一次'), +}); + +const DeleteFileArgs = z.object({ + relative_path: z.string().min(1).describe('要删除的相对路径'), +}); + +const MoveFileArgs = z.object({ + relative_path: z.string().min(1).describe('原相对路径'), + new_relative_path: z + .string() + .min(1) + .describe('新相对路径(同目录改名为重命名,跨目录为移动)'), +}); + +const ExploreReadonlyArgs = z.object({ + query: z.string().min(1).describe('要查阅的问题或目标'), + focus_path: z.string().nullish().describe('可选,优先查阅的相对路径或目录'), +}); + +// ── 只读 / 元信息工具的实现 ──────────────────────────────────────── + +function accountSummary(): string { + // 本地无状态模式(运行时注入匿名用户):如实说明,不展示占位账户 + return '本地模式:LR-Agent 本地服务无云端账户体系,对话、标注与配置均保存在本机。'; +} + +function describeContext(clientContext: ClientContextLike | null): string { + if (!clientContext) return '客户端未提供当前界面上下文。'; + const parts: string[] = []; + if (clientContext.workspaceRoot) { + parts.push(`工作区根目录: ${clientContext.workspaceRoot}`); + } + if (clientContext.activeFilePath) { + parts.push(`当前打开文件: ${clientContext.activeFilePath}`); + } + if (clientContext.activeRelativePath) { + parts.push(`当前打开文件(相对路径): ${clientContext.activeRelativePath}`); + } + const ctx = clientContext as Record; + const projectId = ctx.activeAnnotationProjectId; + if (projectId) parts.push(`当前标注项目 ID: ${String(projectId)}`); + if (ctx.annotationProjectModality) { + parts.push(`任务模态: ${String(ctx.annotationProjectModality)}`); + } + if (ctx.annotationProjectType) { + parts.push(`标注类型: ${String(ctx.annotationProjectType)}`); + } + if (ctx.agentMode) parts.push(`交互模式: ${String(ctx.agentMode)}`); + if (ctx.workMode) parts.push(`工作模式: ${String(ctx.workMode)}`); + if (parts.length === 0) return '工作区已连接,但未打开具体文件或标注项目。'; + return parts.join('\n'); +} + +function describeAnnotationProject(clientContext: ClientContextLike | null): string { + const snapshot = clientContext?.annotationProjectSnapshot; + if (!clientContext || !snapshot) { + return '当前未绑定标注项目快照。请确认用户已在标注任务中打开项目。'; + } + return formatSnapshotForPrompt({ + projectId: String((snapshot as Record).projectId ?? ''), + name: String((snapshot as Record).name ?? ''), + modality: String((snapshot as Record).modality ?? ''), + annotationType: String((snapshot as Record).annotationType ?? ''), + labels: (snapshot as Record).labels as + | Array> + | undefined, + detectionModels: (snapshot as Record).detectionModels as + | Array> + | undefined, + }); +} + +/** LR-Agent 功能概览(get_lr_agent_help 的返回内容)。 */ +export const LR_AGENT_HELP = `# LR-Agent 功能概览 + +- **资源管理器**:浏览本地文件夹与文件 +- **标注任务**:创建与管理图像标注项目 +- **预训练模型**:配置用于辅助标注的模型 +- **大模型配置**:添加 OpenAI 兼容 API(如通义、DeepSeek) +- **Agent 面板**:右侧对话助手,可搜索/读取工作区代码与文档、看图、查已有标注、写文件提案(确认后落盘);大范围摸底可用 explore_readonly + +**Ask 模式**:问答、搜索代码、读文本/文档、看图;标注任务中还可查已有标注。不修改文件、不写入标注。 +**编辑器 Agent**:可改代码/文档(提案确认后落盘),不能标注。 +**标注任务 Agent**:批量检测与提案;标注变更(改标签/删框);报告/文档(Markdown)。 +执行批量写入或变更标注请传入明确 paths(或用户明确要求全部时 all_files=true);查某张图已有标注在 Ask 即可。`; + +/** 按 topic 关键词返回功能说明;无 topic 时返回完整概览。 */ +export function getLrAgentHelp(topic?: string | null): string { + if (!topic || !topic.trim()) return LR_AGENT_HELP; + const needle = topic.trim().toLowerCase(); + if (needle.includes('标注') || needle.includes('annotation')) { + return ( + '标注任务在左侧活动栏「标注任务」中创建。' + + ' **Ask**:分析与建议,不修改文件、不执行批量标注。' + + '**标注 Agent**:新增/重写用 auto_annotate,改已有标注用 mutate_annotation。' + + '**编辑器 Agent**:只改代码/文档,不能标注。' + ); + } + if (needle.includes('模型') || needle.includes('model')) { + return '预训练模型在左侧「预训练模型」面板配置,供标注工作区推理使用。'; + } + if (needle.includes('agent') || needle.includes('对话')) { + return 'Agent 在右侧活动栏打开,在「大模型配置」中添加 OpenAI 兼容 API Key 即可使用。'; + } + return LR_AGENT_HELP; +} + +// ── 工具清单 ─────────────────────────────────────────────────────── + +/** + * 全部内置工具定义。 + * + * 未注册 `execute` 的工具:`proposal` 类由调度层生成提案(阶段 3), + * `async` 类交给 Electron 执行;`explore_readonly` 由子代理 runner 接管(阶段 6)。 + */ +export function buildTools(): ToolDefinition[] { + return [ + { + name: 'get_account_summary', + description: '获取当前登录用户的账户摘要(邮箱、验证状态、显示名)', + kind: 'sync', + argsSchema: z.object({}), + execute: () => accountSummary(), + }, + { + name: 'get_lr_agent_help', + description: '获取 LR-Agent 应用功能说明,可选 topic 关键词', + kind: 'sync', + argsSchema: HelpArgs, + execute: (args) => getLrAgentHelp(args.topic as string | null | undefined), + }, + { + name: 'describe_client_context', + description: + '描述用户当前 Electron 客户端界面上下文(工作区、打开文件、标注项目)', + kind: 'sync', + argsSchema: z.object({}), + execute: (_args, ctx) => describeContext(ctx.clientContext), + }, + { + name: 'describe_annotation_project', + description: '获取当前标注项目的标签树、任务类型、模态与可用检测模型列表', + kind: 'sync', + argsSchema: z.object({}), + execute: (_args, ctx) => describeAnnotationProject(ctx.clientContext), + }, + { + name: 'read_file_annotation', + description: + '读取项目中某文件的已有标注(相对路径,如 data/2.jpg)。' + + '标注较多时可用 annotation_offset/annotation_limit 分页(默认返回前 200 条)。' + + '只读查询,不要用写文件工具回写。', + kind: 'sync', + argsSchema: ReadFileAnnotationArgs, + // 阶段 4 实现(需要 .lr-agent 标注文档读写) + }, + { + name: 'read_workspace_file', + description: + '读取工作区或项目内的文本/代码文件内容(如 .py .ts .md .json .yaml .txt)。' + + 'relative_path 为空时使用当前打开文件。' + + '可选 start_line / end_line(1-indexed,含首尾)读取指定行范围。' + + '返回内容带行号前缀(如 ` 12|code`)。' + + '找代码时建议先用 grep_workspace 或 glob_workspace 定位,再读本工具读具体行。', + kind: 'sync', + argsSchema: ReadWorkspaceFileArgs, + execute: (args, ctx) => + readWorkspaceTextFile(ctx.clientContext, String(args.relative_path ?? ''), { + maxBytes: ctx.settings.readFileMaxBytes, + maxLines: ctx.settings.readFileMaxLines, + startLine: args.start_line as number | null | undefined, + endLine: args.end_line as number | null | undefined, + }), + }, + { + name: 'grep_workspace', + description: + '在工作区内按正则搜索代码/文本,返回 path:line: content 格式。' + + '找定义、引用、符号时优先使用;命中后再对具体文件调用 read_workspace_file。' + + 'pattern 为正则;path 为相对目录或文件(空=整个工作区);' + + 'glob_pattern 可选如 *.py、*.ts。', + kind: 'sync', + argsSchema: GrepArgs, + execute: (args, ctx) => + grepWorkspace(ctx.clientContext, String(args.pattern ?? ''), { + maxResults: ctx.settings.grepMaxResults, + maxFilesScanned: ctx.settings.grepMaxFilesScanned, + path: String(args.path ?? ''), + globPattern: String(args.glob_pattern ?? '*'), + caseInsensitive: Boolean(args.case_insensitive), + }), + }, + { + name: 'glob_workspace', + description: + '按 glob 递归列出工作区文件(如 **/*.py、src/**/*.ts)。' + + 'relative_dir 为空时从工作区根开始。跳过 .git / node_modules / .lr-agent。' + + '找文件路径时优先于反复 list_workspace_directory。', + kind: 'sync', + argsSchema: GlobArgs, + execute: (args, ctx) => + globWorkspace(ctx.clientContext, String(args.glob_pattern ?? ''), { + relativeDir: String(args.relative_dir ?? ''), + maxFilesScanned: ctx.settings.grepMaxFilesScanned, + }), + }, + { + name: 'list_workspace_directory', + description: + '列出工作区目录下的文件与子目录(name | kind | relativePath)。' + + 'relative_dir 为空时列出工作区根;用于了解项目结构。', + kind: 'sync', + argsSchema: ListDirArgs, + execute: (args, ctx) => + listWorkspaceDirectory( + ctx.clientContext, + String(args.relative_dir ?? ''), + { maxEntries: ctx.settings.listDirMaxEntries }, + ), + }, + { + name: 'read_image_for_vision', + description: + '加载图片并在调用成功后由系统注入附图,供你直接根据像素回答' + + '(场景、物体、人数、文字 OCR、外观等)。relative_path 为空时使用当前打开的图片。' + + '需视觉探针通过;查已有标注请用 read_file_annotation,勿用本工具代替。', + kind: 'sync', + argsSchema: ReadImageArgs, + // 阶段 4 实现(需要主进程 nativeImage 读尺寸 + 附图注入) + }, + { + name: 'read_document_file', + description: + '提取 PDF 或 DOCX 文档正文。relative_path 为空时使用当前打开的文件。', + kind: 'sync', + argsSchema: ReadDocumentArgs, + // 阶段 4 实现(需要 PDF/DOCX 解析) + }, + { + name: 'write_workspace_file', + description: + '在工作区内创建或覆写文本/代码文件(如 .md .py .ts)。' + + '生成提案,用户 Keep All 后才落盘。' + + '只用于工作区文档与代码,不能用来保存标注。' + + '删文件请用 delete_workspace_file,不要写入空内容;' + + '移动/重命名文件请用 move_workspace_file。', + kind: 'proposal', + argsSchema: WriteFileArgs, + }, + { + name: 'str_replace_workspace_file', + description: + '对已有文本/代码文件做精确片段替换。' + + 'old_string 必须在文件中唯一出现,除非 replace_all=true。' + + '改局部代码时优先用本工具。不能用来改标注。', + kind: 'proposal', + argsSchema: StrReplaceArgs, + }, + { + name: 'delete_workspace_file', + description: + '删除工作区内的文本/代码文件,生成删除提案。' + + '用户确认 Keep All 后才从磁盘移除。不能删除标注。', + kind: 'proposal', + argsSchema: DeleteFileArgs, + }, + { + name: 'move_workspace_file', + description: + '移动或重命名工作区内的文本/代码文件,生成移动提案。' + + '用户确认 Keep All 后才在磁盘执行。' + + '禁止用「读出内容再写到新路径」来移动文件。', + kind: 'proposal', + argsSchema: MoveFileArgs, + }, + { + name: 'auto_annotate', + description: + '新增或重写当前项目的自动标注(检测、预标注、生成 caption 等);' + + '改已有框/标签用 mutate_annotation。填参规则见系统提示的标注调用纪律。', + kind: 'async', + argsSchema: AutoAnnotateArgs, + }, + { + name: 'mutate_annotation', + description: + '修改或删除已有标注(改标签、删框、改 caption 等)。' + + '不含新增;新增请用 auto_annotate。', + kind: 'async', + argsSchema: MutateAnnotationArgs, + }, + { + name: 'explore_readonly', + description: + '只读查阅子代理:在工作区内搜索/阅读代码与文档' + + '(标注任务还可读已有标注),返回中文摘要。' + + '大范围摸底时使用。query 描述要查什么;focus_path 可选,缩小范围。' + + '本工具不会改文件或写标注;标注与写文件必须由你直接调用对应工具。', + kind: 'sync', + argsSchema: ExploreReadonlyArgs, + // 阶段 6 由 ExploreReadonlyRunner 接管,不走普通 execute + }, + ]; +} + +/** 工具名 → 定义。 */ +export function toolsByName(): Map { + return new Map(buildTools().map((tool) => [tool.name, tool])); +} + +/** 构建指定名称子集的工具声明(发给 LLM 用)。 */ +export function buildToolSpecs( + toolSet: ReadonlySet, + tools: Map, +): ToolSpec[] { + const specs: ToolSpec[] = []; + for (const [name, tool] of tools) { + if (toolSet.has(name)) specs.push(toToolSpec(tool)); + } + // tools 列表按名称排序后固定:阶段门禁不在 bind 期增删工具,避免破坏 LLM 前缀缓存 + return specs.sort((a, b) => (a.function.name < b.function.name ? -1 : 1)); +} + +/** 构造统一失败结果(供调度层复用)。 */ +export function toolFailure(tool: string, status: string, summary: string): string { + return buildToolResult({ ok: false, tool, status, summary }); +} diff --git a/src/main/agent/tools/result.ts b/src/main/agent/tools/result.ts new file mode 100644 index 0000000..7e72d54 --- /dev/null +++ b/src/main/agent/tools/result.ts @@ -0,0 +1,92 @@ +/** + * 统一工具结果结构。 + * + * 移植自 `vendor/local-agent/app/agent/tools/tool_result.py`。 + * + * 两条输出路径的差异是**既成事实**,不要统一(见协议文档 §5.4): + * - `buildToolResult` 产出带 `summary` 的 JSON,回灌给模型的 `ToolMessage` + * 走 `formatToolResultForDisplay`,因此实际只回 `summary` 文本 + * - 子代理内部回灌的是**原文**(不经 `formatToolResultForDisplay`) + * + * 少数只读工具(如 `list_workspace_directory`、`grep_workspace`)直接返回纯文本, + * 不经 `buildToolResult` 包装;此时 `formatToolResultForDisplay` 原样返回。 + */ + +/** 生成统一工具结果 JSON。`file_written` / `proposal_pending` 默认 false。 */ +export function buildToolResult(params: { + ok: boolean; + tool: string; + status: string; + summary: string; + [extra: string]: unknown; +}): string { + const { ok, tool, status, summary, ...rest } = params; + const payload: Record = { + ok, + tool, + status, + summary, + file_written: rest.file_written ?? false, + proposal_pending: rest.proposal_pending ?? false, + }; + delete rest.file_written; + delete rest.proposal_pending; + Object.assign(payload, rest); + return JSON.stringify(payload); +} + +/** + * 将工具结果格式化为 UI / 模型可读摘要。 + * + * 规则:非 JSON → 原样;JSON 非对象 → 原样;有 `summary` → 只取 `summary`; + * 否则有 `message` → 取 `message`;否则原样。 + */ +export function formatToolResultForDisplay(resultText: string): string { + let data: unknown; + try { + data = JSON.parse(resultText); + } catch { + return resultText; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return resultText; + const record = data as Record; + if (record.summary) return String(record.summary); + if (record.message) return String(record.message); + return resultText; +} + +/** 解析工具结果 JSON;非对象返回 null。 */ +export function parseToolResult(resultText: string): Record | null { + try { + const data = JSON.parse(resultText); + return data && typeof data === 'object' && !Array.isArray(data) + ? (data as Record) + : null; + } catch { + return null; + } +} + +/** 工具结果中的内部字段名(不在 UI 展示,供调度层消费)。 */ +export const VISION_PATH_MARKER = '__vision_image_path__'; +export const DOC_PROPOSAL_MARKER = '__doc_proposal__'; + +/** + * 剥离内部标记字段,得到可发给前端的显示文本。 + * + * 仅 `read_image_for_vision` 用到 `VISION_PATH_MARKER`;其余工具无标记,原样返回。 + */ +export function stripInternalMarkers(resultText: string): string { + const parsed = parseToolResult(resultText); + if (!parsed) return resultText; + if (!(VISION_PATH_MARKER in parsed)) return resultText; + const { [VISION_PATH_MARKER]: _removed, ...rest } = parsed; + return JSON.stringify(rest); +} + +/** 从视觉工具结果中提取图片绝对路径;无标记时返回空串。 */ +export function extractVisionPath(resultText: string): string { + const parsed = parseToolResult(resultText); + const value = parsed?.[VISION_PATH_MARKER]; + return typeof value === 'string' ? value : ''; +} diff --git a/src/main/agent/tools/workspacePath.ts b/src/main/agent/tools/workspacePath.ts new file mode 100644 index 0000000..1771756 --- /dev/null +++ b/src/main/agent/tools/workspacePath.ts @@ -0,0 +1,343 @@ +/** + * 工作区 / 项目文件路径解析与安全校验。 + * + * 移植自 `vendor/local-agent/app/agent/tools/workspace_path.py`。 + * 所有文件读写工具的统一入口:确定允许访问的根 → 解析路径 → 校验范围。 + * + * **根顺序至关重要**:项目目录优先,其次工作区根。前端 `applyFileBlock` 的写入根 + * 同样是 `project.directoryPath ?? workspaceRoot`;若两侧顺序不一致,提案的 + * relative_display_path 会算出不同结果,Keep All 会把文件写到另一个根下 + * (历史隐患:同一文件在两个根各落一份),并且前端按路径匹配提案块时会裂成两张卡片。 + * + * 错误文案需与 Python 侧一致——它们会作为工具结果回灌给模型。 + */ + +import fs from 'fs-extra'; +import path from 'path'; + +/** Agent 侧可见的最小 client_context 形状(完整定义见 `../schemas`)。 */ +export interface ClientContextLike { + workspaceRoot?: string | null; + activeFilePath?: string | null; + activeRelativePath?: string | null; + projectDirectoryPath?: string | null; + annotationProjectSnapshot?: { + projectDirectoryPath?: string | null; + } | null; +} + +export const LR_AGENT_DIR = '.lr-agent'; + +export const LR_AGENT_WRITE_DENIED = + '不能用本工具写入标注。新增/重写请调用 auto_annotate,修改请调用 mutate_annotation。'; + +/** 将相对路径统一为正斜杠,去除空段与 `.`。 */ +export function normalizeRelativePath(relativePath: string): string { + return (relativePath ?? '') + .replace(/\\/g, '/') + .split('/') + .filter((part) => part && part !== '.') + .join('/'); +} + +/** 相对路径是否落入 .lr-agent 标注库目录。 */ +export function isLrAgentRelative(relativePath: string): boolean { + return normalizeRelativePath(relativePath) + .split('/') + .some((part) => part === LR_AGENT_DIR); +} + +/** 标注项目目录绝对路径:优先 client_context,其次快照内字段。 */ +export function projectDirectory( + clientContext: ClientContextLike | null | undefined, +): string | null { + if (!clientContext) return null; + const direct = (clientContext.projectDirectoryPath ?? '').trim(); + if (direct) return direct; + const snap = clientContext.annotationProjectSnapshot; + const fromSnap = (snap?.projectDirectoryPath ?? '').trim(); + return fromSnap || null; +} + +/** + * 可访问的根目录列表(项目目录优先 + 工作区根,去重,已 resolve)。 + * + * @returns 绝对路径数组;未绑定任何根时为空数组 + */ +export function allowedRoots( + clientContext: ClientContextLike | null | undefined, +): string[] { + const roots: string[] = []; + const seen = new Set(); + if (!clientContext) return roots; + + for (const raw of [ + (projectDirectory(clientContext) ?? '').trim(), + (clientContext.workspaceRoot ?? '').trim(), + ]) { + if (!raw) continue; + let resolved: string; + try { + resolved = path.resolve(raw); + } catch { + continue; + } + if (seen.has(resolved)) continue; + seen.add(resolved); + roots.push(resolved); + } + return roots; +} + +/** candidate 是否在 root 目录树下(含相等)。 */ +function isUnderRoot(candidate: string, root: string): boolean { + const rel = path.relative(root, candidate); + if (rel === '') return true; + return !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** candidate 是否落在 root 下的 .lr-agent 目录内。 */ +function isLrAgentUnderRoot(candidate: string, root: string): boolean { + const rel = path.relative(root, candidate); + if (rel === '' || rel.startsWith('..')) return false; + return rel.split(path.sep).some((part) => part === LR_AGENT_DIR); +} + +/** 将已解析的绝对路径转为相对显示路径(正斜杠);不在任何根内时回退 fallback。 */ +export function relativeDisplayPath( + clientContext: ClientContextLike | null | undefined, + resolved: string, + fallback: string, +): string { + for (const root of allowedRoots(clientContext)) { + if (isUnderRoot(resolved, root)) { + return normalizeRelativePath(path.relative(root, resolved)); + } + } + return (fallback ?? '').trim(); +} + +/** 将绝对路径转为相对某个根的路径(正斜杠);都不匹配时取 basename。 */ +export function relativePathFromRoots( + candidate: string, + roots: string[], +): string { + for (const root of roots) { + if (isUnderRoot(candidate, root)) { + return normalizeRelativePath(path.relative(root, candidate)); + } + } + return path.basename(candidate); +} + +export interface ResolveResult { + /** 已解析的绝对路径;失败时为 null。 */ + resolved: string | null; + /** 失败原因(可直接回灌给模型);成功时为空串。 */ + error: string; +} + +/** + * 解析并校验**已存在文件**的路径。 + * + * 语义: + * - `path` 为空时回退到 `active_file_path`(绝对),其次 `active_relative_path` + * - 绝对路径须落在某个根内且确实存在 + * - 相对路径禁止 `..` 穿越;按根顺序逐个尝试 + */ +export function resolveWorkspaceFile( + clientContext: ClientContextLike | null | undefined, + filePath: string, +): ResolveResult { + let raw = (filePath ?? '').trim(); + if (!raw && clientContext) { + const activeAbs = (clientContext.activeFilePath ?? '').trim(); + if (activeAbs) { + raw = activeAbs; + } else { + const rel = normalizeRelativePath(clientContext.activeRelativePath ?? ''); + if (rel) raw = rel; + } + } + + if (!raw) { + return { + resolved: null, + error: '请提供相对路径,或在工作区中打开目标文件后再提问。', + }; + } + + const roots = allowedRoots(clientContext); + if (roots.length === 0) { + return { resolved: null, error: '未绑定工作区或项目目录,无法读取本地文件。' }; + } + + if (path.isAbsolute(raw)) { + let candidate: string; + try { + candidate = path.resolve(raw); + } catch (err) { + return { resolved: null, error: `路径无效:${errMessage(err)}` }; + } + for (const root of roots) { + if (isUnderRoot(candidate, root) && isFileSafe(candidate)) { + return { resolved: candidate, error: '' }; + } + } + return { resolved: null, error: '文件不在当前工作区或项目目录内。' }; + } + + const rel = normalizeRelativePath(raw); + if (rel.split('/').includes('..')) { + return { resolved: null, error: '路径不能包含 ..' }; + } + + for (const root of roots) { + const candidate = path.resolve(root, rel); + if (!isUnderRoot(candidate, root)) continue; + if (isFileSafe(candidate)) return { resolved: candidate, error: '' }; + } + return { resolved: null, error: `未找到文件:${rel}` }; +} + +/** + * 解析并校验**目录**路径。 + * + * `path` 为空时回退到第一个根(工作区根语义)。 + */ +export function resolveWorkspaceDirectory( + clientContext: ClientContextLike | null | undefined, + dirPath: string, +): ResolveResult { + const raw = (dirPath ?? '').trim(); + const roots = allowedRoots(clientContext); + if (roots.length === 0) { + return { resolved: null, error: '未绑定工作区或项目目录,无法访问本地目录。' }; + } + + if (!raw) { + const root = roots[0]; + if (isDirSafe(root)) return { resolved: root, error: '' }; + return { resolved: null, error: '工作区根目录无效。' }; + } + + if (path.isAbsolute(raw)) { + let candidate: string; + try { + candidate = path.resolve(raw); + } catch (err) { + return { resolved: null, error: `路径无效:${errMessage(err)}` }; + } + for (const root of roots) { + if (isUnderRoot(candidate, root) && isDirSafe(candidate)) { + return { resolved: candidate, error: '' }; + } + } + return { resolved: null, error: '目录不在当前工作区或项目目录内。' }; + } + + const rel = normalizeRelativePath(raw); + if (rel.split('/').includes('..')) { + return { resolved: null, error: '路径不能包含 ..' }; + } + + for (const root of roots) { + const candidate = path.resolve(root, rel); + if (!isUnderRoot(candidate, root)) continue; + if (isDirSafe(candidate)) return { resolved: candidate, error: '' }; + } + return { resolved: null, error: `未找到目录:${rel}` }; +} + +/** + * 解析并校验**写入目标**路径。 + * + * 与 `resolveWorkspaceFile` 的差异:目标文件不要求已存在;额外禁止 `.lr-agent`。 + */ +export function resolveWorkspaceWritePath( + clientContext: ClientContextLike | null | undefined, + writePath: string, +): ResolveResult { + const raw = (writePath ?? '').trim(); + if (!raw) { + return { + resolved: null, + error: '请提供写入目标的相对路径(如 reports/summary.md)。', + }; + } + + const roots = allowedRoots(clientContext); + if (roots.length === 0) { + return { resolved: null, error: '未绑定工作区或项目目录,无法写入本地文件。' }; + } + + if (path.isAbsolute(raw)) { + let candidate: string; + try { + candidate = path.resolve(raw); + } catch (err) { + return { resolved: null, error: `路径无效:${errMessage(err)}` }; + } + for (const root of roots) { + if (!isUnderRoot(candidate, root)) continue; + if (isLrAgentUnderRoot(candidate, root)) { + return { resolved: null, error: LR_AGENT_WRITE_DENIED }; + } + return { resolved: candidate, error: '' }; + } + return { resolved: null, error: '目标路径不在当前工作区或项目目录内。' }; + } + + const rel = normalizeRelativePath(raw); + if (rel.split('/').includes('..')) { + return { resolved: null, error: '路径不能包含 ..' }; + } + if (isLrAgentRelative(rel)) { + return { resolved: null, error: LR_AGENT_WRITE_DENIED }; + } + + for (const root of roots) { + const candidate = path.resolve(root, rel); + if (!isUnderRoot(candidate, root)) continue; + return { resolved: candidate, error: '' }; + } + return { resolved: null, error: `无法解析写入路径:${rel}` }; +} + +/** + * 把模型给的写入路径归一为与工具结果一致的显示路径。 + * + * 流式拦截器(file_proposal_start / delta)与定稿事件必须产出**同一条**路径字符串, + * 否则前端按路径匹配提案块时会裂成两张卡片(历史 bug:模型写绝对路径或 `./` 前缀)。 + * 解析失败时退化为 `normalizeRelativePath`,保证至少分隔符与空段是干净的。 + */ +export function normalizeWriteDisplayPath( + clientContext: ClientContextLike | null | undefined, + rawPath: string, +): string { + const { resolved } = resolveWorkspaceWritePath(clientContext, rawPath); + if (resolved === null) return normalizeRelativePath(rawPath); + return relativeDisplayPath(clientContext, resolved, rawPath); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** 容错的 isFile 判断(Python 侧用 Candidate.is_file(),不会抛)。 */ +function isFileSafe(target: string): boolean { + try { + return fs.statSync(target).isFile(); + } catch { + return false; + } +} + +/** 容错的 isDirectory 判断。 */ +function isDirSafe(target: string): boolean { + try { + return fs.statSync(target).isDirectory(); + } catch { + return false; + } +} From 9a53b079c2fd288e9a45f8cbacadd9a5dd2bca97 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 01:55:17 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(agent):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E6=B5=81=E5=BC=8F=E6=8B=A6=E6=88=AA=E4=B8=8E?= =?UTF-8?q?=E5=86=99=E5=85=A5=E7=B1=BB=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植 ProposalStreamInterceptor:在 astream 期间按 tc_index 增量解析半截 JSON(含 uXXXX 转义与代理对合成),实时发出 file_proposal_start / file_proposal_delta / file_edit_delta;按码点而非 UTF-16 码元计数,避免 emoji 内容被切在代理对中间。移植 4 个写入类提案工具与 pendingProposals 基线接力(同轮多次 str_replace 累积为同一份提案)。新增 pythonJsonDumps 对齐 json.dumps 的分隔符空格,修正提案失败时原样回灌完整 JSON 的分支赋值语义。golden diff 11 场景通过 10(仅余 subagent 属阶段 6 范围);主进程测试 269/269 通过。 Co-authored-by: Cursor --- docs/agent-protocol.md | 38 ++ src/main/agent/json.ts | 100 ++++ src/main/agent/loop/assistLoop.ts | 139 +++++- src/main/agent/loop/proposalStreamer.test.ts | 353 ++++++++++++++ src/main/agent/loop/proposalStreamer.ts | 376 +++++++++++++++ src/main/agent/tools/fileProposal.ts | 478 +++++++++++++++++++ src/main/agent/tools/registry.ts | 43 ++ src/main/agent/tools/result.ts | 5 +- 8 files changed, 1516 insertions(+), 16 deletions(-) create mode 100644 src/main/agent/json.ts create mode 100644 src/main/agent/loop/proposalStreamer.test.ts create mode 100644 src/main/agent/loop/proposalStreamer.ts create mode 100644 src/main/agent/tools/fileProposal.ts diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 2f4d5af..bc2d88b 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -621,6 +621,35 @@ is_editor || has_workspace → LIGHT_TOOL_SET **易错点**:主循环回灌用**显示文本**;子代理内部回灌用**原文**。二者不一致,不要统一。 +**分支赋值语义**(容易写错):`display_result` 的初值是**原始结果文本**,只有特定分支才覆盖它: + +| 工具类别 | 条件 | display | +|---|---|---| +| `read_image_for_vision` | 提取到图片路径 | 剥离内部标记后格式化 | +| `read_image_for_vision` | 未提取到路径 | **原始文本** | +| `FILE_PROPOSAL_TOOLS` | 提取到 `__doc_proposal__` | 格式化(实际等于 `summary`) | +| `FILE_PROPOSAL_TOOLS` | 未提取到(工具报错) | **原始完整 JSON** | +| 其它 | — | 格式化 | + +关键在第 4 行:提案工具失败时回灌的是**完整 JSON**而非 `summary`。因为 Python 的 `elif` +分支只在 `doc_proposal` 存在时才赋值 `display_result`,否则沿用初值。 + +**JSON 序列化必须用 Python 的 `json.dumps` 语义**:默认分隔符是 `(', ', ': ')`(逗号与冒号 +后都有空格),而 JavaScript 的 `JSON.stringify` 不插空格: + +``` +Python: {"ok": false, "tool": "x"} +JS : {"ok":false,"tool":"x"} +``` + +多数情况下该差异会被 `format_tool_result_for_display` 折叠掉(只取 `summary`),但上面第 4 行 +那条路径会**原样暴露**,导致 `result` 字段与基准不一致。实现见 `src/main/agent/json.ts` 的 +`pythonJsonDumps`。 + +注意:带缩进(`indent=2`)时 Python 的元素分隔符变成 `,`(无空格),键分隔符仍是 `': '`—— +这与 `JSON.stringify(value, null, 2)` **一致**,故缩进场景(如 `tool_start.arguments`) +可直接用 `JSON.stringify`,只有紧凑场景需要 `pythonJsonDumps`。 + **关键规则:工具可执行性以「工具集」为准,而非「注册表」。** `describe_client_context` 与 `get_account_summary` 在注册表中存在,但被模式路由刻意排除在主 @@ -784,6 +813,15 @@ else: ## 7. 提案流式拦截 +### 7.0 状态计数按**码点**而非 UTF-16 码元 + +Python 的字符串索引单位是码点(code point)。若实现时用 JavaScript 的 UTF-16 码元计数, +含 emoji 的文件内容会把增量切在代理对中间,产出半个代理对(渲染成替换字符 `\ufffd`), +且分片边界与 Python 不一致。 + +实现见 `src/main/agent/loop/proposalStreamer.ts`:所有长度比较与切片都经 +`Array.from(str)`(码点数组)完成。 + ### 7.1 目标 仅拦截两个工具: diff --git a/src/main/agent/json.ts b/src/main/agent/json.ts new file mode 100644 index 0000000..80f3b50 --- /dev/null +++ b/src/main/agent/json.ts @@ -0,0 +1,100 @@ +/** + * Python `json.dumps` 兼容的序列化。 + * + * 为什么需要它:工具结果 JSON 会**原文**回灌给模型并出现在 SSE 的 `result` 字段里, + * 而 Python 的 `json.dumps` 默认分隔符是 `(', ', ': ')`(逗号与冒号后都有空格), + * JavaScript 的 `JSON.stringify` 则不插空格: + * + * Python: {"ok": false, "tool": "x"} + * JS : {"ok":false,"tool":"x"} + * + * 多数情况下这个差异会被 `formatToolResultForDisplay` 折叠掉(只取 `summary`), + * 但对**提案工具失败**这类「原样回灌完整 JSON」的路径会直接暴露,因此需要精确对齐。 + * + * 注意:带缩进(`indent=2`)时 Python 的元素分隔符变成 `,`(无空格),键分隔符仍是 + * `': '`——这与 `JSON.stringify(value, null, 2)` 一致,故**缩进场景可直接用 + * `JSON.stringify`**,只有紧凑场景需要本函数。 + * + * `ensure_ascii=False` 语义:非 ASCII 字符原样输出(JS 的 `JSON.stringify` 本就如此)。 + */ + +/** 需要转义的字符(与 Python 的 ESCAPE 表一致)。 */ +const ESCAPES: Record = { + '"': '\\"', + '\\': '\\\\', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\b': '\\b', + '\f': '\\f', +}; + +/** 转义字符串(含控制字符走 `\uXXXX`,与 Python 一致)。 */ +function encodeString(value: string): string { + let out = '"'; + for (const ch of value) { + const escaped = ESCAPES[ch]; + if (escaped !== undefined) { + out += escaped; + continue; + } + const code = ch.codePointAt(0) as number; + if (code < 0x20) { + out += `\\u${code.toString(16).padStart(4, '0')}`; + continue; + } + out += ch; + } + return `${out}"`; +} + +function encodeNumber(value: number): string { + if (!Number.isFinite(value)) { + // Python 默认 allow_nan=True,输出 NaN / Infinity / -Infinity + if (Number.isNaN(value)) return 'NaN'; + return value > 0 ? 'Infinity' : '-Infinity'; + } + if (Number.isInteger(value)) return String(value); + return String(value); +} + +function serialize(value: unknown): string { + if (value === null || value === undefined) return 'null'; + + switch (typeof value) { + case 'boolean': + return value ? 'true' : 'false'; + case 'number': + return encodeNumber(value); + case 'string': + return encodeString(value); + case 'bigint': + return String(value); + case 'object': + break; + default: + return 'null'; + } + + if (Array.isArray(value)) { + // Python 的 json 不支持 tuple 但 list 用 ', ' 分隔 + return `[${value.map((item) => serialize(item ?? null)).join(', ')}]`; + } + + const entries = Object.entries(value as Record).filter( + ([, v]) => v !== undefined, + ); + const body = entries + .map(([key, val]) => `${encodeString(key)}: ${serialize(val ?? null)}`) + .join(', '); + return `{${body}}`; +} + +/** + * 等价于 `json.dumps(value, ensure_ascii=False)`(紧凑、带分隔空格)。 + * + * `undefined` 的键会被跳过(对齐 Python 中不存在该键的情形)。 + */ +export function pythonJsonDumps(value: unknown): string { + return serialize(value); +} diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 501c144..8a2afd5 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -39,6 +39,15 @@ import { formatToolResultForDisplay, stripInternalMarkers, } from '../tools/result'; +import { + extractDocProposalFromToolResult, + FILE_PROPOSAL_TOOLS, + STR_REPLACE_TOOL_NAME, + type PendingProposalContents, +} from '../tools/fileProposal'; +import { ProposalStreamInterceptor } from './proposalStreamer'; +import { isLrAgentRelative } from '../tools/workspacePath'; +import { pythonJsonDumps } from '../json'; import type { ClientContextLike } from '../tools/workspacePath'; import type { AgentSettings } from '../config'; import { @@ -87,7 +96,7 @@ const ALREADY_COMPLETED_SUMMARY = * Agent),此时即使工具在注册表中存在,也必须按「未知工具」拒绝。 */ export function unknownToolResult(name: string): string { - return JSON.stringify({ + return pythonJsonDumps({ ok: false, tool: name, status: 'error', @@ -98,7 +107,7 @@ export function unknownToolResult(name: string): string { /** 工具执行抛异常时的失败结果(同样是裸 JSON,对齐 Python)。 */ export function toolErrorResult(name: string, err: unknown): string { const message = err instanceof Error ? err.message : String(err); - return JSON.stringify({ + return pythonJsonDumps({ ok: false, tool: name, status: 'error', @@ -199,6 +208,8 @@ export async function* streamAssist( settings, providerIsVision: params.providerIsVision, userContent: params.userContent, + // 请求级提案缓存:让同轮对同一文件的多次 str_replace 累积到同一份提案 + pendingProposals: new Map() as PendingProposalContents, }; let budgetExhausted = true; @@ -207,6 +218,8 @@ export async function* streamAssist( if (isCancelled()) return; const accumulator = new ToolCallAccumulator(); + // 拦截器必须**每轮新建**:tc_index 每轮从 0 重新编号 + const interceptor = new ProposalStreamInterceptor(params.clientContext); let pendingText = ''; let gatheredAny = false; @@ -230,6 +243,10 @@ export async function* streamAssist( if (delta.toolCallChunks?.length) { accumulator.push(delta.toolCallChunks); gatheredAny = true; + // 流式拦截:在 chunk 期间实时发出提案 start / delta 事件 + for (const event of interceptor.onChunk(delta.toolCallChunks)) { + yield event; + } } } @@ -259,6 +276,7 @@ export async function* streamAssist( accumulatedText: pendingText, isResume, toolSpecs: params.toolSpecs, + interceptor, }); if (outcome.kind === 'pending') { @@ -306,6 +324,7 @@ interface ExecuteRoundParams { signal?: AbortSignal; accumulatedText: string; isResume: boolean; + interceptor: ProposalStreamInterceptor; } /** @@ -335,6 +354,7 @@ async function* executeRound( llm, isCancelled, signal, + interceptor, } = params; let resolved = resolveRoundToolCalls({ @@ -497,6 +517,11 @@ async function* executeRound( } // ── 分类执行 ────────────────────────────────────────────── + // 拦截器已流式出卡的路径:定稿时用来抑制重复的 start/delta, + // 工具报错时用来补发 dismissed 终态 + const streamedPathsByCallId = interceptor.streamedPathsByCallId(); + const streamedPaths = new Set(streamedPathsByCallId.values()); + const { immediate, asyncPending } = splitResolvedCalls(executable, (name) => kindOf(tools, name), ); @@ -527,6 +552,8 @@ async function* executeRound( toolContext, messages, prefetchedResult: prefetched.get(call.toolCallId), + streamedPaths, + streamedPathsByCallId, }); } @@ -582,6 +609,10 @@ interface StreamToolExecutionParams { messages: ChatMessage[]; /** 并行预取好的结果;未预取时现场执行。 */ prefetchedResult?: string; + /** 拦截器已流式出卡的显示路径集合。 */ + streamedPaths?: ReadonlySet; + /** callId → 已流式出卡的显示路径。 */ + streamedPathsByCallId?: Map; } /** @@ -589,11 +620,26 @@ interface StreamToolExecutionParams { * * `tool_start` 的 `arguments` 是 pretty JSON 字符串;回灌给模型的 ToolMessage 用 * **显示文本**(`formatToolResultForDisplay`),与子代理内部回灌原文的做法不同。 + * + * 提案类工具(`FILE_PROPOSAL_TOOLS`)额外做两件事: + * - 从结果里提取 `__doc_proposal__`,发出定稿的 `file_proposal`(若该路径已在流式 + * 期间出过卡,则跳过重复的 start/delta burst) + * - 提取失败(工具报错,如 `old_string` 未命中)时,为已出卡的路径补发 + * `status="dismissed"` 终态,否则前端会留下悬挂卡片、Keep All 可能把空内容写盘 */ async function* streamToolExecution( params: StreamToolExecutionParams, ): AsyncGenerator { - const { call, tools, toolSet, toolContext, messages, prefetchedResult } = params; + const { + call, + tools, + toolSet, + toolContext, + messages, + prefetchedResult, + streamedPaths, + streamedPathsByCallId, + } = params; yield sse.toolStart(call.toolCallId, call.name, call.arguments); @@ -601,9 +647,22 @@ async function* streamToolExecution( prefetchedResult ?? (await invokeTool(call, tools, toolSet, toolContext)); // read_image_for_vision 的结果含内部标记,需剥离后再展示 - let display = formatToolResultForDisplay( - call.name === 'read_image_for_vision' ? stripInternalMarkers(raw) : raw, - ); + let display: string; + let visionPath = ''; + let docProposal: ReturnType = null; + + if (call.name === 'read_image_for_vision') { + visionPath = extractVisionPath(raw); + // 提取到路径才剥离内部标记;否则保持原样(对齐 Python 的分支写法) + display = visionPath ? formatToolResultForDisplay(stripInternalMarkers(raw)) : raw; + } else if (FILE_PROPOSAL_TOOLS.has(call.name)) { + docProposal = extractDocProposalFromToolResult(call.name, raw); + // 只有拿到提案数据才走显示文本;工具报错时**保持完整 JSON 原样回灌** + // (Python 的 elif 分支只在 doc_proposal 存在时赋值 display_result) + display = docProposal ? formatToolResultForDisplay(raw) : raw; + } else { + display = formatToolResultForDisplay(raw); + } yield sse.toolResult(call.toolCallId, display); messages.push({ @@ -612,18 +671,68 @@ async function* streamToolExecution( toolCallId: call.toolCallId, }); - // 视觉附图注入在阶段 4/5 接入:这里先提取路径以便后续使用 - if (call.name === 'read_image_for_vision') { - const visionPath = extractVisionPath(raw); - if (visionPath && toolContext.providerIsVision) { - // TODO(阶段5): 追加多模态 HumanMessage(需 nativeImage 生成 data URL) - void visionPath; + // ── 提案失败兜底:补发 dismissed 终态 ────────────────────── + if ( + docProposal === null && + FILE_PROPOSAL_TOOLS.has(call.name) && + streamedPathsByCallId && + streamedPathsByCallId.size > 0 + ) { + const streamedPath = streamedPathsByCallId.get(call.toolCallId); + if (streamedPath) { + yield sse.fileProposal({ + summary: String(call.arguments.relative_path ?? streamedPath) || streamedPath, + content: '', + relativePath: streamedPath, + mode: call.name === STR_REPLACE_TOOL_NAME ? 'edit' : 'write', + status: 'dismissed', + }); + } + } + + // ── 提案定稿 ────────────────────────────────────────────── + if (docProposal) { + const fullContent = docProposal.content; + const relPath = docProposal.relativePath; + + // 注意:`.lr-agent` 路径的终态事件**仍然发射**(与流式拦截的 suppress 不同) + if (!isLrAgentRelative(relPath)) { + const operation = docProposal.operation; + const omitForPath = streamedPaths?.has(relPath) ?? false; + + if (operation !== 'delete' && !omitForPath) { + yield sse.fileProposalStart({ + summary: docProposal.title, + relativePath: relPath, + detail: String(fullContent.length), + mode: operation, + }); + // 定稿补齐时分 200 字符一片 + const CHUNK_SIZE = 200; + for (let offset = 0; offset < fullContent.length; offset += CHUNK_SIZE) { + yield sse.fileProposalDelta({ + content: fullContent.slice(offset, offset + CHUNK_SIZE), + relativePath: relPath, + mode: operation, + }); + } + } + + yield sse.fileProposal({ + summary: docProposal.title, + content: fullContent, + relativePath: relPath, + mode: operation, + oldPath: docProposal.oldPath || null, + }); } } - // 提案工具(PROPOSAL)在阶段 3 接入:解析 __doc_proposal__ 并发出 - // file_proposal_start / delta / file_proposal - void display; + // 视觉附图注入在阶段 4/5 接入:这里先提取路径以便后续使用 + if (visionPath && toolContext.providerIsVision) { + // TODO(阶段5): 追加多模态 HumanMessage(需 nativeImage 生成 data URL) + void visionPath; + } } interface ForceToolCallParams { diff --git a/src/main/agent/loop/proposalStreamer.test.ts b/src/main/agent/loop/proposalStreamer.test.ts new file mode 100644 index 0000000..3c70d54 --- /dev/null +++ b/src/main/agent/loop/proposalStreamer.test.ts @@ -0,0 +1,353 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { + ProposalStreamInterceptor, + extractJsonString, + type ToolCallChunkInput, +} from './proposalStreamer'; +import { toSseDict } from '../sse'; +import type { ClientContextLike } from '../tools/workspacePath'; + +/** 夹具工作区上下文:路径归一化需要一个根。 */ +const CTX: ClientContextLike = { + workspaceRoot: '/ws', + projectDirectoryPath: null, +}; + +/** 把 args JSON 切成固定长度的分片,模拟流式 tool_call。 */ +function fragment( + index: number, + id: string, + name: string, + argsJson: string, + size: number, +): ToolCallChunkInput[] { + const out: ToolCallChunkInput[] = []; + for (let i = 0; i < argsJson.length; i += size) { + out.push({ + index, + // 首片带 id / name,与真实 provider 行为一致 + ...(i === 0 ? { id, name } : {}), + argsFragment: argsJson.slice(i, i + size), + }); + } + return out; +} + +/** 逐个分片喂给拦截器,收集全部事件(已序列化为线格式)。 */ +function run( + chunks: ToolCallChunkInput[], + ctx: ClientContextLike | null = CTX, +): Array> { + const interceptor = new ProposalStreamInterceptor(ctx); + const events: Array> = []; + for (const chunk of chunks) { + for (const event of interceptor.onChunk([chunk])) { + events.push(toSseDict(event)); + } + } + return events; +} + +describe('extractJsonString:半截 JSON 解析', () => { + it('完整字符串返回值并标记闭合', () => { + expect(extractJsonString('{"a": "hi"}', 'a')).toEqual({ + value: 'hi', + closed: true, + }); + }); + + it('未闭合时返回已累积部分', () => { + expect(extractJsonString('{"a": "hi', 'a')).toEqual({ + value: 'hi', + closed: false, + }); + }); + + it('key 未出现时返回 null', () => { + expect(extractJsonString('{"b": "x"}', 'a')).toEqual({ + value: null, + closed: false, + }); + }); + + it('key 出现但值尚未开始时返回 null', () => { + expect(extractJsonString('{"a":', 'a')).toEqual({ value: null, closed: false }); + }); + + it('解码标准转义', () => { + const { value } = extractJsonString('{"a": "l1\\nl2\\tt\\"q\\"\\\\s"}', 'a'); + expect(value).toBe('l1\nl2\tt"q"\\s'); + }); + + it('未知转义按字面输出下一个字符', () => { + expect(extractJsonString('{"a": "x\\zy"}', 'a').value).toBe('xzy'); + }); + + it('解码 \\uXXXX', () => { + expect(extractJsonString('{"a": "\\u4f60\\u597d"}', 'a').value).toBe('你好'); + }); + + it('\\u 序列不完整时暂停解析(返回未闭合)', () => { + const result = extractJsonString('{"a": "\\u4f', 'a'); + expect(result.closed).toBe(false); + // 未完整解码的转义不进入结果 + expect(result.value).toBeNull(); + }); + + it('合成代理对(emoji)', () => { + // U+1F600 = \uD83D\uDE00 + const result = extractJsonString('{"a": "\\ud83d\\ude00"}', 'a'); + expect(result.value).toBe('😀'); + expect(result.closed).toBe(true); + }); + + it('孤立低代理输出替换字符', () => { + expect(extractJsonString('{"a": "\\udc00"}', 'a').value).toBe('\ufffd'); + }); + + it('非法十六进制按字面 u 输出,不卡死', () => { + expect(extractJsonString('{"a": "\\uZZZZ"}', 'a').value).toBe('uZZZZ'); + }); + + it('空字符串值为空串而非 null', () => { + expect(extractJsonString('{"a": ""}', 'a')).toEqual({ value: '', closed: true }); + }); + + it('按码点解码,emoji 不丢字', () => { + expect(extractJsonString('{"a": "😀x"}', 'a').value).toBe('😀x'); + }); +}); + +describe('提案拦截器:write 工具', () => { + const ARGS = JSON.stringify({ + relative_path: 'reports/summary.md', + content: '# 标题\n正文内容', + }); + + it('先发 start 再发 content 增量', () => { + const events = run(fragment(0, 'tc1', 'write_workspace_file', ARGS, 7)); + const types = events.map((e) => e.type); + expect(types[0]).toBe('file_proposal_start'); + expect(types.filter((t) => t === 'file_proposal_delta').length).toBeGreaterThan(0); + }); + + it('增量拼接后等于完整内容', () => { + const events = run(fragment(0, 'tc1', 'write_workspace_file', ARGS, 5)); + const content = events + .filter((e) => e.type === 'file_proposal_delta') + .map((e) => e.content as string) + .join(''); + expect(content).toBe('# 标题\n正文内容'); + }); + + it('start 带 detail="0",write 不输出 mode/operation', () => { + const events = run(fragment(0, 'tc1', 'write_workspace_file', ARGS, 7)); + const start = events.find((e) => e.type === 'file_proposal_start'); + expect(start?.detail).toBe('0'); + expect(start?.suggestedRelativePath).toBe('reports/summary.md'); + expect(start?.title).toBe('reports/summary.md'); + expect('mode' in (start ?? {})).toBe(false); + expect('operation' in (start ?? {})).toBe(false); + }); + + it('delta 携带 suggestedRelativePath 但不带 mode', () => { + const events = run(fragment(0, 'tc1', 'write_workspace_file', ARGS, 7)); + const delta = events.find((e) => e.type === 'file_proposal_delta'); + expect(delta?.suggestedRelativePath).toBe('reports/summary.md'); + expect('mode' in (delta ?? {})).toBe(false); + }); + + it('每个分片最多产生一条 delta', () => { + const chunks = fragment(0, 'tc1', 'write_workspace_file', ARGS, 7); + const interceptor = new ProposalStreamInterceptor(CTX); + for (const chunk of chunks) { + const events = interceptor.onChunk([chunk]); + expect(events.filter((e) => e.type === 'file_proposal_delta').length) + .toBeLessThanOrEqual(1); + } + }); + + it('含 emoji 的内容增量拼接后完整还原', () => { + const args = JSON.stringify({ relative_path: 'a.md', content: '前😀后🎉尾' }); + const events = run(fragment(0, 'tc1', 'write_workspace_file', args, 3)); + const content = events + .filter((e) => e.type === 'file_proposal_delta') + .map((e) => e.content as string) + .join(''); + expect(content).toBe('前😀后🎉尾'); + // 不应出现半个代理对(孤立代理会渲染成替换字符) + expect(content).not.toContain('\ufffd'); + }); +}); + +describe('提案拦截器:edit 工具', () => { + const ARGS = JSON.stringify({ + relative_path: 'config/app.json', + old_string: '"timeout": 30', + new_string: '"timeout": 120', + }); + + it('start 带 mode/operation = edit', () => { + const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 6)); + const start = events.find((e) => e.type === 'file_proposal_start'); + expect(start?.mode).toBe('edit'); + expect(start?.operation).toBe('edit'); + }); + + it('file_edit_delta 的 old/new 增量可拼接还原', () => { + const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + const deltas = events.filter((e) => e.type === 'file_edit_delta'); + expect(deltas.length).toBeGreaterThan(0); + + const oldJoined = deltas.map((e) => (e.oldDelta as string) ?? '').join(''); + const newJoined = deltas.map((e) => (e.newDelta as string) ?? '').join(''); + expect(oldJoined).toBe('"timeout": 30'); + expect(newJoined).toBe('"timeout": 120'); + }); + + it('不发 file_proposal_delta(edit 走 edit_delta)', () => { + const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + expect(events.some((e) => e.type === 'file_proposal_delta')).toBe(false); + }); + + it('oldDelta/newDelta 至少一个非空才发事件', () => { + const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + for (const event of events.filter((e) => e.type === 'file_edit_delta')) { + const hasDelta = Boolean(event.oldDelta) || Boolean(event.newDelta); + expect(hasDelta).toBe(true); + } + }); +}); + +describe('提案拦截器:.lr-agent 抑制', () => { + it('路径落入 .lr-agent 时不发任何提案事件', () => { + const args = JSON.stringify({ + relative_path: '.lr-agent/scratch.md', + content: '内部草稿', + }); + expect(run(fragment(0, 'tc1', 'write_workspace_file', args, 5))).toHaveLength(0); + }); + + it('被抑制的调用不出现在 streamedPathsByCallId', () => { + const args = JSON.stringify({ relative_path: '.lr-agent/scratch.md', content: 'x' }); + const interceptor = new ProposalStreamInterceptor(CTX); + for (const chunk of fragment(0, 'tc1', 'write_workspace_file', args, 5)) { + interceptor.onChunk([chunk]); + } + expect(interceptor.streamedPathsByCallId().size).toBe(0); + }); +}); + +describe('提案拦截器:状态重置与 provider 兼容', () => { + it('同一 tc_idx 上换 id 时重置状态(避免残留 rel_path 错标)', () => { + const interceptor = new ProposalStreamInterceptor(CTX); + const first = JSON.stringify({ relative_path: 'a.md', content: 'AAA' }); + for (const chunk of fragment(0, 'tc1', 'write_workspace_file', first, 100)) { + interceptor.onChunk([chunk]); + } + + const second = JSON.stringify({ relative_path: 'b.md', content: 'BBB' }); + let sawSecondStart = false; + for (const chunk of fragment(0, 'tc2', 'write_workspace_file', second, 100)) { + for (const event of interceptor.onChunk([chunk])) { + const dict = toSseDict(event); + if ( + dict.type === 'file_proposal_start' && + dict.suggestedRelativePath === 'b.md' + ) { + sawSecondStart = true; + } + } + } + expect(sawSecondStart).toBe(true); + }); + + it('kind 从 write 变为 edit 时重置状态', () => { + const interceptor = new ProposalStreamInterceptor(CTX); + const writeArgs = JSON.stringify({ relative_path: 'a.md', content: 'x' }); + for (const chunk of fragment(0, 'tc1', 'write_workspace_file', writeArgs, 100)) { + interceptor.onChunk([chunk]); + } + const editArgs = JSON.stringify({ + relative_path: 'a.md', + old_string: 'x', + new_string: 'y', + }); + const events = interceptor.onChunk([ + { + index: 0, + id: 'tc1', + name: 'str_replace_workspace_file', + argsFragment: editArgs, + }, + ]); + expect(events.some((e) => e.mode === 'edit')).toBe(true); + }); + + it('累积式 provider(重复投递完整 args)幂等', () => { + const args = JSON.stringify({ relative_path: 'a.md', content: 'hello' }); + const interceptor = new ProposalStreamInterceptor(CTX); + const collected: string[] = []; + for (let i = 0; i < 3; i += 1) { + for (const event of interceptor.onChunk([ + { index: 0, id: 'tc1', name: 'write_workspace_file', argsFragment: args }, + ])) { + if (event.type === 'file_proposal_delta') { + collected.push(event.content as string); + } + } + } + expect(collected.join('')).toBe('hello'); + }); + + it('增量式 provider(逐字符)也能正确累积', () => { + const args = JSON.stringify({ relative_path: 'a.md', content: 'hello' }); + const events = run(fragment(0, 'tc1', 'write_workspace_file', args, 1)); + const content = events + .filter((e) => e.type === 'file_proposal_delta') + .map((e) => e.content as string) + .join(''); + expect(content).toBe('hello'); + }); +}); + +describe('提案拦截器:streamedPathsByCallId', () => { + it('返回已出卡的 callId → 显示路径', () => { + const args = JSON.stringify({ relative_path: 'dir/x.md', content: 'c' }); + const interceptor = new ProposalStreamInterceptor(CTX); + for (const chunk of fragment(0, 'call-abc', 'write_workspace_file', args, 5)) { + interceptor.onChunk([chunk]); + } + expect(interceptor.streamedPathsByCallId().get('call-abc')).toBe('dir/x.md'); + expect(interceptor.collectedPaths().has('dir/x.md')).toBe(true); + }); + + it('路径未闭合时不出现在映射中', () => { + const interceptor = new ProposalStreamInterceptor(CTX); + interceptor.onChunk([ + { + index: 0, + id: 'tc1', + name: 'write_workspace_file', + argsFragment: '{"relative_path": "dir/x', + }, + ]); + expect(interceptor.streamedPathsByCallId().size).toBe(0); + }); + + it('多轮场景:新实例不携带上一轮状态', () => { + const args = JSON.stringify({ relative_path: 'a.md', content: 'x' }); + const round1 = new ProposalStreamInterceptor(CTX); + for (const chunk of fragment(0, 'tc1', 'write_workspace_file', args, 100)) { + round1.onChunk([chunk]); + } + expect(round1.streamedPathsByCallId().size).toBe(1); + + // 每轮新建实例 → 状态清空 + const round2 = new ProposalStreamInterceptor(CTX); + expect(round2.streamedPathsByCallId().size).toBe(0); + }); +}); diff --git a/src/main/agent/loop/proposalStreamer.ts b/src/main/agent/loop/proposalStreamer.ts new file mode 100644 index 0000000..ea72119 --- /dev/null +++ b/src/main/agent/loop/proposalStreamer.ts @@ -0,0 +1,376 @@ +/** + * 写文件 / 局部替换工具的 tool_call_chunks 流式拦截器。 + * + * 移植自 `vendor/local-agent/app/agent/assist/proposal_streamer.py`。 + * + * 在 LLM astream 过程中,按 tc_index 递增解析 JSON 参数,实时发射提案 SSE 事件: + * - `write_workspace_file` → `file_proposal_start` + `file_proposal_delta`(content 增量) + * - `str_replace_workspace_file` → `file_proposal_start(mode=edit)` + `file_edit_delta` + * (old_string / new_string 增量,前端据此渲染变更区域的局部 diff) + * + * 三个必须保真的要点: + * + * 1. **每轮新建实例**。tc_index 每个推理轮次都从 0 重新编号,拦截器实例不能跨轮 + * 复用。作为兜底,`onChunk` 会依据 tool_call id 检测同一 tc_idx 上的新调用并 + * 自动重置状态,防止上一轮的 rel_path / contentSentLen 残留导致 delta 错标路径、 + * 丢失新内容前缀。 + * + * 2. **按码点(code point)而非 UTF-16 码元计数**。Python 的字符串索引是按码点的, + * 若按码元计数,含 emoji 的文件内容会把增量切在代理对中间——既与 Python 的 + * 分片边界不一致,也可能产出半个代理对。 + * + * 3. **路径归一化单点**。模型可能给出绝对路径 / `./` 前缀 / 反斜杠;拦截器与 + * 工具定稿必须产出**同一条**显示路径,否则前端按路径匹配提案块时会裂成两张卡片。 + */ + +import { sse, type StreamEventPayload } from '../sse'; +import { + isLrAgentRelative, + normalizeWriteDisplayPath, + type ClientContextLike, +} from '../tools/workspacePath'; + +export const WRITE_TOOL_NAME = 'write_workspace_file'; +export const EDIT_TOOL_NAME = 'str_replace_workspace_file'; + +const HEX_DIGITS = /^[0-9a-fA-F]{4}$/; + +/** 一个 tool_call 的拦截状态。 */ +interface ProposalState { + argsBuf: string; + titleSent: boolean; + kind: 'write' | 'edit'; + /** 已发送的内容长度(**码点**数)。 */ + contentSentLen: number; + /** 已发送的 old_string 长度(码点数)。 */ + oldSentLen: number; + /** 已发送的 new_string 长度(码点数)。 */ + newSentLen: number; + relPath: string; + callId: string; + /** 路径落入 `.lr-agent`:本调用后续不再出事件。 */ + suppressed?: boolean; +} + +function newState(callId = '', kind: 'write' | 'edit' = 'write'): ProposalState { + return { + argsBuf: '', + titleSent: false, + kind, + contentSentLen: 0, + oldSentLen: 0, + newSentLen: 0, + relPath: '', + callId, + }; +} + +/** 码点数组(等价于 Python 的字符串索引单位)。 */ +function codePoints(text: string): string[] { + return Array.from(text); +} + +/** + * 解码以 `chars[uPos]`(`'u'` 字符)开头的 `\uXXXX` 转义。 + * + * @returns `{ text, consumed }`;`text` 为 null 表示序列不完整,调用方应暂停解析、 + * 等待更多分片后整体重解析。支持代理对。 + */ +function decodeUnicodeEscape( + chars: string[], + uPos: number, +): { text: string | null; consumed: number } { + const hexPart = chars.slice(uPos + 1, uPos + 5).join(''); + if (hexPart.length < 4) return { text: null, consumed: 0 }; + if (!HEX_DIGITS.test(hexPart)) { + // 非法转义:按字面输出并跳过,避免解析卡死 + return { text: 'u', consumed: 1 }; + } + const cp = parseInt(hexPart, 16); + + if (cp >= 0xd800 && cp <= 0xdbff) { + // 高代理:尝试合成代理对 + const lowEsc = chars.slice(uPos + 5, uPos + 7).join(''); + const lowHex = chars.slice(uPos + 7, uPos + 11).join(''); + if (lowEsc === '\\u' && lowHex.length === 4 && HEX_DIGITS.test(lowHex)) { + const low = parseInt(lowHex, 16); + if (low >= 0xdc00 && low <= 0xdfff) { + const combined = 0x10000 + ((cp - 0xd800) << 10) + (low - 0xdc00); + return { text: String.fromCodePoint(combined), consumed: 11 }; + } + } + return { text: '\ufffd', consumed: 5 }; + } + if (cp >= 0xdc00 && cp <= 0xdfff) { + // 孤立低代理 + return { text: '\ufffd', consumed: 5 }; + } + return { text: String.fromCodePoint(cp), consumed: 5 }; +} + +/** + * 从可能不完整的 JSON 缓冲区中提取字符串字段值。 + * + * 处理标准 JSON 转义(`\n \t \r \\ \" \uXXXX`)。返回: + * - key 尚未出现 → `{ value: null, closed: false }` + * - 出现但字符串值未闭合引号 → `{ value: 已累积部分, closed: false }` + * - 字符串值已闭合引号 → `{ value: 完整值, closed: true }` + */ +export function extractJsonString( + buf: string, + key: string, +): { value: string | null; closed: boolean } { + const chars = codePoints(buf); + const needle = codePoints(`"${key}"`); + + const idx = indexOfSequence(chars, needle); + if (idx === -1) return { value: null, closed: false }; + + const colon = chars.indexOf(':', idx); + if (colon === -1) return { value: null, closed: false }; + + let quote = -1; + for (let i = colon + 1; i < chars.length; i += 1) { + if (chars[i] === '"') { + quote = i; + break; + } + } + if (quote === -1) return { value: null, closed: false }; + + const result: string[] = []; + let i = quote + 1; + while (i < chars.length) { + const c = chars[i]; + if (c === '\\' && i + 1 < chars.length) { + const nxt = chars[i + 1]; + if (nxt === 'u') { + const decoded = decodeUnicodeEscape(chars, i + 1); + if (decoded.text === null) break; + result.push(decoded.text); + i += 1 + decoded.consumed; + continue; + } + const escapes: Record = { + n: '\n', + t: '\t', + r: '\r', + '\\': '\\', + '"': '"', + }; + result.push(escapes[nxt] ?? nxt); + i += 2; + } else if (c === '"') { + return { value: result.join(''), closed: true }; + } else { + result.push(c); + i += 1; + } + } + return { value: result.length ? result.join('') : null, closed: false }; +} + +/** 在码点数组中查找子序列,返回起始下标;未找到返回 -1。 */ +function indexOfSequence(haystack: string[], needle: string[]): number { + if (needle.length === 0) return 0; + outer: for (let i = 0; i + needle.length <= haystack.length; i += 1) { + for (let j = 0; j < needle.length; j += 1) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +/** + * 合并参数分片,兼容增量式与累积式两种 provider 行为,重复投递幂等。 + * + * - `tcArgs` 以 `buf` 开头 → 覆盖(首片或累积式 provider) + * - `buf` 以 `tcArgs` 开头 → 忽略(已见前缀的重复投递) + * - 否则 → 拼接(增量式) + */ +function mergeArgs(state: ProposalState, tcArgs: string): void { + if (!tcArgs) return; + const buf = state.argsBuf; + if (tcArgs.startsWith(buf)) { + state.argsBuf = tcArgs; + } else if (buf.startsWith(tcArgs)) { + return; + } else { + state.argsBuf = buf + tcArgs; + } +} + +/** 单个 tool_call 分片(与 `LlmDelta.toolCallChunks` 元素同形)。 */ +export interface ToolCallChunkInput { + index: number; + id?: string; + name?: string; + argsFragment?: string; +} + +export class ProposalStreamInterceptor { + private readonly clientContext: ClientContextLike | null; + + private readonly states = new Map(); + + constructor(clientContext: ClientContextLike | null = null) { + // client_context 用于把模型给的 relative_path 归一为与工具定稿事件一致的 + // 显示路径(绝对路径 / ./ 前缀 / 反斜杠都会被归一) + this.clientContext = clientContext; + } + + /** 处理一批 tool_call 分片,返回需要产出的 SSE 事件。 */ + onChunk(chunks: ToolCallChunkInput[]): StreamEventPayload[] { + const events: StreamEventPayload[] = []; + + for (const tc of chunks) { + const tcName = tc.name; + const tcArgs = tc.argsFragment ?? ''; + const tcIdx = tc.index; + + if (tcIdx == null) continue; + + if (tcName === WRITE_TOOL_NAME || tcName === EDIT_TOOL_NAME) { + const tcId = tc.id ?? ''; + const kind: 'write' | 'edit' = + tcName === EDIT_TOOL_NAME ? 'edit' : 'write'; + + let state = this.states.get(tcIdx); + if (state === undefined) { + state = newState(tcId, kind); + this.states.set(tcIdx, state); + } else if (tcId && state.callId && tcId !== state.callId) { + // 同一 tc_idx 上出现了新的 tool_call:重置状态。否则上一轮的 rel_path + // 会错标本轮 delta 的归属路径,contentSentLen 会吞掉新内容的前缀。 + state = newState(tcId, kind); + this.states.set(tcIdx, state); + } else if (state.kind !== kind) { + state = newState(tcId, kind); + this.states.set(tcIdx, state); + } + mergeArgs(state, tcArgs); + } else { + const existing = this.states.get(tcIdx); + if (existing && tcArgs) mergeArgs(existing, tcArgs); + } + + const current = this.states.get(tcIdx); + if (!current || !current.argsBuf) continue; + const state = current; + + if (!state.titleSent) { + const { value: relPath, closed: relClosed } = extractJsonString( + state.argsBuf, + 'relative_path', + ); + if (relPath && relClosed) { + state.titleSent = true; + if (isLrAgentRelative(relPath)) { + state.suppressed = true; + } else { + state.relPath = normalizeWriteDisplayPath(this.clientContext, relPath); + events.push( + sse.fileProposalStart({ + summary: state.relPath, + relativePath: state.relPath, + detail: '0', + mode: state.kind === 'edit' ? 'edit' : null, + }), + ); + } + } + } + + if (state.suppressed) continue; + + if (state.kind === 'edit') { + events.push(...this.editDeltaEvents(state)); + continue; + } + + const { value: content } = extractJsonString(state.argsBuf, 'content'); + if (content !== null) { + const contentLen = codePoints(content).length; + if (contentLen > state.contentSentLen) { + const delta = codePoints(content) + .slice(state.contentSentLen) + .join(''); + state.contentSentLen = contentLen; + if (delta) { + events.push( + sse.fileProposalDelta({ + content: delta, + relativePath: state.relPath, + }), + ); + } + } + } + } + + return events; + } + + /** 提取 old_string / new_string 增量,合并为一个 `file_edit_delta` 事件。 */ + private editDeltaEvents(state: ProposalState): StreamEventPayload[] { + const buf = state.argsBuf; + let oldDelta: string | null = null; + let newDelta: string | null = null; + + const oldVal = extractJsonString(buf, 'old_string').value; + if (oldVal !== null) { + const len = codePoints(oldVal).length; + if (len > state.oldSentLen) { + oldDelta = codePoints(oldVal).slice(state.oldSentLen).join(''); + state.oldSentLen = len; + } + } + const newVal = extractJsonString(buf, 'new_string').value; + if (newVal !== null) { + const len = codePoints(newVal).length; + if (len > state.newSentLen) { + newDelta = codePoints(newVal).slice(state.newSentLen).join(''); + state.newSentLen = len; + } + } + + if (!oldDelta && !newDelta) return []; + return [ + sse.fileEditDelta({ + relativePath: state.relPath, + oldDelta, + newDelta, + }), + ]; + } + + /** + * `callId` → 已流式过 start 的显示路径(已归一化)。 + * + * 定稿时用它抑制重复的 start / delta;工具报错时据此对已出卡的路径补发 + * `dismissed` 终态,避免卡片悬挂。无 call_id 的状态无法关联回具体调用,不进入映射。 + */ + streamedPathsByCallId(): Map { + const streamed = new Map(); + for (const state of this.states.values()) { + const { value: relPath, closed: relClosed } = extractJsonString( + state.argsBuf, + 'relative_path', + ); + if (relPath && relClosed && state.titleSent && !state.suppressed) { + const callId = state.callId || ''; + streamed.set( + callId, + state.relPath || normalizeWriteDisplayPath(this.clientContext, relPath), + ); + } + } + return streamed; + } + + /** 已发送过 `file_proposal_start` 的显示路径集合。 */ + collectedPaths(): Set { + return new Set(this.streamedPathsByCallId().values()); + } +} diff --git a/src/main/agent/tools/fileProposal.ts b/src/main/agent/tools/fileProposal.ts new file mode 100644 index 0000000..41c92d0 --- /dev/null +++ b/src/main/agent/tools/fileProposal.ts @@ -0,0 +1,478 @@ +/** + * 写文件提案工具(write / str_replace / delete / move)。 + * + * 移植自 `vendor/local-agent/app/agent/tools/workspace_file_reader.py` 的四个 + * `*_workspace_file_tool` 与 `extract_doc_proposal_from_tool_result`。 + * + * **这些工具不落盘**:只校验路径与内容,产出一个带内部标记 `__doc_proposal__` + * 的结果;调度层据此发出 `file_proposal_start` / `file_proposal_delta` / + * `file_proposal` 事件,真正写盘由 Electron 在用户 Keep All 后执行。 + * + * **基线接力(pendingProposals)**:同一轮里对同一文件的多次修改必须累积成一份提案。 + * `str_replace` 以最新**提案内容**为基线,而不是磁盘原文——否则第二份提案基于未含 + * 第一处修改的磁盘内容,确认落盘后会吞掉前一处修改。该缓存是请求级的,由调用方 + * (`ToolContext`)持有。 + * + * 存储的值是 `(落盘时的磁盘快照 | null, 提案完整内容)`:`null` 表示整文件覆写、 + * 不依赖磁盘快照;非 null 时用于检测「提案生成后磁盘又被改动」(如用户手动保存), + * 此时旧提案基线已过期,需要丢弃。 + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { isBlockedTextExtension } from '../../../shared/workspaceTextExtensions'; +import { buildToolResult, DOC_PROPOSAL_MARKER } from './result'; +import { + relativeDisplayPath, + resolveWorkspaceFile, + resolveWorkspaceWritePath, + type ClientContextLike, +} from './workspacePath'; + +export const WRITE_TOOL_NAME = 'write_workspace_file'; +export const STR_REPLACE_TOOL_NAME = 'str_replace_workspace_file'; +export const DELETE_TOOL_NAME = 'delete_workspace_file'; +export const MOVE_TOOL_NAME = 'move_workspace_file'; + +export const FILE_PROPOSAL_TOOLS: ReadonlySet = new Set([ + WRITE_TOOL_NAME, + STR_REPLACE_TOOL_NAME, + DELETE_TOOL_NAME, + MOVE_TOOL_NAME, +]); + +/** 路径绝对路径 → (磁盘快照 | null, 提案内容)。 */ +export type PendingProposalContents = Map; + +/** 提案数据(供调度层发 SSE 事件)。 */ +export interface DocProposal { + relativePath: string; + title: string; + content: string; + operation: 'write' | 'delete' | 'rename'; + oldPath: string; +} + +/** + * Python `str.title()` 的等价实现。 + * + * 规则:每个「词」的首字母大写,其余小写;词是新出现在一个非字母字符之后。 + * 用于把文件名主干转成提案标题(如 `app-config` → `App Config`)。 + */ +export function pythonTitle(input: string): string { + let out = ''; + let prevWasLetter = false; + for (const ch of input) { + const isLetter = /[A-Za-z]/.test(ch); + if (isLetter) { + out += prevWasLetter ? ch.toLowerCase() : ch.toUpperCase(); + } else { + out += ch; + } + prevWasLetter = isLetter; + } + return out; +} + +/** 由文件绝对路径推导提案标题(沿用 Python 的 stem 处理)。 */ +function titleFromPath(resolved: string): string { + const stem = path.parse(resolved).name; + return pythonTitle(stem.replace(/-/g, ' ').replace(/_/g, ' ')); +} + +function notAllowedSuffix(suffix: string): boolean { + return isBlockedTextExtension(suffix); +} + +/** 读取 UTF-8 文本;文件不存在返回 null,非 UTF-8 抛错。 */ +function readUtf8OrNull(target: string): { text: string; missing: boolean } { + if (!fs.existsSync(target)) return { text: '', missing: true }; + const raw = fs.readFileSync(target); + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(raw); + return { + text: text.charCodeAt(0) === 0xfeff ? text.slice(1) : text, + missing: false, + }; + } catch { + throw new Error('NOT_UTF8'); + } +} + +/** 准备写文件提案。 */ +export function writeWorkspaceFileTool( + clientContext: ClientContextLike | null, + relativePath: string, + content: string, + pendingProposals?: PendingProposalContents, +): string { + const resolveResult = resolveWorkspaceWritePath(clientContext, relativePath); + if (resolveResult.resolved === null) { + return buildToolResult({ + ok: false, + tool: WRITE_TOOL_NAME, + status: 'error', + summary: `无法写入文件:${resolveResult.error}`, + }); + } + const resolved = resolveResult.resolved; + const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); + + const suffix = path.extname(resolved).toLowerCase(); + if (notAllowedSuffix(suffix)) { + return buildToolResult({ + ok: false, + tool: WRITE_TOOL_NAME, + status: 'error', + summary: + `write_workspace_file 不支持后缀 ${suffix}(二进制/富媒体格式)。` + + '请使用 UTF-8 文本或代码文件。', + }); + } + + const title = titleFromPath(resolved); + const summary = + `已生成文件提案:${relDisplay}。` + + '文件尚未写入磁盘;用户确认(Keep All)后才会落盘。' + + '请勿在回复中声称文件已创建或已保存。'; + + if (pendingProposals) pendingProposals.set(resolved, [null, content]); + + return buildToolResult({ + ok: true, + tool: WRITE_TOOL_NAME, + status: 'proposal_ready', + summary, + file_written: false, + proposal_pending: true, + [DOC_PROPOSAL_MARKER]: true, + relative_path: relDisplay, + title, + content, + operation: 'write', + }); +} + +/** 在已有文本文件中做精确替换,生成完整新内容的提案。 */ +export function strReplaceWorkspaceFileTool( + clientContext: ClientContextLike | null, + relativePath: string, + oldString: string, + newString: string, + replaceAll = false, + pendingProposals?: PendingProposalContents, +): string { + const failure = (summary: string): string => + buildToolResult({ + ok: false, + tool: STR_REPLACE_TOOL_NAME, + status: 'error', + summary, + }); + + let resolveResult = resolveWorkspaceFile(clientContext, relativePath); + let resolved = resolveResult.resolved; + + if (resolved === null) { + // 文件在磁盘上不存在:若同路径已有未落盘的 write 提案,则以提案为基线继续 + if (pendingProposals) { + const writeResult = resolveWorkspaceWritePath(clientContext, relativePath); + if ( + writeResult.resolved !== null && + pendingProposals.has(writeResult.resolved) + ) { + resolved = writeResult.resolved; + } + } + if (resolved === null) { + return failure(`无法读取文件:${resolveResult.error}`); + } + } + + const suffix = path.extname(resolved).toLowerCase(); + if (notAllowedSuffix(suffix)) { + return failure(`不支持后缀 ${suffix},请使用 UTF-8 文本或代码文件。`); + } + + if (!oldString) return failure('old_string 不能为空。'); + + let original: string; + try { + original = readUtf8OrNull(resolved).text; + } catch (err) { + if (err instanceof Error && err.message === 'NOT_UTF8') { + return failure('文件不是 UTF-8 文本。'); + } + return failure(`读取失败:${errMessage(err)}`); + } + + // 基线接力:同路径已有未落盘提案且磁盘未再被改动时,以提案内容为基线, + // 使本轮的多处修改在同一份提案里累积。 + let base = original; + let baseFromPending = false; + if (pendingProposals) { + const pending = pendingProposals.get(resolved); + if (pending) { + const [pendingDiskSnap, pendingContent] = pending; + if (pendingDiskSnap === null || pendingDiskSnap === original) { + base = pendingContent; + baseFromPending = true; + } else { + // 磁盘在提案生成后又被改动(如用户手动保存),旧提案基线过期 + pendingProposals.delete(resolved); + } + } + } + + const count = countOccurrences(base, oldString); + if (count === 0) { + const hint = baseFromPending + ? '(注意:本轮对该文件已有未落盘的提案,替换基线是提案内容而非磁盘原文,' + + 'read_workspace_file 读到的磁盘原文可能不含此前提案的修改)' + : ''; + const firstLine = oldString.split(/\r\n|\r|\n/)[0]?.slice(0, 80) ?? ''; + return failure( + `未找到 old_string(首行:${firstLine})。` + + '常见原因:缩进/空白字符与原文不一致,或该处内容已变化。' + + '请先用 read_workspace_file 核对目标段落,原样复制(含缩进)后重试。' + + hint, + ); + } + if (count > 1 && !replaceAll) { + return failure( + `old_string 出现 ${count} 次。请前后多带几行上下文使片段唯一;` + + '确需替换全部出现时设 replace_all=true。', + ); + } + + const content = replaceAll + ? replaceAllOccurrences(base, oldString, newString) + : replaceFirstOccurrence(base, oldString, newString); + + if (pendingProposals) pendingProposals.set(resolved, [original, content]); + + const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); + const title = titleFromPath(resolved); + const summary = + `已生成补丁提案:${relDisplay}(替换 ${replaceAll ? count : 1} 处)。` + + '文件尚未写入磁盘;用户确认(Keep All)后才会落盘。'; + + return buildToolResult({ + ok: true, + tool: STR_REPLACE_TOOL_NAME, + status: 'proposal_ready', + summary, + file_written: false, + proposal_pending: true, + [DOC_PROPOSAL_MARKER]: true, + relative_path: relDisplay, + title, + content, + operation: 'write', + }); +} + +/** 准备删除文本文件的提案。 */ +export function deleteWorkspaceFileTool( + clientContext: ClientContextLike | null, + relativePath: string, + pendingProposals?: PendingProposalContents, +): string { + const failure = (summary: string): string => + buildToolResult({ + ok: false, + tool: DELETE_TOOL_NAME, + status: 'error', + summary, + }); + + const fileResult = resolveWorkspaceFile(clientContext, relativePath); + if (fileResult.resolved === null) { + return failure(`无法删除文件:${fileResult.error}`); + } + const resolved = fileResult.resolved; + + const writeResult = resolveWorkspaceWritePath(clientContext, relativePath); + if (writeResult.resolved === null) { + return failure(`无法删除文件:${writeResult.error}`); + } + + const suffix = path.extname(resolved).toLowerCase(); + if (notAllowedSuffix(suffix)) { + return failure(`不支持删除后缀 ${suffix}。`); + } + + const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); + const title = `删除 ${path.basename(resolved)}`; + const summary = + `已生成删除提案:${relDisplay}。` + + '文件尚未删除;用户确认(Keep All)后才会从磁盘移除。'; + + if (pendingProposals) pendingProposals.delete(resolved); + + return buildToolResult({ + ok: true, + tool: DELETE_TOOL_NAME, + status: 'proposal_ready', + summary, + file_written: false, + proposal_pending: true, + [DOC_PROPOSAL_MARKER]: true, + relative_path: relDisplay, + title, + content: '', + operation: 'delete', + }); +} + +/** 准备移动/重命名文件的提案。 */ +export function moveWorkspaceFileTool( + clientContext: ClientContextLike | null, + relativePath: string, + newRelativePath: string, + pendingProposals?: PendingProposalContents, +): string { + const failure = (summary: string): string => + buildToolResult({ + ok: false, + tool: MOVE_TOOL_NAME, + status: 'error', + summary, + }); + + const fileResult = resolveWorkspaceFile(clientContext, relativePath); + if (fileResult.resolved === null) { + return failure(`无法移动文件:${fileResult.error}`); + } + const resolved = fileResult.resolved; + + const suffix = path.extname(resolved).toLowerCase(); + if (notAllowedSuffix(suffix)) { + return failure(`不支持移动后缀 ${suffix} 的文件。`); + } + + const newRaw = (newRelativePath ?? '').trim(); + if (!newRaw) return failure('new_relative_path 不能为空。'); + + const targetResult = resolveWorkspaceWritePath(clientContext, newRaw); + if (targetResult.resolved === null) { + return failure(`目标路径无效:${targetResult.error}`); + } + const target = targetResult.resolved; + + if (target === resolved) { + return failure('目标路径与原路径相同,无需移动。'); + } + if (fs.existsSync(target)) { + return failure( + `目标已存在:${relativeDisplayPath(clientContext, target, newRaw)}。请换一个目标路径。`, + ); + } + const targetSuffix = path.extname(target).toLowerCase(); + if (notAllowedSuffix(targetSuffix)) { + return failure( + `目标后缀 ${targetSuffix} 不受支持,请使用 UTF-8 文本或代码文件。`, + ); + } + + const oldDisplay = relativeDisplayPath(clientContext, resolved, relativePath); + const newDisplay = relativeDisplayPath(clientContext, target, newRaw); + const title = `移动 ${path.basename(resolved)}`; + const summary = + `已生成移动/重命名提案:${oldDisplay} → ${newDisplay}。` + + '文件尚未改动;用户确认(Keep All)后才会在磁盘执行。'; + + if (pendingProposals) { + pendingProposals.delete(resolved); + pendingProposals.delete(target); + } + + return buildToolResult({ + ok: true, + tool: MOVE_TOOL_NAME, + status: 'proposal_ready', + summary, + file_written: false, + proposal_pending: true, + [DOC_PROPOSAL_MARKER]: true, + relative_path: newDisplay, + old_path: oldDisplay, + title, + content: '', + operation: 'rename', + }); +} + +/** + * 从写/补丁/删除工具结果中提取文档提案数据。 + * + * `operation` 被收窄为 `write` / `delete` / `rename` 三值。 + */ +export function extractDocProposalFromToolResult( + toolName: string, + resultText: string, +): DocProposal | null { + if (!FILE_PROPOSAL_TOOLS.has(toolName)) return null; + + let data: unknown; + try { + data = JSON.parse(resultText); + } catch { + return null; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return null; + const record = data as Record; + if (!record[DOC_PROPOSAL_MARKER]) return null; + + let operation = String(record.operation ?? 'write'); + if (operation !== 'write' && operation !== 'delete' && operation !== 'rename') { + operation = 'write'; + } + + return { + relativePath: String(record.relative_path ?? '') || 'document.md', + title: String(record.title ?? '') || '文档', + content: String(record.content ?? ''), + operation: operation as DocProposal['operation'], + oldPath: String(record.old_path ?? ''), + }; +} + +// ── 字符串工具(避免正则的特殊字符与大小写陷阱) ──────────────────── + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let from = 0; + for (;;) { + const idx = haystack.indexOf(needle, from); + if (idx === -1) break; + count += 1; + from = idx + needle.length; + if (count > 1000) break; + } + return count; +} + +function replaceFirstOccurrence( + haystack: string, + needle: string, + replacement: string, +): string { + const idx = haystack.indexOf(needle); + if (idx === -1) return haystack; + return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length); +} + +function replaceAllOccurrences( + haystack: string, + needle: string, + replacement: string, +): string { + if (!needle) return haystack; + return haystack.split(needle).join(replacement); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index 8c35ab8..4a68c26 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -21,6 +21,13 @@ import { z } from 'zod'; import type { ToolSpec } from '../llm/client'; import { buildToolResult } from './result'; +import { + deleteWorkspaceFileTool, + moveWorkspaceFileTool, + strReplaceWorkspaceFileTool, + writeWorkspaceFileTool, + type PendingProposalContents, +} from './fileProposal'; import { globWorkspace, grepWorkspace, @@ -44,6 +51,13 @@ export interface ToolContext { providerIsVision: boolean; /** 当前轮的用户文本(部分工具需要)。 */ userContent: string; + /** + * 请求级提案内容缓存(路径 → (磁盘快照 | null, 提案内容))。 + * + * 让同轮对同一文件的多次 `str_replace` 累积到同一份提案上。必须是**请求级**的 + * (Python 侧在每次请求重建工具集时创建闭包缓存),因此由调用方注入而非模块级单例。 + */ + pendingProposals: PendingProposalContents; } export interface ToolDefinition { @@ -459,6 +473,13 @@ export function buildTools(): ToolDefinition[] { '移动/重命名文件请用 move_workspace_file。', kind: 'proposal', argsSchema: WriteFileArgs, + execute: (args, ctx) => + writeWorkspaceFileTool( + ctx.clientContext, + String(args.relative_path ?? ''), + String(args.content ?? ''), + ctx.pendingProposals, + ), }, { name: 'str_replace_workspace_file', @@ -468,6 +489,15 @@ export function buildTools(): ToolDefinition[] { '改局部代码时优先用本工具。不能用来改标注。', kind: 'proposal', argsSchema: StrReplaceArgs, + execute: (args, ctx) => + strReplaceWorkspaceFileTool( + ctx.clientContext, + String(args.relative_path ?? ''), + String(args.old_string ?? ''), + String(args.new_string ?? ''), + Boolean(args.replace_all), + ctx.pendingProposals, + ), }, { name: 'delete_workspace_file', @@ -476,6 +506,12 @@ export function buildTools(): ToolDefinition[] { '用户确认 Keep All 后才从磁盘移除。不能删除标注。', kind: 'proposal', argsSchema: DeleteFileArgs, + execute: (args, ctx) => + deleteWorkspaceFileTool( + ctx.clientContext, + String(args.relative_path ?? ''), + ctx.pendingProposals, + ), }, { name: 'move_workspace_file', @@ -485,6 +521,13 @@ export function buildTools(): ToolDefinition[] { '禁止用「读出内容再写到新路径」来移动文件。', kind: 'proposal', argsSchema: MoveFileArgs, + execute: (args, ctx) => + moveWorkspaceFileTool( + ctx.clientContext, + String(args.relative_path ?? ''), + String(args.new_relative_path ?? ''), + ctx.pendingProposals, + ), }, { name: 'auto_annotate', diff --git a/src/main/agent/tools/result.ts b/src/main/agent/tools/result.ts index 7e72d54..bdafd05 100644 --- a/src/main/agent/tools/result.ts +++ b/src/main/agent/tools/result.ts @@ -12,6 +12,8 @@ * 不经 `buildToolResult` 包装;此时 `formatToolResultForDisplay` 原样返回。 */ +import { pythonJsonDumps } from '../json'; + /** 生成统一工具结果 JSON。`file_written` / `proposal_pending` 默认 false。 */ export function buildToolResult(params: { ok: boolean; @@ -32,7 +34,8 @@ export function buildToolResult(params: { delete rest.file_written; delete rest.proposal_pending; Object.assign(payload, rest); - return JSON.stringify(payload); + // 用 Python 的 json.dumps 语义(分隔符带空格),否则原样回灌的完整 JSON 会与基准不一致 + return pythonJsonDumps(payload); } /** From cf0055ce756710c6a19548e4eb8df2eeb6e1032b Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 02:08:03 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat(agent):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E5=B7=A5=E5=85=B7=E4=B8=8E=20nativeImage=20?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agentImage 模块:文件头解析(PNG/JPEG/GIF/WebP/BMP/ICO 的尺寸与格式)+ 基于 nativeImage 的裁剪/缩放/JPEG 编码,零新增依赖替代 Pillow;显式把 RGBA 合成到白底以对齐 Pillow 的 JPEG 输出。新增 imageService 分层:探测为纯函数(独立模式亦可),编码经 RPC 走主进程。补齐 read_file_annotation(直接 import 主进程 annotationDataStore 复用 sha256 fileKey,无重复实现)、read_document_file(DOCX 用 mammoth 懒加载,PDF 用自研轻量提取器,覆盖 FlateDecode 与 Tj/TJ/引号算子、转义、UTF-16BE)、read_image_for_vision(附图标记与路径存在性校验)。顺带修正 Python 侧 pypdf/python-docx 未声明依赖导致该工具在干净环境不可用的问题。golden diff 保持 10/11;主进程测试 308/308。 Co-authored-by: Cursor --- src/main/agent/handlers/chatStream.ts | 3 +- src/main/agent/handlers/deps.ts | 16 + src/main/agent/ipcSurface.ts | 70 ++++ src/main/agent/loop/assistLoop.test.ts | 3 + src/main/agent/loop/assistLoop.ts | 4 + src/main/agent/protocol.ts | 8 + src/main/agent/runtime.ts | 8 +- src/main/agent/services/imageService.ts | 186 +++++++++ src/main/agent/tools/annotationDoc.ts | 84 ++++ src/main/agent/tools/documentReader.test.ts | 146 +++++++ src/main/agent/tools/documentReader.ts | 438 ++++++++++++++++++++ src/main/agent/tools/registry.ts | 38 +- src/main/agent/tools/result.ts | 20 +- src/main/agent/tools/vision.ts | 76 ++++ src/main/agentImage/handlers.ts | 336 +++++++++++++++ src/main/agentImage/imageOps.test.ts | 233 +++++++++++ src/main/agentImage/imageOps.ts | 235 +++++++++++ 17 files changed, 1895 insertions(+), 9 deletions(-) create mode 100644 src/main/agent/services/imageService.ts create mode 100644 src/main/agent/tools/annotationDoc.ts create mode 100644 src/main/agent/tools/documentReader.test.ts create mode 100644 src/main/agent/tools/documentReader.ts create mode 100644 src/main/agent/tools/vision.ts create mode 100644 src/main/agentImage/handlers.ts create mode 100644 src/main/agentImage/imageOps.test.ts create mode 100644 src/main/agentImage/imageOps.ts diff --git a/src/main/agent/handlers/chatStream.ts b/src/main/agent/handlers/chatStream.ts index 822b7c6..97e3372 100644 --- a/src/main/agent/handlers/chatStream.ts +++ b/src/main/agent/handlers/chatStream.ts @@ -17,7 +17,7 @@ import { LlmClient } from '../llm/client'; import { DONE_FRAME, encodeSseFrame, sse, toSseDict } from '../sse'; import { buildToolSpecs } from '../tools/registry'; -import { toolRegistry, type RuntimeDeps } from './deps'; +import { toolRegistry, resolveImageService, type RuntimeDeps } from './deps'; import { deriveTaskPhase } from '../loop/taskPhase'; import { resolveAssistToolSet } from '../loop/modeRouter'; import { streamAssist } from '../loop/assistLoop'; @@ -165,6 +165,7 @@ async function* streamFrames( taskPhaseContext, isCancelled: params.isCancelled, signal: params.signal, + imageService: resolveImageService(deps), }); } else { const systemPrompt = buildChatSystemPrompt(body.systemPrompt); diff --git a/src/main/agent/handlers/deps.ts b/src/main/agent/handlers/deps.ts index 5e33c10..e77d909 100644 --- a/src/main/agent/handlers/deps.ts +++ b/src/main/agent/handlers/deps.ts @@ -9,6 +9,10 @@ import { loadAgentSettings, type AgentSettings } from '../config'; import { buildTools, type ToolDefinition } from '../tools/registry'; +import { + createStandaloneImageService, + type ImageService, +} from '../services/imageService'; export interface RuntimeDeps { settings: AgentSettings; @@ -16,6 +20,18 @@ export interface RuntimeDeps { fetchImpl?: typeof fetch; /** LLM 请求超时(默认 120s,对齐 Python)。 */ llmTimeoutMs?: number; + /** + * 图像服务。 + * + * utilityProcess 模式下由 `runtime.ts` 用 RPC 桥构造(编码能力可用); + * 独立模式下用 `createStandaloneImageService()`(仅探测能力)。测试可注入替身。 + */ + imageService?: ImageService; +} + +/** 解析出可用的图像服务;未注入时退化为独立模式(仅探测)。 */ +export function resolveImageService(deps: RuntimeDeps): ImageService { + return deps.imageService ?? createStandaloneImageService(); } let cachedTools: Map | null = null; diff --git a/src/main/agent/ipcSurface.ts b/src/main/agent/ipcSurface.ts index dc58b7c..6cafe3b 100644 --- a/src/main/agent/ipcSurface.ts +++ b/src/main/agent/ipcSurface.ts @@ -18,6 +18,12 @@ import { readWorkspaceTextFile, writeWorkspaceTextFile, } from '../workspace/workspaceOperations'; +import { + cropImageBatch, + imageToJpegDataUrl, + probeImage, + type CropBox, +} from '../agentImage/handlers'; /** 断言 payload 是对象,避免 undefined 解构崩溃。 */ function asRecord(payload: unknown, channel: string): Record { @@ -39,6 +45,24 @@ function requireString( return value; } +/** 可选字符串:undefined / null 返回 null,其它非字符串值报错。 */ +function optionalString(value: unknown): string | null { + if (value === undefined || value === null) return null; + return typeof value === 'string' ? value : null; +} + +function requireNumber( + obj: Record, + key: string, + channel: string, +): number { + const value = obj[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new RpcError(channel, `字段 ${key} 必须是有限数字`); + } + return value; +} + /** * 构造主进程侧 handler 表。 * @@ -88,6 +112,52 @@ export function createMainRpcHandlers(): RpcHandlers { }); }; + // ── 图像能力(nativeImage;运行时内不可用,必须走这里)────────── + handlers[AGENT_CHANNELS.imageProbe] = (payload) => { + const ch = AGENT_CHANNELS.imageProbe; + const obj = asRecord(payload, ch); + return probeImage({ + absolutePath: optionalString(obj.absolutePath) ?? undefined, + base64: optionalString(obj.base64) ?? undefined, + }); + }; + + handlers[AGENT_CHANNELS.imageDataUrl] = (payload) => { + const ch = AGENT_CHANNELS.imageDataUrl; + const obj = asRecord(payload, ch); + return imageToJpegDataUrl({ + absolutePath: optionalString(obj.absolutePath) ?? undefined, + base64: optionalString(obj.base64) ?? undefined, + maxEdge: requireNumber(obj, 'maxEdge', ch), + quality: requireNumber(obj, 'quality', ch), + }); + }; + + handlers[AGENT_CHANNELS.imageCropBatch] = (payload) => { + const ch = AGENT_CHANNELS.imageCropBatch; + const obj = asRecord(payload, ch); + if (!Array.isArray(obj.boxes)) { + throw new RpcError(ch, '字段 boxes 必须是数组'); + } + const boxes: CropBox[] = obj.boxes.map((item, index) => { + const box = asRecord(item, ch); + if (!box) throw new RpcError(ch, `boxes[${index}] 必须是对象`); + return { + left: requireNumber(box, 'left', ch), + top: requireNumber(box, 'top', ch), + right: requireNumber(box, 'right', ch), + bottom: requireNumber(box, 'bottom', ch), + }; + }); + return cropImageBatch({ + absolutePath: optionalString(obj.absolutePath) ?? undefined, + base64: optionalString(obj.base64) ?? undefined, + boxes, + maxEdge: requireNumber(obj, 'maxEdge', ch), + quality: requireNumber(obj, 'quality', ch), + }); + }; + return handlers; } diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts index 3b51fd3..fc45a76 100644 --- a/src/main/agent/loop/assistLoop.test.ts +++ b/src/main/agent/loop/assistLoop.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from '@jest/globals'; import { LlmClient, type ToolSpec } from '../llm/client'; import { buildTools, toToolSpec } from '../tools/registry'; import { DEFAULT_AGENT_SETTINGS } from '../config'; +import { createStandaloneImageService } from '../services/imageService'; import { deriveTaskPhase } from './taskPhase'; import { FULL_TOOL_SET, LIGHT_TOOL_SET, resolveAssistToolSet } from './modeRouter'; import { streamAssist } from './assistLoop'; @@ -131,6 +132,8 @@ const baseParams = { userContent: '测试', taskPhaseContext: null, isCancelled: () => false, + // 独立模式图像服务:探测走文件头解析,编码不可用 + imageService: createStandaloneImageService(), }; describe('Assist 循环:纯文本', () => { diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 8a2afd5..2eec3ac 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -54,6 +54,7 @@ import { appendClientToolResultsToMessages, type ClientToolResultInput, } from '../context/contextService'; +import type { ImageService } from '../services/imageService'; import { checkCallAllowed, gatingEnabled, @@ -153,6 +154,8 @@ export interface AssistLoopParams { taskPhaseContext: TaskPhaseContext | null; isCancelled: () => boolean; signal?: AbortSignal; + /** 图像服务(探测纯本地;编码经 RPC 走主进程 nativeImage)。 */ + imageService: ImageService; } /** 单轮执行结果,用于驱动外层循环。 */ @@ -210,6 +213,7 @@ export async function* streamAssist( userContent: params.userContent, // 请求级提案缓存:让同轮对同一文件的多次 str_replace 累积到同一份提案 pendingProposals: new Map() as PendingProposalContents, + imageService: params.imageService, }; let budgetExhausted = true; diff --git a/src/main/agent/protocol.ts b/src/main/agent/protocol.ts index 0ae7212..3029bc1 100644 --- a/src/main/agent/protocol.ts +++ b/src/main/agent/protocol.ts @@ -83,6 +83,14 @@ export const AGENT_CHANNELS = { workspaceReadTextFile: 'workspace:readTextFile', workspaceDeleteTextFile: 'workspace:deleteTextFile', workspaceMoveTextFile: 'workspace:moveTextFile', + /** 读图片尺寸与格式(替代 Pillow 的 Image.open(...).size / .format)。 */ + imageProbe: 'image:probe', + /** 等比缩放并编码为 JPEG data URL(替代 Pillow 的 resize + JPEG 编码)。 */ + imageDataUrl: 'image:dataUrl', + /** 批量裁剪 + 缩放 + 编码(替代视觉映射的逐框裁剪)。 */ + imageCropBatch: 'image:cropBatch', + /** 读取项目内某文件的标注文档(.lr-agent/annotations/files/.json)。 */ + annotationReadDoc: 'annotation:readFileDoc', } as const; export type AgentChannel = (typeof AGENT_CHANNELS)[keyof typeof AGENT_CHANNELS]; diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index a5ed25f..b07dee8 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -19,6 +19,7 @@ import { createBridge, type Bridge, type MessageEndpoint } from './bridge'; import { startAgentServer, type AgentServer } from './server'; import { buildImplementedRoutes } from './handlers'; import { createDefaultDeps } from './handlers/deps'; +import { createImageService } from './services/imageService'; import { AGENT_CHANNELS, AGENT_DEBUG_ENV, @@ -113,7 +114,12 @@ async function main(): Promise { port: desiredPort, token, corsOrigins: DEFAULT_CORS_ORIGINS, - routes: buildImplementedRoutes(createDefaultDeps()), + routes: buildImplementedRoutes({ + ...createDefaultDeps(), + // utilityProcess 模式下注入经 RPC 的图像服务(具备 nativeImage 编码能力); + // 独立模式 bridge 为 null,退化为仅能探测尺寸/格式 + imageService: createImageService(bridge), + }), onLog: (level, message) => log(level, message), }); } catch (err) { diff --git a/src/main/agent/services/imageService.ts b/src/main/agent/services/imageService.ts new file mode 100644 index 0000000..93da987 --- /dev/null +++ b/src/main/agent/services/imageService.ts @@ -0,0 +1,186 @@ +/** + * 图像服务:把「纯可计算的探测」与「依赖 Electron 的编解码」分层。 + * + * 分层理由: + * - **尺寸/格式探测**用文件头解析即可,是纯函数——两种运行模式(utilityProcess + * 与独立 `node runtime.js`)都能用,且语义与 Pillow 的惰性 `Image.open().size` + * 更接近(读文件头、不解码、不应用 EXIF 旋转)。 + * - **缩放/裁剪/JPEG 编码**必须用 Electron 的 `nativeImage`(utilityProcess 内不可用), + * 因此经 RPC 走主进程;独立模式下不可用,返回明确的降级错误。 + * + * 这样在独立模式下依然能跑通 golden diff 的 `single-tool` 等场景, + * 而需要真正编码图片的场景(附图注入、逐框裁剪)只在 Electron 内生效。 + */ + +import fs from 'fs-extra'; +import type { Bridge } from '../bridge'; +import { AGENT_CHANNELS } from '../protocol'; +import { + fitWithinMaxEdge, + mimeFromSuffix, + parseImageHeader, + toDataUrl, +} from '../../agentImage/imageOps'; + +export interface ImageProbeParams { + absolutePath?: string; + base64?: string; +} + +export interface ImageProbeResult { + ok: boolean; + width?: number; + height?: number; + format?: string; + error?: string; +} + +export interface ImageDataUrlParams { + absolutePath?: string; + base64?: string; + maxEdge: number; + quality: number; +} + +export interface ImageDataUrlResult { + ok: boolean; + dataUrl?: string; + width?: number; + height?: number; + format?: string; + error?: string; +} + +export interface CropBox { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface CropBatchParams { + absolutePath?: string; + base64?: string; + boxes: CropBox[]; + maxEdge: number; + quality: number; +} + +export interface CropBatchResult { + ok: boolean; + images?: string[]; + error?: string; +} + +export interface ImageService { + probe(params: ImageProbeParams): Promise; + toJpegDataUrl(params: ImageDataUrlParams): Promise; + cropBatch(params: CropBatchParams): Promise; + /** 是否具备编码能力(即运行在 Electron 内)。 */ + readonly canEncode: boolean; +} + +/** 读取图片字节:绝对路径优先,其次 base64。 */ +function loadBytes(params: { + absolutePath?: string; + base64?: string; +}): { bytes: Buffer; error?: string } { + const abs = (params.absolutePath ?? '').trim(); + if (abs) { + try { + if (!fs.existsSync(abs)) { + return { bytes: Buffer.alloc(0), error: `文件不存在:${abs}` }; + } + return { bytes: fs.readFileSync(abs) }; + } catch (err) { + return { bytes: Buffer.alloc(0), error: `读取图片失败:${errMessage(err)}` }; + } + } + const raw = (params.base64 ?? '').trim(); + if (raw) { + const comma = raw.indexOf(','); + const payload = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; + return { bytes: Buffer.from(payload, 'base64') }; + } + return { bytes: Buffer.alloc(0), error: '未提供图片路径或 base64 数据。' }; +} + +/** 纯文件头探测(不解码)。 */ +export function probeByHeader(params: ImageProbeParams): ImageProbeResult { + const loaded = loadBytes(params); + if (loaded.error) return { ok: false, error: loaded.error }; + if (loaded.bytes.length === 0) return { ok: false, error: '图片数据为空。' }; + + const header = parseImageHeader(loaded.bytes); + if (!header) return { ok: false, error: '无法识别的图片格式。' }; + return { + ok: true, + width: header.width, + height: header.height, + format: header.format, + }; +} + +/** + * 构造图像服务。 + * + * @param bridge RPC 桥;独立模式下传 null(此时只有探测能力可用) + */ +export function createImageService(bridge: Bridge | null): ImageService { + const unavailable = (): { ok: false; error: string } => ({ + ok: false, + error: '图像编码需要在 Electron 环境中运行(当前为独立模式,仅支持读取尺寸与格式)。', + }); + + return { + canEncode: bridge !== null, + + async probe(params) { + // 先走纯文件头解析:快、确定、与 Pillow 语义一致 + const byHeader = probeByHeader(params); + if (byHeader.ok) return byHeader; + // 文件头无法识别时才让主进程用 nativeImage 兜底 + if (!bridge) return byHeader; + try { + return await bridge.call(AGENT_CHANNELS.imageProbe, params); + } catch { + return byHeader; + } + }, + + async toJpegDataUrl(params) { + if (!bridge) return unavailable(); + return bridge.call(AGENT_CHANNELS.imageDataUrl, params); + }, + + async cropBatch(params) { + if (!bridge) return unavailable(); + return bridge.call(AGENT_CHANNELS.imageCropBatch, params); + }, + }; +} + +/** 无桥(独立模式)的图像服务。 */ +export function createStandaloneImageService(): ImageService { + return createImageService(null); +} + +/** 供测试与工具复用:把本地缩放结果包装成 data URL(无编码场景)。 */ +export function wrapAsDataUrl( + suffix: string, + bytes: Buffer, + width: number, + height: number, +): { dataUrl: string; width: number; height: number } { + return { + dataUrl: toDataUrl(mimeFromSuffix(suffix), bytes), + width, + height, + }; +} + +export { fitWithinMaxEdge }; + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/annotationDoc.ts b/src/main/agent/tools/annotationDoc.ts new file mode 100644 index 0000000..51b1b3a --- /dev/null +++ b/src/main/agent/tools/annotationDoc.ts @@ -0,0 +1,84 @@ +/** + * `read_file_annotation`:读取项目内某文件的已有标注。 + * + * 移植自 `vendor/local-agent/app/agent/tools/registry.py` 的 `read_file_annotation` + * 与 `app/agent/annotation/annotation_doc_reader.py`。 + * + * 标注文档路径规则:`/.lr-agent/annotations/files/.json`。 + * 哈希逻辑与 Electron 侧完全一致——这里**直接 import** 主进程的 + * `annotationDataStore`(它只依赖 path/crypto/fs-extra,不依赖 Electron), + * 因此不存在重复实现导致 fileKey 漂移的风险。 + * + * 分页:默认返回前 200 条,上限 500;整体超过 40000 字符时截断并提示分页。 + */ + +import { readAnnotationDocRaw } from '../../annotation/annotationDataStore'; +import { normalizeRelativePath, projectDirectory, type ClientContextLike } from './workspacePath'; + +/** 结果总体字符上限(对齐 Python 的 40_000)。 */ +const MAX_RESULT_CHARS = 40_000; + +/** 单次返回条数上限(对齐 Python 的 500)。 */ +const MAX_LIMIT = 500; + +export async function readFileAnnotationTool( + clientContext: ClientContextLike | null, + relativePath: string, + options: { offset?: number; limit?: number } = {}, +): Promise { + const projectDir = projectDirectory(clientContext); + if (!projectDir) { + return '无法读取标注:未绑定项目目录。请确认已在标注任务中打开项目。'; + } + + const rel = normalizeRelativePath((relativePath ?? '').trim()); + if (!rel) { + // 与 Python 的 annotation_doc_reader 一致地提示(registry 层先做了 project 检查) + return '请提供相对路径(如 data/2.jpg)。'; + } + + const raw = await readAnnotationDocRaw(projectDir, rel); + if (raw === null) { + return `未找到 ${rel} 的标注文件(可能尚未标注)。`; + } + + let doc: unknown; + try { + doc = JSON.parse(raw); + } catch (err) { + return `读取标注失败:${errMessage(err)}`; + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + return '标注文件格式无效。'; + } + + const record = doc as Record; + let note = ''; + + const annotations = record.annotations; + if (Array.isArray(annotations)) { + const total = annotations.length; + const start = Math.max(0, Math.floor(options.offset ?? 0)); + const limit = Math.max(1, Math.min(Math.floor(options.limit ?? 200), MAX_LIMIT)); + const end = Math.min(start + limit, total); + if (start > 0 || end < total) { + record.annotations = annotations.slice(start, end); + note = + `(共 ${total} 条标注,当前返回第 ${start + 1}-${end} 条;` + + '可用 annotation_offset/annotation_limit 分页读取其余)\n'; + } + } + + // 带缩进:Python 的 indent=2 与 JSON.stringify(v, null, 2) 的分隔符行为一致 + let text = JSON.stringify(record, null, 2); + if (text.length > MAX_RESULT_CHARS) { + text = + text.slice(0, MAX_RESULT_CHARS) + + '\n…[结果已截断,请用 annotation_offset/annotation_limit 分页读取]'; + } + return note + text; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/documentReader.test.ts b/src/main/agent/tools/documentReader.test.ts new file mode 100644 index 0000000..7013813 --- /dev/null +++ b/src/main/agent/tools/documentReader.test.ts @@ -0,0 +1,146 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import zlib from 'node:zlib'; +import { extractPdfText, extractTextFromContentStream } from './documentReader'; + +/** 构造一个含单页、内容流可指定压缩方式的最小 PDF。 */ +function buildPdf(content: string, options: { compress?: boolean } = {}): Buffer { + const contentBytes = Buffer.from(content, 'latin1'); + const streamData = options.compress ? zlib.deflateSync(contentBytes) : contentBytes; + const filter = options.compress ? ' /Filter /FlateDecode' : ''; + + const parts: string[] = []; + parts.push('%PDF-1.4\n'); + + // 1: Catalog + parts.push('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n'); + // 2: Pages + parts.push('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n'); + // 3: Page + parts.push( + '3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n', + ); + // 4: Contents(二进制部分用 latin1 拼接以保字节) + const head = Buffer.from(`4 0 obj\n<< /Length ${streamData.length}${filter} >>\nstream\n`, 'latin1'); + const tail = Buffer.from('\nendstream\nendobj\n', 'latin1'); + parts.push(''); + const prefix = Buffer.from(parts.join(''), 'latin1'); + return Buffer.concat([prefix, head, streamData, tail]); +} + +describe('extractTextFromContentStream:文本算子', () => { + it('提取 Tj 的字符串', () => { + expect(extractTextFromContentStream(Buffer.from('BT (Hello) Tj ET'))).toBe( + 'Hello', + ); + }); + + it('多行 Tj 通过 Td 分隔', () => { + const content = 'BT (Line1) Tj Td (Line2) Tj ET'; + expect(extractTextFromContentStream(Buffer.from(content))).toBe( + 'Line1\nLine2', + ); + }); + + it('TJ 数组按顺序拼接字符串', () => { + const content = 'BT [(Hel) -100 (lo)] TJ ET'; + expect(extractTextFromContentStream(Buffer.from(content))).toBe('Hello'); + }); + + it('从 Td 切换行(换行提示)', () => { + const content = 'BT (A) Tj Td (B) Tj T* (C) Tj ET'; + expect(extractTextFromContentStream(Buffer.from(content))).toBe('A\nB\nC'); + }); + + it("' 算子换行", () => { + const content = "BT (A) Tj (B) ' ET"; + expect(extractTextFromContentStream(Buffer.from(content))).toBe('A\nB'); + }); + + it('解码字面量转义', () => { + const content = 'BT (a\\nb\\(c\\)d\\\\e) Tj ET'; + expect(extractTextFromContentStream(Buffer.from(content))).toBe( + 'a\nb(c)d\\e', + ); + }); + + it('解码八进制转义', () => { + // \101 = 'A' + expect( + extractTextFromContentStream(Buffer.from('BT (\\101\\102) Tj ET')), + ).toBe('AB'); + }); + + it('处理嵌套括号', () => { + expect( + extractTextFromContentStream(Buffer.from('BT (a(b)c) Tj ET')), + ).toBe('a(b)c'); + }); + + it('解码十六进制字符串', () => { + // 48656c6c6f = "Hello" + expect( + extractTextFromContentStream(Buffer.from('BT <48656c6c6f> Tj ET')), + ).toBe('Hello'); + }); + + it('十六进制字符串奇数长度时补齐', () => { + expect( + extractTextFromContentStream(Buffer.from('BT <48656c6c6f7> Tj ET')), + ).toBe('Hello\x70'); + }); + + it('FEFF 前缀按 UTF-16BE 解码', () => { + // FEFF 4F60 597D = "你好" + expect( + extractTextFromContentStream(Buffer.from('BT Tj ET')), + ).toBe('你好'); + }); + + it('忽略注释与数字', () => { + expect( + extractTextFromContentStream(Buffer.from('% comment\nBT (X) Tj ET')), + ).toBe('X'); + }); + + it('无文本时返回空串', () => { + expect(extractTextFromContentStream(Buffer.from('0 0 1 RG'))).toBe(''); + }); +}); + +describe('extractPdfText:页提取', () => { + it('提取未压缩内容流的文本', () => { + const pdf = buildPdf('BT (Plain text) Tj ET'); + const result = extractPdfText(pdf, 30); + expect(result.total).toBe(1); + expect(result.extracted).toBe(1); + expect(result.text).toContain('Plain text'); + }); + + it('提取 FlateDecode 压缩内容流的文本', () => { + const pdf = buildPdf('BT (Compressed text) Tj ET', { compress: true }); + const result = extractPdfText(pdf, 30); + expect(result.text).toContain('Compressed text'); + }); + + it('maxPages 限制提取页数', () => { + const pdf = buildPdf('BT (X) Tj ET'); + const result = extractPdfText(pdf, 0); + // 至少提取 1 页(对齐 Python 的 limit = min(total, max_pages) 与 Math.max(1, ...)) + expect(result.extracted).toBeGreaterThanOrEqual(1); + }); + + it('非 PDF 内容不抛异常', () => { + expect(() => extractPdfText(Buffer.from('not a pdf'), 10)).not.toThrow(); + const result = extractPdfText(Buffer.from('not a pdf'), 10); + expect(result.total).toBe(0); + expect(result.text).toBe(''); + }); + + it('空 buffer 不抛异常', () => { + const result = extractPdfText(Buffer.alloc(0), 10); + expect(result.total).toBe(0); + }); +}); diff --git a/src/main/agent/tools/documentReader.ts b/src/main/agent/tools/documentReader.ts new file mode 100644 index 0000000..b978cbe --- /dev/null +++ b/src/main/agent/tools/documentReader.ts @@ -0,0 +1,438 @@ +/** + * `read_document_file`:提取 PDF / DOCX 正文。 + * + * 输出格式对齐 `vendor/local-agent/app/agent/tools/workspace_file_reader.py`: + * + * ``` + * 文件: + * + * (正文已截断至 N 字符) ← 仅在截断时出现 + * --- + * <正文> + * ``` + * + * meta:PDF → `PDF 共 N 页,已提取前 M 页`;DOCX → `DOCX 段落数:N` + * + * **顺带修正一个既有缺陷**:Python 侧 `pypdf` / `python-docx` 被惰性 import 但 + * **未写入 `requirements.txt`**,因此在干净环境中该工具只会返回 + * `解析文档失败:No module named 'pypdf'`。Node 侧把依赖落实: + * - DOCX 用已在依赖中的 `mammoth` + * - PDF 用本文件的轻量提取器(Node 无现成 PDF 文本库,且不引入重型依赖) + * + * **PDF 提取器的覆盖范围与限制**(诚实说明,避免产出"看似正确实则错乱"的正文): + * - 支持:FlateDecode 压缩或未压缩的内容流;`Tj` / `TJ` / `'` / `"` 文本算子; + * 字面量字符串的转义(含八进制);十六进制字符串;UTF-16BE(`FEFF` 前缀)与 + * Latin-1 编码 + * - 不支持:需要 `ToUnicode` CMap 才能还原的自定义字体编码(此时按字节直译, + * 非 Latin 文本可能不准确)、扫描件(无文本层)、加密文档 + */ + +import fs from 'fs-extra'; +import path from 'path'; +import zlib from 'zlib'; +import { resolveWorkspaceFile, type ClientContextLike } from './workspacePath'; +import { DOCUMENT_SUFFIXES } from './fileReader'; + +/** + * mammoth 按需加载。 + * + * 它连同 jszip 等依赖约 6.5MB;若在模块顶层 import,运行时启动时就会求值这一整块。 + * 放进函数内 `require` 可推迟到首次解析 DOCX 时——DOCX 是低频能力,不值得占用启动路径。 + */ +type MammothModule = typeof import('mammoth'); +let mammothModule: MammothModule | null = null; + +function loadMammoth(): MammothModule { + if (!mammothModule) { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + mammothModule = require('mammoth') as MammothModule; + } + return mammothModule; +} + +export interface DocumentReadOptions { + maxPages: number; + maxChars: number; +} + +export async function readDocumentFile( + clientContext: ClientContextLike | null, + filePath: string, + options: DocumentReadOptions, +): Promise { + const { resolved, error } = resolveWorkspaceFile(clientContext, filePath); + if (resolved === null) return error; + + const fileName = path.basename(resolved); + const suffix = path.extname(resolved).toLowerCase(); + if (!DOCUMENT_SUFFIXES.has(suffix)) { + return `「${fileName}」不是支持的文档格式(pdf、docx)。`; + } + + let text: string; + let meta: string; + try { + if (suffix === '.pdf') { + const extracted = extractPdfText(fs.readFileSync(resolved), options.maxPages); + text = extracted.text; + meta = `PDF 共 ${extracted.total} 页,已提取前 ${extracted.extracted} 页`; + } else { + const extracted = await extractDocxText(resolved); + text = extracted.text; + meta = `DOCX 段落数:${extracted.paragraphs}`; + } + } catch (err) { + return `解析文档失败:${errMessage(err)}`; + } + + if (!text.trim()) { + return `「${fileName}」未能提取到文本(${meta})。`; + } + + let truncated = false; + if (text.length > options.maxChars) { + text = text.slice(0, options.maxChars); + truncated = true; + } + + let header = `文件:${fileName}\n${meta}\n`; + if (truncated) header += `(正文已截断至 ${options.maxChars} 字符)\n`; + header += '---\n'; + return header + text; +} + +/** DOCX 提取(mammoth 的 extractRawText 返回按段落分行的纯文本)。 */ +async function extractDocxText( + resolved: string, +): Promise<{ text: string; paragraphs: number }> { + const buffer = fs.readFileSync(resolved); + const result = await loadMammoth().extractRawText({ buffer }); + const paragraphs = result.value + .split(/\r\n|\r|\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return { text: paragraphs.join('\n\n'), paragraphs: paragraphs.length }; +} + +// ── PDF 文本提取 ──────────────────────────────────────────────────── + +interface PdfExtractResult { + text: string; + total: number; + extracted: number; +} + +/** + * 轻量 PDF 文本提取。 + * + * 策略:扫描 `N 0 obj ... endobj` 取出对象表 → 找出页对象(`/Type /Page`)与其 + * `/Contents` 引用 → 解压内容流 → 按文本算子抽取文字。页顺序取对象在文件中出现的 + * 顺序,对绝大多数文档与页树顺序一致。 + */ +export function extractPdfText(bytes: Buffer, maxPages: number): PdfExtractResult { + // 用 latin1 解析结构:字节保真,且不会因二进制内容破坏索引 + const raw = bytes.toString('latin1'); + + const objects = collectObjects(raw); + const pageContents = collectPageContentRefs(objects); + + const total = pageContents.length; + const limit = Math.min(total, Math.max(1, maxPages)); + + const parts: string[] = []; + for (let i = 0; i < limit; i += 1) { + const chunks = pageContents[i] + .map((objNum) => objects.get(objNum)) + .filter((obj): obj is PdfObject => obj !== undefined) + .map((obj) => obj.stream ?? Buffer.alloc(0)); + parts.push(chunks.map(extractTextFromContentStream).join('')); + } + + return { text: parts.join('\n\n'), total, extracted: limit }; +} + +interface PdfObject { + /** 对象的原始文本(不含 `endobj`)。 */ + body: string; + /** 已解码的流内容(若有);解码失败时为原始字节。 */ + stream: Buffer | null; +} + +/** 扫描所有 `N 0 obj` 定义。 */ +function collectObjects(raw: string): Map { + const objects = new Map(); + const re = /(\d+)\s+(\d+)\s+obj\b/g; + let match: RegExpExecArray | null; + + while ((match = re.exec(raw)) !== null) { + const objNum = Number(match[1]); + const start = match.index + match[0].length; + const endObj = raw.indexOf('endobj', start); + const end = endObj === -1 ? raw.length : endObj; + const body = raw.slice(start, end); + + let stream: Buffer | null = null; + const streamIdx = body.indexOf('stream'); + if (streamIdx !== -1) { + // stream 关键字后需跟 CRLF 或 LF + let dataStart = streamIdx + 'stream'.length; + if (body[dataStart] === '\r') dataStart += 1; + if (body[dataStart] === '\n') dataStart += 1; + const endStream = body.lastIndexOf('endstream'); + const dataEnd = endStream > dataStart ? endStream : body.length; + const rawStream = Buffer.from(body.slice(dataStart, dataEnd), 'latin1'); + + const dict = body.slice(0, streamIdx); + stream = inflateIfNeeded(dict, rawStream); + } + + objects.set(objNum, { body, stream }); + } + + return objects; +} + +/** 按字典中的 `/Filter` 决定是否解压。 */ +function inflateIfNeeded(dict: string, data: Buffer): Buffer { + const isFlate = + /\/Filter\s*\/FlateDecode/.test(dict) || + /\/Filter\s*\[\s*\/FlateDecode/.test(dict); + if (!isFlate) return data; + try { + return zlib.inflateSync(data); + } catch { + try { + // 部分文件缺少 zlib 头,尝试 raw deflate + return zlib.inflateRawSync(data); + } catch { + return data; + } + } +} + +/** + * 收集每页的 `/Contents` 对象号。 + * + * 只认 `/Type /Page`(排除 `/Pages`),并解析 `/Contents N 0 R` 或 + * `/Contents [N 0 R M 0 R ...]` 两种形式。 + */ +function collectPageContentRefs(objects: Map): number[][] { + const pages: Array<{ objNum: number; refs: number[] }> = []; + + for (const [objNum, obj] of objects) { + if (!/\/Type\s*\/Page\b/.test(obj.body)) continue; + if (/\/Type\s*\/Pages\b/.test(obj.body)) continue; + + const m = /\/Contents\s*(\[[^\]]*\]|\d+\s+\d+\s+R)/.exec(obj.body); + if (!m) { + pages.push({ objNum, refs: [] }); + continue; + } + const refs = [...m[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => Number(r[1])); + pages.push({ objNum, refs }); + } + + // 页顺序:优先按对象号升序(与页树顺序一致度最高) + pages.sort((a, b) => a.objNum - b.objNum); + return pages.map((page) => page.refs); +} + +/** + * 从内容流中抽取文本。 + * + * 按 `BT`/`ET` 分块,处理 `Tj`、`TJ`、`'`、`"` 算子,以及 `Td`/`TD`/`T*` 换行提示。 + */ +export function extractTextFromContentStream(content: Buffer): string { + const text = content.toString('latin1'); + const out: string[] = []; + let i = 0; + + // 简单的词法扫描:识别字符串字面量、十六进制串、数组与算子 + const stack: string[] = []; + let pending: string[] = []; + + const flushLine = (): void => { + if (pending.length) { + out.push(pending.join('')); + pending = []; + } + }; + + while (i < text.length) { + const ch = text[i]; + + if (ch === '%') { + // 注释到行尾 + const nl = text.indexOf('\n', i); + i = nl === -1 ? text.length : nl + 1; + continue; + } + + if (ch === '(') { + const { value, next } = readLiteralString(text, i); + stack.push(value); + i = next; + continue; + } + + if (ch === '<' && text[i + 1] !== '<') { + const { value, next } = readHexString(text, i); + stack.push(value); + i = next; + continue; + } + + if (ch === '[' || ch === ']') { + // 数组边界:不作为内容,但保留已压入的字符串 + i += 1; + continue; + } + + if (/[A-Za-z'"*]/.test(ch)) { + // 读一个算子 + let j = i; + while (j < text.length && /[A-Za-z0-9'"*]/.test(text[j])) j += 1; + const op = text.slice(i, j); + i = j; + + if (op === 'Tj') { + const value = stack.pop(); + if (value != null) pending.push(value); + } else if (op === "'" || op === '"') { + // `'` / `"` 的语义是「移到下一行并显示文本」:先结束上一行,再放新内容 + flushLine(); + const value = stack.pop(); + if (value != null) pending.push(value); + } else if (op === 'TJ') { + // 数组内的字符串按顺序拼接(数值是字距调整,忽略) + pending.push(...stack); + stack.length = 0; + } else if (op === 'Td' || op === 'TD' || op === 'T*') { + flushLine(); + } else if (op === 'ET') { + flushLine(); + } + continue; + } + + i += 1; + } + flushLine(); + + return out.join('\n'); +} + +/** 读取 PDF 字面量字符串 `(...)`,处理转义与嵌套括号。 */ +function readLiteralString( + text: string, + start: number, +): { value: string; next: number } { + const bytes: number[] = []; + let depth = 1; + let i = start + 1; + + while (i < text.length && depth > 0) { + const ch = text[i]; + if (ch === '\\') { + const nxt = text[i + 1]; + switch (nxt) { + case 'n': + bytes.push(0x0a); + i += 2; + break; + case 'r': + bytes.push(0x0d); + i += 2; + break; + case 't': + bytes.push(0x09); + i += 2; + break; + case 'b': + bytes.push(0x08); + i += 2; + break; + case 'f': + bytes.push(0x0c); + i += 2; + break; + case '(': + case ')': + case '\\': + bytes.push(nxt.charCodeAt(0)); + i += 2; + break; + default: + if (nxt >= '0' && nxt <= '7') { + // 最多三位八进制 + let oct = ''; + let k = i + 1; + while (k < text.length && oct.length < 3 && text[k] >= '0' && text[k] <= '7') { + oct += text[k]; + k += 1; + } + bytes.push(parseInt(oct, 8) & 0xff); + i = k; + } else { + bytes.push(nxt.charCodeAt(0)); + i += 2; + } + break; + } + continue; + } + if (ch === '(') depth += 1; + if (ch === ')') { + depth -= 1; + if (depth === 0) { + i += 1; + break; + } + } + bytes.push(text.charCodeAt(i) & 0xff); + i += 1; + } + + return { value: decodePdfBytes(Buffer.from(bytes)), next: i }; +} + +/** 读取十六进制字符串 `<...>`。 */ +function readHexString(text: string, start: number): { value: string; next: number } { + const end = text.indexOf('>', start + 1); + const hex = (end === -1 ? text.slice(start + 1) : text.slice(start + 1, end)).replace( + /[^0-9a-fA-F]/g, + '', + ); + const padded = hex.length % 2 === 0 ? hex : `${hex}0`; + const bytes = Buffer.from(padded, 'hex'); + return { + value: decodePdfBytes(bytes), + next: end === -1 ? text.length : end + 1, + }; +} + +/** + * 解码 PDF 字符串字节。 + * + * `FEFF` 前缀视为 UTF-16BE;否则按 Latin-1(即字节直译)处理。 + * 需要 `ToUnicode` CMap 的自定义编码不在此覆盖范围内。 + */ +function decodePdfBytes(bytes: Buffer): string { + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { + return decodeUtf16Be(bytes.subarray(2)); + } + return bytes.toString('latin1'); +} + +/** UTF-16BE → JS 字符串(按码元成对解析)。 */ +function decodeUtf16Be(bytes: Buffer): string { + let out = ''; + for (let i = 0; i + 1 < bytes.length; i += 2) { + out += String.fromCharCode((bytes[i] << 8) | bytes[i + 1]); + } + return out; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index 4a68c26..0882675 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -34,6 +34,10 @@ import { listWorkspaceDirectory, } from './fileSearch'; import { readWorkspaceTextFile } from './fileReader'; +import { readFileAnnotationTool } from './annotationDoc'; +import { readDocumentFile } from './documentReader'; +import { readImageForVisionTool } from './vision'; +import type { ImageService } from '../services/imageService'; import { formatRuntimeIdentityBlock, formatSnapshotForPrompt, @@ -58,6 +62,8 @@ export interface ToolContext { * (Python 侧在每次请求重建工具集时创建闭包缓存),因此由调用方注入而非模块级单例。 */ pendingProposals: PendingProposalContents; + /** 图像服务(探测纯本地;编码经 RPC 走主进程的 nativeImage)。 */ + imageService: ImageService; } export interface ToolDefinition { @@ -379,7 +385,15 @@ export function buildTools(): ToolDefinition[] { '只读查询,不要用写文件工具回写。', kind: 'sync', argsSchema: ReadFileAnnotationArgs, - // 阶段 4 实现(需要 .lr-agent 标注文档读写) + execute: (args, ctx) => + readFileAnnotationTool( + ctx.clientContext, + String(args.relative_path ?? ''), + { + offset: asInt(args.annotation_offset, 0), + limit: asInt(args.annotation_limit, 200), + }, + ), }, { name: 'read_workspace_file', @@ -453,7 +467,11 @@ export function buildTools(): ToolDefinition[] { '需视觉探针通过;查已有标注请用 read_file_annotation,勿用本工具代替。', kind: 'sync', argsSchema: ReadImageArgs, - // 阶段 4 实现(需要主进程 nativeImage 读尺寸 + 附图注入) + execute: (args, ctx) => + readImageForVisionTool(ctx.clientContext, String(args.relative_path ?? ''), { + providerIsVision: ctx.providerIsVision, + imageService: ctx.imageService, + }), }, { name: 'read_document_file', @@ -461,7 +479,11 @@ export function buildTools(): ToolDefinition[] { '提取 PDF 或 DOCX 文档正文。relative_path 为空时使用当前打开的文件。', kind: 'sync', argsSchema: ReadDocumentArgs, - // 阶段 4 实现(需要 PDF/DOCX 解析) + execute: (args, ctx) => + readDocumentFile(ctx.clientContext, String(args.relative_path ?? ''), { + maxPages: ctx.settings.readDocumentMaxPages, + maxChars: ctx.settings.readFileMaxBytes, + }), }, { name: 'write_workspace_file', @@ -581,3 +603,13 @@ export function buildToolSpecs( export function toolFailure(tool: string, status: string, summary: string): string { return buildToolResult({ ok: false, tool, status, summary }); } + +/** 宽松的整数取值:非数字时回退默认值(模型可能传字符串或省略)。 */ +function asInt(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === 'string') { + const parsed = Number(value.trim()); + if (Number.isFinite(parsed)) return Math.trunc(parsed); + } + return fallback; +} diff --git a/src/main/agent/tools/result.ts b/src/main/agent/tools/result.ts index bdafd05..f8d66af 100644 --- a/src/main/agent/tools/result.ts +++ b/src/main/agent/tools/result.ts @@ -12,6 +12,7 @@ * 不经 `buildToolResult` 包装;此时 `formatToolResultForDisplay` 原样返回。 */ +import fs from 'fs-extra'; import { pythonJsonDumps } from '../json'; /** 生成统一工具结果 JSON。`file_written` / `proposal_pending` 默认 false。 */ @@ -78,18 +79,29 @@ export const DOC_PROPOSAL_MARKER = '__doc_proposal__'; * 剥离内部标记字段,得到可发给前端的显示文本。 * * 仅 `read_image_for_vision` 用到 `VISION_PATH_MARKER`;其余工具无标记,原样返回。 + * 序列化用 `indent=2`——与 Python 的 `json.dumps(display, ensure_ascii=False, indent=2)` 一致。 */ export function stripInternalMarkers(resultText: string): string { const parsed = parseToolResult(resultText); if (!parsed) return resultText; if (!(VISION_PATH_MARKER in parsed)) return resultText; const { [VISION_PATH_MARKER]: _removed, ...rest } = parsed; - return JSON.stringify(rest); + return JSON.stringify(rest, null, 2); } -/** 从视觉工具结果中提取图片绝对路径;无标记时返回空串。 */ +/** + * 从视觉工具结果中提取图片绝对路径;无标记或文件不存在时返回空串。 + * + * 与 Python 一致地校验 `is_file()`——避免把已删除的路径注入成附图。 + */ export function extractVisionPath(resultText: string): string { const parsed = parseToolResult(resultText); - const value = parsed?.[VISION_PATH_MARKER]; - return typeof value === 'string' ? value : ''; + if (!parsed || parsed.ok !== true) return ''; + const value = parsed[VISION_PATH_MARKER]; + if (typeof value !== 'string' || !value.trim()) return ''; + try { + return fs.statSync(value).isFile() ? value : ''; + } catch { + return ''; + } } diff --git a/src/main/agent/tools/vision.ts b/src/main/agent/tools/vision.ts new file mode 100644 index 0000000..6d31d6f --- /dev/null +++ b/src/main/agent/tools/vision.ts @@ -0,0 +1,76 @@ +/** + * `read_image_for_vision`:加载图片元信息并标记待注入附图。 + * + * 移植自 `vendor/local-agent/app/agent/tools/workspace_file_reader.py` 的 + * `read_image_for_vision_tool`。 + * + * 结果为 JSON,其中含**内部标记** `__vision_image_path__`:调度层据此提取图片绝对路径, + * 剥离标记后把结果发给前端,并在对应 `ToolMessage` 之后追加一条多模态 `HumanMessage` + * (附图注入,阶段 5 接入)。 + * + * 与 Python 的差异:Python 用 Pillow 读尺寸,这里用文件头解析(纯函数、可在独立模式 + * 运行),文件头无法识别时才经 RPC 让主进程用 `nativeImage` 兜底。 + */ + +import path from 'path'; +import { buildToolResult, VISION_PATH_MARKER } from './result'; +import { + resolveWorkspaceFile, + type ClientContextLike, +} from './workspacePath'; +import { IMAGE_SUFFIXES } from './fileReader'; +import type { ImageService } from '../services/imageService'; + +/** 与 Python 一致的格式清单(用于错误文案)。 */ +const SUFFIX_LIST = [...IMAGE_SUFFIXES].sort().join(', '); + +export async function readImageForVisionTool( + clientContext: ClientContextLike | null, + relativePath: string, + options: { providerIsVision: boolean; imageService: ImageService }, +): Promise { + if (!options.providerIsVision) { + return ( + '当前大模型未通过视觉能力探针,无法分析图片内容。' + + '请在「大模型配置」中选用支持视觉的模型并重探视觉能力。' + ); + } + + const { resolved, error } = resolveWorkspaceFile(clientContext, relativePath); + if (resolved === null) return error; + + const fileName = path.basename(resolved); + const suffix = path.extname(resolved).toLowerCase(); + if (!IMAGE_SUFFIXES.has(suffix)) { + return `「${fileName}」不是支持的图片格式(${SUFFIX_LIST})。`; + } + + const probed = await options.imageService.probe({ absolutePath: resolved }); + if (!probed.ok || probed.width == null || probed.height == null) { + return `无法打开图片:${probed.error ?? '未知错误'}`; + } + + const format = probed.format ?? suffix.replace('.', '').toUpperCase(); + return JSON.stringify({ + ok: true, + path: resolved, + name: fileName, + width: probed.width, + height: probed.height, + format, + [VISION_PATH_MARKER]: resolved, + message: + `已加载图片 ${fileName}(${probed.width}x${probed.height} ${format})。` + + '系统将在本条工具结果后注入附图,请根据图像回答用户问题。', + }); +} + +/** 供调度层复用的失败结果构造。 */ +export function visionFailure(summary: string): string { + return buildToolResult({ + ok: false, + tool: 'read_image_for_vision', + status: 'error', + summary, + }); +} diff --git a/src/main/agentImage/handlers.ts b/src/main/agentImage/handlers.ts new file mode 100644 index 0000000..cf449df --- /dev/null +++ b/src/main/agentImage/handlers.ts @@ -0,0 +1,336 @@ +/** + * 主进程侧的图像服务(替代 Python 的 Pillow)。 + * + * 用 Electron 内置的 `nativeImage` 实现,**零新增依赖**——这正是选择它而非 `sharp` + * 的原因(native 模块会带来 electron-rebuild 与三平台打包矩阵的负担)。 + * + * 覆盖 Pillow 在本地 Agent 侧的全部用途: + * - 读尺寸/格式(`read_image_for_vision`) + * - 等比缩放 + JPEG 编码(附图注入) + * - 逐框裁剪 + 缩放 + 编码(视觉映射,阶段 7) + * + * 两个必须对齐的 Pillow 语义: + * + * 1. **尺寸取文件头而非解码结果**。Pillow 的 `Image.open(...).size` 是惰性的, + * 只读文件头;且不应用 EXIF 旋转。本模块同样以文件头解析为主, + * `nativeImage` 只在文件头无法识别时兜底。 + * + * 2. **RGBA 需合成到白底再编码 JPEG**。Pillow 侧对 RGBA/LA/P 显式铺白底; + * Chromium 的 JPEG 编码器对 alpha 的处理不同(会得到黑底或丢弃 alpha), + * 因此这里手动做白底合成,保证透明 PNG 转出的 JPEG 与 Python 一致。 + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { nativeImage, type NativeImage } from 'electron'; +import { + fitWithinMaxEdge, + mimeFromSuffix, + parseImageHeader, + toDataUrl, + type ImageHeaderInfo, +} from './imageOps'; + +export interface ImageProbeResult { + ok: boolean; + width?: number; + height?: number; + format?: string; + /** 是否由文件头解析得到(false 表示走了 nativeImage 兜底)。 */ + fromHeader?: boolean; + error?: string; +} + +export interface ImageDataUrlResult { + ok: boolean; + dataUrl?: string; + width?: number; + height?: number; + format?: string; + error?: string; +} + +export interface CropBox { + /** 左上角与右下角(左闭右开语义由调用方决定;这里按宽高换算)。 */ + left: number; + top: number; + right: number; + bottom: number; +} + +export interface CropBatchResult { + ok: boolean; + images?: string[]; + error?: string; +} + +/** 加载图片字节:路径优先,其次 base64。 */ +function loadImageBytes(params: { + absolutePath?: string; + base64?: string; +}): { bytes: Buffer; error?: string } { + const abs = (params.absolutePath ?? '').trim(); + if (abs) { + try { + if (!fs.existsSync(abs)) { + return { bytes: Buffer.alloc(0), error: `文件不存在:${abs}` }; + } + return { bytes: fs.readFileSync(abs) }; + } catch (err) { + return { bytes: Buffer.alloc(0), error: `读取图片失败:${errMessage(err)}` }; + } + } + const raw = (params.base64 ?? '').trim(); + if (raw) { + // 剥离 data URL 前缀 + const comma = raw.indexOf(','); + const payload = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; + try { + return { bytes: Buffer.from(payload, 'base64') }; + } catch (err) { + return { bytes: Buffer.alloc(0), error: `解码 base64 失败:${errMessage(err)}` }; + } + } + return { bytes: Buffer.alloc(0), error: '未提供图片路径或 base64 数据。' }; +} + +/** + * 读图片尺寸与格式。 + * + * 主路径是文件头解析(语义对齐 Pillow 的惰性读取),识别失败时退回 `nativeImage` 解码。 + */ +export function probeImage(params: { + absolutePath?: string; + base64?: string; +}): ImageProbeResult { + const loaded = loadImageBytes(params); + if (loaded.error) return { ok: false, error: loaded.error }; + const bytes = loaded.bytes; + if (bytes.length === 0) return { ok: false, error: '图片数据为空。' }; + + const header: ImageHeaderInfo | null = parseImageHeader(bytes); + if (header) { + return { + ok: true, + width: header.width, + height: header.height, + format: header.format, + fromHeader: true, + }; + } + + // 兜底:交给 nativeImage 解码 + const image = nativeImage.createFromBuffer(bytes); + if (image.isEmpty()) { + return { ok: false, error: '无法识别的图片格式。' }; + } + const { width, height } = image.getSize(); + const suffix = path.extname((params.absolutePath ?? '').trim()).toLowerCase(); + return { + ok: true, + width, + height, + // 与 Pillow 的 img.format 对齐:无后缀信息时以 MIME 反推一个合理值 + format: formatFromMime(mimeFromSuffix(suffix)), + fromHeader: false, + }; +} + +function formatFromMime(mime: string): string { + switch (mime) { + case 'image/png': + return 'PNG'; + case 'image/jpeg': + return 'JPEG'; + case 'image/gif': + return 'GIF'; + case 'image/webp': + return 'WEBP'; + case 'image/bmp': + return 'BMP'; + case 'image/x-icon': + return 'ICO'; + default: + return 'UNKNOWN'; + } +} + +/** + * 把 `NativeImage` 的像素合成到白底。 + * + * `toBitmap()` 返回 BGRA 布局。仅当存在非全不透明像素时才需要合成; + * 这里统一处理,代价是一次内存拷贝。 + */ +function flattenOntoWhite(image: NativeImage): NativeImage { + const { width, height } = image.getSize(); + if (width <= 0 || height <= 0) return image; + + const bitmap = image.toBitmap(); + if (bitmap.length < width * height * 4) return image; + + const out = Buffer.alloc(width * height * 4); + let hasAlpha = false; + for (let i = 0; i < width * height; i += 1) { + const offset = i * 4; + const b = bitmap[offset]; + const g = bitmap[offset + 1]; + const r = bitmap[offset + 2]; + const a = bitmap[offset + 3]; + if (a === 255) { + out[offset] = b; + out[offset + 1] = g; + out[offset + 2] = r; + out[offset + 3] = 255; + continue; + } + hasAlpha = true; + // 白底合成:c' = c * a + 255 * (1 - a) + const alpha = a / 255; + out[offset] = Math.round(b * alpha + 255 * (1 - alpha)); + out[offset + 1] = Math.round(g * alpha + 255 * (1 - alpha)); + out[offset + 2] = Math.round(r * alpha + 255 * (1 - alpha)); + out[offset + 3] = 255; + } + + if (!hasAlpha) return image; + return nativeImage.createFromBitmap(out, { width, height }); +} + +/** + * 等比缩放并编码为 JPEG data URL。 + * + * 只缩小不放大(对齐 Pillow 的 `scale = min(1, maxEdge / max(w, h))`)。 + */ +export function imageToJpegDataUrl(params: { + absolutePath?: string; + base64?: string; + maxEdge: number; + quality: number; +}): ImageDataUrlResult { + const loaded = loadImageBytes(params); + if (loaded.error) return { ok: false, error: loaded.error }; + + let image = nativeImage.createFromBuffer(loaded.bytes); + if (image.isEmpty()) { + // nativeImage 无法解码(如 GIF/WebP 变体):退化为原字节直传 + const header = parseImageHeader(loaded.bytes); + const suffix = path.extname((params.absolutePath ?? '').trim()).toLowerCase(); + const mime = mimeFromSuffix(suffix) !== 'application/octet-stream' + ? mimeFromSuffix(suffix) + : formatToMime(header?.format); + return { + ok: true, + dataUrl: toDataUrl(mime, loaded.bytes), + width: header?.width, + height: header?.height, + format: header?.format, + }; + } + + const { width, height } = image.getSize(); + const target = fitWithinMaxEdge(width, height, params.maxEdge); + if (target.scaled) { + image = image.resize({ + width: target.width, + height: target.height, + quality: 'best', + }); + } + + image = flattenOntoWhite(image); + const jpeg = image.toJPEG(clampQuality(params.quality)); + return { + ok: true, + dataUrl: toDataUrl('image/jpeg', jpeg), + width: image.getSize().width, + height: image.getSize().height, + format: 'JPEG', + }; +} + +function formatToMime(format: string | undefined): string { + switch (format) { + case 'PNG': + return 'image/png'; + case 'JPEG': + return 'image/jpeg'; + case 'GIF': + return 'image/gif'; + case 'WEBP': + return 'image/webp'; + case 'BMP': + return 'image/bmp'; + case 'ICO': + return 'image/x-icon'; + default: + return 'application/octet-stream'; + } +} + +function clampQuality(quality: number): number { + if (!Number.isFinite(quality)) return 85; + return Math.max(0, Math.min(100, Math.round(quality))); +} + +/** + * 批量裁剪 + 等比缩放 + JPEG 编码。 + * + * 对应 Python `map_labels_service._crop_boxes_from_image_bytes`:对每个检测框裁剪、 + * 缩放到 `maxEdge`、编码为 JPEG data URL。批量执行以避免逐框 IPC 往返。 + * + * 坐标按 Pillow 的 `(left, top, right, bottom)` 语义,越界部分自动裁剪到图片范围内; + * 完全在图片外的框产出空串(调用方据此判断)。 + */ +export function cropImageBatch(params: { + absolutePath?: string; + base64?: string; + boxes: CropBox[]; + maxEdge: number; + quality: number; +}): CropBatchResult { + const loaded = loadImageBytes(params); + if (loaded.error) return { ok: false, error: loaded.error }; + + const image = nativeImage.createFromBuffer(loaded.bytes); + if (image.isEmpty()) { + return { ok: false, error: '无法解码图片,无法裁剪。' }; + } + const { width, height } = image.getSize(); + + const images: string[] = []; + for (const box of params.boxes) { + const left = Math.max(0, Math.min(width, Math.floor(box.left))); + const top = Math.max(0, Math.min(height, Math.floor(box.top))); + const right = Math.max(left, Math.min(width, Math.ceil(box.right))); + const bottom = Math.max(top, Math.min(height, Math.ceil(box.bottom))); + const cropWidth = right - left; + const cropHeight = bottom - top; + if (cropWidth <= 0 || cropHeight <= 0) { + images.push(''); + continue; + } + + let cropped = image.crop({ + x: left, + y: top, + width: cropWidth, + height: cropHeight, + }); + const target = fitWithinMaxEdge(cropWidth, cropHeight, params.maxEdge); + if (target.scaled) { + cropped = cropped.resize({ + width: target.width, + height: target.height, + quality: 'best', + }); + } + cropped = flattenOntoWhite(cropped); + images.push(toDataUrl('image/jpeg', cropped.toJPEG(clampQuality(params.quality)))); + } + + return { ok: true, images }; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agentImage/imageOps.test.ts b/src/main/agentImage/imageOps.test.ts new file mode 100644 index 0000000..f48e6f4 --- /dev/null +++ b/src/main/agentImage/imageOps.test.ts @@ -0,0 +1,233 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import zlib from 'node:zlib'; +import { + fitWithinMaxEdge, + mimeFromSuffix, + parseImageHeader, + toDataUrl, +} from './imageOps'; + +/** 构造最小 PNG 头(签名 + IHDR)。 */ +function pngBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(33); + buf.writeUInt32BE(0x89504e47, 0); + buf.writeUInt32BE(0x0d0a1a0a, 4); + buf.writeUInt32BE(13, 8); + buf.write('IHDR', 12, 'ascii'); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +} + +/** 构造最小 JPEG(SOI + SOF0 + EOI)。 */ +function jpegBytes(width: number, height: number): Buffer { + return Buffer.from([ + 0xff, 0xd8, // SOI + 0xff, 0xc0, 0x00, 0x11, 0x08, + (height >> 8) & 0xff, height & 0xff, + (width >> 8) & 0xff, width & 0xff, + 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, + 0xff, 0xd9, // EOI + ]); +} + +/** 构造最小 GIF 头。 */ +function gifBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(13); + buf.write('GIF89a', 0, 'ascii'); + buf.writeUInt16LE(width, 6); + buf.writeUInt16LE(height, 8); + return buf; +} + +/** 构造 VP8X 变体的 WebP 头。 */ +function webpBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(40); + buf.write('RIFF', 0, 'ascii'); + buf.writeUInt32LE(32, 4); + buf.write('WEBP', 8, 'ascii'); + buf.write('VP8X', 12, 'ascii'); + buf.writeUInt32LE(10, 16); + // 24 位宽高(减一存储) + buf[24] = (width - 1) & 0xff; + buf[25] = ((width - 1) >> 8) & 0xff; + buf[26] = ((width - 1) >> 16) & 0xff; + buf[27] = (height - 1) & 0xff; + buf[28] = ((height - 1) >> 8) & 0xff; + buf[29] = ((height - 1) >> 16) & 0xff; + return buf; +} + +/** 构造 BMP 头。 */ +function bmpBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(30); + buf.write('BM', 0, 'ascii'); + buf.writeInt32LE(width, 18); + buf.writeInt32LE(height, 22); + return buf; +} + +/** 构造 ICO 头。 */ +function icoBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(22); + buf.writeUInt16LE(0, 0); + buf.writeUInt16LE(1, 2); + buf.writeUInt16LE(1, 4); + buf[6] = width === 256 ? 0 : width; + buf[7] = height === 256 ? 0 : height; + return buf; +} + +describe('parseImageHeader:文件头解析', () => { + it('PNG 读出宽高与格式', () => { + expect(parseImageHeader(pngBytes(800, 600))).toEqual({ + width: 800, + height: 600, + format: 'PNG', + }); + }); + + it('JPEG 读出宽高与格式', () => { + expect(parseImageHeader(jpegBytes(1024, 768))).toEqual({ + width: 1024, + height: 768, + format: 'JPEG', + }); + }); + + it('JPEG 跳过非 SOF 段(DHT/DAC)', () => { + // SOI + DHT(0xC4,无帧信息)+ SOF0 + EOI + const buf = Buffer.from([ + 0xff, 0xd8, + 0xff, 0xc4, 0x00, 0x04, 0x00, 0x00, + 0xff, 0xc0, 0x00, 0x11, 0x08, 0x01, 0x2c, 0x01, 0x90, + 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, + 0xff, 0xd9, + ]); + const info = parseImageHeader(buf); + expect(info).toEqual({ width: 400, height: 300, format: 'JPEG' }); + }); + + it('GIF 读出宽高与格式', () => { + expect(parseImageHeader(gifBytes(320, 240))).toEqual({ + width: 320, + height: 240, + format: 'GIF', + }); + }); + + it('WebP(VP8X)读出宽高与格式', () => { + expect(parseImageHeader(webpBytes(1000, 500))).toEqual({ + width: 1000, + height: 500, + format: 'WEBP', + }); + }); + + it('BMP 读出宽高(高度取绝对值)', () => { + expect(parseImageHeader(bmpBytes(64, 48))).toEqual({ + width: 64, + height: 48, + format: 'BMP', + }); + // 负高度表示自上而下存储 + const topDown = bmpBytes(64, -48); + expect(parseImageHeader(topDown)?.height).toBe(48); + }); + + it('ICO 读出宽高与格式', () => { + expect(parseImageHeader(icoBytes(32, 32))).toEqual({ + width: 32, + height: 32, + format: 'ICO', + }); + }); + + it('ICO 的 0 表示 256', () => { + expect(parseImageHeader(icoBytes(256, 256))?.width).toBe(256); + }); + + it('无法识别时返回 null', () => { + expect(parseImageHeader(Buffer.from('not an image at all'))).toBeNull(); + }); + + it('空 buffer 返回 null 而不抛异常', () => { + expect(parseImageHeader(Buffer.alloc(0))).toBeNull(); + }); + + it('截断的 PNG 返回 null 而不抛异常', () => { + expect(parseImageHeader(pngBytes(10, 10).subarray(0, 10))).toBeNull(); + }); +}); + +describe('fitWithinMaxEdge:等比缩放', () => { + it('超出最长边时按比例缩小', () => { + expect(fitWithinMaxEdge(2560, 1440, 1280)).toEqual({ + width: 1280, + height: 720, + scaled: true, + }); + }); + + it('只缩小不放大(对齐 Pillow 的 min(1.0, ...))', () => { + expect(fitWithinMaxEdge(100, 80, 1280)).toEqual({ + width: 100, + height: 80, + scaled: false, + }); + }); + + it('恰好等于上限时不缩放', () => { + expect(fitWithinMaxEdge(1280, 800, 1280).scaled).toBe(false); + }); + + it('竖图按高度约束', () => { + expect(fitWithinMaxEdge(600, 1200, 600)).toEqual({ + width: 300, + height: 600, + scaled: true, + }); + }); + + it('极端比例不会产生 0 尺寸', () => { + const result = fitWithinMaxEdge(10000, 3, 100); + expect(result.width).toBe(100); + expect(result.height).toBeGreaterThanOrEqual(1); + }); + + it('非法尺寸原样返回', () => { + expect(fitWithinMaxEdge(0, 0, 100).scaled).toBe(false); + }); +}); + +describe('mimeFromSuffix 与 toDataUrl', () => { + it('常见后缀映射到正确 MIME', () => { + expect(mimeFromSuffix('.png')).toBe('image/png'); + expect(mimeFromSuffix('.jpeg')).toBe('image/jpeg'); + expect(mimeFromSuffix('.JPG')).toBe('image/jpeg'); + expect(mimeFromSuffix('.webp')).toBe('image/webp'); + }); + + it('未知后缀退化为 octet-stream', () => { + expect(mimeFromSuffix('.xyz')).toBe('application/octet-stream'); + }); + + it('toDataUrl 产出标准 data URL', () => { + expect(toDataUrl('image/jpeg', Buffer.from([1, 2, 3]))).toBe( + 'data:image/jpeg;base64,AQID', + ); + }); +}); + +describe('PDF 文本提取:辅助函数', () => { + it('zlib 解压能力可用(PDF FlateDecode 依赖它)', () => { + const original = Buffer.from('BT (hello) Tj ET'); + const deflated = zlib.deflateSync(original); + expect(zlib.inflateSync(deflated).toString('latin1')).toBe( + 'BT (hello) Tj ET', + ); + }); +}); diff --git a/src/main/agentImage/imageOps.ts b/src/main/agentImage/imageOps.ts new file mode 100644 index 0000000..4c64790 --- /dev/null +++ b/src/main/agentImage/imageOps.ts @@ -0,0 +1,235 @@ +/** + * 图像操作的纯逻辑部分(不依赖 Electron,可单测)。 + * + * 背景:Python 侧用 Pillow 做三件事——读尺寸/格式、等比缩放、JPEG 编码。 + * Node 侧用 Electron 内置的 `nativeImage` 覆盖后两者(`crop` / `resize` / `toJPEG`), + * 无需引入 `sharp` 等 native 模块。 + * + * 但 `nativeImage` 有两个不足,需要本模块补齐: + * + * 1. **不暴露图片格式**。Python 的 `img.format` 会返回 `PNG` / `JPEG` 等, + * 工具结果里要带这个信息。 + * 2. **官方只保证 PNG/JPEG 解码**。对 GIF / WebP / BMP / ICO 可能返回空图像, + * 此时需要「原字节直传 + 从文件头解析尺寸」的兜底路径。 + * + * 因此这里实现文件头解析:既产出 `format` 字符串,也在解码失败时给出尺寸。 + */ + +/** 文件头解析结果。 */ +export interface ImageHeaderInfo { + width: number; + height: number; + /** 大写格式名(对齐 Pillow 的 `img.format`:PNG / JPEG / GIF / WEBP / BMP / ICO)。 */ + format: string; +} + +function readUInt16BE(buf: Buffer, offset: number): number { + return buf.readUInt16BE(offset); +} + +function readUInt16LE(buf: Buffer, offset: number): number { + return buf.readUInt16LE(offset); +} + +function readUInt32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +function readUInt32LE(buf: Buffer, offset: number): number { + return buf.readUInt32LE(offset); +} + +/** PNG:签名 + IHDR 中的宽高(大端)。 */ +function parsePng(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 24) return null; + if (buf.readUInt32BE(0) !== 0x89504e47) return null; + if (buf.toString('ascii', 12, 16) !== 'IHDR') return null; + return { + width: readUInt32BE(buf, 16), + height: readUInt32BE(buf, 20), + format: 'PNG', + }; +} + +/** + * JPEG:扫描段结构找 SOFn(帧起始),从中读高度与宽度。 + * + * 需跳过 `0xC4`(DHT)、`0xC8`(JPG)、`0xCC`(DAC)——它们不是 SOFn。 + */ +function parseJpeg(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 4) return null; + if (buf[0] !== 0xff || buf[1] !== 0xd8) return null; + + let offset = 2; + while (offset + 9 < buf.length) { + if (buf[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buf[offset + 1]; + // 填充字节 + if (marker === 0xff) { + offset += 1; + continue; + } + // 无长度字段的标记 + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (marker === 0xd9) break; + + const segLength = readUInt16BE(buf, offset + 2); + const isSof = + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc; + if (isSof) { + // SOFn: [标记2][长度2][精度1][高2][宽2] + return { + height: readUInt16BE(buf, offset + 5), + width: readUInt16BE(buf, offset + 7), + format: 'JPEG', + }; + } + if (segLength < 2) break; + offset += 2 + segLength; + } + return null; +} + +/** GIF:`GIF87a` / `GIF89a` + 逻辑屏幕宽高(小端)。 */ +function parseGif(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 10) return null; + const sig = buf.toString('ascii', 0, 6); + if (sig !== 'GIF87a' && sig !== 'GIF89a') return null; + return { + width: readUInt16LE(buf, 6), + height: readUInt16LE(buf, 8), + format: 'GIF', + }; +} + +/** WebP:RIFF 容器,按 VP8 / VP8L / VP8X 三种变体取尺寸。 */ +function parseWebp(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 30) return null; + if (buf.toString('ascii', 0, 4) !== 'RIFF') return null; + if (buf.toString('ascii', 8, 12) !== 'WEBP') return null; + + const chunk = buf.toString('ascii', 12, 16); + if (chunk === 'VP8 ') { + // 有损:帧头中 14 位宽、14 位高 + return { + width: readUInt16LE(buf, 26) & 0x3fff, + height: readUInt16LE(buf, 28) & 0x3fff, + format: 'WEBP', + }; + } + if (chunk === 'VP8L') { + // 无损:位打包的 14 位宽高 + const bits = buf.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + format: 'WEBP', + }; + } + if (chunk === 'VP8X') { + // 扩展:24 位宽高(减一存储) + const width = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1; + const height = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1; + return { width, height, format: 'WEBP' }; + } + return null; +} + +/** BMP:`BM` + DIB 头中的宽高(小端,有符号;高度为负表示自上而下)。 */ +function parseBmp(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 26) return null; + if (buf.toString('ascii', 0, 2) !== 'BM') return null; + const width = buf.readInt32LE(18); + const height = buf.readInt32LE(22); + if (width <= 0) return null; + return { width, height: Math.abs(height), format: 'BMP' }; +} + +/** ICO:目录中第一个条目的宽高(0 表示 256)。 */ +function parseIco(buf: Buffer): ImageHeaderInfo | null { + if (buf.length < 22) return null; + if (buf[0] !== 0x00 || buf[1] !== 0x00 || buf[2] !== 0x01 || buf[3] !== 0x00) { + return null; + } + const count = readUInt16LE(buf, 4); + if (count === 0) return null; + const width = buf[6] === 0 ? 256 : buf[6]; + const height = buf[7] === 0 ? 256 : buf[7]; + return { width, height, format: 'ICO' }; +} + +/** + * 从文件头解析图片尺寸与格式。 + * + * 支持 PNG / JPEG / GIF / WebP / BMP / ICO;无法识别时返回 null。 + */ +export function parseImageHeader(buf: Buffer): ImageHeaderInfo | null { + return ( + parsePng(buf) ?? + parseJpeg(buf) ?? + parseGif(buf) ?? + parseWebp(buf) ?? + parseBmp(buf) ?? + parseIco(buf) + ); +} + +/** 文件后缀 → data URL 的 MIME。 */ +export function mimeFromSuffix(suffix: string): string { + switch (suffix.toLowerCase()) { + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.bmp': + return 'image/bmp'; + case '.ico': + return 'image/x-icon'; + default: + return 'application/octet-stream'; + } +} + +/** + * 等比缩放到最长边不超过 `maxEdge`。 + * + * 对齐 Python `image_bytes_loader.resize_image_to_jpeg_bytes` 的 + * `scale = min(1.0, max_edge / max(w, h))`——**只缩小、不放大**。 + * + * @returns 目标尺寸;无需缩放时返回原尺寸 + */ +export function fitWithinMaxEdge( + width: number, + height: number, + maxEdge: number, +): { width: number; height: number; scaled: boolean } { + if (width <= 0 || height <= 0) return { width, height, scaled: false }; + const longest = Math.max(width, height); + if (longest <= maxEdge) return { width, height, scaled: false }; + const scale = maxEdge / longest; + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + scaled: true, + }; +} + +/** 构造 data URL。 */ +export function toDataUrl(mime: string, bytes: Buffer): string { + return `data:${mime};base64,${bytes.toString('base64')}`; +} From 58b2a4d5d05587d184ce6ea3adb0a0069350d8c7 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 02:14:49 +0800 Subject: [PATCH 06/20] =?UTF-8?q?feat(agent):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E6=80=81=E9=99=84=E5=9B=BE=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=E8=A7=86=E8=A7=89=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 context/multimodal(路径优先的附图构造,编码失败退化为纯文本)与 vision/autoLoader(VisionAutoLoader 等价实现:触发条件严格合取,仅首轮无 tool call 时兜底一次)。接通 nativeImage 编码能力。修正一处语义:视觉结果显示用 stripInternalMarkers 的完整缩进 JSON,而非 formatToolResultForDisplay 的 summary/message 折叠。golden diff 保持 10/11;主进程测试 336/336。 Co-authored-by: Cursor --- src/main/agent/context/multimodal.ts | 62 ++++ src/main/agent/loop/assistLoop.ts | 57 +++- src/main/agent/vision/autoLoader.test.ts | 355 +++++++++++++++++++++++ src/main/agent/vision/autoLoader.ts | 154 ++++++++++ 4 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 src/main/agent/context/multimodal.ts create mode 100644 src/main/agent/vision/autoLoader.test.ts create mode 100644 src/main/agent/vision/autoLoader.ts diff --git a/src/main/agent/context/multimodal.ts b/src/main/agent/context/multimodal.ts new file mode 100644 index 0000000..11b6d94 --- /dev/null +++ b/src/main/agent/context/multimodal.ts @@ -0,0 +1,62 @@ +/** + * 多模态消息构造(附图注入)。 + * + * 移植自 `vendor/local-agent/app/agent/chat_message_builder.py`。 + * + * 图片加载优先级与 Python 一致:**绝对路径优先**,其次 base64。 + * 编码失败或读图失败时**退化为纯文本消息**,不抛异常——避免一张坏图打断整轮对话。 + * + * 与 Python 的差异:Python 用 `asyncio.to_thread` 把 Pillow 的同步编解码丢到线程池; + * Node 侧编解码由主进程的 `nativeImage` 完成(经 RPC),本身不阻塞运行时事件循环, + * 因此无需线程池。 + */ + +import type { ChatMessage } from '../llm/client'; +import type { ImageService } from '../services/imageService'; + +export interface MultimodalOptions { + imageAbsolutePath?: string; + imageBase64?: string; + maxEdge: number; + jpegQuality: number; +} + +/** + * 构造带附图的用户消息。 + * + * 无需附图(或无编码能力/编码失败)时返回纯文本消息。 + */ +export async function buildMultimodalUserMessage( + text: string, + options: MultimodalOptions, + imageService: ImageService, +): Promise { + const absolutePath = (options.imageAbsolutePath ?? '').trim(); + const base64 = (options.imageBase64 ?? '').trim(); + if (!absolutePath && !base64) { + return { role: 'user', content: text }; + } + + const encoded = await imageService.toJpegDataUrl({ + absolutePath: absolutePath || undefined, + base64: base64 || undefined, + maxEdge: options.maxEdge, + quality: options.jpegQuality, + }); + + // 编码失败 / 独立模式无编码能力 → 退化为纯文本 + if (!encoded.ok || !encoded.dataUrl) { + return { role: 'user', content: text }; + } + + return { + role: 'user', + content: [ + { type: 'text', text }, + { type: 'image_url', image_url: { url: encoded.dataUrl } }, + ], + }; +} + +/** 附图注入时使用的固定提示文本(与 Python 一致)。 */ +export const VISION_ATTACHMENT_TEXT = '【附图】请根据上图回答用户关于该图片的问题。'; diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 2eec3ac..8585365 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -48,6 +48,11 @@ import { import { ProposalStreamInterceptor } from './proposalStreamer'; import { isLrAgentRelative } from '../tools/workspacePath'; import { pythonJsonDumps } from '../json'; +import { VisionAutoLoader } from '../vision/autoLoader'; +import { + buildMultimodalUserMessage, + VISION_ATTACHMENT_TEXT, +} from '../context/multimodal'; import type { ClientContextLike } from '../tools/workspacePath'; import type { AgentSettings } from '../config'; import { @@ -216,6 +221,17 @@ export async function* streamAssist( imageService: params.imageService, }; + // 视觉兜底加载器:只在首轮无 tool call 时尝试一次 + const visionLoader = new VisionAutoLoader({ + settings, + clientContext: params.clientContext, + providerIsVision: params.providerIsVision, + userContent: params.userContent, + imageService: params.imageService, + }); + // 可变容器:跨生成器传递「本请求是否已处理过视觉图」 + const visionState = { bootstrapped: false }; + let budgetExhausted = true; for (let roundIdx = 0; roundIdx <= settings.maxToolRounds; roundIdx += 1) { @@ -281,6 +297,7 @@ export async function* streamAssist( isResume, toolSpecs: params.toolSpecs, interceptor, + visionState, }); if (outcome.kind === 'pending') { @@ -293,7 +310,19 @@ export async function* streamAssist( } // ── 阶段 C:本轮无 tool call ──────────────────────────── - // 视觉自动兜底在阶段 5 接入;当前直接结束循环 + // 弱模型首轮未调用任何工具时的视觉兜底(正常路径由模型自己调 + // read_image_for_vision)。触发后置标记并进入下一轮,避免重复注入。 + if ( + roundIdx === 0 && + !isResume && + visionLoader.shouldLoad(isResume) && + !visionState.bootstrapped + ) { + visionState.bootstrapped = true; + yield* visionLoader.tryFallback(messages); + budgetExhausted = true; + continue; + } budgetExhausted = false; break; } @@ -329,6 +358,8 @@ interface ExecuteRoundParams { accumulatedText: string; isResume: boolean; interceptor: ProposalStreamInterceptor; + /** 「本请求是否已处理过视觉图」——由调用方持有,跨轮次共享。 */ + visionState: { bootstrapped: boolean }; } /** @@ -559,6 +590,10 @@ async function* executeRound( streamedPaths, streamedPathsByCallId, }); + // 模型真实调用过视觉工具后,兜底不应再触发(对齐 Python 的 vision_bootstrapped) + if (call.name === 'read_image_for_vision') { + params.visionState.bootstrapped = true; + } } if (asyncPending.length > 0) { @@ -657,8 +692,9 @@ async function* streamToolExecution( if (call.name === 'read_image_for_vision') { visionPath = extractVisionPath(raw); - // 提取到路径才剥离内部标记;否则保持原样(对齐 Python 的分支写法) - display = visionPath ? formatToolResultForDisplay(stripInternalMarkers(raw)) : raw; + // 提取到路径才剥离内部标记(输出完整缩进 JSON,而非 summary); + // 否则保持原样——对齐 Python 的分支写法 + display = visionPath ? stripInternalMarkers(raw) : raw; } else if (FILE_PROPOSAL_TOOLS.has(call.name)) { docProposal = extractDocProposalFromToolResult(call.name, raw); // 只有拿到提案数据才走显示文本;工具报错时**保持完整 JSON 原样回灌** @@ -732,10 +768,19 @@ async function* streamToolExecution( } } - // 视觉附图注入在阶段 4/5 接入:这里先提取路径以便后续使用 + // 视觉附图注入:在对应 ToolMessage 之后追加一条多模态 HumanMessage if (visionPath && toolContext.providerIsVision) { - // TODO(阶段5): 追加多模态 HumanMessage(需 nativeImage 生成 data URL) - void visionPath; + messages.push( + await buildMultimodalUserMessage( + VISION_ATTACHMENT_TEXT, + { + imageAbsolutePath: visionPath, + maxEdge: toolContext.settings.chatVisionMaxEdge, + jpegQuality: toolContext.settings.chatVisionJpegQuality, + }, + toolContext.imageService, + ), + ); } } diff --git a/src/main/agent/vision/autoLoader.test.ts b/src/main/agent/vision/autoLoader.test.ts new file mode 100644 index 0000000..b7b76be --- /dev/null +++ b/src/main/agent/vision/autoLoader.test.ts @@ -0,0 +1,355 @@ +/** + * @jest-environment node + */ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { + VisionAutoLoader, + canBootstrapVision, + pickVisionRelativePath, + visionRelativeFromUserText, +} from './autoLoader'; +import { buildMultimodalUserMessage, VISION_ATTACHMENT_TEXT } from '../context/multimodal'; +import { createImageService } from '../services/imageService'; +import type { ImageService } from '../services/imageService'; +import { DEFAULT_AGENT_SETTINGS } from '../config'; +import type { ChatMessage } from '../llm/client'; +import type { StreamEventPayload } from '../sse'; + +let workspace: string; + +beforeEach(async () => { + workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'lr-vision-')); +}); + +afterEach(async () => { + await fs.remove(workspace); +}); + +/** 写一个最小 PNG(文件名可指定)。 */ +async function writePng(relative: string, width = 640, height = 480): Promise { + const buf = Buffer.alloc(33); + buf.writeUInt32BE(0x89504e47, 0); + buf.writeUInt32BE(0x0d0a1a0a, 4); + buf.writeUInt32BE(13, 8); + buf.write('IHDR', 12, 'ascii'); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + const abs = path.join(workspace, relative); + await fs.ensureDir(path.dirname(abs)); + await fs.writeFile(abs, buf); + return abs; +} + +const ctx = () => ({ workspaceRoot: workspace, projectDirectoryPath: null }); + +/** 编码总是成功的替身服务。 */ +function fakeEncoder(): ImageService { + return { + canEncode: true, + async probe() { + return { ok: true, width: 640, height: 480, format: 'PNG' }; + }, + async toJpegDataUrl() { + return { ok: true, dataUrl: 'data:image/jpeg;base64,AAAA', width: 640, height: 480 }; + }, + async cropBatch() { + return { ok: true, images: [] }; + }, + }; +} + +/** 模拟真实 nativeImage 行为的替身:路径不存在时编码失败。 */ +function pathCheckingEncoder(): ImageService { + return { + canEncode: true, + async probe() { + return { ok: false, error: 'not found' }; + }, + async toJpegDataUrl(params) { + const abs = params.absolutePath ?? ''; + if (!abs || !fs.existsSync(abs)) { + return { ok: false, error: `文件不存在:${abs}` }; + } + return { ok: true, dataUrl: 'data:image/jpeg;base64,AAAA' }; + }, + async cropBatch() { + return { ok: false, error: 'not found' }; + }, + }; +} + +async function collect( + gen: AsyncGenerator, +): Promise { + const out: StreamEventPayload[] = []; + for await (const event of gen) out.push(event); + return out; +} + +describe('visionRelativeFromUserText:从用户文本解析图片路径', () => { + it('解析行首的图片路径', () => { + expect(visionRelativeFromUserText('data/1.jpg 里有什么')).toBe('data/1.jpg'); + }); + + it('解析空白后的图片路径', () => { + expect(visionRelativeFromUserText('看看 data/2.png 这张图')).toBe('data/2.png'); + }); + + it('解析「『引号后的图片路径', () => { + expect(visionRelativeFromUserText('看「data/3.webp」')).toBe('data/3.webp'); + }); + + it('反斜杠路径归一为正斜杠', () => { + expect(visionRelativeFromUserText('看看 data\\sub\\4.bmp 这张图')).toBe( + 'data/sub/4.bmp', + ); + }); + + it('大小写不敏感', () => { + expect(visionRelativeFromUserText('看 data/5.JPG')).toBe('data/5.JPG'); + }); + + it('不在分隔位置时不上当(避免正文误判)', () => { + expect(visionRelativeFromUserText('文件是data/1.jpg')).toBe(''); + }); + + it('无图片路径时返回空串', () => { + expect(visionRelativeFromUserText('帮我看看这个文件夹')).toBe(''); + }); + + it('不支持的后缀不匹配', () => { + expect(visionRelativeFromUserText('看看 data/1.txt')).toBe(''); + }); +}); + +describe('pickVisionRelativePath 与 canBootstrapVision', () => { + it('优先取 active_relative_path', () => { + expect( + pickVisionRelativePath({ workspaceRoot: workspace, activeRelativePath: 'a/b.png' }), + ).toBe('a/b.png'); + }); + + it('未打开文件时返回空串', () => { + expect(pickVisionRelativePath({ workspaceRoot: workspace })).toBe(''); + }); + + it('存在的图片可通过校验', async () => { + await writePng('data/ok.png'); + expect(canBootstrapVision(ctx(), 'data/ok.png')).toBe(true); + }); + + it('不存在的文件不通过', () => { + expect(canBootstrapVision(ctx(), 'data/missing.png')).toBe(false); + }); + + it('非图片后缀不通过', async () => { + await fs.writeFile(path.join(workspace, 'note.md'), 'x'); + expect(canBootstrapVision(ctx(), 'note.md')).toBe(false); + }); +}); + +describe('VisionAutoLoader.shouldLoad', () => { + const params = (overrides: Partial[0]> = {}) => ({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: ctx(), + providerIsVision: true, + userContent: '看看 data/1.jpg', + imageService: fakeEncoder(), + ...overrides, + }); + + it('非 resume + 支持视觉 + 文本含图片路径 → 触发', () => { + expect(new VisionAutoLoader(params()).shouldLoad(false)).toBe(true); + }); + + it('resume 时不触发', () => { + expect(new VisionAutoLoader(params()).shouldLoad(true)).toBe(false); + }); + + it('provider 不支持视觉时不触发', () => { + expect(new VisionAutoLoader(params({ providerIsVision: false })).shouldLoad(false)).toBe( + false, + ); + }); + + it('文本无图片路径时不触发', () => { + expect( + new VisionAutoLoader(params({ userContent: '帮我看看目录' })).shouldLoad(false), + ).toBe(false); + }); +}); + +describe('VisionAutoLoader.tryFallback', () => { + it('优先使用当前打开的图片并注入附图', async () => { + await writePng('data/opened.png'); + const messages: ChatMessage[] = []; + const loader = new VisionAutoLoader({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + providerIsVision: true, + userContent: '这张图里有什么', + imageService: fakeEncoder(), + }); + + const events = await collect(loader.tryFallback(messages)); + + expect(events[0].type).toBe('tool_start'); + expect(events[0].name).toBe('read_image_for_vision'); + expect(events[1].type).toBe('tool_result'); + + // 工具 id 形态与 Python 一致 + expect(String(events[0].toolCallId)).toMatch(/^lr-vision-fallback-[0-9a-f]{10}$/); + + // 追加 ToolMessage + 附图 HumanMessage + expect(messages).toHaveLength(2); + expect(messages[0].role).toBe('tool'); + expect(messages[1].role).toBe('user'); + expect(Array.isArray(messages[1].content)).toBe(true); + }); + + it('无打开文件时回退到用户文本中的路径', async () => { + await writePng('data/from-text.png'); + const messages: ChatMessage[] = []; + const loader = new VisionAutoLoader({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: ctx(), + providerIsVision: true, + userContent: '看看 data/from-text.png', + imageService: fakeEncoder(), + }); + + const events = await collect(loader.tryFallback(messages)); + expect(events).toHaveLength(2); + expect(messages).toHaveLength(2); + }); + + it('路径不存在时不产出任何事件', async () => { + const messages: ChatMessage[] = []; + const loader = new VisionAutoLoader({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: ctx(), + providerIsVision: true, + userContent: '看看 data/missing.jpg', + imageService: fakeEncoder(), + }); + + expect(await collect(loader.tryFallback(messages))).toHaveLength(0); + expect(messages).toHaveLength(0); + }); + + it('结果剥离内部标记,且输出完整缩进 JSON(非 summary)', async () => { + await writePng('data/opened.png'); + const loader = new VisionAutoLoader({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + providerIsVision: true, + userContent: '看图', + imageService: fakeEncoder(), + }); + + const events = await collect(loader.tryFallback([])); + const result = String(events[1].result); + + // 不应包含内部标记 + expect(result).not.toContain('__vision_image_path__'); + // 应是缩进 JSON(含换行),而不是被折叠成 message 单行文本 + expect(result).toContain('\n'); + // 且保留结构化字段 + expect(JSON.parse(result)).toMatchObject({ ok: true, width: 640, height: 480 }); + }); + + it('无编码能力时仍追加纯文本消息(退化不抛异常)', async () => { + await writePng('data/opened.png'); + const standalone = createImageService(null); + expect(standalone.canEncode).toBe(false); + + const messages: ChatMessage[] = []; + const loader = new VisionAutoLoader({ + settings: DEFAULT_AGENT_SETTINGS, + clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + providerIsVision: true, + userContent: '看图', + imageService: standalone, + }); + + await collect(loader.tryFallback(messages)); + // 工具结果 + 退化的纯文本消息 + expect(messages).toHaveLength(2); + expect(typeof messages[1].content).toBe('string'); + }); +}); + +describe('buildMultimodalUserMessage', () => { + it('路径无效时退化为纯文本(模拟真实 nativeImage 的空图像行为)', async () => { + const msg = await buildMultimodalUserMessage( + 'text', + { imageAbsolutePath: '/nonexistent.png', maxEdge: 1280, jpegQuality: 85 }, + pathCheckingEncoder(), + ); + expect(msg.content).toBe('text'); + }); + + it('编码失败时退化为纯文本', async () => { + await writePng('data/x.png'); + const failing: ImageService = { + canEncode: true, + async probe() { + return { ok: true, width: 1, height: 1, format: 'PNG' }; + }, + async toJpegDataUrl() { + return { ok: false, error: 'boom' }; + }, + async cropBatch() { + return { ok: false, error: 'boom' }; + }, + }; + const msg = await buildMultimodalUserMessage( + 'text', + { imageAbsolutePath: path.join(workspace, 'data/x.png'), maxEdge: 1280, jpegQuality: 85 }, + failing, + ); + expect(msg.content).toBe('text'); + }); + + it('编码成功时产出 text + image_url 两段内容', async () => { + const msg = await buildMultimodalUserMessage( + '看图', + { imageAbsolutePath: 'whatever.png', maxEdge: 1280, jpegQuality: 85 }, + fakeEncoder(), + ); + expect(msg.content).toEqual([ + { type: 'text', text: '看图' }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AAAA' } }, + ]); + }); + + it('给定存在的文件路径时产出附图', async () => { + await writePng('data/real.png'); + const msg = await buildMultimodalUserMessage( + '看图', + { + imageAbsolutePath: path.join(workspace, 'data/real.png'), + maxEdge: 1280, + jpegQuality: 85, + }, + pathCheckingEncoder(), + ); + expect(Array.isArray(msg.content)).toBe(true); + }); + + it('未提供图片时直接返回纯文本', async () => { + const msg = await buildMultimodalUserMessage( + 'text', + { maxEdge: 1280, jpegQuality: 85 }, + fakeEncoder(), + ); + expect(msg.content).toBe('text'); + }); + + it('附图提示文本固定', () => { + expect(VISION_ATTACHMENT_TEXT).toBe('【附图】请根据上图回答用户关于该图片的问题。'); + }); +}); diff --git a/src/main/agent/vision/autoLoader.ts b/src/main/agent/vision/autoLoader.ts new file mode 100644 index 0000000..251d19a --- /dev/null +++ b/src/main/agent/vision/autoLoader.ts @@ -0,0 +1,154 @@ +/** + * 视觉兜底加载器。 + * + * 移植自 `vendor/local-agent/app/agent/assist/vision_bootstrap.py` 与 + * `app/agent/assist_vision.py`。 + * + * **设计意图(不要扩权)**:历史上曾在进 LLM 前无条件预加载图片,但系统替模型选图 + * 容易看错、且浪费视觉 token。现在仅保留「弱模型首轮未调用任何工具」这一条兜底路径; + * 正常情况由模型自行调用 `read_image_for_vision`。 + * + * 因此触发条件是严格合取的:非 resume、provider 支持视觉、用户文本里能解析出图片路径、 + * 且仅在第 0 轮无 tool call 时尝试;一旦兜底过就置标记,避免重复注入。 + */ + +import { randomUUID } from 'crypto'; +import { sse, type StreamEventPayload } from '../sse'; +import type { ChatMessage } from '../llm/client'; +import { buildMultimodalUserMessage, VISION_ATTACHMENT_TEXT } from '../context/multimodal'; +import { IMAGE_SUFFIXES } from '../tools/fileReader'; +import { resolveWorkspaceFile, type ClientContextLike } from '../tools/workspacePath'; +import { readImageForVisionTool } from '../tools/vision'; +import { extractVisionPath, formatToolResultForDisplay, stripInternalMarkers } from '../tools/result';import type { ImageService } from '../services/imageService'; +import type { AgentSettings } from '../config'; + +/** + * 从用户文本中解析图片相对路径。 + * + * 与 Python 的正则一致:路径必须出现在**行首或空白/「『之后**,避免把正文里的 + * 任意片段误判为路径。 + */ +export function visionRelativeFromUserText(userContent: string): string { + const match = + /(?:^|[\s「『])([\w./\\-]+\.(?:jpg|jpeg|png|gif|webp|bmp|ico))\b/i.exec( + userContent ?? '', + ); + if (!match) return ''; + return match[1].replace(/\\/g, '/'); +} + +/** 自动兜底时优先使用的相对路径(当前打开的文件)。 */ +export function pickVisionRelativePath( + clientContext: ClientContextLike | null, +): string { + const rel = (clientContext?.activeRelativePath ?? '').trim(); + return rel || ''; +} + +/** 该相对路径是否指向一个存在的受支持图片。 */ +export function canBootstrapVision( + clientContext: ClientContextLike | null, + relativePath: string, +): boolean { + const { resolved } = resolveWorkspaceFile(clientContext, relativePath); + if (resolved === null) return false; + const dot = resolved.lastIndexOf('.'); + const suffix = dot === -1 ? '' : resolved.slice(dot).toLowerCase(); + return IMAGE_SUFFIXES.has(suffix); +} + +export interface VisionLoaderParams { + settings: AgentSettings; + clientContext: ClientContextLike | null; + providerIsVision: boolean; + userContent: string; + imageService: ImageService; +} + +export class VisionAutoLoader { + private readonly params: VisionLoaderParams; + + constructor(params: VisionLoaderParams) { + this.params = params; + } + + /** 是否可能涉及图片(兜底触发前提)。 */ + shouldLoad(isResume: boolean): boolean { + if (isResume || !this.params.providerIsVision) return false; + return Boolean(visionRelativeFromUserText(this.params.userContent)); + } + + /** + * 首轮无 tool call 时尝试兜底加载。 + * + * 产出与普通视觉工具调用一致的事件序列,并在成功后追加附图。 + */ + async *tryFallback( + messages: ChatMessage[], + ): AsyncGenerator { + let rel = pickVisionRelativePath(this.params.clientContext); + if (!rel && this.params.userContent.trim()) { + rel = visionRelativeFromUserText(this.params.userContent); + } + if (!canBootstrapVision(this.params.clientContext, rel)) return; + + // 与 Python 一致的工具 id 形态(兜底调用不对应真实的 LLM tool_call) + const toolId = `lr-vision-fallback-${randomUUID().replace(/-/g, '').slice(0, 10)}`; + yield* this.streamVisionToolExecution(toolId, rel, messages); + } + + /** 执行一次视觉工具调用并产出事件(兜底路径与显式调用共用)。 */ + async *streamVisionToolExecution( + toolId: string, + relativePath: string, + messages: ChatMessage[], + ): AsyncGenerator { + const args = { relative_path: relativePath }; + yield sse.toolStart(toolId, 'read_image_for_vision', args); + + let resultText: string; + try { + resultText = await readImageForVisionTool( + this.params.clientContext, + relativePath, + { + providerIsVision: this.params.providerIsVision, + imageService: this.params.imageService, + }, + ); + } catch (err) { + resultText = `工具执行失败: ${errMessage(err)}`; + } + + const visionPath = extractVisionPath(resultText); + // 注意:视觉结果显示用的是「剥离内部标记后的完整缩进 JSON」, + // **不是** formatToolResultForDisplay(那会只取 summary/message); + // 与 Python 的 format_vision_tool_result_for_display 对齐 + const displayResult = visionPath ? stripInternalMarkers(resultText) : resultText; + + yield sse.toolResult(toolId, displayResult); + messages.push({ + role: 'tool', + content: displayResult, + toolCallId: toolId, + }); + + if (visionPath && this.params.providerIsVision) { + messages.push( + await buildMultimodalUserMessage( + VISION_ATTACHMENT_TEXT, + { + imageAbsolutePath: visionPath, + maxEdge: this.params.settings.chatVisionMaxEdge, + jpegQuality: this.params.settings.chatVisionJpegQuality, + }, + this.params.imageService, + ), + ); + } + } +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} From 33d98652c4a925e309fd08e3a04d3658ea09bc63 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 02:26:47 +0800 Subject: [PATCH 07/20] =?UTF-8?q?feat(agent):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=9F=A5=E9=98=85=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E4=B8=8E=20MCP=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 subagent/exploreReadonly:嵌套只读循环(8 个内层工具白名单、禁用名单含记忆与视觉工具、子代理轮上限、收尾摘要轮不带工具),产出完整 subagent_* 事件族;保留三处与主循环的刻意差异(不做父级去重/门禁、allowed 回灌原文而 blocked 用完整 JSON、收尾轮不绑工具)。接入主循环时为 explore 调用单开一路(tool_start 只发一次,与基线一致)。新增 mcp/client:用已依赖的 @modelcontextprotocol/sdk 替换 langchain-mcp-adapters,保留 TTL 缓存 + stale-while-revalidate + 单台失败隔离 + 名字去重;MCP 工具自带 JSON Schema 直接透传(新增 rawParameters)。接通 /agent/mcp/probe。golden diff 达成 11/11 全通过;主进程测试 362/362。 Co-authored-by: Cursor --- src/main/agent/handlers/chatStream.ts | 58 ++- src/main/agent/handlers/index.ts | 4 +- src/main/agent/handlers/mcpProbe.ts | 56 +++ src/main/agent/loop/assistLoop.ts | 234 ++++++++++- src/main/agent/mcp/client.test.ts | 255 ++++++++++++ src/main/agent/mcp/client.ts | 459 +++++++++++++++++++++ src/main/agent/subagent/exploreReadonly.ts | 408 ++++++++++++++++++ src/main/agent/tools/registry.ts | 13 +- 8 files changed, 1475 insertions(+), 12 deletions(-) create mode 100644 src/main/agent/handlers/mcpProbe.ts create mode 100644 src/main/agent/mcp/client.test.ts create mode 100644 src/main/agent/mcp/client.ts create mode 100644 src/main/agent/subagent/exploreReadonly.ts diff --git a/src/main/agent/handlers/chatStream.ts b/src/main/agent/handlers/chatStream.ts index 97e3372..c42aa6f 100644 --- a/src/main/agent/handlers/chatStream.ts +++ b/src/main/agent/handlers/chatStream.ts @@ -18,6 +18,7 @@ import { LlmClient } from '../llm/client'; import { DONE_FRAME, encodeSseFrame, sse, toSseDict } from '../sse'; import { buildToolSpecs } from '../tools/registry'; import { toolRegistry, resolveImageService, type RuntimeDeps } from './deps'; +import { loadMcpToolsFromServers, shouldExposeMcpTool } from '../mcp/client'; import { deriveTaskPhase } from '../loop/taskPhase'; import { resolveAssistToolSet } from '../loop/modeRouter'; import { streamAssist } from '../loop/assistLoop'; @@ -141,14 +142,57 @@ async function* streamFrames( }); const clientCtx = body.clientContext; - const toolSet = resolveAssistToolSet({ - hasProjectSnapshot: Boolean(clientCtx?.annotationProjectSnapshot), - agentMode: clientCtx?.agentMode ?? null, - isEditor: clientCtx?.workMode === 'editor', - hasWorkspace: Boolean((clientCtx?.workspaceRoot ?? '').trim()), - }); + const toolSet = new Set( + resolveAssistToolSet({ + hasProjectSnapshot: Boolean(clientCtx?.annotationProjectSnapshot), + agentMode: clientCtx?.agentMode ?? null, + isEditor: clientCtx?.workMode === 'editor', + hasWorkspace: Boolean((clientCtx?.workspaceRoot ?? '').trim()), + }), + ); + + const tools = new Map(toolRegistry()); + + // MCP 工具发现:仅在配置了 MCP server 时进行。 + // 先发一条 preparing(stage="mcp"),让渲染层在建连期间就有反馈。 + const mcpUrl = (clientCtx?.mcpServerUrl ?? '').trim(); + const remoteMcp = clientCtx?.mcpServers ?? []; + if (mcpUrl || remoteMcp.length > 0) { + yield encodeSseFrame(toSseDict(sse.preparing('mcp'))); + try { + const mcpTools = await loadMcpToolsFromServers({ + localServerUrl: mcpUrl || null, + localServerToken: clientCtx?.mcpServerToken ?? null, + remoteServers: remoteMcp, + ttlSeconds: deps.settings.mcpToolsTtlSeconds, + onLog: (level, message) => { + if (level === 'warn') console.warn(`[agentRuntime] ${message}`); + }, + }); + const memoryOn = Boolean(clientCtx?.workspaceMemoryEnabled); + for (const tool of mcpTools) { + // 与内置工具重名时不注入;记忆工具按开关与模式过滤 + if (tools.has(tool.name)) continue; + if ( + !shouldExposeMcpTool(tool.name, { + workspaceMemoryEnabled: memoryOn, + agentMode: clientCtx?.agentMode ?? null, + }) + ) { + continue; + } + tools.set(tool.name, tool); + toolSet.add(tool.name); + } + } catch (err) { + // MCP 发现失败不影响主流程(与 Python 一致:只告警不降级) + console.warn( + `[agentRuntime] MCP 工具加载失败:${err instanceof Error ? err.message : String(err)}`, + ); + } + } - const tools = toolRegistry(); + // 工具声明按名称排序后固定:避免破坏 LLM 前缀缓存 const toolSpecs = buildToolSpecs(toolSet, tools); events = streamAssist({ diff --git a/src/main/agent/handlers/index.ts b/src/main/agent/handlers/index.ts index bf4eb99..796813b 100644 --- a/src/main/agent/handlers/index.ts +++ b/src/main/agent/handlers/index.ts @@ -2,12 +2,13 @@ * 已实现路由的装配。 * * 阶段 1 的 `routes.ts` 全部返回 501;本模块在它之上覆盖已实现的端点。 - * 阶段 3/6/7 会继续在此追加提案、MCP、标注编排的实现。 + * 阶段 7 会继续在此追加标注编排的实现。 */ import { AGENT_ROUTE_PATHS, buildRoutes } from '../routes'; import type { RouteHandler } from '../server'; import { createChatCancelHandler, createChatStreamHandler } from './chatStream'; +import { mcpProbeHandler } from './mcpProbe'; import type { RuntimeDeps } from './deps'; /** 装配全部已实现路由。 */ @@ -17,5 +18,6 @@ export function buildImplementedRoutes( return buildRoutes({ [`POST ${AGENT_ROUTE_PATHS.chatStream}`]: createChatStreamHandler(deps), [`POST ${AGENT_ROUTE_PATHS.chatCancel}`]: createChatCancelHandler(), + [`POST ${AGENT_ROUTE_PATHS.mcpProbe}`]: mcpProbeHandler, }); } diff --git a/src/main/agent/handlers/mcpProbe.ts b/src/main/agent/handlers/mcpProbe.ts new file mode 100644 index 0000000..33e80d4 --- /dev/null +++ b/src/main/agent/handlers/mcpProbe.ts @@ -0,0 +1,56 @@ +/** + * `POST /api/v1/agent/mcp/probe`:测试连接单个 MCP Server 并列出工具名。 + * + * 移植自 `vendor/local-agent/app/api/v1/agent.py` 的 `probe_mcp_server`。 + * + * 与 Python 一致的降级语义:异常**不外抛**,转成 `{ok:false, error}`—— + * 配置页需要把失败原因展示给用户,而不是让请求 500。 + */ + +import { AGENT_ROUTE_PATHS } from '../routes'; +import type { RouteHandler } from '../server'; +import { probeMcpTools } from '../mcp/client'; + +interface ProbeBody { + url?: unknown; + transport?: unknown; + headers?: unknown; +} + +export const mcpProbeHandler: RouteHandler = async (req) => { + let body: ProbeBody = {}; + try { + body = req.rawBody ? (JSON.parse(req.rawBody) as ProbeBody) : {}; + } catch { + return { kind: 'json', body: { ok: false, error: '请求体不是合法 JSON' } }; + } + + const url = typeof body.url === 'string' ? body.url.trim() : ''; + // 与 Python 一致:仅接受 http(s) + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return { kind: 'json', body: { ok: false, error: '仅支持 http(s) MCP 端点' } }; + } + + const transport = + body.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const); + + const headers: Record = {}; + if (body.headers && typeof body.headers === 'object' && !Array.isArray(body.headers)) { + for (const [key, value] of Object.entries(body.headers as Record)) { + if (typeof value === 'string') headers[key] = value; + } + } + + try { + const tools = await probeMcpTools({ url, transport, headers }); + return { kind: 'json', body: { ok: true, tools: tools.map((t) => t.name) } }; + } catch (err) { + return { + kind: 'json', + body: { ok: false, error: err instanceof Error ? err.message : String(err) }, + }; + } +}; + +/** 供路由表装配使用。 */ +export const MCP_PROBE_KEY = `POST ${AGENT_ROUTE_PATHS.mcpProbe}`; diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 8585365..dcc87f3 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -32,6 +32,7 @@ import { type ToolContext, type ToolDefinition, type ToolKind, + toToolSpec, } from '../tools/registry'; import { buildToolResult, @@ -49,6 +50,13 @@ import { ProposalStreamInterceptor } from './proposalStreamer'; import { isLrAgentRelative } from '../tools/workspacePath'; import { pythonJsonDumps } from '../json'; import { VisionAutoLoader } from '../vision/autoLoader'; +import { + EXPLORE_READONLY_FORBIDDEN_INNER, + EXPLORE_READONLY_TOOL_NAME, + resolveExploreInnerNames, + shouldIncludeAnnotationReads, + streamExploreReadonly, +} from '../subagent/exploreReadonly'; import { buildMultimodalUserMessage, VISION_ATTACHMENT_TEXT, @@ -298,6 +306,7 @@ export async function* streamAssist( toolSpecs: params.toolSpecs, interceptor, visionState, + settings, }); if (outcome.kind === 'pending') { @@ -360,6 +369,7 @@ interface ExecuteRoundParams { interceptor: ProposalStreamInterceptor; /** 「本请求是否已处理过视觉图」——由调用方持有,跨轮次共享。 */ visionState: { bootstrapped: boolean }; + settings: AgentSettings; } /** @@ -391,6 +401,7 @@ async function* executeRound( signal, interceptor, } = params; + const { settings } = toolContext; let resolved = resolveRoundToolCalls({ apiToolCalls, @@ -561,12 +572,20 @@ async function* executeRound( kindOf(tools, name), ); + // explore_readonly 单独成路:它由子代理 runner 接管,且事件形态不同 + // (工具自身不执行,改由 subagent_* 事件族表达) + const exploreCalls = immediate.filter( + (call) => call.name === EXPLORE_READONLY_TOOL_NAME, + ); + const otherImmediate = immediate.filter( + (call) => call.name !== EXPLORE_READONLY_TOOL_NAME, + ); + // 并行预取只读工具(事件仍按顺序发出) const prefetched = new Map(); - const parallelCalls = immediate.filter( + const parallelCalls = otherImmediate.filter( (call) => PARALLEL_SYNC_TOOLS.has(call.name) && - call.name !== 'explore_readonly' && typeof tools.get(call.name)?.execute === 'function', ); if (parallelCalls.length > 0) { @@ -578,7 +597,7 @@ async function* executeRound( }); } - for (const call of immediate) { + for (const call of otherImmediate) { if (isCancelled()) return; yield* streamToolExecution({ call, @@ -596,6 +615,48 @@ async function* executeRound( } } + // explore 调用:允许多个并行(事件交错),完成后统一补 tool_result + if (exploreCalls.length > 0) { + const finals = new Map(); + const streams = exploreCalls.map((call) => + trackedExplore({ + call, + tools, + toolSet, + toolContext, + settings, + llm, + isCancelled, + signal, + finals, + }), + ); + yield* mergeAsyncIterators(streams); + + for (const call of exploreCalls) { + const final = finals.get(call.toolCallId) ?? { + summary: '查阅失败', + status: 'error' as const, + }; + // 父级 tool_result 的 result 是「摘要」——_explore_result_text 产出带 summary 的 + // JSON,再经显示格式化折叠为 summary 文本 + const display = formatToolResultForDisplay( + buildToolResult({ + ok: final.status === 'done', + tool: EXPLORE_READONLY_TOOL_NAME, + status: final.status === 'done' ? 'ok' : 'error', + summary: final.summary || '(无摘要)', + }), + ); + yield sse.toolResult(call.toolCallId, display); + messages.push({ + role: 'tool', + content: display, + toolCallId: call.toolCallId, + }); + } + } + if (asyncPending.length > 0) { // 派发前为每个调用各发一条 tool_start,再发 tool_pending 结束本轮 for (const call of asyncPending) { @@ -618,6 +679,173 @@ function toChatToolCall(call: ResolvedToolCall): ChatToolCall { return { id: call.toolCallId, name: call.name, args: call.arguments }; } +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** 构造子代理可用的内层工具(按白名单与父级工具集求交)。 */ +function buildInnerExploreTools(params: { + tools: Map; + toolSet: ReadonlySet; + clientContext: ClientContextLike | null; +}): Map { + const includeAnnotations = shouldIncludeAnnotationReads( + params.clientContext as { workMode?: string | null } | null, + params.toolSet, + ); + const allowed = resolveExploreInnerNames(includeAnnotations); + + const inner = new Map(); + for (const [name, tool] of params.tools) { + // 与 Python 一致:必须是「父级工具集内的」且在白名单内 + if (!params.toolSet.has(name)) continue; + if (!allowed.has(name)) continue; + if (EXPLORE_READONLY_FORBIDDEN_INNER.has(name)) continue; + inner.set(name, tool); + } + return inner; +} + +interface TrackedExploreParams { + call: ResolvedToolCall; + tools: Map; + toolSet: ReadonlySet; + toolContext: ToolContext; + settings: AgentSettings; + llm: LlmClient; + isCancelled: () => boolean; + signal?: AbortSignal; + finals: Map; +} + +/** + * 包装一次子代理执行。 + * + * 先发父级 `tool_start`(与基线一致,只发一次),再跑子代理循环, + * 最后把 summary/status 记入 `finals` 供调用方补 `tool_result`。 + */ +async function* trackedExplore( + params: TrackedExploreParams, +): AsyncGenerator { + const { call, finals } = params; + + yield sse.toolStart(call.toolCallId, call.name, call.arguments); + + let summary = ''; + let status: 'done' | 'error' = 'error'; + + try { + const innerTools = buildInnerExploreTools({ + tools: params.tools, + toolSet: params.toolSet, + clientContext: params.toolContext.clientContext, + }); + const innerToolSpecs = [...innerTools.values()] + .map(toToolSpec) + .sort((a, b) => (a.function.name < b.function.name ? -1 : 1)); + + const focusRaw = call.arguments?.focus_path; + const focusPath = + typeof focusRaw === 'string' && focusRaw.trim() ? focusRaw.trim() : null; + + for await (const event of streamExploreReadonly( + { + llm: params.llm, + settings: params.settings, + isCancelled: params.isCancelled, + signal: params.signal, + innerTools, + innerToolSpecs, + toolContext: params.toolContext, + }, + { + query: String(call.arguments?.query ?? ''), + focusPath, + parentToolId: call.toolCallId, + }, + )) { + if (event.type === 'subagent_done') { + summary = event.summary ?? ''; + status = event.status === 'done' ? 'done' : 'error'; + } + yield event; + } + } catch (err) { + summary = `查阅失败:${errMessage(err)}`; + status = 'error'; + yield sse.subagentDone({ + toolCallId: call.toolCallId, + summary, + status: 'error', + }); + } + + finals.set(call.toolCallId, { summary, status }); +} + +/** + * 合并多个异步事件流(交错产出)。 + * + * 对应 Python 的 `_merge_async_iterators`:用于多个 explore_readonly 并行时的 + * 事件交错。调用方应保证 `streams` 非空。 + */ +export async function* mergeAsyncIterators( + streams: Array>, +): AsyncGenerator { + if (streams.length === 0) return; + if (streams.length === 1) { + yield* streams[0]; + return; + } + + // 每个流一个消费任务,把事件投递到共享队列;流结束时投递 null 作为结束标记 + type Item = { value: T } | null; + const queue: Item[] = []; + let notify: (() => void) | null = null; + let remaining = streams.length; + let failure: unknown = null; + + const push = (item: Item): void => { + queue.push(item); + if (notify) { + const fn = notify; + notify = null; + fn(); + } + }; + + const pumps = streams.map(async (stream) => { + try { + for await (const value of stream) push({ value }); + } catch (err) { + failure = err; + } finally { + remaining -= 1; + push(null); + } + }); + + try { + while (remaining > 0 || queue.length > 0) { + if (queue.length === 0) { + // 等待下一个事件 + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + notify = resolve; + }); + continue; + } + const item = queue.shift() as Item; + if (item === null) continue; + yield item.value; + } + if (failure) throw failure; + } finally { + // 调用方提前退出(如取消)时,确保所有 pump 被回收 + await Promise.allSettled(pumps); + } +} + /** 执行单个工具并返回原始结果字符串。 */ async function invokeTool( call: ResolvedToolCall, diff --git a/src/main/agent/mcp/client.test.ts b/src/main/agent/mcp/client.test.ts new file mode 100644 index 0000000..6fd7cb8 --- /dev/null +++ b/src/main/agent/mcp/client.test.ts @@ -0,0 +1,255 @@ +/** + * @jest-environment node + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { + cacheKey, + clearMcpToolsCache, + inferMcpCapability, + localConnection, + remoteConnection, + shouldExposeMcpTool, + stringifyMcpResult, +} from './client'; + +beforeEach(() => { + clearMcpToolsCache(); +}); + +describe('cacheKey:连接指纹', () => { + it('同 URL 与 transport 生成同一键', () => { + const a = cacheKey({ url: 'http://x/mcp', transport: 'streamable_http' }); + const b = cacheKey({ url: 'http://x/mcp', transport: 'streamable_http' }); + expect(a).toBe(b); + }); + + it('transport 不同则键不同', () => { + const a = cacheKey({ url: 'http://x/mcp', transport: 'streamable_http' }); + const b = cacheKey({ url: 'http://x/mcp', transport: 'sse' }); + expect(a).not.toBe(b); + }); + + it('headers 顺序不影响键(排序后比较)', () => { + const a = cacheKey({ + url: 'http://x/mcp', + transport: 'streamable_http', + headers: { B: '2', A: '1' }, + }); + const b = cacheKey({ + url: 'http://x/mcp', + transport: 'streamable_http', + headers: { A: '1', B: '2' }, + }); + expect(a).toBe(b); + }); + + it('headers 值不同则键不同', () => { + const a = cacheKey({ + url: 'http://x/mcp', + transport: 'streamable_http', + headers: { Authorization: 'Bearer a' }, + }); + const b = cacheKey({ + url: 'http://x/mcp', + transport: 'streamable_http', + headers: { Authorization: 'Bearer b' }, + }); + expect(a).not.toBe(b); + }); + + it('无 headers 与空 headers 等价', () => { + const a = cacheKey({ url: 'http://x', transport: 'sse' }); + const b = cacheKey({ url: 'http://x', transport: 'sse', headers: {} }); + expect(a).toBe(b); + }); +}); + +describe('localConnection:本机连接参数', () => { + it('端点固定拼 /mcp', () => { + expect(localConnection('http://127.0.0.1:1234').url).toBe( + 'http://127.0.0.1:1234/mcp', + ); + }); + + it('URL 末尾斜杠不会产生双斜杠', () => { + expect(localConnection('http://127.0.0.1:1234/').url).toBe( + 'http://127.0.0.1:1234/mcp', + ); + }); + + it('传输固定为 streamable_http', () => { + expect(localConnection('http://x').transport).toBe('streamable_http'); + }); + + it('有 token 时注入 Bearer 头', () => { + const conn = localConnection('http://x', 'tok-123'); + expect(conn.headers?.Authorization).toBe('Bearer tok-123'); + }); + + it('空白 token 不注入头', () => { + expect(localConnection('http://x', ' ').headers).toBeUndefined(); + expect(localConnection('http://x', null).headers).toBeUndefined(); + }); +}); + +describe('remoteConnection:远程连接参数', () => { + it('URL 原样使用(不拼 /mcp)', () => { + const conn = remoteConnection({ + id: 'a', + url: 'https://example.com/api/mcp', + transport: 'streamable_http', + headers: {}, + disabledTools: [], + }); + expect(conn.url).toBe('https://example.com/api/mcp'); + }); + + it('按配置选择传输协议', () => { + const sse = remoteConnection({ + id: 'a', + url: 'https://x', + transport: 'sse', + headers: {}, + disabledTools: [], + }); + expect(sse.transport).toBe('sse'); + }); + + it('headers 透传', () => { + const conn = remoteConnection({ + id: 'a', + url: 'https://x', + transport: 'streamable_http', + headers: { 'X-Api-Key': 'k' }, + disabledTools: [], + }); + expect(conn.headers).toEqual({ 'X-Api-Key': 'k' }); + }); + + it('无 headers 时不设置该字段', () => { + const conn = remoteConnection({ + id: 'a', + url: 'https://x', + transport: 'streamable_http', + headers: {}, + disabledTools: [], + }); + expect(conn.headers).toBeUndefined(); + }); +}); + +describe('shouldExposeMcpTool:记忆工具与模式', () => { + it('记忆关闭时不暴露 memory_*', () => { + expect( + shouldExposeMcpTool('memory_read', { + workspaceMemoryEnabled: false, + agentMode: 'annotation', + }), + ).toBe(false); + }); + + it('记忆关闭不影响 read_agent_skill', () => { + expect( + shouldExposeMcpTool('read_agent_skill', { + workspaceMemoryEnabled: false, + agentMode: 'annotation', + }), + ).toBe(true); + }); + + it('记忆开启 + 标注模式:全部 memory_* 可用', () => { + for (const name of ['memory_read', 'memory_write', 'memory_create']) { + expect( + shouldExposeMcpTool(name, { + workspaceMemoryEnabled: true, + agentMode: 'annotation', + }), + ).toBe(true); + } + }); + + it('记忆开启 + Ask 模式:只放行 memory_read', () => { + expect( + shouldExposeMcpTool('memory_read', { + workspaceMemoryEnabled: true, + agentMode: 'chat', + }), + ).toBe(true); + expect( + shouldExposeMcpTool('memory_write', { + workspaceMemoryEnabled: true, + agentMode: 'chat', + }), + ).toBe(false); + expect( + shouldExposeMcpTool('memory_create', { + workspaceMemoryEnabled: true, + agentMode: null, + }), + ).toBe(false); + }); + + it('非记忆工具始终放行', () => { + expect( + shouldExposeMcpTool('tavily_search', { + workspaceMemoryEnabled: false, + agentMode: 'chat', + }), + ).toBe(true); + }); +}); + +describe('inferMcpCapability:能力推断', () => { + it('当前一律返回 null(本地 MCP 工具与内置能力不冲突)', () => { + for (const name of [ + 'memory_read', + 'read_agent_skill', + 'list_agent_skill_files', + 'grep_workspace', + 'anything', + ]) { + expect(inferMcpCapability(name)).toBeNull(); + } + }); +}); + +describe('stringifyMcpResult:结果收拢', () => { + it('null / undefined 收成空串', () => { + expect(stringifyMcpResult(null)).toBe(''); + expect(stringifyMcpResult(undefined)).toBe(''); + }); + + it('字符串原样返回', () => { + expect(stringifyMcpResult('hello')).toBe('hello'); + }); + + it('拼接 text 内容块(对齐 Python 的 stringify_tool_output)', () => { + expect( + stringifyMcpResult({ + content: [ + { type: 'text', text: 'line1' }, + { type: 'text', text: 'line2' }, + ], + }), + ).toBe('line1\nline2'); + }); + + it('过滤空文本部件', () => { + expect( + stringifyMcpResult({ + content: [{ type: 'text', text: 'a' }, { type: 'text', text: '' }], + }), + ).toBe('a'); + }); + + it('无文本部件时退化为结构化 JSON(如图片内容块)', () => { + const result = stringifyMcpResult({ + content: [{ type: 'image', data: 'xxx', mimeType: 'image/png' }], + }); + expect(JSON.parse(result)).toMatchObject({ content: [{ type: 'image' }] }); + }); + + it('无 content 字段时序列化整个对象', () => { + expect(stringifyMcpResult({ foo: 'bar' })).toBe('{"foo":"bar"}'); + }); +}); diff --git a/src/main/agent/mcp/client.ts b/src/main/agent/mcp/client.ts new file mode 100644 index 0000000..33be2c8 --- /dev/null +++ b/src/main/agent/mcp/client.ts @@ -0,0 +1,459 @@ +/** + * MCP 客户端:连接本机 Electron MCP Server 与用户配置的远程 MCP,动态发现工具。 + * + * 移植自 `vendor/local-agent/app/agent/tools/mcp_client.py`。 + * + * 用**仓库已依赖**的 `@modelcontextprotocol/sdk`(`src/main/mcp/server.ts` 用的是它的 + * 服务端部分)替换 Python 的 `langchain-mcp-adapters`——两者底层同源,因此是净收益。 + * + * 三条必须保真的行为: + * + * 1. **TTL 缓存 + stale-while-revalidate**。缓存键为 `(url, transport, 排序后 headers)`。 + * 命中但已过期时,本轮**先用旧列表**、后台重建连,下一轮才生效——避免让首 token + * 等在重建连上。只有应用启动后的首轮(无缓存)才同步等待发现。 + * 2. **单台失败隔离**。并行建连,任一台失败只跳过该台,不影响其它 server 与主流程。 + * 3. **名字去重**。`_infer_mcp_capability` 当前对全部本地 MCP 工具返回 null, + * 因此实际去重靠「名字已在前面 server 出现过则跳过」。 + * + * 与 Python 的一处实现差异:工具调用**按需建连**(每次 `callTool` 独立握手), + * 不长期持有连接。这样无需管理连接生命周期与失效重连,代价是每次调用多一次 + * `initialize` 往返——对 HTTP 传输可接受,且换来无状态、无泄漏。 + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { ToolDefinition } from '../tools/registry'; +import { z } from 'zod'; + +/** 连接参数。 */ +export interface McpConnection { + url: string; + transport: 'streamable_http' | 'sse'; + headers?: Record; + /** 连接超时(毫秒);发现用 60_000,probe 用 30_000。 */ + timeoutMs?: number; + /** SSE 读取超时(毫秒);发现用 300_000,probe 用 60_000。 */ + sseReadTimeoutMs?: number; +} + +/** 远程 server 的输入形态(来自 client_context.mcp_servers)。 */ +export interface McpServerInput { + id: string; + url: string; + transport: 'streamable_http' | 'sse'; + headers: Record; + disabledTools: string[]; +} + +/** 发现阶段的超时配置(对齐 Python 的 timeout / sse_read_timeout)。 */ +const DISCOVER_TIMEOUT_MS = 60_000; +const DISCOVER_SSE_READ_TIMEOUT_MS = 300_000; +const PROBE_TIMEOUT_MS = 30_000; +const PROBE_SSE_READ_TIMEOUT_MS = 60_000; + +/** 默认 TTL(秒);<=0 关闭缓存。 */ +export const DEFAULT_MCP_TTL_SECONDS = 1800; + +// ── 缓存 ──────────────────────────────────────────────────────────── + +interface CacheEntry { + tools: DiscoveredTool[]; + /** 单调递增的过期时间(毫秒)。 */ + expiresAt: number; +} + +/** 已发现的工具(含按需建连的调用器)。 */ +export interface DiscoveredTool { + name: string; + description: string; + /** MCP 原生 JSON Schema,直接透传。 */ + parameters: Record; + invoke: (args: Record) => Promise; +} + +const toolsCache = new Map(); +/** 正在后台刷新的连接指纹,避免同一 server 并发重复刷新。 */ +const refreshing = new Set(); +/** 后台刷新任务,持有强引用避免被回收。 */ +const refreshTasks = new Set>(); + +/** 缓存键:url + transport + 排序后的 headers。 */ +export function cacheKey(conn: McpConnection): string { + const headers = Object.entries(conn.headers ?? {}) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${k}:${v}`) + .join('|'); + return `${conn.url}\u0000${conn.transport}\u0000${headers}`; +} + +/** 清空缓存与在途刷新(测试或强制刷新用)。 */ +export function clearMcpToolsCache(): void { + toolsCache.clear(); + refreshing.clear(); + refreshTasks.clear(); +} + +// ── 连接与调用 ────────────────────────────────────────────────────── + +/** 建立客户端并初始化。 */ +async function connect(conn: McpConnection): Promise { + const client = new Client( + { name: 'lr-agent-local', version: '1.0.0' }, + { capabilities: {} }, + ); + + const transport = + conn.transport === 'sse' + ? new SSEClientTransport(new URL(conn.url), { + requestInit: + conn.headers && Object.keys(conn.headers).length > 0 + ? { headers: conn.headers } + : undefined, + }) + : new StreamableHTTPClientTransport(new URL(conn.url), { + requestInit: + conn.headers && Object.keys(conn.headers).length > 0 + ? { headers: conn.headers } + : undefined, + }); + + await client.connect(transport); + return client; +} + +/** + * 发现单个 server 的工具。 + * + * 失败时抛异常,由调用方决定跳过(单台失败隔离)。 + */ +async function discoverTools( + conn: McpConnection, + dsn: string, +): Promise { + const client = await connect(conn); + try { + const listed = await client.listTools(); + return (listed.tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description ?? '', + // MCP 的 inputSchema 就是 JSON Schema,直接透传 + parameters: (tool.inputSchema as Record) ?? { + type: 'object', + properties: {}, + }, + invoke: async (args: Record): Promise => { + // 按需建连:避免长期持有连接带来的生命周期与重连问题 + const callClient = await connect(conn); + try { + const result = await callClient.callTool({ + name: tool.name, + arguments: args, + }); + return stringifyMcpResult(result); + } finally { + await callClient.close().catch(() => { + /* 关闭失败不阻断 */ + }); + } + }, + })); + } finally { + await client.close().catch(() => { + /* 关闭失败不阻断 */ + }); + void dsn; + } +} + +/** + * 把 MCP 工具调用结果收成字符串。 + * + * 对齐 Python `stringify_tool_output` 对内容块数组的处理:拼接 text 部件。 + */ +export function stringifyMcpResult(result: unknown): string { + if (result === null || result === undefined) return ''; + if (typeof result === 'string') return result; + + if (typeof result === 'object') { + const content = (result as { content?: unknown }).content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const item of content) { + if (typeof item === 'string') { + parts.push(item); + } else if (item && typeof item === 'object') { + const record = item as Record; + if (record.type === 'text') parts.push(String(record.text ?? '')); + else if (typeof record.text === 'string') parts.push(record.text); + } + } + const joined = parts.filter((p) => p.length > 0).join('\n'); + if (joined) return joined; + // 无文本部件时退化为结构化 JSON(例如 image/audio 内容块) + return JSON.stringify(result); + } + return JSON.stringify(result); + } + + return String(result); +} + +// ── 连接参数构造 ──────────────────────────────────────────────────── + +/** + * 本机 Electron MCP Server 连接参数。 + * + * 端点固定拼 `/mcp`(见 `src/main/mcp/server.ts`);token 作为 Bearer 头注入。 + */ +export function localConnection( + mcpServerUrl: string, + token?: string | null, +): McpConnection { + const conn: McpConnection = { + url: `${mcpServerUrl.replace(/\/+$/, '')}/mcp`, + transport: 'streamable_http', + timeoutMs: DISCOVER_TIMEOUT_MS, + sseReadTimeoutMs: DISCOVER_SSE_READ_TIMEOUT_MS, + }; + const trimmed = (token ?? '').trim(); + if (trimmed) conn.headers = { Authorization: `Bearer ${trimmed}` }; + return conn; +} + +/** 远程 MCP Server 连接参数:URL **原样使用**(不拼 /mcp),协议按配置。 */ +export function remoteConnection(server: McpServerInput): McpConnection { + const transport = + server.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const); + const conn: McpConnection = { + url: (server.url ?? '').trim(), + transport, + timeoutMs: DISCOVER_TIMEOUT_MS, + sseReadTimeoutMs: DISCOVER_SSE_READ_TIMEOUT_MS, + }; + const headers = server.headers ?? {}; + if (Object.keys(headers).length > 0) conn.headers = { ...headers }; + return conn; +} + +// ── 工具暴露策略 ──────────────────────────────────────────────────── + +/** + * 是否把某个 MCP 工具暴露给 Agent。 + * + * - 工作区记忆关闭时不暴露 `memory_*`(`read_agent_skill` 不受影响) + * - Ask 模式(`agent_mode !== "annotation"`)只暴露 `memory_read`, + * 禁止 `memory_write` / `memory_create` + */ +export function shouldExposeMcpTool( + toolName: string, + options: { workspaceMemoryEnabled: boolean; agentMode?: string | null }, +): boolean { + if (toolName.startsWith('memory_')) { + if (!options.workspaceMemoryEnabled) return false; + if (options.agentMode !== 'annotation') return toolName === 'memory_read'; + return true; + } + return true; +} + +/** + * 从 MCP 工具名推断能力类型。 + * + * 当前本地 MCP 工具(`memory_*` / `read_agent_skill` / `list_agent_skill_files`) + * 与内置能力不冲突,一律返回 null;将来若暴露与 canonical 重叠的名字, + * 在此登记以免双轨注入。 + */ +export function inferMcpCapability(_toolName: string): string | null { + return null; +} + +// ── 主流程 ────────────────────────────────────────────────────────── + +export interface LoadMcpToolsParams { + localServerUrl?: string | null; + localServerToken?: string | null; + remoteServers?: McpServerInput[]; + /** 已存在的能力集合(保留参数,当前去重实际按名字)。 */ + existingCapabilities?: ReadonlySet; + ttlSeconds?: number; + onLog?: (level: 'info' | 'warn', message: string) => void; +} + +/** + * 连接本机 + 远程 MCP Server,动态发现并按名字去重后返回工具定义。 + * + * 返回的 `ToolDefinition` 可直接并入工具集(`rawParameters` 携带 MCP 原生 schema)。 + */ +export async function loadMcpToolsFromServers( + params: LoadMcpToolsParams, +): Promise { + const ttlSeconds = params.ttlSeconds ?? DEFAULT_MCP_TTL_SECONDS; + const log = params.onLog ?? (() => {}); + + const connections = new Map(); + const disabledByConn = new Map>(); + + const localUrl = (params.localServerUrl ?? '').trim(); + if (localUrl) { + connections.set( + 'lr-agent-local', + localConnection(localUrl, params.localServerToken), + ); + } + + const remotes = params.remoteServers ?? []; + remotes.forEach((server, index) => { + const conn = remoteConnection(server); + if (!conn.url) return; + const name = `mcp-remote-${server.id || index}`; + connections.set(name, conn); + const disabled = new Set( + (server.disabledTools ?? []).filter( + (n) => typeof n === 'string' && n.trim().length > 0, + ), + ); + disabledByConn.set(name, disabled); + }); + + if (connections.size === 0) return []; + + const now = Date.now(); + const rawByConn = new Map(); + const pending = new Map(); + + for (const [name, conn] of connections) { + const key = cacheKey(conn); + const cached = ttlSeconds > 0 ? toolsCache.get(key) : undefined; + if (!cached) { + pending.set(name, conn); + continue; + } + rawByConn.set(name, cached.tools); + if (cached.expiresAt <= now) { + // 过期但可用:先用旧列表,后台刷新下一轮生效 + scheduleRefresh(name, conn, ttlSeconds, log); + } + } + + if (pending.size > 0) { + const entries = [...pending.entries()]; + const results = await Promise.allSettled( + entries.map(([name, conn]) => discoverTools(conn, name)), + ); + entries.forEach(([name, conn], index) => { + const result = results[index]; + if (result.status === 'rejected') { + log( + 'warn', + `MCP: 连接 ${name} (${conn.url}) 失败,跳过该 server:${String(result.reason)}`, + ); + return; + } + rawByConn.set(name, result.value); + if (ttlSeconds > 0) { + toolsCache.set(cacheKey(conn), { + tools: result.value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + } + }); + } + + // 顺序遍历(保持与 Python 一致的 server 顺序),按名字去重 + const seenNames = new Set(); + const out: ToolDefinition[] = []; + + for (const name of connections.keys()) { + let tools = rawByConn.get(name); + if (!tools) continue; + + const disabled = disabledByConn.get(name); + if (disabled && disabled.size > 0) { + tools = tools.filter((tool) => !disabled.has(tool.name)); + } + + for (const tool of tools) { + const capability = inferMcpCapability(tool.name); + if (capability !== null && params.existingCapabilities?.has(capability)) { + continue; + } + if (seenNames.has(tool.name)) continue; + seenNames.add(tool.name); + + out.push({ + name: tool.name, + description: tool.description, + kind: 'sync', + // MCP 不做 zod 校验(schema 已是权威),用宽松透传 + argsSchema: z.record(z.string(), z.unknown()), + rawParameters: tool.parameters, + execute: (args) => tool.invoke(args), + }); + } + } + + return out; +} + +/** 后台重新发现(不阻塞本轮请求);失败沿用旧条目。 */ +function scheduleRefresh( + name: string, + conn: McpConnection, + ttlSeconds: number, + log: (level: 'info' | 'warn', message: string) => void, +): void { + const key = cacheKey(conn); + if (refreshing.has(key)) return; + refreshing.add(key); + + const task = (async () => { + try { + const tools = await discoverTools(conn, name); + if (ttlSeconds > 0) { + toolsCache.set(key, { + tools, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + } + log('info', `MCP: 后台刷新 ${name} 完成,${tools.length} 个工具`); + } catch (err) { + log( + 'warn', + `MCP: 后台刷新 ${name} (${conn.url}) 失败,沿用旧工具列表:${String(err)}`, + ); + } finally { + refreshing.delete(key); + } + })(); + + refreshTasks.add(task); + void task.finally(() => refreshTasks.delete(task)); +} + +/** + * 连接单个 MCP Server 并列出工具名(配置页「测试连接」用)。 + * + * 失败时抛异常,由调用方转为错误响应。 + */ +export async function probeMcpTools(params: { + url: string; + transport: 'streamable_http' | 'sse'; + headers?: Record; +}): Promise<{ name: string; description: string }[]> { + const conn: McpConnection = { + url: params.url.trim(), + transport: + params.transport === 'sse' ? 'sse' : 'streamable_http', + timeoutMs: PROBE_TIMEOUT_MS, + sseReadTimeoutMs: PROBE_SSE_READ_TIMEOUT_MS, + }; + const headers = params.headers ?? {}; + if (Object.keys(headers).length > 0) conn.headers = { ...headers }; + + const tools = await discoverTools(conn, 'mcp-probe'); + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + })); +} diff --git a/src/main/agent/subagent/exploreReadonly.ts b/src/main/agent/subagent/exploreReadonly.ts new file mode 100644 index 0000000..1e2db04 --- /dev/null +++ b/src/main/agent/subagent/exploreReadonly.ts @@ -0,0 +1,408 @@ +/** + * 只读查阅子代理:嵌套短循环,产出 `subagent_*` SSE,最终回中文摘要。 + * + * 移植自 `vendor/local-agent/app/agent/assist/explore_readonly.py`。 + * + * 与主循环的三处关键差异(不要"顺手统一"): + * + * 1. **不走父级的去重与阶段门禁**。子代理只有白名单校验:不在内层工具集内、 + * 或命中禁用名单,一律按 `blocked` 处理。 + * 2. **回灌原文而非显示文本**。`allowed` 调用把工具结果**原文**写进 `ToolMessage`; + * 但发给前端的 `subagent_tool_result.result` 是经显示格式化并截断到 4000 字符的。 + * `blocked` 调用则相反:`ToolMessage` 与显示值都用 `buildToolResult` 的完整 JSON。 + * 3. **收尾摘要轮不带工具**(`self.llm` 而非 `llm_bound`),防止它继续调工具。 + * + * 内层工具集与禁用名单(与 Python 一致): + * - BASE:grep/glob/list/read_workspace_file/read_document_file/ + * describe_client_context/get_lr_agent_help + * - ANNOTATION:read_file_annotation/describe_annotation_project(视父级工具集而定) + * - FORBIDDEN:explore_readonly、auto_annotate、mutate_annotation、四个写文件工具、 + * memory_write、memory_create、read_image_for_vision(**注意含记忆工具**) + */ + +import { sse, type StreamEventPayload } from '../sse'; +import type { ChatMessage, LlmClient, ToolSpec } from '../llm/client'; +import { + ToolCallAccumulator, +} from '../llm/client'; +import { buildToolResult, formatToolResultForDisplay } from '../tools/result'; +import { resolveRoundToolCalls, type ResolvedToolCall } from '../loop/toolInvocation'; +import type { ToolDefinition, ToolContext } from '../tools/registry'; + +export const EXPLORE_READONLY_TOOL_NAME = 'explore_readonly'; + +const EXPLORE_READONLY_BASE_INNER: ReadonlySet = new Set([ + 'grep_workspace', + 'glob_workspace', + 'list_workspace_directory', + 'read_workspace_file', + 'read_document_file', + 'describe_client_context', + 'get_lr_agent_help', +]); + +const EXPLORE_READONLY_ANNOTATION_INNER: ReadonlySet = new Set([ + 'read_file_annotation', + 'describe_annotation_project', +]); + +export const EXPLORE_READONLY_FORBIDDEN_INNER: ReadonlySet = new Set([ + EXPLORE_READONLY_TOOL_NAME, + 'auto_annotate', + 'mutate_annotation', + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'memory_write', + 'memory_create', + 'read_image_for_vision', +]); + +/** 子代理系统提示词(行为契约,需原样保留)。 */ +export const SUBAGENT_SYSTEM = `你是只读查阅子代理。只用只读工具查看代码、文档或已有标注,然后用简洁中文写一份摘要。 +禁止改文件、写标注、调用 explore_readonly,也禁止声称已经改过文件或完成标注。 +调用工具前先用一两句中文说明正在查什么。 +查到足够信息后直接给出结论,不要输出工具伪代码。`; + +/** 内层工具结果回灌上限。 */ +const INNER_RESULT_LIMIT = 4000; + +/** 允许的内层工具名集合。 */ +export function resolveExploreInnerNames(includeAnnotationReads: boolean): Set { + const names = new Set(EXPLORE_READONLY_BASE_INNER); + if (includeAnnotationReads) { + for (const name of EXPLORE_READONLY_ANNOTATION_INNER) names.add(name); + } + for (const name of EXPLORE_READONLY_FORBIDDEN_INNER) names.delete(name); + return names; +} + +/** + * 是否把标注只读工具纳入子代理。 + * + * 编辑器模式一律排除;无父级工具集时默认包含;否则取决于父级工具集是否含标注只读工具。 + */ +export function shouldIncludeAnnotationReads( + clientContext: { workMode?: string | null } | null, + parentToolNames: ReadonlySet | null, +): boolean { + if (clientContext && clientContext.workMode === 'editor') return false; + if (parentToolNames === null) return true; + for (const name of EXPLORE_READONLY_ANNOTATION_INNER) { + if (parentToolNames.has(name)) return true; + } + return false; +} + +/** 截断内层结果(与 Python 的 `_truncate_result` 文案一致)。 */ +export function truncateInnerResult( + text: string, + limit = INNER_RESULT_LIMIT, +): string { + if (text.length <= limit) return text; + return `${text.slice(0, limit)}…\n[已截断,共 ${text.length} 字符]`; +} + +export interface ExploreRunnerParams { + llm: LlmClient; + settings: { subagentMaxToolRounds: number }; + isCancelled: () => boolean; + signal?: AbortSignal; + /** 内层工具(已按父级工具集过滤后的定义表)。 */ + innerTools: Map; + /** 内层工具声明。 */ + innerToolSpecs: ToolSpec[]; + /** 内层执行上下文(复用父级的 clientContext / imageService / settings)。 */ + toolContext: ToolContext; +} + +export interface ExploreStreamParams { + query: string; + focusPath: string | null; + parentToolId: string; +} + +/** + * 运行一次只读查阅。 + * + * 产出 `subagent_start` / `subagent_text_delta` / `subagent_tool_start` / + * `subagent_tool_result` / `subagent_done`。 + */ +export async function* streamExploreReadonly( + params: ExploreRunnerParams, + call: ExploreStreamParams, +): AsyncGenerator { + const query = (call.query ?? '').trim(); + const focus = (call.focusPath ?? '').trim() || null; + + yield sse.subagentStart({ + toolCallId: call.parentToolId, + query, + focusPath: focus, + }); + + if (!query) { + yield sse.subagentDone({ + toolCallId: call.parentToolId, + summary: '查阅任务缺少 query。', + status: 'error', + }); + return; + } + + if (params.innerTools.size === 0) { + yield sse.subagentDone({ + toolCallId: call.parentToolId, + summary: '没有可用的只读查阅工具。', + status: 'error', + }); + return; + } + + let userText = `查阅任务:${query}`; + if (focus) userText = `${userText}\n优先关注路径:${focus}`; + + const messages: ChatMessage[] = [ + { role: 'system', content: SUBAGENT_SYSTEM }, + { role: 'user', content: userText }, + ]; + + const maxRounds = Math.max(1, params.settings.subagentMaxToolRounds || 8); + let summary = ''; + let status: 'done' | 'error' = 'done'; + let stopped = false; + /** + * 是否可以进入收尾摘要轮。 + * + * 对应 Python 的 `last_had_tools`(`for...else` 语义):只有**自然走完所有轮次** + * 才为真;中途因「本轮无 tool call」退出时置 false(已拿到结论,无需再逼摘要)。 + * 异常与取消分支由 `status` / `stopped` 守卫拦住,不依赖本标志。 + */ + let canWrapUp = true; + + for (let roundIdx = 0; roundIdx < maxRounds; roundIdx += 1) { + if (params.isCancelled()) { + status = 'error'; + summary = summary || '已停止'; + stopped = true; + break; + } + + const accumulator = new ToolCallAccumulator(); + let pendingText = ''; + let gatheredAny = false; + + try { + for await (const delta of params.llm.streamChat({ + messages, + tools: params.innerToolSpecs, + toolChoice: 'auto', + signal: params.signal, + })) { + if (params.isCancelled()) { + status = 'error'; + summary = summary || '已停止'; + stopped = true; + break; + } + if (delta.content) { + pendingText += delta.content; + gatheredAny = true; + yield sse.subagentTextDelta(call.parentToolId, delta.content); + } + if (delta.toolCallChunks?.length) { + accumulator.push(delta.toolCallChunks); + gatheredAny = true; + } + } + } catch (err) { + status = 'error'; + summary = `查阅失败:${errMessage(err)}`; + break; + } + + if (stopped) break; + if (!gatheredAny) break; + + // 子代理不做父级的去重/门禁,只按白名单分类 + const resolved = resolveRoundToolCalls({ + apiToolCalls: accumulator.resolve(), + completedTools: new Set(), + }); + + const allowed: ResolvedToolCall[] = []; + const blocked: ResolvedToolCall[] = []; + for (const c of resolved) { + if ( + !params.innerTools.has(c.name) || + EXPLORE_READONLY_FORBIDDEN_INNER.has(c.name) + ) { + blocked.push(c); + } else { + allowed.push(c); + } + } + + if (allowed.length === 0 && blocked.length === 0) { + summary = pendingText.trim(); + canWrapUp = false; + break; + } + + messages.push({ + role: 'assistant', + content: pendingText, + toolCalls: [...blocked, ...allowed].map((c) => ({ + id: c.toolCallId, + name: c.name, + args: c.arguments, + })), + }); + + // 被禁调用:完整 JSON 同时用于 ToolMessage 与显示值 + for (const c of blocked) { + yield sse.subagentToolStart({ + toolCallId: call.parentToolId, + innerToolCallId: c.toolCallId, + name: c.name, + args: c.arguments, + }); + const blockedText = buildToolResult({ + ok: false, + tool: c.name, + status: 'error', + summary: '此工具不可用于只读查阅。', + }); + yield sse.subagentToolResult({ + toolCallId: call.parentToolId, + innerToolCallId: c.toolCallId, + result: formatToolResultForDisplay(blockedText), + status: 'error', + }); + messages.push({ + role: 'tool', + content: blockedText, + toolCallId: c.toolCallId, + }); + } + + for (const c of allowed) { + if (params.isCancelled()) { + status = 'error'; + summary = summary || '已停止'; + stopped = true; + break; + } + + yield sse.subagentToolStart({ + toolCallId: call.parentToolId, + innerToolCallId: c.toolCallId, + name: c.name, + args: c.arguments, + }); + + const resultText = await invokeInner(c, params.innerTools, params.toolContext); + const display = truncateInnerResult(formatToolResultForDisplay(resultText)); + + let stepStatus: 'done' | 'error' = 'done'; + try { + const parsed = JSON.parse(resultText); + if (parsed && typeof parsed === 'object' && parsed.ok === false) { + stepStatus = 'error'; + } + } catch { + /* 非 JSON 结果保持 done */ + } + + yield sse.subagentToolResult({ + toolCallId: call.parentToolId, + innerToolCallId: c.toolCallId, + result: display, + status: stepStatus, + }); + // 注意:回灌**原文**,不是 display + messages.push({ + role: 'tool', + content: resultText, + toolCallId: c.toolCallId, + }); + } + + if (stopped) break; + } + + if (canWrapUp && !stopped && status === 'done' && !summary) { + // 轮次预算耗尽且尚未有结论:用**不带工具**的调用逼一份摘要 + try { + if (params.isCancelled()) { + status = 'error'; + summary = '已停止'; + } else { + let pending = ''; + messages.push({ + role: 'user', + content: '请用中文给出查阅摘要,不要再调用工具。', + }); + for await (const delta of params.llm.streamChat({ + messages, + signal: params.signal, + })) { + if (params.isCancelled()) { + status = 'error'; + summary = '已停止'; + pending = ''; + break; + } + if (delta.content) { + pending += delta.content; + yield sse.subagentTextDelta(call.parentToolId, delta.content); + } + } + if (status === 'done') summary = pending.trim(); + } + } catch (err) { + summary = summary || `已达查阅轮次上限:${errMessage(err)}`; + } + } + + if (!summary) { + summary = status === 'error' ? '查阅失败' : '未找到可用结论。'; + } + + yield sse.subagentDone({ + toolCallId: call.parentToolId, + summary, + status, + }); +} + +/** 执行内层工具;未知工具与异常都转成统一失败 JSON。 */ +async function invokeInner( + call: ResolvedToolCall, + tools: Map, + ctx: ToolContext, +): Promise { + const tool = tools.get(call.name); + if (!tool || typeof tool.execute !== 'function') { + return buildToolResult({ + ok: false, + tool: call.name, + status: 'error', + summary: `未知工具: ${call.name}`, + }); + } + try { + return await tool.execute(call.arguments, ctx); + } catch (err) { + return buildToolResult({ + ok: false, + tool: call.name, + status: 'error', + summary: `工具执行失败: ${errMessage(err)}`, + }); + } +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index 0882675..b1dfcbe 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -72,6 +72,13 @@ export interface ToolDefinition { kind: ToolKind; /** 参数校验器;同时用于生成 LLM 侧 JSON Schema。 */ argsSchema: z.ZodType; + /** + * 已有的 JSON Schema。 + * + * MCP 动态发现的工具自带 schema,直接透传比经 zod 往返更保真 + * (zod 无法无损表达 JSON Schema 的任意组合)。 + */ + rawParameters?: Record; /** * 执行体。`proposal` 与 `async` 类工具没有执行体——前者由调度层生成提案, * 后者交给 Electron 执行。 @@ -114,7 +121,11 @@ export function toToolSpec(tool: ToolDefinition): ToolSpec { function: { name: tool.name, description: tool.description, - parameters: toParametersSchema(tool.argsSchema), + // MCP 动态工具自带 schema,直接透传 + parameters: + tool.rawParameters !== undefined + ? tool.rawParameters + : toParametersSchema(tool.argsSchema), }, }; } From c6fb72ed62bda8a626bba8f99f247bc94004ca30 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 02:35:34 +0800 Subject: [PATCH 08/20] =?UTF-8?q?feat(agent):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E7=BC=96=E6=8E=92=E4=B8=89=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=EF=BC=88llm-generate=20/=20mutation-prepare=20/=20=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E6=8A=A5=E5=91=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 annotate 模块:公共基础(extractJsonObject 对齐 Python 的三步解析、invokeJsonModel 刻意不用 response_format、Unicode 孤立代理清洗、LLM 错误分流)、zod 重建的四个请求 schema、mutation-prepare(含 Pydantic model_dump 等价实现:输出全部 28 个字段并把未设值者置 null)、llm-generate(图片三分支:加载失败 400 / 路径优先 base64 兜底 / 压缩失败回退原图)、质量报告流式撰写(payload 512KB 校验 + 48k 截断,异常时发 error 事件)。扩展 AgentSettings 补全标注相关配置项。主进程测试 422/422。 Co-authored-by: Cursor --- src/main/agent/annotate/annotate.test.ts | 426 +++++++++++++++++++++++ src/main/agent/annotate/common.ts | 221 ++++++++++++ src/main/agent/annotate/generate.ts | 154 ++++++++ src/main/agent/annotate/mutation.ts | 351 +++++++++++++++++++ src/main/agent/annotate/quality.ts | 115 ++++++ src/main/agent/annotate/schemas.ts | 135 +++++++ src/main/agent/config.ts | 27 ++ src/main/agent/handlers/annotation.ts | 211 +++++++++++ src/main/agent/handlers/index.ts | 11 + src/main/agent/llm/client.ts | 13 +- 10 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 src/main/agent/annotate/annotate.test.ts create mode 100644 src/main/agent/annotate/common.ts create mode 100644 src/main/agent/annotate/generate.ts create mode 100644 src/main/agent/annotate/mutation.ts create mode 100644 src/main/agent/annotate/quality.ts create mode 100644 src/main/agent/annotate/schemas.ts create mode 100644 src/main/agent/handlers/annotation.ts diff --git a/src/main/agent/annotate/annotate.test.ts b/src/main/agent/annotate/annotate.test.ts new file mode 100644 index 0000000..84bcf0e --- /dev/null +++ b/src/main/agent/annotate/annotate.test.ts @@ -0,0 +1,426 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { z } from 'zod'; +import { + AnnotateHttpError, + coerceNullStringFields, + extractJsonObject, + httpFromLlmError, + sanitizeJsonValue, + sanitizeUnicodeText, +} from './common'; +import { + dumpMutationOperation, + filterPaths, + labelNamesOf, +} from './mutation'; +import { contentToText } from './generate'; +import { + assertComposePayloadSize, + buildComposeMessages, + QUALITY_REPORT_COMPOSE_SYSTEM, +} from './quality'; +import { + formatZodError, + LlmGenerateRequestSchema, + MapDetectionBoxesRequestSchema, + MutationPrepareRequestSchema, + parseRequest, + QualityReportComposeRequestSchema, +} from './schemas'; + +describe('extractJsonObject:LLM 文本解析', () => { + it('解析纯 JSON 对象', () => { + expect(extractJsonObject('{"a": 1}')).toEqual({ a: 1 }); + }); + + it('剥掉 markdown 围栏', () => { + expect(extractJsonObject('```json\n{"a": 1}\n```')).toEqual({ a: 1 }); + }); + + it('剥掉无语言标注的围栏', () => { + expect(extractJsonObject('```\n{"a": 1}\n```')).toEqual({ a: 1 }); + }); + + it('从夹杂说明文字的响应中抓取 JSON', () => { + expect(extractJsonObject('好的,结果如下:\n{"a": 1}\n希望有帮助')).toEqual({ + a: 1, + }); + }); + + it('贪婪匹配到最后一个 }(尾部有游离 } 时会解析失败)', () => { + // Python 的 re.search(r"\{[\s\S]*\}") 同样是贪婪的,因此行为一致地返回空对象 + expect(extractJsonObject('{"a": {"b": 1}} 之后还有 }')).toEqual({}); + }); + + it('JSON 之后有说明文字时仍能解析', () => { + expect(extractJsonObject('结果如下:\n{"a": 1}')).toEqual({ a: 1 }); + }); + + it('嵌套对象直接解析成功', () => { + expect(extractJsonObject('{"a": {"b": 1}}')).toEqual({ a: { b: 1 } }); + }); + + it('数组响应返回空对象(只接受对象)', () => { + expect(extractJsonObject('[1, 2, 3]')).toEqual({}); + }); + + it('完全无 JSON 时返回空对象', () => { + expect(extractJsonObject('抱歉我无法完成')).toEqual({}); + }); + + it('空输入返回空对象', () => { + expect(extractJsonObject('')).toEqual({}); + }); + + it('JSON 语法错误时返回空对象而不抛异常', () => { + expect(extractJsonObject('{"a": }')).toEqual({}); + }); + + it('null 字面量返回空对象', () => { + expect(extractJsonObject('null')).toEqual({}); + }); +}); + +describe('coerceNullStringFields', () => { + it('把指定字段的 null 归一为空串', () => { + expect(coerceNullStringFields({ a: null, b: 'x' }, ['a'])).toEqual({ + a: '', + b: 'x', + }); + }); + + it('不触碰未列出的字段', () => { + expect(coerceNullStringFields({ a: null }, ['b'])).toEqual({ a: null }); + }); + + it('字段无值时保持缺失', () => { + const result = coerceNullStringFields({}, ['a']); + expect('a' in result).toBe(false); + }); + + it('空字段列表时原样返回', () => { + const input = { a: null }; + expect(coerceNullStringFields(input, [])).toBe(input); + }); +}); + +describe('sanitizeUnicodeText:孤立代理清洗', () => { + it('移除孤立高代理', () => { + expect(sanitizeUnicodeText('a\uD800b')).toBe('ab'); + }); + + it('移除孤立低代理', () => { + expect(sanitizeUnicodeText('a\uDC00b')).toBe('ab'); + }); + + it('保留合法代理对(emoji)', () => { + expect(sanitizeUnicodeText('a😀b')).toBe('a😀b'); + }); + + it('移除替换字符本身', () => { + expect(sanitizeUnicodeText('a\ufffdb')).toBe('ab'); + }); + + it('空输入返回空串', () => { + expect(sanitizeUnicodeText('')).toBe(''); + }); +}); + +describe('sanitizeJsonValue:递归清洗', () => { + it('清洗嵌套对象与数组中的字符串', () => { + expect( + sanitizeJsonValue({ a: ['x\uD800', { b: 'y\ufffd' }] }), + ).toEqual({ a: ['x', { b: 'y' }] }); + }); + + it('清洗键名', () => { + expect(sanitizeJsonValue({ 'k\uD800': 1 })).toEqual({ k: 1 }); + }); + + it('数字与布尔原样保留', () => { + expect(sanitizeJsonValue({ n: 1, b: true, z: null })).toEqual({ + n: 1, + b: true, + z: null, + }); + }); +}); + +describe('httpFromLlmError:错误分流', () => { + it('4xx 上游错误映射为 400 并保留信息', () => { + const err = httpFromLlmError(new Error('bad request'), 400); + expect(err.status).toBe(400); + expect(err.message).toBe('bad request'); + }); + + it('提及 response_format 时映射为 400 并给出中文提示', () => { + const err = httpFromLlmError( + new Error('Unsupported parameter: response_format'), + ); + expect(err.status).toBe(400); + expect(err.message).toContain('不支持结构化输出'); + }); + + it('提及 json_schema 时同样映射为 400', () => { + expect(httpFromLlmError(new Error('json_schema not supported')).status).toBe(400); + }); + + it('其它错误映射为 502', () => { + const err = httpFromLlmError(new Error('连接超时')); + expect(err.status).toBe(502); + expect(err.message).toBe('连接超时'); + }); + + it('502 信息截断到 500 字符', () => { + const err = httpFromLlmError(new Error('x'.repeat(1000))); + expect(err.status).toBe(502); + expect(err.message).toHaveLength(500); + }); + + it('空错误信息回退到 llm_invoke_failed', () => { + expect(httpFromLlmError(new Error('')).message).toBe('llm_invoke_failed'); + }); + + it('返回 AnnotateHttpError 实例', () => { + expect(httpFromLlmError(new Error('x'))).toBeInstanceOf(AnnotateHttpError); + }); +}); + +describe('filterPaths:候选路径过滤', () => { + const candidates = [ + { relative_path: 'data/1.jpg', name: '1.jpg', parent: 'data', index: 0 }, + { relative_path: 'data/2.jpg', name: '2.jpg', parent: 'data', index: 1 }, + ]; + + it('只保留候选列表中的路径', () => { + expect(filterPaths(['data/1.jpg', 'data/9.jpg'], candidates)).toEqual([ + 'data/1.jpg', + ]); + }); + + it('反斜杠路径归一后匹配', () => { + expect(filterPaths(['data\\1.jpg'], candidates)).toEqual(['data/1.jpg']); + }); + + it('保持输入顺序', () => { + expect(filterPaths(['data/2.jpg', 'data/1.jpg'], candidates)).toEqual([ + 'data/2.jpg', + 'data/1.jpg', + ]); + }); + + it('非数组输入返回空数组', () => { + expect(filterPaths('data/1.jpg', candidates)).toEqual([]); + expect(filterPaths(null, candidates)).toEqual([]); + }); +}); + +describe('labelNamesOf', () => { + it('提取非空标签名', () => { + expect( + labelNamesOf({ + project_id: 'p', + name: '', + modality: 'image', + annotation_type: 'bbox', + labels: [{ name: 'person' }, { name: ' ' }, { name: 'car' }], + }), + ).toEqual(['person', 'car']); + }); + + it('无项目快照返回空数组', () => { + expect(labelNamesOf(null)).toEqual([]); + expect(labelNamesOf(undefined)).toEqual([]); + }); +}); + +describe('dumpMutationOperation:完整字段集(对齐 Pydantic model_dump)', () => { + const minimal = { + relative_path: 'data/1.jpg', + mutation_kind: 'patch_label' as const, + targets: [{ by: 'all' as const }], + }; + + it('输出全部字段,未设值者为 null', () => { + const dumped = dumpMutationOperation(minimal as never); + expect(dumped).toMatchObject({ + relative_path: 'data/1.jpg', + mutation_kind: 'patch_label', + new_label_name: null, + x: null, + cx: null, + points: null, + keypoints: null, + steps: null, + turns: null, + note: null, + }); + }); + + it('字段数量固定(不因输入而增减)', () => { + const keys = Object.keys(dumpMutationOperation(minimal as never)); + // 与 MutationOperationSchema 的 28 个字段一一对应 + expect(keys).toHaveLength(28); + }); + + it('targets 递归展开并补齐字段', () => { + const dumped = dumpMutationOperation(minimal as never); + expect(dumped.targets).toEqual([ + { + by: 'all', + id: null, + label_name: null, + index: null, + hint: null, + granularity: null, + language: null, + }, + ]); + }); + + it('嵌套模型递归展开', () => { + const dumped = dumpMutationOperation({ + ...minimal, + points: [{ x: 0.1, y: 0.2 }], + keypoints: [{ x: 0.3, y: 0.4, visibility: 1 }], + steps: [{ description: 'd', conclusion: 'c' }], + turns: [{ role: 'user', content: 'hi' }], + } as never); + + expect(dumped.points).toEqual([{ x: 0.1, y: 0.2 }]); + expect(dumped.keypoints).toEqual([{ x: 0.3, y: 0.4, visibility: 1 }]); + expect(dumped.steps).toEqual([{ description: 'd', conclusion: 'c' }]); + expect(dumped.turns).toEqual([{ role: 'user', content: 'hi' }]); + }); + + it('数字 0 不被误当作缺失', () => { + const dumped = dumpMutationOperation({ ...minimal, start: 0 } as never); + expect(dumped.start).toBe(0); + }); +}); + +describe('contentToText:LLM 内容抽取', () => { + it('字符串原样返回', () => { + expect(contentToText('hi')).toBe('hi'); + }); + + it('内容块数组拼接 text 部件', () => { + expect(contentToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe( + 'ab', + ); + }); + + it('数组中非 text 部件跳过', () => { + expect(contentToText([{ type: 'text', text: 'a' }, { type: 'image' }])).toBe('a'); + }); + + it('null / undefined 返回空串', () => { + expect(contentToText(null)).toBe(''); + expect(contentToText(undefined)).toBe(''); + }); +}); + +describe('quality compose:payload 校验与消息构造', () => { + it('小 payload 通过校验', () => { + expect(() => assertComposePayloadSize({ a: 1 })).not.toThrow(); + }); + + it('超过 512KB 抛 compose_payload_too_large', () => { + const huge = { blob: 'x'.repeat(600_000) }; + expect(() => assertComposePayloadSize(huge)).toThrow('compose_payload_too_large'); + }); + + it('消息含固定系统提示与 JSON 载荷', () => { + const messages = buildComposeMessages({ metrics: { total: 5 } }); + expect(messages).toHaveLength(2); + expect(messages[0].content).toBe(QUALITY_REPORT_COMPOSE_SYSTEM); + expect(messages[1].content).toContain('【质量指标与发现 JSON】'); + expect(messages[1].content).toContain('"total": 5'); + expect(messages[1].content).toContain('请基于以上数据撰写完整 Markdown 质量报告。'); + }); + + it('超过 48000 字符时截断并加提示', () => { + const messages = buildComposeMessages({ blob: 'x'.repeat(60_000) }); + expect(messages[1].content).toContain('…(已截断)'); + }); +}); + +describe('请求 schema:约束对齐', () => { + it('llm-generate 的默认值', () => { + const parsed = LlmGenerateRequestSchema.parse({ provider_id: 'p' }); + expect(parsed.temperature).toBe(0.3); + expect(parsed.max_tokens).toBe(4096); + expect(parsed.image_mime_type).toBe('image/jpeg'); + expect(parsed.system_prompt).toBe(''); + }); + + it('llm-generate 拒绝越界的 temperature', () => { + expect(() => + LlmGenerateRequestSchema.parse({ provider_id: 'p', temperature: 3 }), + ).toThrow(); + }); + + it('llm-generate 拒绝越界的 max_tokens', () => { + expect(() => + LlmGenerateRequestSchema.parse({ provider_id: 'p', max_tokens: 999_999 }), + ).toThrow(); + }); + + it('mutation-prepare 要求 user_request 非空', () => { + expect(() => + MutationPrepareRequestSchema.parse({ provider_id: 'p' }), + ).toThrow(); + expect(() => + MutationPrepareRequestSchema.parse({ provider_id: 'p', user_request: '' }), + ).toThrow(); + }); + + it('map-detection-boxes 的默认值', () => { + const parsed = MapDetectionBoxesRequestSchema.parse({ provider_id: 'p' }); + expect(parsed.use_vision).toBe(false); + expect(parsed.label_strategy).toBe('map_each_box_to_label'); + expect(parsed.mime_type).toBe('image/jpeg'); + expect(parsed.boxes).toEqual([]); + }); + + it('quality compose 的 compose_payload 默认空对象', () => { + expect( + QualityReportComposeRequestSchema.parse({ provider_id: 'p' }).compose_payload, + ).toEqual({}); + }); + + it('provider_id 长度受限', () => { + expect(() => + LlmGenerateRequestSchema.parse({ provider_id: 'x'.repeat(65) }), + ).toThrow(); + }); + + it('忽略多余字段(对齐 Pydantic extra=ignore)', () => { + const parsed = LlmGenerateRequestSchema.parse({ + provider_id: 'p', + unknown_field: 'whatever', + }); + expect('unknown_field' in parsed).toBe(false); + }); + + it('parseRequest 对非法 JSON 给出中文提示', () => { + try { + parseRequest('{bad json', LlmGenerateRequestSchema); + throw new Error('应当抛错'); + } catch (err) { + expect(formatZodError(err as z.ZodError)).toBe('请求体不是合法 JSON'); + } + }); + + it('formatZodError 带字段路径', () => { + try { + parseRequest('{}', LlmGenerateRequestSchema); + throw new Error('应当抛错'); + } catch (err) { + expect(formatZodError(err as z.ZodError)).toContain('provider_id'); + } + }); +}); diff --git a/src/main/agent/annotate/common.ts b/src/main/agent/annotate/common.ts new file mode 100644 index 0000000..2e5731e --- /dev/null +++ b/src/main/agent/annotate/common.ts @@ -0,0 +1,221 @@ +/** + * 标注编排的公共基础。 + * + * 移植自 Python 的这几个小模块: + * - `app/agent/annotation/json_utils.py`(`extract_json_object`) + * - `app/agent/annotation/llm_invoke.py`(`invoke_json_model`) + * - `app/agent/text_sanitize.py`(`sanitize_unicode_text` / `sanitize_json_value`) + * - `app/api/v1/annotation_agent.py`(`_http_from_llm_error`) + */ + +import { z } from 'zod'; +import type { ChatMessage, LlmClient } from '../llm/client'; + +// ── JSON 提取 ──────────────────────────────────────────────────────── + +/** + * 从 LLM 文本响应中提取 JSON 对象。 + * + * 对齐 Python `extract_json_object` 的三步: + * 1. 剥掉 markdown 围栏(`strip("`")` 会去掉**两端所有**反引号,再移除行首 `json\n`) + * 2. 直接 `JSON.parse`,成功且是对象则返回 + * 3. 否则用**贪婪**正则抓第一个 `{...}`(跨行)再解析 + * + * 任何失败都返回 `{}` 而不是抛异常——让上层的 zod 校验给出结构化错误。 + */ +export function extractJsonObject(text: string): Record { + let raw = (text ?? '').trim(); + + if (raw.startsWith('```')) { + // Python 的 strip("`") 语义:去掉两端所有反引号 + raw = raw.replace(/^`+/, '').replace(/`+$/, ''); + raw = raw.replace(/^json\s*\n/i, ''); + } + + const direct = tryParseObject(raw); + if (direct) return direct; + + // 贪婪匹配第一个 { 到最后一个 } + const start = raw.indexOf('{'); + const end = raw.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) return {}; + return tryParseObject(raw.slice(start, end + 1)) ?? {}; +} + +function tryParseObject(text: string): Record | null { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +// ── 结构化输出调用 ────────────────────────────────────────────────── + +/** + * 把指定字段的 JSON `null` 归一为 `""`。 + * + * 避免 LLM 误用 `null` 导致校验失败(Python 的 `coerce_null_string_fields`)。 + */ +export function coerceNullStringFields( + data: Record, + fields: string[], +): Record { + if (fields.length === 0) return data; + const out = { ...data }; + for (const key of fields) { + if (key in out && out[key] === null) out[key] = ''; + } + return out; +} + +export interface InvokeJsonOptions { + /** 输出模型。 */ + schema: z.ZodType; + /** 追加到 system 的指令(Python 的 `extra_instruction`)。 */ + extraInstruction?: string; + /** 日志标签(仅用于调试输出)。 */ + logLabel?: string; + /** 需要把 null 归一为空串的字段。 */ + nullStringFields?: string[]; + /** 覆盖温度。 */ + temperature?: number; + signal?: AbortSignal; +} + +/** + * 调用 LLM 并要求结构化 JSON 输出。 + * + * **不使用** `response_format` / `json_schema`(DeepSeek 等兼容性差,Python 侧同样 + * 刻意避开),改用「系统提示 + 显式指令」约束输出格式,再自行解析与校验。 + * + * 提示词拼装与 Python 一致: + * system = systemParts(或默认)joined with extraInstruction(空则过滤掉) + * human = "只输出一个 JSON 对象,不要 markdown 代码块。" + humanParts + */ +export async function invokeJsonModel( + llm: LlmClient, + messages: ChatMessage[], + options: InvokeJsonOptions, +): Promise { + const systemParts = messages + .filter((m) => m.role === 'system') + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter((part) => part.length > 0); + const humanParts = messages + .filter((m) => m.role === 'user') + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter((part) => part.length > 0); + + const systemCandidates = systemParts.length > 0 ? systemParts : ['你是结构化 JSON 助手。']; + const systemText = [systemCandidates[0], options.extraInstruction] + .filter((p) => p && p.length > 0) + .join('\n'); + const humanText = [ + '只输出一个 JSON 对象,不要 markdown 代码块。', + ...humanParts, + ].join('\n'); + + const turn = await llm.completeChat({ + messages: [ + { role: 'system', content: systemText }, + { role: 'user', content: humanText }, + ], + signal: options.signal, + temperature: options.temperature, + }); + + let data = extractJsonObject(turn.content); + if (options.nullStringFields?.length) { + data = coerceNullStringFields(data, options.nullStringFields); + } + return options.schema.parse(data); +} + +// ── Unicode 清洗 ──────────────────────────────────────────────────── + +/** + * 移除孤立的 UTF-16 代理码元。 + * + * 这些字符会让下游的 UTF-8 写盘失败(Python 侧注释:避免沙箱 UTF-8 写盘失败)。 + */ +export function sanitizeUnicodeText(text: string): string { + if (!text) return ''; + // 替换掉孤立代理(合法代理对会被保留) + const cleaned = text.replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[sanitizeUnicodeText(key)] = sanitizeJsonValue(val); + } + return out; + } + return value; +} + +// ── HTTP 错误分流 ─────────────────────────────────────────────────── + +/** 路由层可抛出的带状态码错误。 */ +export class AnnotateHttpError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'AnnotateHttpError'; + this.status = status; + } +} + +/** + * 把 LLM 调用异常映射为 HTTP 错误。 + * + * 对齐 Python `_http_from_llm_error`: + * - LLM 返回 4xx(如 `BadRequestError`)→ 400,保留原始信息 + * - 错误信息含 `response_format` / `json_schema` → 400 + 中文提示 + * (说明该模型不支持结构化输出) + * - 其它 → 502,信息截断到 500 字符 + */ +export function httpFromLlmError(err: unknown, status?: number): AnnotateHttpError { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + + if (status !== undefined && status >= 400 && status < 500) { + return new AnnotateHttpError(400, message || 'llm_bad_request'); + } + if (lower.includes('response_format') || lower.includes('json_schema')) { + return new AnnotateHttpError( + 400, + '当前模型不支持结构化输出,请使用普通对话模型或更换提供商', + ); + } + return new AnnotateHttpError(502, message.slice(0, 500) || 'llm_invoke_failed'); +} + +/** 校验 provider 凭据是否齐全(对齐 `_require_direct_llm` 的前置检查)。 */ +export function requireCredentials(params: { + api_key: string; + base_url: string; + model: string; +}): void { + if ( + !params.api_key.trim() || + !params.base_url.trim() || + !params.model.trim() + ) { + throw new AnnotateHttpError(400, 'provider_credentials_required'); + } +} diff --git a/src/main/agent/annotate/generate.ts b/src/main/agent/annotate/generate.ts new file mode 100644 index 0000000..aa97a7f --- /dev/null +++ b/src/main/agent/annotate/generate.ts @@ -0,0 +1,154 @@ +/** + * 通用 LLM 生成代理(caption / cot / instruction 等生成类标注)。 + * + * 移植自 `vendor/local-agent/app/api/v1/annotation_agent.py` 的 + * `api_llm_generate` 与 `_build_generate_image_data_url`。 + * + * 图片处理对齐 Python 的三条分支: + * 1. 请求声明了图片但字节加载不到 → **抛 400 `image_load_failed`** + * (让前端跳过该文件,而不是静默产出无图结果) + * 2. 路径优先、base64 兜底 + * 3. 压缩失败 → 回退发送**未压缩原图**,并保持请求声明的 MIME + */ + +import fs from 'fs-extra'; +import type { ChatMessage, LlmClient } from '../llm/client'; +import { AnnotateHttpError } from './common'; +import type { LlmGenerateRequest } from './schemas'; +import type { AgentSettings } from '../config'; +import type { ImageService } from '../services/imageService'; + +/** 从 LLM 响应内容中抽取纯文本(兼容字符串与内容块数组)。 */ +export function contentToText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === 'string') return part; + if (part && typeof part === 'object') { + const record = part as Record; + return typeof record.text === 'string' ? record.text : ''; + } + return ''; + }) + .join(''); + } + if (content === null || content === undefined) return ''; + return String(content); +} + +/** + * 组装多模态图片 data URL。 + * + * 见模块注释的三条分支。 + */ +export async function buildGenerateImageDataUrl( + body: LlmGenerateRequest, + settings: AgentSettings, + imageService: ImageService, +): Promise { + const wantsImage = Boolean( + (body.image_absolute_path ?? '').trim() || (body.image_base64 ?? '').trim(), + ); + if (!wantsImage) return ''; + + const encoded = await imageService.toJpegDataUrl({ + absolutePath: (body.image_absolute_path ?? '').trim() || undefined, + base64: (body.image_base64 ?? '').trim() || undefined, + maxEdge: settings.annotationLlmImageMaxEdge, + quality: settings.annotationLlmImageJpegQuality, + }); + + if (encoded.ok && encoded.dataUrl) return encoded.dataUrl; + + // 加载不到字节 → 400(与 Python 的 image_load_failed 对齐) + if (isLoadFailure(encoded.error)) { + throw new AnnotateHttpError(400, 'image_load_failed'); + } + + // 编码失败但字节可读 → 回退未压缩原图 + const raw = await loadRawBytes(body); + if (!raw) throw new AnnotateHttpError(400, 'image_load_failed'); + return `data:${body.image_mime_type};base64,${Buffer.from(raw).toString('base64')}`; +} + +/** 区分「读不到字节」(400)与「能读到但编码失败」(回退原图)。 */ +function isLoadFailure(error: string | undefined): boolean { + if (!error) return false; + return ( + error.includes('文件不存在') || + error.includes('读取图片失败') || + error.includes('未提供图片路径') || + error.includes('图片数据为空') || + error.includes('无法识别的图片格式') + ); +} + +/** 读取图片原始字节(路径优先,base64 兜底)。 */ +async function loadRawBytes(body: LlmGenerateRequest): Promise { + const abs = (body.image_absolute_path ?? '').trim(); + if (abs) { + try { + if (!(await fs.pathExists(abs))) return null; + return await fs.readFile(abs); + } catch { + return null; + } + } + const raw = (body.image_base64 ?? '').trim(); + if (raw) { + const comma = raw.indexOf(','); + const payload = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; + return Uint8Array.from(Buffer.from(payload, 'base64')); + } + return null; +} + +export interface LlmGenerateParams { + llm: LlmClient; + body: LlmGenerateRequest; + settings: AgentSettings; + imageService: ImageService; + signal?: AbortSignal; +} + +/** + * 执行一次生成类调用,返回纯文本结果。 + * + * 非流式(与 Python 的 `ainvoke` 一致)——生成类 pipeline 是一次性拿完整结果再本地解析。 + */ +export async function llmGenerate(params: LlmGenerateParams): Promise { + const { body } = params; + + const messages: ChatMessage[] = []; + if (body.system_prompt.trim()) { + messages.push({ role: 'system', content: body.system_prompt }); + } + + const imageDataUrl = await buildGenerateImageDataUrl( + body, + params.settings, + params.imageService, + ); + + if (imageDataUrl) { + messages.push({ + role: 'user', + content: [ + { type: 'text', text: body.user_prompt ?? '' }, + { type: 'image_url', image_url: { url: imageDataUrl } }, + ], + }); + } else { + messages.push({ role: 'user', content: body.user_prompt ?? '' }); + } + + const turn = await params.llm.completeChat({ + messages, + temperature: body.temperature, + maxTokens: body.max_tokens, + signal: params.signal, + }); + + return contentToText(turn.content); +} diff --git a/src/main/agent/annotate/mutation.ts b/src/main/agent/annotate/mutation.ts new file mode 100644 index 0000000..82c2a8d --- /dev/null +++ b/src/main/agent/annotate/mutation.ts @@ -0,0 +1,351 @@ +/** + * 标注变更准备(改标签/删框,单次 LLM)。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/mutation_prepare_service.py`。 + * + * 关键保真点:Python 用 `op.model_dump()` 输出操作对象,Pydantic 的 `model_dump()` + * 会**递归展开嵌套模型并把未设值字段输出为 `null`**。若用 zod 解析后直接 + * `JSON.stringify`,未出现的可选键会被省略——前端拿到的形状就与 Python 不同。 + * 因此这里显式重建完整字段集(`dumpMutationOperation`)。 + */ + +import { z } from 'zod'; +import type { LlmClient } from '../llm/client'; +import { invokeJsonModel } from './common'; +import type { ImageCandidate, AnnotationProjectSnapshot } from './schemas'; + +/** 变更准备系统提示词(行为契约,需原样保留)。 */ +export const MUTATION_PREPARE_SYSTEM = `你是 LR-Agent 标注变更准备助手。用户希望修改或删除已有标注(非新增)。 + +支持全部标注类型:bbox、rotated_bbox、polygon、keypoint(骨架)、caption、cot、 +text_classification、classification、span_ner、instruction、preference、conversation。 +用户说「不要新增框/不要新增标注」时,只能改或删已有项,不要改用 auto_annotate。 + +输出 JSON(一次完成): +{ + "selected_paths": ["data/7.jpg"], + "intent_summary": "一句话摘要", + "operations": [ + { + "relative_path": "data/7.jpg", + "mutation_kind": "patch_label|patch_geometry|patch_content|delete", + "targets": [ + {"by": "all"}, + {"by": "id", "id": "uuid"}, + {"by": "label_name", "label_name": "person"}, + {"by": "index", "index": 1}, + {"by": "spatial", "hint": "leftmost"}, + {"by": "selected"}, + {"by": "unlabeled"}, + {"by": "granularity", "granularity": "brief"}, + {"by": "language", "language": "en"}, + {"by": "longest"}, + {"by": "shortest"}, + {"by": "duplicate_label"} + ], + "new_label_name": "worker", + "x": 0.1, "y": 0.2, "width": 0.15, "height": 0.18, + "cx": 0.5, "cy": 0.4, "angle": 30, + "points": [{"x": 0.1, "y": 0.2}], + "keypoints": [{"x": 0.1, "y": 0.2, "visibility": 2}], + "text": "新的 caption", + "granularity": "brief", + "language": "zh", + "steps": [{"description": "...", "conclusion": "..."}], + "answer": "...", + "instruction": "...", + "input": "...", "output": "...", + "start": 0, "end": 12, + "prompt": "...", "chosen": "...", "rejected": "...", + "turns": [{"role": "user", "content": "..."}], + "note": "..." + } + ] +} + +规则: +- patch_label:改已有标签(含多边形/分类空 labelId 补标)。必须给出 new_label_name(项目标签名之一) +- patch_geometry:改几何。 + bbox 填 x/y/width/height(0–1,左上角);rotated_bbox 与 keypoint 骨架填 cx/cy/width/height(0–1,中心点),可带 angle(度); + keypoint 改关键点填 keypoints 整表替换(长度与骨架模板一致,visibility 0=不可见 1=遮挡 2=可见); + polygon 仅在用户明确给出新轮廓时填 points(至少 3 点),飘出的多边形优先 delete +- patch_content:改正文。 + caption 填 text/granularity/language;cot 填 steps(至少 2 步)+ answer 整表替换,可带 instruction; + instruction 填 instruction/input/output;span_ner 改文本偏移填 start/end(字符索引,start < end); + preference 填 prompt/chosen/rejected;conversation 填 turns 整表替换(role 为 user/assistant) +- delete:删除目标;清空某文件全部标注时 targets=[{"by":"all"}];去重分类用 {"by":"duplicate_label"} +- selected_paths 必须为候选列表中的 relative_path +- 优先使用摘要里的 id;无 id 时用 label_name/index/spatial/unlabeled/granularity +- 用户说「这个框」「当前选中」时加入 {"by":"selected"} +- 勿编造不在候选中的路径 +`; + +// ── LLM 输出模型 ──────────────────────────────────────────────────── + +const MutationPointSchema = z.object({ + x: z.number(), + y: z.number(), +}); + +const MutationCotStepSchema = z.object({ + description: z.string(), + conclusion: z.string(), +}); + +const MutationKeypointSchema = z.object({ + x: z.number(), + y: z.number(), + // 0=不可见 1=遮挡 2=可见 + visibility: z.number().int().default(2), +}); + +const MutationTurnSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string().min(1), +}); + +const TARGET_BY_VALUES = [ + 'id', + 'label_name', + 'index', + 'spatial', + 'selected', + 'all', + 'unlabeled', + 'granularity', + 'language', + 'longest', + 'shortest', + 'duplicate_label', +] as const; + +const MutationTargetSchema = z.object({ + by: z.enum(TARGET_BY_VALUES).default('id'), + id: z.string().nullish(), + label_name: z.string().nullish(), + index: z.number().int().nullish(), + hint: z.string().nullish(), + granularity: z.string().nullish(), + language: z.string().nullish(), +}); + +const MUTATION_KIND_VALUES = [ + 'patch_label', + 'patch_geometry', + 'patch_content', + 'delete', +] as const; + +const MutationOperationSchema = z.object({ + relative_path: z.string().min(1), + mutation_kind: z.enum(MUTATION_KIND_VALUES).default('patch_label'), + targets: z.array(MutationTargetSchema).default([]), + new_label_name: z.string().nullish(), + x: z.number().nullish(), + y: z.number().nullish(), + width: z.number().nullish(), + height: z.number().nullish(), + // rotated_bbox / keypoint 骨架:中心点 + 旋转角(度) + cx: z.number().nullish(), + cy: z.number().nullish(), + angle: z.number().nullish(), + points: z.array(MutationPointSchema).nullish(), + // keypoint 骨架关键点(整表替换) + keypoints: z.array(MutationKeypointSchema).nullish(), + text: z.string().nullish(), + granularity: z.string().nullish(), + language: z.string().nullish(), + steps: z.array(MutationCotStepSchema).nullish(), + answer: z.string().nullish(), + instruction: z.string().nullish(), + input: z.string().nullish(), + output: z.string().nullish(), + // span_ner 文本偏移(字符索引) + start: z.number().int().min(0).nullish(), + end: z.number().int().min(0).nullish(), + // preference + prompt: z.string().nullish(), + chosen: z.string().nullish(), + rejected: z.string().nullish(), + // conversation(整表替换) + turns: z.array(MutationTurnSchema).nullish(), + note: z.string().nullish(), +}); + +const MutationPrepareLlmResultSchema = z.object({ + selected_paths: z.array(z.string()).default([]), + intent_summary: z.string().default(''), + operations: z.array(MutationOperationSchema).default([]), +}); + +type MutationOperation = z.infer; + +/** + * 等价于 Pydantic 的 `model_dump()`:**输出全部字段**,未设值者为 `null`, + * 嵌套模型递归展开为普通对象。 + */ +export function dumpMutationOperation(op: MutationOperation): Record { + return { + relative_path: op.relative_path, + mutation_kind: op.mutation_kind, + targets: (op.targets ?? []).map((t) => ({ + by: t.by, + id: t.id ?? null, + label_name: t.label_name ?? null, + index: t.index ?? null, + hint: t.hint ?? null, + granularity: t.granularity ?? null, + language: t.language ?? null, + })), + new_label_name: op.new_label_name ?? null, + x: op.x ?? null, + y: op.y ?? null, + width: op.width ?? null, + height: op.height ?? null, + cx: op.cx ?? null, + cy: op.cy ?? null, + angle: op.angle ?? null, + points: op.points ? op.points.map((p) => ({ x: p.x, y: p.y })) : null, + keypoints: op.keypoints + ? op.keypoints.map((k) => ({ + x: k.x, + y: k.y, + visibility: k.visibility ?? 2, + })) + : null, + text: op.text ?? null, + granularity: op.granularity ?? null, + language: op.language ?? null, + steps: op.steps + ? op.steps.map((s) => ({ + description: s.description, + conclusion: s.conclusion, + })) + : null, + answer: op.answer ?? null, + instruction: op.instruction ?? null, + input: op.input ?? null, + output: op.output ?? null, + start: op.start ?? null, + end: op.end ?? null, + prompt: op.prompt ?? null, + chosen: op.chosen ?? null, + rejected: op.rejected ?? null, + turns: op.turns + ? op.turns.map((t) => ({ role: t.role, content: t.content })) + : null, + note: op.note ?? null, + }; +} + +export interface MutationPrepareResult { + selected_paths: string[]; + intent_summary: string; + operations: Array>; + resolved_user_request: string; +} + +/** 过滤出确实存在于候选列表中的路径(正斜杠归一后比对)。 */ +export function filterPaths( + rawPaths: unknown, + candidates: ImageCandidate[], +): string[] { + const byPath = new Map(); + for (const c of candidates) { + const key = String(c.relative_path ?? '').trim().replace(/\\/g, '/'); + if (key) byPath.set(key, c); + } + + const selected: string[] = []; + if (Array.isArray(rawPaths)) { + for (const raw of rawPaths) { + const key = String(raw).trim().replace(/\\/g, '/'); + if (byPath.has(key)) selected.push(key); + } + } + return selected; +} + +export interface PrepareMutationParams { + llm: LlmClient; + userRequest: string; + currentRelativePath: string; + candidates: ImageCandidate[]; + labelNames: string[]; + selectedAnnotationIds?: string[]; + conversationTranscript?: string; + signal?: AbortSignal; +} + +/** 单次 LLM 解析 mutate 意图与目标。 */ +export async function prepareMutationAnnotation( + params: PrepareMutationParams, +): Promise { + if (params.candidates.length === 0) { + return { + selected_paths: [], + intent_summary: '无文件候选', + operations: [], + resolved_user_request: params.userRequest, + }; + } + + const catalog = JSON.stringify( + params.candidates.slice(0, 80).map((c) => ({ + relative_path: c.relative_path, + name: c.name, + parent: c.parent, + })), + ); + const labels = params.labelNames.slice(0, 50).join(', ') || '(无)'; + const selectedIds = + (params.selectedAnnotationIds ?? []).join(', ') || '(无)'; + const transcript = (params.conversationTranscript ?? '').trim() || '(无历史)'; + + const human = + `【对话上下文】\n${transcript}\n\n` + + `【用户请求】\n${params.userRequest.trim()}\n\n` + + `current_relative_path: ${params.currentRelativePath || '(无)'}\n` + + `selected_annotation_ids: ${selectedIds}\n` + + `项目标签: ${labels}\n` + + `候选文件: ${catalog}\n`; + + const parsed = await invokeJsonModel( + params.llm, + [ + { role: 'system', content: MUTATION_PREPARE_SYSTEM }, + { role: 'user', content: human }, + ], + { schema: MutationPrepareLlmResultSchema, signal: params.signal }, + ); + + let selected = filterPaths(parsed.selected_paths, params.candidates); + if (selected.length === 0 && parsed.operations.length > 0) { + const candidatePaths = new Set( + params.candidates.map((c) => String(c.relative_path ?? '')), + ); + const opPaths = new Set( + parsed.operations.map((op) => + String(op.relative_path).trim().replace(/\\/g, '/'), + ), + ); + selected = [...opPaths].filter((p) => candidatePaths.has(p)); + } + + return { + selected_paths: selected, + intent_summary: parsed.intent_summary || params.userRequest.slice(0, 200), + operations: parsed.operations.map(dumpMutationOperation), + resolved_user_request: params.userRequest, + }; +} + +/** 标注项目快照里的标签名(供 mutation-prepare 使用)。 */ +export function labelNamesOf( + project: AnnotationProjectSnapshot | null | undefined, +): string[] { + if (!project) return []; + return (project.labels ?? []) + .map((label) => String(label.name ?? '')) + .filter((name) => name.trim().length > 0); +} diff --git a/src/main/agent/annotate/quality.ts b/src/main/agent/annotate/quality.ts new file mode 100644 index 0000000..a4691d8 --- /dev/null +++ b/src/main/agent/annotate/quality.ts @@ -0,0 +1,115 @@ +/** + * 标注质量报告 LLM 撰写(SSE 流式)。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/quality_report_compose_service.py` + * 与 `app/api/v1/annotation_quality.py`。 + * + * **注意本端点与 Assist 的 error 语义不同**:Assist 运行时从不发 `error` 事件 + * (由渲染层合成),但本端点在异常时**会**发 `error` 事件并结束流—— + * 因为报告撰写是一次性流,中途失败需要明确告知前端,否则会得到半截报告。 + * + * payload 有两级截断: + * 1. 请求体整体 > 512KB → 400 `compose_payload_too_large`(路由层校验) + * 2. 序列化后 > 48000 字符 → 截断并追加提示(本模块内) + */ + +import { DONE_FRAME, encodeSseFrame, sse, toSseDict } from '../sse'; +import type { LlmClient } from '../llm/client'; +import { sanitizeJsonValue } from './common'; +import { contentToText } from './generate'; + +/** 报告撰写系统提示词(行为契约,需原样保留)。 */ +export const QUALITY_REPORT_COMPOSE_SYSTEM = `你是 LR-Agent 标注质量报告撰写助手。根据提供的标注质量指标、一致性检查发现和图表路径,用中文 Markdown 撰写结构化质量报告。 + +要求: +- 固定章节:概览、覆盖率、标签分布、一致性问题、风险文件清单、改进建议 +- 所有数字必须来自提供的 metrics / findings JSON,禁止编造 +- 在合适章节嵌入图表,使用给定 chart_paths 中的相对路径,格式:![标题](charts/xxx.png) +- 对 critical / warning 级别发现给出明确复核建议 +- 语言简洁专业,适合交付给标注团队或甲方 +- 不要输出代码块包裹整篇报告 +`; + +/** payload 序列化后的字符上限。 */ +const PAYLOAD_CHAR_LIMIT = 48_000; + +/** 请求体字节上限(512KB)。 */ +export const COMPOSE_PAYLOAD_MAX_BYTES = 512_000; + +/** + * 校验 `compose_payload` 的字节规模。 + * + * 按 UTF-8 字节数计(与 Python 的 `len(json.dumps(...).encode("utf-8"))` 一致)。 + */ +export function assertComposePayloadSize(composePayload: unknown): void { + const bytes = Buffer.byteLength(JSON.stringify(composePayload ?? {}), 'utf8'); + if (bytes > COMPOSE_PAYLOAD_MAX_BYTES) { + throw new Error('compose_payload_too_large'); + } +} + +/** 构造发送给 LLM 的消息(含 payload 截断)。 */ +export function buildComposeMessages(composePayload: unknown): Array<{ + role: 'system' | 'user'; + content: string; +}> { + let payloadJson = JSON.stringify(composePayload ?? {}, null, 2); + if (payloadJson.length > PAYLOAD_CHAR_LIMIT) { + payloadJson = `${payloadJson.slice(0, PAYLOAD_CHAR_LIMIT)}\n…(已截断)`; + } + + const human = + '【质量指标与发现 JSON】\n' + + `${payloadJson}\n\n` + + '请基于以上数据撰写完整 Markdown 质量报告。'; + + return [ + { role: 'system', content: QUALITY_REPORT_COMPOSE_SYSTEM }, + { role: 'user', content: human }, + ]; +} + +/** + * 流式撰写质量报告,产出 SSE 帧。 + * + * 异常时发 `error` 事件并结束(与本端点的既有语义一致)。 + */ +export async function* streamQualityReportCompose( + llm: LlmClient, + options: { + composePayload: unknown; + signal?: AbortSignal; + /** 是否在 error 事件中附带 detail(对应 settings.debug)。 */ + debug?: boolean; + onError?: (err: unknown) => void; + }, +): AsyncGenerator { + try { + // 清洗孤立代理码元,避免下游 UTF-8 写盘失败 + const payload = sanitizeJsonValue(options.composePayload); + const messages = buildComposeMessages(payload); + + for await (const delta of llm.streamChat({ + messages, + signal: options.signal, + })) { + if (delta.content) { + yield encodeSseFrame(toSseDict(sse.textDelta(delta.content))); + } + } + yield DONE_FRAME; + } catch (err) { + options.onError?.(err); + const event: Record = { + type: 'error', + message: 'quality_report_compose_failed', + }; + if (options.debug) event.detail = 'quality_report_compose_failed'; + yield encodeSseFrame(event); + } +} + +/** 供测试与路由复用:抽取单个 chunk 的文本。 */ +export function chunkText(content: unknown): string { + return contentToText(content); +} diff --git a/src/main/agent/annotate/schemas.ts b/src/main/agent/annotate/schemas.ts new file mode 100644 index 0000000..ce0e1ee --- /dev/null +++ b/src/main/agent/annotate/schemas.ts @@ -0,0 +1,135 @@ +/** + * 标注编排的请求/响应 schema。 + * + * 用 zod 重建 `vendor/local-agent/app/schemas/annotation_agent.py` 与 + * `annotation_quality.py` 的 Pydantic 模型。**约束必须逐条对齐**,因为它们决定 + * 前端能传什么、以及越界时的错误形态。 + * + * 解析策略与 FastAPI 一致:忽略多余字段(Pydantic 默认 `extra="ignore"`), + * 缺失的可选字段取默认值,必填缺失或越界则报 400。 + */ + +import { z } from 'zod'; + +/** 图片候选(前端列举出的可选文件)。 */ +export const ImageCandidateSchema = z.object({ + relative_path: z.string(), + name: z.string().default(''), + parent: z.string().default(''), + index: z.number().int().default(0), +}); + +export type ImageCandidate = z.infer; + +/** 标注项目快照。 */ +export const AnnotationProjectSnapshotSchema = z.object({ + project_id: z.string().min(1).max(64), + name: z.string().default(''), + modality: z.string().default('image'), + annotation_type: z.string().default('bbox'), + labels: z.array(z.record(z.string(), z.unknown())).default([]), +}); + +export type AnnotationProjectSnapshot = z.infer< + typeof AnnotationProjectSnapshotSchema +>; + +/** + * 标注 LLM 请求的公共基座。 + * + * 注意 `api_key` / `base_url` / `model` 默认空串(不是必填)——与 Python 一致: + * 校验推迟到 `requireCredentials`,这样错误码是统一的 + * `provider_credentials_required`(400)而不是字段缺失。 + */ +const AnnotationLlmBase = z.object({ + provider_id: z.string().min(1).max(64), + api_key: z.string().default(''), + base_url: z.string().default(''), + model: z.string().default(''), + supports_vision: z.boolean().default(false), +}); + +/** `POST /agent/annotation/mutation-prepare` */ +export const MutationPrepareRequestSchema = AnnotationLlmBase.extend({ + user_request: z.string().min(1).max(20_000), + session_id: z.string().max(64).nullish(), + conversation_transcript: z.string().max(24_000).default(''), + current_relative_path: z.string().default(''), + candidates: z.array(ImageCandidateSchema).default([]), + label_candidates: z.array(z.record(z.string(), z.unknown())).default([]), + selected_annotation_ids: z.array(z.string()).default([]), + project: AnnotationProjectSnapshotSchema.nullish(), +}); + +export type MutationPrepareRequest = z.infer; + +/** `POST /agent/annotation/map-detection-boxes` */ +export const MapDetectionBoxesRequestSchema = AnnotationLlmBase.extend({ + user_request: z.string().max(20_000).default(''), + intent_summary: z.string().default(''), + label_candidates: z.array(z.record(z.string(), z.unknown())).default([]), + boxes: z.array(z.record(z.string(), z.unknown())).default([]), + use_vision: z.boolean().default(false), + label_strategy: z.string().default('map_each_box_to_label'), + single_label_id: z.string().nullish(), + annotation_scope: z.record(z.string(), z.unknown()).default({}), + ocr_text: z.string().default(''), + image_absolute_path: z.string().max(1024).default(''), + image_base64: z.string().max(16_000_000).default(''), + mime_type: z.string().default('image/jpeg'), +}); + +export type MapDetectionBoxesRequest = z.infer< + typeof MapDetectionBoxesRequestSchema +>; + +/** `POST /agent/annotation/llm-generate` */ +export const LlmGenerateRequestSchema = AnnotationLlmBase.extend({ + system_prompt: z.string().max(20_000).default(''), + user_prompt: z.string().max(20_000).default(''), + temperature: z.number().min(0).max(2).default(0.3), + max_tokens: z.number().int().min(1).max(128_000).default(4096), + image_absolute_path: z.string().max(1024).default(''), + image_base64: z.string().max(16_000_000).default(''), + image_mime_type: z.string().default('image/jpeg'), +}); + +export type LlmGenerateRequest = z.infer; + +/** `POST /agent/annotation-quality/report/compose/stream` */ +export const QualityReportComposeRequestSchema = AnnotationLlmBase.extend({ + compose_payload: z.record(z.string(), z.unknown()).default({}), +}); + +export type QualityReportComposeRequest = z.infer< + typeof QualityReportComposeRequestSchema +>; + +/** 把 JSON 文本解析为指定 schema;失败时抛出 zod 错误。 */ +export function parseRequest( + rawBody: string, + schema: z.ZodType, +): T { + let parsed: unknown; + try { + parsed = rawBody ? JSON.parse(rawBody) : {}; + } catch { + throw new z.ZodError([ + { + code: 'custom', + path: [], + message: '请求体不是合法 JSON', + input: rawBody, + }, + ]); + } + return schema.parse(parsed); +} + +/** 把 zod 错误整理成可读的中文提示(与 FastAPI 的 detail 形态对齐)。 */ +export function formatZodError(err: z.ZodError): string { + const first = err.issues[0]; + if (!first) return 'invalid_request'; + const path = first.path.join('.'); + return path ? `${path}: ${first.message}` : first.message; +} diff --git a/src/main/agent/config.ts b/src/main/agent/config.ts index f09f1b0..637b808 100644 --- a/src/main/agent/config.ts +++ b/src/main/agent/config.ts @@ -34,6 +34,24 @@ export interface AgentSettings { mcpToolsTtlSeconds: number; /** 标注变更功能开关。 */ mutationEnabled: boolean; + /** 标注 LLM 温度(视觉映射等)。 */ + annotationLlmTemperature: number; + /** 标注准备阶段温度(mutation-prepare 等)。 */ + annotationPrepareTemperature: number; + /** 生成类标注送 LLM 的图片最长边。 */ + annotationLlmImageMaxEdge: number; + /** 生成类标注图片 JPEG 质量。 */ + annotationLlmImageJpegQuality: number; + /** 视觉映射并发度。 */ + annotationVisionMapConcurrency: number; + /** 视觉映射是否校验。 */ + annotationVisionMapValidate: boolean; + /** 视觉映射重试次数。 */ + annotationVisionMapMaxRetries: number; + /** 标签池预检模式(off / auto / always)。 */ + annotationLabelPoolPreflight: 'off' | 'auto' | 'always'; + /** 标签池预检最小额外标签数。 */ + annotationLabelPoolPreflightMinExtra: number; } export const DEFAULT_AGENT_SETTINGS: AgentSettings = { @@ -49,6 +67,15 @@ export const DEFAULT_AGENT_SETTINGS: AgentSettings = { chatVisionJpegQuality: 85, mcpToolsTtlSeconds: 1800, mutationEnabled: true, + annotationLlmTemperature: 0.0, + annotationPrepareTemperature: 0.1, + annotationLlmImageMaxEdge: 1280, + annotationLlmImageJpegQuality: 85, + annotationVisionMapConcurrency: 3, + annotationVisionMapValidate: true, + annotationVisionMapMaxRetries: 1, + annotationLabelPoolPreflight: 'auto', + annotationLabelPoolPreflightMinExtra: 2, }; /** 运行时可覆盖的配置(环境变量 → 覆盖默认值),便于对拍与调试。 */ diff --git a/src/main/agent/handlers/annotation.ts b/src/main/agent/handlers/annotation.ts new file mode 100644 index 0000000..32402bd --- /dev/null +++ b/src/main/agent/handlers/annotation.ts @@ -0,0 +1,211 @@ +/** + * 标注编排端点(第 1 批):`llm-generate`、`mutation-prepare`、质量报告撰写。 + * + * 移植自 `vendor/local-agent/app/api/v1/annotation_agent.py` 与 `annotation_quality.py`。 + * + * 响应格式与错误语义对齐 FastAPI: + * - 成功 → `{ data: ... }` + * - `HTTPException(detail)` → `{ detail: ... }` + 对应状态码 + * + * `map-detection-boxes` 在下一个文件中(它依赖视觉裁剪,体量更大)。 + */ + +import { LlmClient } from '../llm/client'; +import { AGENT_ROUTE_PATHS } from '../routes'; +import type { RouteHandler, RouteResult } from '../server'; +import { + AnnotateHttpError, + httpFromLlmError, + requireCredentials, +} from '../annotate/common'; +import { + formatZodError, + LlmGenerateRequestSchema, + MutationPrepareRequestSchema, + QualityReportComposeRequestSchema, +} from '../annotate/schemas'; +import { + labelNamesOf, + prepareMutationAnnotation, +} from '../annotate/mutation'; +import { llmGenerate } from '../annotate/generate'; +import { + assertComposePayloadSize, + streamQualityReportCompose, +} from '../annotate/quality'; +import { resolveImageService, type RuntimeDeps } from './deps'; + +/** 把路由层异常统一转成 `{detail}` 响应。 */ +function toErrorResult(err: unknown): RouteResult { + if (err instanceof AnnotateHttpError) { + return { kind: 'json', status: err.status, body: { detail: err.message } }; + } + const message = err instanceof Error ? err.message : String(err); + return { kind: 'json', status: 500, body: { detail: message } }; +} + +/** 构造一个指向标注端点的 LLM 客户端(每次请求独立)。 */ +function makeLlm(params: { + deps: RuntimeDeps; + apiKey: string; + baseUrl: string; + model: string; + temperature: number; +}): LlmClient { + return new LlmClient({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, + model: params.model, + temperature: params.temperature, + fetchImpl: params.deps.fetchImpl, + timeoutMs: params.deps.llmTimeoutMs, + }); +} + +/** `POST /agent/annotation/llm-generate` */ +export function createLlmGenerateHandler(deps: RuntimeDeps): RouteHandler { + return async (req) => { + let body; + try { + body = LlmGenerateRequestSchema.parse(JSON.parse(req.rawBody || '{}')); + } catch (err) { + return { + kind: 'json', + status: 400, + body: { detail: formatZodError(err as never) }, + }; + } + + try { + requireCredentials(body); + const llm = makeLlm({ + deps, + apiKey: body.api_key, + baseUrl: body.base_url, + model: body.model, + // 温度由请求体控制(生成类 pipeline 各自传值) + temperature: body.temperature, + }); + const content = await llmGenerate({ + llm, + body, + settings: deps.settings, + imageService: resolveImageService(deps), + }); + return { kind: 'json', body: { data: { ok: true, content } } }; + } catch (err) { + return toErrorResult(httpFromLlmError(err)); + } + }; +} + +/** `POST /agent/annotation/mutation-prepare` */ +export function createMutationPrepareHandler(deps: RuntimeDeps): RouteHandler { + return async (req) => { + // 功能开关:关闭时返回 403(对齐 Python) + if (!deps.settings.mutationEnabled) { + return { kind: 'json', status: 403, body: { detail: 'agent_mutation_disabled' } }; + } + + let body; + try { + body = MutationPrepareRequestSchema.parse(JSON.parse(req.rawBody || '{}')); + } catch (err) { + return { + kind: 'json', + status: 400, + body: { detail: formatZodError(err as never) }, + }; + } + + try { + requireCredentials(body); + const llm = makeLlm({ + deps, + apiKey: body.api_key, + baseUrl: body.base_url, + model: body.model, + temperature: deps.settings.annotationPrepareTemperature, + }); + + const result = await prepareMutationAnnotation({ + llm, + userRequest: body.user_request, + currentRelativePath: (body.current_relative_path ?? '').trim(), + candidates: body.candidates, + labelNames: labelNamesOf(body.project), + selectedAnnotationIds: body.selected_annotation_ids, + conversationTranscript: body.conversation_transcript, + }); + + return { + kind: 'json', + body: { + data: { + selected_paths: result.selected_paths, + intent_summary: result.intent_summary, + operations: result.operations, + resolved_user_request: body.user_request, + }, + }, + }; + } catch (err) { + return toErrorResult(httpFromLlmError(err)); + } + }; +} + +/** `POST /agent/annotation-quality/report/compose/stream` */ +export function createQualityComposeHandler(deps: RuntimeDeps): RouteHandler { + return (req) => { + let body; + try { + body = QualityReportComposeRequestSchema.parse( + JSON.parse(req.rawBody || '{}'), + ); + } catch (err) { + return { + kind: 'json', + status: 400, + body: { detail: formatZodError(err as never) }, + }; + } + + try { + assertComposePayloadSize(body.compose_payload); + requireCredentials(body); + } catch (err) { + if (err instanceof AnnotateHttpError) { + return { kind: 'json', status: err.status, body: { detail: err.message } }; + } + return { + kind: 'json', + status: 400, + body: { detail: err instanceof Error ? err.message : String(err) }, + }; + } + + const llm = makeLlm({ + deps, + apiKey: body.api_key, + baseUrl: body.base_url, + model: body.model, + // 报告撰写温度固定 0.2(对齐 Python) + temperature: 0.2, + }); + + return { + kind: 'sse', + frames: streamQualityReportCompose(llm, { + composePayload: body.compose_payload, + }), + }; + }; +} + +/** 路由键(供装配使用)。 */ +export const ANNOTATION_HANDLER_KEYS = { + llmGenerate: `POST ${AGENT_ROUTE_PATHS.annotationLlmGenerate}`, + mutationPrepare: `POST ${AGENT_ROUTE_PATHS.annotationMutationPrepare}`, + qualityCompose: `POST ${AGENT_ROUTE_PATHS.qualityReportComposeStream}`, +} as const; diff --git a/src/main/agent/handlers/index.ts b/src/main/agent/handlers/index.ts index 796813b..2de5a2a 100644 --- a/src/main/agent/handlers/index.ts +++ b/src/main/agent/handlers/index.ts @@ -9,6 +9,12 @@ import { AGENT_ROUTE_PATHS, buildRoutes } from '../routes'; import type { RouteHandler } from '../server'; import { createChatCancelHandler, createChatStreamHandler } from './chatStream'; import { mcpProbeHandler } from './mcpProbe'; +import { + ANNOTATION_HANDLER_KEYS, + createLlmGenerateHandler, + createMutationPrepareHandler, + createQualityComposeHandler, +} from './annotation'; import type { RuntimeDeps } from './deps'; /** 装配全部已实现路由。 */ @@ -19,5 +25,10 @@ export function buildImplementedRoutes( [`POST ${AGENT_ROUTE_PATHS.chatStream}`]: createChatStreamHandler(deps), [`POST ${AGENT_ROUTE_PATHS.chatCancel}`]: createChatCancelHandler(), [`POST ${AGENT_ROUTE_PATHS.mcpProbe}`]: mcpProbeHandler, + [ANNOTATION_HANDLER_KEYS.llmGenerate]: createLlmGenerateHandler(deps), + [ANNOTATION_HANDLER_KEYS.mutationPrepare]: + createMutationPrepareHandler(deps), + [ANNOTATION_HANDLER_KEYS.qualityCompose]: + createQualityComposeHandler(deps), }); } diff --git a/src/main/agent/llm/client.ts b/src/main/agent/llm/client.ts index 3c12398..8a64d04 100644 --- a/src/main/agent/llm/client.ts +++ b/src/main/agent/llm/client.ts @@ -180,8 +180,10 @@ export class LlmClient { tools?: ToolSpec[]; toolChoice?: 'auto' | 'any' | 'none'; signal?: AbortSignal; + maxTokens?: number; + temperature?: number; }): AsyncGenerator { - const body = this.buildBody({ ...params, stream: true }); + const body = this.buildBody({ ...params, stream: true, maxTokens: params.maxTokens, temperature: params.temperature }); const res = await this.post(body, params.signal); const bodyStream = res.body; if (!bodyStream) throw new Error('LLM 响应没有 body'); @@ -233,8 +235,15 @@ export class LlmClient { tools?: ToolSpec[]; toolChoice?: 'auto' | 'any' | 'none'; signal?: AbortSignal; + maxTokens?: number; + temperature?: number; }): Promise { - const body = this.buildBody({ ...params, stream: false }); + const body = this.buildBody({ + ...params, + stream: false, + maxTokens: params.maxTokens, + temperature: params.temperature, + }); const res = await this.post(body, params.signal); const json = (await res.json()) as unknown; return parseCompletion(json); From f41390dcf51e5ae06b9ac1db9bc2353373c22526 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 02:46:33 +0800 Subject: [PATCH 09/20] =?UTF-8?q?feat(agent):=20=E8=BF=81=E7=A7=BB=20map-d?= =?UTF-8?q?etection-boxes=EF=BC=88=E9=80=90=E6=A1=86=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20+=20=E5=90=AF=E5=8F=91=E5=BC=8F=E6=98=A0?= =?UTF-8?q?=E5=B0=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 5 个支撑模块:scope(中文范围推断,含贪婪正则导致的「同一行合并」行为)、visionPolicy(81 项 COCO 类名表)、heuristic(三级优先级的类名/OCR 匹配)、validation(四条校验规则 + 逐框重试候选)、labelPool(整图 preflight 缩池,user_request 刻意不参与缩池以免误伤)。mapLabels 实现两条映射路径(vision_crop 走 nativeImage 批量裁剪 + 校验重试;heuristic 无 LLM)与 single_label 快捷路径。修正 cropImageBatch 的钳位以完全对齐 Python 的 max(left+1, ...) 语义。新增 pyRepr/pyIntListRepr 使含 {name!r} 的错误文案与映射理由逐字一致。四个标注端点至此全部迁移;golden diff 保持 11/11;主进程测试 513/513。 Co-authored-by: Cursor --- .../agent/annotate/annotationSupport.test.ts | 699 ++++++++++++++++++ src/main/agent/annotate/common.ts | 58 ++ src/main/agent/annotate/heuristic.ts | 105 +++ src/main/agent/annotate/labelPool.ts | 216 ++++++ src/main/agent/annotate/mapLabels.ts | 614 +++++++++++++++ src/main/agent/annotate/scope.ts | 310 ++++++++ src/main/agent/annotate/validation.ts | 218 ++++++ src/main/agent/annotate/visionPolicy.ts | 120 +++ src/main/agent/handlers/annotation.ts | 65 ++ src/main/agent/handlers/index.ts | 3 + src/main/agentImage/handlers.ts | 16 +- 11 files changed, 2416 insertions(+), 8 deletions(-) create mode 100644 src/main/agent/annotate/annotationSupport.test.ts create mode 100644 src/main/agent/annotate/heuristic.ts create mode 100644 src/main/agent/annotate/labelPool.ts create mode 100644 src/main/agent/annotate/mapLabels.ts create mode 100644 src/main/agent/annotate/scope.ts create mode 100644 src/main/agent/annotate/validation.ts create mode 100644 src/main/agent/annotate/visionPolicy.ts diff --git a/src/main/agent/annotate/annotationSupport.test.ts b/src/main/agent/annotate/annotationSupport.test.ts new file mode 100644 index 0000000..83d260c --- /dev/null +++ b/src/main/agent/annotate/annotationSupport.test.ts @@ -0,0 +1,699 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { pyIntListRepr, pyRepr } from './common'; +import { + AnnotationScope, + coerceStrList, + expandDetectionAliases, + filterLabelCandidatesByScope, + inferAnnotationScopeFromText, + mergeAnnotationScope, + normalizeDetectionLabel, + SCOPE_KEYWORD_TO_DETECTION, +} from './scope'; +import { + labelsRequireVisionMapping, + normLabel, + YOLO_COCO_CLASS_NAMES, +} from './visionPolicy'; +import { heuristicMapBoxes, NO_MATCH_REASON } from './heuristic'; +import { + candidatesForRetryBox, + findLabelNamesInText, + formatIssuesForRetry, + MappingIssueCode, + validateVisionMappings, +} from './validation'; +import { shouldRunPreflight } from './labelPool'; +import { coordsAreNormalized, toPixelRect } from './mapLabels'; + +describe('pyRepr:Python 字符串 repr 等价', () => { + it('普通字符串用单引号', () => { + expect(pyRepr('car')).toBe("'car'"); + }); + + it('含单引号而无双引号时改用双引号', () => { + expect(pyRepr("it's")).toBe('"it\'s"'); + }); + + it('两种引号都有时用单引号并转义', () => { + expect(pyRepr(`a'b"c`)).toBe("'a\\'b\"c'"); + }); + + it('转义换行与制表符', () => { + expect(pyRepr('a\nb\tc')).toBe("'a\\nb\\tc'"); + }); + + it('null 输出 None', () => { + expect(pyRepr(null)).toBe('None'); + }); + + it('中文原样保留', () => { + expect(pyRepr('人脸')).toBe("'人脸'"); + }); +}); + +describe('pyIntListRepr', () => { + it('元素间带空格(Python repr 风格)', () => { + expect(pyIntListRepr([0, 1])).toBe('[0, 1]'); + expect(pyIntListRepr([3])).toBe('[3]'); + }); +}); + +describe('coerceStrList:Pydantic 校验器等价', () => { + it('null 返回空数组', () => { + expect(coerceStrList(null)).toEqual([]); + expect(coerceStrList(undefined)).toEqual([]); + }); + + it('非空字符串包成单元素数组', () => { + expect(coerceStrList(' a ')).toEqual(['a']); + }); + + it('空字符串返回空数组', () => { + expect(coerceStrList(' ')).toEqual([]); + }); + + it('数组逐项 str+trim 并丢弃空项', () => { + expect(coerceStrList([' a ', '', ' ', 'b'])).toEqual(['a', 'b']); + }); + + it('数字项被转成字符串', () => { + expect(coerceStrList([1, 2])).toEqual(['1', '2']); + }); + + it('其它类型返回空数组', () => { + expect(coerceStrList(42)).toEqual([]); + expect(coerceStrList({ a: 1 })).toEqual([]); + }); +}); + +describe('normalizeDetectionLabel', () => { + it('strip + lower + 下划线换空格 + 折叠空白', () => { + expect(normalizeDetectionLabel(' Sports_Ball ')).toBe('sports ball'); + expect(normalizeDetectionLabel('a b')).toBe('a b'); + }); +}); + +describe('expandDetectionAliases', () => { + it('中文关键词展开为英文别名', () => { + expect(expandDetectionAliases(['篮球'])).toEqual(['sports ball', 'basketball']); + }); + + it('未命中的词原样作为别名输出', () => { + expect(expandDetectionAliases(['unknown_term'])).toEqual(['unknown_term']); + }); + + it('按归一化值去重但输出原值', () => { + expect(expandDetectionAliases(['人', '人物', '人体'])).toEqual(['person']); + }); + + it('空词被跳过', () => { + expect(expandDetectionAliases(['', ' '])).toEqual([]); + }); +}); + +describe('inferAnnotationScopeFromText:范围推断', () => { + it('「只标注人脸」推断出 person/face', () => { + const scope = inferAnnotationScopeFromText('只标注人脸'); + expect(scope.include_detection_labels).toEqual(['person', 'face']); + expect(scope.scope_summary).toBe('仅标注人脸'); + }); + + it('「不要标注篮球」推断出排除项', () => { + const scope = inferAnnotationScopeFromText('不要标注篮球'); + expect(scope.exclude_detection_labels).toEqual(['sports ball', 'basketball']); + }); + + it('取分隔符前的第一段作为范围词', () => { + const scope = inferAnnotationScopeFromText('只标注人脸,其他都不要'); + expect(scope.include_detection_labels).toEqual(['person', 'face']); + }); + + it('无「只/仅」但有「人脸」时触发兜底', () => { + const scope = inferAnnotationScopeFromText('图里有人脸'); + expect(scope.include_detection_labels).toEqual(['person', 'face']); + expect(scope.scope_summary).toBe('仅标注人脸/人物'); + }); + + it('「人物」+「标注」触发另一条兜底', () => { + const scope = inferAnnotationScopeFromText('标注一下人物'); + expect(scope.include_detection_labels).toEqual(['person']); + expect(scope.scope_summary).toBe('仅标注人物'); + }); + + it('篮球 + 否定词触发特殊排除', () => { + const scope = inferAnnotationScopeFromText('篮球不要'); + expect(scope.exclude_detection_labels).toContain('sports ball'); + }); + + it('同一行内两次「只标注」会被贪婪正则合并为单个范围词', () => { + // `(.{1,24})` 是贪婪的且 `.` 匹配「只/标/注」,因此同一行不会有第二个匹配 + const scope = inferAnnotationScopeFromText('只标注人脸只标注汽车'); + expect(scope.include_detection_labels).toEqual(['人脸只标注汽车']); + expect(scope.scope_summary).toBe(''); + }); + + it('换行分隔时产生多个范围词,最后一个决定 summary', () => { + const scope = inferAnnotationScopeFromText('只标注人脸\n只标注汽车'); + expect(scope.scope_summary).toBe('仅标注汽车'); + expect(scope.include_detection_labels).toContain('person'); + expect(scope.include_detection_labels).toContain('car'); + }); + + it('exclude 侧第一个命中决定 summary', () => { + const scope = inferAnnotationScopeFromText('不要猫\n不要狗'); + expect(scope.scope_summary).toBe('排除猫'); + expect(scope.exclude_detection_labels).toEqual(['cat', 'dog']); + }); + + it('include 已设 summary 时 exclude 不覆盖', () => { + const scope = inferAnnotationScopeFromText('只标注人脸\n不要狗'); + expect(scope.scope_summary).toBe('仅标注人脸'); + expect(scope.exclude_detection_labels).toEqual(['dog']); + }); + + it('label_names 在「只/仅」语境下收进 include_label_names', () => { + const scope = inferAnnotationScopeFromText('只要 Curry', { + labelNames: ['Curry', 'James'], + }); + expect(scope.include_label_names).toEqual(['Curry']); + }); + + it('无「只/仅」时不收 label_names', () => { + const scope = inferAnnotationScopeFromText('Curry 在哪', { + labelNames: ['Curry'], + }); + expect(scope.include_label_names).toEqual([]); + }); + + it('exclude_label_names 推断恒为空', () => { + expect(inferAnnotationScopeFromText('不要 dog').exclude_label_names).toEqual([]); + }); + + it('空文本返回全默认', () => { + const scope = inferAnnotationScopeFromText(' '); + expect(scope.isRestricted()).toBe(false); + }); + + it('超出 24 字的范围词被正则截断为 24 字后采纳', () => { + // 正则本身已限 1–24 字符,因此超长输入会被截断而非拒绝 + const scope = inferAnnotationScopeFromText(`只标注${'很'.repeat(30)}`); + expect(scope.include_detection_labels).toEqual(['很'.repeat(24)]); + }); +}); + +describe('mergeAnnotationScope', () => { + it('base 为空字段时被 inferred 覆盖', () => { + const merged = mergeAnnotationScope( + { scope_summary: '', include_detection_labels: [] }, + '只标注人脸', + ); + expect(merged.scope_summary).toBe('仅标注人脸'); + expect(merged.include_detection_labels).toEqual(['person', 'face']); + }); + + it('base 已有值时不覆盖', () => { + const merged = mergeAnnotationScope( + { scope_summary: '已有摘要', include_detection_labels: ['car'] }, + '只标注人脸', + ); + expect(merged.scope_summary).toBe('已有摘要'); + expect(merged.include_detection_labels).toEqual(['car']); + }); + + it('null 输入等价空 scope', () => { + expect(mergeAnnotationScope(null, '只标注猫').include_detection_labels).toEqual([ + 'cat', + ]); + }); +}); + +describe('filterLabelCandidatesByScope', () => { + const candidates = [ + { id: '1', name: 'Curry' }, + { id: '2', name: 'James' }, + { id: '3', name: 'jokic' }, + ]; + + it('无 include/exclude 时返回同一对象', () => { + const scope = new AnnotationScope(); + expect(filterLabelCandidatesByScope(candidates, scope)).toBe(candidates); + }); + + it('include 精确匹配(大小写不敏感)', () => { + const scope = new AnnotationScope(); + scope.include_label_names = ['curry', 'JOKIC']; + expect( + filterLabelCandidatesByScope(candidates, scope).map((c) => c.id), + ).toEqual(['1', '3']); + }); + + it('exclude 精确匹配', () => { + const scope = new AnnotationScope(); + scope.exclude_label_names = ['james']; + expect( + filterLabelCandidatesByScope(candidates, scope).map((c) => c.id), + ).toEqual(['1', '3']); + }); + + it('是精确匹配而非子串', () => { + const scope = new AnnotationScope(); + scope.include_label_names = ['curr']; + // 过滤后为空 → 回退返回原始全量(scope 失效) + expect(filterLabelCandidatesByScope(candidates, scope)).toBe(candidates); + }); + + it('过滤后为空时回退返回全量', () => { + const scope = new AnnotationScope(); + scope.include_label_names = ['nonexistent']; + expect(filterLabelCandidatesByScope(candidates, scope)).toBe(candidates); + }); + + it('候选名不 strip(带空格的不会命中)', () => { + const scope = new AnnotationScope(); + scope.include_label_names = ['curry']; + const spaced = [{ id: '1', name: ' curry ' }]; + expect(filterLabelCandidatesByScope(spaced, scope)).toBe(spaced); + }); +}); + +describe('YOLO_COCO_CLASS_NAMES', () => { + it('共 81 项(COCO 80 + face)', () => { + expect(YOLO_COCO_CLASS_NAMES.size).toBe(81); + }); + + it('含关键项', () => { + for (const name of ['person', 'car', 'sports ball', 'dining table', 'face']) { + expect(YOLO_COCO_CLASS_NAMES.has(name)).toBe(true); + } + }); + + it('不含球员姓名', () => { + expect(YOLO_COCO_CLASS_NAMES.has('curry')).toBe(false); + }); +}); + +describe('labelsRequireVisionMapping', () => { + it('全部未命中标准类名时返回 true', () => { + expect(labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'James' }])).toBe( + true, + ); + }); + + it('任一命中时返回 false', () => { + expect(labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'person' }])).toBe( + false, + ); + }); + + it('无有效标签名时返回 false', () => { + expect(labelsRequireVisionMapping([])).toBe(false); + expect(labelsRequireVisionMapping([{ name: ' ' }])).toBe(false); + }); + + it('下划线归一为空格后命中', () => { + expect(labelsRequireVisionMapping([{ name: 'sports_ball' }])).toBe(false); + }); + + it('normLabel 不折叠内部连续空白(与 normalizeDetectionLabel 不同)', () => { + expect(normLabel('a b')).toBe('a b'); + expect(normalizeDetectionLabel('a b')).toBe('a b'); + }); +}); + +describe('heuristicMapBoxes', () => { + const candidates = [ + { id: 'l1', name: 'person' }, + { id: 'l2', name: 'car' }, + ]; + + it('精确匹配(class_name)', () => { + const result = heuristicMapBoxes([{ class_name: 'car' }], candidates); + expect(result[0].label_id).toBe('l2'); + expect(result[0].reason).toBe("检测类名 'car' 与标签匹配"); + }); + + it('class_name 为空时回退 detection_label', () => { + const result = heuristicMapBoxes([{ detection_label: 'person' }], candidates); + expect(result[0].label_id).toBe('l1'); + }); + + it('精确匹配大小写不敏感', () => { + expect(heuristicMapBoxes([{ class_name: 'CAR' }], candidates)[0].label_id).toBe( + 'l2', + ); + }); + + it('无匹配时 label_id 为空且 reason 为兜底文案', () => { + const result = heuristicMapBoxes([{ class_name: 'airplane' }], candidates); + expect(result[0].label_id).toBe(''); + expect(result[0].reason).toBe(NO_MATCH_REASON); + }); + + it('唯一部分匹配(双向子串)', () => { + const result = heuristicMapBoxes( + [{ class_name: 'sports' }], + [{ id: 'l1', name: 'sports ball' }, { id: 'l2', name: 'car' }], + ); + expect(result[0].label_id).toBe('l1'); + expect(result[0].reason).toContain('部分匹配'); + }); + + it('部分匹配多命中时不选', () => { + const result = heuristicMapBoxes( + [{ class_name: 'a' }], + [{ id: 'l1', name: 'abc' }, { id: 'l2', name: 'abd' }], + ); + expect(result[0].label_id).toBe(''); + }); + + it('OCR 唯一命中', () => { + const result = heuristicMapBoxes( + [{ class_name: 'unknown' }], + [{ id: 'l1', name: 'curry' }, { id: 'l2', name: 'jokic' }], + '球员 curry 在场上', + ); + expect(result[0].label_id).toBe('l1'); + expect(result[0].reason).toContain('OCR 含标签名'); + }); + + it('OCR 多命中时不选', () => { + const result = heuristicMapBoxes( + [{ class_name: 'unknown' }], + [{ id: 'l1', name: 'curry' }, { id: 'l2', name: 'jokic' }], + 'curry 与 jokic', + ); + expect(result[0].label_id).toBe(''); + }); + + it('box_index 缺失时回退数组下标', () => { + const result = heuristicMapBoxes( + [{ class_name: 'car' }, { class_name: 'person' }], + candidates, + ); + expect(result.map((r) => r.box_index)).toEqual([0, 1]); + }); + + it('保留显式 box_index', () => { + const result = heuristicMapBoxes( + [{ box_index: 7, class_name: 'car' }], + candidates, + ); + expect(result[0].box_index).toBe(7); + }); +}); + +describe('validateVisionMappings', () => { + const candidates = [ + { id: 'p1', name: 'Curry' }, + { id: 'p2', name: 'James' }, + ]; + + it('全部合法时 ok', () => { + const result = validateVisionMappings({ + mappings: [ + { box_index: 0, label_id: 'p1', reason: '图像中左侧球员' }, + { box_index: 1, label_id: 'p2', reason: '图像中右侧球员' }, + ], + candidates, + instanceLabels: true, + }); + expect(result.ok).toBe(true); + }); + + it('无效 label_id 报错并跳过后续校验', () => { + const result = validateVisionMappings({ + mappings: [{ box_index: 0, label_id: 'nope', reason: '无法确定' }], + candidates, + instanceLabels: true, + }); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].code).toBe(MappingIssueCode.INVALID_LABEL_ID); + expect(result.issues[0].message).toBe("label_id='nope' 不在候选中"); + }); + + it('重复标签在实例标签场景报错(每个框各一条)', () => { + const result = validateVisionMappings({ + mappings: [ + { box_index: 0, label_id: 'p1', reason: '' }, + { box_index: 1, label_id: 'p1', reason: '' }, + ], + candidates, + instanceLabels: true, + }); + const dup = result.issues.filter( + (i) => i.code === MappingIssueCode.DUPLICATE_LABEL, + ); + expect(dup).toHaveLength(2); + expect(dup[0].message).toContain("标签 'Curry' 被分配给多个框 [0, 1]"); + }); + + it('非实例标签不检查重复', () => { + const result = validateVisionMappings({ + mappings: [ + { box_index: 0, label_id: 'p1', reason: '' }, + { box_index: 1, label_id: 'p1', reason: '' }, + ], + candidates, + instanceLabels: false, + }); + expect(result.ok).toBe(true); + }); + + it('reason 说无法确定却给了 label_id → 报错', () => { + const result = validateVisionMappings({ + mappings: [{ box_index: 0, label_id: 'p1', reason: '无法确定这是谁' }], + candidates, + instanceLabels: true, + }); + expect( + result.issues.some( + (i) => i.code === MappingIssueCode.REASON_CONTRADICTS_LABEL, + ), + ).toBe(true); + }); + + it('reason 说无法确定且 label_id 为空 → 合法', () => { + const result = validateVisionMappings({ + mappings: [{ box_index: 0, label_id: '', reason: '无法确定' }], + candidates, + instanceLabels: true, + }); + expect(result.ok).toBe(true); + }); + + it('reason 恰好指向另一个候选名 → 报 mismatch', () => { + const result = validateVisionMappings({ + mappings: [{ box_index: 0, label_id: 'p2', reason: '这是 Curry' }], + candidates, + instanceLabels: true, + }); + const mismatch = result.issues.find( + (i) => i.code === MappingIssueCode.REASON_NAME_MISMATCH, + ); + expect(mismatch).toBeDefined(); + expect(mismatch?.message).toBe("reason 指向 'Curry',但 label_id 对应 'James'"); + }); + + it('reason 提到两个候选名时不报 mismatch', () => { + const result = validateVisionMappings({ + mappings: [ + { box_index: 0, label_id: 'p2', reason: 'Curry 或 James 之一' }, + ], + candidates, + instanceLabels: true, + }); + expect( + result.issues.some( + (i) => i.code === MappingIssueCode.REASON_NAME_MISMATCH, + ), + ).toBe(false); + }); + + it('reason 未提到候选名时不报 mismatch', () => { + const result = validateVisionMappings({ + mappings: [{ box_index: 0, label_id: 'p2', reason: '右侧的球员' }], + candidates, + instanceLabels: true, + }); + expect(result.ok).toBe(true); + }); + + it('issuesForBox 只返回该框问题', () => { + const result = validateVisionMappings({ + mappings: [ + { box_index: 0, label_id: 'nope', reason: '' }, + { box_index: 1, label_id: 'p1', reason: '' }, + ], + candidates, + instanceLabels: true, + }); + expect(result.issuesForBox(0)).toHaveLength(1); + expect(result.issuesForBox(1)).toHaveLength(0); + }); +}); + +describe('formatIssuesForRetry', () => { + it('空列表返回空串', () => { + expect(formatIssuesForRetry([])).toBe(''); + }); + + it('格式为 [code] message,用「;」连接', () => { + expect( + formatIssuesForRetry([ + { + boxIndex: 0, + code: MappingIssueCode.DUPLICATE_LABEL, + message: 'msg1', + }, + { + boxIndex: 1, + code: MappingIssueCode.INVALID_LABEL_ID, + message: 'msg2', + }, + ]), + ).toBe('[duplicate_label] msg1;[invalid_label_id] msg2'); + }); +}); + +describe('candidatesForRetryBox', () => { + const candidates = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + ]; + + it('空占用集合时原样返回', () => { + expect(candidatesForRetryBox(candidates, new Set())).toBe(candidates); + }); + + it('排除已占用的 id', () => { + const result = candidatesForRetryBox(candidates, new Set(['a'])); + expect(result.map((c) => c.id)).toEqual(['b', 'c']); + }); + + it('保留当前框原有 label_id', () => { + const result = candidatesForRetryBox(candidates, new Set(['a']), 'a'); + expect(result.map((c) => c.id)).toEqual(['a', 'b', 'c']); + }); + + it('全部被排除时回退全量', () => { + const result = candidatesForRetryBox(candidates, new Set(['a', 'b', 'c'])); + expect(result).toBe(candidates); + }); +}); + +describe('findLabelNamesInText', () => { + it('返回原始大小写并按候选顺序', () => { + expect( + findLabelNamesInText('jokic 与 Curry', [ + { name: 'Curry' }, + { name: 'Jokic' }, + ]), + ).toEqual(['Curry', 'Jokic']); + }); + + it('按 lower 去重', () => { + expect( + findLabelNamesInText('curry', [{ name: 'Curry' }, { name: 'curry' }]), + ).toEqual(['Curry']); + }); + + it('空文本返回空数组', () => { + expect(findLabelNamesInText('', [{ name: 'Curry' }])).toEqual([]); + }); +}); + +describe('shouldRunPreflight', () => { + it('off 恒不跑', () => { + expect( + shouldRunPreflight('off', { candidateCount: 100, boxCount: 1, minExtra: 0 }), + ).toBe(false); + }); + + it('always 在框数 > 0 时恒跑', () => { + expect( + shouldRunPreflight('always', { candidateCount: 1, boxCount: 1, minExtra: 99 }), + ).toBe(true); + }); + + it('框数为 0 时一律不跑', () => { + expect( + shouldRunPreflight('always', { candidateCount: 10, boxCount: 0, minExtra: 0 }), + ).toBe(false); + }); + + it('auto 需候选数比框数多出至少 minExtra', () => { + expect( + shouldRunPreflight('auto', { candidateCount: 5, boxCount: 3, minExtra: 2 }), + ).toBe(true); + expect( + shouldRunPreflight('auto', { candidateCount: 4, boxCount: 3, minExtra: 2 }), + ).toBe(false); + }); +}); + +describe('coordsAreNormalized', () => { + it('全部 <= 1.5 视为归一化', () => { + expect(coordsAreNormalized([{ x: 0.1, y: 0.2, width: 0.3, height: 0.4 }])).toBe( + true, + ); + }); + + it('任一 > 1.5 视为像素坐标', () => { + expect(coordsAreNormalized([{ x: 100, y: 0.2, width: 0.3, height: 0.4 }])).toBe( + false, + ); + }); + + it('非法值视为像素坐标', () => { + expect(coordsAreNormalized([{ x: 'abc' }])).toBe(false); + }); + + it('空数组返回 false', () => { + expect(coordsAreNormalized([])).toBe(false); + }); +}); + +describe('toPixelRect', () => { + const box = { + box_index: 0, + x: 0.25, + y: 0.5, + width: 0.5, + height: 0.25, + class_name: 'person', + detection_label: 'person', + }; + + it('归一化框按图片尺寸换算', () => { + expect(toPixelRect(box as never, 800, 400, true)).toEqual({ + left: 200, + top: 200, + right: 600, + bottom: 300, + }); + }); + + it('像素框原样作为矩形', () => { + expect(toPixelRect(box as never, 800, 400, false)).toEqual({ + left: 0.25, + top: 0.5, + right: 0.75, + bottom: 0.75, + }); + }); +}); + +describe('SCOPE_KEYWORD_TO_DETECTION', () => { + it('含 12 个关键词', () => { + expect(Object.keys(SCOPE_KEYWORD_TO_DETECTION)).toHaveLength(12); + }); + + it('篮球映射到两个别名', () => { + expect(SCOPE_KEYWORD_TO_DETECTION['篮球']).toEqual(['sports ball', 'basketball']); + }); +}); diff --git a/src/main/agent/annotate/common.ts b/src/main/agent/annotate/common.ts index 2e5731e..78c1e20 100644 --- a/src/main/agent/annotate/common.ts +++ b/src/main/agent/annotate/common.ts @@ -219,3 +219,61 @@ export function requireCredentials(params: { throw new AnnotateHttpError(400, 'provider_credentials_required'); } } + +// ── Python repr 等价实现 ──────────────────────────────────────────── + +/** + * 等价于 Python 的 `repr(str)`。 + * + * 存在意义:多处错误文案与映射理由用 `{name!r}` 插值(如 + * `检测类名 'car' 与标签匹配`),这些字符串会**回传给前端展示**, + * 因此引号风格与转义必须一致。 + * + * Python 的规则:优先单引号;若串内有单引号而无双引号则改用双引号; + * 两者都有时用单引号并转义内部单引号。控制字符转义为 `\n` / `\t` 等。 + */ +export function pyRepr(value: unknown): string { + if (value === null || value === undefined) return 'None'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + const text = String(value); + const hasSingle = text.includes("'"); + const hasDouble = text.includes('"'); + const quote = hasSingle && !hasDouble ? '"' : "'"; + + let body = ''; + for (const ch of text) { + switch (ch) { + case '\\': + body += '\\\\'; + break; + case '\n': + body += '\\n'; + break; + case '\r': + body += '\\r'; + break; + case '\t': + body += '\\t'; + break; + case '"': + body += quote === '"' ? '\\"' : '"'; + break; + case "'": + body += quote === "'" ? "\\'" : "'"; + break; + default: + body += ch; + break; + } + } + return `${quote}${body}${quote}`; +} + +/** 等价于 Python 对 list[int] 的 `repr`(元素间为 `, ` 带空格)。 */ +export function pyIntListRepr(values: number[]): string { + return `[${values.join(', ')}]`; +} + +/** 等价于 Python `json.dumps(value, ensure_ascii=False)`(紧凑、带分隔空格)。 */ +export { pythonJsonDumps } from '../json'; diff --git a/src/main/agent/annotate/heuristic.ts b/src/main/agent/annotate/heuristic.ts new file mode 100644 index 0000000..82548af --- /dev/null +++ b/src/main/agent/annotate/heuristic.ts @@ -0,0 +1,105 @@ +/** + * 启发式检测框 → 标签映射(无 LLM)。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/heuristic_map_service.py`。 + * + * 三级优先级,命中即停: + * A. 检测类名**精确**匹配标签名 + * B. 检测类名与标签名**唯一**部分匹配(双向子串;多命中则不选) + * C. OCR 文本**唯一**包含某标签名 + * + * 注意各级的名字处理不一致(与 Python 一致,不要统一): + * - 精确索引:候选名 `strip().lower()` + * - 部分匹配与 OCR:候选名**只 lower,不 strip** + */ + +import { pyRepr } from './common'; + +export interface HeuristicBox { + box_index?: number; + class_name?: unknown; + detection_label?: unknown; + confidence?: unknown; +} + +export interface HeuristicMapping { + box_index: number; + label_id: string; + reason: string; +} + +/** 无匹配时的理由文案。 */ +export const NO_MATCH_REASON = '未能自动映射'; + +/** + * 启发式映射。 + * + * @param boxes 检测框(`class_name` 优先,falsy 时回退 `detection_label`) + * @param labelCandidates 项目标签候选 + * @param ocrText 可选的 OCR 文本 + */ +export function heuristicMapBoxes( + boxes: HeuristicBox[], + labelCandidates: Array>, + ocrText = '', +): HeuristicMapping[] { + // 精确索引:同名后者覆盖前者 + const byName = new Map>(); + for (const item of labelCandidates) { + const name = String(item.name ?? '').trim().toLowerCase(); + if (name) byName.set(name, item); + } + + // 部分匹配与 OCR 用的清单:浅拷贝,不去重不去空 + const flat = [...labelCandidates]; + const ocrLower = (ocrText ?? '').toLowerCase(); + + const results: HeuristicMapping[] = []; + + boxes.forEach((box, idx) => { + const det = String(box.class_name || box.detection_label || '').trim(); + let chosen: Record | null = null; + let reason = ''; + + // 优先级 A:精确匹配 + if (det) { + const hit = byName.get(det.toLowerCase()); + if (hit) { + chosen = hit; + reason = `检测类名 ${pyRepr(det)} 与标签匹配`; + } + } + + // 优先级 B:唯一部分匹配(双向子串) + if (!chosen && det) { + const detLower = det.toLowerCase(); + const partial = flat.filter((n) => { + const name = String(n.name ?? '').toLowerCase(); + return detLower.includes(name) || name.includes(detLower); + }); + if (partial.length === 1) { + chosen = partial[0]; + reason = `检测类名与标签 ${pyRepr(chosen.name)} 部分匹配`; + } + } + + // 优先级 C:OCR 唯一命中 + if (!chosen && ocrLower) { + const nameHits = flat.filter((n) => + ocrLower.includes(String(n.name ?? '').toLowerCase()), + ); + if (nameHits.length === 1) { + chosen = nameHits[0]; + reason = `OCR 含标签名 ${pyRepr(chosen.name)}`; + } + } + + results.push({ + box_index: box.box_index !== undefined ? Number(box.box_index) : idx, + label_id: chosen ? String(chosen.id ?? '') : '', + reason: reason || (chosen ? reason : NO_MATCH_REASON), + }); + }); + + return results; +} diff --git a/src/main/agent/annotate/labelPool.ts b/src/main/agent/annotate/labelPool.ts new file mode 100644 index 0000000..064771e --- /dev/null +++ b/src/main/agent/annotate/labelPool.ts @@ -0,0 +1,216 @@ +/** + * 标签候选池解析(可选整图 preflight 缩池)。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/label_candidate_resolver.py`。 + * + * 流程:先用 scope 过滤 → 满足条件时让视觉模型看**整图**判断「本图实际可能出现哪些 + * 标签」→ 依此缩小候选池。 + * + * 设计意图(不要扩权):文档字符串明确说明,`user_request` / `intent_summary` / + * `box_count` **本身不参与候选池缩小**,只作为 preflight 的提示输入——避免 + * 「prepare 摘要里举例提到的标签」误伤候选池。 + * + * preflight 只在**同时**满足以下条件时执行: + * - 有 LLM 且有图像 + * - `_should_run_preflight` 为真(见下) + * + * 失败与不确定一律静默保持原候选池(`uncertain=true` 时不缩池)。 + */ + +import type { ChatMessage, LlmClient } from '../llm/client'; +import { extractJsonObject, pythonJsonDumps } from './common'; +import type { AnnotationScope } from './scope'; +import { filterLabelCandidatesByScope } from './scope'; +import type { AgentSettings } from '../config'; +import type { ImageService } from '../services/imageService'; + +export type PreflightMode = 'off' | 'auto' | 'always'; + +/** 整图 preflight 系统提示词(行为契约,需原样保留)。 */ +export const PREFLIGHT_SYSTEM = `你是视觉标注准备助手。根据整图与用户意图,从标签候选中选出本图实际可能出现的 label_id。 +不要猜测图中没有的目标。若无法可靠判断,present_label_ids 必须为空并设置 uncertain=true。 +只输出 JSON:{"present_label_ids":["..."], "uncertain": false}`; + +export interface LabelPoolResult { + candidates: Array>; + /** `full` | `scope` | `preflight`。 */ + source: 'full' | 'scope' | 'preflight'; + excludedNames: string[]; + preflightLabelIds: string[] | null; +} + +/** + * 是否值得跑整图 preflight。 + * + * - `off`:恒不跑 + * - `always`:只要框数 > 0 就跑 + * - `auto`:候选数需比框数多出至少 `minExtra` 个才跑 + */ +export function shouldRunPreflight( + mode: PreflightMode, + params: { candidateCount: number; boxCount: number; minExtra: number }, +): boolean { + if (mode === 'off' || params.boxCount <= 0) return false; + if (mode === 'always') return true; + const extra = params.candidateCount - params.boxCount; + return extra >= params.minExtra; +} + +export interface PreflightParams { + llm: LlmClient; + candidates: Array>; + boxCount: number; + userRequest: string; + intentSummary: string; + settings: AgentSettings; + imageService: ImageService; + imageAbsolutePath?: string; + imageBase64?: string; + signal?: AbortSignal; +} + +/** + * 让视觉模型看整图,判断本图实际可能出现的标签。 + * + * @returns `[presentLabelIds, uncertain]` + */ +export async function preflightLabelPool( + params: PreflightParams, +): Promise<[string[], boolean]> { + const validIds = new Set(params.candidates.map((c) => String(c.id ?? ''))); + + // 只送前 80 个,且保留原值(不转字符串) + const payload = params.candidates.slice(0, 80).map((c) => ({ + id: c.id, + name: c.name, + })); + + const userText = + `用户请求:${params.userRequest}\n` + + `意图:${params.intentSummary}\n` + + `检测框数量:${params.boxCount}\n\n` + + `标签候选:\n${pythonJsonDumps(payload)}`; + + const encoded = await params.imageService.toJpegDataUrl({ + absolutePath: params.imageAbsolutePath, + base64: params.imageBase64, + maxEdge: params.settings.chatVisionMaxEdge, + quality: params.settings.chatVisionJpegQuality, + }); + if (!encoded.ok || !encoded.dataUrl) return [[], true]; + + const messages: ChatMessage[] = [ + { role: 'system', content: PREFLIGHT_SYSTEM }, + { + role: 'user', + content: [ + { type: 'text', text: userText }, + { type: 'image_url', image_url: { url: encoded.dataUrl } }, + ], + }, + ]; + + let content: unknown; + try { + const turn = await params.llm.completeChat({ + messages, + signal: params.signal, + }); + content = turn.content; + } catch { + // 任何异常 → 不确定(不缩池) + return [[], true]; + } + + const data = extractJsonObject(String(content ?? '')); + const uncertain = Boolean(data.uncertain ?? false); + + const rawIds = data.present_label_ids ?? data.label_ids ?? []; + if (!Array.isArray(rawIds)) return [[], true]; + + const present = rawIds + .map((id) => String(id).trim()) + .filter((id) => validIds.has(id)); + + if (uncertain || present.length === 0) return [[], true]; + return [present, false]; +} + +export interface ResolvePoolParams { + llm: LlmClient | null; + allCandidates: Array>; + scope: AnnotationScope; + userRequest: string; + intentSummary: string; + boxCount: number; + settings: AgentSettings; + imageService: ImageService; + /** 是否有可读图像(对应 Python 的 `image_bytes` 真值判断)。 */ + imageAvailable: boolean; + imageAbsolutePath?: string; + imageBase64?: string; + signal?: AbortSignal; +} + +/** 解析出实际用于映射的候选池。 */ +export async function resolveEffectiveLabelCandidates( + params: ResolvePoolParams, +): Promise { + const scoped = filterLabelCandidatesByScope([...params.allCandidates], params.scope); + let source: LabelPoolResult['source'] = + scoped.length < params.allCandidates.length ? 'scope' : 'full'; + + const excluded: string[] = []; + let preflightIds: string[] | null = null; + + const mode = params.settings.annotationLabelPoolPreflight; + const shouldRun = + params.llm !== null && + params.imageAvailable && + shouldRunPreflight(mode, { + candidateCount: scoped.length, + boxCount: params.boxCount, + minExtra: params.settings.annotationLabelPoolPreflightMinExtra, + }); + + if (shouldRun && params.llm) { + const [present, uncertain] = await preflightLabelPool({ + llm: params.llm, + candidates: scoped, + boxCount: params.boxCount, + userRequest: params.userRequest, + intentSummary: params.intentSummary, + settings: params.settings, + imageService: params.imageService, + imageAbsolutePath: params.imageAbsolutePath, + imageBase64: params.imageBase64, + signal: params.signal, + }); + + if (!uncertain && present.length > 0) { + const idSet = new Set(present); + const prefFiltered = scoped.filter((c) => idSet.has(String(c.id ?? ''))); + if (prefFiltered.length > 0) { + preflightIds = present; + for (const c of scoped) { + if (!idSet.has(String(c.id ?? ''))) { + excluded.push(String(c.name ?? '')); + } + } + return { + candidates: prefFiltered, + source: 'preflight', + excludedNames: excluded.filter((n) => n.length > 0), + preflightLabelIds: preflightIds, + }; + } + } + } + + return { + candidates: scoped, + source, + excludedNames: excluded.filter((n) => n.length > 0), + preflightLabelIds: preflightIds, + }; +} diff --git a/src/main/agent/annotate/mapLabels.ts b/src/main/agent/annotate/mapLabels.ts new file mode 100644 index 0000000..2a2347a --- /dev/null +++ b/src/main/agent/annotate/mapLabels.ts @@ -0,0 +1,614 @@ +/** + * 统一的检测框 → 标签映射服务(标注图像类主路径)。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/map_labels_service.py`。 + * + * 两条策略(无纯文本 LLM 回退): + * 1. `vision_crop`:逐框裁剪 + 并发视觉 LLM 映射(可带校验重试) + * 2. `heuristic`:检测类名 / OCR 与标签名匹配(见 `heuristic.ts`) + * + * 特殊快捷:`label_strategy === "single_label_for_all_boxes"` 时直接赋同一 label_id。 + * + * 图像处理差异:Python 用 Pillow 在主进程内裁剪;Node 侧经 `imageService.cropBatch` + * 走主进程的 `nativeImage`(utilityProcess 内不可用)。因此这里先本地算出像素矩形, + * 再一次 RPC 批量裁剪——避免逐框往返。 + */ + +import type { ChatMessage, LlmClient } from '../llm/client'; +import { extractJsonObject, pythonJsonDumps } from './common'; +import { heuristicMapBoxes } from './heuristic'; +import { labelsRequireVisionMapping } from './visionPolicy'; +import { + resolveEffectiveLabelCandidates, + type LabelPoolResult, +} from './labelPool'; +import { AnnotationScope, filterLabelCandidatesByScope } from './scope'; +import { + candidatesForRetryBox, + formatIssuesForRetry, + validateVisionMappings, + type MappingIssue, +} from './validation'; +import type { AgentSettings } from '../config'; +import type { ImageService } from '../services/imageService'; + +/** 逐框视觉映射系统提示词(行为契约,需原样保留)。 */ +export const CROP_VISION_SYSTEM = `你是视觉标注助手。你会收到一张裁剪后的目标区域图。 +请根据图像内容与用户标注意图,从标签候选中选择最合适的 label_id。 +若裁剪区域与任一候选标签语义不符,必须返回空 label_id。 +只输出 JSON:{"label_id":"...","reason":"..."} ;无法确定或与任务无关时 label_id 必须为 ""。`; + +/** 裁剪后送 LLM 的最长边。 */ +const CROP_MAX_EDGE = 768; + +/** 送 LLM 的候选标签上限。 */ +const VISION_CANDIDATE_LIMIT = 40; + +/** 单框视觉映射结果。 */ +export interface BoxMapping { + box_index: number; + label_id: string; + reason: string; +} + +/** 归一化后的检测框。 */ +interface NormalizedBox { + box_index: number; + x: number; + y: number; + width: number; + height: number; + class_name?: unknown; + detection_label?: unknown; + confidence?: unknown; +} + +interface CropRegion { + box_index: number; + data_url: string; + box: NormalizedBox; +} + +/** + * 判断检测框坐标是否为 0~1 归一化格式。 + * + * 任一维度 > 1.5 视为像素坐标;任一值无法转数字也视为像素(返回 false)。 + */ +export function coordsAreNormalized(boxes: Array>): boolean { + for (const b of boxes) { + for (const key of ['x', 'y', 'width', 'height']) { + const value = Number(b[key] ?? 0); + if (!Number.isFinite(value)) return false; + if (value > 1.5) return false; + } + } + return boxes.length > 0; +} + +/** 把归一化框转为像素矩形(不裁剪,仅换算)。 */ +export function toPixelRect( + box: NormalizedBox, + imageWidth: number, + imageHeight: number, + normalized: boolean, +): { left: number; top: number; right: number; bottom: number } { + let { x, y, width, height } = box; + if (normalized) { + x *= imageWidth; + y *= imageHeight; + width *= imageWidth; + height *= imageHeight; + } + return { + left: x, + top: y, + right: x + width, + bottom: y + height, + }; +} + +interface VisionMapBoxParams { + llm: LlmClient; + boxIndex: number; + box: NormalizedBox; + cropDataUrl: string; + candidates: Array>; + userRequest: string; + intentSummary: string; + scopeNote: string; + retryNote: string; + reservedLabelIds: string[] | null; + signal?: AbortSignal; +} + +/** 对单个裁剪区域调用视觉 LLM。 */ +async function visionMapBoxWithCrop(params: VisionMapBoxParams): Promise { + const validIds = new Set(params.candidates.map((c) => String(c.id ?? ''))); + + const retryBlock = params.retryNote.trim() + ? `\n【校验反馈】${params.retryNote.trim()}\n` + : ''; + const reservedBlock = + params.reservedLabelIds && params.reservedLabelIds.length > 0 + ? `\n同图已被其它框占用的 label_id(请勿重复选择):${pythonJsonDumps(params.reservedLabelIds)}\n` + : ''; + + const userText = + `用户请求:${params.userRequest}\n意图:${params.intentSummary}\n` + + `box_index=${params.boxIndex} 检测类名:${params.box.class_name || params.box.detection_label || ''}\n` + + `${params.scopeNote}${retryBlock}${reservedBlock}\n\nlabel_candidates:\n` + + `${pythonJsonDumps(params.candidates.slice(0, VISION_CANDIDATE_LIMIT))}`; + + const messages: ChatMessage[] = [ + { role: 'system', content: CROP_VISION_SYSTEM }, + { + role: 'user', + content: [ + { type: 'text', text: userText }, + { type: 'image_url', image_url: { url: params.cropDataUrl } }, + ], + }, + ]; + + let content = ''; + try { + const turn = await params.llm.completeChat({ messages, signal: params.signal }); + content = typeof turn.content === 'string' ? turn.content : ''; + } catch { + return { box_index: params.boxIndex, label_id: '', reason: '视觉调用失败' }; + } + + const parsed = extractJsonObject(content); + let lid = String(parsed.label_id ?? '').trim(); + if (lid && !validIds.has(lid)) lid = ''; + return { + box_index: params.boxIndex, + label_id: lid, + reason: String(parsed.reason ?? ''), + }; +} + +interface RegionOverrides { + candidates?: Array>; + retryNote?: string; + reservedLabelIds?: string[]; +} + +/** 并发执行多框视觉映射(信号量控制并发度)。 */ +async function visionMapRegionsConcurrent( + llm: LlmClient, + regions: CropRegion[], + options: { + candidates: Array>; + userRequest: string; + intentSummary: string; + scopeNote: string; + concurrency: number; + regionOverrides?: Map; + signal?: AbortSignal; + }, +): Promise { + const limit = Math.max(1, options.concurrency); + const overrides = options.regionOverrides ?? new Map(); + + // 简化的并发闸门:按批执行(保持结果顺序与输入一致) + const results: BoxMapping[] = []; + for (let start = 0; start < regions.length; start += limit) { + const batch = regions.slice(start, start + limit); + // eslint-disable-next-line no-await-in-loop + const mapped = await Promise.all( + batch.map((region) => { + const extra = overrides.get(region.box_index); + return visionMapBoxWithCrop({ + llm, + boxIndex: region.box_index, + box: region.box, + cropDataUrl: region.data_url, + candidates: extra?.candidates ?? options.candidates, + userRequest: options.userRequest, + intentSummary: options.intentSummary, + scopeNote: options.scopeNote, + retryNote: extra?.retryNote ?? '', + reservedLabelIds: extra?.reservedLabelIds ?? null, + signal: options.signal, + }); + }), + ); + results.push(...mapped); + } + return results; +} + +/** 候选标签名列表(丢弃空名)。 */ +function candidateNames(candidates: Array>): string[] { + return candidates + .map((c) => String(c.name ?? '')) + .filter((n) => n.trim().length > 0); +} + +/** 标签候选池调试信息(供 Electron DevTools 展示)。 */ +export function labelPoolDebug(params: { + allCandidates: Array>; + scopedCandidates: Array>; + effectiveCandidates: Array>; + source: string; + excludedNames?: string[]; + preflightLabelIds?: string[] | null; +}): Record { + return { + source: params.source, + project_count: params.allCandidates.length, + scoped_count: params.scopedCandidates.length, + effective_count: params.effectiveCandidates.length, + project_names: candidateNames(params.allCandidates), + scoped_names: candidateNames(params.scopedCandidates), + effective_names: candidateNames(params.effectiveCandidates), + excluded_names: [...(params.excludedNames ?? [])], + preflight_label_ids: [...(params.preflightLabelIds ?? [])], + }; +} + +/** 映射按 box_index 建索引。 */ +function mappingByIndex(mappings: BoxMapping[]): Map { + const map = new Map(); + for (const m of mappings) map.set(Number(m.box_index ?? 0), m); + return map; +} + +/** 已占用的 label_id 集合(可排除某个框)。 */ +function usedLabelIds( + mappings: BoxMapping[], + options: { excludeBox?: number } = {}, +): Set { + const used = new Set(); + for (const m of mappings) { + const idx = Number(m.box_index ?? 0); + if (options.excludeBox !== undefined && idx === options.excludeBox) continue; + const lid = String(m.label_id ?? '').trim(); + if (lid) used.add(lid); + } + return used; +} + +interface ValidationRetryParams { + llm: LlmClient; + regions: CropRegion[]; + candidates: Array>; + allCandidates: Array>; + userRequest: string; + intentSummary: string; + scopeNote: string; + concurrency: number; + instanceLabels: boolean; + maxRetries: number; + validate: boolean; + signal?: AbortSignal; +} + +/** 逐框映射 + 校验失败则带上下文重试(仅重跑失败框)。 */ +async function visionMapWithValidationRetry( + params: ValidationRetryParams, +): Promise<{ mappings: BoxMapping[]; retryRounds: number }> { + const validIds = new Set(params.allCandidates.map((c) => String(c.id ?? ''))); + + let mappings = await visionMapRegionsConcurrent(params.llm, params.regions, { + candidates: params.candidates, + userRequest: params.userRequest, + intentSummary: params.intentSummary, + scopeNote: params.scopeNote, + concurrency: params.concurrency, + signal: params.signal, + }); + + let retryRounds = 0; + if (!params.validate || params.maxRetries <= 0) return { mappings, retryRounds }; + + const regionByIndex = new Map(params.regions.map((r) => [r.box_index, r])); + + for (let attempt = 0; attempt < params.maxRetries; attempt += 1) { + const result = validateVisionMappings({ + mappings: mappings as unknown as Array>, + candidates: params.allCandidates, + instanceLabels: params.instanceLabels, + validIds, + }); + if (result.ok) break; + + const retryIndices = [...new Set(result.issues.map((i) => i.boxIndex))].sort( + (a, b) => a - b, + ); + if (retryIndices.length === 0) break; + + retryRounds += 1; + const overrides = new Map(); + const byIndex = mappingByIndex(mappings); + + for (const boxIndex of retryIndices) { + const used = usedLabelIds(mappings, { excludeBox: boxIndex }); + const currentLid = String(byIndex.get(boxIndex)?.label_id ?? ''); + const boxCandidates = candidatesForRetryBox(params.candidates, used, currentLid); + const boxIssues: MappingIssue[] = result.issuesForBox(boxIndex); + overrides.set(boxIndex, { + candidates: boxCandidates, + retryNote: formatIssuesForRetry(boxIssues), + reservedLabelIds: [...used].sort(), + }); + } + + const retryRegions = retryIndices + .map((i) => regionByIndex.get(i)) + .filter((r): r is CropRegion => r !== undefined); + + // 重试并发度固定 1(对齐 Python) + // eslint-disable-next-line no-await-in-loop + const retried = await visionMapRegionsConcurrent(params.llm, retryRegions, { + candidates: params.candidates, + userRequest: params.userRequest, + intentSummary: params.intentSummary, + scopeNote: params.scopeNote, + concurrency: 1, + regionOverrides: overrides, + signal: params.signal, + }); + + for (const item of retried) { + byIndex.set(Number(item.box_index), item); + } + mappings = params.regions.map( + (r) => byIndex.get(r.box_index) as BoxMapping, + ); + } + + return { mappings, retryRounds }; +} + +export interface MapUnifiedParams { + llm: LlmClient | null; + userRequest: string; + intentSummary: string; + labelCandidates: Array>; + boxes: Array>; + useVision: boolean; + ocrText: string; + scope: AnnotationScope | Record | null; + labelStrategy: string; + singleLabelId: string | null; + imageAbsolutePath: string; + imageBase64: string; + mimeType: string; + settings: AgentSettings; + imageService: ImageService; + /** 图像是否可读(对应 Python 的 `image_bytes is not None`)。 */ + imageAvailable: boolean; + signal?: AbortSignal; +} + +/** 统一映射入口。 */ +export async function mapDetectionBoxesToLabelsUnified( + params: MapUnifiedParams, +): Promise> { + const scopeModel = + params.scope instanceof AnnotationScope + ? params.scope + : AnnotationScope.fromPayload(params.scope); + const settings = params.settings; + + const scopedCandidates = filterLabelCandidatesByScope( + [...params.labelCandidates], + scopeModel, + ); + let validIds = new Set(scopedCandidates.map((c) => String(c.id ?? ''))); + + const concurrency = settings.annotationVisionMapConcurrency; + + if (params.boxes.length === 0) { + return { ok: false, error: 'boxes 不能为空', method: 'none' }; + } + + // ── 快捷:所有框同一标签 ────────────────────────────────────────── + if ( + params.labelStrategy === 'single_label_for_all_boxes' && + params.singleLabelId && + validIds.has(params.singleLabelId) + ) { + const mappings = params.boxes.map((b, i) => ({ + box_index: b.box_index !== undefined ? Number(b.box_index) : i, + label_id: params.singleLabelId, + reason: 'single_label_for_all_boxes', + })); + return { + ok: true, + method: 'single_label', + mappings, + unmapped_indices: [], + }; + } + + // ── 归一化检测框 ──────────────────────────────────────────────── + const normalizedBoxes: NormalizedBox[] = params.boxes.map((b, i) => ({ + box_index: b.box_index !== undefined ? Number(b.box_index) : i, + x: Number(b.x ?? 0), + y: Number(b.y ?? 0), + width: Number(b.width ?? 0), + height: Number(b.height ?? 0), + class_name: b.class_name || b.detection_label, + detection_label: b.detection_label || b.class_name, + confidence: b.confidence, + })); + + // ── 路径 1:逐框裁剪视觉映射 ───────────────────────────────────── + if (params.useVision && params.llm !== null) { + if (!params.imageAvailable) { + return { + ok: false, + error: 'image_unavailable', + method: 'vision_crop', + hint: '视觉映射需要可读本地 image_absolute_path 或 image_base64', + }; + } + + let scopeNote = ''; + if (scopeModel.isRestricted()) { + const summary = + scopeModel.scope_summary || + pythonJsonDumps(scopeModel.toPayload()); + scopeNote = `\n用户标注范围:${summary}`; + } + + const pool: LabelPoolResult = await resolveEffectiveLabelCandidates({ + llm: params.llm, + allCandidates: scopedCandidates, + scope: scopeModel, + userRequest: params.userRequest, + intentSummary: params.intentSummary, + boxCount: normalizedBoxes.length, + settings, + imageService: params.imageService, + imageAvailable: params.imageAvailable, + imageAbsolutePath: params.imageAbsolutePath, + imageBase64: params.imageBase64, + signal: params.signal, + }); + + const candidates = pool.candidates; + validIds = new Set(candidates.map((c) => String(c.id ?? ''))); + + const normalized = coordsAreNormalized(normalizedBoxes as never); + + // 只探测一次图片尺寸,然后一次性批量裁剪(避免逐框 RPC 往返)。 + // 与 Python 一致:w/h <= 0 或坐标非法的框**不产生裁剪区域**, + // 因而也不会有对应的映射条目。 + const probed = await params.imageService.probe({ + absolutePath: params.imageAbsolutePath || undefined, + base64: params.imageBase64 || undefined, + }); + + const regions: CropRegion[] = []; + if (probed.ok && probed.width != null && probed.height != null) { + const usable: Array<{ + box: NormalizedBox; + rect: { left: number; top: number; right: number; bottom: number }; + }> = []; + for (const box of normalizedBoxes) { + if ( + !Number.isFinite(box.x) || + !Number.isFinite(box.y) || + !Number.isFinite(box.width) || + !Number.isFinite(box.height) + ) { + continue; + } + if (box.width <= 0 || box.height <= 0) continue; + usable.push({ + box, + rect: toPixelRect(box, probed.width, probed.height, normalized), + }); + } + + const cropped = await params.imageService.cropBatch({ + absolutePath: params.imageAbsolutePath || undefined, + base64: params.imageBase64 || undefined, + boxes: usable.map((u) => u.rect), + maxEdge: CROP_MAX_EDGE, + quality: settings.annotationLlmImageJpegQuality, + }); + + if (cropped.ok && cropped.images) { + cropped.images.forEach((dataUrl, index) => { + const entry = usable[index]; + if (!entry || !dataUrl) return; + regions.push({ + box_index: entry.box.box_index, + data_url: dataUrl, + box: entry.box, + }); + }); + } + } + + const instanceLabels = labelsRequireVisionMapping(params.labelCandidates); + const { mappings, retryRounds } = await visionMapWithValidationRetry({ + llm: params.llm, + regions, + candidates, + allCandidates: scopedCandidates, + userRequest: params.userRequest, + intentSummary: params.intentSummary, + scopeNote, + concurrency, + instanceLabels, + maxRetries: settings.annotationVisionMapMaxRetries, + validate: settings.annotationVisionMapValidate, + signal: params.signal, + }); + + const unmapped = mappings + .filter((m) => !m.label_id) + .map((m) => m.box_index); + + return { + ok: true, + method: 'vision_crop', + mappings, + unmapped_indices: unmapped, + label_candidates: candidates, + label_pool_source: pool.source, + label_pool_debug: labelPoolDebug({ + allCandidates: params.labelCandidates, + scopedCandidates, + effectiveCandidates: candidates, + source: pool.source, + excludedNames: pool.excludedNames, + preflightLabelIds: pool.preflightLabelIds, + }), + vision_map_retry_rounds: retryRounds, + next_step: 'finalize_image_change', + }; + } + + // ── 路径 2:启发式映射(无 LLM)────────────────────────────────── + const candidates = scopedCandidates; + const heuristicRaw = heuristicMapBoxes( + normalizedBoxes.map((b) => ({ + box_index: b.box_index, + class_name: b.class_name, + detection_label: b.detection_label, + confidence: b.confidence, + })), + candidates, + params.ocrText, + ); + + const mappings = heuristicRaw.map((m) => ({ + box_index: Number(m.box_index ?? 0), + label_id: String(m.label_id ?? ''), + reason: m.reason ?? '', + })); + + const unmapped = mappings.filter((m) => !m.label_id).map((m) => m.box_index); + const hint = + unmapped.length > 0 + ? '当前未启用视觉模型或未提供图像,多人物/实例标签无法可靠区分;' + + '请使用通过视觉探针的多模态模型,并确保 use_vision_mapping=true' + : ''; + + const poolSource = + scopedCandidates.length < params.labelCandidates.length ? 'scope' : 'full'; + + return { + ok: unmapped.length < mappings.length, + method: 'heuristic', + mappings, + unmapped_indices: unmapped, + label_candidates: candidates, + label_pool_source: poolSource, + label_pool_debug: labelPoolDebug({ + allCandidates: params.labelCandidates, + scopedCandidates, + effectiveCandidates: candidates, + source: poolSource, + }), + hint, + next_step: 'finalize_image_change', + }; +} diff --git a/src/main/agent/annotate/scope.ts b/src/main/agent/annotate/scope.ts new file mode 100644 index 0000000..29b213b --- /dev/null +++ b/src/main/agent/annotate/scope.ts @@ -0,0 +1,310 @@ +/** + * 标注范围(scope):从用户文本推断「只标 X / 不要 Y」的约束。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/annotation_scope.py`。 + * + * 三个易错细节: + * 1. `ONLY_ANNOTATE_RE` 与 `EXCLUDE_RE` 的**可选动词组不同**(后者少了 + * `识别` / `打框` / `标出`)——不要"顺手统一"。 + * 2. `filterLabelCandidatesByScope` 的匹配是**去空格+小写后的精确相等**, + * 且候选名只 lower、**不 strip**(scope 侧由校验器 strip)。 + * 3. 过滤后为空时**回退返回原始全量候选**(视为 scope 失效)。 + */ + +export interface AnnotationScopePayload { + scope_summary: string; + include_detection_labels: string[]; + exclude_detection_labels: string[]; + include_label_names: string[]; + exclude_label_names: string[]; +} + +/** 中文关键词 → 检测类名别名(逐字对齐 Python 的字典)。 */ +export const SCOPE_KEYWORD_TO_DETECTION: Record = { + 人脸: ['person', 'face'], + 面部: ['person', 'face'], + 人体: ['person'], + 人物: ['person'], + 人: ['person'], + 球员: ['person'], + 篮球: ['sports ball', 'basketball'], + 球: ['sports ball', 'basketball', 'ball'], + 汽车: ['car'], + 车: ['car'], + 猫: ['cat'], + 狗: ['dog'], +}; + +/** + * 「只/仅」范围提取正则。 + * + * 注意:JS 的 `.` 默认不匹配换行,与 Python 一致,故无需 `s` 标志。 + */ +const ONLY_ANNOTATE_RE = /(?:只|仅)(?:标注|标|检测|框选|识别|打框|标出)?(.{1,24})/g; + +/** 「不要/排除」范围提取正则(可选动词组比 ONLY 少三项)。 */ +const EXCLUDE_RE = /(?:不要|别|排除|不包括|无需|不用)(?:标注|标|检测|框选)?(.{1,24})/g; + +/** 范围模型。 */ +export class AnnotationScope { + scope_summary = ''; + + include_detection_labels: string[] = []; + + exclude_detection_labels: string[] = []; + + include_label_names: string[] = []; + + exclude_label_names: string[] = []; + + /** 从任意输入构造(null / payload / 普通对象)。 */ + static fromPayload( + payload: AnnotationScopePayload | Record | null | undefined, + ): AnnotationScope { + const scope = new AnnotationScope(); + if (!payload) return scope; + const record = payload as Record; + scope.scope_summary = + typeof record.scope_summary === 'string' ? record.scope_summary : ''; + scope.include_detection_labels = coerceStrList(record.include_detection_labels); + scope.exclude_detection_labels = coerceStrList(record.exclude_detection_labels); + scope.include_label_names = coerceStrList(record.include_label_names); + scope.exclude_label_names = coerceStrList(record.exclude_label_names); + return scope; + } + + toPayload(): AnnotationScopePayload { + return { + scope_summary: this.scope_summary, + include_detection_labels: [...this.include_detection_labels], + exclude_detection_labels: [...this.exclude_detection_labels], + include_label_names: [...this.include_label_names], + exclude_label_names: [...this.exclude_label_names], + }; + } + + isRestricted(): boolean { + return Boolean( + this.scope_summary.trim() || + this.include_detection_labels.length || + this.exclude_detection_labels.length || + this.include_label_names.length || + this.exclude_label_names.length, + ); + } +} + +/** + * Pydantic `field_validator(mode="before")` 的等价实现。 + * + * 非字符串、非数组一律收成空数组(含数字、对象等异常输入)。 + */ +export function coerceStrList(value: unknown): string[] { + if (value === null || value === undefined) return []; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (Array.isArray(value)) { + return value + .map((item) => String(item).trim()) + .filter((item) => item.length > 0); + } + return []; +} + +/** 归一化检测类名:strip → lower → `_` 换空格 → 折叠连续空白。 */ +export function normalizeDetectionLabel(label: string): string { + return (label ?? '') + .trim() + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\s+/g, ' '); +} + +/** + * 把中文关键词展开为检测类名别名。 + * + * 用**原样 term**(未小写)查表;去重键是归一化后的值,但**输出原值**。 + */ +export function expandDetectionAliases(terms: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const term of terms) { + const t = (term ?? '').trim(); + if (!t) continue; + const aliases = SCOPE_KEYWORD_TO_DETECTION[t] ?? [t]; + for (const alias of aliases) { + const nc = normalizeDetectionLabel(alias); + if (nc && !seen.has(nc)) { + seen.add(nc); + out.push(alias); + } + } + } + return out; +} + +/** 提取范围词:取分隔符切分后的第一段。 */ +function extractScopeTerms(text: string, pattern: RegExp): string[] { + const terms: string[] = []; + // 每次调用重置 lastIndex(正则有 g 标志) + pattern.lastIndex = 0; + for (const match of text.matchAll(pattern)) { + let chunk = (match[1] ?? '').trim(); + chunk = chunk.split(/[,。;;、和与及\s]+/)[0]?.trim() ?? ''; + if (chunk && chunk.length <= 24) terms.push(chunk); + } + return terms; +} + +/** 保序去重(等价 `list(dict.fromkeys(...))`)。 */ +function dedupe(items: T[]): T[] { + return [...new Set(items)]; +} + +/** + * 从用户文本推断标注范围。 + * + * summary 的赋值顺序有讲究:include 侧的**最后一个**命中覆盖前面的; + * exclude 侧只在 summary 仍为空时生效(**第一个**排除词生效)。 + */ +export function inferAnnotationScopeFromText( + userText: string, + options: { labelNames?: string[] } = {}, +): AnnotationScope { + const text = (userText ?? '').trim(); + const scope = new AnnotationScope(); + if (!text) return scope; + + const includeTerms = extractScopeTerms(text, ONLY_ANNOTATE_RE); + const excludeTerms = extractScopeTerms(text, EXCLUDE_RE); + + const includeDet: string[] = []; + const excludeDet: string[] = []; + const includeLabels: string[] = []; + + for (const term of includeTerms) { + includeDet.push(...expandDetectionAliases([term])); + if ( + term in SCOPE_KEYWORD_TO_DETECTION || + ['人脸', '面部', '人物', '人体'].includes(term) + ) { + scope.scope_summary = `仅标注${term}`; + } + } + + if (includeTerms.length === 0) { + if (['人脸', '面部'].some((k) => text.includes(k))) { + includeDet.push(...expandDetectionAliases(['人脸'])); + if (!scope.scope_summary) scope.scope_summary = '仅标注人脸/人物'; + } else if (text.includes('人物') && text.includes('标注')) { + includeDet.push(...expandDetectionAliases(['人物'])); + if (!scope.scope_summary) scope.scope_summary = '仅标注人物'; + } + } + + for (const term of excludeTerms) { + excludeDet.push(...expandDetectionAliases([term])); + if (!scope.scope_summary) scope.scope_summary = `排除${term}`; + } + + // 特殊规则:篮球 + 否定词 + if ( + ['篮球', '球'].some((k) => text.includes(k)) && + ['不要', '别', '排除', '不包括', '无需'].some((k) => text.includes(k)) + ) { + excludeDet.push(...expandDetectionAliases(['篮球'])); + } + + for (const name of options.labelNames ?? []) { + if (name && text.includes(name) && ['只', '仅'].some((k) => text.includes(k))) { + includeLabels.push(name); + } + } + + scope.include_detection_labels = dedupe(includeDet); + scope.exclude_detection_labels = dedupe(excludeDet); + scope.include_label_names = dedupe(includeLabels); + // exclude_label_names 推断从不产出,恒为空 + return scope; +} + +/** + * 合并已有 scope 与从文本推断的 scope。 + * + * 规则:**base 该字段为空则用 inferred 覆盖**(取并集)。 + */ +export function mergeAnnotationScope( + scope: AnnotationScopePayload | Record | null | undefined, + userText: string, + options: { labelNames?: string[] } = {}, +): AnnotationScope { + const base = AnnotationScope.fromPayload(scope ?? null); + const inferred = inferAnnotationScopeFromText(userText, options); + + if (!base.scope_summary.trim() && inferred.scope_summary.trim()) { + base.scope_summary = inferred.scope_summary; + } + if ( + base.include_detection_labels.length === 0 && + inferred.include_detection_labels.length > 0 + ) { + base.include_detection_labels = inferred.include_detection_labels; + } + if ( + base.exclude_detection_labels.length === 0 && + inferred.exclude_detection_labels.length > 0 + ) { + base.exclude_detection_labels = inferred.exclude_detection_labels; + } + if ( + base.include_label_names.length === 0 && + inferred.include_label_names.length > 0 + ) { + base.include_label_names = inferred.include_label_names; + } + if ( + base.exclude_label_names.length === 0 && + inferred.exclude_label_names.length > 0 + ) { + base.exclude_label_names = inferred.exclude_label_names; + } + return base; +} + +/** + * 按 scope 的标签名过滤候选。 + * + * **匹配语义是精确相等**(去空格 + 小写),不是子串;检测类名字段不参与过滤。 + * 过滤后为空时回退返回原始全量。 + */ +export function filterLabelCandidatesByScope( + candidates: Array>, + scope: AnnotationScope, +): Array> { + if ( + scope.include_label_names.length === 0 && + scope.exclude_label_names.length === 0 + ) { + return candidates; + } + + const include = new Set(scope.include_label_names.map((n) => n.toLowerCase())); + const exclude = new Set(scope.exclude_label_names.map((n) => n.toLowerCase())); + + const filtered: Array> = []; + for (const candidate of candidates) { + // 候选侧只 lower、不 strip(与 Python 一致) + const name = String(candidate.name ?? '').toLowerCase(); + if (exclude.size > 0 && exclude.has(name)) continue; + if (include.size > 0) { + if (include.has(name)) filtered.push(candidate); + } else { + filtered.push(candidate); + } + } + + return filtered.length > 0 ? filtered : candidates; +} diff --git a/src/main/agent/annotate/validation.ts b/src/main/agent/annotate/validation.ts new file mode 100644 index 0000000..1f58af5 --- /dev/null +++ b/src/main/agent/annotate/validation.ts @@ -0,0 +1,218 @@ +/** + * 视觉映射结果校验。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/map_validation_service.py`。 + * + * 四条规则按 A→D 顺序执行,问题按追加顺序返回: + * A. `invalid_label_id` —— label_id 不在候选中;**该框跳过后续全部校验** + * B. `duplicate_label` —— 同一标签分配给多个框(仅实例标签场景) + * C. `reason_contradicts_label` —— reason 表示无法确定,却返回了非空 label_id + * D. `reason_name_mismatch` —— reason 中恰好提到**一个**候选名且与 label_id 不符 + * + * 规则 D 的「恰好一个」是关键:0 个或多于 1 个都**不报错**(避免误伤)。 + */ + +import { pyIntListRepr, pyRepr } from './common'; + +export const MappingIssueCode = { + DUPLICATE_LABEL: 'duplicate_label', + REASON_NAME_MISMATCH: 'reason_name_mismatch', + REASON_CONTRADICTS_LABEL: 'reason_contradicts_label', + INVALID_LABEL_ID: 'invalid_label_id', +} as const; + +export type MappingIssueCodeType = + (typeof MappingIssueCode)[keyof typeof MappingIssueCode]; + +export interface MappingIssue { + boxIndex: number; + code: MappingIssueCodeType; + message: string; +} + +export class ValidationResult { + readonly issues: MappingIssue[]; + + constructor(issues: MappingIssue[] = []) { + this.issues = issues; + } + + get ok(): boolean { + return this.issues.length === 0; + } + + /** 某个框的全部问题(可能含多个 code)。 */ + issuesForBox(boxIndex: number): MappingIssue[] { + return this.issues.filter((i) => i.boxIndex === boxIndex); + } +} + +/** reason 中表示「无法确定」的表述(大小写不敏感子串)。 */ +const REASON_CONTRADICT_RE = + /无法确认|无法确定|无法选择|不能确定|不符|不匹配|无关|cannot determine|not sure|no match|does not match|unable to/i; + +/** 标签名 → label_id(同名后者覆盖前者)。 */ +function nameToId( + candidates: Array>, +): Map { + const out = new Map(); + for (const c of candidates) { + const name = String(c.name ?? '').trim().toLowerCase(); + const id = String(c.id ?? '').trim(); + if (name && id) out.set(name, id); + } + return out; +} + +/** + * 找出文本中出现的候选标签名。 + * + * 返回**原始大小写**的名字,按候选顺序,按 lower 去重。 + */ +export function findLabelNamesInText( + text: string, + candidates: Array>, +): string[] { + const raw = (text ?? '').toLowerCase(); + if (!raw) return []; + + const seen = new Set(); + const hits: string[] = []; + for (const c of candidates) { + const name = String(c.name ?? '').trim(); + if (!name) continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + if (raw.includes(key)) { + seen.add(key); + hits.push(name); + } + } + return hits; +} + +/** 把问题列表格式化为重试提示(`;` 分隔)。 */ +export function formatIssuesForRetry(issues: MappingIssue[]): string { + if (issues.length === 0) return ''; + return issues.map((i) => `[${i.code}] ${i.message}`).join(';'); +} + +/** + * 为某个重试框筛出可选候选。 + * + * 排除已被其它框占用的 label_id,但**保留该框原本选择的 label_id**。 + * 若全部被排除则回退返回全量(避免无候选可选)。 + */ +export function candidatesForRetryBox( + candidates: Array>, + usedLabelIds: ReadonlySet, + keepLabelId = '', +): Array> { + if (usedLabelIds.size === 0) return candidates; + + const keep = keepLabelId.trim(); + const filtered = candidates.filter((c) => { + const id = String(c.id ?? ''); + return !usedLabelIds.has(id) || id === keep; + }); + + return filtered.length > 0 ? filtered : candidates; +} + +export interface ValidateParams { + mappings: Array>; + candidates: Array>; + /** 是否为实例级标签(如球员姓名)——决定是否检查重复标签。 */ + instanceLabels: boolean; + /** 合法 id 集合;为空集合时回退为从 candidates 自算。 */ + validIds?: ReadonlySet | null; +} + +/** 校验视觉映射结果。 */ +export function validateVisionMappings(params: ValidateParams): ValidationResult { + const ids = + params.validIds && params.validIds.size > 0 + ? params.validIds + : new Set(params.candidates.map((c) => String(c.id ?? ''))); + + const nameToIdMap = nameToId(params.candidates); + + // 同 box_index 后者覆盖前者 + const byBox = new Map>(); + for (const m of params.mappings) { + byBox.set(Number(m.box_index ?? 0), m); + } + + const labelToBoxes = new Map(); + const issues: MappingIssue[] = []; + + // ── 规则 A:无效 label_id(并跳过后续校验)──────────────────────── + const skipped = new Set(); + for (const [boxIndex, m] of byBox) { + const lid = String(m.label_id ?? '').trim(); + if (!lid) continue; + if (!ids.has(lid)) { + issues.push({ + boxIndex, + code: MappingIssueCode.INVALID_LABEL_ID, + message: `label_id=${pyRepr(lid)} 不在候选中`, + }); + skipped.add(boxIndex); + continue; + } + const list = labelToBoxes.get(lid) ?? []; + list.push(boxIndex); + labelToBoxes.set(lid, list); + } + + // ── 规则 B:重复标签(仅实例标签)──────────────────────────────── + if (params.instanceLabels) { + for (const [lid, boxIndices] of labelToBoxes) { + if (boxIndices.length <= 1) continue; + const name = + params.candidates.find((c) => String(c.id ?? '') === lid)?.name ?? lid; + for (const boxIndex of boxIndices) { + issues.push({ + boxIndex, + code: MappingIssueCode.DUPLICATE_LABEL, + message: `标签 ${pyRepr(String(name))} 被分配给多个框 ${pyIntListRepr(boxIndices)}`, + }); + } + } + } + + // ── 规则 C 与 D:逐框检查 ─────────────────────────────────────── + for (const [boxIndex, m] of byBox) { + if (skipped.has(boxIndex)) continue; + const lid = String(m.label_id ?? '').trim(); + const reason = String(m.reason ?? ''); + + // 规则 C:reason 说无法确定,却给了 label_id + if (lid && REASON_CONTRADICT_RE.test(reason)) { + issues.push({ + boxIndex, + code: MappingIssueCode.REASON_CONTRADICTS_LABEL, + message: 'reason 表示无法/不匹配,但返回了非空 label_id', + }); + } + + // 规则 D:reason 恰好提到一个候选名,且与 label_id 不符 + if (!lid || !reason) continue; + const mentioned = findLabelNamesInText(reason, params.candidates); + if (mentioned.length !== 1) continue; + + const mentionedName = mentioned[0].toLowerCase(); + const expectedId = nameToIdMap.get(mentionedName) ?? ''; + if (expectedId && expectedId !== lid) { + const chosenName = + params.candidates.find((c) => String(c.id ?? '') === lid)?.name ?? lid; + issues.push({ + boxIndex, + code: MappingIssueCode.REASON_NAME_MISMATCH, + message: `reason 指向 ${pyRepr(mentioned[0])},但 label_id 对应 ${pyRepr(String(chosenName))}`, + }); + } + } + + return new ValidationResult(issues); +} diff --git a/src/main/agent/annotate/visionPolicy.ts b/src/main/agent/annotate/visionPolicy.ts new file mode 100644 index 0000000..b05f5b7 --- /dev/null +++ b/src/main/agent/annotate/visionPolicy.ts @@ -0,0 +1,120 @@ +/** + * 标签是否需要视觉映射的判定。 + * + * 移植自 `vendor/local-agent/app/agent/annotation/label_vision_policy.py`。 + * + * 用途:当项目标签名与 YOLO/COCO 标准检测类名**完全无交集**时(如球员姓名 + * Curry / James vs `person`),检测器输出的类名无法直接对应项目标签, + * 必须靠视觉模型逐框判断。 + */ + +/** COCO 80 类 + `face`(逐字对齐 Python 的 frozenset,共 81 项)。 */ +export const YOLO_COCO_CLASS_NAMES: ReadonlySet = new Set([ + 'person', + 'bicycle', + 'car', + 'motorcycle', + 'airplane', + 'bus', + 'train', + 'truck', + 'boat', + 'traffic light', + 'fire hydrant', + 'stop sign', + 'parking meter', + 'bench', + 'bird', + 'cat', + 'dog', + 'horse', + 'sheep', + 'cow', + 'elephant', + 'bear', + 'zebra', + 'giraffe', + 'backpack', + 'umbrella', + 'handbag', + 'tie', + 'suitcase', + 'frisbee', + 'skis', + 'snowboard', + 'sports ball', + 'kite', + 'baseball bat', + 'baseball glove', + 'skateboard', + 'surfboard', + 'tennis racket', + 'bottle', + 'wine glass', + 'cup', + 'fork', + 'knife', + 'spoon', + 'bowl', + 'banana', + 'apple', + 'sandwich', + 'orange', + 'broccoli', + 'carrot', + 'hot dog', + 'pizza', + 'donut', + 'cake', + 'chair', + 'couch', + 'potted plant', + 'bed', + 'dining table', + 'toilet', + 'tv', + 'laptop', + 'mouse', + 'remote', + 'keyboard', + 'cell phone', + 'microwave', + 'oven', + 'toaster', + 'sink', + 'refrigerator', + 'book', + 'clock', + 'vase', + 'scissors', + 'teddy bear', + 'hair drier', + 'toothbrush', + 'face', +]); + +/** + * 标签名归一化。 + * + * 注意与 `scope.normalizeDetectionLabel` 的差异:这里**不折叠内部连续空白**。 + */ +export function normLabel(name: string): string { + return (name ?? '').trim().toLowerCase().replace(/_/g, ' '); +} + +/** + * 是否必须依赖视觉映射。 + * + * 规则:全部候选名都**未精确命中**标准类名集合 → `true`; + * 任一命中 → `false`;无有效标签名 → `false`。 + */ +export function labelsRequireVisionMapping( + labelCandidates: Array>, +): boolean { + const names = labelCandidates + .map((c) => normLabel(String(c.name ?? ''))) + .filter((n) => n.length > 0); + + if (names.length === 0) return false; + return !names.some((n) => YOLO_COCO_CLASS_NAMES.has(n)); +} diff --git a/src/main/agent/handlers/annotation.ts b/src/main/agent/handlers/annotation.ts index 32402bd..b98a6d7 100644 --- a/src/main/agent/handlers/annotation.ts +++ b/src/main/agent/handlers/annotation.ts @@ -21,9 +21,11 @@ import { import { formatZodError, LlmGenerateRequestSchema, + MapDetectionBoxesRequestSchema, MutationPrepareRequestSchema, QualityReportComposeRequestSchema, } from '../annotate/schemas'; +import { mapDetectionBoxesToLabelsUnified } from '../annotate/mapLabels'; import { labelNamesOf, prepareMutationAnnotation, @@ -203,9 +205,72 @@ export function createQualityComposeHandler(deps: RuntimeDeps): RouteHandler { }; } +/** `POST /agent/annotation/map-detection-boxes` */ +export function createMapDetectionBoxesHandler(deps: RuntimeDeps): RouteHandler { + return async (req) => { + let body; + try { + body = MapDetectionBoxesRequestSchema.parse(JSON.parse(req.rawBody || '{}')); + } catch (err) { + return { + kind: 'json', + status: 400, + body: { detail: formatZodError(err as never) }, + }; + } + + try { + // 视觉映射才需要 LLM;且需要 provider 通过视觉探针 + const useVision = Boolean(body.use_vision) && body.supports_vision; + let llm: LlmClient | null = null; + if (useVision) { + requireCredentials(body); + llm = makeLlm({ + deps, + apiKey: body.api_key, + baseUrl: body.base_url, + model: body.model, + temperature: deps.settings.annotationLlmTemperature, + }); + } + + const imageService = resolveImageService(deps); + // 图像可读性:探测一次(与 Python 的 load_image_bytes 真值判断对齐) + const probed = await imageService.probe({ + absolutePath: body.image_absolute_path.trim() || undefined, + base64: body.image_base64.trim() || undefined, + }); + + const result = await mapDetectionBoxesToLabelsUnified({ + llm, + userRequest: body.user_request, + intentSummary: body.intent_summary, + labelCandidates: body.label_candidates, + boxes: body.boxes, + useVision, + ocrText: body.ocr_text, + scope: body.annotation_scope, + labelStrategy: body.label_strategy, + singleLabelId: body.single_label_id ?? null, + imageAbsolutePath: body.image_absolute_path, + imageBase64: body.image_base64, + mimeType: body.mime_type, + settings: deps.settings, + imageService, + imageAvailable: probed.ok, + }); + + return { kind: 'json', body: { data: result } }; + } catch (err) { + return toErrorResult(httpFromLlmError(err)); + } + }; +} + /** 路由键(供装配使用)。 */ export const ANNOTATION_HANDLER_KEYS = { llmGenerate: `POST ${AGENT_ROUTE_PATHS.annotationLlmGenerate}`, mutationPrepare: `POST ${AGENT_ROUTE_PATHS.annotationMutationPrepare}`, + mapDetectionBoxes: `POST ${AGENT_ROUTE_PATHS.annotationMapDetectionBoxes}`, qualityCompose: `POST ${AGENT_ROUTE_PATHS.qualityReportComposeStream}`, } as const; diff --git a/src/main/agent/handlers/index.ts b/src/main/agent/handlers/index.ts index 2de5a2a..ae9da9e 100644 --- a/src/main/agent/handlers/index.ts +++ b/src/main/agent/handlers/index.ts @@ -12,6 +12,7 @@ import { mcpProbeHandler } from './mcpProbe'; import { ANNOTATION_HANDLER_KEYS, createLlmGenerateHandler, + createMapDetectionBoxesHandler, createMutationPrepareHandler, createQualityComposeHandler, } from './annotation'; @@ -28,6 +29,8 @@ export function buildImplementedRoutes( [ANNOTATION_HANDLER_KEYS.llmGenerate]: createLlmGenerateHandler(deps), [ANNOTATION_HANDLER_KEYS.mutationPrepare]: createMutationPrepareHandler(deps), + [ANNOTATION_HANDLER_KEYS.mapDetectionBoxes]: + createMapDetectionBoxesHandler(deps), [ANNOTATION_HANDLER_KEYS.qualityCompose]: createQualityComposeHandler(deps), }); diff --git a/src/main/agentImage/handlers.ts b/src/main/agentImage/handlers.ts index cf449df..b8cbe25 100644 --- a/src/main/agentImage/handlers.ts +++ b/src/main/agentImage/handlers.ts @@ -299,16 +299,16 @@ export function cropImageBatch(params: { const images: string[] = []; for (const box of params.boxes) { - const left = Math.max(0, Math.min(width, Math.floor(box.left))); - const top = Math.max(0, Math.min(height, Math.floor(box.top))); - const right = Math.max(left, Math.min(width, Math.ceil(box.right))); - const bottom = Math.max(top, Math.min(height, Math.ceil(box.bottom))); + // 与 Python `_crop_boxes_from_image_bytes` 的钳位完全一致: + // left = max(0, min(int(x), iw - 1)) + // right = max(left + 1, min(int(x + w), iw)) + // 注意 `max(left + 1, ...)` 保证裁剪区域至少 1px 宽/高。 + const left = Math.max(0, Math.min(Math.floor(box.left), width - 1)); + const top = Math.max(0, Math.min(Math.floor(box.top), height - 1)); + const right = Math.max(left + 1, Math.min(Math.floor(box.right), width)); + const bottom = Math.max(top + 1, Math.min(Math.floor(box.bottom), height)); const cropWidth = right - left; const cropHeight = bottom - top; - if (cropWidth <= 0 || cropHeight <= 0) { - images.push(''); - continue; - } let cropped = image.crop({ x: left, From a17058c8948263ab542881673d528ecd7e2fe671 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 03:04:03 +0800 Subject: [PATCH 10/20] =?UTF-8?q?refactor(agent):=20=E7=A7=BB=E9=99=A4=20P?= =?UTF-8?q?ython=20Agent=20=E6=9C=8D=E5=8A=A1=E4=B8=8E=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E5=BC=8F=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 vendor/local-agent(125 个文件、约 16000 行)与 src/main/localAgent/serverProcess.ts;主进程改为直接启动 Node 运行时(utilityProcess)。移除嵌入式 Python 运行时:删除 scripts/fetchPythonRuntimes.mjs、assets/python-runtime 与对应 extraResources,package 脚本不再拉取运行时。env 体系收敛:LocalAgentEnvStatus 去掉全部 Python 字段(改为仅报告服务是否运行)、InstallTarget 只剩 inference、环境向导与横幅不再要求 Python(Agent 编排面板改为纯状态展示)、skillScriptRunner 的 .py 解释器降级为系统 Python。inference 保留为可选自装(conda),安装入口在缺少内置运行时后给出明确的自建指引。README 与基线文档同步更新;冻结基准 .baseline/python 保留(原实现已删,无法重新生成)。主进程测试 513/513;golden diff 11/11。 Co-authored-by: Cursor --- .gitignore | 5 +- README.md | 22 +- docs/agent-protocol.md | 11 +- package.json | 22 +- scripts/agent-baseline/README.md | 30 +- scripts/fetchPythonRuntimes.mjs | 142 ---- src/main/env/envHandlers.ts | 2 +- src/main/env/envInstaller.ts | 27 +- src/main/env/envStatus.ts | 21 +- src/main/env/runtimeManager.ts | 14 +- src/main/localAgent/serverProcess.ts | 289 ------- src/main/main.ts | 10 +- src/main/skills/docxWorkflow.test.ts | 23 +- src/main/skills/skillScriptRunner.ts | 14 +- .../environment/EnvironmentBanner.tsx | 3 +- .../environment/EnvironmentWizard.tsx | 81 +- src/renderer/context/EnvironmentContext.tsx | 8 +- src/shared/envTypes.ts | 19 +- vendor/local-agent/README.md | 93 -- vendor/local-agent/app/__init__.py | 0 vendor/local-agent/app/agent/__init__.py | 1 - .../app/agent/annotation/__init__.py | 14 - .../agent/annotation/annotation_doc_reader.py | 55 -- .../app/agent/annotation/annotation_scope.py | 206 ----- .../agent/annotation/batch_prepare_service.py | 217 ----- .../app/agent/annotation/debug_log.py | 19 - .../agent/annotation/heuristic_map_service.py | 67 -- .../agent/annotation/image_bytes_loader.py | 83 -- .../app/agent/annotation/json_utils.py | 26 - .../annotation/label_candidate_resolver.py | 170 ---- .../agent/annotation/label_vision_policy.py | 107 --- .../app/agent/annotation/llm_invoke.py | 65 -- .../agent/annotation/map_labels_service.py | 591 ------------- .../annotation/map_validation_service.py | 184 ---- .../annotation/mutation_prepare_service.py | 263 ------ .../app/agent/annotation/plan_parsing.py | 118 --- .../quality_report_compose_service.py | 52 -- .../app/agent/annotation/schemas.py | 51 -- .../local-agent/app/agent/assist/__init__.py | 10 - .../app/agent/assist/explore_readonly.py | 436 ---------- .../app/agent/assist/pending_emitter.py | 32 - .../app/agent/assist/proposal_streamer.py | 266 ------ .../app/agent/assist/task_phase.py | 261 ------ .../local-agent/app/agent/assist/tool_loop.py | 720 ---------------- .../app/agent/assist/vision_bootstrap.py | 66 -- .../app/agent/assist_mode_router.py | 79 -- .../local-agent/app/agent/assist_service.py | 188 ----- vendor/local-agent/app/agent/assist_vision.py | 104 --- .../app/agent/chat_message_builder.py | 80 -- vendor/local-agent/app/agent/chat_service.py | 16 - .../local-agent/app/agent/context_helpers.py | 22 - .../local-agent/app/agent/context_service.py | 117 --- .../local-agent/app/agent/context_snapshot.py | 254 ------ .../local-agent/app/agent/stream_adapter.py | 48 -- vendor/local-agent/app/agent/text_sanitize.py | 29 - .../local-agent/app/agent/tool_dispatcher.py | 37 - .../local-agent/app/agent/tool_invocation.py | 59 -- .../local-agent/app/agent/tools/__init__.py | 3 - vendor/local-agent/app/agent/tools/help.py | 33 - .../local-agent/app/agent/tools/mcp_client.py | 333 -------- .../local-agent/app/agent/tools/registry.py | 589 ------------- .../app/agent/tools/tool_registry_meta.py | 108 --- .../app/agent/tools/tool_result.py | 74 -- .../app/agent/tools/workspace_file_reader.py | 744 ---------------- .../app/agent/tools/workspace_path.py | 261 ------ .../app/agent/tools/workspace_search.py | 342 -------- .../agent/tools/workspace_text_extensions.py | 168 ---- vendor/local-agent/app/api/__init__.py | 0 vendor/local-agent/app/api/v1/__init__.py | 0 vendor/local-agent/app/api/v1/agent.py | 329 -------- .../app/api/v1/annotation_agent.py | 341 -------- .../app/api/v1/annotation_quality.py | 93 -- vendor/local-agent/app/api/v1/router.py | 12 - vendor/local-agent/app/core/__init__.py | 0 vendor/local-agent/app/core/config.py | 63 -- vendor/local-agent/app/core/deps.py | 51 -- vendor/local-agent/app/models/__init__.py | 0 vendor/local-agent/app/models/user.py | 23 - vendor/local-agent/app/schemas/__init__.py | 0 vendor/local-agent/app/schemas/agent.py | 250 ------ .../app/schemas/annotation_agent.py | 95 --- .../app/schemas/annotation_quality.py | 7 - vendor/local-agent/local_main.py | 82 -- vendor/local-agent/pytest.ini | 3 - vendor/local-agent/requirements.txt | 20 - .../scripts/measure_grep_blocking.py | 109 --- vendor/local-agent/tests/conftest.py | 9 - .../tests/test_agent_sse_serialization.py | 56 -- .../tests/test_annotation_agent_scope.py | 8 - .../tests/test_annotation_doc_reader.py | 8 - .../tests/test_annotation_tool_args.py | 33 - .../tests/test_assist_mode_router.py | 124 --- .../tests/test_assist_phase_flow.py | 373 -------- .../tests/test_assist_resume_messages.py | 328 -------- .../local-agent/tests/test_assist_vision.py | 7 - .../local-agent/tests/test_assist_wrapup.py | 113 --- .../tests/test_chat_stream_preparing.py | 90 -- .../tests/test_context_snapshot.py | 158 ---- .../tests/test_explore_readonly.py | 451 ---------- .../tests/test_label_candidate_resolver.py | 73 -- .../tests/test_llm_generate_image.py | 83 -- vendor/local-agent/tests/test_local_auth.py | 60 -- .../tests/test_map_validation_service.py | 72 -- vendor/local-agent/tests/test_mcp_dedup.py | 91 -- vendor/local-agent/tests/test_mcp_servers.py | 324 ------- .../tests/test_mutation_prepare_schema.py | 115 --- vendor/local-agent/tests/test_plan_parsing.py | 43 - .../tests/test_proposal_streamer.py | 362 -------- .../tests/test_read_file_annotation.py | 109 --- vendor/local-agent/tests/test_resume_cycle.py | 17 - .../local-agent/tests/test_skills_context.py | 88 -- vendor/local-agent/tests/test_task_phase.py | 435 ---------- .../local-agent/tests/test_text_sanitize.py | 18 - .../local-agent/tests/test_tool_blocking.py | 196 ----- .../local-agent/tests/test_tool_dispatcher.py | 98 --- vendor/local-agent/tests/test_tool_fn_map.py | 125 --- .../local-agent/tests/test_tool_invocation.py | 55 -- vendor/local-agent/tests/test_tool_loop.py | 793 ------------------ .../tests/test_tool_registry_work_mode.py | 60 -- .../tests/test_vision_bootstrap.py | 53 -- .../tests/test_workspace_file_tools.py | 621 -------------- .../tests/test_workspace_memory.py | 57 -- .../tests/test_workspace_search.py | 165 ---- .../tests/test_workspace_text_extensions.py | 43 - 124 files changed, 105 insertions(+), 15891 deletions(-) delete mode 100644 scripts/fetchPythonRuntimes.mjs delete mode 100644 src/main/localAgent/serverProcess.ts delete mode 100644 vendor/local-agent/README.md delete mode 100644 vendor/local-agent/app/__init__.py delete mode 100644 vendor/local-agent/app/agent/__init__.py delete mode 100644 vendor/local-agent/app/agent/annotation/__init__.py delete mode 100644 vendor/local-agent/app/agent/annotation/annotation_doc_reader.py delete mode 100644 vendor/local-agent/app/agent/annotation/annotation_scope.py delete mode 100644 vendor/local-agent/app/agent/annotation/batch_prepare_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/debug_log.py delete mode 100644 vendor/local-agent/app/agent/annotation/heuristic_map_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/image_bytes_loader.py delete mode 100644 vendor/local-agent/app/agent/annotation/json_utils.py delete mode 100644 vendor/local-agent/app/agent/annotation/label_candidate_resolver.py delete mode 100644 vendor/local-agent/app/agent/annotation/label_vision_policy.py delete mode 100644 vendor/local-agent/app/agent/annotation/llm_invoke.py delete mode 100644 vendor/local-agent/app/agent/annotation/map_labels_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/map_validation_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/mutation_prepare_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/plan_parsing.py delete mode 100644 vendor/local-agent/app/agent/annotation/quality_report_compose_service.py delete mode 100644 vendor/local-agent/app/agent/annotation/schemas.py delete mode 100644 vendor/local-agent/app/agent/assist/__init__.py delete mode 100644 vendor/local-agent/app/agent/assist/explore_readonly.py delete mode 100644 vendor/local-agent/app/agent/assist/pending_emitter.py delete mode 100644 vendor/local-agent/app/agent/assist/proposal_streamer.py delete mode 100644 vendor/local-agent/app/agent/assist/task_phase.py delete mode 100644 vendor/local-agent/app/agent/assist/tool_loop.py delete mode 100644 vendor/local-agent/app/agent/assist/vision_bootstrap.py delete mode 100644 vendor/local-agent/app/agent/assist_mode_router.py delete mode 100644 vendor/local-agent/app/agent/assist_service.py delete mode 100644 vendor/local-agent/app/agent/assist_vision.py delete mode 100644 vendor/local-agent/app/agent/chat_message_builder.py delete mode 100644 vendor/local-agent/app/agent/chat_service.py delete mode 100644 vendor/local-agent/app/agent/context_helpers.py delete mode 100644 vendor/local-agent/app/agent/context_service.py delete mode 100644 vendor/local-agent/app/agent/context_snapshot.py delete mode 100644 vendor/local-agent/app/agent/stream_adapter.py delete mode 100644 vendor/local-agent/app/agent/text_sanitize.py delete mode 100644 vendor/local-agent/app/agent/tool_dispatcher.py delete mode 100644 vendor/local-agent/app/agent/tool_invocation.py delete mode 100644 vendor/local-agent/app/agent/tools/__init__.py delete mode 100644 vendor/local-agent/app/agent/tools/help.py delete mode 100644 vendor/local-agent/app/agent/tools/mcp_client.py delete mode 100644 vendor/local-agent/app/agent/tools/registry.py delete mode 100644 vendor/local-agent/app/agent/tools/tool_registry_meta.py delete mode 100644 vendor/local-agent/app/agent/tools/tool_result.py delete mode 100644 vendor/local-agent/app/agent/tools/workspace_file_reader.py delete mode 100644 vendor/local-agent/app/agent/tools/workspace_path.py delete mode 100644 vendor/local-agent/app/agent/tools/workspace_search.py delete mode 100644 vendor/local-agent/app/agent/tools/workspace_text_extensions.py delete mode 100644 vendor/local-agent/app/api/__init__.py delete mode 100644 vendor/local-agent/app/api/v1/__init__.py delete mode 100644 vendor/local-agent/app/api/v1/agent.py delete mode 100644 vendor/local-agent/app/api/v1/annotation_agent.py delete mode 100644 vendor/local-agent/app/api/v1/annotation_quality.py delete mode 100644 vendor/local-agent/app/api/v1/router.py delete mode 100644 vendor/local-agent/app/core/__init__.py delete mode 100644 vendor/local-agent/app/core/config.py delete mode 100644 vendor/local-agent/app/core/deps.py delete mode 100644 vendor/local-agent/app/models/__init__.py delete mode 100644 vendor/local-agent/app/models/user.py delete mode 100644 vendor/local-agent/app/schemas/__init__.py delete mode 100644 vendor/local-agent/app/schemas/agent.py delete mode 100644 vendor/local-agent/app/schemas/annotation_agent.py delete mode 100644 vendor/local-agent/app/schemas/annotation_quality.py delete mode 100644 vendor/local-agent/local_main.py delete mode 100644 vendor/local-agent/pytest.ini delete mode 100644 vendor/local-agent/requirements.txt delete mode 100644 vendor/local-agent/scripts/measure_grep_blocking.py delete mode 100644 vendor/local-agent/tests/conftest.py delete mode 100644 vendor/local-agent/tests/test_agent_sse_serialization.py delete mode 100644 vendor/local-agent/tests/test_annotation_agent_scope.py delete mode 100644 vendor/local-agent/tests/test_annotation_doc_reader.py delete mode 100644 vendor/local-agent/tests/test_annotation_tool_args.py delete mode 100644 vendor/local-agent/tests/test_assist_mode_router.py delete mode 100644 vendor/local-agent/tests/test_assist_phase_flow.py delete mode 100644 vendor/local-agent/tests/test_assist_resume_messages.py delete mode 100644 vendor/local-agent/tests/test_assist_vision.py delete mode 100644 vendor/local-agent/tests/test_assist_wrapup.py delete mode 100644 vendor/local-agent/tests/test_chat_stream_preparing.py delete mode 100644 vendor/local-agent/tests/test_context_snapshot.py delete mode 100644 vendor/local-agent/tests/test_explore_readonly.py delete mode 100644 vendor/local-agent/tests/test_label_candidate_resolver.py delete mode 100644 vendor/local-agent/tests/test_llm_generate_image.py delete mode 100644 vendor/local-agent/tests/test_local_auth.py delete mode 100644 vendor/local-agent/tests/test_map_validation_service.py delete mode 100644 vendor/local-agent/tests/test_mcp_dedup.py delete mode 100644 vendor/local-agent/tests/test_mcp_servers.py delete mode 100644 vendor/local-agent/tests/test_mutation_prepare_schema.py delete mode 100644 vendor/local-agent/tests/test_plan_parsing.py delete mode 100644 vendor/local-agent/tests/test_proposal_streamer.py delete mode 100644 vendor/local-agent/tests/test_read_file_annotation.py delete mode 100644 vendor/local-agent/tests/test_resume_cycle.py delete mode 100644 vendor/local-agent/tests/test_skills_context.py delete mode 100644 vendor/local-agent/tests/test_task_phase.py delete mode 100644 vendor/local-agent/tests/test_text_sanitize.py delete mode 100644 vendor/local-agent/tests/test_tool_blocking.py delete mode 100644 vendor/local-agent/tests/test_tool_dispatcher.py delete mode 100644 vendor/local-agent/tests/test_tool_fn_map.py delete mode 100644 vendor/local-agent/tests/test_tool_invocation.py delete mode 100644 vendor/local-agent/tests/test_tool_loop.py delete mode 100644 vendor/local-agent/tests/test_tool_registry_work_mode.py delete mode 100644 vendor/local-agent/tests/test_vision_bootstrap.py delete mode 100644 vendor/local-agent/tests/test_workspace_file_tools.py delete mode 100644 vendor/local-agent/tests/test_workspace_memory.py delete mode 100644 vendor/local-agent/tests/test_workspace_search.py delete mode 100644 vendor/local-agent/tests/test_workspace_text_extensions.py diff --git a/.gitignore b/.gitignore index 35c43ff..13d782b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,12 +80,11 @@ ehthumbs.db *.safetensors *.ckpt -# Python (vendor/local-agent, vendor/inference) +# Python(vendor/inference) __pycache__/ .pytest_cache/ -# 嵌入式 Python 运行时(scripts/fetchPythonRuntimes.mjs 产物,打包前自动下载) -assets/python-runtime/ +# 嵌入式 Python 运行时已移除(Agent 编排迁到 Node,不再需要) # CI 后续安装冒烟(scripts/ciInstallAndTest.mjs) .ci-venvs/ diff --git a/README.md b/README.md index b2f5cfc..9b7bb89 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 项目简介 -基于 Electron + React + FastAPI + LangChain 的多模态 AI 智能标注平台。 +基于 Electron + React 的多模态 AI 智能标注平台(Agent 编排内置于应用)。 支持矩形框、旋转框、多边形、关键点、图像/文本分类、Caption、指令遵循、思维链与实体识别等标注场景,以及支持多轮对话、长短期记忆、MCP 工具使用、Agent Skills 使用。 @@ -139,7 +139,9 @@ ### 前置环境 - Node.js ≥ 18,npm ≥ 7 -- Conda(本机 Agent 编排、预标注推理) + +Agent 编排已内置在应用中(Node 运行时),**无需安装 Python**。 +仅当需要使用「预训练模型标注」时才需要自行准备推理环境(可选)。 ### 安装前端依赖 @@ -148,19 +150,7 @@ npm install ``` -### 本机 Python 环境依赖 - -Electron 会自己拉起 AI Agent 编排、预训练模型标注两个服务,一般不用手动开进程。如果没有自己预先安装,可以在启动项目时根据向导安装 Agent 编排、预训练模型标注所需环境。 - -### local-agent(AI Agent 编排) - -```bash -conda create -n lr-agent-local python=3.12 -y -conda activate lr-agent-local -pip install -r vendor/local-agent/requirements.txt -``` - -### inference(预训练模型标注,可选) +### 推理环境(可选) 源码在 `vendor/inference`。有 NVIDIA 显卡用 GPU 清单,否则用 CPU。 @@ -171,6 +161,8 @@ pip install -r vendor/inference/requirements-gpu.txt # 或:pip install -r vendor/inference/requirements-cpu.txt ``` +未安装时应用与 Agent 编排仍可正常使用,仅「预训练模型标注」不可用。 + --- diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index bc2d88b..1c26350 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -6,10 +6,19 @@ > 渲染层静默降级——`src/renderer/services/backendChatClient.ts` 的 SSE 解析对畸形分片是 > `catch { // ignore }`,协议对不上不会报错,只会「没有反应」,排查成本极高。 > -> 本文档描述的是**迁移前 Python 实现(`vendor/local-agent`)的既成行为**。迁移完成后, +> 本文档描述的是**从 Python 实现迁移前的既成行为**(原 `vendor/local-agent`,已移除)。 > 它仍是对外契约的权威描述,不再指向具体实现语言。 > > 变更本文档需要同时更新 `scripts/agent-baseline/` 下的 golden 用例。 +> +> 各契约在代码中的落点: +> - SSE 序列化规则 → `src/main/agent/sse.ts` 的 `toSseDict` +> - 请求体/消息解析 → `src/main/agent/schemas.ts` +> - 工具循环 → `src/main/agent/loop/assistLoop.ts` +> - 提案流式拦截 → `src/main/agent/loop/proposalStreamer.ts` +> - 子代理 → `src/main/agent/subagent/exploreReadonly.ts` +> - 标注编排 → `src/main/agent/annotate/*` +> - system prompt 组装 → `src/main/agent/context/systemPrompt.ts` --- diff --git a/package.json b/package.json index 7377c1a..c434a2e 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,9 @@ "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", - "fetch-python-runtimes": "node scripts/fetchPythonRuntimes.mjs", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", "lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", - "package": "ts-node ./.erb/scripts/clean.js dist && npm run fetch-python-runtimes && npm run build && electron-builder build --publish never && npm run build:dll", + "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "dev": "cross-env NODE_ENV=development SKIP_DEVTOOLS=1 npm run start:renderer", "dev:open": "node .erb/scripts/dev-fast.js", @@ -286,17 +285,6 @@ }, "extraResources": [ "./assets/**", - { - "from": "vendor/local-agent", - "to": "local-agent", - "filter": [ - "**/*", - "!**/__pycache__/**", - "!**/.pytest_cache/**", - "!**/tests/**", - "!**/.env" - ] - }, { "from": "vendor/inference", "to": "inference", @@ -306,14 +294,6 @@ "!scripts/**", "!**/.env" ] - }, - { - "from": "assets/python-runtime", - "to": "python-runtime", - "filter": [ - "**/*", - "!**/*.pdb" - ] } ], "publish": { diff --git a/scripts/agent-baseline/README.md b/scripts/agent-baseline/README.md index 5a5f8d4..0171d53 100644 --- a/scripts/agent-baseline/README.md +++ b/scripts/agent-baseline/README.md @@ -18,35 +18,35 @@ ## 使用 -### 1. 抓取基准(迁移前的 Python 实现) +### 1. 基准(已冻结,无需重新抓取) + +`.baseline/python/` 是**迁移前从 Python 实现抓取并已入库的冻结基准**。 +原 Python 实现(`vendor/local-agent`)已随迁移完成而删除,因此这份基准**无法重新生成**—— +这正是它在阶段 0 就被提交入库的原因。 + +不要删除或改动 `.baseline/python/`;它是判断协议是否漂移的唯一参照。 + +### 2. 抓取候选(当前的 Node 实现) ```bash # 起 mock LLM node scripts/agent-baseline/mockLlmServer.mjs --port 8799 -# 另一个终端:起 Python 服务(固定端口,便于被抓取) -cd vendor/local-agent -LR_AGENT_LOCAL_PORT=8765 python local_main.py +# 另一个终端:起 Node 运行时(独立模式,无需 Electron) +LR_AGENT_LOCAL_TOKEN=baseline-token LR_AGENT_LOCAL_PORT=8765 \ + npx ts-node src/main/agent/runtime.ts # 再一个终端:抓取 node scripts/agent-baseline/captureSse.mjs \ --target http://127.0.0.1:8765/api/v1 \ - --label python \ - --out .baseline/python + --token baseline-token \ + --label node \ + --out .baseline/node ``` > `captureSse.mjs` 会在缺少 `--mock` 时自动拉起 mock LLM 并在结束时关闭; > 若你已手动起了 mock,传 `--mock http://127.0.0.1:8799` 复用它。 -### 2. 抓取候选(迁移后的 Node 实现) - -```bash -node scripts/agent-baseline/captureSse.mjs \ - --target http://127.0.0.1:/api/v1 \ - --label node \ - --out .baseline/node -``` - ### 3. 对比 ```bash diff --git a/scripts/fetchPythonRuntimes.mjs b/scripts/fetchPythonRuntimes.mjs deleted file mode 100644 index 952d0ff..0000000 --- a/scripts/fetchPythonRuntimes.mjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 下载 python-build-standalone 嵌入式运行时(Windows x64,install_only)。 - * - * 供 Electron 打包使用:解包到 assets/python-runtime/py312, - * 随 extraResources 分发到 resources/python-runtime/。运行时二进制不提交 - * git(见 .gitignore),本脚本入库,`npm run package` 前会自动执行。 - * - * 用法: node scripts/fetchPythonRuntimes.mjs [--force] - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const TARGETS = [{ version: '3.12', dir: 'py312' }]; -const ASSET_PATTERN = (version) => - new RegExp( - '^cpython-' + - version.replace(/\./g, '\\.') + - '\\.\\d+(?:\\+\\d{8})?-x86_64-pc-windows-msvc-install_only\\.tar\\.gz$', - ); -const RELEASE_API = - 'https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const outputRoot = path.join(root, 'assets', 'python-runtime'); -const force = process.argv.includes('--force'); -const isWin32 = process.platform === 'win32'; - -if (!isWin32) { - console.error( - `本脚本仅支持在 Windows 上为 Windows x64 产物准备运行时(当前平台: ${process.platform})。` + - '其他平台沿用 conda/系统 Python 发现,无需嵌入式运行时。', - ); - process.exit(1); -} - -function githubHeaders(url) { - const headers = { 'User-Agent': 'lr-agent-fetch-python-runtimes' }; - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - if (token && new URL(url).hostname === 'api.github.com') { - headers.Authorization = `Bearer ${token}`; - } - return headers; -} - -async function fetchJson(url) { - const res = await fetch(url, { - headers: githubHeaders(url), - }); - if (!res.ok) { - throw new Error(`HTTP ${res.status} for ${url}`); - } - return res.json(); -} - -async function downloadTo(asset, destPath) { - console.log(`下载 ${asset.name} ...`); - const res = await fetch(asset.browser_download_url, { - headers: githubHeaders(asset.browser_download_url), - }); - if (!res.ok) { - throw new Error(`下载失败: HTTP ${res.status} — ${asset.name}`); - } - const buffer = Buffer.from(await res.arrayBuffer()); - fs.writeFileSync(destPath, buffer); - console.log(` 完成 (${(buffer.length / 1024 / 1024).toFixed(1)} MB)`); -} - -function* walkFiles(dir, extension) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - yield* walkFiles(full, extension); - } else if (entry.name.toLowerCase().endsWith(extension)) { - yield full; - } - } -} - -async function main() { - const release = await fetchJson(RELEASE_API); - const assets = release.assets ?? []; - - for (const { version, dir } of TARGETS) { - const destDir = path.join(outputRoot, dir); - const pythonExe = path.join(destDir, 'python.exe'); - if (!force && fs.existsSync(pythonExe)) { - console.log(`${dir}: 已存在,跳过(--force 可重取)`); - continue; - } - - const asset = assets.find((a) => ASSET_PATTERN(version).test(a.name)); - if (!asset) { - throw new Error( - `${version}: 未在 latest release 中找到 x86_64-pc-windows-msvc install_only 资产`, - ); - } - - fs.rmSync(destDir, { recursive: true, force: true }); - fs.mkdirSync(destDir, { recursive: true }); - - const archivePath = path.join(outputRoot, `tmp-${asset.name}`); - // 顺序下载以降低峰值内存/带宽(每个包 ~25MB) - // eslint-disable-next-line no-await-in-loop - await downloadTo(asset, archivePath); - - // install_only 包顶层为 python/ 目录,剥掉一层直接铺开。 - // 用相对路径调用 tar:Windows bsdtar 会把含盘符的绝对路径误判为远程主机 - const tar = spawnSync( - 'tar', - [ - '-xf', - path.basename(archivePath), - '-C', - path.relative(outputRoot, destDir), - '--strip-components=1', - ], - { cwd: outputRoot, stdio: 'inherit' }, - ); - fs.rmSync(archivePath, { force: true }); - if (tar.status !== 0) { - throw new Error(`${version}: tar 解包失败 (exit ${tar.status})`); - } - if (!fs.existsSync(pythonExe)) { - throw new Error(`${version}: 解包后未找到 python.exe: ${pythonExe}`); - } - - // 删掉 PDB 调试符号(约 80MB/版本),纯运行时用不到 - for (const pdb of walkFiles(destDir, '.pdb')) { - fs.rmSync(pdb, { force: true }); - } - console.log(`${version}: 完成 → ${destDir}`); - } - - console.log('全部运行时就绪。打包时可通过 resources/python-runtime 访问。'); -} - -main().catch((err) => { - console.error(`[fetchPythonRuntimes] ${err.message}`); - process.exit(1); -}); diff --git a/src/main/env/envHandlers.ts b/src/main/env/envHandlers.ts index ec0c12d..d09982e 100644 --- a/src/main/env/envHandlers.ts +++ b/src/main/env/envHandlers.ts @@ -42,7 +42,7 @@ function registerEnvHandlers(): void { ); ipcMain.handle('env:install:start', async (_event, target: InstallTarget) => { - if (target !== 'local-agent' && target !== 'inference') { + if (target !== 'inference') { return { ok: false, error: `未知安装目标: ${String(target)}` }; } try { diff --git a/src/main/env/envInstaller.ts b/src/main/env/envInstaller.ts index 8bc0920..93029a2 100644 --- a/src/main/env/envInstaller.ts +++ b/src/main/env/envInstaller.ts @@ -48,7 +48,6 @@ const TORCH_CPU_INDEX = 'https://download.pytorch.org/whl/cpu'; /** 验证阶段要 import 的模块(import 成功即视为核心依赖可用) */ const VERIFY_IMPORTS: Record = { - 'local-agent': 'fastapi, uvicorn, langchain_core', inference: 'torch, torchvision, ultralytics, cv2, PIL, sam2', }; @@ -66,12 +65,7 @@ let currentInstall: InstallJob | null = null; class InstallCanceledError extends Error {} function rootOf(target: InstallTarget): string { - if (target === 'local-agent') { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'local-agent'); - } - return path.resolve(app.getAppPath(), 'vendor', 'local-agent'); - } + void target; if (app.isPackaged) { return path.join(process.resourcesPath, 'inference'); } @@ -83,10 +77,7 @@ async function requirementsPathOf( variant: InferenceVariant, ): Promise { const root = rootOf(target); - const file = - target === 'local-agent' - ? 'requirements.txt' - : `requirements-${variant}.txt`; + const file = `requirements-${variant}.txt`; const requirementsPath = path.join(root, file); if (!(await fs.pathExists(requirementsPath))) { throw new Error(`依赖清单不存在: ${requirementsPath}`); @@ -205,8 +196,11 @@ async function runInstall(job: InstallJob): Promise { const runtimePython = await ensureRuntimeExtracted(version); if (!runtimePython) { throw new Error( - `嵌入式运行时 ${version} 未就绪。开发模式请先运行 npm run fetch-python-runtimes;` + - `打包产物缺失请重新构建安装包。`, + '应用不再内置 Python 运行时(Agent 编排已迁到 Node)。' + + '请自行准备预标注推理环境:' + + 'conda create -n lr-agent-inference python=3.12 -y,' + + '然后 pip install -r vendor/inference/requirements-cpu.txt(或 -gpu.txt),' + + '完成后回到本向导点击「重新检测」。', ); } appendLine(`使用嵌入式运行时: ${runtimePython}`); @@ -310,8 +304,7 @@ export async function startInstall( return { ok: false, error: '已有安装任务进行中' }; } - const variant = - target === 'inference' ? await detectInferenceVariant() : 'cpu'; + const variant = await detectInferenceVariant(); const requirementsPath = await requirementsPathOf(target, variant); const content = sanitizeRequirements( @@ -324,7 +317,7 @@ export async function startInstall( if ( marker && marker.requirementsHash === hash && - (target === 'local-agent' || marker.variant === variant) && + marker.variant === variant && venvReady ) { return { ok: true, alreadyInstalled: true }; @@ -338,7 +331,7 @@ export async function startInstall( child: null, progress: { target, - variant: target === 'inference' ? variant : undefined, + variant, stage: 'pending', lines: [], startedAt: Date.now(), diff --git a/src/main/env/envStatus.ts b/src/main/env/envStatus.ts index a454eae..8af23d8 100644 --- a/src/main/env/envStatus.ts +++ b/src/main/env/envStatus.ts @@ -15,10 +15,7 @@ import { } from '../../shared/envTypes'; import { getEnvironmentConfig } from './envStore'; import { getVenvPythonPath, readInstallMarker } from './runtimeManager'; -import { - resolveLocalAgentPython, - getLocalAgentBaseUrl, -} from '../localAgent/serverProcess'; +import { getAgentRuntimeBaseUrl } from '../agent/host'; import { checkInferenceRuntime, resolvePythonExecutable, @@ -37,7 +34,7 @@ function isUsablePython(pythonPath: string | null): boolean { } function isVenvPath( - target: 'local-agent' | 'inference', + target: 'inference', pythonPath: string | null, ): boolean { if (!pythonPath) return false; @@ -48,9 +45,7 @@ function isVenvPath( ); } -async function hasInstalledVenv( - target: 'local-agent' | 'inference', -): Promise { +async function hasInstalledVenv(target: 'inference'): Promise { return ( isUsablePython(getVenvPythonPath(target)) && (await readInstallMarker(target)) !== null @@ -86,14 +81,10 @@ async function probeBackend(baseUrl: string): Promise { } async function localAgentStatus(): Promise { - const pythonPath = resolveLocalAgentPython(); - const pythonUsable = isUsablePython(pythonPath); - const serviceUrl = getLocalAgentBaseUrl(); + // Agent 编排已迁到 Node 运行时(utilityProcess),不再依赖 Python 解释器。 + // 这里只需报告服务是否在跑。 + const serviceUrl = getAgentRuntimeBaseUrl(); return { - pythonPath: pythonUsable ? pythonPath : null, - pythonOk: pythonUsable, - pythonFromVenv: isVenvPath('local-agent', pythonUsable ? pythonPath : null), - depsInstalled: await hasInstalledVenv('local-agent'), serviceRunning: Boolean(serviceUrl), serviceUrl, }; diff --git a/src/main/env/runtimeManager.ts b/src/main/env/runtimeManager.ts index bf18379..c63ffc0 100644 --- a/src/main/env/runtimeManager.ts +++ b/src/main/env/runtimeManager.ts @@ -1,12 +1,13 @@ /** * 嵌入式 Python 运行时与 venv 管理。 * - * 运行时资产随安装包分发(resources/python-runtime/py312,由 - * scripts/fetchPythonRuntimes.mjs 准备),首启复制到 - * /runtimes/(resources 目录可能只读),再为其上的 - * local-agent / inference 创建 venv;依赖安装结果通过 - * /runtimes/-installed.json 记录 requirements hash, - * 依赖文件变化时据此提示重装。 + * **注意**:Agent 编排已迁到 Node 运行时,不再需要 Python;嵌入式运行时亦已移除。 + * 本模块现在只服务于**可选的**预标注推理(`vendor/inference`),且 + * `ensureRuntimeExtracted` 在无资产时返回 `null`,由调用方给出「请自行准备 + * conda 环境」的指引(见 `envInstaller.ts`)。 + * + * venv 与安装标记路径保留,用于识别用户此前已创建的 `inference` venv + * (`/runtimes/inference-venv`)。 */ import crypto from 'crypto'; import path from 'path'; @@ -15,7 +16,6 @@ import { app } from 'electron'; import type { InstallTarget } from '../../shared/envTypes'; export const TARGET_PYTHON_VERSION: Record = { - 'local-agent': 'py312', inference: 'py312', }; diff --git a/src/main/localAgent/serverProcess.ts b/src/main/localAgent/serverProcess.ts deleted file mode 100644 index 02d184c..0000000 --- a/src/main/localAgent/serverProcess.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * LR-Agent-local 本地 Agent 编排服务进程管理。 - * - * 由 Electron 主进程 spawn 一个长驻 uvicorn 子进程(FastAPI,仅监听 127.0.0.1), - * 承担 Assist 工具循环 SSE、标注 / 质量报告 LLM 编排。 - * 用户认证仍走云端 LR-Agent-backend;本服务无认证、无数据库。 - * - * 生命周期对齐 mcp/server.ts:start → getBaseUrl → stop。 - * Python 解释器解析顺序统一为: - * LR_AGENT_LOCAL_PYTHON / LR_AGENT_LOCAL_CONDA_ENV(环境变量) - * → environment.json 用户覆盖 → CONDA_PREFIX/conda 候选 - * → 嵌入式运行时 venv → 系统 python 兜底 - * 服务状态通过 localAgent:status 事件推送给渲染层(向导 / 横幅消费)。 - */ - -import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; -import { randomBytes } from 'crypto'; -import http from 'http'; -import net from 'net'; -import fs from 'fs-extra'; -import path from 'path'; -import { app, BrowserWindow } from 'electron'; -import type { LocalAgentServiceStatus } from '../../shared/envTypes'; -import { - condaEnvCandidates, - condaRegistryCandidates, - normalizePythonPath, - pickExistingPython, - systemPythonFallback, - trimEnvironmentValue, -} from '../env/pythonDiscovery'; -import { getEnvironmentConfig } from '../env/envStore'; -import { getVenvPythonPath } from '../env/runtimeManager'; -import { buildInferenceSpawnEnv } from '../preAnnot/inferenceProcess'; -import { - getAgentRuntimeBaseUrl, - getAgentRuntimeToken, - startAgentRuntime, - stopAgentRuntime, -} from '../agent/host'; - -/** - * Agent 编排的运行时实现。 - * - * 迁移期间两者并存:`python` 走 vendor/local-agent(既有实现),`node` 走 - * utilityProcess 内的 Node 运行时。默认 `python`——Node 运行时在阶段 2 之前 - * 只提供路由骨架,直接切过去会让业务请求全部 501。 - * - * 通过环境变量 `LR_AGENT_RUNTIME=node` 启用 Node 运行时,用于开发对拍与灰度验证。 - * 阶段 8 删除 Python 实现后,本开关及其 Python 分支一并移除。 - */ -export type LocalAgentRuntimeKind = 'python' | 'node'; - -export function resolveLocalAgentRuntimeKind(): LocalAgentRuntimeKind { - return process.env.LR_AGENT_RUNTIME === 'node' ? 'node' : 'python'; -} - -let processRef: ChildProcessWithoutNullStreams | null = null; -let startingProcess: Promise | null = null; -let listenPort: number | null = null; -let stopping = false; -/** 本地服务访问 token(每次启动重新生成,仅注入子进程与渲染层) */ -let authToken: string | null = null; - -function pushLocalAgentStatus(status: LocalAgentServiceStatus): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('localAgent:status', status); - } -} - -const HEALTH_TIMEOUT_MS = 20_000; -const HEALTH_INTERVAL_MS = 300; - -function getLocalAgentRoot(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'local-agent'); - } - return path.resolve(app.getAppPath(), 'vendor', 'local-agent'); -} - -export function resolveLocalAgentPython(): string { - const condaEnv = process.env.LR_AGENT_LOCAL_CONDA_ENV ?? 'lr-agent-local'; - const candidates: string[] = []; - - const fromEnvVar = trimEnvironmentValue(process.env.LR_AGENT_LOCAL_PYTHON); - if (fromEnvVar) { - candidates.push(normalizePythonPath(fromEnvVar)); - } - - const userOverride = trimEnvironmentValue( - getEnvironmentConfig().localAgentPythonOverride, - ); - if (userOverride) { - candidates.push(normalizePythonPath(userOverride)); - } - - const home = trimEnvironmentValue(process.env.CONDA_PREFIX); - if (home && path.basename(home) === condaEnv) { - candidates.push( - process.platform === 'win32' - ? path.join(home, 'python.exe') - : path.join(home, 'bin', 'python'), - ); - } - - candidates.push( - ...condaEnvCandidates(app.getPath('home'), condaEnv), - ...condaRegistryCandidates(app.getPath('home'), condaEnv), - getVenvPythonPath('local-agent'), - ); - - const found = pickExistingPython(candidates); - if (found) return found; - - return systemPythonFallback('[localAgent]', condaEnv); -} - -/** 获取本机随机空闲端口 */ -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address() as net.AddressInfo; - srv.close((err) => { - if (err) reject(err); - else resolve(addr.port); - }); - }); - srv.on('error', reject); - }); -} - -function pingHealth(port: number): Promise { - return new Promise((resolve) => { - const req = http.get( - { host: '127.0.0.1', port, path: '/health', timeout: 2000 }, - (res) => { - res.resume(); - resolve(res.statusCode === 200); - }, - ); - req.on('error', () => resolve(false)); - req.on('timeout', () => { - req.destroy(); - resolve(false); - }); - }); -} - -async function waitForHealthy(port: number): Promise { - const deadline = Date.now() + HEALTH_TIMEOUT_MS; - for (;;) { - if (processRef === null || processRef.exitCode !== null) { - throw new Error('本地 Agent 服务进程已退出'); - } - if (await pingHealth(port)) return; - if (Date.now() > deadline) { - throw new Error(`本地 Agent 服务健康检查超时(${HEALTH_TIMEOUT_MS}ms)`); - } - await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS)); - } -} - -/** 启动本地 Agent 服务,返回 API base URL(含 /api/v1 前缀) */ -export async function startLocalAgentServer(): Promise { - if (resolveLocalAgentRuntimeKind() === 'node') { - return startAgentRuntime(); - } - if (processRef && listenPort) { - return `http://127.0.0.1:${listenPort}/api/v1`; - } - if (startingProcess) { - return startingProcess; - } - - startingProcess = (async () => { - const root = getLocalAgentRoot(); - const entry = path.join(root, 'local_main.py'); - if (!(await fs.pathExists(entry))) { - throw new Error(`本地 Agent 服务未找到: ${entry}`); - } - - const port = await getFreePort(); - const pythonPath = resolveLocalAgentPython(); - const spawnEnv = buildInferenceSpawnEnv(pythonPath); - spawnEnv.LR_AGENT_LOCAL_PORT = String(port); - authToken = randomBytes(32).toString('hex'); - spawnEnv.LR_AGENT_LOCAL_TOKEN = authToken; - - pushLocalAgentStatus({ state: 'starting', baseUrl: null }); - - const proc = spawn(pythonPath, [entry], { - cwd: root, - stdio: 'pipe', - env: spawnEnv, - }) as ChildProcessWithoutNullStreams; - // ENOENT/权限等 spawn 失败走正常启动错误流,而不是 unhandled 'error' - await new Promise((resolve, reject) => { - proc.once('spawn', () => resolve()); - proc.once('error', reject); - }); - proc.on('error', (err) => { - console.error('[localAgent] process error:', err); - }); - proc.stdout.setEncoding('utf8'); - proc.stderr.setEncoding('utf8'); - proc.stdout.on('data', (chunk: string) => { - console.log('[localAgent]', chunk.trimEnd()); - }); - proc.stderr.on('data', (chunk: string) => { - console.error('[localAgent]', chunk.trimEnd()); - }); - proc.on('exit', (code) => { - console.log(`[localAgent] process exited with code ${code}`); - if (processRef === proc) { - processRef = null; - listenPort = null; - authToken = null; - } - const wasStopping = stopping; - stopping = false; - pushLocalAgentStatus({ - state: 'stopped', - baseUrl: null, - message: wasStopping - ? undefined - : `本地 Agent 服务已退出 (code ${code ?? 'unknown'})`, - }); - }); - - processRef = proc; - try { - await waitForHealthy(port); - } catch (err) { - proc.kill(); - processRef = null; - pushLocalAgentStatus({ - state: 'error', - baseUrl: null, - message: err instanceof Error ? err.message : String(err), - }); - throw err; - } - - listenPort = port; - const baseUrl = `http://127.0.0.1:${port}/api/v1`; - console.log(`[localAgent] Local agent server started at ${baseUrl}`); - pushLocalAgentStatus({ state: 'running', baseUrl }); - return baseUrl; - })(); - - try { - return await startingProcess; - } finally { - startingProcess = null; - } -} - -/** 停止本地 Agent 服务 */ -export function stopLocalAgentServer(): void { - if (resolveLocalAgentRuntimeKind() === 'node') { - stopAgentRuntime(); - return; - } - if (processRef) { - stopping = true; - processRef.kill(); - processRef = null; - listenPort = null; - authToken = null; - } -} - -/** 获取当前服务 base URL(未启动时返回 null) */ -export function getLocalAgentBaseUrl(): string | null { - if (resolveLocalAgentRuntimeKind() === 'node') { - return getAgentRuntimeBaseUrl(); - } - if (listenPort) return `http://127.0.0.1:${listenPort}/api/v1`; - return null; -} - -/** 获取当前服务访问 token(未启动时返回 null) */ -export function getLocalAgentToken(): string | null { - if (resolveLocalAgentRuntimeKind() === 'node') { - return getAgentRuntimeToken(); - } - return authToken; -} diff --git a/src/main/main.ts b/src/main/main.ts index 497e874..d6fbf23 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -36,11 +36,11 @@ import { getMcpServerToken, } from './mcp/server'; import { - startLocalAgentServer, - stopLocalAgentServer, - getLocalAgentBaseUrl, - getLocalAgentToken, -} from './localAgent/serverProcess'; + startAgentRuntime as startLocalAgentServer, + stopAgentRuntime as stopLocalAgentServer, + getAgentRuntimeBaseUrl as getLocalAgentBaseUrl, + getAgentRuntimeToken as getLocalAgentToken, +} from './agent/host'; import { initializeDatabase, closeDatabase } from './db/database'; import { migrateSecrets } from './security/migrateSecrets'; import { registerDbHandlers } from './db/handlers'; diff --git a/src/main/skills/docxWorkflow.test.ts b/src/main/skills/docxWorkflow.test.ts index a818525..06159a7 100644 --- a/src/main/skills/docxWorkflow.test.ts +++ b/src/main/skills/docxWorkflow.test.ts @@ -28,14 +28,12 @@ jest.mock('electron', () => ({ }, })); -/** 与 runner 的解释器分派保持一致的可用性探测;不可用时跳过整套用例 */ +/** + * 与 runner 的解释器分派保持一致的可用性探测;不可用时跳过整套用例。 + * + * 嵌入式 Python 运行时已随 Agent 编排迁移移除,因此这里只探测**系统 Python**。 + */ function hasUsablePython(): boolean { - try { - const venv = getVenvPythonPath('local-agent'); - if (fs.existsSync(venv)) return true; - } catch { - // 运行时未就绪 - } const probe = spawnSync(process.platform === 'win32' ? 'python' : 'python3', [ '--version', ]); @@ -108,15 +106,8 @@ describe('docx-demo skill 工作流(生成 → 修改 → 读回)', () => { it('生成的 docx 是合法 zip(OOXML 包结构)', async () => { if (!PYTHON_AVAILABLE) return; // 宿主机无可用 python 时跳过 const docxPath = path.join(workspace, 'report.docx'); - // 用 venv python 旁路校验(系统 'python' 可能是 WindowsApps 占位符) - let pythonPath: string | null = null; - try { - const venv = getVenvPythonPath('local-agent'); - if (fs.existsSync(venv)) pythonPath = venv; - } catch { - // 运行时未就绪 - } - if (!pythonPath) return; + // 嵌入式 venv 已移除,直接用系统解释器校验 + const pythonPath = process.platform === 'win32' ? 'python' : 'python3'; const probe = spawnSync(pythonPath, [ '-c', 'import zipfile,sys; zf=zipfile.ZipFile(sys.argv[1]); ' + diff --git a/src/main/skills/skillScriptRunner.ts b/src/main/skills/skillScriptRunner.ts index f252eef..3e94017 100644 --- a/src/main/skills/skillScriptRunner.ts +++ b/src/main/skills/skillScriptRunner.ts @@ -16,7 +16,6 @@ import fs from 'fs-extra'; import path from 'path'; import { killProcessTree, buildAgentSpawnEnv } from '../exec/processUtils'; import { getActiveWorkspaceRoot } from '../workspace/activeWorkspace'; -import { getVenvPythonPath } from '../env/runtimeManager'; import { normalizeSkillRelativePath, resolveSkillDirPath, @@ -67,14 +66,13 @@ export interface SkillScriptRunOptions { rootDir?: string; } -/** .py 解释器:优先应用自带 local-agent venv(安装向导产物,必有可用依赖),退化系统 python */ +/** + * `.py` 脚本的解释器。 + * + * 原先优先用应用自带的 `local-agent` venv;该嵌入式运行时已随 Agent 编排迁移而移除, + * 因此 Python skill 脚本**降级为需要系统 Python**(不再保证开箱可用)。 + */ function resolvePythonInterpreter(): string { - try { - const venvPython = getVenvPythonPath('local-agent'); - if (venvPython && fs.existsSync(venvPython)) return venvPython; - } catch { - // 运行时未就绪(如开发模式未 fetch runtime),走系统解释器 - } return process.platform === 'win32' ? 'python' : 'python3'; } diff --git a/src/renderer/components/environment/EnvironmentBanner.tsx b/src/renderer/components/environment/EnvironmentBanner.tsx index 9924456..1768a67 100644 --- a/src/renderer/components/environment/EnvironmentBanner.tsx +++ b/src/renderer/components/environment/EnvironmentBanner.tsx @@ -26,8 +26,7 @@ export default function EnvironmentBanner() { return null; } - const localAgentMissing = - !status.localAgent.pythonOk && !status.localAgent.depsInstalled; + const localAgentMissing = !status.localAgent.serviceRunning; const inferenceMissing = !status.inference.pythonOk; if (!localAgentMissing && !inferenceMissing) return null; diff --git a/src/renderer/components/environment/EnvironmentWizard.tsx b/src/renderer/components/environment/EnvironmentWizard.tsx index 561a063..5dcefb6 100644 --- a/src/renderer/components/environment/EnvironmentWizard.tsx +++ b/src/renderer/components/environment/EnvironmentWizard.tsx @@ -416,10 +416,7 @@ export default function EnvironmentWizard(): ReactElement | null { setSavingOverride(true); setError(null); try { - await savePythonOverride( - target, - target === 'local-agent' ? overrideLocal : overrideInference, - ); + await savePythonOverride(target, overrideInference); } catch (saveError) { setError( saveError instanceof Error ? saveError.message : String(saveError), @@ -444,14 +441,6 @@ export default function EnvironmentWizard(): ReactElement | null { const installActive = isInstallActive(install); - const localInfo = status - ? { - pythonPath: status.localAgent.pythonPath, - pythonOk: status.localAgent.pythonOk, - pythonFromVenv: status.localAgent.pythonFromVenv, - depsInstalled: status.localAgent.depsInstalled, - } - : null; const inference = status?.inference ?? null; return ( @@ -574,74 +563,26 @@ export default function EnvironmentWizard(): ReactElement | null { )} - {step === 2 && localInfo && ( + {step === 2 && status && (
- Python 解释器 - {localInfo.pythonOk ? ( - - ) : ( - - )} -
- {localInfo.pythonPath && ( -
- {localInfo.pythonPath} -
- )} - handleSaveOverride('local-agent')} - /> -
- 依赖环境 - {!localInfo.pythonOk ? ( - - ) : localInfo.pythonFromVenv ? ( - localInfo.depsInstalled ? ( - - ) : ( - - ) - ) : ( - - )} -
-
- 服务状态 - {status?.localAgent.serviceRunning ? ( + Agent 编排服务 + {status.localAgent.serviceRunning ? ( ) : ( )}
+ {status.localAgent.serviceUrl && ( +
+ {status.localAgent.serviceUrl} +
+ )}
- Agent 编排服务(Assist 工具循环 / 标注与质量报告编排)由本机 Python - 环境运行。检测到开发用 conda - 环境时无需安装;缺失时可一键安装到内置运行时 - (约几分钟,不影响现有开发环境)。 + Agent 编排(Assist 工具循环、标注与质量报告编排、Skills 与 MCP 调用) + 已内置在应用中,随主程序一起启动,无需安装 Python 或额外依赖。
- - {installFor('local-agent') && ( - - )} - {!isInstallActive(installFor('local-agent')) && ( -
- handleInstall('local-agent')} - /> -
- )}
)} diff --git a/src/renderer/context/EnvironmentContext.tsx b/src/renderer/context/EnvironmentContext.tsx index 68bc771..e2c4aaa 100644 --- a/src/renderer/context/EnvironmentContext.tsx +++ b/src/renderer/context/EnvironmentContext.tsx @@ -100,11 +100,9 @@ export function EnvironmentProvider({ children }: { children: ReactNode }) { /** 保存手动 Python 解释器覆盖(清空即恢复自动检测),随后重新检测 */ const savePythonOverride = useCallback( async (target: InstallTarget, pythonPath: string) => { - const patch = - target === 'local-agent' - ? { localAgentPythonOverride: pythonPath.trim() } - : { inferencePythonOverride: pythonPath.trim() }; - await saveEnvSettings(patch); + // 目前仅剩 inference 一个安装/覆盖目标(Agent 编排已迁到 Node,无需 Python) + void target; + await saveEnvSettings({ inferencePythonOverride: pythonPath.trim() }); await refresh(); }, [refresh], diff --git a/src/shared/envTypes.ts b/src/shared/envTypes.ts index f872d1c..9bf235d 100644 --- a/src/shared/envTypes.ts +++ b/src/shared/envTypes.ts @@ -23,11 +23,12 @@ export interface BackendEnvStatus { } export interface LocalAgentEnvStatus { - pythonPath: string | null; - pythonOk: boolean; - /** 解释器是否来自嵌入式运行时 venv(决定「依赖环境」是否按一键安装口径展示) */ - pythonFromVenv: boolean; - depsInstalled: boolean; + /** + * Agent 编排服务是否在运行。 + * + * 该服务已迁到 Node 运行时(utilityProcess),**不再依赖 Python 解释器**, + * 因此这里没有 `pythonPath` / `pythonOk` / `depsInstalled` 之类的字段。 + */ serviceRunning: boolean; serviceUrl: string | null; error?: string; @@ -59,7 +60,13 @@ export interface EnvironmentStatus { models: ModelGroupStatus[]; } -export type InstallTarget = 'local-agent' | 'inference'; +/** + * 环境安装目标。 + * + * 原先还有 `'local-agent'`——Agent 编排已迁到 Node 运行时,不再需要 Python 环境, + * 因此只剩预标注推理一项(且它是**可选**能力,用户自装 conda 环境即可)。 + */ +export type InstallTarget = 'inference'; export type InferenceVariant = 'gpu' | 'cpu'; /** 「手动指定 Python 解释器」路径的真实校验结果(执行 --version 得到) */ diff --git a/vendor/local-agent/README.md b/vendor/local-agent/README.md deleted file mode 100644 index 67a18d5..0000000 --- a/vendor/local-agent/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# LR-Agent Local - -本机 Agent 编排服务,由 Electron 客户端 spawn,仅监听 `127.0.0.1`。 - -> 本服务内嵌于客户端仓库 `vendor/local-agent/`:开发模式由主进程从 -> `app.getAppPath()/vendor/local-agent` 启动,打包后随客户端分发至 -> `resources/local-agent`。 - -## 架构定位 - -- **LR-Agent-local(本服务)**:Assist 工具循环 SSE、标注 / 质量报告 LLM 编排 -- **LR-Agent-backend(云端)**:仅用户认证与资料管理(auth / users) -- **LR-Agent-inference(`vendor/inference`)**:本地预训练模型推理(YOLO / SAM2 / Keypoint) - -API Key 由前端在请求体中直传(BYOK),仅用于本次 LLM 调用,不落库、不经云端。 - -## 技术栈 - -- FastAPI + Uvicorn(仅 bind 127.0.0.1) -- LangChain(Assist 工具循环、标注 LLM 编排) -- langchain-mcp-adapters(连接 Electron 本地 MCP Server) - -## 快速开始 - -### 1. Conda 环境 - -```bash -conda create -n lr-agent-local python=3.12 -y -conda activate lr-agent-local -pip install -r requirements.txt -``` - -也可设置环境变量让 Electron 自动查找: - -- `LR_AGENT_LOCAL_PYTHON` — Python 可执行文件完整路径 -- `LR_AGENT_LOCAL_CONDA_ENV` — conda 环境名(默认 `lr-agent-local`) - -### 2. 启动 - -```bash -python local_main.py -# 或 -uvicorn local_main:app --host 127.0.0.1 --port 8765 -``` - -端口通过环境变量 `LR_AGENT_LOCAL_PORT` 覆盖(默认 8765)。 - -- 健康检查:http://127.0.0.1:8765/health -- OpenAPI 文档:http://127.0.0.1:8765/docs - -## 项目结构 - -``` -app/ -├── agent/ # Assist / 标注 LLM 编排(与 backend 同源迁移) -├── api/v1/ # 路由(无认证、无 Redis 限流) -├── core/ # 精简 Settings、依赖注入 -├── models/ # User stub(本地无数据库) -└── schemas/ # Pydantic DTO -local_main.py # FastAPI 入口 -``` - -## API 概览 - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/v1/agent/chat/stream` | Assist SSE 流式对话 | -| POST | `/api/v1/agent/chat/cancel` | 取消 Assist 任务 | -| POST | `/api/v1/agent/annotation/batch-prepare` | 批量标注范围+计划 | -| POST | `/api/v1/agent/annotation/mutation-prepare` | 标注变更准备 | -| POST | `/api/v1/agent/annotation/map-detection-boxes` | 检测框标签映射 | -| POST | `/api/v1/agent/annotation/judge-detection-labels` | 整图评分复核 | -| POST | `/api/v1/agent/annotation/map-heuristic` | 启发式映射(无 LLM) | -| POST | `/api/v1/agent/annotation/llm-generate` | 通用 LLM 生成代理(caption/cot/instruction 等) | -| POST | `/api/v1/agent/annotation-quality/*` | 质量报告 LLM 服务 | - -## 与 backend 的差异 - -| 维度 | backend(云端) | local(本服务) | -|------|----------------|-----------------| -| 认证 | JWT + `CurrentUser` | 无(本机单用户) | -| 限流 | Redis | 无 | -| 数据库 | PostgreSQL | 无 | -| 配置 | 完整 `Settings` | 精简 `Settings`(仅 agent/annotation 字段) | -| 监听 | `0.0.0.0` | `127.0.0.1` | - -## 测试 - -```bash -pytest -``` - -测试不依赖 PostgreSQL / Redis / MinIO。 diff --git a/vendor/local-agent/app/__init__.py b/vendor/local-agent/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/agent/__init__.py b/vendor/local-agent/app/agent/__init__.py deleted file mode 100644 index 2e4a658..0000000 --- a/vendor/local-agent/app/agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""LangChain agent orchestration.""" diff --git a/vendor/local-agent/app/agent/annotation/__init__.py b/vendor/local-agent/app/agent/annotation/__init__.py deleted file mode 100644 index e6a1c0d..0000000 --- a/vendor/local-agent/app/agent/annotation/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# ⚠️ 这些函数仅供前端流水线(batchOrchestrator / mutationOrchestrator)内部调用。 -# 外部不应直接 import 或通过 API 暴露。 -# 标注执行的唯一用户入口是 auto_annotate → agentJobRegistry。 -from app.agent.annotation.annotation_scope import merge_annotation_scope -from app.agent.annotation.heuristic_map_service import heuristic_map_boxes -from app.agent.annotation.map_labels_service import map_detection_boxes_to_labels_unified -from app.agent.annotation.batch_prepare_service import prepare_batch_annotation - -__all__ = [ - "prepare_batch_annotation", - "map_detection_boxes_to_labels_unified", - "merge_annotation_scope", - "heuristic_map_boxes", -] diff --git a/vendor/local-agent/app/agent/annotation/annotation_doc_reader.py b/vendor/local-agent/app/agent/annotation/annotation_doc_reader.py deleted file mode 100644 index a340abd..0000000 --- a/vendor/local-agent/app/agent/annotation/annotation_doc_reader.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Read per-file annotation JSON from project .lr-agent/annotations (Electron co-located API).""" - -from __future__ import annotations - -import hashlib -import json -from pathlib import Path - -PROJECT_HIDDEN = ".lr-agent" -ANNOTATIONS_DIR = "annotations" -FILES_DIR = "files" - - -def normalize_relative_path(relative_path: str) -> str: - return "/".join(part for part in relative_path.replace("\\", "/").split("/") if part) - - -def compute_file_key(relative_path: str) -> str: - normalized = normalize_relative_path(relative_path) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest() - - -def annotations_files_dir(project_dir: str) -> Path: - return Path(project_dir) / PROJECT_HIDDEN / ANNOTATIONS_DIR / FILES_DIR - - -def read_file_annotation_doc(project_dir: str, relative_path: str) -> tuple[dict | None, str]: - """ - Return (document dict or None, error/detail message). - """ - root = (project_dir or "").strip() - if not root: - return None, "未提供项目目录路径(project_directory_path)。" - - rel = normalize_relative_path(relative_path.strip()) - if not rel: - return None, "请提供相对路径(如 data/2.jpg)。" - - base = Path(root) - if not base.is_dir(): - return None, f"项目目录不可读:{root}" - - file_key = compute_file_key(rel) - doc_path = annotations_files_dir(root) / f"{file_key}.json" - if not doc_path.is_file(): - return None, f"未找到 {rel} 的标注文件(可能尚未标注)。" - - try: - data = json.loads(doc_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, f"读取标注失败:{exc}" - - if not isinstance(data, dict): - return None, "标注文件格式无效。" - return data, "" diff --git a/vendor/local-agent/app/agent/annotation/annotation_scope.py b/vendor/local-agent/app/agent/annotation/annotation_scope.py deleted file mode 100644 index e9893ce..0000000 --- a/vendor/local-agent/app/agent/annotation/annotation_scope.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Annotation scope filtering & merge (fusion-reasoning parity).""" -from __future__ import annotations - -import re -from typing import Any - -from pydantic import BaseModel, Field, field_validator - -from app.agent.annotation.schemas import AnnotationScopePayload - -SCOPE_KEYWORD_TO_DETECTION: dict[str, list[str]] = { - "人脸": ["person", "face"], - "面部": ["person", "face"], - "人体": ["person"], - "人物": ["person"], - "人": ["person"], - "球员": ["person"], - "篮球": ["sports ball", "basketball"], - "球": ["sports ball", "basketball", "ball"], - "汽车": ["car"], - "车": ["car"], - "猫": ["cat"], - "狗": ["dog"], -} - -ONLY_ANNOTATE_RE = re.compile( - r"(?:只|仅)(?:标注|标|检测|框选|识别|打框|标出)?(.{1,24})" -) -EXCLUDE_RE = re.compile( - r"(?:不要|别|排除|不包括|无需|不用)(?:标注|标|检测|框选)?(.{1,24})" -) - - -class AnnotationScope(BaseModel): - scope_summary: str = "" - include_detection_labels: list[str] = Field(default_factory=list) - exclude_detection_labels: list[str] = Field(default_factory=list) - include_label_names: list[str] = Field(default_factory=list) - exclude_label_names: list[str] = Field(default_factory=list) - - @field_validator( - "include_detection_labels", - "exclude_detection_labels", - "include_label_names", - "exclude_label_names", - mode="before", - ) - @classmethod - def _coerce_str_list(cls, v: Any) -> list[str]: - if v is None: - return [] - if isinstance(v, str): - v = v.strip() - return [v] if v else [] - if isinstance(v, list): - return [str(item).strip() for item in v if str(item).strip()] - return [] - - def is_restricted(self) -> bool: - return bool( - self.scope_summary.strip() - or self.include_detection_labels - or self.exclude_detection_labels - or self.include_label_names - or self.exclude_label_names - ) - - def to_payload(self) -> AnnotationScopePayload: - return AnnotationScopePayload( - scope_summary=self.scope_summary, - include_detection_labels=list(self.include_detection_labels), - exclude_detection_labels=list(self.exclude_detection_labels), - include_label_names=list(self.include_label_names), - exclude_label_names=list(self.exclude_label_names), - ) - - @classmethod - def from_payload(cls, payload: AnnotationScopePayload | dict[str, Any] | None) -> AnnotationScope: - if payload is None: - return cls() - if isinstance(payload, AnnotationScopePayload): - return cls.model_validate(payload.model_dump()) - if isinstance(payload, dict): - return cls.model_validate(payload) - return cls() - - -def normalize_detection_label(label: str) -> str: - return re.sub(r"\s+", " ", (label or "").strip().lower().replace("_", " ")) - - -def expand_detection_aliases(terms: list[str]) -> list[str]: - out: list[str] = [] - seen: set[str] = set() - for term in terms: - t = (term or "").strip() - if not t: - continue - for c in SCOPE_KEYWORD_TO_DETECTION.get(t, [t]): - nc = normalize_detection_label(c) - if nc and nc not in seen: - seen.add(nc) - out.append(c) - return out - - -def _extract_scope_terms(text: str, pattern: re.Pattern[str]) -> list[str]: - terms: list[str] = [] - for m in pattern.finditer(text): - chunk = (m.group(1) or "").strip() - chunk = re.split(r"[,。;;、和与及\s]+", chunk)[0].strip() - if chunk and len(chunk) <= 24: - terms.append(chunk) - return terms - - -def infer_annotation_scope_from_text( - user_text: str, - *, - label_names: list[str] | None = None, -) -> AnnotationScope: - text = (user_text or "").strip() - if not text: - return AnnotationScope() - - include_terms = _extract_scope_terms(text, ONLY_ANNOTATE_RE) - exclude_terms = _extract_scope_terms(text, EXCLUDE_RE) - scope = AnnotationScope() - include_det: list[str] = [] - exclude_det: list[str] = [] - include_labels: list[str] = [] - - for term in include_terms: - include_det.extend(expand_detection_aliases([term])) - if term in SCOPE_KEYWORD_TO_DETECTION or term in ("人脸", "面部", "人物", "人体"): - scope.scope_summary = f"仅标注{term}" - - if not include_terms: - if any(k in text for k in ("人脸", "面部")): - include_det.extend(expand_detection_aliases(["人脸"])) - scope.scope_summary = scope.scope_summary or "仅标注人脸/人物" - elif "人物" in text and "标注" in text: - include_det.extend(expand_detection_aliases(["人物"])) - scope.scope_summary = scope.scope_summary or "仅标注人物" - - for term in exclude_terms: - exclude_det.extend(expand_detection_aliases([term])) - if not scope.scope_summary: - scope.scope_summary = f"排除{term}" - - if any(k in text for k in ("篮球", "球")) and any( - k in text for k in ("不要", "别", "排除", "不包括", "无需") - ): - exclude_det.extend(expand_detection_aliases(["篮球"])) - - if label_names: - for name in label_names: - if name and name in text and any(k in text for k in ("只", "仅")): - include_labels.append(name) - - scope.include_detection_labels = list(dict.fromkeys(include_det)) - scope.exclude_detection_labels = list(dict.fromkeys(exclude_det)) - scope.include_label_names = list(dict.fromkeys(include_labels)) - return scope - - -def merge_annotation_scope( - scope: AnnotationScopePayload | AnnotationScope | None, - user_text: str, - *, - label_names: list[str] | None = None, -) -> AnnotationScope: - base = AnnotationScope.from_payload(scope) if scope else AnnotationScope() - inferred = infer_annotation_scope_from_text(user_text, label_names=label_names) - if not base.scope_summary.strip() and inferred.scope_summary.strip(): - base.scope_summary = inferred.scope_summary - if not base.include_detection_labels and inferred.include_detection_labels: - base.include_detection_labels = inferred.include_detection_labels - if not base.exclude_detection_labels and inferred.exclude_detection_labels: - base.exclude_detection_labels = inferred.exclude_detection_labels - if not base.include_label_names and inferred.include_label_names: - base.include_label_names = inferred.include_label_names - if not base.exclude_label_names and inferred.exclude_label_names: - base.exclude_label_names = inferred.exclude_label_names - return base - - -def filter_label_candidates_by_scope( - candidates: list[dict], - scope: AnnotationScope, -) -> list[dict]: - if not scope.include_label_names and not scope.exclude_label_names: - return candidates - include = {n.lower() for n in scope.include_label_names} - exclude = {n.lower() for n in scope.exclude_label_names} - filtered: list[dict] = [] - for c in candidates: - name = str(c.get("name") or "").lower() - if exclude and name in exclude: - continue - if include: - if name in include: - filtered.append(c) - else: - filtered.append(c) - return filtered if filtered else candidates diff --git a/vendor/local-agent/app/agent/annotation/batch_prepare_service.py b/vendor/local-agent/app/agent/annotation/batch_prepare_service.py deleted file mode 100644 index e1bff5e..0000000 --- a/vendor/local-agent/app/agent/annotation/batch_prepare_service.py +++ /dev/null @@ -1,217 +0,0 @@ -"""批量标注准备:单次 LLM 同时完成图片范围选择与执行计划生成。 - -替代原先多步 parse-task + parse-scope + create-plan 流程。 -由 annotation_agent API `/batch-prepare` 调用,前端在 execute_batch 路由后发起。 - -注意:当前几何标注管线(geometryBatchPipeline)不调用本服务——范围由 auto_annotate -的 paths/all_files 解析,检测约束由 auto_annotate 的 conf_threshold/model_id 等参数透传。 -本服务保留供后续「LLM 规划模式」使用。 - -输出 BatchPrepareResult: - - selected_paths:本轮待标注图片相对路径 - - scope_reason:选图依据 - - plan:子 Agent 执行计划(检测参数、标签策略、视觉映射、annotation_scope 等) -""" - -from __future__ import annotations - -import json - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI - -from app.agent.annotation.annotation_scope import merge_annotation_scope -from app.agent.annotation.debug_log import log_annotation_agent -from app.agent.annotation.json_utils import extract_json_object -from app.agent.annotation.label_vision_policy import labels_require_vision_mapping -from app.agent.annotation.plan_parsing import build_batch_plan_from_data -from app.agent.annotation.schemas import AnnotationScopePayload, BatchPlanResult, BatchPrepareResult - -BATCH_PREPARE_SYSTEM = """你是批量图片 bbox 标注的准备助手(一次输出,勿分步啰嗦)。 - -你需要同时完成: -1. 从候选图片列表中选出本轮要标注的文件(selected_paths,必须为候选中的 relative_path) -2. 生成子 Agent 执行计划(检测参数、标签策略、是否视觉映射、annotation_scope 等) - -子 Agent 工具链:run_object_detection → map_detection_boxes_to_labels → finalize_image_change - -硬性要求: -- 不确定的框可留空 label_id(allow_unlabeled_boxes=true 时仍进入提案,供人工补标) -- 实例/细粒度标签(球员名等)默认 allow_unlabeled_boxes=true、min_labeled_box_count=1 -- label_strategy=single_label_for_all_boxes 仅当用户明确要求所有框同一标签 -- 标签名为具体实例(球员名等)且与 YOLO 类名不一致时,use_vision_mapping=true(需视觉探针通过) -- 用户说置信度/IoU/模型名时写入 detection_hints -- 勿编造不在候选列表中的路径;无法确定时 selected_paths 为空并在 scope_reason 说明 - -选图规则(selected_paths 由你根据本轮用户请求决定,每轮重新分析): -- 以【用户请求】为最高优先级;用户纠正或扩大范围时,覆盖对话历史中的旧范围 -- 用户指定文件夹/目录(如「data 下所有图片」)时,从候选列表按 parent 字段选出该目录下全部图片 -- 用户指定单张或若干具体文件时,只选对应 path;指代消解可参考【对话上下文】(如「第一张」「按上面说的」) -- 「当前打开文件」仅作参考;用户未限定为单张时,不要仅因当前打开图而只选一张 -- scope_reason 必须与 selected_paths 的实际数量和内容一致 - -只输出一个 JSON 对象: -{ - "selected_paths": ["relative/path.jpg"], - "scope_reason": "为何选这些图", - "intent_summary": "一句话任务摘要", - "label_strategy": "map_each_box_to_label|single_label_for_all_boxes", - "use_vision_mapping": false, - "detection_hints": {"needs_object_detection": true, "model_id": null, "conf_threshold": null, "iou_threshold": null, "notes": ""}, - "sub_agent_constraints": {"require_per_box_mapping": true, "allow_unlabeled_boxes": true, "min_labeled_box_count": 1}, - "annotation_scope": {"scope_summary": "", "include_detection_labels": [], "exclude_detection_labels": [], "include_label_names": [], "exclude_label_names": []}, - "plan_steps": ["简要步骤"] -}""" - - -def _filter_selected_paths( - raw_paths: object, - candidates: list[dict], -) -> tuple[list[str], str]: - """将 LLM 输出的路径列表过滤为候选中存在的 relative_path。""" - by_path: dict[str, dict] = {} - for item in candidates: - rel = str(item.get("relative_path") or "").strip().replace("\\", "/") - if rel: - by_path[rel] = item - - selected: list[str] = [] - if isinstance(raw_paths, list): - for raw in raw_paths: - key = str(raw).strip().replace("\\", "/") - if key and key in by_path: - selected.append(key) - - reason = "" - return selected, reason - - -async def prepare_batch_annotation( - llm: ChatOpenAI, - *, - user_request: str, - current_relative_path: str, - candidates: list[dict], - label_candidates: list[dict], - detection_models: list[dict], - default_conf: float = 0.7, - default_iou: float = 0.5, - provider_is_vision: bool = False, - project_name: str | None = None, - label_names: list[str] | None = None, - conversation_transcript: str = "", - preselected_paths: list[str] | None = None, -) -> BatchPrepareResult: - """单次 LLM 调用:选图 + 生成 BatchPlan,供后续逐张确定性 detect → map → finalize 使用。""" - if not candidates: - empty_scope = AnnotationScopePayload() - plan = build_batch_plan_from_data( - {}, - user_request=user_request, - intent_summary=user_request[:200] or "批量图片标注", - annotation_scope=empty_scope, - label_candidates=label_candidates, - detection_models=detection_models, - default_conf=default_conf, - default_iou=default_iou, - provider_is_vision=provider_is_vision, - log_prefix="batch-prepare", - ) - return BatchPrepareResult( - selected_paths=[], - scope_reason="项目目录无图片候选", - plan=plan, - ) - - prompt_candidates = [ - { - "relative_path": c.get("relative_path"), - "name": c.get("name"), - "parent": c.get("parent"), - } - for c in candidates[:500] - ] - labels_line = "" - if label_names: - labels_line = f"\n项目标签:{', '.join(label_names[:40])}\n" - - # 仅当客户端显式传入 preselected_paths(如 UI 勾选)时锁定选图,不由回合理解注入 - locked_paths, _ = _filter_selected_paths(preselected_paths or [], candidates) - locked_block = "" - if locked_paths: - locked_block = ( - f"\n【客户端已指定图片范围(勿修改 selected_paths)】\n" - f"{json.dumps(locked_paths, ensure_ascii=False)}\n\n" - ) - - transcript_block = "" - if conversation_transcript.strip(): - transcript_block = ( - f"\n【对话上下文】\n{conversation_transcript.strip()}\n\n" - ) - - messages = [ - SystemMessage(content=BATCH_PREPARE_SYSTEM), - HumanMessage( - content=( - f"{transcript_block}" - f"{locked_block}" - f"用户请求:{user_request}\n\n" - f"项目名称:{project_name or '未知'}\n" - f"当前打开文件:{current_relative_path or '(无)'}" - f"{labels_line}\n" - f"候选图片({len(prompt_candidates)} 项):\n" - f"{json.dumps(prompt_candidates, ensure_ascii=False)}\n\n" - f"当前 LLM 视觉探针(API 实测):{bool(provider_is_vision)}\n" - f"标签是否需视觉映射:{labels_require_vision_mapping(label_candidates)}\n\n" - f"检测模型:{json.dumps(detection_models[:10], ensure_ascii=False)}\n\n" - f"标签候选:{json.dumps(label_candidates[:80], ensure_ascii=False)}" - ) - ), - ] - - resp = await llm.ainvoke(messages) - content = resp.content if hasattr(resp, "content") else str(resp) - data = extract_json_object(str(content)) - - if locked_paths: - selected = locked_paths - scope_reason = str(data.get("scope_reason") or "").strip() or "客户端已指定图片范围" - else: - selected, _ = _filter_selected_paths(data.get("selected_paths"), candidates) - scope_reason = str(data.get("scope_reason") or data.get("reason") or "").strip() - if not selected and not scope_reason: - scope_reason = "未从候选中解析到图片,请更具体说明文件夹或文件名" - - base_scope = merge_annotation_scope( - AnnotationScopePayload(), - user_request, - label_names=label_names, - ) - plan = build_batch_plan_from_data( - data, - user_request=user_request, - intent_summary=str(data.get("intent_summary") or user_request[:200]), - annotation_scope=base_scope, - label_candidates=label_candidates, - detection_models=detection_models, - default_conf=default_conf, - default_iou=default_iou, - provider_is_vision=provider_is_vision, - log_prefix="batch-prepare", - ) - - log_annotation_agent( - "batch-prepare", - "批量准备完成", - selected_count=len(selected), - paths=selected[:10], - use_vision_mapping=plan.use_vision_mapping, - label_strategy=plan.label_strategy, - ) - - return BatchPrepareResult( - selected_paths=selected, - scope_reason=scope_reason, - plan=plan, - ) diff --git a/vendor/local-agent/app/agent/annotation/debug_log.py b/vendor/local-agent/app/agent/annotation/debug_log.py deleted file mode 100644 index 071e06c..0000000 --- a/vendor/local-agent/app/agent/annotation/debug_log.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Structured logs for batch annotation debugging (uvicorn terminal).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -logger = logging.getLogger(__name__) - - -def log_annotation_agent(stage: str, message: str, **fields: Any) -> None: - if fields: - try: - extra = json.dumps(fields, ensure_ascii=False, default=str) - except (TypeError, ValueError): - extra = str(fields) - logger.info("[annotation_agent] [%s] %s | %s", stage, message, extra) - else: - logger.info("[annotation_agent] [%s] %s", stage, message) diff --git a/vendor/local-agent/app/agent/annotation/heuristic_map_service.py b/vendor/local-agent/app/agent/annotation/heuristic_map_service.py deleted file mode 100644 index 04071b9..0000000 --- a/vendor/local-agent/app/agent/annotation/heuristic_map_service.py +++ /dev/null @@ -1,67 +0,0 @@ -"""启发式检测框 → 标签映射(无 LLM)。 - -作为 map_detection_boxes_to_labels_unified 的非视觉回退路径, -也可通过 /map-heuristic API 独立调用。匹配规则: - 1. 检测类名与标签名精确匹配 - 2. 检测类名与标签名部分匹配(唯一命中时) - 3. OCR 文本含唯一标签名 -""" - -from __future__ import annotations - - -def heuristic_map_boxes( - boxes: list[dict], - label_candidates: list[dict], - *, - ocr_text: str = "", -) -> list[dict]: - """为每个检测框尝试基于类名/OCR 的自动标签映射,无法匹配时 label_id 为空。""" - by_name: dict[str, dict] = {} - for item in label_candidates: - name = str(item.get("name") or "").strip().lower() - if name: - by_name[name] = item - - flat = list(label_candidates) - ocr_lower = (ocr_text or "").lower() - mappings: list[dict] = [] - - for idx, box in enumerate(boxes): - det = str(box.get("class_name") or box.get("detection_label") or "").strip() - chosen: dict | None = None - reason = "" - - if det: - hit = by_name.get(det.lower()) - if hit: - chosen = hit - reason = f"检测类名 {det!r} 与标签匹配" - else: - det_lower = det.lower() - partial = [ - n - for n in flat - if det_lower in str(n.get("name") or "").lower() - or str(n.get("name") or "").lower() in det_lower - ] - if len(partial) == 1: - chosen = partial[0] - reason = f"检测类名与标签 {chosen.get('name')!r} 部分匹配" - - if not chosen and ocr_lower: - name_hits = [ - n for n in flat if str(n.get("name") or "").lower() in ocr_lower - ] - if len(name_hits) == 1: - chosen = name_hits[0] - reason = f"OCR 含标签名 {chosen.get('name')!r}" - - mappings.append( - { - "box_index": int(box.get("box_index", idx)), - "label_id": str(chosen.get("id") or "") if chosen else "", - "reason": reason or ("未能自动映射" if not chosen else reason), - } - ) - return mappings diff --git a/vendor/local-agent/app/agent/annotation/image_bytes_loader.py b/vendor/local-agent/app/agent/annotation/image_bytes_loader.py deleted file mode 100644 index a72e6ba..0000000 --- a/vendor/local-agent/app/agent/annotation/image_bytes_loader.py +++ /dev/null @@ -1,83 +0,0 @@ -"""图像字节加载:从本地绝对路径或 base64 获取原始图像数据。 - -Electron 客户端与后端 API 同机部署时优先读 image_absolute_path; -否则回退 image_base64。供 map_labels_service 裁剪、chat_message_builder 多模态消息使用。 -""" - -from __future__ import annotations - -import base64 -import io -from pathlib import Path - - -def load_image_bytes( - *, - image_absolute_path: str = "", - image_base64: str = "", -) -> tuple[bytes | None, str]: - """加载图像字节,返回 (bytes, source),source 为 path | base64 | none。""" - path_str = (image_absolute_path or "").strip() - if path_str: - try: - path = Path(path_str) - if path.is_file(): - return path.read_bytes(), "path" - except OSError: - pass - - raw = (image_base64 or "").strip() - if not raw: - return None, "none" - try: - if "," in raw[:80]: - raw = raw.split(",", 1)[1] - return base64.b64decode(raw), "base64" - except Exception: - return None, "none" - - -def resize_image_to_jpeg_bytes( - raw: bytes, - *, - max_edge: int, - jpeg_quality: int, -) -> tuple[bytes, str]: - """等比缩放并编码为 JPEG,返回 (jpeg_bytes, mime)。""" - from PIL import Image - - image = Image.open(io.BytesIO(raw)) - if image.mode not in ("RGB",): - if image.mode in ("RGBA", "LA", "P"): - background = Image.new("RGB", image.size, (255, 255, 255)) - if image.mode == "P": - image = image.convert("RGBA") - background.paste( - image, - mask=image.split()[-1] if image.mode == "RGBA" else None, - ) - image = background - else: - image = image.convert("RGB") - w, h = image.size - scale = min(1.0, max_edge / max(w, h)) - if scale < 1.0: - image = image.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS) - buffer = io.BytesIO() - image.save(buffer, format="JPEG", quality=jpeg_quality, optimize=True) - return buffer.getvalue(), "image/jpeg" - - -def image_bytes_to_data_url( - raw: bytes, - *, - max_edge: int, - jpeg_quality: int, -) -> str: - jpeg_bytes, mime = resize_image_to_jpeg_bytes( - raw, - max_edge=max_edge, - jpeg_quality=jpeg_quality, - ) - b64 = base64.b64encode(jpeg_bytes).decode("ascii") - return f"data:{mime};base64,{b64}" diff --git a/vendor/local-agent/app/agent/annotation/json_utils.py b/vendor/local-agent/app/agent/annotation/json_utils.py deleted file mode 100644 index 31aeded..0000000 --- a/vendor/local-agent/app/agent/annotation/json_utils.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Extract JSON objects from LLM text responses.""" -from __future__ import annotations - -import json -import re -from typing import Any - - -def extract_json_object(text: str) -> dict[str, Any]: - raw = (text or "").strip() - if raw.startswith("```"): - raw = raw.strip("`") - raw = re.sub(r"^json\s*\n", "", raw, flags=re.IGNORECASE) - try: - data = json.loads(raw) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError: - pass - match = re.search(r"\{[\s\S]*\}", raw) - if not match: - return {} - try: - data = json.loads(match.group(0)) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError: - return {} diff --git a/vendor/local-agent/app/agent/annotation/label_candidate_resolver.py b/vendor/local-agent/app/agent/annotation/label_candidate_resolver.py deleted file mode 100644 index b08d201..0000000 --- a/vendor/local-agent/app/agent/annotation/label_candidate_resolver.py +++ /dev/null @@ -1,170 +0,0 @@ -"""标签候选池解析:scope 显式范围 / 可选整图 preflight。""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from typing import Any, Literal - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI - -from app.agent.annotation.annotation_scope import ( - AnnotationScope, - filter_label_candidates_by_scope, -) -from app.agent.annotation.json_utils import extract_json_object -from app.agent.annotation.image_bytes_loader import image_bytes_to_data_url, load_image_bytes -from app.core.config import Settings, get_settings - -PreflightMode = Literal["off", "auto", "always"] - -PREFLIGHT_SYSTEM = """你是视觉标注准备助手。根据整图与用户意图,从标签候选中选出本图实际可能出现的 label_id。 -不要猜测图中没有的目标。若无法可靠判断,present_label_ids 必须为空并设置 uncertain=true。 -只输出 JSON:{"present_label_ids":["..."], "uncertain": false}""" - - -@dataclass -class LabelPoolResult: - candidates: list[dict] - source: str = "full" - excluded_names: list[str] = field(default_factory=list) - preflight_label_ids: list[str] | None = None - - -def _should_run_preflight( - mode: PreflightMode, - *, - candidate_count: int, - box_count: int, - min_extra: int, -) -> bool: - if mode == "off" or box_count <= 0: - return False - if mode == "always": - return True - extra = candidate_count - box_count - return extra >= min_extra - - -def _image_bytes_to_data_url(image_bytes: bytes, settings: Settings) -> str: - return image_bytes_to_data_url( - image_bytes, - max_edge=settings.agent_chat_vision_max_edge, - jpeg_quality=settings.agent_chat_vision_jpeg_quality, - ) - - -async def preflight_label_pool( - llm: ChatOpenAI, - image_bytes: bytes, - candidates: list[dict], - *, - box_count: int, - user_request: str, - intent_summary: str, - settings: Settings | None = None, -) -> tuple[list[str], bool]: - """整图 preflight:返回 (present_label_ids, uncertain)。""" - cfg = settings or get_settings() - valid_ids = {str(c.get("id") or "") for c in candidates} - payload = [ - {"id": c.get("id"), "name": c.get("name")} - for c in candidates[:80] - ] - user_text = ( - f"用户请求:{user_request}\n意图:{intent_summary}\n" - f"检测框数量:{box_count}\n\n标签候选:\n" - f"{json.dumps(payload, ensure_ascii=False)}" - ) - data_url = _image_bytes_to_data_url(image_bytes, cfg) - messages = [ - SystemMessage(content=PREFLIGHT_SYSTEM), - HumanMessage( - content=[ - {"type": "text", "text": user_text}, - {"type": "image_url", "image_url": {"url": data_url}}, - ] - ), - ] - try: - resp = await llm.ainvoke(messages) - except Exception: - return [], True - - content = resp.content if hasattr(resp, "content") else str(resp) - if isinstance(content, list): - content = "".join( - p.get("text", "") if isinstance(p, dict) else str(p) for p in content - ) - data = extract_json_object(str(content)) - uncertain = bool(data.get("uncertain", False)) - raw_ids = data.get("present_label_ids") or data.get("label_ids") or [] - if not isinstance(raw_ids, list): - return [], True - present = [str(i).strip() for i in raw_ids if str(i).strip() in valid_ids] - if uncertain or not present: - return [], True - return present, False - - -async def resolve_effective_label_candidates( - llm: ChatOpenAI | None, - *, - all_candidates: list[dict], - scope: AnnotationScope, - user_request: str, - intent_summary: str, - box_count: int, - image_bytes: bytes | None = None, - settings: Settings | None = None, -) -> LabelPoolResult: - """L0 annotation_scope 显式标签范围 → L1 可选 preflight。 - - user_request / intent_summary 不参与候选池缩小(避免 prepare 摘要举例误伤)。 - """ - cfg = settings or get_settings() - scoped = filter_label_candidates_by_scope(list(all_candidates), scope) - source = "scope" if scoped is not all_candidates and len(scoped) < len(all_candidates) else "full" - excluded: list[str] = [] - - preflight_ids: list[str] | None = None - mode: PreflightMode = cfg.annotation_label_pool_preflight # type: ignore[assignment] - if ( - llm is not None - and image_bytes - and _should_run_preflight( - mode, - candidate_count=len(scoped), - box_count=box_count, - min_extra=cfg.annotation_label_pool_preflight_min_extra, - ) - ): - present, uncertain = await preflight_label_pool( - llm, - image_bytes, - scoped, - box_count=box_count, - user_request=user_request, - intent_summary=intent_summary, - settings=cfg, - ) - if not uncertain and present: - id_set = set(present) - pref_filtered = [c for c in scoped if str(c.get("id") or "") in id_set] - if pref_filtered: - preflight_ids = present - excluded.extend( - str(c.get("name") or "") - for c in scoped - if str(c.get("id") or "") not in id_set - ) - scoped = pref_filtered - source = "preflight" - - return LabelPoolResult( - candidates=scoped, - source=source, - excluded_names=[n for n in excluded if n], - preflight_label_ids=preflight_ids, - ) diff --git a/vendor/local-agent/app/agent/annotation/label_vision_policy.py b/vendor/local-agent/app/agent/annotation/label_vision_policy.py deleted file mode 100644 index 417ac2c..0000000 --- a/vendor/local-agent/app/agent/annotation/label_vision_policy.py +++ /dev/null @@ -1,107 +0,0 @@ -"""标签视觉映射策略:判断任务标签是否必须启用视觉映射。 - -当项目标签名与 YOLO/COCO 标准检测类名无交集时(如球员姓名 Curry/James vs person), -启发式类名匹配无法工作,batch_prepare / plan_parsing 会据此建议 use_vision_mapping=true。 -""" - -from __future__ import annotations - -# COCO / YOLO 常见检测类名(小写),用于与项目标签名比对 -YOLO_COCO_CLASS_NAMES = frozenset( - { - "person", - "bicycle", - "car", - "motorcycle", - "airplane", - "bus", - "train", - "truck", - "boat", - "traffic light", - "fire hydrant", - "stop sign", - "parking meter", - "bench", - "bird", - "cat", - "dog", - "horse", - "sheep", - "cow", - "elephant", - "bear", - "zebra", - "giraffe", - "backpack", - "umbrella", - "handbag", - "tie", - "suitcase", - "frisbee", - "skis", - "snowboard", - "sports ball", - "kite", - "baseball bat", - "baseball glove", - "skateboard", - "surfboard", - "tennis racket", - "bottle", - "wine glass", - "cup", - "fork", - "knife", - "spoon", - "bowl", - "banana", - "apple", - "sandwich", - "orange", - "broccoli", - "carrot", - "hot dog", - "pizza", - "donut", - "cake", - "chair", - "couch", - "potted plant", - "bed", - "dining table", - "toilet", - "tv", - "laptop", - "mouse", - "remote", - "keyboard", - "cell phone", - "microwave", - "oven", - "toaster", - "sink", - "refrigerator", - "book", - "clock", - "vase", - "scissors", - "teddy bear", - "hair drier", - "toothbrush", - "face", - } -) - - -def _norm_label(name: str) -> str: - return (name or "").strip().lower().replace("_", " ") - - -def labels_require_vision_mapping(label_candidates: list[dict]) -> bool: - """判断标签候选是否需视觉映射:无标签名命中标准检测类名时返回 True。""" - names = [_norm_label(str(c.get("name") or "")) for c in label_candidates] - names = [n for n in names if n] - if not names: - return False - return not any(n in YOLO_COCO_CLASS_NAMES for n in names) diff --git a/vendor/local-agent/app/agent/annotation/llm_invoke.py b/vendor/local-agent/app/agent/annotation/llm_invoke.py deleted file mode 100644 index 91b3f08..0000000 --- a/vendor/local-agent/app/agent/annotation/llm_invoke.py +++ /dev/null @@ -1,65 +0,0 @@ -"""LLM invoke helpers without OpenAI json_schema (DeepSeek-compatible).""" -from __future__ import annotations - -import logging -from typing import Any, TypeVar - -from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI -from pydantic import BaseModel - -from app.agent.annotation.json_utils import extract_json_object - -logger = logging.getLogger(__name__) - -T = TypeVar("T", bound=BaseModel) - -_LOG_TRUNCATE = 4_000 - - -def coerce_null_string_fields(data: dict[str, Any], *fields: str) -> dict[str, Any]: - """将指定字段的 JSON null 归一为 \"\",避免 LLM 误用 null 导致 Pydantic 校验失败。""" - if not fields: - return data - out = dict(data) - for key in fields: - if key in out and out[key] is None: - out[key] = "" - return out - - -async def invoke_json_model( - llm: ChatOpenAI, - messages: list[BaseMessage], - model_cls: type[T], - *, - extra_instruction: str = "", - log_label: str | None = None, - null_string_fields: tuple[str, ...] = (), -) -> T: - sys_parts = [m.content for m in messages if isinstance(m, SystemMessage)] - human_parts = [m.content for m in messages if isinstance(m, HumanMessage)] - prompt = [ - *(sys_parts or ["你是结构化 JSON 助手。"]), - extra_instruction, - "只输出一个 JSON 对象,不要 markdown 代码块。", - "\n".join(human_parts), - ] - merged = [ - SystemMessage(content="\n".join(p for p in prompt[:2] if p)), - HumanMessage(content="\n".join(prompt[2:])), - ] - resp = await llm.ainvoke(merged) - content = resp.content if hasattr(resp, "content") else str(resp) - raw_text = str(content) - data = extract_json_object(raw_text) - if null_string_fields: - data = coerce_null_string_fields(data, *null_string_fields) - if log_label: - logger.info( - "[%s] llm raw response (truncated): %s", - log_label, - raw_text[:_LOG_TRUNCATE], - ) - logger.info("[%s] extracted json: %s", log_label, data) - return model_cls.model_validate(data) diff --git a/vendor/local-agent/app/agent/annotation/map_labels_service.py b/vendor/local-agent/app/agent/annotation/map_labels_service.py deleted file mode 100644 index cbfd20b..0000000 --- a/vendor/local-agent/app/agent/annotation/map_labels_service.py +++ /dev/null @@ -1,591 +0,0 @@ -"""统一的检测框 → 标签映射服务(Fusion 主路径)。 - -由 /map-detection-boxes API、deterministicSubImageRunner 调用。 -映射策略(二选一,无纯文本 LLM 回退): - 1. vision_crop:use_vision=true 且有图像时,逐框裁剪 + 并发视觉 LLM 映射 - 2. heuristic:检测类名 / OCR 文本与标签名匹配(heuristic_map_service) - -特殊快捷:label_strategy=single_label_for_all_boxes 时直接赋同一 label_id。 -""" - -from __future__ import annotations - -import asyncio -import base64 -import io -import json -from typing import Any - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI - -from app.agent.annotation.annotation_scope import ( - AnnotationScope, - filter_label_candidates_by_scope, -) -from app.agent.annotation.heuristic_map_service import heuristic_map_boxes -from app.agent.annotation.image_bytes_loader import load_image_bytes -from app.agent.annotation.json_utils import extract_json_object -from app.agent.annotation.debug_log import log_annotation_agent -from app.agent.annotation.label_candidate_resolver import resolve_effective_label_candidates -from app.agent.annotation.label_vision_policy import labels_require_vision_mapping -from app.agent.annotation.map_validation_service import ( - candidates_for_retry_box, - format_issues_for_retry, - validate_vision_mappings, -) -from app.agent.annotation.schemas import AnnotationScopePayload -from app.core.config import get_settings - -CROP_VISION_SYSTEM = """你是视觉标注助手。你会收到一张裁剪后的目标区域图。 -请根据图像内容与用户标注意图,从标签候选中选择最合适的 label_id。 -若裁剪区域与任一候选标签语义不符,必须返回空 label_id。 -只输出 JSON:{"label_id":"...","reason":"..."} ;无法确定或与任务无关时 label_id 必须为 ""。""" - - -def _resize_image_for_llm(img, max_side: int = 768): - """等比缩放裁剪图,控制视觉 LLM 输入尺寸。""" - from PIL import Image - - w, h = img.size - if max(w, h) <= max_side: - return img - scale = max_side / max(w, h) - return img.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS) - - -def _pil_to_data_url(img, fmt: str = "JPEG") -> str: - """将 PIL 图像编码为 data URL,供多模态 LLM 使用。""" - buf = io.BytesIO() - if fmt.upper() == "JPEG" and img.mode in ("RGBA", "P"): - img = img.convert("RGB") - img.save(buf, format=fmt, quality=85) - mime = "image/jpeg" if fmt.upper() == "JPEG" else f"image/png" - b64 = base64.b64encode(buf.getvalue()).decode("ascii") - return f"data:{mime};base64,{b64}" - - -def _coords_are_normalized(boxes: list[dict]) -> bool: - """判断检测框坐标是否为 0~1 归一化格式(任一维度 >1.5 则视为像素坐标)。""" - for b in boxes: - for key in ("x", "y", "width", "height"): - try: - if float(b.get(key, 0)) > 1.5: - return False - except (TypeError, ValueError): - return False - return bool(boxes) - - -def _crop_boxes_from_image_bytes( - image_bytes: bytes, - boxes: list[dict], - *, - normalized: bool, -) -> list[dict[str, Any]]: - """从原图按检测框裁剪子区域,生成带 data_url 的区域列表。""" - from PIL import Image - - regions: list[dict[str, Any]] = [] - try: - with Image.open(io.BytesIO(image_bytes)) as original: - img = original.convert("RGB") - iw, ih = img.size - for idx, box in enumerate(boxes): - try: - x = float(box.get("x", 0)) - y = float(box.get("y", 0)) - w = float(box.get("width", 0)) - h = float(box.get("height", 0)) - except (TypeError, ValueError): - continue - if w <= 0 or h <= 0: - continue - if normalized: - x, y, w, h = x * iw, y * ih, w * iw, h * ih - left = max(0, min(int(x), iw - 1)) - top = max(0, min(int(y), ih - 1)) - right = max(left + 1, min(int(x + w), iw)) - bottom = max(top + 1, min(int(y + h), ih)) - crop = img.crop((left, top, right, bottom)) - regions.append( - { - "box_index": int(box.get("box_index", idx)), - "data_url": _pil_to_data_url(_resize_image_for_llm(crop, 768)), - "box": box, - } - ) - except Exception: - return [] - return regions - - -async def _vision_map_box_with_crop( - llm: ChatOpenAI, - *, - box_index: int, - box: dict, - crop_data_url: str, - candidates: list[dict], - user_request: str, - intent_summary: str, - scope_note: str = "", - retry_note: str = "", - reserved_label_ids: list[str] | None = None, -) -> dict[str, Any]: - """对单个裁剪区域调用视觉 LLM,返回 label_id 映射结果。""" - valid_ids = {str(c.get("id") or "") for c in candidates} - retry_block = "" - if retry_note.strip(): - retry_block = f"\n【校验反馈】{retry_note.strip()}\n" - reserved_block = "" - if reserved_label_ids: - reserved_block = ( - f"\n同图已被其它框占用的 label_id(请勿重复选择):" - f"{json.dumps(reserved_label_ids, ensure_ascii=False)}\n" - ) - user_text = ( - f"用户请求:{user_request}\n意图:{intent_summary}\n" - f"box_index={box_index} 检测类名:{box.get('class_name') or box.get('detection_label') or ''}\n" - f"{scope_note}{retry_block}{reserved_block}\n\nlabel_candidates:\n" - f"{json.dumps(candidates[:40], ensure_ascii=False)}" - ) - human_content: Any = [ - {"type": "text", "text": user_text}, - {"type": "image_url", "image_url": {"url": crop_data_url}}, - ] - messages = [ - SystemMessage(content=CROP_VISION_SYSTEM), - HumanMessage(content=human_content), - ] - try: - resp = await llm.ainvoke(messages) - except Exception: - return {"box_index": box_index, "label_id": "", "reason": "视觉调用失败"} - content = resp.content if hasattr(resp, "content") else str(resp) - if isinstance(content, list): - content = "".join( - part.get("text", "") if isinstance(part, dict) else str(part) - for part in content - ) - parsed = extract_json_object(str(content)) - lid = str(parsed.get("label_id") or "").strip() - if lid and lid not in valid_ids: - lid = "" - return { - "box_index": box_index, - "label_id": lid, - "reason": str(parsed.get("reason") or ""), - } - - -async def _vision_map_regions_concurrent( - llm: ChatOpenAI, - regions: list[dict[str, Any]], - *, - candidates: list[dict], - user_request: str, - intent_summary: str, - scope_note: str, - concurrency: int, - region_overrides: dict[int, dict[str, Any]] | None = None, -) -> list[dict[str, Any]]: - """并发执行多框视觉映射,Semaphore 控制并发度。""" - sem = asyncio.Semaphore(max(1, concurrency)) - overrides = region_overrides or {} - - async def _map_one(region: dict[str, Any]) -> dict[str, Any]: - async with sem: - box_index = int(region["box_index"]) - extra = overrides.get(box_index, {}) - return await _vision_map_box_with_crop( - llm, - box_index=box_index, - box=region["box"], - crop_data_url=str(region["data_url"]), - candidates=extra.get("candidates") or candidates, - user_request=user_request, - intent_summary=intent_summary, - scope_note=scope_note, - retry_note=str(extra.get("retry_note") or ""), - reserved_label_ids=extra.get("reserved_label_ids"), - ) - - return list(await asyncio.gather(*[_map_one(r) for r in regions])) - - -def _candidate_names(candidates: list[dict]) -> list[str]: - return [ - str(c.get("name") or "") - for c in candidates - if str(c.get("name") or "").strip() - ] - - -def _label_pool_debug( - *, - all_candidates: list[dict], - scoped_candidates: list[dict], - effective_candidates: list[dict], - source: str, - excluded_names: list[str] | None = None, - preflight_label_ids: list[str] | None = None, -) -> dict[str, Any]: - """结构化标签候选池调试信息,供 Electron DevTools 展示。""" - return { - "source": source, - "project_count": len(all_candidates), - "scoped_count": len(scoped_candidates), - "effective_count": len(effective_candidates), - "project_names": _candidate_names(all_candidates), - "scoped_names": _candidate_names(scoped_candidates), - "effective_names": _candidate_names(effective_candidates), - "excluded_names": list(excluded_names or []), - "preflight_label_ids": list(preflight_label_ids or []), - } - - -def _mapping_by_index(mappings: list[dict]) -> dict[int, dict]: - return {int(m.get("box_index", 0)): m for m in mappings} - - -def _used_label_ids(mappings: list[dict], *, exclude_box: int | None = None) -> set[str]: - used: set[str] = set() - for m in mappings: - idx = int(m.get("box_index", 0)) - if exclude_box is not None and idx == exclude_box: - continue - lid = str(m.get("label_id") or "").strip() - if lid: - used.add(lid) - return used - - -async def _vision_map_with_validation_retry( - llm: ChatOpenAI, - regions: list[dict[str, Any]], - *, - candidates: list[dict], - all_candidates: list[dict], - user_request: str, - intent_summary: str, - scope_note: str, - concurrency: int, - instance_labels: bool, - max_retries: int, - validate: bool, -) -> tuple[list[dict[str, Any]], int]: - """逐框映射 + 校验失败则带上下文重试(仅重跑失败 box)。""" - valid_ids = {str(c.get("id") or "") for c in all_candidates} - mappings = await _vision_map_regions_concurrent( - llm, - regions, - candidates=candidates, - user_request=user_request, - intent_summary=intent_summary, - scope_note=scope_note, - concurrency=concurrency, - ) - retry_rounds = 0 - - if not validate or max_retries <= 0: - return mappings, retry_rounds - - region_by_index = {int(r["box_index"]): r for r in regions} - - for attempt in range(max_retries): - result = validate_vision_mappings( - mappings, - all_candidates, - instance_labels=instance_labels, - valid_ids=valid_ids, - ) - if result.ok: - break - - retry_indices = sorted({i.box_index for i in result.issues}) - if not retry_indices: - break - - retry_rounds += 1 - overrides: dict[int, dict[str, Any]] = {} - by_index = _mapping_by_index(mappings) - - for box_index in retry_indices: - used = _used_label_ids(mappings, exclude_box=box_index) - current_lid = str(by_index.get(box_index, {}).get("label_id") or "") - box_candidates = candidates_for_retry_box( - candidates, - used, - keep_label_id=current_lid, - ) - box_issues = result.issues_for_box(box_index) - overrides[box_index] = { - "candidates": box_candidates, - "retry_note": format_issues_for_retry(box_issues), - "reserved_label_ids": sorted(used), - } - - log_annotation_agent( - "map-retry", - f"视觉映射校验失败,第 {retry_rounds} 轮重试", - box_indices=retry_indices, - issue_codes=[i.code for i in result.issues[:12]], - ) - - retry_regions = [region_by_index[i] for i in retry_indices if i in region_by_index] - retried = await _vision_map_regions_concurrent( - llm, - retry_regions, - candidates=candidates, - user_request=user_request, - intent_summary=intent_summary, - scope_note=scope_note, - concurrency=1, - region_overrides=overrides, - ) - for item in retried: - by_index[int(item["box_index"])] = item - mappings = [by_index[int(r["box_index"])] for r in regions] - - return mappings, retry_rounds - - -async def map_detection_boxes_to_labels_unified( - llm: ChatOpenAI | None, - *, - user_request: str, - intent_summary: str, - label_candidates: list[dict], - boxes: list[dict], - use_vision: bool = False, - ocr_text: str = "", - scope: AnnotationScopePayload | AnnotationScope | None = None, - label_strategy: str = "map_each_box_to_label", - single_label_id: str | None = None, - image_absolute_path: str = "", - image_base64: str = "", - mime_type: str = "image/jpeg", - vision_map_concurrency: int | None = None, -) -> dict[str, Any]: - """统一映射入口:vision_crop(逐框裁剪)或 heuristic(类名/OCR 匹配)。""" - scope_model = ( - scope - if isinstance(scope, AnnotationScope) - else AnnotationScope.from_payload(scope) - ) - settings = get_settings() - scoped_candidates = filter_label_candidates_by_scope(list(label_candidates), scope_model) - valid_ids = {str(c.get("id") or "") for c in scoped_candidates} - image_bytes, image_source = load_image_bytes( - image_absolute_path=image_absolute_path, - image_base64=image_base64, - ) - concurrency = vision_map_concurrency - if concurrency is None: - concurrency = settings.annotation_vision_map_concurrency - - log_annotation_agent( - "map-start", - "map_detection_boxes_to_labels_unified", - use_vision=use_vision, - box_count=len(boxes), - label_candidate_count=len(label_candidates), - label_strategy=label_strategy, - has_image_bytes=image_bytes is not None, - image_source=image_source, - vision_map_concurrency=concurrency if use_vision else None, - scope_restricted=scope_model.is_restricted(), - scope_summary=scope_model.scope_summary, - ) - - if not boxes: - return {"ok": False, "error": "boxes 不能为空", "method": "none"} - - if label_strategy == "single_label_for_all_boxes" and single_label_id: - if single_label_id in valid_ids: - mappings = [ - { - "box_index": int(b.get("box_index", i)), - "label_id": single_label_id, - "reason": "single_label_for_all_boxes", - } - for i, b in enumerate(boxes) - ] - return { - "ok": True, - "method": "single_label", - "mappings": mappings, - "unmapped_indices": [], - } - - normalized_boxes = [] - for i, b in enumerate(boxes): - normalized_boxes.append( - { - "box_index": int(b.get("box_index", i)), - "x": float(b.get("x", 0)), - "y": float(b.get("y", 0)), - "width": float(b.get("width", 0)), - "height": float(b.get("height", 0)), - "class_name": b.get("class_name") or b.get("detection_label"), - "detection_label": b.get("detection_label") or b.get("class_name"), - "confidence": b.get("confidence"), - } - ) - - # ── 路径 1:逐框裁剪视觉映射 ────────────────────────────────────────── - if use_vision and llm is not None: - if image_bytes is None: - return { - "ok": False, - "error": "image_unavailable", - "method": "vision_crop", - "hint": "视觉映射需要可读本地 image_absolute_path 或 image_base64", - } - - scope_note = "" - if scope_model.is_restricted(): - scope_note = f"\n用户标注范围:{scope_model.scope_summary or scope_model.to_payload().model_dump()}" - - pool = await resolve_effective_label_candidates( - llm, - all_candidates=scoped_candidates, - scope=scope_model, - user_request=user_request, - intent_summary=intent_summary, - box_count=len(normalized_boxes), - image_bytes=image_bytes, - settings=settings, - ) - candidates = pool.candidates - valid_ids = {str(c.get("id") or "") for c in candidates} - - log_annotation_agent( - "map-pool", - "标签候选池", - source=pool.source, - candidate_count=len(candidates), - scoped_count=len(scoped_candidates), - excluded_names=pool.excluded_names[:20] or None, - preflight_ids=pool.preflight_label_ids, - ) - - normalized = _coords_are_normalized(normalized_boxes) - # 裁剪 + 缩放到 768 + JPEG 编码 + base64 是同步 CPU 重活, - # 一批图可能有上千个框;走线程池避免阻塞事件循环。 - regions = await asyncio.to_thread( - _crop_boxes_from_image_bytes, - image_bytes, - normalized_boxes, - normalized=normalized, - ) - instance_labels = labels_require_vision_mapping(label_candidates) - mappings, retry_rounds = await _vision_map_with_validation_retry( - llm, - regions, - candidates=candidates, - all_candidates=scoped_candidates, - user_request=user_request, - intent_summary=intent_summary, - scope_note=scope_note, - concurrency=concurrency, - instance_labels=instance_labels, - max_retries=settings.annotation_vision_map_max_retries, - validate=settings.annotation_vision_map_validate, - ) - - unmapped = [m["box_index"] for m in mappings if not m.get("label_id")] - mapped_n = len(mappings) - len(unmapped) - log_annotation_agent( - "map-done", - "vision_crop", - mapped=mapped_n, - unmapped=len(unmapped), - crop_regions=len(regions), - image_source=image_source, - vision_map_concurrency=concurrency, - retry_rounds=retry_rounds, - label_pool_source=pool.source, - ) - return { - "ok": True, - "method": "vision_crop", - "mappings": mappings, - "unmapped_indices": unmapped, - "label_candidates": candidates, - "label_pool_source": pool.source, - "label_pool_debug": _label_pool_debug( - all_candidates=list(label_candidates), - scoped_candidates=scoped_candidates, - effective_candidates=candidates, - source=pool.source, - excluded_names=pool.excluded_names, - preflight_label_ids=pool.preflight_label_ids, - ), - "vision_map_retry_rounds": retry_rounds, - "next_step": "finalize_image_change", - } - - # ── 路径 2:启发式映射(无 LLM)────────────────────────────────────── - candidates = scoped_candidates - heuristic_raw = heuristic_map_boxes( - [ - { - "box_index": b["box_index"], - "class_name": b.get("class_name") or b.get("detection_label"), - "confidence": b.get("confidence"), - } - for b in normalized_boxes - ], - candidates, - ocr_text=ocr_text, - ) - mappings = [ - { - "box_index": int(m.get("box_index", 0)), - "label_id": str(m.get("label_id") or ""), - "reason": m.get("reason") or "", - } - for m in heuristic_raw - ] - unmapped = [m["box_index"] for m in mappings if not m.get("label_id")] - mapped_n = len(mappings) - len(unmapped) - hint = "" - if unmapped: - hint = ( - "当前未启用视觉模型或未提供图像,多人物/实例标签无法可靠区分;" - "请使用通过视觉探针的多模态模型,并确保 use_vision_mapping=true" - ) - log_annotation_agent( - "map-done", - "heuristic", - mapped=mapped_n, - unmapped=len(unmapped), - hint=hint or None, - sample_detection_classes=[ - str(b.get("class_name") or b.get("detection_label") or "") for b in normalized_boxes[:6] - ], - sample_label_names=[str(c.get("name") or "") for c in candidates[:12]], - ) - pool_source = ( - "scope" - if len(scoped_candidates) < len(label_candidates) - else "full" - ) - return { - "ok": len(unmapped) < len(mappings), - "method": "heuristic", - "mappings": mappings, - "unmapped_indices": unmapped, - "label_candidates": candidates, - "label_pool_source": pool_source, - "label_pool_debug": _label_pool_debug( - all_candidates=list(label_candidates), - scoped_candidates=scoped_candidates, - effective_candidates=candidates, - source=pool_source, - ), - "hint": hint, - "next_step": "finalize_image_change", - } diff --git a/vendor/local-agent/app/agent/annotation/map_validation_service.py b/vendor/local-agent/app/agent/annotation/map_validation_service.py deleted file mode 100644 index 0e4c143..0000000 --- a/vendor/local-agent/app/agent/annotation/map_validation_service.py +++ /dev/null @@ -1,184 +0,0 @@ -"""视觉映射结果校验与重试辅助(规则驱动,标签名来自候选集)。""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from enum import Enum - - -class MappingIssueCode(str, Enum): - DUPLICATE_LABEL = "duplicate_label" - REASON_NAME_MISMATCH = "reason_name_mismatch" - REASON_CONTRADICTS_LABEL = "reason_contradicts_label" - INVALID_LABEL_ID = "invalid_label_id" - - -@dataclass -class MappingIssue: - box_index: int - code: MappingIssueCode - message: str - - -@dataclass -class ValidationResult: - issues: list[MappingIssue] = field(default_factory=list) - - @property - def ok(self) -> bool: - return not self.issues - - def issues_for_box(self, box_index: int) -> list[MappingIssue]: - return [i for i in self.issues if i.box_index == box_index] - - -_REASON_CONTRADICT_RE = re.compile( - r"无法确认|无法确定|无法选择|不能确定|不符|不匹配|无关|" - r"cannot determine|not sure|no match|does not match|unable to", - re.IGNORECASE, -) - - -def _name_to_id(candidates: list[dict]) -> dict[str, str]: - out: dict[str, str] = {} - for c in candidates: - name = str(c.get("name") or "").strip().lower() - cid = str(c.get("id") or "").strip() - if name and cid: - out[name] = cid - return out - - -def find_label_names_in_text(text: str, candidates: list[dict]) -> list[str]: - """在 reason 等文本中查找出现的候选标签名(大小写不敏感子串)。""" - raw = (text or "").lower() - if not raw: - return [] - hits: list[str] = [] - seen: set[str] = set() - for c in candidates: - name = str(c.get("name") or "").strip() - if not name: - continue - key = name.lower() - if key in seen: - continue - if key in raw: - seen.add(key) - hits.append(name) - return hits - - -def format_issues_for_retry(issues: list[MappingIssue]) -> str: - if not issues: - return "" - return ";".join(f"[{i.code}] {i.message}" for i in issues) - - -def candidates_for_retry_box( - candidates: list[dict], - used_label_ids: set[str], - *, - keep_label_id: str = "", -) -> list[dict]: - """重试时排除同图已被其它框占用的 label_id(L3 占用约束)。""" - if not used_label_ids: - return candidates - keep = keep_label_id.strip() - filtered = [ - c - for c in candidates - if str(c.get("id") or "") not in used_label_ids - or str(c.get("id") or "") == keep - ] - return filtered if filtered else candidates - - -def validate_vision_mappings( - mappings: list[dict], - candidates: list[dict], - *, - instance_labels: bool, - valid_ids: set[str] | None = None, -) -> ValidationResult: - """校验逐框视觉映射结果。""" - ids = valid_ids or {str(c.get("id") or "") for c in candidates} - name_to_id = _name_to_id(candidates) - issues: list[MappingIssue] = [] - - by_box: dict[int, dict] = {} - for m in mappings: - by_box[int(m.get("box_index", 0))] = m - - label_to_boxes: dict[str, list[int]] = {} - for box_index, m in by_box.items(): - lid = str(m.get("label_id") or "").strip() - if not lid: - continue - if lid not in ids: - issues.append( - MappingIssue( - box_index=box_index, - code=MappingIssueCode.INVALID_LABEL_ID, - message=f"label_id={lid!r} 不在候选中", - ) - ) - continue - label_to_boxes.setdefault(lid, []).append(box_index) - - if instance_labels: - for lid, box_indices in label_to_boxes.items(): - if len(box_indices) <= 1: - continue - name = next( - (str(c.get("name") or lid) for c in candidates if str(c.get("id") or "") == lid), - lid, - ) - for box_index in box_indices: - issues.append( - MappingIssue( - box_index=box_index, - code=MappingIssueCode.DUPLICATE_LABEL, - message=f"标签 {name!r} 被分配给多个框 {box_indices}", - ) - ) - - for box_index, m in by_box.items(): - lid = str(m.get("label_id") or "").strip() - reason = str(m.get("reason") or "") - - if lid and _REASON_CONTRADICT_RE.search(reason): - issues.append( - MappingIssue( - box_index=box_index, - code=MappingIssueCode.REASON_CONTRADICTS_LABEL, - message="reason 表示无法/不匹配,但返回了非空 label_id", - ) - ) - - if not lid or not reason: - continue - - mentioned = find_label_names_in_text(reason, candidates) - if len(mentioned) != 1: - continue - mentioned_name = mentioned[0].lower() - expected_id = name_to_id.get(mentioned_name, "") - if expected_id and expected_id != lid: - chosen_name = next( - (str(c.get("name") or "") for c in candidates if str(c.get("id") or "") == lid), - lid, - ) - issues.append( - MappingIssue( - box_index=box_index, - code=MappingIssueCode.REASON_NAME_MISMATCH, - message=( - f"reason 指向 {mentioned[0]!r}," - f"但 label_id 对应 {chosen_name!r}" - ), - ) - ) - - return ValidationResult(issues=issues) diff --git a/vendor/local-agent/app/agent/annotation/mutation_prepare_service.py b/vendor/local-agent/app/agent/annotation/mutation_prepare_service.py deleted file mode 100644 index 226f1a6..0000000 --- a/vendor/local-agent/app/agent/annotation/mutation_prepare_service.py +++ /dev/null @@ -1,263 +0,0 @@ -"""标注变更准备:单次 LLM 解析 mutate 意图与目标描述。""" - -from __future__ import annotations - -import json -from typing import Any, Literal - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI -from pydantic import BaseModel, Field - -from app.agent.annotation.debug_log import log_annotation_agent -from app.agent.annotation.llm_invoke import invoke_json_model - -MUTATION_PREPARE_SYSTEM = """你是 LR-Agent 标注变更准备助手。用户希望修改或删除已有标注(非新增)。 - -支持全部标注类型:bbox、rotated_bbox、polygon、keypoint(骨架)、caption、cot、 -text_classification、classification、span_ner、instruction、preference、conversation。 -用户说「不要新增框/不要新增标注」时,只能改或删已有项,不要改用 auto_annotate。 - -输出 JSON(一次完成): -{ - "selected_paths": ["data/7.jpg"], - "intent_summary": "一句话摘要", - "operations": [ - { - "relative_path": "data/7.jpg", - "mutation_kind": "patch_label|patch_geometry|patch_content|delete", - "targets": [ - {"by": "all"}, - {"by": "id", "id": "uuid"}, - {"by": "label_name", "label_name": "person"}, - {"by": "index", "index": 1}, - {"by": "spatial", "hint": "leftmost"}, - {"by": "selected"}, - {"by": "unlabeled"}, - {"by": "granularity", "granularity": "brief"}, - {"by": "language", "language": "en"}, - {"by": "longest"}, - {"by": "shortest"}, - {"by": "duplicate_label"} - ], - "new_label_name": "worker", - "x": 0.1, "y": 0.2, "width": 0.15, "height": 0.18, - "cx": 0.5, "cy": 0.4, "angle": 30, - "points": [{"x": 0.1, "y": 0.2}], - "keypoints": [{"x": 0.1, "y": 0.2, "visibility": 2}], - "text": "新的 caption", - "granularity": "brief", - "language": "zh", - "steps": [{"description": "...", "conclusion": "..."}], - "answer": "...", - "instruction": "...", - "input": "...", "output": "...", - "start": 0, "end": 12, - "prompt": "...", "chosen": "...", "rejected": "...", - "turns": [{"role": "user", "content": "..."}], - "note": "..." - } - ] -} - -规则: -- patch_label:改已有标签(含多边形/分类空 labelId 补标)。必须给出 new_label_name(项目标签名之一) -- patch_geometry:改几何。 - bbox 填 x/y/width/height(0–1,左上角);rotated_bbox 与 keypoint 骨架填 cx/cy/width/height(0–1,中心点),可带 angle(度); - keypoint 改关键点填 keypoints 整表替换(长度与骨架模板一致,visibility 0=不可见 1=遮挡 2=可见); - polygon 仅在用户明确给出新轮廓时填 points(至少 3 点),飘出的多边形优先 delete -- patch_content:改正文。 - caption 填 text/granularity/language;cot 填 steps(至少 2 步)+ answer 整表替换,可带 instruction; - instruction 填 instruction/input/output;span_ner 改文本偏移填 start/end(字符索引,start < end); - preference 填 prompt/chosen/rejected;conversation 填 turns 整表替换(role 为 user/assistant) -- delete:删除目标;清空某文件全部标注时 targets=[{"by":"all"}];去重分类用 {"by":"duplicate_label"} -- selected_paths 必须为候选列表中的 relative_path -- 优先使用摘要里的 id;无 id 时用 label_name/index/spatial/unlabeled/granularity -- 用户说「这个框」「当前选中」时加入 {"by":"selected"} -- 勿编造不在候选中的路径 -""" - - -class MutationPointSchema(BaseModel): - x: float - y: float - - -class MutationCotStepSchema(BaseModel): - description: str - conclusion: str - - -class MutationKeypointSchema(BaseModel): - x: float - y: float - # 0=不可见 1=遮挡 2=可见 - visibility: int = 2 - - -class MutationTurnSchema(BaseModel): - role: Literal["user", "assistant"] - content: str = Field(min_length=1) - - -class MutationTargetSchema(BaseModel): - by: Literal[ - "id", - "label_name", - "index", - "spatial", - "selected", - "all", - "unlabeled", - "granularity", - "language", - "longest", - "shortest", - "duplicate_label", - ] = "id" - id: str | None = None - label_name: str | None = None - index: int | None = None - hint: str | None = None - granularity: str | None = None - language: str | None = None - - -class MutationOperationSchema(BaseModel): - relative_path: str = Field(min_length=1) - mutation_kind: Literal[ - "patch_label", "patch_geometry", "patch_content", "delete" - ] = "patch_label" - targets: list[MutationTargetSchema] = Field(default_factory=list) - new_label_name: str | None = None - x: float | None = None - y: float | None = None - width: float | None = None - height: float | None = None - # rotated_bbox / keypoint 骨架:中心点 + 旋转角(度) - cx: float | None = None - cy: float | None = None - angle: float | None = None - points: list[MutationPointSchema] | None = None - # keypoint 骨架关键点(整表替换) - keypoints: list[MutationKeypointSchema] | None = None - text: str | None = None - granularity: str | None = None - language: str | None = None - steps: list[MutationCotStepSchema] | None = None - answer: str | None = None - instruction: str | None = None - input: str | None = None - output: str | None = None - # span_ner 文本偏移(字符索引) - start: int | None = Field(default=None, ge=0) - end: int | None = Field(default=None, ge=0) - # preference - prompt: str | None = None - chosen: str | None = None - rejected: str | None = None - # conversation(整表替换) - turns: list[MutationTurnSchema] | None = None - note: str | None = None - - -class MutationPrepareLlmResult(BaseModel): - selected_paths: list[str] = Field(default_factory=list) - intent_summary: str = "" - operations: list[MutationOperationSchema] = Field(default_factory=list) - - -class MutationPrepareResult(BaseModel): - selected_paths: list[str] = Field(default_factory=list) - intent_summary: str = "" - operations: list[dict[str, Any]] = Field(default_factory=list) - resolved_user_request: str = "" - - -def _filter_paths(raw_paths: object, candidates: list[dict]) -> list[str]: - by_path = { - str(c.get("relative_path") or "").strip().replace("\\", "/"): c - for c in candidates - if str(c.get("relative_path") or "").strip() - } - selected: list[str] = [] - if isinstance(raw_paths, list): - for raw in raw_paths: - key = str(raw).strip().replace("\\", "/") - if key in by_path: - selected.append(key) - return selected - - -async def prepare_mutation_annotation( - llm: ChatOpenAI, - *, - user_request: str, - current_relative_path: str, - candidates: list[dict], - label_names: list[str], - selected_annotation_ids: list[str] | None = None, - conversation_transcript: str = "", -) -> MutationPrepareResult: - if not candidates: - return MutationPrepareResult( - intent_summary="无文件候选", - resolved_user_request=user_request, - ) - - catalog = json.dumps( - [ - { - "relative_path": c.get("relative_path"), - "name": c.get("name"), - "parent": c.get("parent"), - } - for c in candidates[:80] - ], - ensure_ascii=False, - ) - labels = ", ".join(label_names[:50]) or "(无)" - selected_ids = ", ".join(selected_annotation_ids or []) or "(无)" - transcript = conversation_transcript.strip() or "(无历史)" - - human = ( - f"【对话上下文】\n{transcript}\n\n" - f"【用户请求】\n{user_request.strip()}\n\n" - f"current_relative_path: {current_relative_path or '(无)'}\n" - f"selected_annotation_ids: {selected_ids}\n" - f"项目标签: {labels}\n" - f"候选文件: {catalog}\n" - ) - - parsed = await invoke_json_model( - llm, - messages=[ - SystemMessage(content=MUTATION_PREPARE_SYSTEM), - HumanMessage(content=human), - ], - model_cls=MutationPrepareLlmResult, - ) - - selected = _filter_paths(parsed.selected_paths, candidates) - if not selected and parsed.operations: - op_paths = { - str(op.relative_path).strip().replace("\\", "/") - for op in parsed.operations - } - selected = [p for p in op_paths if p in {c.get("relative_path") for c in candidates}] - - operations = [op.model_dump() for op in parsed.operations] - - log_annotation_agent( - "mutation-prepare", - "mutation-prepare 完成", - selected=len(selected), - operations=len(operations), - ) - - return MutationPrepareResult( - selected_paths=selected, - intent_summary=parsed.intent_summary or user_request[:200], - operations=operations, - resolved_user_request=user_request, - ) diff --git a/vendor/local-agent/app/agent/annotation/plan_parsing.py b/vendor/local-agent/app/agent/annotation/plan_parsing.py deleted file mode 100644 index 1ef7522..0000000 --- a/vendor/local-agent/app/agent/annotation/plan_parsing.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Shared parsing of LLM JSON into BatchPlanResult.""" -from __future__ import annotations - -from typing import Any - -from app.agent.annotation.debug_log import log_annotation_agent -from app.agent.annotation.label_vision_policy import labels_require_vision_mapping -from app.agent.annotation.schemas import ( - AnnotationScopePayload, - BatchPlanResult, - DetectionHintsPayload, - SubAgentConstraintsPayload, -) - - -def build_batch_plan_from_data( - data: dict[str, Any], - *, - user_request: str, - intent_summary: str, - annotation_scope: AnnotationScopePayload, - label_candidates: list[dict], - detection_models: list[dict], - default_conf: float, - default_iou: float, - provider_is_vision: bool, - log_prefix: str = "batch-prepare", -) -> BatchPlanResult: - label_strategy = str(data.get("label_strategy") or "map_each_box_to_label") - if label_strategy not in ("single_label_for_all_boxes", "map_each_box_to_label"): - label_strategy = "map_each_box_to_label" - - det_raw = data.get("detection_hints") if isinstance(data.get("detection_hints"), dict) else {} - valid_ids = {str(m.get("id") or "") for m in detection_models} - model_id = str(det_raw.get("model_id") or "").strip() - if model_id and model_id not in valid_ids: - model_id = "" - - def _clamp(val: Any, lo: float, hi: float, default: float) -> float: - try: - f = float(val) - return max(lo, min(hi, f)) - except (TypeError, ValueError): - return default - - conf = det_raw.get("conf_threshold") - iou = det_raw.get("iou_threshold") - detection_hints = DetectionHintsPayload( - needs_object_detection=bool(det_raw.get("needs_object_detection", True)), - model_id=model_id or None, - conf_threshold=_clamp(conf, 0.05, 0.95, default_conf) if conf is not None else default_conf, - iou_threshold=_clamp(iou, 0.05, 0.95, default_iou) if iou is not None else default_iou, - notes=str(det_raw.get("notes") or "")[:200], - ) - - sub_raw = data.get("sub_agent_constraints") if isinstance(data.get("sub_agent_constraints"), dict) else {} - instance_labels = labels_require_vision_mapping(label_candidates) - default_allow_unlabeled = instance_labels - sub_constraints = SubAgentConstraintsPayload( - require_per_box_mapping=bool( - sub_raw.get("require_per_box_mapping", label_strategy == "map_each_box_to_label") - ), - allow_unlabeled_boxes=bool( - sub_raw.get("allow_unlabeled_boxes", default_allow_unlabeled) - ), - min_labeled_box_count=max(1, int(sub_raw.get("min_labeled_box_count", 1))), - ) - - scope_raw = data.get("annotation_scope") if isinstance(data.get("annotation_scope"), dict) else {} - merged_scope = AnnotationScopePayload( - scope_summary=str(scope_raw.get("scope_summary") or annotation_scope.scope_summary), - include_detection_labels=list( - scope_raw.get("include_detection_labels") or annotation_scope.include_detection_labels - ), - exclude_detection_labels=list( - scope_raw.get("exclude_detection_labels") or annotation_scope.exclude_detection_labels - ), - include_label_names=list( - scope_raw.get("include_label_names") or annotation_scope.include_label_names - ), - exclude_label_names=list( - scope_raw.get("exclude_label_names") or annotation_scope.exclude_label_names - ), - ) - - plan_steps = data.get("plan_steps") if isinstance(data.get("plan_steps"), list) else [] - steps = [str(s).strip() for s in plan_steps if str(s).strip()][:12] - - plan_use_vision_raw = bool(data.get("use_vision_mapping", False)) - task_needs_vision = ( - label_strategy == "map_each_box_to_label" - and labels_require_vision_mapping(label_candidates) - ) - want_vision = plan_use_vision_raw or task_needs_vision - use_vision = want_vision and bool(provider_is_vision) - if want_vision and not provider_is_vision: - log_annotation_agent( - f"{log_prefix}-gate", - "需要视觉映射但提供商视觉探针未通过", - provider_is_vision=provider_is_vision, - task_needs_vision=task_needs_vision, - ) - elif task_needs_vision and provider_is_vision and not plan_use_vision_raw: - log_annotation_agent( - f"{log_prefix}-auto", - "标签与 YOLO 类名不匹配,已自动开启 use_vision_mapping", - label_names=[str(c.get("name") or "") for c in label_candidates[:20]], - ) - - return BatchPlanResult( - intent_summary=str(data.get("intent_summary") or intent_summary or user_request[:200] or "批量图片标注"), - label_strategy=label_strategy, # type: ignore[arg-type] - use_vision_mapping=use_vision, - detection_hints=detection_hints, - sub_agent_constraints=sub_constraints, - annotation_scope=merged_scope, - plan_steps=steps, - ) diff --git a/vendor/local-agent/app/agent/annotation/quality_report_compose_service.py b/vendor/local-agent/app/agent/annotation/quality_report_compose_service.py deleted file mode 100644 index 74215cc..0000000 --- a/vendor/local-agent/app/agent/annotation/quality_report_compose_service.py +++ /dev/null @@ -1,52 +0,0 @@ -"""标注质量报告 LLM 撰写。""" - -from __future__ import annotations - -import json -from collections.abc import AsyncIterator - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI - -from app.schemas.agent import StreamEventPayload - -QUALITY_REPORT_COMPOSE_SYSTEM = """你是 LR-Agent 标注质量报告撰写助手。根据提供的标注质量指标、一致性检查发现和图表路径,用中文 Markdown 撰写结构化质量报告。 - -要求: -- 固定章节:概览、覆盖率、标签分布、一致性问题、风险文件清单、改进建议 -- 所有数字必须来自提供的 metrics / findings JSON,禁止编造 -- 在合适章节嵌入图表,使用给定 chart_paths 中的相对路径,格式:![标题](charts/xxx.png) -- 对 critical / warning 级别发现给出明确复核建议 -- 语言简洁专业,适合交付给标注团队或甲方 -- 不要输出代码块包裹整篇报告 -""" - - -async def stream_quality_report_compose( - llm: ChatOpenAI, - *, - compose_payload: dict, -) -> AsyncIterator[StreamEventPayload]: - payload_json = json.dumps(compose_payload, ensure_ascii=False, indent=2) - if len(payload_json) > 48_000: - payload_json = payload_json[:48_000] + "\n…(已截断)" - - human = ( - "【质量指标与发现 JSON】\n" - f"{payload_json}\n\n" - "请基于以上数据撰写完整 Markdown 质量报告。" - ) - messages = [ - SystemMessage(content=QUALITY_REPORT_COMPOSE_SYSTEM), - HumanMessage(content=human), - ] - async for chunk in llm.astream(messages): - content = chunk.content - if isinstance(content, str) and content: - yield StreamEventPayload(type="text_delta", content=content) - elif isinstance(content, list): - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - text = str(part.get("text") or "") - if text: - yield StreamEventPayload(type="text_delta", content=text) diff --git a/vendor/local-agent/app/agent/annotation/schemas.py b/vendor/local-agent/app/agent/annotation/schemas.py deleted file mode 100644 index cd5d274..0000000 --- a/vendor/local-agent/app/agent/annotation/schemas.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Internal structured outputs for annotation agent LLM calls.""" -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, Field - - -class AnnotationScopePayload(BaseModel): - scope_summary: str = "" - include_detection_labels: list[str] = Field(default_factory=list) - exclude_detection_labels: list[str] = Field(default_factory=list) - include_label_names: list[str] = Field(default_factory=list) - exclude_label_names: list[str] = Field(default_factory=list) - - -class DetectionHintsPayload(BaseModel): - needs_object_detection: bool = True - model_id: str | None = None - conf_threshold: float | None = None - iou_threshold: float | None = None - notes: str = "" - - -class SubAgentConstraintsPayload(BaseModel): - require_per_box_mapping: bool = True - allow_unlabeled_boxes: bool = True - min_labeled_box_count: int = 1 - - -class BatchPlanResult(BaseModel): - intent_summary: str = "批量图片标注" - label_strategy: Literal["map_each_box_to_label", "single_label_for_all_boxes"] = ( - "map_each_box_to_label" - ) - use_vision_mapping: bool = False - detection_hints: DetectionHintsPayload = Field(default_factory=DetectionHintsPayload) - sub_agent_constraints: SubAgentConstraintsPayload = Field( - default_factory=SubAgentConstraintsPayload - ) - annotation_scope: AnnotationScopePayload = Field(default_factory=AnnotationScopePayload) - plan_steps: list[str] = Field(default_factory=list) - - -class BatchPrepareResult(BaseModel): - """Scope + plan in one LLM call.""" - - selected_paths: list[str] = Field(default_factory=list) - scope_reason: str = "" - plan: BatchPlanResult = Field(default_factory=BatchPlanResult) - diff --git a/vendor/local-agent/app/agent/assist/__init__.py b/vendor/local-agent/app/agent/assist/__init__.py deleted file mode 100644 index d5d5427..0000000 --- a/vendor/local-agent/app/agent/assist/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Assist 模式流式推理子模块。 - -职责划分: - - tool_loop 多轮 LLM ↔ 工具循环 - - vision_bootstrap 视觉兜底加载(首轮无 tool call 时触发) - - proposal_streamer write_workspace_file chunk 流式拦截与 file_proposal SSE 生成 - - pending_emitter 异步工具 pending SSE 发送 - - task_phase 标注任务阶段状态机(提案门禁与阶段提示词) - - explore_readonly 只读查阅子代理(嵌套短循环 + subagent_* SSE) -""" diff --git a/vendor/local-agent/app/agent/assist/explore_readonly.py b/vendor/local-agent/app/agent/assist/explore_readonly.py deleted file mode 100644 index 0583dcc..0000000 --- a/vendor/local-agent/app/agent/assist/explore_readonly.py +++ /dev/null @@ -1,436 +0,0 @@ -"""只读查阅子代理:嵌套短循环,产出 subagent_* SSE,最终回中文摘要。""" - -from __future__ import annotations - -import asyncio -import inspect -import json -from collections.abc import AsyncIterator -from typing import Any - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import StructuredTool -from langchain_openai import ChatOpenAI - -from app.agent.stream_adapter import events_from_chunk -from app.agent.tool_dispatcher import resolve_round_tool_calls -from app.agent.tools.tool_result import ( - build_tool_result, - format_tool_result_for_display, - stringify_tool_output, -) -from app.core.config import Settings -from app.schemas.agent import ClientContextInput, StreamEventPayload - -EXPLORE_READONLY_TOOL_NAME = "explore_readonly" - -EXPLORE_READONLY_BASE_INNER: frozenset[str] = frozenset( - { - "grep_workspace", - "glob_workspace", - "list_workspace_directory", - "read_workspace_file", - "read_document_file", - "describe_client_context", - "get_lr_agent_help", - } -) - -EXPLORE_READONLY_ANNOTATION_INNER: frozenset[str] = frozenset( - { - "read_file_annotation", - "describe_annotation_project", - } -) - -EXPLORE_READONLY_FORBIDDEN_INNER: frozenset[str] = frozenset( - { - EXPLORE_READONLY_TOOL_NAME, - "auto_annotate", - "mutate_annotation", - "write_workspace_file", - "str_replace_workspace_file", - "delete_workspace_file", - "memory_write", - "memory_create", - "read_image_for_vision", - } -) - -_SUBAGENT_SYSTEM = """你是只读查阅子代理。只用只读工具查看代码、文档或已有标注,然后用简洁中文写一份摘要。 -禁止改文件、写标注、调用 explore_readonly,也禁止声称已经改过文件或完成标注。 -调用工具前先用一两句中文说明正在查什么。 -查到足够信息后直接给出结论,不要输出工具伪代码。""" - -_INNER_RESULT_LIMIT = 4000 - - -def resolve_explore_inner_names(*, include_annotation_reads: bool) -> frozenset[str]: - names = set(EXPLORE_READONLY_BASE_INNER) - if include_annotation_reads: - names |= EXPLORE_READONLY_ANNOTATION_INNER - return frozenset(names - EXPLORE_READONLY_FORBIDDEN_INNER) - - -def should_include_annotation_reads( - client_context: ClientContextInput | None, - parent_tools: list[StructuredTool] | None = None, -) -> bool: - if client_context is not None and client_context.work_mode == "editor": - return False - if parent_tools is None: - return True - parent_names = {tool.name for tool in parent_tools} - return bool(parent_names & EXPLORE_READONLY_ANNOTATION_INNER) - - -def filter_explore_inner_tools( - parent_tools: list[StructuredTool], - *, - include_annotation_reads: bool, -) -> list[StructuredTool]: - allowed = resolve_explore_inner_names( - include_annotation_reads=include_annotation_reads - ) - return [ - tool - for tool in parent_tools - if tool.name in allowed and tool.name not in EXPLORE_READONLY_FORBIDDEN_INNER - ] - - -def filter_explore_inner_fn_map( - parent_fn_map: dict[str, object], - *, - include_annotation_reads: bool, -) -> dict[str, object]: - allowed = resolve_explore_inner_names( - include_annotation_reads=include_annotation_reads - ) - return { - name: fn - for name, fn in parent_fn_map.items() - if name in allowed and name not in EXPLORE_READONLY_FORBIDDEN_INNER - } - - -def _truncate_result(text: str, limit: int = _INNER_RESULT_LIMIT) -> str: - if len(text) <= limit: - return text - return f"{text[:limit]}…\n[已截断,共 {len(text)} 字符]" - - -async def _invoke_inner(name: str, args: dict, fn_map: dict[str, object]) -> str: - fn = fn_map.get(name) - try: - if fn is None: - return build_tool_result( - ok=False, - tool=name, - status="error", - summary=f"未知工具: {name}", - ) - if asyncio.iscoroutinefunction(fn): - raw = await fn(**args) - else: - # 同主循环 _invoke_tool_fn:同步内层工具走线程池, - # 否则子代理里的 grep / 读文件同样会卡住事件循环。 - raw = await asyncio.to_thread(fn, **args) - if inspect.isawaitable(raw): - raw = await raw - return stringify_tool_output(raw) - except Exception as exc: - return build_tool_result( - ok=False, - tool=name, - status="error", - summary=f"工具执行失败: {exc}", - ) - - -def _tool_calls_as_dicts(raw: Any) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for call in raw or []: - if isinstance(call, dict): - out.append(call) - continue - out.append( - { - "id": str(getattr(call, "id", "") or ""), - "name": str(getattr(call, "name", "") or ""), - "args": getattr(call, "args", None) or {}, - } - ) - return out - - -def _ai_message_from_resolved(text: str, calls: list) -> AIMessage: - return AIMessage( - content=text, - tool_calls=[ - { - "id": call.tool_call_id, - "name": call.name, - "args": call.arguments, - "type": "tool_call", - } - for call in calls - ], - ) - - -class ExploreReadonlyRunner: - """复用父 LLM / 取消信号,跑一轮独立的只读查阅循环。""" - - def __init__( - self, - *, - llm: ChatOpenAI, - settings: Settings, - is_cancelled, - parent_tools: list[StructuredTool], - parent_fn_map: dict[str, object], - client_context: ClientContextInput | None = None, - ) -> None: - self.llm = llm - self.settings = settings - self.is_cancelled = is_cancelled - self.parent_tools = parent_tools - self.parent_fn_map = parent_fn_map - self.client_context = client_context - - async def stream( - self, - *, - query: str, - focus_path: str | None, - parent_tool_id: str, - ) -> AsyncIterator[StreamEventPayload]: - query = (query or "").strip() - focus = (focus_path or "").strip() or None - - yield StreamEventPayload( - type="subagent_start", - tool_call_id=parent_tool_id, - query=query, - focus_path=focus, - ) - - if not query: - yield StreamEventPayload( - type="subagent_done", - tool_call_id=parent_tool_id, - summary="查阅任务缺少 query。", - status="error", - ) - return - - include_annotation = should_include_annotation_reads( - self.client_context, - self.parent_tools, - ) - inner_tools = filter_explore_inner_tools( - self.parent_tools, - include_annotation_reads=include_annotation, - ) - inner_fn_map = filter_explore_inner_fn_map( - self.parent_fn_map, - include_annotation_reads=include_annotation, - ) - - if not inner_tools: - yield StreamEventPayload( - type="subagent_done", - tool_call_id=parent_tool_id, - summary="没有可用的只读查阅工具。", - status="error", - ) - return - - user_text = f"查阅任务:{query}" - if focus: - user_text = f"{user_text}\n优先关注路径:{focus}" - messages: list = [ - SystemMessage(content=_SUBAGENT_SYSTEM), - HumanMessage(content=user_text), - ] - - llm_bound = self.llm.bind_tools(inner_tools) - max_rounds = max( - 1, int(getattr(self.settings, "agent_subagent_max_tool_rounds", 8) or 8) - ) - summary = "" - status = "done" - stopped = False - last_had_tools = False - - for _round_idx in range(max_rounds): - if await self.is_cancelled(): - status = "error" - summary = summary or "已停止" - stopped = True - break - - gathered = None - pending_text: list[str] = [] - try: - async for chunk in llm_bound.astream(messages): - if await self.is_cancelled(): - status = "error" - summary = summary or "已停止" - stopped = True - gathered = None - break - for event in events_from_chunk(chunk, emit_tool_chunks=False): - if event.type == "text_delta" and event.content: - pending_text.append(event.content) - yield StreamEventPayload( - type="subagent_text_delta", - tool_call_id=parent_tool_id, - content=event.content, - ) - if gathered is None: - gathered = chunk - else: - gathered = gathered + chunk - except Exception as exc: - status = "error" - summary = f"查阅失败:{exc}" - break - - if stopped: - break - if gathered is None: - break - - resolved = resolve_round_tool_calls( - api_tool_calls=_tool_calls_as_dicts( - getattr(gathered, "tool_calls", None) - ) - ) - allowed: list = [] - blocked: list = [] - for call in resolved: - if ( - call.name not in inner_fn_map - or call.name in EXPLORE_READONLY_FORBIDDEN_INNER - ): - blocked.append(call) - else: - allowed.append(call) - - if not allowed and not blocked: - summary = "".join(pending_text).strip() - last_had_tools = False - break - - messages.append( - _ai_message_from_resolved("".join(pending_text), [*blocked, *allowed]) - ) - last_had_tools = True - - for call in blocked: - yield StreamEventPayload( - type="subagent_tool_start", - tool_call_id=parent_tool_id, - inner_tool_call_id=call.tool_call_id, - name=call.name, - arguments=json.dumps(call.arguments, ensure_ascii=False, indent=2), - ) - blocked_text = build_tool_result( - ok=False, - tool=call.name, - status="error", - summary="此工具不可用于只读查阅。", - ) - yield StreamEventPayload( - type="subagent_tool_result", - tool_call_id=parent_tool_id, - inner_tool_call_id=call.tool_call_id, - result=format_tool_result_for_display(blocked_text), - status="error", - ) - messages.append( - ToolMessage(content=blocked_text, tool_call_id=call.tool_call_id) - ) - - for call in allowed: - if await self.is_cancelled(): - status = "error" - summary = summary or "已停止" - stopped = True - break - yield StreamEventPayload( - type="subagent_tool_start", - tool_call_id=parent_tool_id, - inner_tool_call_id=call.tool_call_id, - name=call.name, - arguments=json.dumps(call.arguments, ensure_ascii=False, indent=2), - ) - result_text = await _invoke_inner( - call.name, call.arguments, inner_fn_map - ) - display = _truncate_result(format_tool_result_for_display(result_text)) - step_status = "done" - try: - parsed = json.loads(result_text) - if isinstance(parsed, dict) and parsed.get("ok") is False: - step_status = "error" - except json.JSONDecodeError: - pass - yield StreamEventPayload( - type="subagent_tool_result", - tool_call_id=parent_tool_id, - inner_tool_call_id=call.tool_call_id, - result=display, - status=step_status, - ) - messages.append( - ToolMessage(content=result_text, tool_call_id=call.tool_call_id) - ) - if stopped: - break - else: - last_had_tools = True - - if last_had_tools and status == "done" and not summary: - try: - if await self.is_cancelled(): - status = "error" - summary = "已停止" - else: - pending: list[str] = [] - messages.append( - HumanMessage(content="请用中文给出查阅摘要,不要再调用工具。") - ) - async for chunk in self.llm.astream(messages): - if await self.is_cancelled(): - status = "error" - summary = "已停止" - pending = [] - break - for event in events_from_chunk(chunk, emit_tool_chunks=False): - if event.type == "text_delta" and event.content: - pending.append(event.content) - yield StreamEventPayload( - type="subagent_text_delta", - tool_call_id=parent_tool_id, - content=event.content, - ) - if status == "done": - summary = "".join(pending).strip() - except Exception as exc: - summary = summary or f"已达查阅轮次上限:{exc}" - - if not summary: - if status == "error": - summary = "查阅失败" - else: - summary = "未找到可用结论。" - - yield StreamEventPayload( - type="subagent_done", - tool_call_id=parent_tool_id, - summary=summary, - status=status, - ) diff --git a/vendor/local-agent/app/agent/assist/pending_emitter.py b/vendor/local-agent/app/agent/assist/pending_emitter.py deleted file mode 100644 index 0a798d7..0000000 --- a/vendor/local-agent/app/agent/assist/pending_emitter.py +++ /dev/null @@ -1,32 +0,0 @@ -"""发出 tool_pending SSE 并结束当前 HTTP 轮次。""" - -import json -from collections.abc import AsyncIterator - -from app.agent.tool_invocation import ResolvedToolCall -from app.schemas.agent import ClientToolCallPayload, StreamEventPayload - - -async def emit_tool_pending( - calls: list[ResolvedToolCall], -) -> AsyncIterator[StreamEventPayload]: - """发出 tool_pending SSE 并结束当前 HTTP 轮次。""" - pending_calls = [ - ClientToolCallPayload( - tool_call_id=c.tool_call_id, - name=c.name, - arguments=c.arguments, - ) - for c in calls - ] - for c in calls: - yield StreamEventPayload( - type="tool_start", - tool_call_id=c.tool_call_id, - name=c.name, - arguments=json.dumps(c.arguments, ensure_ascii=False, indent=2), - ) - yield StreamEventPayload( - type="tool_pending", - client_tool_calls=pending_calls, - ) diff --git a/vendor/local-agent/app/agent/assist/proposal_streamer.py b/vendor/local-agent/app/agent/assist/proposal_streamer.py deleted file mode 100644 index 646a814..0000000 --- a/vendor/local-agent/app/agent/assist/proposal_streamer.py +++ /dev/null @@ -1,266 +0,0 @@ -"""写文件 / 局部替换工具的 tool_call_chunks 流式拦截器。 - -在 LLM astream 过程中,按 tc_index 递增解析 JSON 参数,实时发射提案 SSE 事件: - - write_workspace_file → file_proposal_start + file_proposal_delta(content 增量) - - str_replace_workspace_file → file_proposal_start(mode=edit) + file_edit_delta - (old_string / new_string 增量,前端据此渲染变更区域的局部 diff) - -注意:tc_index 每个推理轮次都从 0 重新编号,拦截器实例不能跨轮复用 -(assist_service 每轮新建实例)。作为兜底,on_chunk 依据 tool_call id -检测同一 tc_idx 上的新调用并自动重置状态,防止上一轮的 rel_path / -content_sent_len 残留导致提案 delta 错标路径、丢失新内容前缀。 -""" - -from collections.abc import AsyncIterator - -from app.schemas.agent import ClientContextInput, StreamEventPayload -from app.agent.tools.workspace_path import ( - is_lr_agent_relative, - normalize_write_display_path, -) - -WRITE_TOOL_NAME = "write_workspace_file" -EDIT_TOOL_NAME = "str_replace_workspace_file" - -_HEX_DIGITS = set("0123456789abcdefABCDEF") - - -def _decode_unicode_escape(buf: str, u_pos: int) -> tuple[str | None, int]: - """解码以 buf[u_pos]('u' 字符)开头的 \\uXXXX 转义。 - - 返回 (解码文本, 从 u_pos 起消耗的字符数);序列不完整时返回 (None, 0), - 由调用方暂停解析、等待更多分片后整体重解析。支持代理对。 - """ - hex_part = buf[u_pos + 1 : u_pos + 5] - if len(hex_part) < 4: - return None, 0 - if any(ch not in _HEX_DIGITS for ch in hex_part): - # 非法转义:按字面输出并跳过,避免解析卡死 - return "u", 1 - cp = int(hex_part, 16) - if 0xD800 <= cp <= 0xDBFF: - low_esc = buf[u_pos + 5 : u_pos + 7] - low_hex = buf[u_pos + 7 : u_pos + 11] - if ( - low_esc == "\\u" - and len(low_hex) == 4 - and all(ch in _HEX_DIGITS for ch in low_hex) - and 0xDC00 <= int(low_hex, 16) <= 0xDFFF - ): - low = int(low_hex, 16) - combined = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00) - return chr(combined), 11 - return "\ufffd", 5 - if 0xDC00 <= cp <= 0xDFFF: - # 孤立低代理 - return "\ufffd", 5 - return chr(cp), 5 - - -def _extract_json_string(buf: str, key: str) -> tuple[str | None, bool]: - """从可能不完整的 JSON 缓冲区中提取字符串字段值。 - - 处理标准 JSON 转义(\\n \\t \\r \\\\ \\" \\uXXXX),返回 (解码后的字符串, 是否已闭合)。 - 若 key 尚未出现则返回 (None, False); - 若出现但字符串值未闭合引号则返回 (已累积部分, False); - 若字符串值已闭合引号则返回 (完整值, True)。 - """ - idx = buf.find(f'"{key}"') - if idx == -1: - return None, False - try: - colon = buf.index(":", idx) - quote = buf.index('"', colon + 1) - except ValueError: - return None, False - result: list[str] = [] - i = quote + 1 - while i < len(buf): - c = buf[i] - if c == "\\" and i + 1 < len(buf): - nxt = buf[i + 1] - if nxt == "u": - text, consumed = _decode_unicode_escape(buf, i + 1) - if text is None: - break - result.append(text) - i += 1 + consumed - continue - esc: dict[str, str] = {"n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"'} - result.append(esc.get(nxt, nxt)) - i += 2 - elif c == '"': - return "".join(result), True - else: - result.append(c) - i += 1 - return ("".join(result) if result else None), False - - -class ProposalStreamInterceptor: - """拦截 LLM astream 的 tool_call_chunks,实时生成 file_proposal SSE。""" - - def __init__(self, client_context: ClientContextInput | None = None) -> None: - # client_context 用于把模型给的 relative_path 归一为与工具结果定稿 - # 事件一致的显示路径(绝对路径/./ 前缀/反斜杠都会被归一), - # 否则前端按路径匹配提案块会裂成两张卡片。 - self.client_context = client_context - # fp_states: tc_index → {args_buf, title_sent, content_sent_len, rel_path, call_id} - self.fp_states: dict[int, dict] = {} - - @staticmethod - def _new_state(call_id: str = "", kind: str = "write") -> dict: - return { - "args_buf": "", - "title_sent": False, - "kind": kind, - "content_sent_len": 0, - "old_sent_len": 0, - "new_sent_len": 0, - "rel_path": "", - "call_id": call_id, - } - - @staticmethod - def _merge_args(state: dict, tc_args: str) -> None: - """合并参数分片,兼容增量式与累积式两种 provider 行为,重复投递幂等。""" - if not tc_args: - return - buf = state["args_buf"] - if tc_args.startswith(buf): - # 首个分片或累积式 provider - state["args_buf"] = tc_args - elif buf.startswith(tc_args): - # 已见前缀的重复投递,忽略 - return - else: - state["args_buf"] = buf + tc_args - - def on_chunk(self, chunk) -> list[StreamEventPayload]: - """处理一个 AIMessageChunk,返回需要产出的 SSE 事件。""" - events: list[StreamEventPayload] = [] - for tc in chunk.tool_call_chunks or []: - tc_name = tc.get("name") - tc_args = tc.get("args") or "" - tc_idx = tc.get("index") - - if tc_idx is None: - continue - - if tc_name in (WRITE_TOOL_NAME, EDIT_TOOL_NAME): - tc_id = tc.get("id") or "" - kind = "edit" if tc_name == EDIT_TOOL_NAME else "write" - state = self.fp_states.get(tc_idx) - if state is None: - state = self.fp_states[tc_idx] = self._new_state(tc_id, kind) - elif tc_id and state.get("call_id") and tc_id != state["call_id"]: - # 同一 tc_idx 上出现了新的 tool_call:重置状态。否则 - # 上一轮的 rel_path 会错标本轮 delta 的归属路径, - # content_sent_len 会吞掉新内容的前缀。 - state = self.fp_states[tc_idx] = self._new_state(tc_id, kind) - elif state.get("kind") != kind: - state = self.fp_states[tc_idx] = self._new_state(tc_id, kind) - self._merge_args(state, tc_args) - elif tc_idx in self.fp_states and tc_args: - self._merge_args(self.fp_states[tc_idx], tc_args) - - if tc_idx in self.fp_states and self.fp_states[tc_idx]["args_buf"]: - state = self.fp_states[tc_idx] - if not state["title_sent"]: - rel_path, rel_closed = _extract_json_string( - state["args_buf"], "relative_path" - ) - if rel_path and rel_closed: - state["title_sent"] = True - if is_lr_agent_relative(rel_path): - state["suppressed"] = True - else: - state["rel_path"] = normalize_write_display_path( - self.client_context, rel_path - ) - events.append( - StreamEventPayload( - type="file_proposal_start", - summary=state["rel_path"], - image_path=state["rel_path"], - detail="0", - mode=( - "edit" - if state["kind"] == "edit" - else None - ), - ) - ) - - if state.get("suppressed"): - continue - - if state["kind"] == "edit": - events.extend(self._edit_delta_events(state)) - continue - - content, _ = _extract_json_string(state["args_buf"], "content") - if content is not None and len(content) > state["content_sent_len"]: - delta = content[state["content_sent_len"]:] - state["content_sent_len"] = len(content) - if delta: - events.append( - StreamEventPayload( - type="file_proposal_delta", - content=delta, - image_path=state.get("rel_path"), - ) - ) - return events - - @staticmethod - def _edit_delta_events(state: dict) -> list[StreamEventPayload]: - """提取 old_string / new_string 增量,合并为一个 file_edit_delta 事件。""" - buf = state["args_buf"] - old_delta: str | None = None - new_delta: str | None = None - old_val, _ = _extract_json_string(buf, "old_string") - if old_val is not None and len(old_val) > state["old_sent_len"]: - old_delta = old_val[state["old_sent_len"]:] - state["old_sent_len"] = len(old_val) - new_val, _ = _extract_json_string(buf, "new_string") - if new_val is not None and len(new_val) > state["new_sent_len"]: - new_delta = new_val[state["new_sent_len"]:] - state["new_sent_len"] = len(new_val) - if not old_delta and not new_delta: - return [] - return [ - StreamEventPayload( - type="file_edit_delta", - image_path=state.get("rel_path"), - old_delta=old_delta, - new_delta=new_delta, - ) - ] - - def collected_paths(self) -> set[str]: - """返回拦截器已发送过 file_proposal_start 的显示路径集合(已归一化)。""" - return set(self.streamed_paths_by_call_id().values()) - - def streamed_paths_by_call_id(self) -> dict[str, str]: - """call_id → 已流式过 start 的显示路径(已归一化)。 - - tool_loop 定稿时用它抑制重复的 start/假 delta burst;工具报错时 - 据此对已出卡的路径补发 dismissed 终态,避免卡片悬挂。 - 无 call_id 的状态无法关联回具体调用,不进入本映射。 - """ - streamed: dict[str, str] = {} - for state in self.fp_states.values(): - args_buf = state.get("args_buf", "") - rel_path, rel_closed = _extract_json_string(args_buf, "relative_path") - if ( - rel_path - and rel_closed - and state.get("title_sent") - and not state.get("suppressed") - ): - call_id = state.get("call_id") or "" - streamed[call_id] = state.get("rel_path") or ( - normalize_write_display_path(self.client_context, rel_path) - ) - return streamed diff --git a/vendor/local-agent/app/agent/assist/task_phase.py b/vendor/local-agent/app/agent/assist/task_phase.py deleted file mode 100644 index 598c3c8..0000000 --- a/vendor/local-agent/app/agent/assist/task_phase.py +++ /dev/null @@ -1,261 +0,0 @@ -"""标注任务阶段状态机:从结构化提案状态推导阶段,按阶段门禁工具调用。 - -设计要点: - - 后端无状态:每次请求由 derive_task_phase 根据 client_context.proposal_states - 重新推导,不持久化任何阶段。 - - 阶段是不变量约束而非线性流程:无标注提案历史的请求返回 None, - 不启用任何门禁(旧客户端与纯问答零行为变化)。 - - 门禁分两级: - 1. 工具级:await_confirm 阶段禁止全部写入工具(标注写入 + 工作区写入), - 防止"报告跑在落盘前"与"未确认就重复标注"。 - 2. 路径级:verify 阶段禁止对已 applied 的标注路径重复调用标注工具。 -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from enum import Enum -from typing import Any - -from app.schemas.agent import ProposalStateInput - -ANNOTATION_WRITE_TOOLS: frozenset[str] = frozenset( - {"auto_annotate", "mutate_annotation"} -) -WORKSPACE_WRITE_TOOLS: frozenset[str] = frozenset( - { - "write_workspace_file", - "str_replace_workspace_file", - "delete_workspace_file", - "move_workspace_file", - } -) - - -class TaskPhase(str, Enum): - """标注任务阶段。SCAN/ANNOTATE/REPORT 目前仅作语义标记,门禁集中在 - AWAIT_CONFIRM 与 VERIFY。""" - - SCAN = "scan" - ANNOTATE = "annotate" - AWAIT_CONFIRM = "await_confirm" - VERIFY = "verify" - REPORT = "report" - - -@dataclass(frozen=True) -class TaskPhaseContext: - """一次请求推导出的阶段上下文。""" - - phase: TaskPhase - pending_annotation_paths: frozenset[str] - applied_annotation_paths: frozenset[str] - applied_annotation_ids: frozenset[str] = frozenset() - - @property - def gating_enabled(self) -> bool: - return self.phase in (TaskPhase.AWAIT_CONFIRM, TaskPhase.VERIFY) - - -def normalize_rel_path(path: str) -> str: - """归一化相对路径,便于跨调用比对(统一斜杠、去 ./ 前缀)。""" - normalized = path.strip().replace("\\", "/") - while normalized.startswith("./"): - normalized = normalized[2:] - return normalized - - -def derive_task_phase( - proposal_states: list[ProposalStateInput] | None, -) -> TaskPhaseContext | None: - """从结构化提案状态推导任务阶段。 - - 返回 None 表示不启用门禁: - - 无 proposal_states(旧客户端 / 纯问答 / 无提案历史) - - 所有提案均为 dismissed(用户关闭提案,重新规划) - """ - if not proposal_states: - return None - pending = frozenset( - normalize_rel_path(state.path) - for state in proposal_states - if state.kind == "annotation" and state.status == "pending" - ) - applied = frozenset( - normalize_rel_path(state.path) - for state in proposal_states - if state.kind == "annotation" and state.status == "applied" - ) - applied_ids = frozenset( - ann_id - for state in proposal_states - if state.kind == "annotation" and state.status == "applied" - for ann_id in state.annotation_ids - if ann_id - ) - if pending: - return TaskPhaseContext( - phase=TaskPhase.AWAIT_CONFIRM, - pending_annotation_paths=pending, - applied_annotation_paths=applied, - applied_annotation_ids=applied_ids, - ) - if applied: - return TaskPhaseContext( - phase=TaskPhase.VERIFY, - pending_annotation_paths=frozenset(), - applied_annotation_paths=applied, - applied_annotation_ids=applied_ids, - ) - return None - - -def coerce_str_list(value: Any) -> list[str]: - """把模型传来的 list / JSON 数组字符串 / 逗号分隔 / 单值收成字符串列表。 - - 部分模型会把 array 参数序列化成 '["data/4.jpg"]' 或 'data/4.jpg', - 不能按 list 去迭代字符串(否则会拆成单字符)。 - """ - if value is None: - return [] - if isinstance(value, list): - items: list[Any] = value - elif isinstance(value, str): - text = value.strip() - if not text: - return [] - parsed: Any = None - if text[:1] in "[{": - try: - parsed = json.loads(text) - except json.JSONDecodeError: - parsed = None - if isinstance(parsed, list): - items = parsed - elif "," in text: - items = text.split(",") - else: - items = [text] - else: - items = [value] - return [str(item).strip() for item in items if str(item).strip()] - - -def coerce_bool(value: Any) -> bool: - """把模型传来的 true/false(含字符串形式)收成 bool。""" - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() == "true" - return bool(value) - - -def extract_call_paths(arguments: dict[str, Any]) -> frozenset[str]: - """从标注工具参数中提取目标路径集合(paths 优先,兼容 scope_hint)。""" - paths: set[str] = set() - for item in coerce_str_list(arguments.get("paths")): - paths.add(normalize_rel_path(item)) - scope_hint = arguments.get("scope_hint") - if isinstance(scope_hint, str): - for item in scope_hint.split(","): - text = item.strip() - if text: - paths.add(normalize_rel_path(text)) - return frozenset(paths) - - -def blocked_tool_names(ctx: TaskPhaseContext | None) -> frozenset[str]: - """工具级门禁:bind_tools 时直接移除的工具集合。""" - if ctx is None or not ctx.gating_enabled: - return frozenset() - if ctx.phase == TaskPhase.AWAIT_CONFIRM: - return ANNOTATION_WRITE_TOOLS | WORKSPACE_WRITE_TOOLS - return frozenset() - - -def check_call_allowed( - name: str, - arguments: dict[str, Any], - ctx: TaskPhaseContext | None, -) -> str | None: - """执行前门禁。返回 None 表示允许;否则返回给模型的错误说明。 - - 覆盖 bind 过滤之外的兜底场景(例如阶段上下文存在但工具未被移除)。 - """ - if ctx is None or not ctx.gating_enabled: - return None - if ctx.phase == TaskPhase.AWAIT_CONFIRM: - if name in ANNOTATION_WRITE_TOOLS or name in WORKSPACE_WRITE_TOOLS: - pending = "、".join(sorted(ctx.pending_annotation_paths)) - return ( - f"当前存在未确认的标注提案({pending}),用户尚未 Keep All,提案未写盘。" - "禁止标注写入与文件写入操作。请用一两句说明提案内容," - "提示用户 Keep All 或关闭提案后再继续。" - ) - return None - if ctx.phase == TaskPhase.VERIFY and name in ANNOTATION_WRITE_TOOLS: - if not ctx.applied_annotation_paths: - return None - applied_paths_text = "、".join(sorted(ctx.applied_annotation_paths)) - if coerce_bool(arguments.get("all_files")): - return ( - f"以下文件本轮已完成标注并经用户确认落盘:{applied_paths_text}。" - "禁止 all_files=true 的全量重标;如确需对其他文件标注,请用 paths 明确指定。" - ) - call_paths = extract_call_paths(arguments) - if name == "auto_annotate": - if not call_paths: - return ( - f"以下文件本轮已完成标注并经用户确认落盘:{applied_paths_text}。" - "auto_annotate 未指明 paths/scope_hint,无法确认是否重复标注已落盘文件," - "请用 paths 明确指定要标注的新文件。" - ) - overlap = call_paths & ctx.applied_annotation_paths - if overlap: - return ( - f"以下文件本轮已完成标注并经用户确认落盘:{'、'.join(sorted(overlap))}。" - "请勿重复标注。如核对后认为确需重标,必须向用户说明检测到的不一致" - "(检测到几个框、标签是什么、与预期差在哪)," - "并给出确切的下一步指令(如「请说:重新标注 data/x.jpg」),由用户发起。" - ) - return None - # mutate_annotation - annotation_ids = coerce_str_list(arguments.get("annotation_ids")) - if not call_paths and not annotation_ids: - return ( - f"以下文件本轮已完成标注并经用户确认落盘:{applied_paths_text}。" - "mutate_annotation 未指明 paths 或 annotation_ids,无法确认是否修改已落盘标注," - "请明确指定目标。" - ) - # 定向修正(含已落盘文件)放行;整文件重标仍由 auto_annotate 分支拦截 - return None - return None - - -def phase_prompt_block(ctx: TaskPhaseContext | None) -> str: - """阶段提示词块,追加到系统提示词末尾。""" - if ctx is None or not ctx.gating_enabled: - return "" - if ctx.phase == TaskPhase.AWAIT_CONFIRM: - paths = "、".join(sorted(ctx.pending_annotation_paths)) - return ( - "【任务阶段】等待用户确认标注提案\n" - f"- 以下文件的标注提案未确认、未写盘:{paths}\n" - "- 禁止调用标注写入与文件写入工具;禁止声称已标注/已修改/已写入。\n" - "- 不要用 read_file_annotation 验证提案内容(磁盘仍是旧态,以提案台账为准)。\n" - "- 用一两句说明提案内容,提示用户 Keep All 或关闭提案。" - ) - if ctx.phase == TaskPhase.VERIFY: - paths = "、".join(sorted(ctx.applied_annotation_paths)) - return ( - "【任务阶段】标注提案已确认落盘\n" - f"- 以下文件的标注提案已由用户 Keep All 并写盘:{paths}\n" - "- 可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。\n" - "- 禁止对上述路径重复调用 auto_annotate 整文件重标;核对发现错标/漏标/重复框时," - "用 mutate_annotation 定向修正,并带上 paths 或 annotation_ids。\n" - "- 提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。\n" - "- 报告中的每个数字必须来自工具返回或提案明细,禁止估算或凭印象填写。\n" - "- 工具完成后用一两句确认即可,不要重复输出报告全文。" - ) - return "" diff --git a/vendor/local-agent/app/agent/assist/tool_loop.py b/vendor/local-agent/app/agent/assist/tool_loop.py deleted file mode 100644 index 19f2fa9..0000000 --- a/vendor/local-agent/app/agent/assist/tool_loop.py +++ /dev/null @@ -1,720 +0,0 @@ -"""多轮 LLM ↔ 工具循环:stream_chunks → execute_round → 追加 ToolMessage。 - -拆分为两个阶段: - 1. stream_chunks: 流式产出 text/reasoning + 拦截 proposal chunks - 2. execute_round: 解析 tool_calls → 执行 SYNC / 发射 ASYNC pending -""" - -import asyncio -import inspect -import json -from collections.abc import AsyncIterator - -from langchain_core.messages import AIMessage, ToolMessage -from langchain_core.tools import StructuredTool -from langchain_openai import ChatOpenAI - -from app.agent.assist.explore_readonly import ( - EXPLORE_READONLY_TOOL_NAME, - ExploreReadonlyRunner, -) -from app.agent.assist.proposal_streamer import ProposalStreamInterceptor -from app.agent.assist.task_phase import ( - TaskPhaseContext, - check_call_allowed, - extract_call_paths, -) -from app.agent.chat_message_builder import build_multimodal_user_message -from app.agent.stream_adapter import events_from_chunk -from app.agent.tool_dispatcher import ( - SplitToolCalls, - resolve_round_tool_calls, - split_resolved_calls, -) -from app.agent.tool_invocation import ResolvedToolCall, client_tool_mentioned_in_text -from app.agent.tools.workspace_path import is_lr_agent_relative -from app.agent.tools.tool_result import ( - build_tool_result, - format_tool_result_for_display, - stringify_tool_output, -) -from app.agent.tools.workspace_file_reader import ( - FILE_PROPOSAL_TOOLS, - STR_REPLACE_TOOL_NAME, - VISION_TOOL_NAME, - extract_vision_path_from_tool_result, - extract_doc_proposal_from_tool_result, - format_vision_tool_result_for_display, - format_write_tool_result_for_display, -) -from app.core.config import Settings -from app.schemas.agent import StreamEventPayload - - -def _api_call_name_and_id(call: object) -> tuple[str, str]: - if isinstance(call, dict): - name = str(call.get("name") or "").strip() - tool_id = str(call.get("id") or "").strip() - return name, tool_id - name = str(getattr(call, "name", "") or "").strip() - tool_id = str(getattr(call, "id", "") or "").strip() - return name, tool_id - - -TOOL_CALLS_ALREADY_COMPLETED = "tool_calls_already_completed" -ALREADY_COMPLETED_SUMMARY = ( - "该工具本轮已执行,请勿重复调用。请总结,勿重跑标注工具。" -) - -PARALLEL_SYNC_TOOLS = frozenset( - { - "get_lr_agent_help", - "describe_annotation_project", - "read_file_annotation", - "read_workspace_file", - "grep_workspace", - "glob_workspace", - "list_workspace_directory", - "read_document_file", - } -) - - -async def _invoke_tool_fn(name: str, args: dict, fn_map: dict[str, object]) -> str: - fn = fn_map.get(name) - try: - if fn is None: - return json.dumps( - {"ok": False, "tool": name, "status": "error", "summary": f"未知工具: {name}"}, - ensure_ascii=False, - ) - if asyncio.iscoroutinefunction(fn): - raw = await fn(**args) - else: - # 同步工具丢线程池:既不阻塞事件循环(大范围 grep 会卡住整个服务的 - # SSE / 健康检查),又让并行白名单里的多个只读工具真正重叠执行。 - raw = await asyncio.to_thread(fn, **args) - if inspect.isawaitable(raw): - raw = await raw - return stringify_tool_output(raw) - except Exception as exc: - return json.dumps( - {"ok": False, "tool": name, "status": "error", "summary": f"工具执行失败: {exc}"}, - ensure_ascii=False, - ) - - -async def _force_tool_call_once( - llm: ChatOpenAI, - tools: list[StructuredTool], - messages: list, - full_text: str, -) -> tuple[AIMessage, list[ResolvedToolCall]] | None: - """tool_choice="any" 强制 LLM 发起真实 tool call(正文伪代码兜底)。 - - 返回 (携带 tool_calls 的 AIMessage, 解析后的调用);失败返回 None。 - AIMessage 保留已流式输出的原文本,确保后续 ToolMessage 的 tool_call_id 有归属。 - """ - try: - llm_forced = llm.bind_tools(tools, tool_choice="any") - response = await llm_forced.ainvoke(messages) - except Exception: - return None - api_calls = getattr(response, "tool_calls", None) or [] - resolved = resolve_round_tool_calls(api_tool_calls=api_calls) - if not resolved: - return None - tool_calls = [ - {"id": call.tool_call_id, "name": call.name, "args": call.arguments} - for call in resolved - ] - return AIMessage(content=full_text, tool_calls=tool_calls), resolved - - -def _is_explore_runner(runner: object) -> bool: - return isinstance(runner, ExploreReadonlyRunner) or callable( - getattr(runner, "stream", None) - ) - - -def _explore_result_text(summary: str, status: str) -> str: - return build_tool_result( - ok=status == "done", - tool=EXPLORE_READONLY_TOOL_NAME, - status="ok" if status == "done" else "error", - summary=summary or "(无摘要)", - ) - - -async def _merge_async_iterators( - streams: list[AsyncIterator[StreamEventPayload]], -) -> AsyncIterator[StreamEventPayload]: - if not streams: - return - if len(streams) == 1: - async for event in streams[0]: - yield event - return - - queue: asyncio.Queue[StreamEventPayload | None] = asyncio.Queue() - - async def _pump(gen: AsyncIterator[StreamEventPayload]) -> None: - try: - async for event in gen: - await queue.put(event) - finally: - await queue.put(None) - - tasks = [asyncio.create_task(_pump(stream)) for stream in streams] - remaining = len(tasks) - try: - while remaining > 0: - item = await queue.get() - if item is None: - remaining -= 1 - else: - yield item - finally: - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - - -async def _stream_explore_events( - *, - tool_id: str, - args: dict, - fn_map: dict[str, object], -) -> AsyncIterator[StreamEventPayload]: - """产出 tool_start + subagent_*,不写父 messages、不发 tool_result。""" - yield StreamEventPayload( - type="tool_start", - tool_call_id=tool_id, - name=EXPLORE_READONLY_TOOL_NAME, - arguments=json.dumps(args, ensure_ascii=False, indent=2), - ) - runner = fn_map.get(EXPLORE_READONLY_TOOL_NAME) - query = str(args.get("query") or "").strip() - focus_raw = args.get("focus_path") - focus_path = str(focus_raw).strip() if focus_raw else None - stream_fn = getattr(runner, "stream", None) - if _is_explore_runner(runner) and callable(stream_fn): - async for event in stream_fn( - query=query, - focus_path=focus_path, - parent_tool_id=tool_id, - ): - yield event - return - yield StreamEventPayload( - type="subagent_done", - tool_call_id=tool_id, - summary="查阅子代理未就绪", - status="error", - ) - - -async def _stream_tool_execution( - *, - tool_id: str, - name: str, - args: dict, - fn_map: dict[str, object], - provider_is_vision: bool, - settings: Settings, - messages: list, - omit_file_proposal_start_delta: set[str] | None = None, - streamed_paths_by_call_id: dict[str, str] | None = None, - result_text: str | None = None, -) -> AsyncIterator[StreamEventPayload]: - """执行单个同步工具,产出 tool_start / tool_result / file_proposal* 事件。""" - if omit_file_proposal_start_delta is None: - omit_file_proposal_start_delta = set() - yield StreamEventPayload( - type="tool_start", - tool_call_id=tool_id, - name=name, - arguments=json.dumps(args, ensure_ascii=False, indent=2), - ) - - if name == EXPLORE_READONLY_TOOL_NAME: - summary = "" - status = "error" - async for event in _stream_explore_events( - tool_id=tool_id, - args=args, - fn_map=fn_map, - ): - yield event - if event.type == "subagent_done": - summary = event.summary or "" - status = event.status or "done" - display_result = format_tool_result_for_display( - _explore_result_text(summary, status) - ) - yield StreamEventPayload( - type="tool_result", - tool_call_id=tool_id, - result=display_result, - ) - messages.append(ToolMessage(content=display_result, tool_call_id=tool_id)) - return - - if result_text is None: - result_text = await _invoke_tool_fn(name, args, fn_map) - - display_result = result_text - vision_path: str | None = None - doc_proposal: dict | None = None - - if name == VISION_TOOL_NAME: - vision_path = extract_vision_path_from_tool_result(name, result_text) - if vision_path: - display_result = format_vision_tool_result_for_display(result_text) - elif name in FILE_PROPOSAL_TOOLS: - doc_proposal = extract_doc_proposal_from_tool_result(name, result_text) - if doc_proposal: - display_result = format_write_tool_result_for_display(result_text) - else: - display_result = format_tool_result_for_display(result_text) - - yield StreamEventPayload( - type="tool_result", - tool_call_id=tool_id, - result=display_result, - ) - messages.append(ToolMessage(content=display_result, tool_call_id=tool_id)) - - if doc_proposal is None and name in FILE_PROPOSAL_TOOLS and streamed_paths_by_call_id: - # 工具执行失败(如 old_string 未命中):拦截器流式期间已为该路径出卡 - # (pending 块 content 为空/不完整)。补发 dismissed 终态让前端收掉 - # 悬挂卡片,否则 Keep All 会把空内容写盘。 - streamed_path = streamed_paths_by_call_id.get(tool_id) - if streamed_path: - yield StreamEventPayload( - type="file_proposal", - summary=str(args.get("relative_path") or streamed_path), - content="", - image_path=streamed_path, - mode="edit" if name == STR_REPLACE_TOOL_NAME else "write", - status="dismissed", - ) - - if doc_proposal: - full_content = doc_proposal["content"] - rel_path = doc_proposal["relative_path"] - if not is_lr_agent_relative(rel_path): - operation = str(doc_proposal.get("operation") or "write") - omit_for_path = rel_path in omit_file_proposal_start_delta if omit_file_proposal_start_delta else False - if operation != "delete" and not omit_for_path: - yield StreamEventPayload( - type="file_proposal_start", - summary=doc_proposal["title"], - image_path=rel_path, - detail=str(len(full_content)), - mode=operation, - ) - chunk_size = 200 - offset = 0 - while offset < len(full_content): - end = min(offset + chunk_size, len(full_content)) - chunk = full_content[offset:end] - yield StreamEventPayload( - type="file_proposal_delta", - content=chunk, - image_path=rel_path, - mode=operation, - ) - offset = end - yield StreamEventPayload( - type="file_proposal", - summary=doc_proposal["title"], - content=full_content, - image_path=rel_path, - mode=operation, - old_path=str(doc_proposal.get("old_path") or "") or None, - ) - - if vision_path and provider_is_vision: - messages.append( - await build_multimodal_user_message( - "【附图】请根据上图回答用户关于该图片的问题。", - image_absolute_path=vision_path, - max_edge=settings.agent_chat_vision_max_edge, - jpeg_quality=settings.agent_chat_vision_jpeg_quality, - ), - ) - - -class ToolLoopRunner: - """多轮 LLM ↔ 工具循环执行器。""" - - def __init__( - self, - llm: ChatOpenAI, - tools: list[StructuredTool], - fn_map: dict[str, object], - settings: Settings, - is_cancelled, - user_content: str, - provider_is_vision: bool = False, - task_phase_ctx: TaskPhaseContext | None = None, - ) -> None: - self.llm = llm - self.tools = tools - self.fn_map = fn_map - self.settings = settings - self.is_cancelled = is_cancelled - self.user_content = user_content - self.provider_is_vision = provider_is_vision - self.phase_ctx = task_phase_ctx - self.vision_bootstrapped = False - self.completed_tools: set[str] = set() - self.tool_choice_retries = 0 - - def mark_completed(self, tool_call_ids: set[str]) -> None: - """标记已 resume 的 tool_call_id(允许同名新调用)。""" - self.completed_tools |= tool_call_ids - - async def _maybe_force_tool_call( - self, - full_text: str, - messages: list, - ) -> tuple[AIMessage, list[ResolvedToolCall]] | None: - """正文提到客户端工具却未发起真实 tool_call 时,强制重试一次。 - - 仅触发一次;被阶段门禁禁止的工具不强制(让模型收到门禁反馈而非硬调)。 - """ - if self.tool_choice_retries >= 1: - return None - mentioned = client_tool_mentioned_in_text(full_text) - if not mentioned: - return None - if mentioned not in {t.name for t in self.tools}: - return None - if check_call_allowed(mentioned, {}, self.phase_ctx) is not None: - return None - self.tool_choice_retries += 1 - return await _force_tool_call_once(self.llm, self.tools, messages, full_text) - - async def _yield_blocked_call( - self, - call: ResolvedToolCall, - *, - status: str, - reason: str, - messages: list, - ) -> AsyncIterator[StreamEventPayload]: - """被门禁/去重拦截的调用:产出 tool_start/tool_result 事件并补 ToolMessage。""" - result_text = build_tool_result( - ok=False, - tool=call.name, - status=status, - summary=reason, - ) - yield StreamEventPayload( - type="tool_start", - tool_call_id=call.tool_call_id, - name=call.name, - arguments=json.dumps(call.arguments, ensure_ascii=False, indent=2), - ) - yield StreamEventPayload( - type="tool_result", - tool_call_id=call.tool_call_id, - result=result_text, - ) - messages.append(ToolMessage(content=result_text, tool_call_id=call.tool_call_id)) - - async def stream_chunks( - self, - messages: list, - interceptor: ProposalStreamInterceptor, - ) -> AsyncIterator[StreamEventPayload]: - """流式产出 text_delta / reasoning_delta + 拦截 proposal chunks。""" - llm_with_tools = self.llm.bind_tools(self.tools) - - async for chunk in llm_with_tools.astream(messages): - if await self.is_cancelled(): - return - - for event in events_from_chunk(chunk, emit_tool_chunks=False): - if event.type in ("text_delta", "reasoning_delta") and event.content: - yield event - - for proposal_event in interceptor.on_chunk(chunk): - yield proposal_event - - async def execute_round( - self, - gathered: AIMessage, - full_text: str, - messages: list, - interceptor: ProposalStreamInterceptor, - ) -> AsyncIterator[StreamEventPayload]: - """解析 tool_calls → 阶段门禁 → 执行 SYNC / 发射 ASYNC pending → 产出事件。""" - api_tool_calls = gathered.tool_calls or [] - - resolved = resolve_round_tool_calls( - api_tool_calls=api_tool_calls, - completed_tools=frozenset(self.completed_tools), - ) - - if not resolved and not api_tool_calls: - # 伪代码兜底:正文写了 auto_annotate(...) 等客户端工具却未发起真实 tool_call - forced = await self._maybe_force_tool_call(full_text, messages) - if forced is not None: - gathered, resolved = forced - - if not resolved: - if not api_tool_calls: - return - messages.append(gathered) - for call in api_tool_calls: - name, tool_id = _api_call_name_and_id(call) - if not name or not tool_id: - continue - result_text = build_tool_result( - ok=False, - tool=name, - status="already_completed", - summary=ALREADY_COMPLETED_SUMMARY, - ) - messages.append( - ToolMessage(content=result_text, tool_call_id=tool_id) - ) - yield StreamEventPayload(type=TOOL_CALLS_ALREADY_COMPLETED) - return - - messages.append(gathered) - - # ── 同轮顺序不变量:标注写入与工作区写入不能同轮 ── - # 否则 write_workspace_file(SYNC)会先执行,auto_annotate(ASYNC)后挂起, - # 出现「先写报告、后标注」的本末倒置。此处拦截工作区写入,标注照常。 - from app.agent.assist.task_phase import ( - ANNOTATION_WRITE_TOOLS, - WORKSPACE_WRITE_TOOLS, - ) - - has_annotation_write = any( - call.name in ANNOTATION_WRITE_TOOLS for call in resolved - ) - if has_annotation_write: - kept: list[ResolvedToolCall] = [] - for call in resolved: - if call.name in WORKSPACE_WRITE_TOOLS: - async for event in self._yield_blocked_call( - call, - status="phase_blocked", - reason=( - "本轮已包含标注写入(auto_annotate / mutate_annotation)。" - "请先完成标注并等待用户 Keep All,再写报告或修改文件。" - ), - messages=messages, - ): - yield event - continue - kept.append(call) - resolved = kept - if not resolved: - return - - # ── 阶段门禁:被禁调用转为错误反馈(loop 继续,模型收到反馈自行调整)── - if self.phase_ctx is not None and self.phase_ctx.gating_enabled: - allowed_calls: list[ResolvedToolCall] = [] - for call in resolved: - reason = check_call_allowed(call.name, call.arguments, self.phase_ctx) - if reason is None: - allowed_calls.append(call) - continue - async for event in self._yield_blocked_call( - call, - status="phase_blocked", - reason=reason, - messages=messages, - ): - yield event - resolved = allowed_calls - if not resolved: - return - - split = split_resolved_calls(resolved) - - # ── 同轮多个 auto_annotate 合并为一次批量 ── - # 5 次单文件 auto_annotate 会产出 5 份提案且单张更易过检;合并 paths 后 - # 前端只跑一次批量标注,只生成一份提案。 - auto_calls = [c for c in split.async_pending if c.name == "auto_annotate"] - if len(auto_calls) > 1: - merged_paths: list[str] = [] - seen_paths: set[str] = set() - merged_all_files = False - merged_write_mode = "append" - for call in auto_calls: - for p in extract_call_paths(call.arguments): - if p not in seen_paths: - seen_paths.add(p) - merged_paths.append(p) - if call.arguments.get("all_files") is True: - merged_all_files = True - wm = call.arguments.get("write_mode") - if wm == "replace_matching": - merged_write_mode = "replace_matching" - - first = auto_calls[0] - first_args = dict(first.arguments) - first_args["paths"] = merged_paths - first_args["all_files"] = merged_all_files - first_args["write_mode"] = merged_write_mode - merged_first = ResolvedToolCall( - tool_call_id=first.tool_call_id, - name=first.name, - arguments=first_args, - source=first.source, - ) - async_pending = [merged_first] - for call in auto_calls[1:]: - async for event in self._yield_blocked_call( - call, - status="coalesced", - reason=( - f"本次调用已并入第一个 auto_annotate(paths 合并为 {merged_paths})," - "请勿重复发起。" - ), - messages=messages, - ): - yield event - # 保留非 auto_annotate 的异步调用(mutate_annotation 不合并) - async_pending.extend( - c for c in split.async_pending if c.name != "auto_annotate" - ) - split = SplitToolCalls( - immediate=split.immediate, - async_pending=async_pending, - ) - - # ── 同批异步调用去重:同工具同范围只保留第一个 ── - if split.async_pending: - seen_scopes: set[tuple[str, frozenset[str]]] = set() - deduped_pending: list[ResolvedToolCall] = [] - for call in split.async_pending: - scope_key = (call.name, extract_call_paths(call.arguments)) - if scope_key in seen_scopes: - async for event in self._yield_blocked_call( - call, - status="duplicate_call", - reason="同一轮内相同范围的重复标注调用已忽略,请勿重复发起。", - messages=messages, - ): - yield event - continue - seen_scopes.add(scope_key) - deduped_pending.append(call) - split = SplitToolCalls( - immediate=split.immediate, - async_pending=deduped_pending, - ) - if not split.immediate and not split.async_pending: - return - - streamed_paths_by_call_id = interceptor.streamed_paths_by_call_id() - streamed_paths = set(streamed_paths_by_call_id.values()) - - explore_calls = [ - call - for call in split.immediate - if call.name == EXPLORE_READONLY_TOOL_NAME - ] - other_immediate = [ - call - for call in split.immediate - if call.name != EXPLORE_READONLY_TOOL_NAME - ] - - parallel_calls = [ - call for call in other_immediate if call.name in PARALLEL_SYNC_TOOLS - ] - precomputed: dict[str, str] = {} - if parallel_calls: - async def _run_parallel(call: ResolvedToolCall) -> tuple[str, str]: - text = await _invoke_tool_fn(call.name, call.arguments, self.fn_map) - return call.tool_call_id, text - - pairs = await asyncio.gather(*[_run_parallel(call) for call in parallel_calls]) - precomputed = dict(pairs) - - for call in other_immediate: - if await self.is_cancelled(): - return - async for event in _stream_tool_execution( - tool_id=call.tool_call_id, - name=call.name, - args=call.arguments, - fn_map=self.fn_map, - provider_is_vision=self.provider_is_vision, - settings=self.settings, - messages=messages, - omit_file_proposal_start_delta=streamed_paths, - streamed_paths_by_call_id=streamed_paths_by_call_id, - result_text=precomputed.get(call.tool_call_id), - ): - yield event - if call.name == VISION_TOOL_NAME: - self.vision_bootstrapped = True - - if explore_calls: - finals: dict[str, tuple[str, str]] = {} - - async def _tracked_explore( - call: ResolvedToolCall, - ) -> AsyncIterator[StreamEventPayload]: - summary = "" - status = "error" - try: - async for event in _stream_explore_events( - tool_id=call.tool_call_id, - args=call.arguments, - fn_map=self.fn_map, - ): - if event.type == "subagent_done": - summary = event.summary or "" - status = event.status or "done" - yield event - except Exception as exc: - summary = f"查阅失败:{exc}" - status = "error" - yield StreamEventPayload( - type="subagent_done", - tool_call_id=call.tool_call_id, - summary=summary, - status="error", - ) - finals[call.tool_call_id] = (summary, status) - - async for event in _merge_async_iterators( - [_tracked_explore(call) for call in explore_calls] - ): - yield event - - for call in explore_calls: - summary, status = finals.get( - call.tool_call_id, ("查阅失败", "error") - ) - display_result = format_tool_result_for_display( - _explore_result_text(summary, status) - ) - yield StreamEventPayload( - type="tool_result", - tool_call_id=call.tool_call_id, - result=display_result, - ) - messages.append( - ToolMessage( - content=display_result, tool_call_id=call.tool_call_id - ) - ) - - if split.async_pending: - from app.agent.assist.pending_emitter import emit_tool_pending - - async for event in emit_tool_pending(split.async_pending): - yield event diff --git a/vendor/local-agent/app/agent/assist/vision_bootstrap.py b/vendor/local-agent/app/agent/assist/vision_bootstrap.py deleted file mode 100644 index 6c2f457..0000000 --- a/vendor/local-agent/app/agent/assist/vision_bootstrap.py +++ /dev/null @@ -1,66 +0,0 @@ -"""视觉兜底加载:第一轮 LLM 未发起任何 tool call 时,自动加载相关图片。 - -设计说明:历史上曾在进 LLM 前无条件预加载(bootstrap),但系统替模型选图容易看错、 -浪费视觉 token;现仅保留弱模型未调用工具时的首轮兜底(try_fallback), -正常路径由模型自行调用 read_image_for_vision。 -""" - -import uuid -from collections.abc import AsyncIterator, Callable - -from app.agent.assist_vision import ( - can_bootstrap_vision, - pick_vision_relative_path, - stream_vision_tool_execution, - vision_relative_from_user_text, -) -from app.core.config import Settings -from app.schemas.agent import ClientContextInput, StreamEventPayload - - -class VisionAutoLoader: - """视觉兜底加载器。 - - 根据 user_content 判断是否涉及图片;仅在第一轮无 tool call 时 - 通过流式执行 vision tool 将图片注入消息上下文(弱模型兜底)。 - """ - - def __init__( - self, - vision_fn: Callable | None, - provider_is_vision: bool, - settings: Settings, - client_context: ClientContextInput | None, - user_content: str, - ) -> None: - self.vision_fn = vision_fn - self.provider_is_vision = provider_is_vision - self.settings = settings - self.client_context = client_context - self.user_content = user_content - - def should_load(self, is_resume: bool) -> bool: - """判断本轮是否可能涉及图片(fallback 触发前提)。""" - if is_resume or not self.provider_is_vision or self.vision_fn is None: - return False - return bool(vision_relative_from_user_text(self.user_content)) - - async def try_fallback( - self, messages: list - ) -> AsyncIterator[StreamEventPayload]: - """在第一轮无 tool call 时尝试视觉回退(fallback)加载。""" - rel = pick_vision_relative_path(self.client_context) - if not rel and self.user_content.strip(): - rel = vision_relative_from_user_text(self.user_content) - - if can_bootstrap_vision(self.client_context, rel): - tool_id = f"lr-vision-fallback-{uuid.uuid4().hex[:10]}" - async for event in stream_vision_tool_execution( - tool_id=tool_id, - relative_path=rel, - vision_fn=self.vision_fn, # type: ignore[arg-type] - provider_is_vision=self.provider_is_vision, - settings=self.settings, - messages=messages, - ): - yield event diff --git a/vendor/local-agent/app/agent/assist_mode_router.py b/vendor/local-agent/app/agent/assist_mode_router.py deleted file mode 100644 index 8aaa451..0000000 --- a/vendor/local-agent/app/agent/assist_mode_router.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Assist 模式路由器:基于客户端上下文决定运行模式与工具集。 - -三种模式: - - FULL: 完整工具集(标注、分析、写文件、读文件、视觉)— 标注项目内 - - LIGHT: 只读工具 + write_workspace_file(报告、文档、问答)— 编辑器 / 工作区 - - CHAT: 纯对话,无工具(由 chat_service 处理,不经过 assist) -""" - -# 每种模式的工具白名单。 -# get_account_summary / describe_client_context 不注入主 Agent:前者在本地应用里无用, -# 后者要的信息 system prompt 已注入,二者的 schema 只是白占前缀并诱发无关调用。 -# 两者仍在 registry 注册,子代理 explore_readonly 内部可用(见 explore_readonly.py)。 -LIGHT_TOOL_SET: frozenset[str] = frozenset({ - "read_workspace_file", - "grep_workspace", - "glob_workspace", - "list_workspace_directory", - "read_document_file", - "read_image_for_vision", - "write_workspace_file", - "str_replace_workspace_file", - "delete_workspace_file", - "move_workspace_file", - "get_lr_agent_help", - "describe_annotation_project", - "read_file_annotation", - "explore_readonly", -}) - -FULL_TOOL_SET: frozenset[str] = frozenset({ - "read_workspace_file", - "grep_workspace", - "glob_workspace", - "list_workspace_directory", - "read_document_file", - "read_image_for_vision", - "write_workspace_file", - "str_replace_workspace_file", - "delete_workspace_file", - "move_workspace_file", - "auto_annotate", - "mutate_annotation", - "get_lr_agent_help", - "describe_annotation_project", - "read_file_annotation", - "explore_readonly", -}) - -WRITE_TOOL_NAMES: frozenset[str] = frozenset( - { - "auto_annotate", - "mutate_annotation", - "write_workspace_file", - "str_replace_workspace_file", - "delete_workspace_file", - "move_workspace_file", - } -) - -# Ask 模式:有项目快照也只读,禁止标注写入与工作区写文件 -ASK_TOOL_SET: frozenset[str] = FULL_TOOL_SET - WRITE_TOOL_NAMES - - -def resolve_assist_tool_set( - *, - has_project_snapshot: bool, - agent_mode: str | None, - is_editor: bool, - has_workspace: bool, -) -> frozenset[str]: - """Ask / 缺失模式一律只读;Agent 仅在标注任务(有快照且非编辑器)给完整工具。""" - has_context = has_project_snapshot or is_editor or has_workspace - if agent_mode != "annotation": - return ASK_TOOL_SET if has_context else frozenset() - if has_project_snapshot and not is_editor: - return FULL_TOOL_SET - if is_editor or has_workspace: - return LIGHT_TOOL_SET - return frozenset() diff --git a/vendor/local-agent/app/agent/assist_service.py b/vendor/local-agent/app/agent/assist_service.py deleted file mode 100644 index 04043f2..0000000 --- a/vendor/local-agent/app/agent/assist_service.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Assist 模式流式推理:编排 ToolLoopRunner + VisionAutoLoader + ProposalStreamInterceptor。""" - -from collections.abc import AsyncIterator - -from langchain_core.messages import AIMessage, HumanMessage -from langchain_core.tools import StructuredTool -from langchain_openai import ChatOpenAI - -from app.agent.assist.proposal_streamer import ProposalStreamInterceptor -from app.agent.assist.task_phase import TaskPhase, TaskPhaseContext -from app.agent.assist.tool_loop import TOOL_CALLS_ALREADY_COMPLETED, ToolLoopRunner -from app.agent.assist.vision_bootstrap import VisionAutoLoader -from app.agent.context_service import append_client_tool_results_to_messages -from app.agent.stream_adapter import events_from_chunk -from app.agent.tools.registry import tool_fn_map -from app.agent.tools.workspace_file_reader import VISION_TOOL_NAME -from app.core.config import Settings -from app.schemas.agent import ClientContextInput, StreamEventPayload - - -async def stream_assist( - llm: ChatOpenAI, - lc_messages: list, - tools: list[StructuredTool], - *, - settings: Settings, - max_tool_rounds: int, - is_cancelled, - provider_is_vision: bool = False, - client_context: ClientContextInput | None = None, - user_content: str = "", - client_tool_results: list | None = None, - task_phase_ctx: TaskPhaseContext | None = None, - aux_llm: ChatOpenAI | None = None, -) -> AsyncIterator[StreamEventPayload]: - yield StreamEventPayload(type="preparing", stage="streaming") - - llm_with_tools = llm.bind_tools(tools) - fn_map = tool_fn_map(tools) - from app.agent.assist.explore_readonly import ( - EXPLORE_READONLY_TOOL_NAME, - ExploreReadonlyRunner, - ) - - parent_fns = { - name: fn - for name, fn in fn_map.items() - if name != EXPLORE_READONLY_TOOL_NAME - } - if any(tool.name == EXPLORE_READONLY_TOOL_NAME for tool in tools): - fn_map[EXPLORE_READONLY_TOOL_NAME] = ExploreReadonlyRunner( - llm=aux_llm or llm, - settings=settings, - is_cancelled=is_cancelled, - parent_tools=tools, - parent_fn_map=parent_fns, - client_context=client_context, - ) - messages = list(lc_messages) - vision_fn = fn_map.get(VISION_TOOL_NAME) - is_resume = bool(client_tool_results) - - # ── 子模块实例化 ───────────────────────────────────────────────── - vision = VisionAutoLoader( - vision_fn=vision_fn, - provider_is_vision=provider_is_vision, - settings=settings, - client_context=client_context, - user_content=user_content, - ) - loop = ToolLoopRunner( - llm=llm, - tools=tools, - fn_map=fn_map, - settings=settings, - is_cancelled=is_cancelled, - user_content=user_content, - provider_is_vision=provider_is_vision, - task_phase_ctx=task_phase_ctx, - ) - - # ── resume:注入已完成的工具结果 ───────────────────────────────── - if client_tool_results: - completed_ids = {ctr.tool_call_id for ctr in client_tool_results} - loop.mark_completed(completed_ids) - append_client_tool_results_to_messages( - messages, - client_tool_results, - user_content=user_content, - proposals_applied=bool( - task_phase_ctx and task_phase_ctx.phase == TaskPhase.VERIFY - ), - ) - - # ── 主循环 ─────────────────────────────────────────────────────── - # 视觉图片由模型按需调用 read_image_for_vision 加载; - # 仅在第一轮无 tool call 时由 VisionAutoLoader 兜底(见循环内 fallback 分支)。 - for round_idx in range(max_tool_rounds + 1): - if await is_cancelled(): - return - - # 每轮使用全新拦截器:tool_call 的 tc_index 每轮都从 0 重新编号, - # 复用实例会把上一轮 write 调用的 rel_path / content_sent_len 残留到 - # 本轮,导致提案 delta 错标归属路径并把别的文件内容追加进旧提案。 - interceptor = ProposalStreamInterceptor(client_context) - - gathered: AIMessage | None = None - pending_text: list[str] = [] - - # 单次 astream:同时收集 text/reasoning + 拦截 proposal + 累加 gathered - async for chunk in llm_with_tools.astream(messages): - if await is_cancelled(): - return - - for event in events_from_chunk(chunk, emit_tool_chunks=False): - if event.type == "text_delta" and event.content: - pending_text.append(event.content) - yield event - elif event.type == "reasoning_delta" and event.content: - yield event - - # 提案流式拦截 - for proposal_event in interceptor.on_chunk(chunk): - yield proposal_event - - if gathered is None: - gathered = chunk - else: - gathered = gathered + chunk - - if gathered is None: - break - - full_text = "".join(pending_text) - - # 执行本轮 tool calls(由 ToolLoopRunner 统一解析 + 执行 + pending) - had_tool_call = False - async for event in loop.execute_round( - gathered=gathered, - full_text=full_text, - messages=messages, - interceptor=interceptor, - ): - if event.type == TOOL_CALLS_ALREADY_COMPLETED: - had_tool_call = True - continue - had_tool_call = True - if event.type == "tool_pending": - yield event - return - yield event - - if had_tool_call: - continue - - # 无 tool call 时的处理 - # 视觉 fallback(仅第一轮) - if ( - round_idx == 0 - and not is_resume - and vision.should_load(is_resume) - and not loop.vision_bootstrapped - ): - async for event in vision.try_fallback(messages): - yield event - loop.vision_bootstrapped = True - continue - - break - else: - # 工具轮预算耗尽兜底:照抄子代理的收尾模式,强制一次无工具的 - # 最终回答,避免回合在工具链中途无声结束、前端只剩半截输出。 - if await is_cancelled(): - return - messages.append( - HumanMessage( - content=( - "工具调用预算已用完。请基于以上进展直接给出最终回答," - "不要再调用工具。" - ) - ) - ) - async for chunk in llm.astream(messages): - if await is_cancelled(): - return - for event in events_from_chunk(chunk, emit_tool_chunks=False): - if event.type in ("text_delta", "reasoning_delta") and event.content: - yield event diff --git a/vendor/local-agent/app/agent/assist_vision.py b/vendor/local-agent/app/agent/assist_vision.py deleted file mode 100644 index e1fd5e5..0000000 --- a/vendor/local-agent/app/agent/assist_vision.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Vision bootstrap path resolution and tool execution helpers for assist chat.""" - -from __future__ import annotations - -import asyncio -import json -import uuid -from collections.abc import AsyncIterator -from typing import Callable - -from langchain_core.messages import ToolMessage - -from app.agent.chat_message_builder import build_multimodal_user_message -from app.agent.tools.workspace_file_reader import ( - IMAGE_SUFFIXES, - VISION_TOOL_NAME, - extract_vision_path_from_tool_result, - format_vision_tool_result_for_display, -) -from app.agent.tools.workspace_path import resolve_workspace_file -from app.core.config import Settings -from app.schemas.agent import ClientContextInput, StreamEventPayload - - -def pick_vision_relative_path( - client_context: ClientContextInput | None, -) -> str: - """Best relative path for auto vision load (active_relative_path > empty for UI file).""" - if client_context is not None: - rel = (client_context.active_relative_path or "").strip() - if rel: - return rel - return "" - - -def vision_relative_from_user_text(user_content: str) -> str: - """Extract data/foo.jpg style path from user message when understanding is missing.""" - import re - - match = re.search( - r"(?:^|[\s「『])([\w./\\-]+\.(?:jpg|jpeg|png|gif|webp|bmp|ico))\b", - user_content, - re.IGNORECASE, - ) - if not match: - return "" - return match.group(1).replace("\\", "/") - - -def can_bootstrap_vision( - client_context: ClientContextInput | None, - relative_path: str, -) -> bool: - resolved, _err = resolve_workspace_file(client_context, relative_path) - if resolved is None: - return False - return resolved.suffix.lower() in IMAGE_SUFFIXES - - -async def stream_vision_tool_execution( - *, - tool_id: str, - relative_path: str, - vision_fn: Callable[..., str], - provider_is_vision: bool, - settings: Settings, - messages: list, -) -> AsyncIterator[StreamEventPayload]: - """Run read_image_for_vision, yield tool events, append ToolMessage + optional image.""" - args = {"relative_path": relative_path} - yield StreamEventPayload( - type="tool_start", - tool_call_id=tool_id, - name=VISION_TOOL_NAME, - arguments=json.dumps(args, ensure_ascii=False, indent=2), - ) - - try: - # read_image_for_vision 要开图读尺寸(同步 IO),走线程池避免阻塞事件循环 - result_text = str(await asyncio.to_thread(vision_fn, **args)) - except Exception as exc: - result_text = f"工具执行失败: {exc}" - - display_result = result_text - vision_path = extract_vision_path_from_tool_result(VISION_TOOL_NAME, result_text) - if vision_path: - display_result = format_vision_tool_result_for_display(result_text) - - yield StreamEventPayload( - type="tool_result", - tool_call_id=tool_id, - result=display_result, - ) - messages.append(ToolMessage(content=display_result, tool_call_id=tool_id)) - - if vision_path and provider_is_vision: - messages.append( - await build_multimodal_user_message( - "【附图】请根据上图回答用户关于该图片的问题。", - image_absolute_path=vision_path, - max_edge=settings.agent_chat_vision_max_edge, - jpeg_quality=settings.agent_chat_vision_jpeg_quality, - ), - ) diff --git a/vendor/local-agent/app/agent/chat_message_builder.py b/vendor/local-agent/app/agent/chat_message_builder.py deleted file mode 100644 index bd5daf4..0000000 --- a/vendor/local-agent/app/agent/chat_message_builder.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Build LangChain messages including optional vision attachment for the latest user turn.""" - -from __future__ import annotations - -import asyncio -import base64 - -from langchain_core.messages import HumanMessage - -from app.agent.annotation.image_bytes_loader import load_image_bytes, resize_image_to_jpeg_bytes - - -def _resize_image_bytes( - raw: bytes, - *, - max_edge: int, - jpeg_quality: int, -) -> tuple[bytes, str]: - return resize_image_to_jpeg_bytes(raw, max_edge=max_edge, jpeg_quality=jpeg_quality) - - -def _encode_image_data_url( - *, - image_absolute_path: str, - image_base64: str, - max_edge: int, - jpeg_quality: int, -) -> str | None: - """同步重活:读盘 + 解码 + 缩放 + JPEG 编码 + base64。 - - 单独抽出来是为了能整体丢进线程池执行。 - """ - raw, _source = load_image_bytes( - image_absolute_path=image_absolute_path, - image_base64=image_base64, - ) - if raw is None: - return None - - try: - jpeg_bytes, mime = _resize_image_bytes( - raw, - max_edge=max_edge, - jpeg_quality=jpeg_quality, - ) - except Exception: - return None - - b64 = base64.b64encode(jpeg_bytes).decode("ascii") - return f"data:{mime};base64,{b64}" - - -async def build_multimodal_user_message( - text: str, - *, - image_absolute_path: str = "", - image_base64: str = "", - max_edge: int = 1280, - jpeg_quality: int = 85, -) -> HumanMessage: - """构造带附图的多模态消息。 - - 图片解码与编码是 CPU 密集的同步操作,大图可达数百毫秒;丢到线程池执行, - 避免阻塞事件循环(否则会卡住同进程内其它会话的 SSE 流)。 - """ - data_url = await asyncio.to_thread( - _encode_image_data_url, - image_absolute_path=image_absolute_path, - image_base64=image_base64, - max_edge=max_edge, - jpeg_quality=jpeg_quality, - ) - if data_url is None: - return HumanMessage(content=text) - - content: list[dict] = [ - {"type": "text", "text": text}, - {"type": "image_url", "image_url": {"url": data_url}}, - ] - return HumanMessage(content=content) diff --git a/vendor/local-agent/app/agent/chat_service.py b/vendor/local-agent/app/agent/chat_service.py deleted file mode 100644 index 7d7b91c..0000000 --- a/vendor/local-agent/app/agent/chat_service.py +++ /dev/null @@ -1,16 +0,0 @@ -from collections.abc import AsyncIterator - -from langchain_openai import ChatOpenAI - -from app.agent.stream_adapter import events_from_chunk -from app.schemas.agent import StreamEventPayload - - -async def stream_chat( - llm: ChatOpenAI, - lc_messages: list, -) -> AsyncIterator[StreamEventPayload]: - yield StreamEventPayload(type="preparing", stage="streaming") - async for chunk in llm.astream(lc_messages): - for event in events_from_chunk(chunk, emit_tool_chunks=False): - yield event diff --git a/vendor/local-agent/app/agent/context_helpers.py b/vendor/local-agent/app/agent/context_helpers.py deleted file mode 100644 index b77fcf5..0000000 --- a/vendor/local-agent/app/agent/context_helpers.py +++ /dev/null @@ -1,22 +0,0 @@ -"""客户端上下文提取与格式化工具。 - -从 ClientContextInput 中统一读取标注项目、工作区路径等字段,供下游模块复用: - - tools/workspace_path、tools/registry:解析项目目录作为文件读取的根路径 -""" - -from __future__ import annotations - -from app.schemas.agent import ClientContextInput - - -def project_directory(client_context: ClientContextInput | None) -> str | None: - """获取标注项目目录绝对路径,优先 client_context,其次快照内字段。""" - if client_context is None: - return None - direct = (client_context.project_directory_path or "").strip() - if direct: - return direct - snap = client_context.annotation_project_snapshot - if snap and getattr(snap, "project_directory_path", None): - return str(snap.project_directory_path).strip() or None - return None diff --git a/vendor/local-agent/app/agent/context_service.py b/vendor/local-agent/app/agent/context_service.py deleted file mode 100644 index 61e202e..0000000 --- a/vendor/local-agent/app/agent/context_service.py +++ /dev/null @@ -1,117 +0,0 @@ -"""对话上下文服务:Assist resume 与 chat 系统提示词。""" - -from __future__ import annotations - -import json - -from langchain_core.messages import AIMessage, ToolMessage - - -CHAT_SYSTEM_PROMPT = """在 LR-Agent 系统内回答用户问题。结合【你的身份】中的模型信息作答,勿自称独立产品助手或其它未配置的模型。""" - -RESUME_NEXT_HINT = ( - "客户端工具已结束。不要重复调用刚才同一个 tool_call。" - "若 proposal_pending=true:提案未 Keep All、未写盘,不要声称已标注/已删除/已写入。" - "改或删已有标注必须调用 mutate_annotation。" - "若还要给其他文件做新标注,再调用 auto_annotate 并传入新的 paths。" - "不要 memory_write progress.md 或 annotated-files.md。" -) - -RESUME_APPLIED_NEXT_HINT = ( - "客户端工具已结束,用户已 Keep All:提案已写盘,file_written=true。" - "可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。" - "报告中的每个数字必须来自工具返回或提案明细,禁止估算。" - "不要对已写盘文件重复调用 auto_annotate 整文件重标;" - "核对发现错标/漏标/重复框时,用 mutate_annotation 定向修正(带上 paths 或 annotation_ids)。" - "提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。" - "不要 memory_write progress.md 或 annotated-files.md。" -) - - -def _tool_call_id(tool_call: object) -> str: - if isinstance(tool_call, dict): - return str(tool_call.get("id") or tool_call.get("tool_call_id") or "").strip() - return str(getattr(tool_call, "id", "") or "").strip() - - -def unanswered_tool_call_ids(lc_messages: list) -> set[str]: - declared: set[str] = set() - answered: set[str] = set() - for message in lc_messages: - if isinstance(message, AIMessage): - for tool_call in message.tool_calls or []: - call_id = _tool_call_id(tool_call) - if call_id: - declared.add(call_id) - elif isinstance(message, ToolMessage) and message.tool_call_id: - answered.add(str(message.tool_call_id)) - return declared - answered - - -def enrich_resume_tool_result(result: str, *, proposals_applied: bool = False) -> str: - """为 resume 的客户端工具结果补上 next_hint(已有则保留)。 - - proposals_applied=True(用户已 Keep All)时同步修正 proposal_pending / - file_written 标志,避免模型把已落盘提案误判为未确认。 - """ - try: - data = json.loads(result) - except json.JSONDecodeError: - return result - if not isinstance(data, dict): - return result - # 谎报防护:skipped/未生成提案的 mutate(Dismiss 后或被门禁拦下)不能标成已写盘。 - if proposals_applied and data.get("proposal_pending") is True: - data["proposal_pending"] = False - data["file_written"] = True - data["next_hint"] = RESUME_APPLIED_NEXT_HINT - elif not str(data.get("next_hint") or "").strip(): - data["next_hint"] = RESUME_NEXT_HINT - return json.dumps(data, ensure_ascii=False) - - -def append_client_tool_results_to_messages( - lc_messages: list, - client_tool_results: list, - *, - user_content: str = "", - proposals_applied: bool = False, -) -> list: - """Resume 时补齐客户端工具的 ToolMessage;已有对应 AIMessage 时不再重复插入。""" - if not client_tool_results: - return lc_messages - - unanswered = unanswered_tool_call_ids(lc_messages) - - for ctr in client_tool_results: - result = enrich_resume_tool_result(ctr.result, proposals_applied=proposals_applied) - if ctr.tool_call_id in unanswered: - lc_messages.append( - ToolMessage(content=result, tool_call_id=ctr.tool_call_id), - ) - unanswered.discard(ctr.tool_call_id) - continue - - args: dict = {"user_request": user_content.strip()} - try: - parsed = json.loads(ctr.result) - if isinstance(parsed, dict) and parsed.get("user_request"): - args["user_request"] = str(parsed["user_request"]) - except Exception: - pass - lc_messages.append( - AIMessage( - content="", - tool_calls=[ - { - "id": ctr.tool_call_id, - "name": ctr.name, - "args": args, - }, - ], - ), - ) - lc_messages.append( - ToolMessage(content=result, tool_call_id=ctr.tool_call_id), - ) - return lc_messages diff --git a/vendor/local-agent/app/agent/context_snapshot.py b/vendor/local-agent/app/agent/context_snapshot.py deleted file mode 100644 index 382d4b7..0000000 --- a/vendor/local-agent/app/agent/context_snapshot.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Assist 模式系统提示词组装:运行时身份、任务指令与客户端上下文快照。""" -from __future__ import annotations - -from app.schemas.agent import AnnotationProjectSnapshotInput, ClientContextInput, SkillCatalogEntryInput - -# 精简后的 prompt 常量 —— 规则由代码层承担,不靠模型理解 -VISION_HINT = """- 看图描述:按需调用 read_image_for_vision。 -- 查已有标注:read_file_annotation。""" - -_ANNOTATION_SCOPE_HINT = ( - "【标注工具】paths 填 list_workspace_directory 的 relativePath;全部文件才设 all_files=true。" -) -_ANNOTATION_KNOWN_TYPES: frozenset[str] = frozenset( - { - "bbox", - "caption", - "classification", - "polygon", - "keypoint", - "rotated_bbox", - "span_ner", - "text_classification", - "instruction", - "preference", - "conversation", - "cot", - } -) -_ANNOTATION_TYPE_EXTRAS: dict[str, str] = { - "polygon": "需配置检测模型与 SAM2 分割模型。", - "keypoint": "需先选择骨架模板并配置关键点模型。", - "rotated_bbox": "需配置 OBB 检测模型。", -} - -# 不需要展示检测模型列表的标注类型 -_DETECTION_MODEL_TYPES: frozenset[str] = frozenset({"bbox", "polygon", "keypoint", "rotated_bbox"}) - -WORKSPACE_ASSIST_TASK = """【任务】在 LR-Agent 内回答用户问题,按需使用工具完成读/写/分析操作。 -{vision_hint} -- 大范围摸底用 explore_readonly;独立摸底可一次发起多个(不同 query / focus_path),它们会并行执行。标注与改文件必须由主 Agent 调对应工具。 -- 用自然、简洁的中文回复。 -- 调用工具前先用一两句中文说明下一步要做什么。""" - -ASSISTANT_TASK_BASE = """【任务】在 LR-Agent 内完成问答、标注、分析、写文件。 -- 新增/重写标注用 auto_annotate;改已有标注用 mutate_annotation。不要用写文件工具保存标注。 -- 写文件工具只用于工作区文档与代码。 -- 大范围摸底用 explore_readonly;独立摸底可一次发起多个(不同 query / focus_path),它们会并行执行。标注与改文件必须由主 Agent 调对应工具。 -{vision_hint} -- 未收到工具返回前,禁止输出执行结果、统计数字或完成声明;不得用文本假装执行了工具。 -- 报告与汇总中的每个数字必须来自工具返回或提案明细,禁止估算或凭印象填写。 -- 用简洁中文回复;调用工具前用一两句说明下一步。""" - -def format_runtime_identity_block( - *, - model: str, - provider_label: str = "", - supports_vision: bool = False, -) -> str: - label = provider_label.strip() or "(未命名提供商)" - if supports_vision: - vision_line = "视觉能力:已通过 API 探针,可调用 read_image_for_vision 并在调用后查看附图。" - else: - vision_line = ( - "视觉能力:未通过探针或未检测。不要声称能分析图片像素;" - "若用户要看图,说明需在「大模型配置」中选用支持视觉的模型并重新检测视觉。" - ) - return ( - "【你的身份】\n" - f"- 你是后端模型 `{model}`(配置名称:{label})。\n" - f"- {vision_line}\n" - "- 你在 LR-Agent 系统内与用户对话、调用工具完成任务;不要把自己说成独立的「LR-Agent 助手」或其它品牌模型。" - ) - - -def format_snapshot_for_prompt(snapshot: AnnotationProjectSnapshotInput) -> str: - labels = snapshot.labels or [] - label_lines = [ - f"- {item.get('id', '')}: {item.get('name', '')}" - for item in labels[:40] - if isinstance(item, dict) - ] - annotation_type = snapshot.annotation_type or "" - lines = [ - f"项目 ID: {snapshot.project_id}", - f"项目名称: {snapshot.name}", - f"模态: {snapshot.modality}", - f"标注类型: {annotation_type}", - "标签列表:", - *(label_lines or ["- (无)"]), - ] - - # 仅在目标检测类标注类型时展示可用的检测模型 - if annotation_type in _DETECTION_MODEL_TYPES: - models = snapshot.detection_models or [] - model_lines = [ - f"- {m.get('id', '')}: {m.get('name', '')}" - + (" (默认)" if m.get("is_default") else "") - for m in models[:12] - if isinstance(m, dict) - ] - lines.append("可用检测模型(object_detection):") - lines.extend(model_lines or ["- (未配置或未传入)"]) - - if annotation_type in _ANNOTATION_KNOWN_TYPES: - extra = _ANNOTATION_TYPE_EXTRAS.get(annotation_type) - lines.append( - f"{_ANNOTATION_SCOPE_HINT} {extra}" if extra else _ANNOTATION_SCOPE_HINT - ) - - return "\n".join(lines) - - -# 文件编辑纪律:仅在允许写工作区的模式下注入(见 build_workspace_assistant_system_prompt) -FILE_EDIT_GUIDE = """【文件编辑纪律】 -- 修改已有文件的局部内容时,必须优先用 str_replace_workspace_file(old_string → new_string),禁止用 write_workspace_file 整文件重写来改几行。 -- 编辑前必须先 read_workspace_file 看原文;old_string 从真实原文中原样复制(含缩进与空白),禁止凭记忆编写。 -- old_string 取最小但唯一的片段:太短容易命中多处,必要时前后多带几行上下文。 -- new_string 保持与上下文一致的缩进与换行风格;替换后的全文必须是合法代码/文档。 -- 同一文件多处修改:连续多次调用 str_replace_workspace_file(提案自动累积为一份),不要为省事整文件重写。""" - - -# 自动标注填参纪律:仅在标注模式的 Agent 里注入(auto_annotate 只在 FULL_TOOL_SET 出现)。 -# 这些「何时填」的规则从 AutoAnnotateArgs 的字段描述里搬来,集中一份、留在稳定前缀里, -# 避免同一套纪律在 11 个字段描述里重复携带。 -ANNOTATION_CALL_GUIDE = """【标注调用纪律】(auto_annotate) -- paths 取自 list_workspace_directory 的 relativePath,目录以 / 结尾(如 data/);用户点了文件或文件夹必须填 paths。 -- all_files 仅在用户明确要求标注整个项目/全部文件时为 true;未确认时禁止默认为 true。 -- 用户说“重写 / 重新标注 / 每文件只留一条”用 write_mode=replace_matching,否则用 append。 -- conf_threshold / iou_threshold 仅在用户明确给出数值时填,否则留空用模型默认值。 -- model_id 仅在用户点名检测模型时填(可用模型见 describe_annotation_project)。 -- include_classes / exclude_classes 仅在用户说“只标 X / 不要 Y”时填,类名以检测模型输出为准。 -- use_vision_mapping 留空由系统按标签情况决定;标签为实例/细粒度(球员名等)时填 true。 -- 所有参数以用户本轮原话为依据,禁止凭猜测补值。""" - - -def build_workspace_assistant_system_prompt(client_context: ClientContextInput | None) -> str: - task = WORKSPACE_ASSIST_TASK.format(vision_hint=VISION_HINT) - parts = [task, FILE_EDIT_GUIDE] - if client_context is None: - return "\n".join(parts) - if client_context.workspace_root: - parts.append(f"\n【工作区】\n根目录: {client_context.workspace_root}") - if client_context.active_file_path: - parts.append(f"当前打开文件: {client_context.active_file_path}") - return "\n".join(parts) - - -def build_project_assistant_system_prompt(client_context: ClientContextInput | None) -> str: - task = ASSISTANT_TASK_BASE.format(vision_hint=VISION_HINT) - if client_context is None or client_context.annotation_project_snapshot is None: - return task - snap = client_context.annotation_project_snapshot - return ( - f"{task}\n\n" - f"【当前标注项目快照】\n{format_snapshot_for_prompt(snap)}" - ) - - -def format_skills_catalog_block( - skills: list[SkillCatalogEntryInput] | None, -) -> str: - """把全局 skills catalog(name + description)格式化为 prompt 块;空时不输出。""" - if not skills: - return "" - lines = [ - "【可用 Skills】", - "以下为可用的任务工作流 Skills。当用户请求与某 skill 的 description 匹配时," - "先调用 read_agent_skill(skill_name) 读取该 SKILL.md 正文,再按其中步骤执行;" - "需要附属资料时用 list_agent_skill_files(skill_name) 查看文件," - "再用 read_agent_skill(skill_name, relative_path) 读取;" - "不要凭名字猜测内容。脚本仅可阅读源码,不可执行。", - ] - for item in skills: - name = (item.name or "").strip() - desc = (item.description or "").strip() - if not name or not desc: - continue - lines.append(f"- {name}: {desc}") - return "\n".join(lines) - - -def build_assist_system_prompt( - client_context: ClientContextInput | None, - *, - model: str, - provider_label: str = "", - supports_vision: bool = False, -) -> str: - identity = format_runtime_identity_block( - model=model, - provider_label=provider_label, - supports_vision=supports_vision, - ) - if client_context and client_context.work_mode == "editor": - task = build_workspace_assistant_system_prompt(client_context) - elif client_context and client_context.annotation_project_snapshot is not None: - task = build_project_assistant_system_prompt(client_context) - # 标注 Agent 才有 auto_annotate;编辑器模式已被 registry 剥掉标注工具,无需注入。 - if client_context.agent_mode == "annotation": - task = f"{task}\n\n{ANNOTATION_CALL_GUIDE}" - elif client_context and (client_context.workspace_root or "").strip(): - task = build_workspace_assistant_system_prompt(client_context) - else: - task = WORKSPACE_ASSIST_TASK.format(vision_hint=VISION_HINT) - editor_note = "" - if client_context and client_context.work_mode == "editor": - if client_context.agent_mode == "annotation": - editor_note = ( - "\n【编辑器模式】当前为编辑器 Agent:禁止调用标注读写、批量标注、标注变更相关工具;" - "可使用 read_workspace_file、write_workspace_file、" - "str_replace_workspace_file、move_workspace_file、read_document_file 等通用工具。" - ) - else: - editor_note = ( - "\n【编辑器模式】当前为编辑器 Ask:只读问答与分析。" - "禁止调用写文件、标注读写、批量标注与标注变更相关工具。" - ) - base = f"{identity}\n\n{task}" - if editor_note: - base = f"{base}{editor_note}" - instructions = (client_context.project_instructions or "").strip() if client_context else "" - if instructions: - base = f"{base}\n\n【项目指令】\n{instructions}" - # 块顺序按“稳定在前、动态沉底”排列,让 system prompt 长前缀跨轮保持一致, - # 以命中 DeepSeek / OpenAI 等服务端的前缀缓存:skills(稳定)先于记忆与台账(动态)。 - skills = (client_context.skills_catalog or []) if client_context else [] - skills_block = format_skills_catalog_block(skills) - if skills_block: - base = f"{base}\n\n{skills_block}" - memory_enabled = bool( - client_context and client_context.workspace_memory_enabled - ) - if memory_enabled: - memory_index = ( - (client_context.memory_index or "").strip() if client_context else "" - ) - index_block = ( - memory_index - if memory_index - else "(尚无工作区记忆文件。进度与已标文件将在用户确认或保存标注后由系统生成。)" - ) - base = ( - f"{base}\n\n【工作区记忆】\n{index_block}\n" - "这是本标注任务的工作区记忆,可有多个 Markdown 文件。" - "progress.md 与 annotated-files.md 由系统在用户确认或标注落盘后更新;" - "需要细节时用 memory_read,不要凭印象改计数。" - "memory_create / memory_write 只用于用户规范、标注偏好与纠正" - "(如 conventions.md、preferences.md);不要把未确认提案写成已完成。" - "项目指令优先级高于记忆。" - ) - ledger = (client_context.proposal_ledger or "").strip() if client_context else "" - if ledger: - base = f"{base}\n\n{ledger}" - return base diff --git a/vendor/local-agent/app/agent/stream_adapter.py b/vendor/local-agent/app/agent/stream_adapter.py deleted file mode 100644 index 5f88f36..0000000 --- a/vendor/local-agent/app/agent/stream_adapter.py +++ /dev/null @@ -1,48 +0,0 @@ -from langchain_core.messages import AIMessageChunk - -from app.schemas.agent import StreamEventPayload - - -def events_from_chunk( - chunk: AIMessageChunk, - *, - emit_tool_chunks: bool = True, -) -> list[StreamEventPayload]: - events: list[StreamEventPayload] = [] - content = chunk.content - if isinstance(content, str) and content: - events.append(StreamEventPayload(type="text_delta", content=content)) - elif isinstance(content, list): - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - text = part.get("text", "") - if text: - events.append(StreamEventPayload(type="text_delta", content=text)) - - additional = getattr(chunk, "additional_kwargs", None) or {} - reasoning = additional.get("reasoning_content") - if reasoning: - events.append(StreamEventPayload(type="reasoning_delta", content=str(reasoning))) - - if not emit_tool_chunks: - return events - - tool_chunks = chunk.tool_call_chunks or [] - for tool_chunk in tool_chunks: - tool_id = tool_chunk.get("id") or tool_chunk.get("index") - name = tool_chunk.get("name") or "tool" - args = tool_chunk.get("args") or "" - if isinstance(args, dict): - import json - - args = json.dumps(args, ensure_ascii=False) - events.append( - StreamEventPayload( - type="tool_start", - tool_call_id=str(tool_id) if tool_id is not None else "tool-pending", - name=str(name), - arguments=str(args), - ), - ) - - return events diff --git a/vendor/local-agent/app/agent/text_sanitize.py b/vendor/local-agent/app/agent/text_sanitize.py deleted file mode 100644 index e2b9fb8..0000000 --- a/vendor/local-agent/app/agent/text_sanitize.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Unicode normalization for LLM-generated text.""" - -from __future__ import annotations - -from typing import Any - - -def sanitize_unicode_text(text: str) -> str: - """Remove lone UTF-16 surrogates that break downstream parsers.""" - if not text: - return "" - text = text.encode("utf-8", "surrogatepass").decode("utf-8", "replace") - return text.replace("\ufffd", "") - - -def sanitize_json_value(value: Any) -> Any: - """递归清洗快照/JSON 中的字符串,避免沙箱 UTF-8 写盘失败。""" - if isinstance(value, str): - return sanitize_unicode_text(value) - if isinstance(value, dict): - return { - sanitize_unicode_text(k) if isinstance(k, str) else k: sanitize_json_value(v) - for k, v in value.items() - } - if isinstance(value, list): - return [sanitize_json_value(item) for item in value] - if isinstance(value, tuple): - return tuple(sanitize_json_value(item) for item in value) - return value diff --git a/vendor/local-agent/app/agent/tool_dispatcher.py b/vendor/local-agent/app/agent/tool_dispatcher.py deleted file mode 100644 index b98d80f..0000000 --- a/vendor/local-agent/app/agent/tool_dispatcher.py +++ /dev/null @@ -1,37 +0,0 @@ -"""统一工具调度:按 registry runner 拆分 API 调用结果。""" - -from __future__ import annotations - -from dataclasses import dataclass - -from app.agent.tool_invocation import ResolvedToolCall, normalize_api_tool_calls -from app.agent.tools.tool_registry_meta import ToolRunner, get_tool_runner - - -@dataclass(frozen=True) -class SplitToolCalls: - immediate: list[ResolvedToolCall] - async_pending: list[ResolvedToolCall] - - -def split_resolved_calls(calls: list[ResolvedToolCall]) -> SplitToolCalls: - immediate: list[ResolvedToolCall] = [] - async_pending: list[ResolvedToolCall] = [] - for call in calls: - if get_tool_runner(call.name) is ToolRunner.ASYNC: - async_pending.append(call) - else: - immediate.append(call) - return SplitToolCalls(immediate=immediate, async_pending=async_pending) - - -def resolve_round_tool_calls( - *, - api_tool_calls: list, - completed_tools: frozenset[str] | None = None, -) -> list[ResolvedToolCall]: - """仅使用 LLM API tool_calls 解析,不再回退伪代码。""" - return normalize_api_tool_calls( - api_tool_calls, - completed_tools=completed_tools, - ) diff --git a/vendor/local-agent/app/agent/tool_invocation.py b/vendor/local-agent/app/agent/tool_invocation.py deleted file mode 100644 index c167fb8..0000000 --- a/vendor/local-agent/app/agent/tool_invocation.py +++ /dev/null @@ -1,59 +0,0 @@ -"""工具调用解析:归一化 LLM API 返回的 tool_calls。""" - -from __future__ import annotations - -import re -import uuid -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ResolvedToolCall: - tool_call_id: str - name: str - arguments: dict - source: str # api - - -def client_tool_mentioned_in_text(text: str) -> str | None: - from app.agent.tools.tool_registry_meta import ASYNC_TOOL_NAMES - - for name in sorted(ASYNC_TOOL_NAMES, key=len, reverse=True): - if re.search(rf"{re.escape(name)}\s*\(", text, re.IGNORECASE): - return name - return None - - -def normalize_api_tool_calls( - api_tool_calls: list, - *, - completed_tools: frozenset[str] | None = None, -) -> list[ResolvedToolCall]: - completed = completed_tools or frozenset() - calls: list[ResolvedToolCall] = [] - for call in api_tool_calls or []: - name = str(call.get("name") or "").strip() - if not name: - continue - tool_id = str(call.get("id") or "").strip() - if tool_id and tool_id in completed: - continue - if not tool_id: - tool_id = f"tool-{uuid.uuid4().hex[:12]}" - args = call.get("args") or {} - if not isinstance(args, dict): - try: - import json - - args = json.loads(args) if args else {} - except Exception: - args = {} - calls.append( - ResolvedToolCall( - tool_call_id=tool_id, - name=name, - arguments=args if isinstance(args, dict) else {}, - source="api", - ) - ) - return calls diff --git a/vendor/local-agent/app/agent/tools/__init__.py b/vendor/local-agent/app/agent/tools/__init__.py deleted file mode 100644 index 4a65f3a..0000000 --- a/vendor/local-agent/app/agent/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.agent.tools.registry import build_tools_p1 - -__all__ = ["build_tools_p1"] diff --git a/vendor/local-agent/app/agent/tools/help.py b/vendor/local-agent/app/agent/tools/help.py deleted file mode 100644 index 2867a25..0000000 --- a/vendor/local-agent/app/agent/tools/help.py +++ /dev/null @@ -1,33 +0,0 @@ -"""LR-Agent 应用功能说明,供 get_lr_agent_help 工具返回。""" - -LR_AGENT_HELP = """# LR-Agent 功能概览 - -- **资源管理器**:浏览本地文件夹与文件 -- **标注任务**:创建与管理图像标注项目 -- **预训练模型**:配置用于辅助标注的模型 -- **大模型配置**:添加 OpenAI 兼容 API(如通义、DeepSeek) -- **Agent 面板**:右侧对话助手,可搜索/读取工作区代码与文档、看图、查已有标注、写文件提案(确认后落盘);大范围摸底可用 explore_readonly - -**Ask 模式**:问答、搜索代码、读文本/文档、看图;标注任务中还可查已有标注。不修改文件、不写入标注。 -**编辑器 Agent**:可改代码/文档(提案确认后落盘),不能标注。 -**标注任务 Agent**:批量检测与提案;标注变更(改标签/删框);报告/文档(Markdown)。 -执行批量写入或变更标注请传入明确 paths(或用户明确要求全部时 all_files=true);查某张图已有标注在 Ask 即可。""" - - -def get_lr_agent_help(topic: str | None = None) -> str: - """按 topic 关键词返回功能说明;无 topic 时返回完整概览。""" - if not topic or not topic.strip(): - return LR_AGENT_HELP - needle = topic.strip().lower() - if "标注" in needle or "annotation" in needle: - return ( - "标注任务在左侧活动栏「标注任务」中创建。" - " **Ask**:分析与建议,不修改文件、不执行批量标注。" - "**标注 Agent**:新增/重写用 auto_annotate,改已有标注用 mutate_annotation。" - "**编辑器 Agent**:只改代码/文档,不能标注。" - ) - if "模型" in needle or "model" in needle: - return "预训练模型在左侧「预训练模型」面板配置,供标注工作区推理使用。" - if "agent" in needle or "对话" in needle: - return "Agent 在右侧活动栏打开,在「大模型配置」中添加 OpenAI 兼容 API Key 即可使用。" - return LR_AGENT_HELP diff --git a/vendor/local-agent/app/agent/tools/mcp_client.py b/vendor/local-agent/app/agent/tools/mcp_client.py deleted file mode 100644 index c0c5846..0000000 --- a/vendor/local-agent/app/agent/tools/mcp_client.py +++ /dev/null @@ -1,333 +0,0 @@ -"""后端 MCP 客户端:连接本机 Electron MCP Server 与用户配置的远程 MCP,动态获取工具并注入 Agent 工具集。 - -使用 langchain-mcp-adapters 将 MCP 工具 schema 转换为 LangChain StructuredTool。 -本机 Server 地址由 client_context.mcp_server_url 传入; -远程 Server(userData/mcp.json 已启用项)由 client_context.mcp_servers 传入, -支持 streamable_http 与 sse 两种传输。 - -工具发现流程: - 1. 逐台连接 MCP Server(本机强制 /mcp 端点;远程 URL 原样使用) - 2. 调用 get_tools() 获取所有工具 - 3. 按 ToolCapability + 名称去重后返回 list[StructuredTool](不与内置工具能力冲突) - -已知 MCP 工具(前端 server.ts 暴露): - - memory_read 读取工作区记忆 topic 文件(需任务开关) - - memory_write 覆盖已有工作区记忆 topic(需任务开关) - - memory_create 新建工作区记忆 topic(需任务开关) - - read_agent_skill 读取全局 Agent Skill 的 SKILL.md 或附属文本文件 - - list_agent_skill_files 列出 skill 目录内文件(只读,不执行脚本) - (二者走默认 SYNC runner,与 canonical 无能力冲突) -""" - -from __future__ import annotations - -import asyncio -import logging -import time - -from langchain_core.tools import StructuredTool - -from app.agent.tools.tool_registry_meta import ( - CANONICAL_CAPABILITIES, - ToolCapability, -) - -logger = logging.getLogger(__name__) - -# 工具发现 TTL 缓存:key 为连接指纹(url + transport + 排序后的 headers), -# value 为 (原始发现的工具列表, 过期时间)。disabled_tools 过滤与跨 server 去重 -# 在缓存之后执行,运行时改配置无需失效缓存。 -_tools_cache: dict[tuple, tuple[list[StructuredTool], float]] = {} - -# 过期缓存的后台刷新:_refresh_tasks 持有 task 强引用(否则可能被 GC 回收), -# _refreshing 按连接指纹去重,避免同一 server 并发重复刷新。 -_refresh_tasks: set[asyncio.Task] = set() -_refreshing: set[tuple] = set() - - -def _cache_key(conn: dict) -> tuple: - headers = conn.get("headers") or {} - return ( - conn.get("url", ""), - conn.get("transport", ""), - tuple(sorted(headers.items())), - ) - - -async def _discover_tools(name: str, conn: dict) -> list[StructuredTool]: - from langchain_mcp_adapters.client import MultiServerMCPClient - - client = MultiServerMCPClient({name: conn}) - return await client.get_tools() - - -def _store_tools(key: tuple, tools: list[StructuredTool], ttl_seconds: float) -> None: - if ttl_seconds > 0: - _tools_cache[key] = (tools, time.monotonic() + ttl_seconds) - - -def _schedule_refresh(name: str, conn: dict, ttl_seconds: float) -> None: - """后台重新发现(不阻塞本轮请求);失败沿用旧条目。""" - key = _cache_key(conn) - if key in _refreshing: - return - _refreshing.add(key) - - async def _run() -> None: - try: - tools = await _discover_tools(name, conn) - except Exception as exc: # noqa: BLE001 — 任何失败都只降级为沿用旧列表 - logger.warning( - "MCP: 后台刷新 %s (%s) 失败,沿用旧工具列表:%s", - name, - conn.get("url"), - exc, - ) - return - finally: - _refreshing.discard(key) - _store_tools(key, tools, ttl_seconds) - logger.info( - "MCP: 后台刷新 %s 完成,%d 个工具", name, len(tools) - ) - - task = asyncio.create_task(_run()) - _refresh_tasks.add(task) - task.add_done_callback(_refresh_tasks.discard) - - -def clear_mcp_tools_cache() -> None: - """清空工具发现缓存(测试或强制刷新用)。""" - for task in list(_refresh_tasks): - task.cancel() - _refresh_tasks.clear() - _refreshing.clear() - _tools_cache.clear() - - -def should_expose_mcp_tool( - tool_name: str, - *, - workspace_memory_enabled: bool, - agent_mode: str | None = None, -) -> bool: - """工作区记忆关闭时不向 Agent 暴露 memory_*,保留 read_agent_skill。 - - Ask(agent_mode 非 annotation)只暴露 memory_read,禁止 memory_write / memory_create。 - """ - if tool_name.startswith("memory_"): - if not workspace_memory_enabled: - return False - if agent_mode != "annotation": - return tool_name == "memory_read" - return True - return True - - -def _infer_mcp_capability(tool_name: str) -> ToolCapability | None: - """从 MCP 工具名称推断其能力类型。 - - 当前 Electron MCP 工具(memory_* / read_agent_skill / list_agent_skill_files) - 与内置能力不冲突,返回 None。 - 若将来再暴露与 canonical 重叠的名字,在此登记以免双轨注入。 - """ - _ = tool_name - return None - - -def _local_connection(mcp_server_url: str, token: str | None = None) -> dict: - """本机 Electron MCP Server 连接参数(端点固定 /mcp,见 src/main/mcp/server.ts)。 - - token 由 client_context.mcp_server_token 传入,作为 Bearer 头注入。 - """ - conn: dict = { - "url": mcp_server_url.rstrip("/") + "/mcp", - "transport": "streamable_http", - "timeout": 60, - "sse_read_timeout": 300, - "terminate_on_close": True, - } - if token and token.strip(): - conn["headers"] = {"Authorization": f"Bearer {token.strip()}"} - return conn - - -def _remote_connection(server) -> dict: - """远程 MCP Server 连接参数:URL 原样使用(不拼 /mcp),协议按配置。""" - transport = getattr(server, "transport", None) or "streamable_http" - conn: dict = { - "url": (getattr(server, "url", "") or "").strip(), - "transport": transport if transport in ("streamable_http", "sse") else "streamable_http", - "timeout": 60, - "sse_read_timeout": 300, - } - headers = getattr(server, "headers", None) - if headers: - conn["headers"] = dict(headers) - return conn - - -def _filter_tools( - tools: list[StructuredTool], - *, - existing_capabilities: set[ToolCapability], - seen_names: set[str], -) -> tuple[list[StructuredTool], list[str]]: - """按能力 + 名称去重:跳过已有 canonical 实现及前序 server 已提供的工具。""" - filtered: list[StructuredTool] = [] - skipped: list[str] = [] - for t in tools: - cap = _infer_mcp_capability(t.name) - if cap is not None and cap in existing_capabilities: - skipped.append(t.name) - continue - if t.name in seen_names: - skipped.append(t.name) - continue - seen_names.add(t.name) - filtered.append(t) - return filtered, skipped - - -async def load_mcp_tools_from_servers( - local_server_url: str | None, - remote_servers: list | None = None, - *, - local_server_token: str | None = None, - existing_capabilities: set[ToolCapability] | None = None, - ttl_seconds: float = 300.0, -) -> list[StructuredTool]: - """连接本机 + 远程 MCP Server,动态发现并按能力/名称去重后返回工具列表。 - - 发现结果按连接指纹缓存 ttl_seconds(<=0 关闭缓存);未命中的 server 并行 - 建连,单台失败仅跳过该 server,不影响其他 server 与 Agent 主流程。 - 缓存过期时走 stale-while-revalidate:本轮用旧列表继续,后台刷新下一轮生效, - 因此只有应用启动后的首轮请求需要同步等待发现。 - """ - if existing_capabilities is None: - existing_capabilities = CANONICAL_CAPABILITIES - - try: - from langchain_mcp_adapters.client import MultiServerMCPClient - except ImportError: - logger.warning("langchain-mcp-adapters 未安装,跳过 MCP 工具加载") - return [] - - connections: dict[str, dict] = {} - disabled_by_conn: dict[str, set[str]] = {} - if local_server_url and local_server_url.strip(): - connections["lr-agent-local"] = _local_connection( - local_server_url, local_server_token - ) - for server in remote_servers or []: - server_id = getattr(server, "id", "") or f"remote-{len(connections)}" - conn = _remote_connection(server) - if not conn["url"]: - continue - conn_name = f"mcp-remote-{server_id}" - connections[conn_name] = conn - disabled_by_conn[conn_name] = { - name - for name in (getattr(server, "disabled_tools", None) or []) - if isinstance(name, str) and name.strip() - } - - now = time.monotonic() - raw_by_conn: dict[str, list[StructuredTool]] = {} - pending: dict[str, dict] = {} - for name, conn in connections.items(): - key = _cache_key(conn) - cached = _tools_cache.get(key) if ttl_seconds > 0 else None - if cached is None: - # 无缓存(首轮):只能同步发现 - pending[name] = conn - continue - raw_by_conn[name] = cached[0] - if cached[1] <= now: - # 过期但可用:先返回旧列表,后台刷新,别让本轮首 token 等重建连 - _schedule_refresh(name, conn, ttl_seconds) - - if pending: - results = await asyncio.gather( - *(_discover_tools(name, conn) for name, conn in pending.items()), - return_exceptions=True, - ) - for (name, conn), result in zip(pending.items(), results): - if isinstance(result, BaseException): - logger.warning( - "MCP: 连接 %s (%s) 失败,跳过该 server:%s", - name, - conn.get("url"), - result, - ) - continue - raw_by_conn[name] = result - _store_tools(_cache_key(conn), result, ttl_seconds) - - tools_all: list[StructuredTool] = [] - seen_names: set[str] = set() - skipped_all: list[str] = [] - for name in connections: - tools = raw_by_conn.get(name) - if tools is None: - continue - disabled = disabled_by_conn.get(name) or set() - if disabled: - skipped_all.extend(t.name for t in tools if t.name in disabled) - tools = [t for t in tools if t.name not in disabled] - filtered, skipped = _filter_tools( - tools, - existing_capabilities=existing_capabilities, - seen_names=seen_names, - ) - tools_all.extend(filtered) - skipped_all.extend(skipped) - - if tools_all or skipped_all: - logger.info( - "MCP: 已加载 %d 个工具:%s (跳过: %s)", - len(tools_all), - [t.name for t in tools_all], - skipped_all or ["无"], - ) - return tools_all - - -async def load_mcp_tools_from_server( - mcp_server_url: str, - *, - existing_capabilities: set[ToolCapability] | None = None, -) -> list[StructuredTool]: - """连接本地 MCP Server,动态发现并按能力去重后返回工具列表。 - - 失败时返回空列表(不影响 Agent 正常运行)。 - """ - return await load_mcp_tools_from_servers( - mcp_server_url, - None, - existing_capabilities=existing_capabilities, - ) - - -async def probe_mcp_tools( - url: str, - *, - transport: str = "streamable_http", - headers: dict[str, str] | None = None, -) -> list[StructuredTool]: - """连接单个 MCP Server 并列出工具(配置页「测试连接」用)。 - - 失败时抛出异常,由调用方转为错误响应。 - """ - from langchain_mcp_adapters.client import MultiServerMCPClient - - conn: dict = { - "url": url.strip(), - "transport": transport if transport in ("streamable_http", "sse") else "streamable_http", - "timeout": 30, - "sse_read_timeout": 60, - } - if headers: - conn["headers"] = dict(headers) - client = MultiServerMCPClient({"mcp-probe": conn}) - return await client.get_tools() diff --git a/vendor/local-agent/app/agent/tools/registry.py b/vendor/local-agent/app/agent/tools/registry.py deleted file mode 100644 index 84b4309..0000000 --- a/vendor/local-agent/app/agent/tools/registry.py +++ /dev/null @@ -1,589 +0,0 @@ -"""Assist 模式工具注册表。 - -由 orchestrator 调用 build_tools_p1 构建工具列表,assist_service 通过 tool_fn_map 按名称执行。 -工具分三类: - - 上下文查询:账户、帮助、界面状态、标注项目快照 - - 文件读取/写入:文本/代码、文档、图片(视觉)、已有标注 JSON、写文件提案 - - 异步工具(ASYNC_TOOL_NAMES):由前端 Electron 执行;调度器发出 tool_pending SSE 并暂停 loop -""" - -import json -from collections.abc import Callable -from typing import Any, Literal - -from langchain_core.tools import StructuredTool -from pydantic import BaseModel, Field - -from app.agent.tools.tool_registry_meta import ( - TOOL_CAPABILITY_MAP, - ToolCapability, -) - -from app.agent.annotation.annotation_doc_reader import read_file_annotation_doc -from app.agent.context_helpers import project_directory -from app.agent.context_snapshot import format_snapshot_for_prompt -from app.agent.tools.help import get_lr_agent_help -from app.agent.tools.workspace_file_reader import ( - read_document_file, - read_image_for_vision_tool, - read_workspace_text_file, - str_replace_workspace_file_tool, - delete_workspace_file_tool, - move_workspace_file_tool, - write_workspace_file_tool, -) -from app.agent.tools.workspace_search import ( - glob_workspace, - grep_workspace, - list_workspace_directory, -) -from app.core.config import Settings -from app.models.user import User -from app.schemas.agent import ClientContextInput - -ANNOTATION_TOOL_NAMES: frozenset[str] = frozenset( - { - "describe_annotation_project", - "read_file_annotation", - "auto_annotate", - "mutate_annotation", - } -) - - -# Pydantic args_schema for client tools — forces LLM to include user_request as a required param -def _strip_titles(node: Any) -> Any: - """递归剥掉 JSON schema 里的 title:pydantic 自动生成,键名已表达同一语义。""" - if isinstance(node, dict): - node.pop("title", None) - for value in node.values(): - _strip_titles(value) - elif isinstance(node, list): - for value in node: - _strip_titles(value) - return node - - -class SlimArgsModel(BaseModel): - """工具参数基类:schema 不带 title(每字段省 ~30 字符的工具前缀)。""" - - @classmethod - def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict: - return _strip_titles(super().model_json_schema(*args, **kwargs)) - - -# 字段描述只写参数语义;「何时填」的行为纪律在标注模式的系统提示块里 -# (context_snapshot.ANNOTATION_CALL_GUIDE),避免为每个工具调用重复携带。 -class AutoAnnotateArgs(SlimArgsModel): - user_request: str = Field( - min_length=1, - description="本轮标注任务说明(按用户意图归纳)", - ) - paths: list[str] = Field( - default_factory=list, - description="要标注的相对路径,取自 list_workspace_directory 的 relativePath;目录以 / 结尾", - ) - all_files: bool = Field( - default=False, - description="是否标注整个项目全部文件(仅用户明确要求时为 true)", - ) - write_mode: Literal["append", "replace_matching"] = Field( - default="append", - description="append 追加新标注;replace_matching 替换同类型已有标注", - ) - scope_hint: str | None = Field( - default=None, - description="兼容旧参数:逗号分隔的相对路径,优先使用 paths", - ) - conf_threshold: float | None = Field( - default=None, - ge=0.0, - le=1.0, - description="检测置信度阈值(0-1),用户明确给出时填", - ) - iou_threshold: float | None = Field( - default=None, - ge=0.0, - le=1.0, - description="检测 NMS IoU 阈值(0-1),用户明确给出时填", - ) - model_id: str | None = Field( - default=None, - description="指定检测模型 id,仅用户点名模型时填", - ) - include_classes: list[str] | None = Field( - default=None, - description='只保留这些检测类名的框(如 ["person"])', - ) - exclude_classes: list[str] | None = Field( - default=None, - description="排除这些检测类名的框", - ) - use_vision_mapping: bool | None = Field( - default=None, - description="是否用视觉模型把检测框映射到项目标签;留空由系统决定", - ) - - -class MutateAnnotationArgs(SlimArgsModel): - user_request: str = Field(min_length=1, description="必须原样传递用户的原始请求") - paths: list[str] | None = Field( - default=None, - description="要修改的文件相对路径。有明确文件时必须填写。", - ) - annotation_ids: list[str] | None = Field( - default=None, - description="画布选中或用户指定的标注 id。", - ) - - -class StrReplaceArgs(SlimArgsModel): - relative_path: str = Field(min_length=1, description="要修改的相对路径") - old_string: str = Field(min_length=1, description="文件中必须唯一出现的原文片段") - new_string: str = Field(description="替换后的文本") - replace_all: bool = Field( - default=False, - description="为 true 时替换全部出现;默认要求 old_string 只出现一次", - ) - - -class DeleteWorkspaceFileArgs(SlimArgsModel): - relative_path: str = Field(min_length=1, description="要删除的相对路径") - - -class MoveWorkspaceFileArgs(SlimArgsModel): - relative_path: str = Field(min_length=1, description="原相对路径") - new_relative_path: str = Field( - min_length=1, - description="新相对路径(同目录改名为重命名,跨目录为移动)", - ) - - -class ExploreReadonlyArgs(SlimArgsModel): - query: str = Field(min_length=1, description="要查阅的问题或目标") - focus_path: str | None = Field( - default=None, - description="可选,优先查阅的相对路径或目录", - ) - - -def _explore_readonly_stub(query: str, focus_path: str | None = None) -> str: - return json.dumps( - { - "ok": False, - "tool": "explore_readonly", - "status": "error", - "summary": "explore_readonly 必须由编排层执行", - }, - ensure_ascii=False, - ) - - -def _client_tool_stub(tool_name: str) -> StructuredTool: - """返回一个客户端工具的 schema 存根(func 不会被本地调用)。""" - # assist_service 在执行工具前会先检测 CLIENT_TOOL_NAMES,拦截并发出 tool_pending - def _unreachable(**_kwargs: object) -> str: # noqa: ANN001 - return f"[{tool_name}] 此工具应由前端执行,本地调用不应发生。" - _unreachable.__name__ = tool_name - return _unreachable - - -def build_tools_p1( - user: User, - client_context: ClientContextInput | None, - *, - settings: Settings, - provider_is_vision: bool = False, -) -> list[StructuredTool]: - """构建完整工具集(向后兼容别名)。""" - return _build_all_tools(user, client_context, settings=settings, provider_is_vision=provider_is_vision) - - -def build_tools_by_name_set( - user: User, - client_context: ClientContextInput | None, - tool_set: frozenset[str], - *, - settings: Settings, - provider_is_vision: bool = False, -) -> list[StructuredTool]: - """构建指定名称的工具子集。""" - all_tools = _build_all_tools(user, client_context, settings=settings, provider_is_vision=provider_is_vision) - return [t for t in all_tools if t.name in tool_set] - - -def _build_all_tools( - user: User, - client_context: ClientContextInput | None, - *, - settings: Settings, - provider_is_vision: bool = False, -) -> list[StructuredTool]: - """构建工具集:只读工具 + 写文件提案工具 + 客户端工具 schema 存根。""" - # 本轮请求内未落盘提案的内容接力(见 workspace_file_reader.PendingProposalContents), - # 工具集每次请求重建,缓存生命周期即一轮。 - pending_proposals: dict[str, tuple[str | None, str]] = {} - - def account_summary() -> str: - # 本地无状态模式(agent.py 注入匿名用户):如实说明,不展示占位账户 - if user.username == "anonymous" and str(user.email).endswith("@local"): - return ( - "本地模式:LR-Agent 本地服务无云端账户体系," - "对话、标注与配置均保存在本机。" - ) - verified = "已验证" if user.email_verified else "未验证" - name = user.display_name or user.username or "未设置" - return ( - f"邮箱: {user.email} ({verified})\n" - f"显示名: {name}\n" - f"用户名: {user.username or '未设置'}" - ) - - def describe_context() -> str: - if client_context is None: - return "客户端未提供当前界面上下文。" - parts: list[str] = [] - if client_context.workspace_root: - parts.append(f"工作区根目录: {client_context.workspace_root}") - if client_context.active_file_path: - parts.append(f"当前打开文件: {client_context.active_file_path}") - if client_context.active_relative_path: - parts.append(f"当前打开文件(相对路径): {client_context.active_relative_path}") - if client_context.active_annotation_project_id: - parts.append(f"当前标注项目 ID: {client_context.active_annotation_project_id}") - if client_context.annotation_project_modality: - parts.append(f"任务模态: {client_context.annotation_project_modality}") - if client_context.annotation_project_type: - parts.append(f"标注类型: {client_context.annotation_project_type}") - if client_context.agent_mode: - parts.append(f"交互模式: {client_context.agent_mode}") - if client_context.work_mode: - parts.append(f"工作模式: {client_context.work_mode}") - if not parts: - return "工作区已连接,但未打开具体文件或标注项目。" - return "\n".join(parts) - - def describe_annotation_project() -> str: - if client_context is None or client_context.annotation_project_snapshot is None: - return "当前未绑定标注项目快照。请确认用户已在标注任务中打开项目。" - return format_snapshot_for_prompt(client_context.annotation_project_snapshot) - - def help_tool(topic: str | None = None) -> str: - return get_lr_agent_help(topic) - - def read_file_annotation( - relative_path: str, - annotation_offset: int = 0, - annotation_limit: int = 200, - ) -> str: - project_dir = project_directory(client_context) - if not project_dir: - return "无法读取标注:未绑定项目目录。请确认已在标注任务中打开项目。" - doc, err = read_file_annotation_doc(project_dir, relative_path) - if doc is None: - return err or "未找到标注。" - - note = "" - annotations = doc.get("annotations") - if isinstance(annotations, list): - total = len(annotations) - start = max(0, annotation_offset) - limit = max(1, min(annotation_limit, 500)) - end = min(start + limit, total) - if start > 0 or end < total: - doc = {**doc, "annotations": annotations[start:end]} - note = ( - f"(共 {total} 条标注,当前返回第 {start + 1}-{end} 条;" - "可用 annotation_offset/annotation_limit 分页读取其余)\n" - ) - - text = json.dumps(doc, ensure_ascii=False, indent=2) - if len(text) > 40_000: - text = ( - text[:40_000] - + "\n…[结果已截断,请用 annotation_offset/annotation_limit 分页读取]" - ) - return note + text - - def read_workspace_file( - relative_path: str = "", - start_line: int | None = None, - end_line: int | None = None, - ) -> str: - return read_workspace_text_file( - client_context, - relative_path, - settings=settings, - start_line=start_line, - end_line=end_line, - ) - - def grep_tool( - pattern: str, - path: str = "", - glob_pattern: str = "*", - case_insensitive: bool = False, - ) -> str: - return grep_workspace( - client_context, - pattern, - path=path, - glob_pattern=glob_pattern, - case_insensitive=case_insensitive, - settings=settings, - ) - - def list_directory_tool(relative_dir: str = "") -> str: - return list_workspace_directory( - client_context, - relative_dir, - settings=settings, - ) - - def glob_tool(glob_pattern: str, relative_dir: str = "") -> str: - return glob_workspace( - client_context, - glob_pattern, - relative_dir=relative_dir, - settings=settings, - ) - - def read_image_for_vision(relative_path: str = "") -> str: - return read_image_for_vision_tool( - client_context, - relative_path, - provider_is_vision=provider_is_vision, - ) - - def read_document(relative_path: str = "") -> str: - return read_document_file(client_context, relative_path, settings=settings) - - def write_file(relative_path: str, content: str) -> str: - return write_workspace_file_tool( - client_context, relative_path, content, pending_proposals - ) - - def str_replace_file( - relative_path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - ) -> str: - return str_replace_workspace_file_tool( - client_context, - relative_path, - old_string, - new_string, - replace_all=replace_all, - pending_proposals=pending_proposals, - ) - - def delete_file(relative_path: str) -> str: - return delete_workspace_file_tool( - client_context, relative_path, pending_proposals - ) - - def move_file(relative_path: str, new_relative_path: str) -> str: - return move_workspace_file_tool( - client_context, relative_path, new_relative_path, pending_proposals - ) - - # Phase 1 工具集(只读 + 写文件提案) - tools = [ - StructuredTool.from_function( - func=account_summary, - name="get_account_summary", - description="获取当前登录用户的账户摘要(邮箱、验证状态、显示名)", - ), - StructuredTool.from_function( - func=help_tool, - name="get_lr_agent_help", - description="获取 LR-Agent 应用功能说明,可选 topic 关键词", - ), - StructuredTool.from_function( - func=describe_context, - name="describe_client_context", - description="描述用户当前 Electron 客户端界面上下文(工作区、打开文件、标注项目)", - ), - StructuredTool.from_function( - func=describe_annotation_project, - name="describe_annotation_project", - description="获取当前标注项目的标签树、任务类型、模态与可用检测模型列表", - ), - StructuredTool.from_function( - func=read_file_annotation, - name="read_file_annotation", - description=( - "读取项目中某文件的已有标注(相对路径,如 data/2.jpg)。" - "标注较多时可用 annotation_offset/annotation_limit 分页(默认返回前 200 条)。" - "只读查询,不要用写文件工具回写。" - ), - ), - StructuredTool.from_function( - func=read_workspace_file, - name="read_workspace_file", - description=( - "读取工作区或项目内的文本/代码文件内容(如 .py .ts .md .json .yaml .txt)。" - "relative_path 为空时使用当前打开文件。" - "可选 start_line / end_line(1-indexed,含首尾)读取指定行范围。" - "返回内容带行号前缀(如 ` 12|code`)。" - "找代码时建议先用 grep_workspace 或 glob_workspace 定位,再读本工具读具体行。" - ), - ), - StructuredTool.from_function( - func=grep_tool, - name="grep_workspace", - description=( - "在工作区内按正则搜索代码/文本,返回 path:line: content 格式。" - "找定义、引用、符号时优先使用;命中后再对具体文件调用 read_workspace_file。" - "pattern 为正则;path 为相对目录或文件(空=整个工作区);" - "glob_pattern 可选如 *.py、*.ts。" - ), - ), - StructuredTool.from_function( - func=glob_tool, - name="glob_workspace", - description=( - "按 glob 递归列出工作区文件(如 **/*.py、src/**/*.ts)。" - "relative_dir 为空时从工作区根开始。跳过 .git / node_modules / .lr-agent。" - "找文件路径时优先于反复 list_workspace_directory。" - ), - ), - StructuredTool.from_function( - func=list_directory_tool, - name="list_workspace_directory", - description=( - "列出工作区目录下的文件与子目录(name | kind | relativePath)。" - "relative_dir 为空时列出工作区根;用于了解项目结构。" - ), - ), - StructuredTool.from_function( - func=read_image_for_vision, - name="read_image_for_vision", - description=( - "加载图片并在调用成功后由系统注入附图,供你直接根据像素回答" - "(场景、物体、人数、文字 OCR、外观等)。relative_path 为空时使用当前打开的图片。" - "需视觉探针通过;查已有标注请用 read_file_annotation,勿用本工具代替。" - ), - ), - StructuredTool.from_function( - func=read_document, - name="read_document_file", - description=( - "提取 PDF 或 DOCX 文档正文。" - "relative_path 为空时使用当前打开的文件。" - ), - ), - StructuredTool.from_function( - func=write_file, - name="write_workspace_file", - description=( - "在工作区内创建或覆写文本/代码文件(如 .md .py .ts)。" - "生成提案,用户 Keep All 后才落盘。" - "只用于工作区文档与代码,不能用来保存标注。" - "删文件请用 delete_workspace_file,不要写入空内容;" - "移动/重命名文件请用 move_workspace_file。" - ), - ), - StructuredTool.from_function( - func=str_replace_file, - name="str_replace_workspace_file", - description=( - "对已有文本/代码文件做精确片段替换。" - "old_string 必须在文件中唯一出现,除非 replace_all=true。" - "改局部代码时优先用本工具。不能用来改标注。" - ), - args_schema=StrReplaceArgs, - ), - StructuredTool.from_function( - func=delete_file, - name="delete_workspace_file", - description=( - "删除工作区内的文本/代码文件,生成删除提案。" - "用户确认 Keep All 后才从磁盘移除。不能删除标注。" - ), - args_schema=DeleteWorkspaceFileArgs, - ), - StructuredTool.from_function( - func=move_file, - name="move_workspace_file", - description=( - "移动或重命名工作区内的文本/代码文件,生成移动提案。" - "用户确认 Keep All 后才在磁盘执行。" - "禁止用「读出内容再写到新路径」来移动文件。" - ), - args_schema=MoveWorkspaceFileArgs, - ), - # ── 客户端工具(schema 存根,实现体在前端 Electron 进程)──────────── - StructuredTool.from_function( - func=_client_tool_stub("auto_annotate"), - name="auto_annotate", - description=( - "新增或重写当前项目的自动标注(检测、预标注、生成 caption 等);" - "改已有框/标签用 mutate_annotation。填参规则见系统提示的标注调用纪律。" - ), - args_schema=AutoAnnotateArgs, - ), - StructuredTool.from_function( - func=_client_tool_stub("mutate_annotation"), - name="mutate_annotation", - description=( - "修改或删除已有标注(改标签、删框、改 caption 等)。" - "不含新增;新增请用 auto_annotate。" - ), - args_schema=MutateAnnotationArgs, - ), - StructuredTool.from_function( - func=_explore_readonly_stub, - name="explore_readonly", - description=( - "只读查阅子代理:在工作区内搜索/阅读代码与文档" - "(标注任务还可读已有标注),返回中文摘要。" - "大范围摸底时使用。query 描述要查什么;focus_path 可选,缩小范围。" - "本工具不会改文件或写标注;标注与写文件必须由你直接调用对应工具。" - ), - args_schema=ExploreReadonlyArgs, - ), - ] - - if client_context and client_context.work_mode == "editor": - tools = [tool for tool in tools if tool.name not in ANNOTATION_TOOL_NAMES] - - # 断言:每个工具对应唯一 capability(防御 MCP 或 registry 错误注入同一能力多次) - _capability_seen: set[str] = set() - for t in tools: - cap = TOOL_CAPABILITY_MAP.get(t.name) - if cap is not None: - # QUERY_CONTEXT 允许多个工具(账户、帮助、上下文查询共享) - if cap != ToolCapability.QUERY_CONTEXT: - assert cap not in _capability_seen, ( - f"工具能力冲突: {t.name} 的能力 {cap.value} 已由另一工具提供" - ) - _capability_seen.add(cap) - - return tools - - -def _async_structured_caller(tool: StructuredTool) -> Callable[..., Any]: - """MCP 等仅有 coroutine 的工具:经 ainvoke 执行,保留 content_and_artifact 处理。""" - - async def _call(**kwargs: Any) -> Any: - return await tool.ainvoke(kwargs) - - _call.__name__ = tool.name - return _call - - -def tool_fn_map(tools: list[StructuredTool]) -> dict[str, Callable[..., Any]]: - """将 StructuredTool 列表转为 name → 可调用映射,供 assist_service 本地执行。 - - 本地工具走 sync ``func``;langchain-mcp-adapters 转换的工具只有 ``coroutine``, - 仍需纳入映射,否则 bind_tools 可见、执行时变成「未知工具」。 - """ - mapping: dict[str, Callable[..., Any]] = {} - for tool in tools: - if tool.func is not None: - mapping[tool.name] = tool.func - elif tool.coroutine is not None: - mapping[tool.name] = _async_structured_caller(tool) - return mapping diff --git a/vendor/local-agent/app/agent/tools/tool_registry_meta.py b/vendor/local-agent/app/agent/tools/tool_registry_meta.py deleted file mode 100644 index 52d2343..0000000 --- a/vendor/local-agent/app/agent/tools/tool_registry_meta.py +++ /dev/null @@ -1,108 +0,0 @@ -"""工具注册元数据:runner 类型、能力分类与副作用分类(Agent 调度单一真相源)。""" - -from __future__ import annotations - -from enum import Enum - - -class ToolRunner(str, Enum): - """工具执行器类型。""" - - SYNC = "sync" # 本轮在后端同步执行完毕 - PROPOSAL = "proposal" # 后端校验并生成提案 SSE,落盘由 Electron 确认 - ASYNC = "async" # 暂停 loop,由 Electron 执行后 resume - - -class ToolCapability(str, Enum): - """工具能力抽象——每个经典工具对应一个唯一能力,防止双轨制。""" - - READ_TEXT_FILE = "read_text_file" - READ_DOCUMENT = "read_document" - READ_IMAGE_VISION = "read_image_vision" - READ_ANNOTATION_JSON = "read_annotation_json" - WRITE_FILE = "write_file" - STR_REPLACE_FILE = "str_replace_file" - DELETE_FILE = "delete_file" - MOVE_FILE = "move_file" - AUTO_ANNOTATE = "auto_annotate" - MUTATE_ANNOTATION = "mutate_annotation_cap" - RUN_TERMINAL_COMMAND = "run_terminal_command" - QUERY_CONTEXT = "query_context" - GREP_WORKSPACE = "grep_workspace" - GLOB_WORKSPACE = "glob_workspace" - LIST_DIRECTORY = "list_directory" - EXPLORE_READONLY = "explore_readonly" - - -# 内置工具 → 唯一能力映射 -TOOL_CAPABILITY_MAP: dict[str, ToolCapability] = { - "read_workspace_file": ToolCapability.READ_TEXT_FILE, - "grep_workspace": ToolCapability.GREP_WORKSPACE, - "glob_workspace": ToolCapability.GLOB_WORKSPACE, - "list_workspace_directory": ToolCapability.LIST_DIRECTORY, - "read_document_file": ToolCapability.READ_DOCUMENT, - "read_image_for_vision": ToolCapability.READ_IMAGE_VISION, - "read_file_annotation": ToolCapability.READ_ANNOTATION_JSON, - "write_workspace_file": ToolCapability.WRITE_FILE, - "str_replace_workspace_file": ToolCapability.STR_REPLACE_FILE, - "delete_workspace_file": ToolCapability.DELETE_FILE, - "move_workspace_file": ToolCapability.MOVE_FILE, - "auto_annotate": ToolCapability.AUTO_ANNOTATE, - "mutate_annotation": ToolCapability.MUTATE_ANNOTATION, - "start_terminal_command": ToolCapability.RUN_TERMINAL_COMMAND, - "get_account_summary": ToolCapability.QUERY_CONTEXT, - "get_lr_agent_help": ToolCapability.QUERY_CONTEXT, - "describe_client_context": ToolCapability.QUERY_CONTEXT, - "describe_annotation_project": ToolCapability.QUERY_CONTEXT, - "explore_readonly": ToolCapability.EXPLORE_READONLY, -} - -# 所有内置工具的能力集合 -CANONICAL_CAPABILITIES: frozenset[ToolCapability] = frozenset(TOOL_CAPABILITY_MAP.values()) - - -# 内置工具 runner 映射(MCP 动态工具默认 SYNC,且不与下列重名注入) -TOOL_RUNNERS: dict[str, ToolRunner] = { - "get_account_summary": ToolRunner.SYNC, - "get_lr_agent_help": ToolRunner.SYNC, - "describe_client_context": ToolRunner.SYNC, - "describe_annotation_project": ToolRunner.SYNC, - "read_file_annotation": ToolRunner.SYNC, - "read_workspace_file": ToolRunner.SYNC, - "grep_workspace": ToolRunner.SYNC, - "glob_workspace": ToolRunner.SYNC, - "list_workspace_directory": ToolRunner.SYNC, - "read_image_for_vision": ToolRunner.SYNC, - "read_document_file": ToolRunner.SYNC, - "write_workspace_file": ToolRunner.PROPOSAL, - "str_replace_workspace_file": ToolRunner.PROPOSAL, - "delete_workspace_file": ToolRunner.PROPOSAL, - "move_workspace_file": ToolRunner.PROPOSAL, - "auto_annotate": ToolRunner.ASYNC, - "mutate_annotation": ToolRunner.ASYNC, - # 本地 MCP 的终端工具:执行前需用户聊天内批准,由 Electron pending→resume 驱动 - "start_terminal_command": ToolRunner.ASYNC, - "explore_readonly": ToolRunner.SYNC, -} - -# 不与 MCP 重复暴露的本地实现工具名 -LOCAL_CANONICAL_TOOL_NAMES: frozenset[str] = frozenset(TOOL_RUNNERS.keys()) - -ASYNC_TOOL_NAMES: frozenset[str] = frozenset( - name for name, runner in TOOL_RUNNERS.items() if runner is ToolRunner.ASYNC -) - -# 向后兼容别名 -CLIENT_TOOL_NAMES = ASYNC_TOOL_NAMES - - -def get_tool_runner(name: str) -> ToolRunner: - return TOOL_RUNNERS.get(name, ToolRunner.SYNC) - - -def is_async_tool(name: str) -> bool: - return get_tool_runner(name) is ToolRunner.ASYNC - - -def is_proposal_tool(name: str) -> bool: - return get_tool_runner(name) is ToolRunner.PROPOSAL diff --git a/vendor/local-agent/app/agent/tools/tool_result.py b/vendor/local-agent/app/agent/tools/tool_result.py deleted file mode 100644 index bb51860..0000000 --- a/vendor/local-agent/app/agent/tools/tool_result.py +++ /dev/null @@ -1,74 +0,0 @@ -"""统一 ToolMessage JSON 结构。""" - -from __future__ import annotations - -import json -from typing import Any - -from langchain_core.messages import ToolMessage - - -def build_tool_result( - *, - ok: bool, - tool: str, - status: str, - summary: str, - **extra: Any, -) -> str: - payload: dict[str, Any] = { - "ok": ok, - "tool": tool, - "status": status, - "summary": summary, - "file_written": extra.pop("file_written", False), - "proposal_pending": extra.pop("proposal_pending", False), - } - payload.update(extra) - return json.dumps(payload, ensure_ascii=False) - - -def stringify_tool_output(result: Any) -> str: - """把 StructuredTool / MCP ainvoke 的返回值收成 ToolMessage 字符串。""" - if isinstance(result, ToolMessage): - result = result.content - if isinstance(result, tuple) and result: - result = result[0] - if result is None: - return "" - if isinstance(result, str): - return result - if isinstance(result, list): - texts: list[str] = [] - for item in result: - if isinstance(item, str): - texts.append(item) - elif isinstance(item, dict) and item.get("type") == "text": - texts.append(str(item.get("text") or "")) - else: - texts.append(str(item)) - return "\n".join(part for part in texts if part) - return str(result) - - -def format_tool_result_for_display(result_text: str) -> str: - """将 ToolMessage JSON 格式化为 UI/模型可读摘要。""" - try: - data = json.loads(result_text) - except json.JSONDecodeError: - return result_text - if not isinstance(data, dict): - return result_text - if data.get("summary"): - return str(data["summary"]) - if data.get("message"): - return str(data["message"]) - return result_text - - -def parse_tool_result(result_text: str) -> dict[str, Any] | None: - try: - data = json.loads(result_text) - except json.JSONDecodeError: - return None - return data if isinstance(data, dict) else None diff --git a/vendor/local-agent/app/agent/tools/workspace_file_reader.py b/vendor/local-agent/app/agent/tools/workspace_file_reader.py deleted file mode 100644 index b1f982d..0000000 --- a/vendor/local-agent/app/agent/tools/workspace_file_reader.py +++ /dev/null @@ -1,744 +0,0 @@ -"""工作区文件读取与写入实现:文本/代码、文档(PDF/DOCX)、图片(视觉)、写文件提案。 - -由 registry 注册为 LLM 工具;视觉与写文件相关辅助函数供 assist_service / assist_vision 使用: - - extract_vision_path_from_tool_result:从工具结果提取图片绝对路径 - - format_vision_tool_result_for_display:隐藏内部路径标记后展示给用户 - - extract_doc_proposal_from_tool_result:从写文件工具结果提取文档提案数据 -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from PIL import Image - -from app.agent.tools.tool_result import build_tool_result, format_tool_result_for_display -from app.agent.tools.workspace_path import ( - relative_display_path, - resolve_workspace_file, - resolve_workspace_write_path, -) -from app.agent.tools.workspace_text_extensions import ( - TEXT_BLOCKLIST_SUFFIXES, - is_allowed_text_extension, -) -from app.core.config import Settings -from app.schemas.agent import ClientContextInput - -VISION_TOOL_NAME = "read_image_for_vision" -WRITE_TOOL_NAME = "write_workspace_file" -STR_REPLACE_TOOL_NAME = "str_replace_workspace_file" -DELETE_TOOL_NAME = "delete_workspace_file" -MOVE_TOOL_NAME = "move_workspace_file" -FILE_PROPOSAL_TOOLS = frozenset( - {WRITE_TOOL_NAME, STR_REPLACE_TOOL_NAME, DELETE_TOOL_NAME, MOVE_TOOL_NAME} -) -# 工具结果 JSON 中的内部字段,assist_service 据此注入多模态消息 -VISION_PATH_MARKER = "__vision_image_path__" -# 工具结果 JSON 中的内部字段,assist_service 据此发出 file_proposal SSE 事件 -DOC_PROPOSAL_MARKER = "__doc_proposal__" - -IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico"}) -DOCUMENT_SUFFIXES = frozenset({".pdf", ".docx"}) - - -# 单行展示上限:防止 minified / 单行巨型文件把工具结果撑爆 -MAX_LINE_DISPLAY_CHARS = 2000 - - -def _cap_line(line: str) -> str: - if len(line) <= MAX_LINE_DISPLAY_CHARS: - return line - return f"{line[:MAX_LINE_DISPLAY_CHARS]}…[行已截断,共 {len(line)} 字符]" - - -def _read_line_window( - resolved: Path, - start: int, - end: int | None, - max_lines: int, -) -> tuple[list[str], int | None, bool, str | None]: - """流式读取 [start, end](1-indexed,含首尾;end=None 表示到 EOF)行窗口。 - - 返回 (lines, total_lines, truncated, error): - - total_lines 为 None 表示提前停止(按 end 或窗口上限),总行数未知 - - truncated=True 表示窗口超过 max_lines 被截断 - """ - for encoding in ("utf-8", "utf-8-sig"): - try: - collected: list[str] = [] - total: int | None = 0 - truncated = False - with resolved.open("r", encoding=encoding) as fh: - for idx, raw_line in enumerate(fh, start=1): - if idx < start: - total = idx - continue - if end is not None and idx > end: - total = None - break - if len(collected) >= max_lines: - truncated = True - total = None - break - collected.append(raw_line.rstrip("\n")) - total = idx - return collected, total, truncated, None - except UnicodeDecodeError: - continue - except OSError as exc: - return [], None, False, f"读取失败:{exc}" - return [], None, False, f"「{resolved.name}」不是 UTF-8 文本,暂不支持读取。" - - -def _read_ranged_text( - resolved: Path, - size: int, - start_line: int | None, - end_line: int | None, - max_lines: int, -) -> str: - """行范围读取:流式窗口,不受 max_bytes / max_lines 对文件头部的预截断影响。""" - s = max(1, start_line if start_line is not None else 1) - if end_line is not None and end_line < s: - return f"无效行范围:start_line ({s}) 不能大于 end_line ({end_line})。" - - try: - with resolved.open("rb") as fh: - if b"\x00" in fh.read(8192): - return f"「{resolved.name}」似乎是二进制文件,请使用对应专用工具。" - except OSError as exc: - return f"读取失败:{exc}" - - lines, total, truncated, read_err = _read_line_window(resolved, s, end_line, max_lines) - if read_err is not None: - return read_err - - header = f"文件:{resolved.name}\n大小:{size} 字节\n" - end_label = f"L{end_line}" if end_line is not None else "EOF" - header += f"行范围:L{s}-{end_label}" - if total is not None: - header += f"(共 {total} 行)" - header += "\n" - if truncated: - header += ( - f"(行窗口已截断,最多 {max_lines} 行;" - "可用 start_line/end_line 继续分段读取)\n" - ) - header += "---\n" - if not lines: - if total is not None: - return header + f"(起始行超出文件末尾,共 {total} 行)" - return header + "(指定范围内无内容)" - numbered = [ - f"{s + offset:6d}|{_cap_line(line)}" - for offset, line in enumerate(lines) - ] - return header + "\n".join(numbered) - - -def read_workspace_text_file( - client_context: ClientContextInput | None, - path: str, - *, - settings: Settings, - start_line: int | None = None, - end_line: int | None = None, -) -> str: - """读取 UTF-8 文本/代码文件,按配置截断字节数与行数;可选行范围(1-indexed,含首尾)。 - - 指定行范围时按流式窗口读取,支持大文件任意位置;否则读取文件头部并截断。 - """ - resolved, err = resolve_workspace_file(client_context, path) - if resolved is None: - return err - - suffix = resolved.suffix.lower() - if suffix in TEXT_BLOCKLIST_SUFFIXES: - return ( - f"「{resolved.name}」不是纯文本文件。" - f"图片请用 {VISION_TOOL_NAME};PDF/DOCX 请用 read_document_file。" - ) - - max_bytes = settings.agent_read_file_max_bytes - max_lines = settings.agent_read_file_max_lines - - try: - size = resolved.stat().st_size - except OSError as exc: - return f"无法读取文件:{exc}" - - if start_line is not None or end_line is not None: - return _read_ranged_text(resolved, size, start_line, end_line, max_lines) - - truncated = False - try: - if size > max_bytes: - truncated = True - raw = resolved.read_bytes()[:max_bytes] - else: - raw = resolved.read_bytes() - except OSError as exc: - return f"读取失败:{exc}" - - if b"\x00" in raw[:8192]: - return f"「{resolved.name}」似乎是二进制文件,请使用对应专用工具。" - - try: - text = raw.decode("utf-8") - except UnicodeDecodeError: - try: - text = raw.decode("utf-8-sig") - except UnicodeDecodeError: - return f"「{resolved.name}」不是 UTF-8 文本,暂不支持读取。" - - lines = text.splitlines() - if len(lines) > max_lines: - truncated = True - lines = lines[:max_lines] - - header = f"文件:{resolved.name}\n大小:{size} 字节\n" - if truncated: - header += ( - f"(内容已截断,最多 {max_bytes} 字节 / {max_lines} 行;" - "可用 start_line/end_line 分段读取后续内容)\n" - ) - header += "---\n" - numbered = [ - f"{1 + offset:6d}|{_cap_line(line)}" - for offset, line in enumerate(lines) - ] - return header + "\n".join(numbered) - - -def read_image_for_vision_tool( - client_context: ClientContextInput | None, - path: str, - *, - provider_is_vision: bool, -) -> str: - """加载图片元信息并返回 JSON;assist_service 据此注入多模态用户消息。""" - if not provider_is_vision: - return ( - "当前大模型未通过视觉能力探针,无法分析图片内容。" - "请在「大模型配置」中选用支持视觉的模型并重探视觉能力。" - ) - - resolved, err = resolve_workspace_file(client_context, path) - if resolved is None: - return err - - suffix = resolved.suffix.lower() - if suffix not in IMAGE_SUFFIXES: - return f"「{resolved.name}」不是支持的图片格式({', '.join(sorted(IMAGE_SUFFIXES))})。" - - try: - with Image.open(resolved) as img: - width, height = img.size - fmt = (img.format or suffix.lstrip(".")).upper() - except OSError as exc: - return f"无法打开图片:{exc}" - - payload = { - "ok": True, - "path": str(resolved), - "name": resolved.name, - "width": width, - "height": height, - "format": fmt, - VISION_PATH_MARKER: str(resolved), - "message": ( - f"已加载图片 {resolved.name}({width}x{height} {fmt})。" - "系统将在本条工具结果后注入附图,请根据图像回答用户问题。" - ), - } - return json.dumps(payload, ensure_ascii=False) - - -def read_document_file( - client_context: ClientContextInput | None, - path: str, - *, - settings: Settings, -) -> str: - """提取 PDF / DOCX 正文,按配置截断页数与字符数。""" - resolved, err = resolve_workspace_file(client_context, path) - if resolved is None: - return err - - suffix = resolved.suffix.lower() - if suffix not in DOCUMENT_SUFFIXES: - return f"「{resolved.name}」不是支持的文档格式(pdf、docx)。" - - max_pages = settings.agent_read_document_max_pages - - try: - if suffix == ".pdf": - text, meta = _extract_pdf_text(resolved, max_pages=max_pages) - else: - text, meta = _extract_docx_text(resolved) - except Exception as exc: - return f"解析文档失败:{exc}" - - if not text.strip(): - return f"「{resolved.name}」未能提取到文本({meta})。" - - max_chars = settings.agent_read_file_max_bytes - truncated = len(text) > max_chars - if truncated: - text = text[:max_chars] - - header = f"文件:{resolved.name}\n{meta}\n" - if truncated: - header += f"(正文已截断至 {max_chars} 字符)\n" - header += "---\n" - return header + text - - -def _extract_pdf_text(path: Path, *, max_pages: int) -> tuple[str, str]: - from pypdf import PdfReader - - reader = PdfReader(str(path)) - total = len(reader.pages) - limit = min(total, max_pages) - parts: list[str] = [] - for idx in range(limit): - page = reader.pages[idx] - parts.append(page.extract_text() or "") - meta = f"PDF 共 {total} 页,已提取前 {limit} 页" - return "\n\n".join(parts), meta - - -def _extract_docx_text(path: Path) -> tuple[str, str]: - from docx import Document - - doc = Document(str(path)) - paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] - return "\n\n".join(paragraphs), f"DOCX 段落数:{len(paragraphs)}" - - -def extract_vision_path_from_tool_result(tool_name: str, result_text: str) -> str | None: - """从 read_image_for_vision 工具结果中提取图片绝对路径。""" - if tool_name != VISION_TOOL_NAME: - return None - try: - data = json.loads(result_text) - except json.JSONDecodeError: - return None - if not isinstance(data, dict) or not data.get("ok"): - return None - path = str(data.get(VISION_PATH_MARKER) or "").strip() - if not path: - return None - candidate = Path(path) - return str(candidate) if candidate.is_file() else None - - -def format_vision_tool_result_for_display(result_text: str) -> str: - """格式化视觉工具结果供前端展示,移除内部路径标记。""" - try: - data = json.loads(result_text) - except json.JSONDecodeError: - return result_text - if not isinstance(data, dict): - return result_text - display = dict(data) - display.pop(VISION_PATH_MARKER, None) - return json.dumps(display, ensure_ascii=False, indent=2) - - -def _relative_display_path( - client_context: ClientContextInput | None, - resolved: Path, - fallback: str, -) -> str: - return relative_display_path(client_context, resolved, fallback) - - -# 同一轮请求内的未落盘提案内容接力:key 为 resolved 绝对路径字符串, -# value 为 (落盘时的磁盘快照或 None, 提案完整内容)。None 表示整文件覆写, -# 不依赖磁盘快照。str_replace 以最新提案内容为基线,避免第二份提案 -# 基于未含第一处修改的磁盘原文、确认落盘后吞掉前一处修改。 -PendingProposalContents = dict[str, tuple[str | None, str]] - - -def write_workspace_file_tool( - client_context: ClientContextInput | None, - relative_path: str, - content: str, - pending_proposals: PendingProposalContents | None = None, -) -> str: - """准备写文件提案:校验路径后返回 file_proposal 标记供 assist_service 转换为 SSE 事件。 - - 不直接写盘——写操作由前端在用户确认后执行。 - """ - resolved, err = resolve_workspace_write_path(client_context, relative_path) - if resolved is None: - return build_tool_result( - ok=False, - tool=WRITE_TOOL_NAME, - status="error", - summary=f"无法写入文件:{err}", - ) - - rel_display = _relative_display_path(client_context, resolved, relative_path) - - suffix = resolved.suffix.lower() - if not is_allowed_text_extension(suffix): - return build_tool_result( - ok=False, - tool=WRITE_TOOL_NAME, - status="error", - summary=( - f"write_workspace_file 不支持后缀 {suffix!r}(二进制/富媒体格式)。" - f"请使用 UTF-8 文本或代码文件。" - ), - ) - - title = resolved.stem.replace("-", " ").replace("_", " ").title() - summary = ( - f"已生成文件提案:{rel_display}。" - "文件尚未写入磁盘;用户确认(Keep All)后才会落盘。" - "请勿在回复中声称文件已创建或已保存。" - ) - if pending_proposals is not None: - pending_proposals[str(resolved)] = (None, content) - return build_tool_result( - ok=True, - tool=WRITE_TOOL_NAME, - status="proposal_ready", - summary=summary, - file_written=False, - proposal_pending=True, - **{ - DOC_PROPOSAL_MARKER: True, - "relative_path": rel_display, - "title": title, - "content": content, - "operation": "write", - }, - ) - - -def str_replace_workspace_file_tool( - client_context: ClientContextInput | None, - relative_path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - pending_proposals: PendingProposalContents | None = None, -) -> str: - """在已有文本文件中做精确替换,生成完整新内容的 file_proposal。""" - resolved, err = resolve_workspace_file(client_context, relative_path) - if resolved is None: - # 文件在磁盘上不存在:若同路径已有未落盘的 write 提案, - # 则以提案为基线继续(否则保持原有报错)。 - if pending_proposals is not None: - write_resolved, _ = resolve_workspace_write_path( - client_context, relative_path - ) - if write_resolved is not None and str(write_resolved) in pending_proposals: - resolved = write_resolved - if resolved is None: - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary=f"无法读取文件:{err}", - ) - - suffix = resolved.suffix.lower() - if not is_allowed_text_extension(suffix): - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary=f"不支持后缀 {suffix!r},请使用 UTF-8 文本或代码文件。", - ) - - if not old_string: - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary="old_string 不能为空。", - ) - - try: - original = resolved.read_text(encoding="utf-8") - except FileNotFoundError: - # write 提案尚未落盘:以空字符串作为磁盘快照参与基线判断 - original = "" - except UnicodeDecodeError: - try: - original = resolved.read_text(encoding="utf-8-sig") - except UnicodeDecodeError: - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary="文件不是 UTF-8 文本。", - ) - except OSError as exc: - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary=f"读取失败:{exc}", - ) - - # 基线接力:同路径已有未落盘提案且磁盘未再被改动时,以提案内容为基线, - # 使本轮的多处修改在同一份提案里累积。 - base = original - base_from_pending = False - if pending_proposals is not None: - pending = pending_proposals.get(str(resolved)) - if pending is not None: - pending_disk_snap, pending_content = pending - if pending_disk_snap is None or pending_disk_snap == original: - base = pending_content - base_from_pending = True - else: - # 磁盘在提案生成后又被改动(如用户手动保存),旧提案基线过期。 - pending_proposals.pop(str(resolved), None) - - count = base.count(old_string) - if count == 0: - hint = ( - "(注意:本轮对该文件已有未落盘的提案,替换基线是提案内容而非磁盘原文," - "read_workspace_file 读到的磁盘原文可能不含此前提案的修改)" - if base_from_pending - else "" - ) - first_line = old_string.splitlines()[0][:80] if old_string else "" - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary=( - f"未找到 old_string(首行:{first_line})。" - "常见原因:缩进/空白字符与原文不一致,或该处内容已变化。" - "请先用 read_workspace_file 核对目标段落,原样复制(含缩进)后重试。" - f"{hint}" - ), - ) - if count > 1 and not replace_all: - return build_tool_result( - ok=False, - tool=STR_REPLACE_TOOL_NAME, - status="error", - summary=( - f"old_string 出现 {count} 次。请前后多带几行上下文使片段唯一;" - "确需替换全部出现时设 replace_all=true。" - ), - ) - - content = base.replace(old_string, new_string) if replace_all else base.replace( - old_string, new_string, 1 - ) - if pending_proposals is not None: - pending_proposals[str(resolved)] = (original, content) - rel_display = _relative_display_path(client_context, resolved, relative_path) - title = resolved.stem.replace("-", " ").replace("_", " ").title() - summary = ( - f"已生成补丁提案:{rel_display}(替换 {count if replace_all else 1} 处)。" - "文件尚未写入磁盘;用户确认(Keep All)后才会落盘。" - ) - return build_tool_result( - ok=True, - tool=STR_REPLACE_TOOL_NAME, - status="proposal_ready", - summary=summary, - file_written=False, - proposal_pending=True, - **{ - DOC_PROPOSAL_MARKER: True, - "relative_path": rel_display, - "title": title, - "content": content, - "operation": "write", - }, - ) - - -def delete_workspace_file_tool( - client_context: ClientContextInput | None, - relative_path: str, - pending_proposals: PendingProposalContents | None = None, -) -> str: - """准备删除文本文件的提案;用户确认后才删盘。""" - resolved, err = resolve_workspace_file(client_context, relative_path) - if resolved is None: - return build_tool_result( - ok=False, - tool=DELETE_TOOL_NAME, - status="error", - summary=f"无法删除文件:{err}", - ) - - write_resolved, write_err = resolve_workspace_write_path( - client_context, relative_path - ) - if write_resolved is None: - return build_tool_result( - ok=False, - tool=DELETE_TOOL_NAME, - status="error", - summary=f"无法删除文件:{write_err}", - ) - - suffix = resolved.suffix.lower() - if not is_allowed_text_extension(suffix): - return build_tool_result( - ok=False, - tool=DELETE_TOOL_NAME, - status="error", - summary=f"不支持删除后缀 {suffix!r}。", - ) - - rel_display = _relative_display_path(client_context, resolved, relative_path) - title = f"删除 {resolved.name}" - summary = ( - f"已生成删除提案:{rel_display}。" - "文件尚未删除;用户确认(Keep All)后才会从磁盘移除。" - ) - if pending_proposals is not None: - pending_proposals.pop(str(resolved), None) - return build_tool_result( - ok=True, - tool=DELETE_TOOL_NAME, - status="proposal_ready", - summary=summary, - file_written=False, - proposal_pending=True, - **{ - DOC_PROPOSAL_MARKER: True, - "relative_path": rel_display, - "title": title, - "content": "", - "operation": "delete", - }, - ) - - -def move_workspace_file_tool( - client_context: ClientContextInput | None, - relative_path: str, - new_relative_path: str, - pending_proposals: PendingProposalContents | None = None, -) -> str: - """准备移动/重命名文件的提案;用户确认后才在磁盘执行。""" - resolved, err = resolve_workspace_file(client_context, relative_path) - if resolved is None: - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary=f"无法移动文件:{err}", - ) - - suffix = resolved.suffix.lower() - if not is_allowed_text_extension(suffix): - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary=f"不支持移动后缀 {suffix!r} 的文件。", - ) - - new_raw = (new_relative_path or "").strip() - if not new_raw: - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary="new_relative_path 不能为空。", - ) - - target, err = resolve_workspace_write_path(client_context, new_raw) - if target is None: - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary=f"目标路径无效:{err}", - ) - - if target == resolved: - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary="目标路径与原路径相同,无需移动。", - ) - if target.exists(): - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary=f"目标已存在:{relative_display_path(client_context, target, new_raw)}。请换一个目标路径。", - ) - target_suffix = target.suffix.lower() - if not is_allowed_text_extension(target_suffix): - return build_tool_result( - ok=False, - tool=MOVE_TOOL_NAME, - status="error", - summary=f"目标后缀 {target_suffix!r} 不受支持,请使用 UTF-8 文本或代码文件。", - ) - - old_display = _relative_display_path(client_context, resolved, relative_path) - new_display = _relative_display_path(client_context, target, new_raw) - title = f"移动 {resolved.name}" - summary = ( - f"已生成移动/重命名提案:{old_display} → {new_display}。" - "文件尚未改动;用户确认(Keep All)后才会在磁盘执行。" - ) - if pending_proposals is not None: - pending_proposals.pop(str(resolved), None) - pending_proposals.pop(str(target), None) - return build_tool_result( - ok=True, - tool=MOVE_TOOL_NAME, - status="proposal_ready", - summary=summary, - file_written=False, - proposal_pending=True, - **{ - DOC_PROPOSAL_MARKER: True, - "relative_path": new_display, - "old_path": old_display, - "title": title, - "content": "", - "operation": "rename", - }, - ) - - -def extract_doc_proposal_from_tool_result( - tool_name: str, result_text: str -) -> dict | None: - """从写/补丁/删除工具结果中提取文档提案数据。""" - if tool_name not in FILE_PROPOSAL_TOOLS: - return None - try: - data = json.loads(result_text) - except json.JSONDecodeError: - return None - if not isinstance(data, dict) or not data.get(DOC_PROPOSAL_MARKER): - return None - operation = str(data.get("operation") or "write") - if operation not in ("write", "delete", "rename"): - operation = "write" - return { - "relative_path": str(data.get("relative_path") or "document.md"), - "title": str(data.get("title") or "文档"), - "content": str(data.get("content") or ""), - "operation": operation, - "old_path": str(data.get("old_path") or ""), - } - - -def format_write_tool_result_for_display(result_text: str) -> str: - return format_tool_result_for_display(result_text) diff --git a/vendor/local-agent/app/agent/tools/workspace_path.py b/vendor/local-agent/app/agent/tools/workspace_path.py deleted file mode 100644 index 03f4ad5..0000000 --- a/vendor/local-agent/app/agent/tools/workspace_path.py +++ /dev/null @@ -1,261 +0,0 @@ -"""工作区 / 项目文件路径解析与安全校验。 - -所有文件读取工具(workspace_file_reader、assist_vision)的统一入口: - 1. 确定允许访问的根目录(workspace_root + project_directory) - 2. 解析用户/LLM 传入的路径,回退到当前打开文件 - 3. 校验路径在根目录内、禁止 .. 穿越 -""" - -from __future__ import annotations - -from pathlib import Path - -from app.agent.context_helpers import project_directory -from app.schemas.agent import ClientContextInput - - -def normalize_relative_path(relative_path: str) -> str: - """将相对路径统一为正斜杠格式,去除空段与 `.`。""" - return "/".join(part for part in relative_path.replace("\\", "/").split("/") if part and part != ".") - - -LR_AGENT_DIR = ".lr-agent" -LR_AGENT_WRITE_DENIED = ( - "不能用本工具写入标注。新增/重写请调用 auto_annotate,修改请调用 mutate_annotation。" -) - - -def is_lr_agent_relative(relative_path: str) -> bool: - """相对路径是否落入 .lr-agent 标注库目录。""" - parts = [p for p in normalize_relative_path(relative_path).split("/") if p] - return any(part == LR_AGENT_DIR for part in parts) - - -def _is_lr_agent_under_root(candidate: Path, root: Path) -> bool: - try: - rel = candidate.relative_to(root) - except ValueError: - return False - return any(part == LR_AGENT_DIR for part in rel.parts) - - -def allowed_roots(client_context: ClientContextInput | None) -> list[Path]: - """返回可访问的根目录列表(项目目录优先 + 工作区根,去重)。 - - 顺序必须与前端 applyFileBlock 的写入根一致(project.directoryPath - ?? workspaceRoot):提案的 relative_display_path 以列表第一个匹配根 - 计算,若后端 workspace 优先而前端 project 优先,Keep All 会把文件 - 写到另一个根下(历史隐患:同文件在两个根各落一份)。 - """ - roots: list[Path] = [] - seen: set[str] = set() - if client_context is None: - return roots - - for raw in ( - (project_directory(client_context) or "").strip(), - (client_context.workspace_root or "").strip(), - ): - if not raw: - continue - try: - resolved = str(Path(raw).resolve()) - except OSError: - continue - if resolved in seen: - continue - seen.add(resolved) - roots.append(Path(resolved)) - return roots - - -def _is_under_root(candidate: Path, root: Path) -> bool: - """判断 candidate 是否在 root 目录树下。""" - try: - candidate.relative_to(root) - return True - except ValueError: - return False - - -def relative_display_path( - client_context: ClientContextInput | None, - resolved: Path, - fallback: str, -) -> str: - """将已解析的绝对路径转为相对显示路径(正斜杠),不在任何根内时回退 fallback。""" - for root in allowed_roots(client_context): - try: - return str(resolved.relative_to(root)).replace("\\", "/") - except ValueError: - continue - return fallback.strip() - - -def normalize_write_display_path( - client_context: ClientContextInput | None, - raw_path: str, -) -> str: - """把模型给的写入路径归一为与工具结果一致的显示路径。 - - 流式拦截器(file_proposal_start / delta)与工具结果定稿事件必须产出 - 同一条路径字符串,否则前端按路径匹配提案块时会裂成两张卡片 - (历史 bug:模型写绝对路径或 ./ 前缀导致)。解析失败时退化为 - normalize_relative_path,保证至少分隔符/空段是干净的。 - """ - resolved, _err = resolve_workspace_write_path(client_context, raw_path) - if resolved is None: - return normalize_relative_path(raw_path) - return relative_display_path(client_context, resolved, raw_path) - - -def resolve_workspace_file( - client_context: ClientContextInput | None, - path: str, -) -> tuple[Path | None, str]: - """解析并校验文件路径,返回 (绝对路径, 错误信息)。 - - - path 为空时回退到 active_file_path / active_relative_path - - 支持绝对路径(须在 allowed_roots 内)与相对路径 - - 禁止 .. 目录穿越 - """ - raw = (path or "").strip() - if not raw and client_context is not None: - active_abs = (client_context.active_file_path or "").strip() - if active_abs: - raw = active_abs - else: - rel = normalize_relative_path(client_context.active_relative_path or "") - if rel: - raw = rel - - if not raw: - return None, "请提供相对路径,或在工作区中打开目标文件后再提问。" - - roots = allowed_roots(client_context) - if not roots: - return None, "未绑定工作区或项目目录,无法读取本地文件。" - - candidate_input = Path(raw) - if candidate_input.is_absolute(): - try: - candidate = candidate_input.resolve() - except OSError as exc: - return None, f"路径无效:{exc}" - for root in roots: - if _is_under_root(candidate, root) and candidate.is_file(): - return candidate, "" - return None, "文件不在当前工作区或项目目录内。" - - rel = normalize_relative_path(raw) - if ".." in rel.split("/"): - return None, "路径不能包含 .." - - for root in roots: - candidate = (root / rel).resolve() - if not _is_under_root(candidate, root): - continue - if candidate.is_file(): - return candidate, "" - return None, f"未找到文件:{rel}" - - -def resolve_workspace_directory( - client_context: ClientContextInput | None, - path: str, -) -> tuple[Path | None, str]: - """解析并校验目录路径,返回 (绝对路径, 错误信息)。 - - - path 为空时回退到第一个 allowed root(工作区根) - - 支持绝对路径(须在 allowed_roots 内)与相对路径 - - 禁止 .. 目录穿越 - """ - raw = (path or "").strip() - roots = allowed_roots(client_context) - if not roots: - return None, "未绑定工作区或项目目录,无法访问本地目录。" - - if not raw: - root = roots[0] - if root.is_dir(): - return root, "" - return None, "工作区根目录无效。" - - candidate_input = Path(raw) - if candidate_input.is_absolute(): - try: - candidate = candidate_input.resolve() - except OSError as exc: - return None, f"路径无效:{exc}" - for root in roots: - if _is_under_root(candidate, root) and candidate.is_dir(): - return candidate, "" - return None, "目录不在当前工作区或项目目录内。" - - rel = normalize_relative_path(raw) - if ".." in rel.split("/"): - return None, "路径不能包含 .." - - for root in roots: - candidate = (root / rel).resolve() - if not _is_under_root(candidate, root): - continue - if candidate.is_dir(): - return candidate, "" - return None, f"未找到目录:{rel}" - - -def relative_path_from_roots(candidate: Path, roots: list[Path]) -> str: - """将绝对路径转为相对工作区根的路径(正斜杠)。""" - for root in roots: - try: - return normalize_relative_path(str(candidate.relative_to(root))) - except ValueError: - continue - return candidate.name - - -def resolve_workspace_write_path( - client_context: ClientContextInput | None, - path: str, -) -> tuple[Path | None, str]: - """解析并校验写文件目标路径,返回 (绝对路径, 错误信息)。 - - 与 resolve_workspace_file 的差异:目标文件不要求已存在,仅校验: - - 路径在 allowed_roots 内 - - 无 .. 目录穿越 - - 父目录不存在时由前端/Electron ensureDir 自动创建 - """ - raw = (path or "").strip() - if not raw: - return None, "请提供写入目标的相对路径(如 reports/summary.md)。" - - roots = allowed_roots(client_context) - if not roots: - return None, "未绑定工作区或项目目录,无法写入本地文件。" - - candidate_input = Path(raw) - if candidate_input.is_absolute(): - try: - candidate = candidate_input.resolve() - except OSError as exc: - return None, f"路径无效:{exc}" - for root in roots: - if _is_under_root(candidate, root): - if _is_lr_agent_under_root(candidate, root): - return None, LR_AGENT_WRITE_DENIED - return candidate, "" - return None, "目标路径不在当前工作区或项目目录内。" - - rel = normalize_relative_path(raw) - if ".." in rel.split("/"): - return None, "路径不能包含 .." - if is_lr_agent_relative(rel): - return None, LR_AGENT_WRITE_DENIED - - for root in roots: - candidate = (root / rel).resolve() - if not _is_under_root(candidate, root): - continue - return candidate, "" - return None, f"无法解析写入路径:{rel}" diff --git a/vendor/local-agent/app/agent/tools/workspace_search.py b/vendor/local-agent/app/agent/tools/workspace_search.py deleted file mode 100644 index a8dab33..0000000 --- a/vendor/local-agent/app/agent/tools/workspace_search.py +++ /dev/null @@ -1,342 +0,0 @@ -"""工作区代码/文本搜索与目录列表(grep、list_dir)。""" - -from __future__ import annotations - -import fnmatch -import re -from pathlib import Path - -from app.agent.tools.workspace_file_reader import TEXT_BLOCKLIST_SUFFIXES -from app.agent.tools.workspace_path import ( - allowed_roots, - normalize_relative_path, - relative_path_from_roots, - resolve_workspace_directory, - resolve_workspace_file, -) -from app.core.config import Settings -from app.schemas.agent import ClientContextInput - -SKIP_DIR_NAMES = frozenset( - { - ".git", - ".lr-agent", - "node_modules", - "__pycache__", - ".venv", - "venv", - ".mypy_cache", - ".pytest_cache", - "dist", - "build", - } -) - - -def _should_skip_dir(name: str) -> bool: - """只跳过显式名单(.git / node_modules 等);.github、.cursor 等点目录仍参与搜索。""" - return name in SKIP_DIR_NAMES - - -def _rel_matches_glob(relative_path: str, glob_pattern: str) -> bool: - rel = relative_path.replace("\\", "/") - pat = (glob_pattern or "*").strip().replace("\\", "/") - if not pat or pat == "**/*" or pat == "*": - return True - name = rel.rsplit("/", 1)[-1] - if pat.startswith("**/"): - rest = pat[3:] - return ( - fnmatch.fnmatch(rel, pat) - or fnmatch.fnmatch(rel, rest) - or fnmatch.fnmatch(name, rest) - ) - return fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(name, pat) - - -def _matches_glob(name: str, glob_pattern: str) -> bool: - pattern = (glob_pattern or "*").strip() - if pattern == "*": - return True - if pattern.startswith("*."): - return fnmatch.fnmatch(name, pattern) - return fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(name, f"*{pattern}") - - -def _is_searchable_file(path: Path) -> bool: - if not path.is_file(): - return False - if path.suffix.lower() in TEXT_BLOCKLIST_SUFFIXES: - return False - return True - - -def _iter_search_files( - search_root: Path, - *, - glob_pattern: str, - max_files: int, -) -> list[Path]: - """递归收集可搜索文件,上限 max_files。""" - files: list[Path] = [] - stack: list[Path] = [search_root] - - while stack and len(files) < max_files: - current = stack.pop() - try: - entries = sorted(current.iterdir(), key=lambda p: p.name) - except OSError: - continue - - for entry in entries: - if len(files) >= max_files: - break - if entry.is_dir(): - if _should_skip_dir(entry.name): - continue - stack.append(entry) - elif entry.is_file() and _is_searchable_file(entry): - if _matches_glob(entry.name, glob_pattern): - files.append(entry) - - return files - - -def _grep_single_file( - file_path: Path, - regex: re.Pattern[str], - *, - roots: list[Path], - max_results: int, - results: list[str], -) -> bool: - """搜索单文件,返回是否已达 max_results。""" - try: - raw = file_path.read_bytes() - except OSError: - return len(results) >= max_results - - if b"\x00" in raw[:8192]: - return len(results) >= max_results - - try: - text = raw.decode("utf-8") - except UnicodeDecodeError: - try: - text = raw.decode("utf-8-sig") - except UnicodeDecodeError: - return len(results) >= max_results - - rel = relative_path_from_roots(file_path, roots) - for line_num, line in enumerate(text.splitlines(), start=1): - if regex.search(line): - results.append(f"{rel}:{line_num}: {line.rstrip()}") - if len(results) >= max_results: - return True - return False - - -def grep_workspace( - client_context: ClientContextInput | None, - pattern: str, - path: str = "", - glob_pattern: str = "*", - case_insensitive: bool = False, - max_results: int | None = None, - *, - settings: Settings, -) -> str: - """在工作区内按正则搜索代码/文本,返回 path:line: content 格式。""" - needle = (pattern or "").strip() - if not needle: - return "请提供搜索 pattern(正则表达式)。" - - try: - flags = re.IGNORECASE if case_insensitive else 0 - regex = re.compile(needle, flags) - except re.error as exc: - return f"无效的正则表达式:{exc}" - - roots = allowed_roots(client_context) - if not roots: - return "未绑定工作区或项目目录,无法搜索。" - - limit = max_results if max_results is not None else settings.agent_grep_max_results - limit = min(max(limit, 1), settings.agent_grep_max_results) - max_files = settings.agent_grep_max_files_scanned - - raw_path = (path or "").strip() - files_to_search: list[Path] = [] - - if raw_path: - resolved_file, file_err = resolve_workspace_file(client_context, raw_path) - if resolved_file is not None: - files_to_search = [resolved_file] - else: - resolved_dir, dir_err = resolve_workspace_directory(client_context, raw_path) - if resolved_dir is None: - return file_err or dir_err or "路径无效。" - files_to_search = _iter_search_files( - resolved_dir, - glob_pattern=glob_pattern, - max_files=max_files, - ) - else: - for root in roots: - files_to_search.extend( - _iter_search_files(root, glob_pattern=glob_pattern, max_files=max_files) - ) - if len(files_to_search) >= max_files: - files_to_search = files_to_search[:max_files] - break - - results: list[str] = [] - files_scanned = 0 - truncated_files = False - - for file_path in files_to_search: - if files_scanned >= max_files: - truncated_files = True - break - files_scanned += 1 - if _grep_single_file( - file_path, - regex, - roots=roots, - max_results=limit, - results=results, - ): - break - - scope = normalize_relative_path(raw_path) if raw_path else "workspace" - header = f"搜索:{needle}\n范围:{scope}\n" - if glob_pattern and glob_pattern != "*": - header += f"文件匹配:{glob_pattern}\n" - - if not results: - header += "未找到匹配。\n" - return header - - truncated_results = len(results) >= limit - if truncated_results: - header += f"(结果已截断,最多 {limit} 条)\n" - if truncated_files: - header += f"(已扫描文件数达上限 {max_files})\n" - header += "---\n" - return header + "\n".join(results) - - -def glob_workspace( - client_context: ClientContextInput | None, - glob_pattern: str, - relative_dir: str = "", - *, - settings: Settings, -) -> str: - """按 glob 递归列出工作区文件(跳过 .git / node_modules / .lr-agent)。""" - pattern = (glob_pattern or "").strip() - if not pattern: - return "请提供 glob_pattern(如 **/*.py、src/**/*.ts)。" - - roots = allowed_roots(client_context) - if not roots: - return "未绑定工作区或项目目录,无法搜索。" - - raw_dir = (relative_dir or "").strip() - if raw_dir: - resolved_dir, dir_err = resolve_workspace_directory(client_context, raw_dir) - if resolved_dir is None: - return dir_err or "目录无效。" - search_roots = [resolved_dir] - else: - search_roots = list(roots) - - max_files = settings.agent_grep_max_files_scanned - files: list[Path] = [] - truncated = False - for search_root in search_roots: - found = _iter_search_files( - search_root, - glob_pattern="*", - max_files=max_files, - ) - for path in found: - rel = relative_path_from_roots(path, roots) - if not _rel_matches_glob(rel, pattern): - continue - files.append(path) - if len(files) >= max_files: - truncated = True - break - if truncated: - break - - rel_dir = relative_path_from_roots(search_roots[0], roots) if search_roots else "" - header = f"glob:{pattern}\n范围:{rel_dir or '.'}\n" - if not files: - return header + "未找到匹配文件。\n" - - header += f"条目数:{len(files)}" - if truncated: - header += f"(已截断,最多 {max_files} 条)" - header += "\n---\n" - lines = [relative_path_from_roots(path, roots) for path in files] - return header + "\n".join(lines) - - -def list_workspace_directory( - client_context: ClientContextInput | None, - relative_dir: str = "", - max_entries: int | None = None, - *, - settings: Settings, -) -> str: - """列出工作区目录下的文件与子目录。""" - resolved, err = resolve_workspace_directory(client_context, relative_dir) - if resolved is None: - return err - - roots = allowed_roots(client_context) - cap = max_entries if max_entries is not None else settings.agent_list_dir_max_entries - cap = min(max(cap, 1), settings.agent_list_dir_max_entries) - - rel_dir = relative_path_from_roots(resolved, roots) if roots else "" - if not rel_dir and relative_dir.strip(): - rel_dir = normalize_relative_path(relative_dir) - - try: - entries = sorted(resolved.iterdir(), key=lambda p: p.name.lower()) - except OSError as exc: - return f"无法读取目录:{exc}" - - lines: list[str] = [] - truncated = False - - for entry in entries: - if len(lines) >= cap: - truncated = True - break - if entry.name.startswith("."): - continue - - child_rel = normalize_relative_path( - f"{rel_dir}/{entry.name}" if rel_dir else entry.name - ) - if entry.is_dir(): - kind = "directory" - elif entry.is_file(): - kind = "file" - else: - continue - lines.append(f"{entry.name} | {kind} | {child_rel}") - - header = f"目录:{rel_dir or '.'}\n" - header += f"条目数:{len(lines)}" - if truncated: - header += f"(已截断,最多 {cap} 条)" - header += "\n---\n" - - if not lines: - return header + "(空目录或无可见条目)\n" - - return header + "\n".join(lines) diff --git a/vendor/local-agent/app/agent/tools/workspace_text_extensions.py b/vendor/local-agent/app/agent/tools/workspace_text_extensions.py deleted file mode 100644 index f37e497..0000000 --- a/vendor/local-agent/app/agent/tools/workspace_text_extensions.py +++ /dev/null @@ -1,168 +0,0 @@ -"""工作区文本文件策略。 - -- 读取/预览:用「二进制/富媒体后缀集合」判断是否可作为纯文本读取。 -- 写盘/编辑:用「文本后缀白名单」,白名单之外一律禁止(默认拒绝), - 从根上阻断 agent 写入脚本/可执行文件再触发执行。 - -须与客户端同仓库的 src/shared/workspaceTextExtensions.ts 保持同步。 -本服务内嵌于客户端 vendor/local-agent/,由 Electron 主进程 spawn。 -""" - -from __future__ import annotations - -# 二进制/富媒体后缀:不可当纯文本读取(read_workspace_file / 搜索跳过) -TEXT_WRITE_BLOCKLIST: frozenset[str] = frozenset( - { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".ico", - ".svg", - ".pdf", - ".docx", - ".doc", - ".zip", - ".rar", - ".7z", - ".exe", - ".dll", - ".so", - ".dylib", - ".mp3", - ".mp4", - ".avi", - ".mov", - ".woff", - ".woff2", - ".ttf", - ".otf", - } -) - -# read_workspace_file 禁止当纯文本读取的格式(与二进制集合一致) -TEXT_BLOCKLIST_SUFFIXES = TEXT_WRITE_BLOCKLIST - -# 允许写盘/编辑的文本与代码后缀白名单(不含点,全小写)。 -# 刻意排除 .bat/.cmd/.ps1/.psm1/.vbs/.wsf/.hta/.scr/.com/.msi/.jar/.reg/.lnk 等。 -TEXT_WRITE_WHITELIST: frozenset[str] = frozenset( - { - # 文档 - "md", - "markdown", - "mdx", - "txt", - "text", - "log", - "rst", - "adoc", - "csv", - "tsv", - # 数据 / 配置 - "json", - "jsonl", - "ndjson", - "jsonc", - "yaml", - "yml", - "toml", - "ini", - "cfg", - "conf", - "config", - "properties", - "env", - "gitignore", - "gitattributes", - "dockerignore", - "editorconfig", - "npmrc", - "nvmrc", - "lock", - # 代码 - "js", - "jsx", - "mjs", - "cjs", - "ts", - "tsx", - "mts", - "cts", - "py", - "pyw", - "pyi", - "java", - "kt", - "kts", - "c", - "h", - "cc", - "cpp", - "cxx", - "hpp", - "hh", - "hxx", - "cs", - "go", - "rs", - "rb", - "php", - "swift", - "m", - "mm", - "lua", - "r", - "pl", - "pm", - "scala", - "dart", - "groovy", - "gradle", - "sh", - "bash", - "zsh", - "fish", - "sql", - "html", - "htm", - "xml", - "css", - "scss", - "sass", - "less", - "vue", - "svelte", - "astro", - "graphql", - "gql", - "proto", - "tf", - "hcl", - "dockerfile", - "makefile", - "cmake", - "mk", - } -) - - -def is_blocked_text_extension(ext: str) -> bool: - """是否禁止写盘/编辑(白名单之外一律禁止)。 - - 与 TS 侧 isBlockedTextExtension 语义一致;二进制/富媒体读取拦截请用 - TEXT_BLOCKLIST_SUFFIXES。 - """ - return not is_allowed_text_extension(ext) - - -def is_allowed_text_extension(ext: str) -> bool: - """仅白名单内的文本/代码后缀可写、可被 str_replace / delete 操作。 - - 无后缀(LICENSE、Makefile、.env、.gitignore 等 Path.suffix 为空)视为文本放行。 - """ - normalized = ext.lower().lstrip(".") - if not normalized: - return True - return normalized in TEXT_WRITE_WHITELIST diff --git a/vendor/local-agent/app/api/__init__.py b/vendor/local-agent/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/api/v1/__init__.py b/vendor/local-agent/app/api/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/api/v1/agent.py b/vendor/local-agent/app/api/v1/agent.py deleted file mode 100644 index 6005ecc..0000000 --- a/vendor/local-agent/app/api/v1/agent.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Agent API — stateless Assist chat/stream for tool-enabled sessions. - -Session/chat CRUD and LLM provider config live in the Electron frontend -(local SQLite storage + direct LLM API calls for simple chat). -""" - -import asyncio -import json -import logging -from typing import Any - -from fastapi import APIRouter -from fastapi.responses import StreamingResponse -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_openai import ChatOpenAI - -from app.agent import assist_service, chat_service -from app.agent.assist.task_phase import ( - derive_task_phase, - phase_prompt_block, -) -from app.agent.assist_mode_router import resolve_assist_tool_set -from app.agent.context_service import CHAT_SYSTEM_PROMPT -from app.agent.context_snapshot import ( - build_assist_system_prompt, - format_runtime_identity_block, -) -from app.agent.tools.mcp_client import ( - load_mcp_tools_from_servers, - probe_mcp_tools, - should_expose_mcp_tool, -) -from app.agent.tools.registry import build_tools_by_name_set -from app.agent.tools.tool_registry_meta import CANONICAL_CAPABILITIES -from app.core.deps import SettingsDep -from app.models.user import User -from app.schemas.agent import ( - ChatCancelRequest, - ClientContextInput, - LocalChatStreamRequest, - McpProbeRequest, - StreamEventPayload, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/agent", tags=["agent"]) - -_cancel_events: dict[str, asyncio.Event] = {} - - -def _get_or_create_cancel_event(client_job_id: str) -> asyncio.Event: - if client_job_id not in _cancel_events: - _cancel_events[client_job_id] = asyncio.Event() - return _cancel_events[client_job_id] - - -def _cleanup_cancel_event(client_job_id: str) -> None: - _cancel_events.pop(client_job_id, None) - - -def _anonymous_user() -> User: - """Minimal anonymous User for tool building in stateless mode.""" - import uuid - - return User( - id=uuid.uuid4(), - email="anonymous@local", - email_verified=False, - username="anonymous", - display_name="本地匿名用户", - ) - - -def _build_lc_messages_from_local( - body: LocalChatStreamRequest, - system_prompt: str, -) -> list: - """Build LangChain messages from LocalChatStreamRequest (no DB dependency).""" - lc_messages: list = [SystemMessage(content=system_prompt)] - if body.context_summary: - lc_messages.append( - SystemMessage(content=f"【此前对话摘要】\n{body.context_summary}"), - ) - for item in body.messages: - if item.role == "user": - lc_messages.append(HumanMessage(content=item.content or "")) - elif item.role == "assistant": - tool_calls = [ - {"id": tc.id, "name": tc.name, "args": tc.args} - for tc in (item.tool_calls or []) - ] - lc_messages.append( - AIMessage( - content=item.content or "", - tool_calls=tool_calls, - ) - if tool_calls - else AIMessage(content=item.content or "") - ) - elif item.role == "tool": - if not (item.tool_call_id or "").strip(): - continue - lc_messages.append( - ToolMessage( - content=item.content or "", - tool_call_id=item.tool_call_id, - ) - ) - elif item.role == "system": - lc_messages.append(SystemMessage(content=item.content or "")) - return lc_messages - - -async def _stream_local_chat(body: LocalChatStreamRequest, settings) -> Any: - """无状态 chat/stream 生成器——所有数据从请求体获取,不读 DB。""" - client_job_id: str = body.client_job_id - cancel_event = _get_or_create_cancel_event(client_job_id) - - async def is_cancelled() -> bool: - return cancel_event.is_set() - - try: - base_url = body.base_url.rstrip("/") - llm = ChatOpenAI( - model=body.model, - api_key=body.api_key, - base_url=base_url, - streaming=True, - temperature=0.7, - timeout=120, - ) - - # 辅助模型:子代理查阅等轻量调用使用;未配置时跟随主模型 - aux_llm: ChatOpenAI | None = None - if ( - body.aux_model.strip() - and body.aux_api_key.strip() - and body.aux_base_url.strip() - ): - aux_llm = ChatOpenAI( - model=body.aux_model.strip(), - api_key=body.aux_api_key.strip(), - base_url=body.aux_base_url.strip().rstrip("/"), - streaming=True, - temperature=0.7, - timeout=120, - ) - - client_ctx: ClientContextInput | None = body.client_context - has_tools = bool( - client_ctx - and ( - (client_ctx.workspace_root or "").strip() - or client_ctx.active_annotation_project_id - or client_ctx.annotation_project_snapshot is not None - ) - ) - - if has_tools: - user = _anonymous_user() - - # 标注任务阶段机:从结构化提案状态推导阶段(无状态,每次请求重推导) - task_phase_ctx = derive_task_phase( - client_ctx.proposal_states if client_ctx else None, - ) - - identity = format_runtime_identity_block( - model=body.model, - provider_label="local", - supports_vision=body.supports_vision, - ) - if client_ctx: - system_prompt = build_assist_system_prompt( - client_ctx, - model=body.model, - provider_label="local", - supports_vision=body.supports_vision, - ) - else: - system_prompt = f"{identity}\n\n{CHAT_SYSTEM_PROMPT}" - phase_block = phase_prompt_block(task_phase_ctx) - if phase_block: - system_prompt = f"{system_prompt}\n\n{phase_block}" - - lc_messages = _build_lc_messages_from_local(body, system_prompt) - - has_workspace = bool(client_ctx and (client_ctx.workspace_root or "").strip()) - has_project_snapshot = bool( - client_ctx and client_ctx.annotation_project_snapshot is not None - ) - is_editor = bool(client_ctx and client_ctx.work_mode == "editor") - - tool_set = resolve_assist_tool_set( - has_project_snapshot=has_project_snapshot, - agent_mode=client_ctx.agent_mode if client_ctx else None, - is_editor=is_editor, - has_workspace=has_workspace, - ) - tools = build_tools_by_name_set( - user, - client_ctx, - tool_set, - settings=settings, - provider_is_vision=body.supports_vision, - ) - - mcp_url = ( - (client_ctx.mcp_server_url or "").strip() - if client_ctx - else "" - ) - remote_mcp = list(client_ctx.mcp_servers) if client_ctx else [] - local_mcp_token = ( - (client_ctx.mcp_server_token or "").strip() if client_ctx else "" - ) - if mcp_url or remote_mcp: - # 先发一个 preparing 事件,MCP 发现(建连 + 列工具)期间渲染层即有反馈 - yield ( - "data: " - + json.dumps( - StreamEventPayload(type="preparing", stage="mcp").to_sse_dict(), - ensure_ascii=False, - ) - + "\n\n" - ) - try: - mcp_tools = await load_mcp_tools_from_servers( - mcp_url or None, - remote_mcp, - local_server_token=local_mcp_token or None, - existing_capabilities=CANONICAL_CAPABILITIES, - ttl_seconds=settings.agent_mcp_tools_ttl_seconds, - ) - if mcp_tools: - memory_on = bool( - client_ctx and client_ctx.workspace_memory_enabled - ) - existing = {t.name for t in tools} - tools = tools + [ - t - for t in mcp_tools - if t.name not in existing - and should_expose_mcp_tool( - t.name, - workspace_memory_enabled=memory_on, - agent_mode=client_ctx.agent_mode if client_ctx else None, - ) - ] - except Exception: - logger.warning( - "Failed to load MCP tools", - exc_info=True, - ) - - # tools 列表保持稳定(按名称排序):阶段门禁不在 bind 时增删工具, - # 交由执行层 check_call_allowed 拦截(tool_loop.py), - # 避免 tools 前缀随任务阶段变化而破坏 LLM 前缀缓存。 - tools = sorted(tools, key=lambda t: t.name) - - stream = assist_service.stream_assist( - llm, - lc_messages, - tools, - settings=settings, - max_tool_rounds=settings.agent_max_tool_rounds, - is_cancelled=is_cancelled, - provider_is_vision=body.supports_vision, - client_context=client_ctx, - user_content=body.user_content, - client_tool_results=body.client_tool_results or None, - task_phase_ctx=task_phase_ctx, - aux_llm=aux_llm, - ) - else: - system_prompt = body.system_prompt or CHAT_SYSTEM_PROMPT - lc_messages = _build_lc_messages_from_local(body, system_prompt) - stream = chat_service.stream_chat(llm, lc_messages) - - async for event in stream: - if await is_cancelled(): - break - yield f"data: {json.dumps(event.to_sse_dict(), ensure_ascii=False)}\n\n" - - yield 'data: {"type": "done"}\n\n' - - finally: - _cleanup_cancel_event(client_job_id) - - -@router.post("/chat/stream") -async def local_chat_stream( - body: LocalChatStreamRequest, - settings: SettingsDep, -) -> StreamingResponse: - """无状态 chat/stream —— 所有数据从请求体获取,不读 DB。""" - return StreamingResponse( - _stream_local_chat(body, settings), - media_type="text/event-stream", - ) - - -@router.post("/chat/cancel") -async def cancel_chat( - body: ChatCancelRequest, -) -> dict[str, bool]: - """取消正在进行的 chat/stream 任务。""" - event = _cancel_events.get(body.client_job_id) - if event: - event.set() - return {"ok": True} - - -@router.post("/mcp/probe") -async def probe_mcp_server(body: McpProbeRequest) -> dict: - """连接单个 MCP Server 并列出工具名(配置页「测试连接」,不走 LLM)。""" - url = body.url.strip() - if not url.startswith(("http://", "https://")): - return {"ok": False, "error": "仅支持 http(s) MCP 端点"} - try: - tools = await probe_mcp_tools( - url, - transport=body.transport, - headers=body.headers, - ) - return {"ok": True, "tools": [t.name for t in tools]} - except Exception as exc: - logger.info("MCP probe 失败:%s (%s)", url, exc) - return {"ok": False, "error": str(exc)} diff --git a/vendor/local-agent/app/api/v1/annotation_agent.py b/vendor/local-agent/app/api/v1/annotation_agent.py deleted file mode 100644 index 3a698c1..0000000 --- a/vendor/local-agent/app/api/v1/annotation_agent.py +++ /dev/null @@ -1,341 +0,0 @@ -import base64 -import logging - -from fastapi import APIRouter, HTTPException, status -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI -from openai import BadRequestError - -from app.agent.annotation import ( - heuristic_map_boxes, - map_detection_boxes_to_labels_unified, - prepare_batch_annotation, -) -from app.agent.annotation.debug_log import log_annotation_agent -from app.agent.annotation.image_bytes_loader import ( - image_bytes_to_data_url, - load_image_bytes, -) -from app.agent.annotation.mutation_prepare_service import prepare_mutation_annotation -from app.agent.annotation.schemas import AnnotationScopePayload -from app.core.deps import SettingsDep -from app.schemas.annotation_agent import ( - BatchPrepareRequest, - HeuristicMapRequest, - LlmGenerateRequest, - MapDetectionBoxesRequest, - MutationPrepareRequest, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/agent/annotation", tags=["agent-annotation"]) - - -def _http_from_llm_error(exc: Exception) -> HTTPException: - msg = str(exc) - if isinstance(exc, BadRequestError): - return HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=msg or "llm_bad_request", - ) - if "response_format" in msg.lower() or "json_schema" in msg.lower(): - return HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="当前模型不支持结构化输出,请使用普通对话模型或更换提供商", - ) - logger.exception("annotation_llm_error") - return HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=msg[:500] if msg else "llm_invoke_failed", - ) - - -def _require_direct_llm( - *, - api_key: str, - base_url: str, - model: str, - temperature: float, - streaming: bool = False, -) -> ChatOpenAI: - if not api_key.strip() or not base_url.strip() or not model.strip(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="provider_credentials_required", - ) - return ChatOpenAI( - model=model.strip(), - api_key=api_key.strip(), - base_url=base_url.strip().rstrip("/"), - streaming=streaming, - temperature=temperature, - timeout=120, - ) - - -@router.post("/map-heuristic", summary="启发式检测框映射(无 LLM)") -async def api_map_heuristic( - body: HeuristicMapRequest, -): - mappings = heuristic_map_boxes( - body.boxes, - body.label_candidates, - ocr_text=body.ocr_text, - ) - return {"data": {"mappings": mappings, "method": "heuristic"}} - - -@router.post("/mutation-prepare", summary="标注变更准备(改标签/删框,单次 LLM)") -async def api_mutation_prepare( - body: MutationPrepareRequest, - settings: SettingsDep, -): - if not settings.agent_mutation_enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="agent_mutation_disabled", - ) - try: - llm = _require_direct_llm( - api_key=body.api_key, - base_url=body.base_url, - model=body.model, - temperature=settings.annotation_prepare_temperature, - ) - label_names = None - if body.project is not None: - label_names = [ - str(label.get("name") or "") - for label in (body.project.labels or []) - if str(label.get("name") or "").strip() - ] - - result = await prepare_mutation_annotation( - llm, - user_request=body.user_request, - current_relative_path=(body.current_relative_path or "").strip(), - candidates=[candidate.model_dump() for candidate in body.candidates], - label_names=label_names or [], - selected_annotation_ids=body.selected_annotation_ids or None, - conversation_transcript=body.conversation_transcript, - ) - return { - "data": { - **result.model_dump(), - "resolved_user_request": body.user_request, - } - } - except HTTPException: - raise - except Exception as exc: - raise _http_from_llm_error(exc) from exc - - -@router.post("/batch-prepare", summary="批量准备(范围+计划,单次 LLM)") -async def api_batch_prepare( - body: BatchPrepareRequest, - settings: SettingsDep, -): - try: - llm = _require_direct_llm( - api_key=body.api_key, - base_url=body.base_url, - model=body.model, - temperature=settings.annotation_prepare_temperature, - ) - provider_is_vision = body.supports_vision - - label_names = None - project_name = None - if body.project is not None: - project_name = body.project.name or None - label_names = [ - str(label.get("name") or "") - for label in (body.project.labels or []) - if str(label.get("name") or "").strip() - ] - - current_rel = (body.current_relative_path or "").strip() - candidates = [candidate.model_dump() for candidate in body.candidates] - result = await prepare_batch_annotation( - llm, - user_request=body.user_request, - current_relative_path=current_rel, - candidates=candidates, - label_candidates=body.label_candidates, - detection_models=body.detection_models, - default_conf=body.default_conf_threshold, - default_iou=body.default_iou_threshold, - provider_is_vision=provider_is_vision, - project_name=project_name, - label_names=label_names, - conversation_transcript=body.conversation_transcript, - preselected_paths=body.preselected_paths or None, - ) - payload = { - "selected_paths": result.selected_paths, - "scope_reason": result.scope_reason, - "resolved_user_request": body.user_request, - **result.plan.model_dump(), - } - return {"data": payload} - except HTTPException: - raise - except Exception as exc: - raise _http_from_llm_error(exc) from exc - - -@router.post("/map-detection-boxes", summary="Fusion 统一检测框映射(启发式或逐框视觉)") -async def api_map_detection_boxes( - body: MapDetectionBoxesRequest, - settings: SettingsDep, -): - try: - provider_is_vision = body.supports_vision - use_vision_requested = bool(body.use_vision) - use_vision = use_vision_requested and provider_is_vision - llm = None - if use_vision: - llm = _require_direct_llm( - api_key=body.api_key, - base_url=body.base_url, - model=body.model, - temperature=settings.annotation_llm_temperature, - ) - log_annotation_agent( - "map-api", - "map-detection-boxes 请求", - provider_id=body.provider_id, - provider_name=body.model, - provider_model=body.model, - provider_is_vision=provider_is_vision, - vision_probe_detail="direct", - use_vision_requested=use_vision_requested, - use_vision_effective=use_vision, - box_count=len(body.boxes), - has_image_bytes=bool( - load_image_bytes( - image_absolute_path=body.image_absolute_path, - image_base64=body.image_base64, - )[0] - ), - image_absolute_path=bool(body.image_absolute_path.strip()), - label_names=[ - str(candidate.get("name") or "") - for candidate in (body.label_candidates or [])[:20] - ], - ) - scope = AnnotationScopePayload.model_validate(body.annotation_scope or {}) - result = await map_detection_boxes_to_labels_unified( - llm, - user_request=body.user_request, - intent_summary=body.intent_summary, - label_candidates=body.label_candidates, - boxes=body.boxes, - use_vision=use_vision, - ocr_text=body.ocr_text, - scope=scope, - label_strategy=body.label_strategy, - single_label_id=body.single_label_id, - image_absolute_path=body.image_absolute_path, - image_base64=body.image_base64, - mime_type=body.mime_type, - ) - log_annotation_agent( - "map-api-result", - "map-detection-boxes 响应", - ok=result.get("ok"), - method=result.get("method"), - mapped=len(result.get("mappings") or []) - - len(result.get("unmapped_indices") or []), - unmapped=len(result.get("unmapped_indices") or []), - label_pool_source=result.get("label_pool_source"), - hint=result.get("hint"), - ) - return {"data": result} - except HTTPException: - raise - except Exception as exc: - raise _http_from_llm_error(exc) from exc - - -def _build_generate_image_data_url( - body: LlmGenerateRequest, - settings, -) -> str: - """组装 llm-generate 的多模态图片 data URL。 - - 路径优先、base64 兜底;统一压缩为 JPEG(长边 / 质量见 Settings)。 - 请求声明了图片但字节无法加载时抛 400,让前端跳过该文件(与旧的前端读图 - 失败行为一致);压缩失败则回退发送未压缩原图。 - """ - wants_image = bool( - (body.image_absolute_path or "").strip() or (body.image_base64 or "").strip() - ) - if not wants_image: - return "" - - raw, _source = load_image_bytes( - image_absolute_path=body.image_absolute_path, - image_base64=body.image_base64, - ) - if raw is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="image_load_failed", - ) - try: - return image_bytes_to_data_url( - raw, - max_edge=settings.annotation_llm_image_max_edge, - jpeg_quality=settings.annotation_llm_image_jpeg_quality, - ) - except Exception: - logger.warning("llm-generate image resize failed, send raw", exc_info=True) - return f"data:{body.image_mime_type};base64,{base64.b64encode(raw).decode('ascii')}" - - -@router.post("/llm-generate", summary="通用 LLM 生成代理(caption/cot/instruction 等)") -async def api_llm_generate( - body: LlmGenerateRequest, - settings: SettingsDep, -): - try: - llm = _require_direct_llm( - api_key=body.api_key, - base_url=body.base_url, - model=body.model, - temperature=body.temperature, - ) - llm = llm.bind(max_tokens=body.max_tokens) - - messages = [] - if body.system_prompt.strip(): - messages.append(SystemMessage(content=body.system_prompt)) - - image_data_url = _build_generate_image_data_url(body, settings) - if image_data_url: - human_content = [ - {"type": "text", "text": body.user_prompt or ""}, - { - "type": "image_url", - "image_url": {"url": image_data_url}, - }, - ] - messages.append(HumanMessage(content=human_content)) - else: - messages.append(HumanMessage(content=body.user_prompt or "")) - - resp = await llm.ainvoke(messages) - content = resp.content if hasattr(resp, "content") else str(resp) - if isinstance(content, list): - content = "".join( - part.get("text", "") if isinstance(part, dict) else str(part) - for part in content - ) - return {"data": {"ok": True, "content": str(content)}} - except HTTPException: - raise - except Exception as exc: - raise _http_from_llm_error(exc) from exc diff --git a/vendor/local-agent/app/api/v1/annotation_quality.py b/vendor/local-agent/app/api/v1/annotation_quality.py deleted file mode 100644 index 9b77631..0000000 --- a/vendor/local-agent/app/api/v1/annotation_quality.py +++ /dev/null @@ -1,93 +0,0 @@ -import json -import logging -from collections.abc import AsyncIterator - -from fastapi import APIRouter, HTTPException, status -from fastapi.responses import StreamingResponse -from langchain_openai import ChatOpenAI - -from app.agent.annotation.quality_report_compose_service import ( - stream_quality_report_compose, -) -from app.agent.text_sanitize import sanitize_json_value -from app.core.deps import SettingsDep -from app.schemas.annotation_quality import QualityReportComposeRequest - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/agent/annotation-quality", tags=["annotation-quality"]) - - -def _require_direct_llm( - *, - api_key: str, - base_url: str, - model: str, -) -> ChatOpenAI: - if not api_key.strip() or not base_url.strip() or not model.strip(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="provider_credentials_required", - ) - return ChatOpenAI( - model=model.strip(), - api_key=api_key.strip(), - base_url=base_url.strip().rstrip("/"), - streaming=True, - temperature=0.2, - timeout=120, - ) - - -def _validate_compose_payload(payload: dict, settings: SettingsDep) -> None: - payload_bytes = len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) - max_bytes = 512_000 - if payload_bytes > max_bytes: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="compose_payload_too_large", - ) - - -async def _compose_sse( - llm, - *, - compose_payload: dict, - settings: SettingsDep, -) -> AsyncIterator[str]: - try: - async for event in stream_quality_report_compose( - llm, - compose_payload=compose_payload, - ): - yield f"data: {json.dumps(event.to_sse_dict(), ensure_ascii=False)}\n\n" - yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n" - except Exception: - logger.exception("quality_report_compose_stream_error") - err = {"type": "error", "message": "quality_report_compose_failed"} - if settings.debug: - err["detail"] = "quality_report_compose_failed" - yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" - - -@router.post("/report/compose/stream", summary="流式撰写标注质量报告") -async def api_quality_report_compose_stream( - body: QualityReportComposeRequest, - settings: SettingsDep, -) -> StreamingResponse: - _validate_compose_payload(body.compose_payload, settings) - - llm = _require_direct_llm( - api_key=body.api_key, - base_url=body.base_url, - model=body.model, - ) - - return StreamingResponse( - _compose_sse( - llm, - compose_payload=sanitize_json_value(body.compose_payload), - settings=settings, - ), - media_type="text/event-stream", - ) diff --git a/vendor/local-agent/app/api/v1/router.py b/vendor/local-agent/app/api/v1/router.py deleted file mode 100644 index ed949fc..0000000 --- a/vendor/local-agent/app/api/v1/router.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import APIRouter - -from app.api.v1 import ( - agent, - annotation_agent, - annotation_quality, -) - -api_router = APIRouter() -api_router.include_router(agent.router) -api_router.include_router(annotation_agent.router) -api_router.include_router(annotation_quality.router) diff --git a/vendor/local-agent/app/core/__init__.py b/vendor/local-agent/app/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/core/config.py b/vendor/local-agent/app/core/config.py deleted file mode 100644 index 5185734..0000000 --- a/vendor/local-agent/app/core/config.py +++ /dev/null @@ -1,63 +0,0 @@ -"""LR-Agent-local 本地配置。 - -仅保留 Agent 编排相关字段;账号 / 数据库 / Redis / MinIO / SMTP 等云端配置 -仍由 LR-Agent-backend 持有,本地服务不涉及。 -""" - -from functools import lru_cache -from typing import Literal - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - app_name: str = "LR-Agent Local" - debug: bool = False - api_v1_prefix: str = "/api/v1" - - # 本地服务只监听 127.0.0.1,CORS 默认放开 Electron renderer - cors_origins: list[str] = ["http://localhost:1212"] - - # Assist 工具循环(可用 AGENT_MAX_TOOL_ROUNDS / AGENT_SUBAGENT_MAX_TOOL_ROUNDS 覆盖) - agent_max_tool_rounds: int = 30 - agent_subagent_max_tool_rounds: int = 12 - # MCP 工具发现结果缓存时长(秒):避免每条消息都重新建连列工具。 - # 过期后走 stale-while-revalidate(先返回旧列表、后台刷新),所以这里可以放长。 - agent_mcp_tools_ttl_seconds: float = 1800.0 - - # 上下文 / 读取限制 - agent_chat_vision_max_edge: int = 1280 - agent_chat_vision_jpeg_quality: int = 85 - agent_read_file_max_bytes: int = 524_288 - agent_read_file_max_lines: int = 2000 - agent_read_document_max_pages: int = 30 - agent_grep_max_results: int = 100 - agent_grep_max_files_scanned: int = 1000 - agent_list_dir_max_entries: int = 80 - - # 功能开关 - agent_mutation_enabled: bool = True - - # 标注 LLM - annotation_llm_temperature: float = 0.0 - annotation_prepare_temperature: float = 0.1 - # 生成类标注(caption/分类等)发送给 LLM 的图片压缩参数 - annotation_llm_image_max_edge: int = 1280 - annotation_llm_image_jpeg_quality: int = 85 - annotation_vision_map_concurrency: int = 3 - annotation_vision_map_validate: bool = True - annotation_vision_map_max_retries: int = 1 - annotation_label_pool_preflight: Literal["off", "auto", "always"] = "auto" - annotation_label_pool_preflight_min_extra: int = 2 - - -@lru_cache -def get_settings() -> Settings: - return Settings() diff --git a/vendor/local-agent/app/core/deps.py b/vendor/local-agent/app/core/deps.py deleted file mode 100644 index 2800e5f..0000000 --- a/vendor/local-agent/app/core/deps.py +++ /dev/null @@ -1,51 +0,0 @@ -"""本地服务依赖注入:Settings + 本地 token 鉴权。 - -LR_AGENT_LOCAL_TOKEN 由 Electron 主进程在 spawn 子进程时通过环境变量注入, -渲染层请求时携带 `Authorization: Bearer `。失败关闭:未配置 token 时 -拒绝所有 API 请求(仅 /health 保持开放),避免本机其它进程或网页调用该服务。 -""" - -import hmac -import logging -import os -from typing import Annotated - -from fastapi import Depends, HTTPException, Request, status - -from app.core.config import Settings, get_settings - -logger = logging.getLogger(__name__) - -SettingsDep = Annotated[Settings, Depends(get_settings)] - -LOCAL_TOKEN_ENV = "LR_AGENT_LOCAL_TOKEN" -_BEARER_PREFIX = "bearer " - - -def get_expected_local_token() -> str | None: - return (os.environ.get(LOCAL_TOKEN_ENV) or "").strip() or None - - -async def require_local_token(request: Request) -> None: - """校验本机服务访问 token。""" - expected = get_expected_local_token() - if expected is None: - logger.error( - "LR_AGENT_LOCAL_TOKEN 未配置:拒绝 API 请求(仅 /health 可用)" - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="local_token_not_configured", - ) - - header = request.headers.get("authorization") or "" - provided = ( - header[len(_BEARER_PREFIX):].strip() - if header.lower().startswith(_BEARER_PREFIX) - else "" - ) - if not provided or not hmac.compare_digest(provided, expected): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="unauthorized", - ) diff --git a/vendor/local-agent/app/models/__init__.py b/vendor/local-agent/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/models/user.py b/vendor/local-agent/app/models/user.py deleted file mode 100644 index 606ce78..0000000 --- a/vendor/local-agent/app/models/user.py +++ /dev/null @@ -1,23 +0,0 @@ -"""本地 User stub。 - -云端 SQLAlchemy User 模型的轻量替代:Agent 工具(如 get_account_summary) -只需要读取用户基本字段,本地服务无数据库,由 Electron 在 client_context 中 -携带账号信息;未登录时使用匿名 stub。 -""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass, field - - -@dataclass -class User: - email: str - id: uuid.UUID = field(default_factory=uuid.uuid4) - email_verified: bool = False - username: str | None = None - display_name: str | None = None - # 兼容云端模型字段;本地服务不使用,仅避免调用方传参报错 - password_hash: str = "" - is_active: bool = True diff --git a/vendor/local-agent/app/schemas/__init__.py b/vendor/local-agent/app/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/local-agent/app/schemas/agent.py b/vendor/local-agent/app/schemas/agent.py deleted file mode 100644 index d3cbb31..0000000 --- a/vendor/local-agent/app/schemas/agent.py +++ /dev/null @@ -1,250 +0,0 @@ -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class ChatToolCallInput(BaseModel): - """OpenAI 兼容的 assistant tool_call,用于 resume 还原本轮工具对话。""" - - id: str = Field(min_length=1, max_length=128) - name: str = Field(min_length=1, max_length=128) - args: dict[str, Any] = Field(default_factory=dict) - - -class ChatMessageInput(BaseModel): - role: Literal["user", "assistant", "system", "tool"] - content: str = "" - message_id: str | None = None - interaction_mode: Literal["chat", "annotation"] | None = None - tool_calls: list[ChatToolCallInput] | None = None - tool_call_id: str | None = Field( - default=None, - description="role=tool 时对应的 tool_call id。", - ) - - -class AnnotationProjectSnapshotInput(BaseModel): - project_id: str = Field(min_length=1, max_length=64) - name: str = "" - modality: str = "" - annotation_type: str = "" - labels: list[dict[str, Any]] = Field(default_factory=list) - detection_models: list[dict[str, Any]] = Field(default_factory=list) - project_directory_path: str | None = None - - -class SkillCatalogEntryInput(BaseModel): - """全局 Agent Skill 目录条目(catalog,仅 name + description 注入 prompt)。""" - - name: str = Field(min_length=1, max_length=128) - description: str = Field(max_length=512) - - -class ProposalStateInput(BaseModel): - """会话中单个提案变更的结构化状态(由前端消息块构建,随请求发送)。""" - - path: str = Field(min_length=1, description="提案涉及的相对路径") - kind: Literal["annotation", "file"] = "annotation" - status: Literal["pending", "applied", "dismissed", "undone"] = "pending" - operation: str | None = None - annotation_ids: list[str] = Field( - default_factory=list, - description="该提案涉及的标注实例 id(append/replace 的 annotations、delete 的 deleteIds、patch 的 patches)", - ) - - -class McpServerInput(BaseModel): - """用户配置的远程 MCP Server(前端 userData/mcp.json 中已启用项,随 client_context 注入)。""" - - id: str = Field(min_length=1, max_length=64) - url: str = Field(min_length=1) - transport: Literal["streamable_http", "sse"] = "streamable_http" - headers: dict[str, str] = Field(default_factory=dict) - disabled_tools: list[str] = Field(default_factory=list) - - -class McpProbeRequest(BaseModel): - """测试连接单个 MCP Server(配置页使用,不走 LLM)。""" - - url: str = Field(min_length=1) - transport: Literal["streamable_http", "sse"] = "streamable_http" - headers: dict[str, str] = Field(default_factory=dict) - - -class ClientContextInput(BaseModel): - workspace_root: str | None = None - active_file_path: str | None = None - active_relative_path: str | None = None - project_directory_path: str | None = None - active_annotation_project_id: str | None = None - annotation_project_modality: str | None = None - annotation_project_type: str | None = None - agent_mode: Literal["chat", "annotation"] | None = None - work_mode: Literal["editor", "annotation"] | None = None - selected_annotation_id: str | None = None - selected_annotation_ids: list[str] = Field(default_factory=list) - annotation_project_snapshot: AnnotationProjectSnapshotInput | None = None - mcp_server_url: str | None = None - mcp_server_token: str | None = None - mcp_servers: list[McpServerInput] = Field(default_factory=list) - project_instructions: str | None = None - memory_index: str | None = None - workspace_memory_enabled: bool = False - skills_catalog: list[SkillCatalogEntryInput] = Field(default_factory=list) - proposal_ledger: str | None = None - proposal_states: list[ProposalStateInput] = Field(default_factory=list) - - -class ClientToolResult(BaseModel): - """客户端执行工具后返回的结果,随下一轮 /chat/stream 请求一并发送。""" - - tool_call_id: str = Field(min_length=1, max_length=128) - name: str = Field(min_length=1, max_length=128) - result: str = Field(description="工具执行结果(JSON 序列化字符串)") - - -class LocalChatStreamRequest(BaseModel): - """Lightweight chat/stream request — no DB dependency.""" - - api_key: str = Field(min_length=1) - base_url: str = Field(min_length=1) - model: str = Field(min_length=1) - supports_vision: bool = False - # 辅助模型(子代理查阅等轻量调用);三者齐全才生效,否则跟随主模型 - aux_model: str = "" - aux_api_key: str = "" - aux_base_url: str = "" - messages: list[ChatMessageInput] - user_content: str = Field(min_length=1) - system_prompt: str | None = None - context_summary: str | None = None - context_summary_up_to_message_id: str | None = None - client_context: ClientContextInput | None = None - client_tool_results: list[ClientToolResult] = Field(default_factory=list) - client_job_id: str = Field(min_length=1, max_length=64) - - -class ChatCancelRequest(BaseModel): - client_job_id: str = Field(min_length=1, max_length=64) - - -class ClientToolCallPayload(BaseModel): - """tool_pending SSE 事件中单个客户端工具调用的描述。""" - - tool_call_id: str - name: str - arguments: dict[str, Any] = Field(default_factory=dict) - - -class StreamEventPayload(BaseModel): - """SSE 流事件载荷。""" - - type: str - content: str | None = None - stage: str | None = None - status: str | None = None - detail: str | None = None - proposal: dict[str, Any] | None = None - summary: str | None = None - summary_up_to_message_id: str | None = None - token_estimate: int | None = None - tool_call_id: str | None = None - name: str | None = None - arguments: str | None = None - result: str | None = None - message: str | None = None - image_path: str | None = None - mode: str | None = None - domain: str | None = None - target: str | None = None - reason: str | None = None - client_tool_calls: list[ClientToolCallPayload] | None = None - query: str | None = None - focus_path: str | None = None - inner_tool_call_id: str | None = None - # file_edit_delta:str_replace 流式期间 old_string / new_string 的增量片段 - old_delta: str | None = None - new_delta: str | None = None - # rename 提案:原路径(relative_path 为新路径) - old_path: str | None = None - - def to_sse_dict(self) -> dict[str, Any]: - data: dict[str, Any] = {"type": self.type} - if self.content is not None: - data["content"] = self.content - if self.stage is not None: - data["stage"] = self.stage - if self.status is not None: - data["status"] = self.status - if self.detail is not None: - data["detail"] = self.detail - if self.proposal is not None: - data["proposal"] = self.proposal - if self.summary is not None: - data["summary"] = self.summary - if self.summary_up_to_message_id is not None: - data["summaryUpToMessageId"] = self.summary_up_to_message_id - if self.token_estimate is not None: - data["tokenEstimate"] = self.token_estimate - if self.tool_call_id is not None: - data["toolCallId"] = self.tool_call_id - if self.name is not None: - data["name"] = self.name - if self.arguments is not None: - data["arguments"] = self.arguments - if self.result is not None: - data["result"] = self.result - if self.message is not None: - data["message"] = self.message - if self.image_path is not None: - if self.type in ("file_proposal_start", "file_proposal_delta", "file_proposal", "document_proposal"): - data["suggestedRelativePath"] = self.image_path - else: - data["imagePath"] = self.image_path - if self.type in ("file_proposal_start", "file_proposal", "document_proposal"): - if self.summary is not None: - data["title"] = self.summary - if self.detail is not None and "title" not in data: - data["title"] = self.detail - if self.mode is not None: - data["mode"] = self.mode - if self.type in ( - "file_proposal_start", - "file_proposal_delta", - "file_proposal", - "document_proposal", - ): - data["operation"] = self.mode - if self.old_path is not None and self.type in ( - "file_proposal_start", - "file_proposal", - ): - data["oldPath"] = self.old_path - if self.domain is not None: - data["domain"] = self.domain - if self.target is not None: - data["target"] = self.target - if self.reason is not None: - data["reason"] = self.reason - if self.query is not None: - data["query"] = self.query - if self.focus_path is not None: - data["focusPath"] = self.focus_path - if self.inner_tool_call_id is not None: - data["innerToolCallId"] = self.inner_tool_call_id - if self.old_delta is not None: - data["oldDelta"] = self.old_delta - if self.new_delta is not None: - data["newDelta"] = self.new_delta - if self.client_tool_calls is not None: - serialized = [ - { - "toolCallId": call.tool_call_id, - "name": call.name, - "arguments": call.arguments, - } - for call in self.client_tool_calls - ] - data["clientToolCalls"] = serialized - data["toolCalls"] = serialized - return data diff --git a/vendor/local-agent/app/schemas/annotation_agent.py b/vendor/local-agent/app/schemas/annotation_agent.py deleted file mode 100644 index 28d8df4..0000000 --- a/vendor/local-agent/app/schemas/annotation_agent.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Any - -from pydantic import BaseModel, Field - - -class AnnotationProjectSnapshotInput(BaseModel): - project_id: str = Field(min_length=1, max_length=64) - name: str = "" - modality: str = "image" - annotation_type: str = "bbox" - labels: list[dict[str, Any]] = Field(default_factory=list) - - -class ImageCandidateInput(BaseModel): - relative_path: str - name: str = "" - parent: str = "" - index: int = 0 - - -class AnnotationLlmBaseRequest(BaseModel): - provider_id: str = Field(min_length=1, max_length=64) - # 前端直传模式:当 Electron 本地配置与后端 DB 不同步时,直接传入凭据,跳过 DB 查询 - api_key: str = "" - base_url: str = "" - model: str = "" - supports_vision: bool = False - - -class MutationPrepareRequest(AnnotationLlmBaseRequest): - user_request: str = Field(min_length=1, max_length=20_000) - session_id: str | None = Field(default=None, max_length=64) - conversation_transcript: str = Field(default="", max_length=24_000) - current_relative_path: str = "" - candidates: list[ImageCandidateInput] = Field(default_factory=list) - label_candidates: list[dict[str, Any]] = Field(default_factory=list) - selected_annotation_ids: list[str] = Field(default_factory=list) - project: AnnotationProjectSnapshotInput | None = None - - -class BatchPrepareRequest(AnnotationLlmBaseRequest): - """Single-shot scope + plan (replaces separate parse-task / parse-scope / create-plan in batch).""" - - user_request: str = Field(min_length=1, max_length=20_000) - preselected_paths: list[str] = Field( - default_factory=list, - description="客户端显式指定的图片路径(如 UI 勾选);非空则跳过 LLM 选图,仅生成执行计划", - ) - session_id: str | None = Field(default=None, max_length=64) - conversation_transcript: str = Field(default="", max_length=24_000) - current_relative_path: str = "" - candidates: list[ImageCandidateInput] = Field(default_factory=list) - label_candidates: list[dict[str, Any]] = Field(default_factory=list) - detection_models: list[dict[str, Any]] = Field(default_factory=list) - default_conf_threshold: float = Field(default=0.7, ge=0.05, le=0.95) - default_iou_threshold: float = Field(default=0.5, ge=0.05, le=0.95) - project: AnnotationProjectSnapshotInput | None = None - - -class HeuristicMapRequest(AnnotationLlmBaseRequest): - boxes: list[dict[str, Any]] = Field(default_factory=list) - label_candidates: list[dict[str, Any]] = Field(default_factory=list) - ocr_text: str = "" - - -class MapDetectionBoxesRequest(AnnotationLlmBaseRequest): - """Fusion-style unified mapping (heuristic or vision_crop only).""" - - user_request: str = Field(default="", max_length=20_000) - intent_summary: str = "" - label_candidates: list[dict[str, Any]] = Field(default_factory=list) - boxes: list[dict[str, Any]] = Field(default_factory=list) - use_vision: bool = False - label_strategy: str = "map_each_box_to_label" - single_label_id: str | None = None - annotation_scope: dict[str, Any] = Field(default_factory=dict) - ocr_text: str = "" - image_absolute_path: str = Field(default="", max_length=1024) - image_base64: str = Field(default="", max_length=16_000_000) - mime_type: str = "image/jpeg" - - -class LlmGenerateRequest(AnnotationLlmBaseRequest): - """通用 LLM 生成代理:供 caption/cot/instruction 等生成类标注调用。""" - - system_prompt: str = Field(default="", max_length=20_000) - user_prompt: str = Field(default="", max_length=20_000) - temperature: float = Field(default=0.3, ge=0.0, le=2.0) - max_tokens: int = Field(default=4096, ge=1, le=128_000) - # 图片路径优先:同机部署时后端直接读盘并压缩,免去前端整图 base64 传输 - image_absolute_path: str = Field(default="", max_length=1024) - image_base64: str = Field(default="", max_length=16_000_000) - image_mime_type: str = "image/jpeg" - - diff --git a/vendor/local-agent/app/schemas/annotation_quality.py b/vendor/local-agent/app/schemas/annotation_quality.py deleted file mode 100644 index 0e8a3b1..0000000 --- a/vendor/local-agent/app/schemas/annotation_quality.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel, Field - -from app.schemas.annotation_agent import AnnotationLlmBaseRequest - - -class QualityReportComposeRequest(AnnotationLlmBaseRequest): - compose_payload: dict = Field(default_factory=dict) diff --git a/vendor/local-agent/local_main.py b/vendor/local-agent/local_main.py deleted file mode 100644 index fcf170f..0000000 --- a/vendor/local-agent/local_main.py +++ /dev/null @@ -1,82 +0,0 @@ -"""LR-Agent-local 入口:本机 Agent 编排服务。 - -由 Electron 主进程 spawn,仅监听 127.0.0.1。 -承担 Assist 工具循环 SSE、标注 / 数据分析 / 质量报告 LLM 编排; -用户认证与资料管理仍由云端 LR-Agent-backend 负责。 -""" - -import logging -import os -import sys - -import uvicorn -from fastapi import Depends, FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.api.v1.router import api_router -from app.core.config import get_settings -from app.core.deps import require_local_token - -logger = logging.getLogger(__name__) - - -def configure_utf8_console() -> None: - """尽量让 stdout/stderr 在 Windows 控制台使用 UTF-8。""" - os.environ.setdefault("PYTHONUTF8", "1") - os.environ.setdefault("PYTHONIOENCODING", "utf-8") - - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - if hasattr(sys.stderr, "reconfigure"): - sys.stderr.reconfigure(encoding="utf-8", errors="replace") - - -def create_app() -> FastAPI: - settings = get_settings() - - app = FastAPI( - title=settings.app_name, - debug=settings.debug, - ) - - cors_origins = [origin.strip() for origin in settings.cors_origins if origin.strip()] - # allow_credentials=True 与通配符 origin 组合会让任意站点携带凭据访问,直接拒绝启动 - if any(origin == "*" for origin in cors_origins): - raise RuntimeError( - "cors_origins 不能包含 '*': allow_credentials=True 时禁止通配符" - ) - - app.add_middleware( - CORSMiddleware, - allow_origins=cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - app.include_router( - api_router, - prefix=settings.api_v1_prefix, - dependencies=[Depends(require_local_token)], - ) - - @app.get("/health", tags=["health"]) - async def health_check() -> dict[str, str]: - return {"status": "ok"} - - return app - - -app = create_app() - -if __name__ == "__main__": - configure_utf8_console() - - port = int(os.environ.get("LR_AGENT_LOCAL_PORT", "8765")) - uvicorn.run( - "local_main:app", - host="127.0.0.1", - port=port, - reload=False, - use_colors=True, - ) diff --git a/vendor/local-agent/pytest.ini b/vendor/local-agent/pytest.ini deleted file mode 100644 index 78c5011..0000000 --- a/vendor/local-agent/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -asyncio_mode = auto -testpaths = tests diff --git a/vendor/local-agent/requirements.txt b/vendor/local-agent/requirements.txt deleted file mode 100644 index 2091e91..0000000 --- a/vendor/local-agent/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -# LR-Agent-local 依赖:FastAPI + LangChain 编排,不含 torch / 数据库 / Redis -fastapi==0.136.3 -uvicorn==0.47.0 -pydantic==2.13.4 -pydantic-settings==2.14.1 - -# LLM 编排 -langchain-core==1.4.0 -langchain-openai==1.2.2 -langchain-mcp-adapters==0.3.0 -mcp==1.28.0 -openai==2.40.0 - -# 图像处理(视觉映射裁图) -pillow==12.2.0 - -# 测试 -pytest==9.0.3 -pytest-asyncio==1.3.0 -httpx==0.28.1 diff --git a/vendor/local-agent/scripts/measure_grep_blocking.py b/vendor/local-agent/scripts/measure_grep_blocking.py deleted file mode 100644 index d318d1e..0000000 --- a/vendor/local-agent/scripts/measure_grep_blocking.py +++ /dev/null @@ -1,109 +0,0 @@ -"""一次性基线测量脚本:真实 grep 对事件循环的阻塞时长(前后对照)。 - -用法(仓库根目录下): - python vendor/local-agent/scripts/measure_grep_blocking.py <工作区根目录> [pattern] - -分别测量两种调用方式,输出事件循环的最大阻塞时长: - 1. 直接调用(当前实现)—— 同步代码跑在事件循环线程上,会阻塞 - 2. asyncio.to_thread(改造目标)—— 跑在线程池,不阻塞 -""" - -import asyncio -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from app.agent.tools.workspace_search import grep_workspace # noqa: E402 -from app.core.config import get_settings # noqa: E402 -from app.schemas.agent import ClientContextInput # noqa: E402 - - -class LoopLagMonitor: - """测量事件循环被阻塞的最长连续时间(loop lag)。""" - - def __init__(self, interval: float = 0.005) -> None: - self.interval = interval - self.samples: list[float] = [] - self._task: asyncio.Task | None = None - self._running = False - - async def _ticker(self) -> None: - while self._running: - before = time.perf_counter() - await asyncio.sleep(self.interval) - # 实际间隔 - 期望间隔 = 这次调度被延迟的时间 - self.samples.append(time.perf_counter() - before - self.interval) - - async def __aenter__(self) -> "LoopLagMonitor": - self._running = True - self._task = asyncio.create_task(self._ticker()) - await asyncio.sleep(0) - return self - - async def __aexit__(self, *exc: object) -> None: - self._running = False - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - @property - def max_lag_ms(self) -> float: - return max(self.samples) * 1000 if self.samples else 0.0 - - @property - def ticks(self) -> int: - return len(self.samples) - - -def _call_grep(ctx: ClientContextInput, pattern: str, settings) -> str: - return grep_workspace( - ctx, - pattern, - "", # 整个工作区 - "*", - False, - None, - settings=settings, - ) - - -async def _measure(mode: str, ctx, pattern, settings) -> tuple[float, LoopLagMonitor, str]: - async with LoopLagMonitor() as mon: - started = time.perf_counter() - if mode == "direct": - out = _call_grep(ctx, pattern, settings) - else: - out = await asyncio.to_thread(_call_grep, ctx, pattern, settings) - elapsed = (time.perf_counter() - started) * 1000 - return elapsed, mon, out - - -async def main() -> None: - root = sys.argv[1] if len(sys.argv) > 1 else "." - pattern = sys.argv[2] if len(sys.argv) > 2 else r"def\s+\w+" - - settings = get_settings() - ctx = ClientContextInput(workspace_root=str(Path(root).resolve())) - - # 预热一次,排除磁盘缓存冷启动的影响 - _call_grep(ctx, pattern, settings) - - for mode, label in (("direct", "当前实现(直接调用,阻塞事件循环)"), - ("thread", "改造目标(asyncio.to_thread)")): - elapsed, mon, out = await _measure(mode, ctx, pattern, settings) - print(f"--- {label} ---") - print(f" 调用耗时 : {elapsed:.1f} ms") - print(f" 心跳次数 : {mon.ticks}") - print(f" 事件循环最大阻塞: {mon.max_lag_ms:.1f} ms") - print(f" 结果字节数 : {len(out)}") - print(f"\nworkspace: {ctx.workspace_root}") - print(f"pattern : {pattern}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/vendor/local-agent/tests/conftest.py b/vendor/local-agent/tests/conftest.py deleted file mode 100644 index e2b28a7..0000000 --- a/vendor/local-agent/tests/conftest.py +++ /dev/null @@ -1,9 +0,0 @@ -"""LR-Agent-local 测试配置:无数据库 / Redis / SMTP 依赖。""" - -import os - -os.environ.setdefault("APP_ENV", "testing") - -from app.core.config import get_settings - -get_settings.cache_clear() diff --git a/vendor/local-agent/tests/test_agent_sse_serialization.py b/vendor/local-agent/tests/test_agent_sse_serialization.py deleted file mode 100644 index 4eda14a..0000000 --- a/vendor/local-agent/tests/test_agent_sse_serialization.py +++ /dev/null @@ -1,56 +0,0 @@ -import json - -from app.schemas.agent import StreamEventPayload - - -def test_file_proposal_sse_uses_frontend_field_names(): - event = StreamEventPayload( - type="file_proposal", - summary="Readme", - content="# Hello", - image_path="docs/readme.md", - ) - data = event.to_sse_dict() - assert data["type"] == "file_proposal" - assert data["suggestedRelativePath"] == "docs/readme.md" - assert data["title"] == "Readme" - assert data["content"] == "# Hello" - assert "image_path" not in data - - -def test_file_proposal_sse_includes_operation_alongside_mode(): - event = StreamEventPayload( - type="file_proposal", - summary="删除 notes.md", - content="", - image_path="notes.md", - mode="delete", - ) - data = event.to_sse_dict() - assert data["mode"] == "delete" - assert data["operation"] == "delete" - - -def test_file_proposal_write_sse_includes_operation(): - event = StreamEventPayload( - type="file_proposal", - summary="Readme", - content="# Hello", - image_path="docs/readme.md", - mode="write", - ) - data = event.to_sse_dict() - assert data["operation"] == "write" - - -def test_chat_stream_serializes_with_to_sse_dict_shape(): - event = StreamEventPayload( - type="file_proposal_start", - summary="notes/todo.md", - image_path="notes/todo.md", - detail="0", - ) - payload = json.dumps(event.to_sse_dict(), ensure_ascii=False) - parsed = json.loads(payload) - assert parsed["suggestedRelativePath"] == "notes/todo.md" - assert parsed["title"] == "notes/todo.md" diff --git a/vendor/local-agent/tests/test_annotation_agent_scope.py b/vendor/local-agent/tests/test_annotation_agent_scope.py deleted file mode 100644 index 65561be..0000000 --- a/vendor/local-agent/tests/test_annotation_agent_scope.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Unit tests for annotation scope resolution helpers (no LLM).""" -from app.agent.annotation.schemas import AnnotationScopePayload - - -def test_annotation_scope_defaults(): - scope = AnnotationScopePayload() - assert scope.scope_summary == "" - assert scope.include_detection_labels == [] diff --git a/vendor/local-agent/tests/test_annotation_doc_reader.py b/vendor/local-agent/tests/test_annotation_doc_reader.py deleted file mode 100644 index a6ac6ce..0000000 --- a/vendor/local-agent/tests/test_annotation_doc_reader.py +++ /dev/null @@ -1,8 +0,0 @@ -from app.agent.annotation.annotation_doc_reader import compute_file_key, normalize_relative_path - - -def test_file_key_matches_frontend_algorithm(): - assert normalize_relative_path("data\\2.jpg") == "data/2.jpg" - key = compute_file_key("data/2.jpg") - assert len(key) == 64 - assert key == compute_file_key("data\\2.jpg") diff --git a/vendor/local-agent/tests/test_annotation_tool_args.py b/vendor/local-agent/tests/test_annotation_tool_args.py deleted file mode 100644 index 7d9edb9..0000000 --- a/vendor/local-agent/tests/test_annotation_tool_args.py +++ /dev/null @@ -1,33 +0,0 @@ -from app.agent.tools.registry import AutoAnnotateArgs, MutateAnnotationArgs - - -def test_auto_annotate_args_defaults(): - parsed = AutoAnnotateArgs.model_validate({"user_request": "标 data/1.jpg"}) - assert parsed.paths == [] - assert parsed.all_files is False - assert parsed.write_mode == "append" - - -def test_auto_annotate_args_explicit_scope(): - parsed = AutoAnnotateArgs.model_validate( - { - "user_request": "重写 data/", - "paths": ["data/"], - "write_mode": "replace_matching", - } - ) - assert parsed.paths == ["data/"] - assert parsed.write_mode == "replace_matching" - assert parsed.all_files is False - - -def test_mutate_annotation_args_paths_and_ids(): - parsed = MutateAnnotationArgs.model_validate( - { - "user_request": "把这个框改成 worker", - "paths": ["data/7.jpg"], - "annotation_ids": ["ann-1"], - } - ) - assert parsed.paths == ["data/7.jpg"] - assert parsed.annotation_ids == ["ann-1"] diff --git a/vendor/local-agent/tests/test_assist_mode_router.py b/vendor/local-agent/tests/test_assist_mode_router.py deleted file mode 100644 index a1ef021..0000000 --- a/vendor/local-agent/tests/test_assist_mode_router.py +++ /dev/null @@ -1,124 +0,0 @@ -from app.agent.assist_mode_router import ( - ASK_TOOL_SET, - FULL_TOOL_SET, - LIGHT_TOOL_SET, - WRITE_TOOL_NAMES, - resolve_assist_tool_set, -) - - -def test_three_sets_include_explore_readonly(): - assert "explore_readonly" in ASK_TOOL_SET - assert "explore_readonly" in LIGHT_TOOL_SET - assert "explore_readonly" in FULL_TOOL_SET - - -def test_query_context_tools_not_exposed_to_main_agent(): - """get_account_summary / describe_client_context 退出默认工具集。 - - 二者仍在 registry 注册(子代理内可用),但不注入主 Agent,以免白占前缀并诱发无关调用。 - """ - for name in ("get_account_summary", "describe_client_context"): - assert name not in ASK_TOOL_SET - assert name not in LIGHT_TOOL_SET - assert name not in FULL_TOOL_SET - - -def test_ask_tool_set_strips_writes(): - assert WRITE_TOOL_NAMES.isdisjoint(ASK_TOOL_SET) - assert "read_file_annotation" in ASK_TOOL_SET - assert "read_image_for_vision" in ASK_TOOL_SET - assert "list_workspace_directory" in ASK_TOOL_SET - assert "glob_workspace" in ASK_TOOL_SET - assert "str_replace_workspace_file" not in ASK_TOOL_SET - for name in WRITE_TOOL_NAMES: - assert name in FULL_TOOL_SET - assert name not in ASK_TOOL_SET - - -def test_light_still_has_write_workspace_file(): - assert "write_workspace_file" in LIGHT_TOOL_SET - assert "str_replace_workspace_file" in LIGHT_TOOL_SET - assert "delete_workspace_file" in LIGHT_TOOL_SET - assert "move_workspace_file" in LIGHT_TOOL_SET - assert "glob_workspace" in LIGHT_TOOL_SET - assert "explore_readonly" in LIGHT_TOOL_SET - assert "auto_annotate" not in LIGHT_TOOL_SET - - -def test_resolve_assist_tool_set_fail_closed_when_mode_missing(): - tools = resolve_assist_tool_set( - has_project_snapshot=True, - agent_mode=None, - is_editor=False, - has_workspace=True, - ) - assert tools == ASK_TOOL_SET - assert WRITE_TOOL_NAMES.isdisjoint(tools) - - -def test_resolve_assist_tool_set_full_only_in_annotation_mode(): - tools = resolve_assist_tool_set( - has_project_snapshot=True, - agent_mode="annotation", - is_editor=False, - has_workspace=True, - ) - assert tools == FULL_TOOL_SET - assert WRITE_TOOL_NAMES.issubset(tools) - - -def test_resolve_editor_ask_is_readonly(): - tools = resolve_assist_tool_set( - has_project_snapshot=False, - agent_mode="chat", - is_editor=True, - has_workspace=True, - ) - assert tools == ASK_TOOL_SET - assert WRITE_TOOL_NAMES.isdisjoint(tools) - - -def test_resolve_editor_agent_is_light(): - tools = resolve_assist_tool_set( - has_project_snapshot=False, - agent_mode="annotation", - is_editor=True, - has_workspace=True, - ) - assert tools == LIGHT_TOOL_SET - assert "write_workspace_file" in tools - assert "move_workspace_file" in tools - assert "auto_annotate" not in tools - - -def test_resolve_editor_agent_ignores_leaked_snapshot(): - tools = resolve_assist_tool_set( - has_project_snapshot=True, - agent_mode="annotation", - is_editor=True, - has_workspace=True, - ) - assert tools == LIGHT_TOOL_SET - assert "auto_annotate" not in tools - - -def test_resolve_annotation_ask_is_readonly(): - tools = resolve_assist_tool_set( - has_project_snapshot=True, - agent_mode="chat", - is_editor=False, - has_workspace=True, - ) - assert tools == ASK_TOOL_SET - assert WRITE_TOOL_NAMES.isdisjoint(tools) - - -def test_resolve_ask_without_context_is_empty(): - tools = resolve_assist_tool_set( - has_project_snapshot=False, - agent_mode="chat", - is_editor=False, - has_workspace=False, - ) - assert tools == frozenset() diff --git a/vendor/local-agent/tests/test_assist_phase_flow.py b/vendor/local-agent/tests/test_assist_phase_flow.py deleted file mode 100644 index 2c05e75..0000000 --- a/vendor/local-agent/tests/test_assist_phase_flow.py +++ /dev/null @@ -1,373 +0,0 @@ -"""服务级集成:stream_assist + 标注任务阶段机。 - -模拟「扫描 → 补标 → 提案确认 → 核对 → 报告」复合任务的骨干流程: - 1. 提案 pending 时 resume → await_confirm:写报告/重复标注被门禁拦截 - 2. Keep All 后 resume(applied)→ verify:对已落盘路径的重复标注被拦截 - 3. 无结构化提案状态(旧客户端)→ 不启用门禁,行为与之前一致 -""" - -import json - -from langchain_core.messages import HumanMessage - -from app.agent.assist.task_phase import TaskPhase, derive_task_phase -from app.agent.assist_service import stream_assist -from app.schemas.agent import ( - ClientContextInput, - ClientToolResult, - ProposalStateInput, -) - - -class MockChunk: - """Mock AIMessageChunk(与 test_tool_loop 同款)。""" - - def __init__(self, content="", tool_call_chunks=None, tool_calls=None, additional_kwargs=None): - self.content = content - self.tool_call_chunks = tool_call_chunks or [] - self.tool_calls = tool_calls - self.additional_kwargs = additional_kwargs or {} - - def __add__(self, other): - merged_tool_calls = (self.tool_calls or []) + (other.tool_calls or []) - return MockChunk( - content=(self.content or "") + (other.content or ""), - tool_call_chunks=self.tool_call_chunks + other.tool_call_chunks, - tool_calls=merged_tool_calls, - additional_kwargs={**self.additional_kwargs, **other.additional_kwargs}, - ) - - -class SequenceMockChat: - """按轮次返回预设 chunk 序列的 Mock LLM。""" - - def __init__(self, rounds): - self._rounds = list(rounds) - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - chunks = self._rounds.pop(0) if self._rounds else [] - for chunk in chunks: - yield chunk - - async def ainvoke(self, messages): - return type("MockMsg", (), {"tool_calls": [], "content": ""})() - - -async def _never_cancel() -> bool: - return False - - -async def _collect(async_iter): - return [e async for e in async_iter] - - -def _auto_annotate_result() -> ClientToolResult: - return ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result=json.dumps( - { - "status": "completed", - "tool": "auto_annotate", - "summary": "已生成待确认提案(未写盘)。批量标注完成:处理 5 张,共 14 个框。", - "proposal_pending": True, - "file_written": False, - }, - ensure_ascii=False, - ), - ) - - -class TestAwaitConfirmGate: - async def test_pending_proposal_blocks_report_write(self) -> None: - """提案未确认时 resume:模型试图写报告 → 门禁拦截并反馈。""" - states = [ - ProposalStateInput(path="data/2.jpg", kind="annotation", status="pending") - ] - ctx = derive_task_phase(states) - assert ctx is not None and ctx.phase == TaskPhase.AWAIT_CONFIRM - - llm = SequenceMockChat( - [ - # 第一轮:模型试图在落盘前写报告 - [ - MockChunk( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": { - "relative_path": "reports/r.md", - "content": "# 报告", - }, - } - ] - ) - ], - # 第二轮:收到门禁反馈后改为提示用户确认 - [MockChunk(content="提案已生成,请 Keep All 后我再生成报告。")], - ] - ) - events = await _collect( - stream_assist( - llm, - [HumanMessage(content="补标并写报告")], - [], - settings=None, # type: ignore[arg-type] - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput( - workspace_root="/w", proposal_states=states - ), - user_content="补标并写报告", - client_tool_results=[_auto_annotate_result()], - task_phase_ctx=ctx, - ) - ) - blocked = [ - e - for e in events - if e.type == "tool_result" and "phase_blocked" in (e.result or "") - ] - assert len(blocked) == 1 - assert "Keep All" in (blocked[0].result or "") - # 未产生任何文件提案 - assert not any(e.type == "file_proposal" for e in events) - # 最终输出等待确认的文本 - assert any( - e.type == "text_delta" and "Keep All" in (e.content or "") - for e in events - ) - - -class TestAwaitConfirmToolsStayBound: - async def test_write_tool_bound_but_blocked_at_execution(self) -> None: - """await_confirm 阶段:写工具仍在 bind 列表中(保前缀缓存),执行层拦截。""" - from langchain_core.tools import StructuredTool - - from app.agent.assist_service import stream_assist - - states = [ - ProposalStateInput(path="data/2.jpg", kind="annotation", status="pending") - ] - ctx = derive_task_phase(states) - assert ctx is not None and ctx.phase == TaskPhase.AWAIT_CONFIRM - - def _must_not_run(**_kwargs) -> str: - raise AssertionError("await_confirm 阶段不应执行写工具") - - write_tool = StructuredTool.from_function( - func=_must_not_run, - name="write_workspace_file", - description="write file", - ) - - class CaptureBindLLM: - def __init__(self) -> None: - self.bound_tool_names: list[str] = [] - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - self.bound_tool_names = [t.name for t in tools] - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": { - "relative_path": "reports/r.md", - "content": "# 报告", - }, - } - ] - ) - return - yield MockChunk(content="提案待确认,请 Keep All。") - - llm = CaptureBindLLM() - events = await _collect( - stream_assist( - llm, # type: ignore[arg-type] - [HumanMessage(content="写报告")], - [write_tool], - settings=None, # type: ignore[arg-type] - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput( - workspace_root="/w", proposal_states=states - ), - user_content="写报告", - client_tool_results=[_auto_annotate_result()], - task_phase_ctx=ctx, - ) - ) - # 工具仍在 bind 列表(不再 bind 时删减) - assert "write_workspace_file" in llm.bound_tool_names - # 但执行层拦截,func 未运行(否则会抛 AssertionError) - blocked = [ - e - for e in events - if e.type == "tool_result" and "phase_blocked" in (e.result or "") - ] - assert len(blocked) == 1 - - -class TestVerifyGate: - async def test_applied_proposal_blocks_reannotation(self) -> None: - """Keep All 后续跑:模型试图重复标注已落盘路径 → 拦截。""" - states = [ - ProposalStateInput(path="data/2.jpg", kind="annotation", status="applied") - ] - ctx = derive_task_phase(states) - assert ctx is not None and ctx.phase == TaskPhase.VERIFY - - llm = SequenceMockChat( - [ - [ - MockChunk( - tool_calls=[ - { - "id": "c1", - "name": "auto_annotate", - "args": { - "user_request": "补标", - "paths": ["data/2.jpg"], - }, - } - ] - ) - ], - [MockChunk(content="已核对落盘结果,报告已生成。")], - ] - ) - events = await _collect( - stream_assist( - llm, - [HumanMessage(content="补标并写报告")], - [], - settings=None, # type: ignore[arg-type] - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput( - workspace_root="/w", proposal_states=states - ), - user_content="补标并写报告", - client_tool_results=[_auto_annotate_result()], - task_phase_ctx=ctx, - ) - ) - blocked = [ - e - for e in events - if e.type == "tool_result" and "phase_blocked" in (e.result or "") - ] - assert len(blocked) == 1 - assert "data/2.jpg" in (blocked[0].result or "") - # 被拦截的标注调用不会发射 tool_pending - assert not any(e.type == "tool_pending" for e in events) - - -class TestVerifyBypass: - async def test_mutate_by_annotation_ids_allowed(self) -> None: - """VERIFY:定向修正已落盘标注 id 放行,发射 tool_pending 交给前端。""" - states = [ - ProposalStateInput( - path="data/7.jpg", - kind="annotation", - status="applied", - annotation_ids=["ann-1"], - ) - ] - ctx = derive_task_phase(states) - assert ctx is not None and ctx.phase == TaskPhase.VERIFY - - llm = SequenceMockChat( - [ - [ - MockChunk( - tool_calls=[ - { - "id": "m1", - "name": "mutate_annotation", - "args": { - "user_request": "改标签", - "annotation_ids": ["ann-1"], - }, - } - ] - ) - ], - [MockChunk(content="已生成修正提案,待用户确认。")], - ] - ) - events = await _collect( - stream_assist( - llm, - [HumanMessage(content="修正标注")], - [], - settings=None, # type: ignore[arg-type] - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput( - workspace_root="/w", proposal_states=states - ), - user_content="修正标注", - client_tool_results=[_auto_annotate_result()], - task_phase_ctx=ctx, - ) - ) - assert not any( - e.type == "tool_result" and "phase_blocked" in (e.result or "") - for e in events - ) - assert any(e.type == "tool_pending" for e in events) - - -class TestLegacyCompatibility: - async def test_no_proposal_states_no_gating(self) -> None: - """旧客户端(无 proposal_states):不启用门禁,写文件调用照常执行。""" - llm = SequenceMockChat( - [ - [ - MockChunk( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": { - "relative_path": "reports/r.md", - "content": "# 报告", - }, - } - ] - ) - ], - [MockChunk(content="报告已生成。")], - ] - ) - events = await _collect( - stream_assist( - llm, - [HumanMessage(content="写报告")], - [], - settings=None, # type: ignore[arg-type] - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput(workspace_root="/w"), - user_content="写报告", - client_tool_results=[_auto_annotate_result()], - task_phase_ctx=None, - ) - ) - # 无门禁:write_workspace_file 进入执行(fn_map 未注册 → 未知工具错误,而非 phase_blocked) - results = [e for e in events if e.type == "tool_result"] - assert len(results) == 1 - assert "phase_blocked" not in (results[0].result or "") diff --git a/vendor/local-agent/tests/test_assist_resume_messages.py b/vendor/local-agent/tests/test_assist_resume_messages.py deleted file mode 100644 index 4d095a4..0000000 --- a/vendor/local-agent/tests/test_assist_resume_messages.py +++ /dev/null @@ -1,328 +0,0 @@ -import json - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage - -from app.agent.context_service import ( - RESUME_APPLIED_NEXT_HINT, - RESUME_NEXT_HINT, - append_client_tool_results_to_messages, -) -from app.api.v1.agent import _build_lc_messages_from_local -from app.schemas.agent import ( - ChatMessageInput, - ChatToolCallInput, - ClientToolResult, - LocalChatStreamRequest, -) - - -def test_append_client_tool_results_adds_ai_and_tool_messages() -> None: - lc_messages = [ - SystemMessage(content="sys"), - HumanMessage(content="标注并生成报告"), - ] - results = [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result=json.dumps( - { - "status": "completed", - "tool": "auto_annotate", - "user_request": "标注并生成报告", - "summary": "批量标注已完成", - "next_hint": "可继续 write_workspace_file", - }, - ensure_ascii=False, - ), - ), - ] - - append_client_tool_results_to_messages( - lc_messages, - results, - user_content="标注并生成报告", - ) - - assert len(lc_messages) == 4 - ai_msg = lc_messages[2] - tool_msg = lc_messages[3] - assert isinstance(ai_msg, AIMessage) - assert ai_msg.tool_calls - assert ai_msg.tool_calls[0]["name"] == "auto_annotate" - assert ai_msg.tool_calls[0]["id"] == "call-1" - assert isinstance(tool_msg, ToolMessage) - assert tool_msg.tool_call_id == "call-1" - parsed = json.loads(tool_msg.content) - assert parsed["status"] == "completed" - assert parsed["summary"] == "批量标注已完成" - - -def test_append_client_tool_results_cumulative_pairs() -> None: - lc_messages = [HumanMessage(content="标注并写报告")] - results = [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result='{"status":"completed","summary":"标注完成"}', - ), - ClientToolResult( - tool_call_id="call-2", - name="mutate_annotation", - result='{"status":"completed","summary":"分析完成"}', - ), - ] - - append_client_tool_results_to_messages( - lc_messages, - results, - user_content="标注并写报告", - ) - - assert len(lc_messages) == 5 - assert isinstance(lc_messages[1], AIMessage) - assert isinstance(lc_messages[2], ToolMessage) - assert isinstance(lc_messages[3], AIMessage) - assert isinstance(lc_messages[4], ToolMessage) - assert lc_messages[2].tool_call_id == "call-1" - assert lc_messages[4].tool_call_id == "call-2" - - -def test_append_client_tool_results_empty_is_noop() -> None: - lc_messages = [HumanMessage(content="hi")] - append_client_tool_results_to_messages(lc_messages, []) - assert len(lc_messages) == 1 - - -def test_resume_placeholder_preserves_blocks_when_flag_set() -> None: - """Resume 时 preserve_blocks=True 不应写入 blocks_json=[]。""" - values: dict = {"status": "streaming", "error": None} - preserve_blocks = True - if not preserve_blocks: - values["blocks_json"] = [] - assert "blocks_json" not in values - - -def test_non_resume_placeholder_clears_blocks() -> None: - values: dict = {"status": "streaming", "error": None} - preserve_blocks = False - if not preserve_blocks: - values["blocks_json"] = [] - assert values["blocks_json"] == [] - - -def test_append_only_tool_message_when_aimessage_already_present() -> None: - lc_messages = [ - HumanMessage(content="标注第 6~8 张"), - AIMessage( - content="现在执行自动标注。", - tool_calls=[ - { - "id": "call-1", - "name": "auto_annotate", - "args": {"user_request": "标注第 6~8 张"}, - } - ], - ), - ] - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result='{"status":"completed","summary":"已处理 3 张图片"}', - ) - ], - user_content="标注第 6~8 张", - ) - assert len(lc_messages) == 3 - assert isinstance(lc_messages[1], AIMessage) - assert isinstance(lc_messages[2], ToolMessage) - assert lc_messages[2].tool_call_id == "call-1" - parsed = json.loads(lc_messages[2].content) - assert parsed["summary"] == "已处理 3 张图片" - assert parsed["next_hint"] == RESUME_NEXT_HINT - - -def test_resume_hint_forbids_fake_completion() -> None: - assert "mutate_annotation" in RESUME_NEXT_HINT - assert "proposal_pending" in RESUME_NEXT_HINT - assert "请给出简短总结" not in RESUME_NEXT_HINT - assert "已发送给用户确认" not in RESUME_NEXT_HINT - - -def test_append_preserves_existing_next_hint() -> None: - lc_messages = [HumanMessage(content="标注")] - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result=json.dumps( - { - "status": "completed", - "summary": "ok", - "next_hint": "可继续 write_workspace_file", - }, - ensure_ascii=False, - ), - ) - ], - ) - parsed = json.loads(lc_messages[2].content) - assert parsed["next_hint"] == "可继续 write_workspace_file" - - -def test_resume_hint_applied_after_keep_all() -> None: - """Keep All 后续跑:proposal_pending 修正为 False,hint 切换为已落盘口径。""" - lc_messages = [HumanMessage(content="标注并写报告")] - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result=json.dumps( - { - "status": "completed", - "summary": "已生成待确认提案(未写盘)。批量标注完成:处理 5 张,共 14 个框。", - "proposal_pending": True, - "file_written": False, - }, - ensure_ascii=False, - ), - ) - ], - user_content="标注并写报告", - proposals_applied=True, - ) - parsed = json.loads(lc_messages[2].content) - assert parsed["proposal_pending"] is False - assert parsed["file_written"] is True - assert parsed["next_hint"] == RESUME_APPLIED_NEXT_HINT - assert "已写盘" in parsed["next_hint"] - # 禁止过时的确认引导(治 Keep All 后模型仍说"请确认 Keep All") - assert "禁止再要求用户确认" in parsed["next_hint"] - - -def test_resume_hint_pending_by_default() -> None: - """未确认(或 Dismiss)后续跑:保留 pending 口径,禁止声称已标注。""" - lc_messages = [HumanMessage(content="标注")] - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result='{"status":"completed","summary":"ok","proposal_pending":true}', - ) - ], - user_content="标注", - ) - parsed = json.loads(lc_messages[2].content) - assert parsed["proposal_pending"] is True - assert parsed["next_hint"] == RESUME_NEXT_HINT - - -def test_resume_applied_hint_skips_no_proposal_result() -> None: - """VERIFY 阶段但 mutate 未生成提案(skipped):不得谎报已写盘。""" - lc_messages = [HumanMessage(content="修正标注")] - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="m1", - name="mutate_annotation", - result=json.dumps( - { - "status": "skipped", - "summary": "未生成提案", - "proposal_pending": False, - "file_written": False, - }, - ensure_ascii=False, - ), - ) - ], - user_content="修正标注", - proposals_applied=True, - ) - parsed = json.loads(lc_messages[2].content) - assert parsed["file_written"] is False - assert parsed["next_hint"] == RESUME_NEXT_HINT - - -def test_build_lc_messages_restores_assistant_tool_chain() -> None: - body = LocalChatStreamRequest( - api_key="k", - base_url="http://localhost", - model="m", - messages=[ - ChatMessageInput(role="user", content="标注第 6~8 张并更新记忆"), - ChatMessageInput( - role="assistant", - content="我先预览。", - tool_calls=[ - ChatToolCallInput( - id="v1", - name="read_image_for_vision", - args={"relative_path": "6.jpg"}, - ) - ], - ), - ChatMessageInput( - role="tool", - content='{"ok":true,"name":"6.jpg"}', - tool_call_id="v1", - ), - ChatMessageInput( - role="assistant", - content="现在执行自动标注。", - tool_calls=[ - ChatToolCallInput( - id="call-1", - name="auto_annotate", - args={"user_request": "标注第 6~8 张"}, - ) - ], - ), - ], - user_content="标注第 6~8 张并更新记忆", - client_job_id="job-1", - ) - lc_messages = _build_lc_messages_from_local(body, "sys") - assert isinstance(lc_messages[0], SystemMessage) - assert isinstance(lc_messages[1], HumanMessage) - assert isinstance(lc_messages[2], AIMessage) - assert lc_messages[2].tool_calls[0]["name"] == "read_image_for_vision" - assert isinstance(lc_messages[3], ToolMessage) - assert lc_messages[3].tool_call_id == "v1" - assert isinstance(lc_messages[4], AIMessage) - assert lc_messages[4].tool_calls[0]["id"] == "call-1" - - append_client_tool_results_to_messages( - lc_messages, - [ - ClientToolResult( - tool_call_id="call-1", - name="auto_annotate", - result='{"status":"completed","summary":"已处理 3 张"}', - ) - ], - user_content="标注第 6~8 张并更新记忆", - ) - tool_msgs = [m for m in lc_messages if isinstance(m, ToolMessage)] - assert [m.tool_call_id for m in tool_msgs] == ["v1", "call-1"] - ai_msgs = [m for m in lc_messages if isinstance(m, AIMessage)] - assert len(ai_msgs) == 2 - - -def test_agent_api_does_not_double_append_client_tool_results() -> None: - from pathlib import Path - - import app.api.v1.agent as agent_mod - - source = Path(agent_mod.__file__).read_text(encoding="utf-8") - assert "append_client_tool_results_to_messages" not in source diff --git a/vendor/local-agent/tests/test_assist_vision.py b/vendor/local-agent/tests/test_assist_vision.py deleted file mode 100644 index 13f04f3..0000000 --- a/vendor/local-agent/tests/test_assist_vision.py +++ /dev/null @@ -1,7 +0,0 @@ -from app.agent.assist_vision import vision_relative_from_user_text - - -def test_vision_relative_from_user_text() -> None: - assert vision_relative_from_user_text("data/1.jpg 是谁") == "data/1.jpg" - assert vision_relative_from_user_text("看图") == "" - assert vision_relative_from_user_text("path/to/photo.PNG 描述") == "path/to/photo.PNG" diff --git a/vendor/local-agent/tests/test_assist_wrapup.py b/vendor/local-agent/tests/test_assist_wrapup.py deleted file mode 100644 index 1517211..0000000 --- a/vendor/local-agent/tests/test_assist_wrapup.py +++ /dev/null @@ -1,113 +0,0 @@ -"""主循环预算耗尽的兜底总结:耗尽时强制一次无工具最终回答,正常结束不触发。""" - -from langchain_core.messages import HumanMessage - -from app.agent.assist_service import stream_assist -from app.schemas.agent import ClientContextInput - - -class MockChunk: - """Mock AIMessageChunk(与 test_tool_loop 同款)。""" - - def __init__(self, content="", tool_call_chunks=None, tool_calls=None, additional_kwargs=None): - self.content = content - self.tool_call_chunks = tool_call_chunks or [] - self.tool_calls = tool_calls - self.additional_kwargs = additional_kwargs or {} - - def __add__(self, other): - merged_tool_calls = (self.tool_calls or []) + (other.tool_calls or []) - return MockChunk( - content=(self.content or "") + (other.content or ""), - tool_call_chunks=self.tool_call_chunks + other.tool_call_chunks, - tool_calls=merged_tool_calls, - additional_kwargs={**self.additional_kwargs, **other.additional_kwargs}, - ) - - -class CountingMockChat: - """按轮次返回预设 chunk 序列,并记录 astream 被调用的次数。""" - - def __init__(self, rounds): - self._rounds = list(rounds) - self.astream_calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.astream_calls += 1 - chunks = self._rounds.pop(0) if self._rounds else [] - for chunk in chunks: - yield chunk - - -async def _never_cancel() -> bool: - return False - - -async def _collect(async_iter): - return [e async for e in async_iter] - - -def _tool_round(call_id: str) -> list: - return [ - MockChunk( - tool_calls=[ - { - "id": call_id, - "name": "read_workspace_file", - "args": {"relative_path": "a.md"}, - } - ] - ) - ] - - -async def _run(llm, max_tool_rounds: int): - return await _collect( - stream_assist( - llm, - [HumanMessage(content="帮我整理工作区")], - [], - settings=None, # type: ignore[arg-type] - max_tool_rounds=max_tool_rounds, - is_cancelled=_never_cancel, - client_context=ClientContextInput(workspace_root="/w"), - user_content="帮我整理工作区", - ) - ) - - -async def test_budget_exhaustion_forces_final_answer() -> None: - """预算耗尽时:追加 HumanMessage 并强制一次无工具回答,产出 text_delta。""" - llm = CountingMockChat( - [ - _tool_round("call-1"), - _tool_round("call-2"), - # 兜底调用(无工具 bind)的输出 - [MockChunk(content="预算已用完。当前进展:已读取 a.md,尚未完成整理。")], - ] - ) - events = await _run(llm, max_tool_rounds=1) - - # 两次带工具的轮 + 一次兜底 = 3 次 astream - assert llm.astream_calls == 3 - texts = [e.content or "" for e in events if e.type == "text_delta"] - assert any("预算已用完" in t for t in texts) - - -async def test_normal_completion_skips_wrapup() -> None: - """模型自己停止(无 tool call)时正常 break,不触发兜底调用。""" - llm = CountingMockChat( - [ - _tool_round("call-1"), - [MockChunk(content="整理完成。")], - ] - ) - events = await _run(llm, max_tool_rounds=5) - - assert llm.astream_calls == 2 - texts = [e.content or "" for e in events if e.type == "text_delta"] - assert any("整理完成" in t for t in texts) - assert not any("预算已用完" in t for t in texts) diff --git a/vendor/local-agent/tests/test_chat_stream_preparing.py b/vendor/local-agent/tests/test_chat_stream_preparing.py deleted file mode 100644 index 10f5e8d..0000000 --- a/vendor/local-agent/tests/test_chat_stream_preparing.py +++ /dev/null @@ -1,90 +0,0 @@ -"""chat/stream 的 preparing 事件顺序:MCP 发现前先发 preparing(stage=mcp)。""" - -import pytest - -from app.api.v1.agent import _stream_local_chat -from app.core.config import get_settings -from app.schemas.agent import ( - ClientContextInput, - LocalChatStreamRequest, - StreamEventPayload, -) - -pytest.importorskip("langchain_openai") - - -class _FakeChatOpenAI: - """替代 ChatOpenAI:assist 流被整体 patch,实例不被真正调用。""" - - def __init__(self, **kwargs): - self.kwargs = kwargs - - -async def _fake_load_mcp_tools(*args, **kwargs): - return [] - - -async def _fake_assist_stream(*args, **kwargs): - yield StreamEventPayload(type="preparing", stage="streaming") - yield StreamEventPayload(type="text_delta", content="hello") - - -def _body(mcp_server_url: str | None) -> LocalChatStreamRequest: - client_context = ClientContextInput( - workspace_root="/tmp/ws", - mcp_server_url=mcp_server_url, - ) - return LocalChatStreamRequest( - api_key="k", - base_url="https://api.example.com/v1", - model="test-model", - messages=[{"role": "user", "content": "hi"}], - user_content="hi", - client_job_id="job-preparing-1", - client_context=client_context, - ) - - -async def _collect( - body: LocalChatStreamRequest, monkeypatch -) -> list[dict]: - """收集 SSE 字符串并解析回事件 dict(_stream_local_chat 产出 data: 行)。""" - import json - - monkeypatch.setattr("app.api.v1.agent.ChatOpenAI", _FakeChatOpenAI) - monkeypatch.setattr( - "app.api.v1.agent.load_mcp_tools_from_servers", _fake_load_mcp_tools - ) - monkeypatch.setattr( - "app.agent.assist_service.stream_assist", _fake_assist_stream - ) - events: list[dict] = [] - async for chunk in _stream_local_chat(body, get_settings()): - for line in chunk.split("\n"): - line = line.strip() - if not line.startswith("data:"): - continue - payload = line[len("data:"):].strip() - if payload: - events.append(json.loads(payload)) - return events - - -async def test_preparing_mcp_emitted_before_assist_stream(monkeypatch): - events = await _collect(_body("http://127.0.0.1:9000"), monkeypatch) - assert events[0]["type"] == "preparing" - assert events[0]["stage"] == "mcp" - assert events[1]["type"] == "preparing" - assert events[1]["stage"] == "streaming" - assert events[2]["type"] == "text_delta" - assert events[-1] == {"type": "done"} - - -async def test_no_mcp_preparing_without_mcp_config(monkeypatch): - events = await _collect(_body(None), monkeypatch) - assert events[0]["type"] == "preparing" - assert events[0]["stage"] == "streaming" - assert all( - not (e["type"] == "preparing" and e.get("stage") == "mcp") - for e in events - ) diff --git a/vendor/local-agent/tests/test_context_snapshot.py b/vendor/local-agent/tests/test_context_snapshot.py deleted file mode 100644 index 5f5f5e3..0000000 --- a/vendor/local-agent/tests/test_context_snapshot.py +++ /dev/null @@ -1,158 +0,0 @@ -"""系统提示与帮助文案:标注入口不泄漏存储路径。""" - -from app.agent.context_snapshot import ( - build_assist_system_prompt, - format_snapshot_for_prompt, -) -from app.agent.tools.help import get_lr_agent_help -from app.schemas.agent import ( - AnnotationProjectSnapshotInput, - ClientContextInput, - SkillCatalogEntryInput, -) - - -def _bbox_snapshot() -> AnnotationProjectSnapshotInput: - return AnnotationProjectSnapshotInput( - project_id="p1", - name="faces", - modality="image", - annotation_type="bbox", - labels=[{"id": "face", "name": "人脸"}], - ) - - -def _build_prompt(client_context: ClientContextInput) -> str: - return build_assist_system_prompt( - client_context, - model="test-model", - provider_label="测试", - supports_vision=False, - ) - - -def test_snapshot_prompt_does_not_leak_annotation_storage_path() -> None: - snap = format_snapshot_for_prompt(_bbox_snapshot()) - assert ".lr-agent/annotations" not in snap - assert "list_workspace_directory" in snap - - -def test_project_system_prompt_routes_annotation_writes_to_tools() -> None: - prompt = _build_prompt( - ClientContextInput( - workspace_root="/ws", - agent_mode="annotation", - annotation_project_snapshot=_bbox_snapshot(), - ) - ) - assert ".lr-agent/annotations" not in prompt - assert "auto_annotate" in prompt - assert "mutate_annotation" in prompt - assert "explore_readonly" in prompt - assert "不要用写文件工具保存标注" in prompt - assert "查已有标注 JSON" not in prompt - - -def test_annotation_call_guide_only_in_annotation_agent() -> None: - """自动标注填参纪律只在标注 Agent 的 prompt 里(auto_annotate 也只在那里可用)。""" - annotation = _build_prompt( - ClientContextInput( - workspace_root="/ws", - agent_mode="annotation", - annotation_project_snapshot=_bbox_snapshot(), - ) - ) - assert "【标注调用纪律】" in annotation - assert "all_files 仅在用户明确要求" in annotation - assert "replace_matching" in annotation - - # 同一项目但 Ask(只读):不注入 - ask = _build_prompt( - ClientContextInput( - workspace_root="/ws", - annotation_project_snapshot=_bbox_snapshot(), - ) - ) - assert "【标注调用纪律】" not in ask - - # 普通工作区:不注入 - workspace = _build_prompt(ClientContextInput(workspace_root="/ws")) - assert "【标注调用纪律】" not in workspace - - -def test_fallback_prompt_formats_vision_hint() -> None: - """兜底分支(无 client_context)也要格式化 vision_hint,不能漏字面量占位符。""" - prompt = build_assist_system_prompt( - None, - model="test-model", - provider_label="测试", - supports_vision=False, - ) - assert "{vision_hint}" not in prompt - assert "read_image_for_vision" in prompt - - -def test_proposal_ledger_appended_to_system_prompt() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - proposal_ledger="【未确认提案】未 Keep All,未写盘。\n- annotation pending data/8.jpg append", - ) - prompt = build_assist_system_prompt( - ctx, - model="test-model", - provider_label="测试", - supports_vision=False, - ) - assert "【未确认提案】" in prompt - assert "data/8.jpg" in prompt - - -def test_dynamic_blocks_sink_to_bottom_for_prefix_cache() -> None: - """稳定块(指令、skills)在前,动态块(记忆、台账)沉底,保证前缀缓存命中。""" - ctx = ClientContextInput( - workspace_root="/ws", - project_instructions="优先使用中文回答。", - workspace_memory_enabled=True, - memory_index="- progress.md: 标注进度", - skills_catalog=[SkillCatalogEntryInput(name="demo", description="演示技能")], - proposal_ledger="【未确认提案】未 Keep All,未写盘。", - ) - prompt = _build_prompt(ctx) - instructions_pos = prompt.index("【项目指令】") - skills_pos = prompt.index("【可用 Skills】") - memory_pos = prompt.index("【工作区记忆】") - ledger_pos = prompt.index("【未确认提案】") - assert instructions_pos < skills_pos < memory_pos < ledger_pos - - -def test_help_annotation_topic_does_not_leak_storage_path() -> None: - text = get_lr_agent_help("标注") - assert ".lr-agent/annotations" not in text - assert "auto_annotate" in text - assert "mutate_annotation" in text - - -def test_editor_ask_prompt_is_readonly() -> None: - prompt = _build_prompt( - ClientContextInput( - workspace_root="/ws", - work_mode="editor", - agent_mode="chat", - ) - ) - assert "编辑器 Ask" in prompt - assert "禁止调用写文件" in prompt - assert "write_workspace_file" not in prompt.split("【编辑器模式】")[-1] - - -def test_editor_agent_prompt_allows_file_writes() -> None: - prompt = _build_prompt( - ClientContextInput( - workspace_root="/ws", - work_mode="editor", - agent_mode="annotation", - ) - ) - assert "编辑器 Agent" in prompt - assert "write_workspace_file" in prompt - assert "禁止调用标注" in prompt diff --git a/vendor/local-agent/tests/test_explore_readonly.py b/vendor/local-agent/tests/test_explore_readonly.py deleted file mode 100644 index c45f5f5..0000000 --- a/vendor/local-agent/tests/test_explore_readonly.py +++ /dev/null @@ -1,451 +0,0 @@ -"""只读子代理:内层白名单、取消、轮次封顶。""" - -from types import SimpleNamespace - -import pytest -from langchain_core.messages import HumanMessage, ToolMessage -from langchain_core.tools import StructuredTool - -from app.agent.assist.explore_readonly import ( - EXPLORE_READONLY_FORBIDDEN_INNER, - EXPLORE_READONLY_TOOL_NAME, - ExploreReadonlyRunner, - filter_explore_inner_fn_map, - filter_explore_inner_tools, - resolve_explore_inner_names, - should_include_annotation_reads, -) -from app.agent.assist_service import stream_assist -from app.schemas.agent import ClientContextInput - - -def _tool(name: str) -> StructuredTool: - return StructuredTool.from_function( - func=lambda **_kwargs: f"ok:{name}", - name=name, - description=name, - ) - - -class MockChunk: - def __init__(self, content="", tool_calls=None): - self.content = content - self.tool_call_chunks = [] - self.tool_calls = tool_calls - self.additional_kwargs = {} - - def __add__(self, other): - merged = (self.tool_calls or []) + (other.tool_calls or []) - return MockChunk( - content=(self.content or "") + (other.content or ""), - tool_calls=merged, - ) - - -class SequenceLLM: - def __init__(self, tool_rounds: int, summary: str = "这是查阅摘要"): - self.tool_rounds = tool_rounds - self.summary = summary - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls <= self.tool_rounds: - yield MockChunk( - tool_calls=[ - { - "id": f"inner-{self.calls}", - "name": "grep_workspace", - "args": {"pattern": "Foo"}, - } - ] - ) - return - yield MockChunk(content=self.summary) - - -async def _never_cancel() -> bool: - return False - - -def test_inner_names_exclude_writes_and_self(): - names = resolve_explore_inner_names(include_annotation_reads=True) - assert EXPLORE_READONLY_TOOL_NAME not in names - for forbidden in EXPLORE_READONLY_FORBIDDEN_INNER: - assert forbidden not in names - assert "grep_workspace" in names - assert "read_file_annotation" in names - - -def test_editor_context_excludes_annotation_reads(): - ctx = ClientContextInput(workspace_root="/ws", work_mode="editor") - parent = [ - _tool("grep_workspace"), - _tool("read_file_annotation"), - _tool("describe_annotation_project"), - _tool("explore_readonly"), - _tool("write_workspace_file"), - ] - assert should_include_annotation_reads(ctx, parent) is False - inner = filter_explore_inner_tools(parent, include_annotation_reads=False) - names = {tool.name for tool in inner} - assert "read_file_annotation" not in names - assert "describe_annotation_project" not in names - assert "write_workspace_file" not in names - assert EXPLORE_READONLY_TOOL_NAME not in names - assert "grep_workspace" in names - - -def test_annotation_context_keeps_read_tools(): - ctx = ClientContextInput(workspace_root="/ws", work_mode="annotation") - parent = [ - _tool("grep_workspace"), - _tool("read_file_annotation"), - _tool("auto_annotate"), - _tool("explore_readonly"), - ] - assert should_include_annotation_reads(ctx, parent) is True - fn_map = filter_explore_inner_fn_map( - { - "grep_workspace": lambda: "ok", - "read_file_annotation": lambda: "ann", - "auto_annotate": lambda: "no", - "explore_readonly": lambda: "no", - "memory_write": lambda: "no", - }, - include_annotation_reads=True, - ) - assert "read_file_annotation" in fn_map - assert "auto_annotate" not in fn_map - assert "explore_readonly" not in fn_map - assert "memory_write" not in fn_map - - -@pytest.mark.asyncio -async def test_cancel_stops_inner_loop(): - async def cancel() -> bool: - return True - - runner = ExploreReadonlyRunner( - llm=SequenceLLM(tool_rounds=4), - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - is_cancelled=cancel, - parent_tools=[_tool("grep_workspace")], - parent_fn_map={"grep_workspace": lambda pattern="": "hit"}, - client_context=None, - ) - events = [ - event - async for event in runner.stream( - query="找 Foo", - focus_path=None, - parent_tool_id="parent-1", - ) - ] - types = [event.type for event in events] - assert types[0] == "subagent_start" - assert types[-1] == "subagent_done" - assert events[-1].status == "error" - assert "停止" in (events[-1].summary or "") - assert "subagent_tool_start" not in types - - -@pytest.mark.asyncio -async def test_max_rounds_still_returns_summary(): - calls = {"n": 0} - - def grep(pattern=""): - calls["n"] += 1 - return f"hit:{pattern}" - - runner = ExploreReadonlyRunner( - llm=SequenceLLM(tool_rounds=8, summary="轮次用尽后的摘要"), - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - is_cancelled=_never_cancel, - parent_tools=[_tool("grep_workspace")], - parent_fn_map={"grep_workspace": grep}, - client_context=None, - ) - events = [ - event - async for event in runner.stream( - query="大范围摸底", - focus_path="src", - parent_tool_id="parent-2", - ) - ] - assert calls["n"] == 8 - done = [event for event in events if event.type == "subagent_done"] - assert len(done) == 1 - assert done[0].status == "done" - assert "摘要" in (done[0].summary or "") - starts = [event for event in events if event.type == "subagent_tool_start"] - assert len(starts) == 8 - - -@pytest.mark.asyncio -async def test_missing_tool_call_id_is_aligned(): - captured: dict = {} - - class NoIdLLM: - def __init__(self): - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk( - tool_calls=[ - {"name": "grep_workspace", "args": {"pattern": "X"}} - ] - ) - return - ai = [m for m in messages if getattr(m, "tool_calls", None)] - tools = [m for m in messages if isinstance(m, ToolMessage)] - captured["ai_ids"] = [c.get("id") for c in ai[-1].tool_calls] - captured["tool_ids"] = [m.tool_call_id for m in tools] - yield MockChunk(content="对齐后的摘要") - - runner = ExploreReadonlyRunner( - llm=NoIdLLM(), - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - is_cancelled=_never_cancel, - parent_tools=[_tool("grep_workspace")], - parent_fn_map={"grep_workspace": lambda pattern="": "hit"}, - client_context=None, - ) - events = [ - event - async for event in runner.stream( - query="缺 id", - focus_path=None, - parent_tool_id="parent-3", - ) - ] - assert captured["ai_ids"] - assert captured["ai_ids"] == captured["tool_ids"] - assert all(captured["ai_ids"]) - assert any(event.type == "subagent_done" and event.status == "done" for event in events) - - -@pytest.mark.asyncio -async def test_forbidden_inner_tools_are_not_invoked(): - counts = {"write": 0, "self": 0, "memory": 0, "grep": 0} - - class ForbiddenLLM: - def __init__(self): - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": {"relative_path": "a.py"}, - }, - { - "id": "e1", - "name": "explore_readonly", - "args": {"query": "x"}, - }, - { - "id": "m1", - "name": "memory_write", - "args": {"name": "x"}, - }, - ] - ) - return - yield MockChunk(content="已拦截写入") - - runner = ExploreReadonlyRunner( - llm=ForbiddenLLM(), - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - is_cancelled=_never_cancel, - parent_tools=[ - _tool("grep_workspace"), - _tool("write_workspace_file"), - _tool("explore_readonly"), - ], - parent_fn_map={ - "grep_workspace": lambda **_: counts.__setitem__("grep", counts["grep"] + 1) or "ok", - "write_workspace_file": lambda **_: counts.__setitem__("write", counts["write"] + 1) or "no", - "explore_readonly": lambda **_: counts.__setitem__("self", counts["self"] + 1) or "no", - "memory_write": lambda **_: counts.__setitem__("memory", counts["memory"] + 1) or "no", - }, - client_context=None, - ) - events = [ - event - async for event in runner.stream( - query="不要写", - focus_path=None, - parent_tool_id="parent-4", - ) - ] - assert counts["write"] == 0 - assert counts["self"] == 0 - assert counts["memory"] == 0 - blocked = [event for event in events if event.type == "subagent_tool_result"] - assert len(blocked) == 3 - assert all("不可用于只读查阅" in (event.result or "") for event in blocked) - - -@pytest.mark.asyncio -async def test_streams_text_delta_before_and_after_tools(): - class NarratingLLM: - def __init__(self): - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk(content="先看") - yield MockChunk(content="路由") - yield MockChunk( - tool_calls=[ - { - "id": "g1", - "name": "grep_workspace", - "args": {"pattern": "route"}, - } - ] - ) - return - yield MockChunk(content="结") - yield MockChunk(content="论") - - runner = ExploreReadonlyRunner( - llm=NarratingLLM(), - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - is_cancelled=_never_cancel, - parent_tools=[_tool("grep_workspace")], - parent_fn_map={"grep_workspace": lambda pattern="": "hit"}, - client_context=None, - ) - events = [ - event - async for event in runner.stream( - query="查路由", - focus_path=None, - parent_tool_id="parent-5", - ) - ] - types = [event.type for event in events] - assert types.index("subagent_text_delta") < types.index("subagent_tool_start") - deltas = [event.content for event in events if event.type == "subagent_text_delta"] - assert deltas == ["先看", "路由", "结", "论"] - done = [event for event in events if event.type == "subagent_done"] - assert len(done) == 1 - assert done[0].summary == "结论" - - -@pytest.mark.asyncio -async def test_stream_assist_routes_subagent_to_aux_llm(): - """配置了辅助模型时,explore_readonly 子代理走 aux_llm 而非主模型。""" - - class ParentLLM: - def __init__(self): - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk( - tool_calls=[ - { - "id": "x1", - "name": EXPLORE_READONLY_TOOL_NAME, - "args": {"query": "找 Foo"}, - } - ] - ) - return - yield MockChunk(content="完成") - - aux = SequenceLLM(tool_rounds=0, summary="查阅摘要") - parent = ParentLLM() - events = [ - event - async for event in stream_assist( - parent, - [HumanMessage(content="查一下")], - [_tool(EXPLORE_READONLY_TOOL_NAME), _tool("grep_workspace")], - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput(workspace_root="/ws"), - user_content="查一下", - aux_llm=aux, # type: ignore[arg-type] - ) - ] - assert aux.calls == 1 - assert parent.calls == 2 - assert any( - event.type == "subagent_done" and event.status == "done" for event in events - ) - - -@pytest.mark.asyncio -async def test_stream_assist_subagent_falls_back_to_parent_llm(): - """未配置辅助模型时,子代理回退为父会话 LLM。""" - - class ParentLLM: - def __init__(self): - self.calls = 0 - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - self.calls += 1 - if self.calls == 1: - yield MockChunk( - tool_calls=[ - { - "id": "x1", - "name": EXPLORE_READONLY_TOOL_NAME, - "args": {"query": "找 Foo"}, - } - ] - ) - return - yield MockChunk(content="完成") - - parent = ParentLLM() - events = [ - event - async for event in stream_assist( - parent, - [HumanMessage(content="查一下")], - [_tool(EXPLORE_READONLY_TOOL_NAME), _tool("grep_workspace")], - settings=SimpleNamespace(agent_subagent_max_tool_rounds=8), - max_tool_rounds=5, - is_cancelled=_never_cancel, - client_context=ClientContextInput(workspace_root="/ws"), - user_content="查一下", - ) - ] - # 父 LLM 既跑主循环也跑子代理内层循环 - assert parent.calls == 3 - assert any( - event.type == "subagent_done" and event.status == "done" for event in events - ) diff --git a/vendor/local-agent/tests/test_label_candidate_resolver.py b/vendor/local-agent/tests/test_label_candidate_resolver.py deleted file mode 100644 index 5348b57..0000000 --- a/vendor/local-agent/tests/test_label_candidate_resolver.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Unit tests for label candidate resolver (no LLM).""" - -import asyncio - -from app.agent.annotation.annotation_scope import AnnotationScope -from app.agent.annotation.label_candidate_resolver import resolve_effective_label_candidates - -ALL = [ - {"id": "a", "name": "curry"}, - {"id": "b", "name": "james"}, - {"id": "c", "name": "durant"}, - {"id": "d", "name": "jokic"}, - {"id": "e", "name": "giannis"}, -] - - -def test_resolve_without_llm_keeps_full_pool(): - scope = AnnotationScope() - - async def _run(): - return await resolve_effective_label_candidates( - None, - all_candidates=ALL, - scope=scope, - user_request="hello", - intent_summary="", - box_count=3, - image_bytes=None, - ) - - result = asyncio.run(_run()) - assert len(result.candidates) == len(ALL) - assert result.source == "full" - - -def test_resolve_ignores_partial_names_in_intent_summary(): - """prepare 摘要举例(curry/james 等)不应缩小 map 候选池。""" - scope = AnnotationScope() - - async def _run(): - return await resolve_effective_label_candidates( - None, - all_candidates=ALL, - scope=scope, - user_request="批量标注 data 文件夹", - intent_summary="标注斯蒂芬库里、勒布朗詹姆斯等球员", - box_count=2, - image_bytes=None, - ) - - result = asyncio.run(_run()) - assert len(result.candidates) == len(ALL) - assert result.source == "full" - - -def test_resolve_scope_include_label_names(): - scope = AnnotationScope(include_label_names=["curry", "durant"]) - - async def _run(): - return await resolve_effective_label_candidates( - None, - all_candidates=ALL, - scope=scope, - user_request="mark curry and durant", - intent_summary="", - box_count=2, - image_bytes=None, - ) - - result = asyncio.run(_run()) - names = {c["name"] for c in result.candidates} - assert names == {"curry", "durant"} - assert result.source == "scope" diff --git a/vendor/local-agent/tests/test_llm_generate_image.py b/vendor/local-agent/tests/test_llm_generate_image.py deleted file mode 100644 index 6561ad3..0000000 --- a/vendor/local-agent/tests/test_llm_generate_image.py +++ /dev/null @@ -1,83 +0,0 @@ -"""llm-generate 图片处理:路径优先、统一压缩为 JPEG、无法加载时 400。""" - -import base64 -import io - -import pytest -from fastapi import HTTPException -from PIL import Image - -from app.api.v1.annotation_agent import _build_generate_image_data_url -from app.schemas.annotation_agent import LlmGenerateRequest - - -class _Settings: - annotation_llm_image_max_edge = 1280 - annotation_llm_image_jpeg_quality = 85 - - -def _png_bytes(width: int, height: int) -> bytes: - buf = io.BytesIO() - Image.new("RGB", (width, height), (255, 0, 0)).save(buf, format="PNG") - return buf.getvalue() - - -def _request(**overrides) -> LlmGenerateRequest: - base = { - "provider_id": "p1", - "api_key": "k", - "base_url": "https://example.com", - "model": "m", - "user_prompt": "描述图片", - } - base.update(overrides) - return LlmGenerateRequest(**base) - - -def _decode_jpeg(data_url: str) -> Image.Image: - assert data_url.startswith("data:image/jpeg;base64,") - raw = base64.b64decode(data_url.split(",", 1)[1]) - image = Image.open(io.BytesIO(raw)) - image.load() - return image - - -def test_no_image_returns_empty() -> None: - assert _build_generate_image_data_url(_request(), _Settings()) == "" - - -def test_base64_image_is_resized_to_jpeg() -> None: - big = _png_bytes(2400, 1200) - body = _request(image_base64=base64.b64encode(big).decode("ascii")) - data_url = _build_generate_image_data_url(body, _Settings()) - image = _decode_jpeg(data_url) - assert max(image.size) <= 1280 - assert image.size == (1280, 640) - - -def test_data_url_prefix_is_stripped() -> None: - raw = _png_bytes(100, 50) - body = _request( - image_base64=f"data:image/png;base64,{base64.b64encode(raw).decode('ascii')}" - ) - image = _decode_jpeg(_build_generate_image_data_url(body, _Settings())) - assert image.size == (100, 50) - - -def test_absolute_path_takes_precedence(tmp_path) -> None: - path = tmp_path / "a.png" - path.write_bytes(_png_bytes(2000, 2000)) - body = _request( - image_absolute_path=str(path), - image_base64=base64.b64encode(_png_bytes(10, 10)).decode("ascii"), - ) - image = _decode_jpeg(_build_generate_image_data_url(body, _Settings())) - # 来自路径的 2000px 图被缩放,而非 base64 的 10px 图 - assert image.size == (1280, 1280) - - -def test_unloadable_image_raises_400() -> None: - body = _request(image_absolute_path="/nonexistent/x.png") - with pytest.raises(HTTPException) as exc_info: - _build_generate_image_data_url(body, _Settings()) - assert exc_info.value.status_code == 400 diff --git a/vendor/local-agent/tests/test_local_auth.py b/vendor/local-agent/tests/test_local_auth.py deleted file mode 100644 index 2ac0fb1..0000000 --- a/vendor/local-agent/tests/test_local_auth.py +++ /dev/null @@ -1,60 +0,0 @@ -"""local-agent 本地 token 鉴权测试。 - -覆盖:未配置 token 时失败关闭、错误/缺失 token 返回 401、正确 token 放行, -以及 /health 保持开放(Electron 主进程据此探活)。 -""" - -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from app.core.deps import get_expected_local_token, require_local_token - - -def _build_app() -> FastAPI: - app = FastAPI() - - @app.get("/health") - async def health() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/protected", dependencies=[Depends(require_local_token)]) - async def protected() -> dict[str, bool]: - return {"ok": True} - - return app - - -def test_health_stays_open_without_token(monkeypatch): - monkeypatch.delenv("LR_AGENT_LOCAL_TOKEN", raising=False) - client = TestClient(_build_app()) - assert client.get("/health").status_code == 200 - # 未配置 token 时失败关闭,禁止访问业务接口 - assert client.get("/protected").status_code == 401 - - -def test_wrong_or_missing_token_rejected(monkeypatch): - monkeypatch.setenv("LR_AGENT_LOCAL_TOKEN", "expected-token") - client = TestClient(_build_app()) - assert client.get("/protected").status_code == 401 - assert ( - client.get( - "/protected", headers={"Authorization": "Bearer nope"} - ).status_code - == 401 - ) - assert ( - client.get( - "/protected", headers={"Authorization": "expected-token"} - ).status_code - == 401 - ) - - -def test_correct_token_accepted(monkeypatch): - monkeypatch.setenv("LR_AGENT_LOCAL_TOKEN", "expected-token") - client = TestClient(_build_app()) - response = client.get( - "/protected", headers={"Authorization": "Bearer expected-token"} - ) - assert response.status_code == 200 - assert get_expected_local_token() == "expected-token" diff --git a/vendor/local-agent/tests/test_map_validation_service.py b/vendor/local-agent/tests/test_map_validation_service.py deleted file mode 100644 index 52eb492..0000000 --- a/vendor/local-agent/tests/test_map_validation_service.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Unit tests for vision mapping validation.""" - -from app.agent.annotation.map_validation_service import ( - MappingIssueCode, - candidates_for_retry_box, - find_label_names_in_text, - validate_vision_mappings, -) - -CANDIDATES = [ - {"id": "a", "name": "curry"}, - {"id": "b", "name": "james"}, - {"id": "c", "name": "durant"}, - {"id": "d", "name": "jokic"}, -] - - -def test_reason_name_mismatch_detects_wrong_id(): - mappings = [ - { - "box_index": 1, - "label_id": "d", - "reason": "图像中的人物是凯文·杜兰特(Kevin Durant)", - } - ] - result = validate_vision_mappings( - mappings, - CANDIDATES, - instance_labels=True, - ) - codes = [i.code for i in result.issues] - assert MappingIssueCode.REASON_NAME_MISMATCH in codes - - -def test_duplicate_label_detected(): - mappings = [ - {"box_index": 0, "label_id": "a", "reason": "curry"}, - {"box_index": 1, "label_id": "a", "reason": "also curry"}, - ] - result = validate_vision_mappings(mappings, CANDIDATES, instance_labels=True) - assert any(i.code == MappingIssueCode.DUPLICATE_LABEL for i in result.issues) - - -def test_valid_mapping_passes(): - mappings = [ - {"box_index": 0, "label_id": "b", "reason": "勒布朗·詹姆斯"}, - {"box_index": 1, "label_id": "c", "reason": "凯文·杜兰特"}, - ] - result = validate_vision_mappings(mappings, CANDIDATES, instance_labels=True) - assert result.ok - - -def test_empty_label_id_allowed_in_mapping_list(): - mappings = [ - {"box_index": 0, "label_id": "b", "reason": "勒布朗·詹姆斯"}, - {"box_index": 1, "label_id": "", "reason": "无法确定"}, - ] - result = validate_vision_mappings(mappings, CANDIDATES, instance_labels=True) - assert result.ok - - -def test_candidates_for_retry_excludes_used(): - filtered = candidates_for_retry_box(CANDIDATES, {"a", "b"}, keep_label_id="") - ids = {c["id"] for c in filtered} - assert "a" not in ids - assert "b" not in ids - assert "c" in ids - - -def test_find_label_names_in_text(): - hits = find_label_names_in_text("这是 durant 的比赛", CANDIDATES) - assert hits == ["durant"] diff --git a/vendor/local-agent/tests/test_mcp_dedup.py b/vendor/local-agent/tests/test_mcp_dedup.py deleted file mode 100644 index 0034827..0000000 --- a/vendor/local-agent/tests/test_mcp_dedup.py +++ /dev/null @@ -1,91 +0,0 @@ -"""测试 MCP 工具注入时的 capability 去重。""" - -from app.agent.tools.mcp_client import ( - _infer_mcp_capability, - should_expose_mcp_tool, -) -from app.agent.tools.tool_registry_meta import TOOL_CAPABILITY_MAP - - -def test_current_mcp_unique_tools_have_no_capability(): - """现有 MCP 工具不与 canonical 能力冲突,应全部注入。""" - for name in ( - "memory_read", - "memory_write", - "memory_create", - "read_agent_skill", - "list_agent_skill_files", - ): - assert _infer_mcp_capability(name) is None - - -def test_infer_unknown_tool(): - assert _infer_mcp_capability("some_unknown_tool") is None - - -def test_all_canonical_tools_have_capability(): - from app.agent.tools.tool_registry_meta import TOOL_RUNNERS - - for name in TOOL_RUNNERS: - assert name in TOOL_CAPABILITY_MAP, f"工具 {name} 缺少能力映射" - - -def test_hides_memory_tools_when_workspace_memory_disabled(): - assert not should_expose_mcp_tool( - "memory_read", workspace_memory_enabled=False - ) - assert not should_expose_mcp_tool( - "memory_write", workspace_memory_enabled=False - ) - assert not should_expose_mcp_tool( - "memory_create", workspace_memory_enabled=False - ) - assert should_expose_mcp_tool( - "read_agent_skill", workspace_memory_enabled=False - ) - assert should_expose_mcp_tool( - "list_agent_skill_files", workspace_memory_enabled=False - ) - - -def test_exposes_memory_tools_when_workspace_memory_enabled(): - for name in ( - "memory_read", - "memory_write", - "memory_create", - "read_agent_skill", - "list_agent_skill_files", - ): - assert should_expose_mcp_tool( - name, - workspace_memory_enabled=True, - agent_mode="annotation", - ) - - -def test_ask_mode_exposes_only_memory_read(): - assert should_expose_mcp_tool( - "memory_read", - workspace_memory_enabled=True, - agent_mode="chat", - ) - assert not should_expose_mcp_tool( - "memory_write", - workspace_memory_enabled=True, - agent_mode="chat", - ) - assert not should_expose_mcp_tool( - "memory_create", - workspace_memory_enabled=True, - agent_mode=None, - ) - assert should_expose_mcp_tool( - "read_agent_skill", - workspace_memory_enabled=True, - agent_mode="chat", - ) - assert should_expose_mcp_tool( - "list_agent_skill_files", - workspace_memory_enabled=True, - agent_mode="chat", - ) diff --git a/vendor/local-agent/tests/test_mcp_servers.py b/vendor/local-agent/tests/test_mcp_servers.py deleted file mode 100644 index 7b1a109..0000000 --- a/vendor/local-agent/tests/test_mcp_servers.py +++ /dev/null @@ -1,324 +0,0 @@ -"""多 MCP Server(本机 + 远程)加载:连接参数、失败隔离、去重与 probe。""" - -import asyncio - -import pytest -from langchain_core.tools import StructuredTool - -pytest.importorskip("langchain_mcp_adapters") - -from app.agent.tools import mcp_client # noqa: E402 -from app.agent.tools.tool_registry_meta import ToolCapability # noqa: E402 - - -class _FakeClient: - """替代 MultiServerMCPClient:记录连接参数,按 server 名返回预设工具。""" - - instances: list["_FakeClient"] = [] - fail_on: set[str] = set() - tools_by_server: dict[str, list[StructuredTool]] = {} - - def __init__(self, connections): - self.connections = connections - type(self).instances.append(self) - - async def get_tools(self): - name = next(iter(self.connections)) - if name in type(self).fail_on: - raise RuntimeError("connect failed") - return list(type(self).tools_by_server.get(name, [])) - - -def _tool(name: str) -> StructuredTool: - return StructuredTool.from_function(func=lambda: "ok", name=name, description="t") - - -class _Server: - """模拟 schemas.agent.McpServerInput 的属性访问。""" - - def __init__(self, id, url, transport="streamable_http", headers=None, disabled_tools=None): - self.id = id - self.url = url - self.transport = transport - self.headers = headers or {} - self.disabled_tools = disabled_tools or [] - - -@pytest.fixture(autouse=True) -def _patch_client(monkeypatch): - _FakeClient.instances = [] - _FakeClient.fail_on = set() - _FakeClient.tools_by_server = {} - mcp_client.clear_mcp_tools_cache() - monkeypatch.setattr( - "langchain_mcp_adapters.client.MultiServerMCPClient", - _FakeClient, - ) - yield - mcp_client.clear_mcp_tools_cache() - - -async def test_local_url_appends_mcp_endpoint(): - await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - conn = _FakeClient.instances[0].connections["lr-agent-local"] - assert conn["url"] == "http://127.0.0.1:9000/mcp" - assert conn["transport"] == "streamable_http" - - -async def test_remote_url_used_verbatim_with_sse_and_headers(): - servers = [ - _Server( - "tavily", - "https://mcp.tavily.com/mcp", - "sse", - {"Authorization": "Bearer k"}, - ) - ] - await mcp_client.load_mcp_tools_from_servers(None, servers) - conn = _FakeClient.instances[0].connections["mcp-remote-tavily"] - # 远程 URL 原样使用,不拼 /mcp - assert conn["url"] == "https://mcp.tavily.com/mcp" - assert conn["transport"] == "sse" - assert conn["headers"] == {"Authorization": "Bearer k"} - - -async def test_remote_invalid_transport_falls_back(): - servers = [_Server("a", "https://a.example.com/mcp", "stdio")] - await mcp_client.load_mcp_tools_from_servers(None, servers) - conn = _FakeClient.instances[0].connections["mcp-remote-a"] - assert conn["transport"] == "streamable_http" - - -async def test_failing_server_does_not_block_others(): - _FakeClient.fail_on.add("lr-agent-local") - _FakeClient.tools_by_server["mcp-remote-tavily"] = [_tool("tavily_search")] - servers = [_Server("tavily", "https://mcp.tavily.com/mcp")] - tools = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", servers - ) - assert [t.name for t in tools] == ["tavily_search"] - - -async def test_duplicate_tool_names_across_servers_skipped(): - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("shared")] - _FakeClient.tools_by_server["mcp-remote-a"] = [_tool("shared"), _tool("other")] - servers = [_Server("a", "https://a.example.com/mcp")] - tools = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", servers - ) - assert [t.name for t in tools] == ["shared", "other"] - - -async def test_capability_conflict_skipped(monkeypatch): - """远程工具若推断出与 canonical 相同能力,应被去重跳过。""" - monkeypatch.setattr( - mcp_client, - "_infer_mcp_capability", - lambda name: ( - ToolCapability.READ_TEXT_FILE if name == "shadow_read" else None - ), - ) - _FakeClient.tools_by_server["mcp-remote-a"] = [ - _tool("shadow_read"), - _tool("fine"), - ] - servers = [_Server("a", "https://a.example.com/mcp")] - tools = await mcp_client.load_mcp_tools_from_servers(None, servers) - assert [t.name for t in tools] == ["fine"] - - -async def test_no_servers_returns_empty(): - assert await mcp_client.load_mcp_tools_from_servers(None, []) == [] - assert _FakeClient.instances == [] - - -async def test_backward_compat_single_local_loader(): - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - tools = await mcp_client.load_mcp_tools_from_server("http://127.0.0.1:9000/") - assert [t.name for t in tools] == ["memory_read"] - assert _FakeClient.instances[0].connections["lr-agent-local"]["url"].endswith( - "/mcp" - ) - - -async def test_probe_passes_transport_and_headers(): - _FakeClient.tools_by_server["mcp-probe"] = [_tool("x")] - tools = await mcp_client.probe_mcp_tools( - "https://x.example.com/mcp", - transport="sse", - headers={"Authorization": "Bearer k"}, - ) - conn = _FakeClient.instances[0].connections["mcp-probe"] - assert conn["url"] == "https://x.example.com/mcp" - assert conn["transport"] == "sse" - assert conn["headers"]["Authorization"] == "Bearer k" - assert [t.name for t in tools] == ["x"] - - -async def test_disabled_tools_are_filtered_before_dedup(): - _FakeClient.tools_by_server["mcp-remote-tavily"] = [ - _tool("tavily_search"), - _tool("tavily_crawl"), - ] - servers = [ - _Server( - "tavily", - "https://mcp.tavily.com/mcp", - disabled_tools=["tavily_crawl"], - ) - ] - tools = await mcp_client.load_mcp_tools_from_servers(None, servers) - assert [t.name for t in tools] == ["tavily_search"] - - -# ── 工具发现 TTL 缓存 ──────────────────────────────────────────────── - - -async def test_cache_hit_skips_second_discovery(): - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - first = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [] - ) - assert len(_FakeClient.instances) == 1 - second = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [] - ) - # 命中缓存:不重新建连 - assert len(_FakeClient.instances) == 1 - assert [t.name for t in first] == ["memory_read"] - assert [t.name for t in second] == ["memory_read"] - - -async def test_ttl_zero_bypasses_cache(): - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [], ttl_seconds=0 - ) - await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [], ttl_seconds=0 - ) - assert len(_FakeClient.instances) == 2 - - -async def test_different_headers_discover_separately(): - _FakeClient.tools_by_server["mcp-remote-a"] = [_tool("tool_a")] - await mcp_client.load_mcp_tools_from_servers( - None, [_Server("a", "https://a.example.com/mcp", headers={"K": "1"})] - ) - await mcp_client.load_mcp_tools_from_servers( - None, [_Server("a", "https://a.example.com/mcp", headers={"K": "2"})] - ) - assert len(_FakeClient.instances) == 2 - - -async def test_disabled_tools_filter_applies_to_cached_result(): - """缓存存原始发现结果:运行时改 disabled_tools 立即生效,无需重新建连。""" - _FakeClient.tools_by_server["mcp-remote-a"] = [ - _tool("tool_a"), - _tool("tool_b"), - ] - tools_all = await mcp_client.load_mcp_tools_from_servers( - None, [_Server("a", "https://a.example.com/mcp")] - ) - assert [t.name for t in tools_all] == ["tool_a", "tool_b"] - tools_filtered = await mcp_client.load_mcp_tools_from_servers( - None, - [_Server("a", "https://a.example.com/mcp", disabled_tools=["tool_b"])], - ) - assert len(_FakeClient.instances) == 1 - assert [t.name for t in tools_filtered] == ["tool_a"] - - -async def test_failed_discovery_not_cached(): - _FakeClient.fail_on.add("lr-agent-local") - first = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [] - ) - assert first == [] - _FakeClient.fail_on.clear() - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - second = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [] - ) - # 失败不写缓存:第二次重新发现成功 - assert len(_FakeClient.instances) == 2 - assert [t.name for t in second] == ["memory_read"] - - -# ── 过期缓存:stale-while-revalidate ───────────────────────────────── - - -async def _flush_refreshes(): - """等待已排期的后台刷新跑完。 - - 每轮让出一次事件循环:task 的 done-callback(从 _refresh_tasks 摘除)靠 loop 回调执行, - 只 await 已完成的 task 不会让出循环。 - """ - for _ in range(10): - if not mcp_client._refresh_tasks: - return - await asyncio.gather(*list(mcp_client._refresh_tasks), return_exceptions=True) - await asyncio.sleep(0) - - -async def test_stale_cache_returns_old_tools_without_waiting(): - """过期后本轮直接用旧列表(不重新建连),后台刷新下一轮生效。""" - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - assert len(_FakeClient.instances) == 1 - - # 让缓存过期,并换成新工具列表 - key = mcp_client._cache_key(mcp_client._local_connection("http://127.0.0.1:9000")) - tools_old, _ = mcp_client._tools_cache[key] - mcp_client._tools_cache[key] = (tools_old, 0.0) - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_write")] - - stale = await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - # 本轮仍是旧列表,且没有为它同步建连 - assert [t.name for t in stale] == ["memory_read"] - assert len(_FakeClient.instances) == 1 - - await _flush_refreshes() - refreshed = await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - assert [t.name for t in refreshed] == ["memory_write"] - - -async def test_stale_refresh_failure_keeps_old_tools(): - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - key = mcp_client._cache_key(mcp_client._local_connection("http://127.0.0.1:9000")) - tools_old, _ = mcp_client._tools_cache[key] - mcp_client._tools_cache[key] = (tools_old, 0.0) - - _FakeClient.fail_on.add("lr-agent-local") - assert [t.name for t in await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [] - )] == ["memory_read"] - await _flush_refreshes() - - # 刷新失败:旧条目仍在,下一轮仍可用 - _FakeClient.fail_on.clear() - still = await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - assert [t.name for t in still] == ["memory_read"] - - -async def test_no_cache_still_discovers_synchronously(): - """首轮无缓存必须同步发现(不能返回空列表)。""" - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - tools = await mcp_client.load_mcp_tools_from_servers("http://127.0.0.1:9000", []) - assert [t.name for t in tools] == ["memory_read"] - assert len(_FakeClient.instances) == 1 - - -async def test_ttl_zero_does_not_serve_stale(): - """ttl=0 关闭缓存:每次都重新发现,不走 stale 分支。""" - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_read")] - await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [], ttl_seconds=0 - ) - _FakeClient.tools_by_server["lr-agent-local"] = [_tool("memory_write")] - tools = await mcp_client.load_mcp_tools_from_servers( - "http://127.0.0.1:9000", [], ttl_seconds=0 - ) - assert [t.name for t in tools] == ["memory_write"] - assert len(_FakeClient.instances) == 2 diff --git a/vendor/local-agent/tests/test_mutation_prepare_schema.py b/vendor/local-agent/tests/test_mutation_prepare_schema.py deleted file mode 100644 index 135a0af..0000000 --- a/vendor/local-agent/tests/test_mutation_prepare_schema.py +++ /dev/null @@ -1,115 +0,0 @@ -from app.agent.annotation.mutation_prepare_service import ( - MutationOperationSchema, - MutationPrepareLlmResult, -) - - -def test_mutation_kinds_include_geometry_and_content(): - op = MutationOperationSchema.model_validate( - { - "relative_path": "data/1.jpg", - "mutation_kind": "patch_geometry", - "targets": [{"by": "unlabeled"}], - "x": 0.1, - "y": 0.2, - "width": 0.15, - "height": 0.18, - } - ) - assert op.mutation_kind == "patch_geometry" - assert op.x == 0.1 - - content = MutationOperationSchema.model_validate( - { - "relative_path": "math.md", - "mutation_kind": "patch_content", - "targets": [{"by": "all"}], - "steps": [ - {"description": "设未知数", "conclusion": "x,y"}, - {"description": "求解", "conclusion": "23,12"}, - ], - "answer": "鸡 23 兔 12", - } - ) - assert content.mutation_kind == "patch_content" - assert len(content.steps or []) == 2 - - -def test_prepare_llm_result_accepts_duplicate_label_target(): - parsed = MutationPrepareLlmResult.model_validate( - { - "selected_paths": ["review.txt"], - "intent_summary": "去重", - "operations": [ - { - "relative_path": "review.txt", - "mutation_kind": "delete", - "targets": [{"by": "duplicate_label"}], - } - ], - } - ) - assert parsed.operations[0].targets[0].by == "duplicate_label" - - -def test_geometry_accepts_rotated_and_pose_fields(): - op = MutationOperationSchema.model_validate( - { - "relative_path": "data/1.jpg", - "mutation_kind": "patch_geometry", - "targets": [{"by": "selected"}], - "cx": 0.5, - "cy": 0.4, - "angle": 30.0, - "keypoints": [{"x": 0.1, "y": 0.2, "visibility": 2}], - } - ) - assert op.cx == 0.5 - assert op.angle == 30.0 - assert op.keypoints is not None and op.keypoints[0].visibility == 2 - - -def test_content_accepts_span_preference_conversation_fields(): - op = MutationOperationSchema.model_validate( - { - "relative_path": "data/a.txt", - "mutation_kind": "patch_content", - "targets": [{"by": "id", "id": "s1"}], - "start": 0, - "end": 12, - "prompt": "问", - "chosen": "好", - "rejected": "差", - "turns": [{"role": "user", "content": "你好"}], - } - ) - assert op.start == 0 and op.end == 12 - assert op.turns is not None and op.turns[0].role == "user" - - -def test_span_offset_rejects_negative(): - import pytest - - with pytest.raises(ValueError): - MutationOperationSchema.model_validate( - { - "relative_path": "data/a.txt", - "mutation_kind": "patch_content", - "targets": [{"by": "all"}], - "start": -1, - } - ) - - -def test_turns_reject_unknown_role(): - import pytest - - with pytest.raises(ValueError): - MutationOperationSchema.model_validate( - { - "relative_path": "data/a.txt", - "mutation_kind": "patch_content", - "targets": [{"by": "all"}], - "turns": [{"role": "system", "content": "x"}], - } - ) diff --git a/vendor/local-agent/tests/test_plan_parsing.py b/vendor/local-agent/tests/test_plan_parsing.py deleted file mode 100644 index fcc6edf..0000000 --- a/vendor/local-agent/tests/test_plan_parsing.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for batch plan parsing defaults.""" - -from app.agent.annotation.plan_parsing import build_batch_plan_from_data -from app.agent.annotation.schemas import AnnotationScopePayload - - -def test_instance_labels_default_allow_unlabeled_boxes(): - label_candidates = [ - {"id": "a", "name": "斯蒂芬库里"}, - {"id": "b", "name": "勒布朗詹姆斯"}, - ] - plan = build_batch_plan_from_data( - { - "use_vision_mapping": False, - "sub_agent_constraints": {}, - }, - user_request="标注球星", - intent_summary="标注球星", - annotation_scope=AnnotationScopePayload(), - label_candidates=label_candidates, - detection_models=[{"id": "m1", "name": "YOLO"}], - default_conf=0.7, - default_iou=0.5, - provider_is_vision=True, - ) - assert plan.sub_agent_constraints.allow_unlabeled_boxes is True - assert plan.use_vision_mapping is True - - -def test_coco_label_names_do_not_force_allow_unlabeled_by_default(): - label_candidates = [{"id": "p", "name": "person"}] - plan = build_batch_plan_from_data( - {"sub_agent_constraints": {}}, - user_request="标注人", - intent_summary="标注人", - annotation_scope=AnnotationScopePayload(), - label_candidates=label_candidates, - detection_models=[{"id": "m1"}], - default_conf=0.7, - default_iou=0.5, - provider_is_vision=True, - ) - assert plan.sub_agent_constraints.allow_unlabeled_boxes is False diff --git a/vendor/local-agent/tests/test_proposal_streamer.py b/vendor/local-agent/tests/test_proposal_streamer.py deleted file mode 100644 index c984a52..0000000 --- a/vendor/local-agent/tests/test_proposal_streamer.py +++ /dev/null @@ -1,362 +0,0 @@ -"""测试 ProposalStreamInterceptor 的 JSON 增量解析。""" - -from app.agent.assist.proposal_streamer import ( - ProposalStreamInterceptor, - _extract_json_string, -) - - -class TestExtractJsonString: - def test_complete_string(self): - buf = '{"relative_path": "reports/summary.md", "content": "hello"}' - val, closed = _extract_json_string(buf, "relative_path") - assert val == "reports/summary.md" - assert closed is True - - def test_incomplete_string(self): - buf = '{"relative_path": "reports/summary' - val, closed = _extract_json_string(buf, "relative_path") - assert val == "reports/summary" - assert closed is False - - def test_key_not_present(self): - buf = '{"other": "value"}' - val, closed = _extract_json_string(buf, "relative_path") - assert val is None - assert closed is False - - def test_escaped_quotes(self): - buf = '{"relative_path": "path/with\\"quote.jpg"}' - val, closed = _extract_json_string(buf, "relative_path") - assert val == 'path/with"quote.jpg' - assert closed is True - - def test_content_extraction(self): - buf = '{"content": "hello world"}' - val, closed = _extract_json_string(buf, "content") - assert val == "hello world" - assert closed is True - - -class TestProposalStreamInterceptor: - def _make_chunk(self, index, name, args): - """构造一个 mock AIMessageChunk。""" - class MockChunk: - tool_call_chunks = [] - chunk = MockChunk() - chunk.tool_call_chunks = [ - {"index": index, "name": name, "args": args, "id": None} - ] - return chunk - - def test_write_tool_starts_state(self): - interceptor = ProposalStreamInterceptor() - chunk = self._make_chunk(0, "write_workspace_file", '{"relative_path": "out.md') - events = interceptor.on_chunk(chunk) - # 路径尚未闭合,不应有 file_proposal_start - assert len(events) == 0 - assert 0 in interceptor.fp_states - - def test_path_closed_emits_start(self): - interceptor = ProposalStreamInterceptor() - chunk = self._make_chunk(0, "write_workspace_file", '{"relative_path": "out.md"}') - events = interceptor.on_chunk(chunk) - assert any(e.type == "file_proposal_start" for e in events) - - def test_non_write_tool_ignored(self): - interceptor = ProposalStreamInterceptor() - chunk = self._make_chunk(1, "read_workspace_file", '{"relative_path": "x.txt"}') - events = interceptor.on_chunk(chunk) - assert len(events) == 0 - assert 1 not in interceptor.fp_states - - def test_collected_paths(self): - interceptor = ProposalStreamInterceptor() - chunk = self._make_chunk(0, "write_workspace_file", '{"relative_path": "a.md"}') - interceptor.on_chunk(chunk) - paths = interceptor.collected_paths() - assert "a.md" in paths - - def test_lr_agent_path_does_not_emit_proposal(self): - interceptor = ProposalStreamInterceptor() - chunk = self._make_chunk( - 0, - "write_workspace_file", - '{"relative_path": ".lr-agent/annotations/files/a.json", "content": "{}"}', - ) - events = interceptor.on_chunk(chunk) - assert events == [] - assert interceptor.collected_paths() == set() - - def _make_chunk_with_id(self, index, name, args, call_id): - class MockChunk: - tool_call_chunks = [] - chunk = MockChunk() - chunk.tool_call_chunks = [ - {"index": index, "name": name, "args": args, "id": call_id} - ] - return chunk - - def _stream_call(self, interceptor, index, call_id, rel_path, content, pieces=3): - """把一次完整 write 调用按 pieces 个分片喂给拦截器,返回全部事件。 - - 模拟 OpenAI 风格流式:首块携带 name 与 tool_call id(args 为空), - 后续块为 args 增量且 id 为 None。 - """ - args = '{"relative_path": "%s", "content": "%s"}' % (rel_path, content) - step = max(1, len(args) // pieces) - events = interceptor.on_chunk( - self._make_chunk_with_id(index, "write_workspace_file", "", call_id) - ) - for start in range(0, len(args), step): - events += interceptor.on_chunk( - self._make_chunk_with_id( - index, "write_workspace_file", args[start : start + step], None - ) - ) - return events - - def test_new_call_reusing_index_resets_state(self): - """回归:同请求下一轮复用 tc_index 写另一个文件时, - delta 必须归属新路径,且不能吞掉新内容前缀。 - - 历史故障:拦截器跨轮复用时残留上一轮的 rel_path 与 - content_sent_len,导致 A 文件提案被追加 B 文件内容。 - """ - interceptor = ProposalStreamInterceptor() - # 第 1 轮:idx 0 写 a.md - round1 = self._stream_call( - interceptor, 0, "call_1", "a.md", "A" * 50 - ) - assert any(e.type == "file_proposal_start" and e.image_path == "a.md" for e in round1) - - # 第 2 轮:idx 0 复用,写 b.md(内容比 a.md 长) - round2 = self._stream_call( - interceptor, 0, "call_2", "b.md", "B" * 120 - ) - starts = [e for e in round2 if e.type == "file_proposal_start"] - deltas = [e for e in round2 if e.type == "file_proposal_delta"] - # 必须产生新的 start,且归属 b.md - assert any(e.image_path == "b.md" for e in starts) - # 所有 delta 都归属 b.md,不能错标成 a.md - assert all(e.image_path == "b.md" for e in deltas) - # delta 拼接必须等于完整内容(无前缀丢失) - assert "".join(e.content for e in deltas) == "B" * 120 - - def test_fresh_instance_per_round_production_pattern(self): - """生产模式回归:assist_service 每轮新建拦截器,跨轮互不影响。""" - # 第 1 轮 - first = ProposalStreamInterceptor() - self._stream_call(first, 0, "call_1", "a.md", "A" * 50) - # 第 2 轮 - second = ProposalStreamInterceptor() - round2 = self._stream_call(second, 0, "call_2", "b.md", "B" * 120) - deltas = [e for e in round2 if e.type == "file_proposal_delta"] - assert all(e.image_path == "b.md" for e in deltas) - assert "".join(e.content for e in deltas) == "B" * 120 - - def test_duplicate_delivery_is_idempotent(self): - interceptor = ProposalStreamInterceptor() - events = interceptor.on_chunk( - self._make_chunk_with_id( - 0, "write_workspace_file", '{"relative_path": "a.md", "content": "hel', None - ) - ) - # 重复投递已见前缀 - events += interceptor.on_chunk( - self._make_chunk_with_id( - 0, "write_workspace_file", '{"relative_path": "a.md", "content": "hel', None - ) - ) - events += interceptor.on_chunk( - self._make_chunk_with_id(0, "write_workspace_file", 'lo"}', None) - ) - deltas = [e for e in events if e.type == "file_proposal_delta"] - assert "".join(e.content for e in deltas) == "hello" - - def test_unicode_escape_in_content(self): - val, closed = _extract_json_string( - '{"content": "\\u4e2d\\u6587"}', "content" - ) - assert val == "中文" - assert closed is True - - def test_unicode_surrogate_pair(self): - val, _ = _extract_json_string( - '{"content": "\\ud83d\\ude00"}', "content" - ) - assert val == "\U0001f600" - - def test_incomplete_unicode_escape_waits_for_more(self): - # \u 转义被分片截断:解析应暂停,等更多数据后整体重解析 - val, closed = _extract_json_string('{"content": "\\u4e2', "content") - assert closed is False - val2, closed2 = _extract_json_string('{"content": "\\u4e2d"}', "content") - assert val2 == "中" - assert closed2 is True - - -class TestDisplayPathNormalization: - """回归:流式事件与工具结果定稿事件必须产出同一条路径字符串。 - - 历史 bug:模型把 relative_path 写成绝对路径或带 ./ 前缀时,拦截器 - 原样透传,而定稿事件用的是后端归一化后的显示路径,前端按路径匹配 - 提案块失败,同一次提案裂成两张卡片,点击各开一个同名 tab。 - """ - - def _interceptor(self, workspace_root): - from app.schemas.agent import ClientContextInput - - return ProposalStreamInterceptor( - ClientContextInput(workspace_root=str(workspace_root)) - ) - - def _feed_write_args(self, interceptor, index, relative_path_value): - from types import SimpleNamespace - - args = ( - '{"relative_path": "%s", "content": "hello"}' % relative_path_value - ) - chunk = SimpleNamespace( - tool_call_chunks=[ - { - "index": index, - "name": "write_workspace_file", - "args": args, - "id": "c1", - } - ] - ) - events = interceptor.on_chunk(chunk) - starts = [e for e in events if e.type == "file_proposal_start"] - deltas = [e for e in events if e.type == "file_proposal_delta"] - return starts, deltas - - def test_absolute_path_normalized(self, tmp_path): - target = tmp_path / "sub" - target.mkdir() - interceptor = self._interceptor(tmp_path) - # 真实模型输出的 JSON 中反斜杠必须转义为 \\ - absolute = str(target / "a.md") - starts, deltas = self._feed_write_args( - interceptor, 0, absolute.replace("\\", "\\\\") - ) - assert len(starts) == 1 - assert starts[0].image_path == "sub/a.md" - assert all(e.image_path == "sub/a.md" for e in deltas) - assert interceptor.collected_paths() == {"sub/a.md"} - - def test_dot_slash_prefix_normalized(self, tmp_path): - interceptor = self._interceptor(tmp_path) - starts, _ = self._feed_write_args(interceptor, 0, "./a.md") - assert len(starts) == 1 - assert starts[0].image_path == "a.md" - assert interceptor.collected_paths() == {"a.md"} - - def test_plain_relative_path_unchanged(self, tmp_path): - interceptor = self._interceptor(tmp_path) - starts, _ = self._feed_write_args(interceptor, 0, "docs/a.md") - assert len(starts) == 1 - assert starts[0].image_path == "docs/a.md" - - def test_no_client_context_falls_back_to_clean_relative(self): - interceptor = ProposalStreamInterceptor() - starts, _ = self._feed_write_args(interceptor, 0, ".\\\\docs\\\\a.md") - assert len(starts) == 1 - assert starts[0].image_path == "docs/a.md" - - -class TestStrReplaceStreaming: - """str_replace_workspace_file 的流式:start 提前出卡 + old/new 增量。""" - - def _make_chunk_with_id(self, index, name, args, call_id): - class MockChunk: - tool_call_chunks = [] - chunk = MockChunk() - chunk.tool_call_chunks = [ - {"index": index, "name": name, "args": args, "id": call_id} - ] - return chunk - - def _stream_edit_call( - self, interceptor, index, call_id, rel_path, old, new, pieces=5 - ): - """把一次完整 str_replace 调用按分片喂给拦截器,返回全部事件。""" - args = ( - '{"relative_path": "%s", "old_string": "%s", "new_string": "%s"}' - % (rel_path, old, new) - ) - step = max(1, len(args) // pieces) - events = interceptor.on_chunk( - self._make_chunk_with_id(index, "str_replace_workspace_file", "", call_id) - ) - for start in range(0, len(args), step): - events += interceptor.on_chunk( - self._make_chunk_with_id( - index, - "str_replace_workspace_file", - args[start : start + step], - None, - ) - ) - return events - - def test_start_emitted_with_edit_mode(self): - interceptor = ProposalStreamInterceptor() - events = self._stream_edit_call( - interceptor, 0, "call_1", "a.py", "print(1)", "print(2)" - ) - starts = [e for e in events if e.type == "file_proposal_start"] - assert len(starts) == 1 - assert starts[0].image_path == "a.py" - assert starts[0].mode == "edit" - - def test_old_new_deltas_reassemble(self): - interceptor = ProposalStreamInterceptor() - old = "def hello():\\n print('hello')\\n" - new = "def hello():\\n print('hi')\\n" - events = self._stream_edit_call( - interceptor, 0, "call_1", "a.py", old, new - ) - edits = [e for e in events if e.type == "file_edit_delta"] - assert edits - joined_old = "".join(e.old_delta or "" for e in edits) - joined_new = "".join(e.new_delta or "" for e in edits) - assert joined_old == old.replace("\\n", "\n") - assert joined_new == new.replace("\\n", "\n") - - def test_collected_paths_and_call_id_map_cover_edits(self): - interceptor = ProposalStreamInterceptor() - self._stream_edit_call( - interceptor, 0, "call_1", "a.py", "old", "new" - ) - assert interceptor.collected_paths() == {"a.py"} - assert interceptor.streamed_paths_by_call_id() == {"call_1": "a.py"} - - def test_new_edit_call_reusing_index_resets_state(self): - """同请求下一轮复用 tc_index 编辑另一文件时不得串路径/吞前缀。""" - interceptor = ProposalStreamInterceptor() - self._stream_edit_call(interceptor, 0, "call_1", "a.py", "A" * 30, "B" * 10) - events = self._stream_edit_call( - interceptor, 0, "call_2", "b.py", "C" * 40, "D" * 20 - ) - starts = [e for e in events if e.type == "file_proposal_start"] - assert any(e.image_path == "b.py" for e in starts) - edits = [e for e in events if e.type == "file_edit_delta"] - assert all(e.image_path == "b.py" for e in edits) - assert "".join(e.old_delta or "" for e in edits) == "C" * 40 - assert "".join(e.new_delta or "" for e in edits) == "D" * 20 - - def test_lr_agent_edit_suppressed(self): - interceptor = ProposalStreamInterceptor() - events = self._stream_edit_call( - interceptor, - 0, - "call_1", - ".lr-agent/memory/progress.md", - "old", - "new", - ) - assert events == [] - assert interceptor.collected_paths() == set() - diff --git a/vendor/local-agent/tests/test_read_file_annotation.py b/vendor/local-agent/tests/test_read_file_annotation.py deleted file mode 100644 index f3a900d..0000000 --- a/vendor/local-agent/tests/test_read_file_annotation.py +++ /dev/null @@ -1,109 +0,0 @@ -"""read_file_annotation 工具:大标注文件分页与截断。""" - -from __future__ import annotations - -import json -import uuid -from pathlib import Path - -import pytest - -from app.agent.annotation.annotation_doc_reader import compute_file_key -from app.agent.tools.registry import build_tools_p1 -from app.core.config import Settings -from app.models.user import User -from app.schemas.agent import ClientContextInput - - -@pytest.fixture -def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: - monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-unit-tests") - return Settings() - - -@pytest.fixture -def user() -> User: - return User( - id=uuid.uuid4(), - email="test@example.com", - email_verified=True, - username="tester", - display_name="Tester", - password_hash="x", - is_active=True, - ) - - -def _write_annotation_doc(project_dir: Path, relative_path: str, count: int) -> None: - files_dir = project_dir / ".lr-agent" / "annotations" / "files" - files_dir.mkdir(parents=True) - doc = { - "relativePath": relative_path, - "annotations": [ - {"id": f"ann-{i}", "kind": "bbox", "labelId": "l1"} for i in range(count) - ], - } - (files_dir / f"{compute_file_key(relative_path)}.json").write_text( - json.dumps(doc, ensure_ascii=False), - encoding="utf-8", - ) - - -def _get_tool(tools: list, name: str): - return next(t for t in tools if t.name == name) - - -def test_read_file_annotation_paginates( - tmp_path: Path, - user: User, - settings: Settings, -) -> None: - project_dir = tmp_path / "project" - project_dir.mkdir() - _write_annotation_doc(project_dir, "data/a.jpg", 250) - - ctx = ClientContextInput(project_directory_path=str(project_dir)) - tools = build_tools_p1(user, ctx, settings=settings) - tool = _get_tool(tools, "read_file_annotation") - - first_page = tool.func("data/a.jpg") - assert "共 250 条标注" in first_page - assert "第 1-200 条" in first_page - assert "ann-199" in first_page - assert "ann-200" not in first_page - - second_page = tool.func("data/a.jpg", annotation_offset=200) - assert "第 201-250 条" in second_page - assert "ann-249" in second_page - assert "ann-0" not in second_page - - -def test_read_file_annotation_small_doc_no_note( - tmp_path: Path, - user: User, - settings: Settings, -) -> None: - project_dir = tmp_path / "project" - project_dir.mkdir() - _write_annotation_doc(project_dir, "data/b.jpg", 3) - - ctx = ClientContextInput(project_directory_path=str(project_dir)) - tools = build_tools_p1(user, ctx, settings=settings) - tool = _get_tool(tools, "read_file_annotation") - - result = tool.func("data/b.jpg") - assert "共 3 条标注" not in result - assert "ann-2" in result - - -def test_read_file_annotation_missing( - tmp_path: Path, - user: User, - settings: Settings, -) -> None: - project_dir = tmp_path / "project" - project_dir.mkdir() - ctx = ClientContextInput(project_directory_path=str(project_dir)) - tools = build_tools_p1(user, ctx, settings=settings) - tool = _get_tool(tools, "read_file_annotation") - assert "未找到" in tool.func("data/none.jpg") diff --git a/vendor/local-agent/tests/test_resume_cycle.py b/vendor/local-agent/tests/test_resume_cycle.py deleted file mode 100644 index da52bab..0000000 --- a/vendor/local-agent/tests/test_resume_cycle.py +++ /dev/null @@ -1,17 +0,0 @@ -"""集成测试:orchestrator → pending → resume mock。""" - -# 需要完整的 FastAPI + DB + Redis 环境,使用默认标记跳过 -import pytest - -pytestmark = pytest.mark.integration - - -@pytest.mark.skip(reason="需要完整的 PostgreSQL + Redis 基础设施") -class TestResumeCycle: - async def test_full_resume_cycle(self): - """1. stream → tool_pending → 中断 - 2. 前端执行 client_tool → 收集结果 - 3. 新 POST 带 client_tool_results → resume - 4. LLM 收到 ToolMessage → 继续推理 → done - """ - pass diff --git a/vendor/local-agent/tests/test_skills_context.py b/vendor/local-agent/tests/test_skills_context.py deleted file mode 100644 index b141317..0000000 --- a/vendor/local-agent/tests/test_skills_context.py +++ /dev/null @@ -1,88 +0,0 @@ -"""context_snapshot 的 skills catalog 注入测试。""" - -from app.agent.context_snapshot import ( - build_assist_system_prompt, - format_skills_catalog_block, -) -from app.schemas.agent import ClientContextInput, SkillCatalogEntryInput - - -def _build_prompt(client_context: ClientContextInput | None) -> str: - return build_assist_system_prompt( - client_context, - model="test-model", - provider_label="测试", - supports_vision=False, - ) - - -def test_injects_skills_catalog_block() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - skills_catalog=[ - SkillCatalogEntryInput( - name="caveman", - description="Ultra-compressed communication mode.", - ), - SkillCatalogEntryInput( - name="docx", - description="Create and edit Word documents.", - ), - ], - ) - prompt = _build_prompt(ctx) - - assert "【可用 Skills】" in prompt - assert "read_agent_skill" in prompt - assert "list_agent_skill_files" in prompt - assert "relative_path" in prompt - assert "- caveman: Ultra-compressed communication mode." in prompt - assert "- docx: Create and edit Word documents." in prompt - # 其他既有区块仍存在 - assert "【任务】" in prompt - - -def test_skips_empty_catalog() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - skills_catalog=[], - ) - prompt = _build_prompt(ctx) - - assert "【可用 Skills】" not in prompt - assert "read_agent_skill" not in prompt - assert "【任务】" in prompt - - -def test_no_client_context_still_plain() -> None: - prompt = _build_prompt(None) - - assert "【可用 Skills】" not in prompt - - -def test_format_block_skips_entries_missing_name_or_description() -> None: - # 直接单测 format_skills_catalog_block 的防呆逻辑(绕过 pydantic 校验的畸形输入) - block = format_skills_catalog_block( - [ - SkillCatalogEntryInput(name="a", description="valid entry"), - type( - "BadEntry", - (), - {"name": "", "description": "no name"}, - )(), - type( - "BadEntry", - (), - {"name": "no-desc", "description": ""}, - )(), - ] - ) - - assert "- a: valid entry" in block - assert "no-desc" not in block - assert "no name" not in block - - -def test_format_block_empty() -> None: - assert format_skills_catalog_block([]) == "" - assert format_skills_catalog_block(None) == "" diff --git a/vendor/local-agent/tests/test_task_phase.py b/vendor/local-agent/tests/test_task_phase.py deleted file mode 100644 index 7cfabe8..0000000 --- a/vendor/local-agent/tests/test_task_phase.py +++ /dev/null @@ -1,435 +0,0 @@ -"""测试标注任务阶段状态机:阶段推导、工具级门禁、路径级门禁、阶段提示词。""" - -from app.agent.assist.task_phase import ( - TaskPhase, - blocked_tool_names, - check_call_allowed, - coerce_str_list, - derive_task_phase, - extract_call_paths, - phase_prompt_block, -) -from app.schemas.agent import ProposalStateInput - - -def _state( - path: str, - *, - kind: str = "annotation", - status: str = "pending", - operation: str = "append", -) -> ProposalStateInput: - return ProposalStateInput( - path=path, - kind=kind, # type: ignore[arg-type] - status=status, # type: ignore[arg-type] - operation=operation, - ) - - -class TestDeriveTaskPhase: - def test_none_states_returns_none(self) -> None: - assert derive_task_phase(None) is None - - def test_empty_states_returns_none(self) -> None: - assert derive_task_phase([]) is None - - def test_pending_annotation_returns_await_confirm(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - assert ctx is not None - assert ctx.phase == TaskPhase.AWAIT_CONFIRM - assert ctx.pending_annotation_paths == frozenset({"data/2.jpg"}) - assert ctx.gating_enabled - - def test_applied_annotation_returns_verify(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ctx is not None - assert ctx.phase == TaskPhase.VERIFY - assert ctx.applied_annotation_paths == frozenset({"data/2.jpg"}) - assert ctx.gating_enabled - - def test_dismissed_only_returns_none(self) -> None: - assert derive_task_phase([_state("data/2.jpg", status="dismissed")]) is None - - def test_undone_only_returns_none(self) -> None: - assert derive_task_phase([_state("data/2.jpg", status="undone")]) is None - - def test_pending_file_proposal_ignored(self) -> None: - """文件提案 pending 不触发标注阶段门禁。""" - assert derive_task_phase([_state("reports/r.md", kind="file")]) is None - - def test_pending_wins_over_applied(self) -> None: - ctx = derive_task_phase( - [ - _state("data/2.jpg", status="applied"), - _state("data/5.jpg", status="pending"), - ] - ) - assert ctx is not None - assert ctx.phase == TaskPhase.AWAIT_CONFIRM - assert ctx.pending_annotation_paths == frozenset({"data/5.jpg"}) - assert ctx.applied_annotation_paths == frozenset({"data/2.jpg"}) - - def test_paths_normalized(self) -> None: - ctx = derive_task_phase([_state("./data\\2.jpg")]) - assert ctx is not None - assert ctx.pending_annotation_paths == frozenset({"data/2.jpg"}) - - -class TestBlockedToolNames: - def test_no_ctx_no_blocking(self) -> None: - assert blocked_tool_names(None) == frozenset() - - def test_await_confirm_blocks_all_writes(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - blocked = blocked_tool_names(ctx) - assert "auto_annotate" in blocked - assert "mutate_annotation" in blocked - assert "write_workspace_file" in blocked - assert "str_replace_workspace_file" in blocked - assert "delete_workspace_file" in blocked - assert "read_file_annotation" not in blocked - - def test_verify_no_tool_level_blocking(self) -> None: - """verify 阶段只做路径级拦截,不做工具级移除。""" - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert blocked_tool_names(ctx) == frozenset() - - -class TestCheckCallAllowed: - def test_no_ctx_allows_everything(self) -> None: - assert check_call_allowed("auto_annotate", {"paths": ["a.jpg"]}, None) is None - - def test_await_confirm_blocks_annotation_write(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - reason = check_call_allowed( - "auto_annotate", {"paths": ["data/3.jpg"]}, ctx - ) - assert reason is not None - assert "Keep All" in reason - - def test_await_confirm_blocks_workspace_write(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - assert ( - check_call_allowed( - "write_workspace_file", - {"relative_path": "reports/r.md", "content": "x"}, - ctx, - ) - is not None - ) - - def test_await_confirm_allows_reads(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - assert ( - check_call_allowed("read_file_annotation", {"relative_path": "data/2.jpg"}, ctx) - is None - ) - assert ( - check_call_allowed("read_image_for_vision", {"relative_path": "data/2.jpg"}, ctx) - is None - ) - - def test_verify_blocks_reannotate_applied_path(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - reason = check_call_allowed( - "auto_annotate", - {"paths": ["data/2.jpg"], "user_request": "补标"}, - ctx, - ) - assert reason is not None - assert "data/2.jpg" in reason - # 门禁反馈要求模型转述时给出不一致证据与确切下一步 - assert "不一致" in reason - assert "重新标注" in reason - - def test_verify_allows_mutate_applied_path(self) -> None: - """定向修正已落盘文件(带 paths)放行;整文件重标仍走 auto_annotate 拦截。""" - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ( - check_call_allowed( - "mutate_annotation", {"paths": ["data/2.jpg"]}, ctx - ) - is None - ) - - def test_verify_allows_new_path_annotation(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ( - check_call_allowed( - "auto_annotate", - {"paths": ["data/9.jpg"], "user_request": "补标"}, - ctx, - ) - is None - ) - - def test_verify_blocks_all_files(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ( - check_call_allowed( - "auto_annotate", - {"all_files": True, "user_request": "全量"}, - ctx, - ) - is not None - ) - - def test_verify_blocks_auto_annotate_without_paths(self) -> None: - """空 paths/scope_hint 视为未指明范围,拦截。""" - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - reason = check_call_allowed( - "auto_annotate", - {"user_request": "补标"}, - ctx, - ) - assert reason is not None - assert "paths" in reason - - def test_verify_blocks_mutate_without_paths_and_ids(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - reason = check_call_allowed("mutate_annotation", {"user_request": "改"}, ctx) - assert reason is not None - assert "paths" in reason or "annotation_ids" in reason - - def test_verify_allows_mutate_by_applied_annotation_ids(self) -> None: - """定向修正已落盘标注 id(带 annotation_ids)放行。""" - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ - ProposalStateInput( - path="data/7.jpg", - kind="annotation", - status="applied", - annotation_ids=["ann-1", "ann-2"], - ) - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - {"user_request": "改标签", "annotation_ids": ["ann-1"]}, - ctx, - ) - is None - ) - - def test_verify_allows_mutate_by_unknown_ids_without_paths(self) -> None: - """定向修正(annotation_ids 未命中 applied 也无 paths)放行,由前端按 id 落盘。""" - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ - ProposalStateInput( - path="data/7.jpg", - kind="annotation", - status="applied", - annotation_ids=["ann-1"], - ) - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - {"user_request": "改标签", "annotation_ids": ["ann-unknown"]}, - ctx, - ) - is None - ) - - def test_verify_allows_mutate_by_new_path(self) -> None: - """用新 paths 修正未落盘文件,放行。""" - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ - ProposalStateInput( - path="data/7.jpg", - kind="annotation", - status="applied", - annotation_ids=["ann-1"], - ) - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - {"user_request": "改标签", "paths": ["data/9.jpg"]}, - ctx, - ) - is None - ) - - def test_verify_allows_mutate_string_path_not_in_applied(self) -> None: - """模型把 paths 传成单个字符串时,未落盘文件仍应放行。""" - ctx = derive_task_phase( - [ - _state("data/2.jpg", status="applied"), - _state("data/3.jpg", status="applied"), - _state("data/7.jpg", status="applied"), - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - {"user_request": "修正 4.jpg", "paths": "data/4.jpg"}, - ctx, - ) - is None - ) - - def test_verify_allows_mutate_json_string_paths_and_ids(self) -> None: - """模型把 array 序列化成 JSON 字符串时,未落盘目标仍应放行。""" - ctx = derive_task_phase( - [ - _state("data/2.jpg", status="applied"), - _state("data/3.jpg", status="applied"), - _state("data/7.jpg", status="applied"), - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - { - "user_request": "修正 4.jpg", - "paths": '["data/4.jpg"]', - "annotation_ids": ( - '["a1cf7b82-989e-4d91-b009-e83c0ddbfac0",' - ' "a5c8ab25-a442-4f1a-880e-fcf4332a3c18"]' - ), - }, - ctx, - ) - is None - ) - - def test_verify_allows_mutate_by_ids_with_new_path(self) -> None: - """annotation_ids 未命中 applied 且带了新 paths,放行。""" - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ - ProposalStateInput( - path="data/7.jpg", - kind="annotation", - status="applied", - annotation_ids=["ann-1"], - ) - ] - ) - assert ( - check_call_allowed( - "mutate_annotation", - { - "user_request": "改标签", - "annotation_ids": ["ann-unknown"], - "paths": ["data/9.jpg"], - }, - ctx, - ) - is None - ) - - def test_verify_allows_report_write(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ( - check_call_allowed( - "write_workspace_file", - {"relative_path": "reports/r.md", "content": "x"}, - ctx, - ) - is None - ) - - def test_verify_path_matching_normalized(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - assert ( - check_call_allowed( - "auto_annotate", - {"paths": ["./data\\2.jpg"], "user_request": "补标"}, - ctx, - ) - is not None - ) - - -class TestPhasePromptBlock: - def test_no_ctx_empty_block(self) -> None: - assert phase_prompt_block(None) == "" - - def test_await_confirm_block_mentions_paths_and_forbidden(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg")]) - block = phase_prompt_block(ctx) - assert "data/2.jpg" in block - assert "禁止" in block - assert "Keep All" in block - - def test_verify_block_mentions_applied_and_report_rule(self) -> None: - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - block = phase_prompt_block(ctx) - assert "data/2.jpg" in block - assert "落盘" in block - assert "禁止估算" in block - # 禁止过时的确认引导 + 收尾约束(治 Keep All 后的奇怪措辞) - assert "禁止再要求用户确认" in block - assert "不要重复输出报告全文" in block - - -class TestExtractCallPaths: - def test_paths_list(self) -> None: - assert extract_call_paths({"paths": ["a.jpg", " b.jpg "]}) == frozenset( - {"a.jpg", "b.jpg"} - ) - - def test_scope_hint_fallback(self) -> None: - assert extract_call_paths({"scope_hint": "data/, a.jpg"}) == frozenset( - {"data/", "a.jpg"} - ) - - def test_empty_arguments(self) -> None: - assert extract_call_paths({}) == frozenset() - - def test_all_files_string_blocked(self) -> None: - """模型把 all_files 传成字符串 \"true\" 时同样拦截全量重标。""" - ctx = derive_task_phase([_state("data/2.jpg", status="applied")]) - reason = check_call_allowed( - "auto_annotate", - {"all_files": "true", "user_request": "全量"}, - ctx, - ) - assert reason is not None - assert "all_files" in reason - - def test_json_string_array(self) -> None: - assert extract_call_paths({"paths": '["data/4.jpg"]'}) == frozenset( - {"data/4.jpg"} - ) - - def test_single_path_string(self) -> None: - assert extract_call_paths({"paths": "data/4.jpg"}) == frozenset( - {"data/4.jpg"} - ) - - -class TestCoerceStrList: - def test_list_passthrough(self) -> None: - assert coerce_str_list([" a ", "b"]) == ["a", "b"] - - def test_json_array_string(self) -> None: - assert coerce_str_list('["data/4.jpg", "data/5.jpg"]') == [ - "data/4.jpg", - "data/5.jpg", - ] - - def test_single_string(self) -> None: - assert coerce_str_list("data/4.jpg") == ["data/4.jpg"] - - def test_does_not_split_uuid_string_into_chars(self) -> None: - uid = "a1cf7b82-989e-4d91-b009-e83c0ddbfac0" - assert coerce_str_list(uid) == [uid] - - def test_none_and_empty(self) -> None: - assert coerce_str_list(None) == [] - assert coerce_str_list("") == [] - assert coerce_str_list(" ") == [] diff --git a/vendor/local-agent/tests/test_text_sanitize.py b/vendor/local-agent/tests/test_text_sanitize.py deleted file mode 100644 index b586e57..0000000 --- a/vendor/local-agent/tests/test_text_sanitize.py +++ /dev/null @@ -1,18 +0,0 @@ -from app.agent.text_sanitize import sanitize_json_value, sanitize_unicode_text - - -def test_sanitize_unicode_text_removes_surrogates(): - dirty = "标签统计" + "\udcad" + "完成" - cleaned = sanitize_unicode_text(dirty) - assert "\udcad" not in cleaned - assert "标签统计" in cleaned - - -def test_sanitize_json_value_recursive(): - dirty = { - "labelCounts": {"a\uDCAF": 1}, - "files": [{"relativePath": "x\uDCAF.jpg", "labelCounts": {}}], - } - cleaned = sanitize_json_value(dirty) - dumped = str(cleaned) - assert "\udcaf" not in dumped.lower() diff --git a/vendor/local-agent/tests/test_tool_blocking.py b/vendor/local-agent/tests/test_tool_blocking.py deleted file mode 100644 index 7cd5ec9..0000000 --- a/vendor/local-agent/tests/test_tool_blocking.py +++ /dev/null @@ -1,196 +0,0 @@ -"""工具执行的阻塞性回归测试。 - -核心命题:同步工具(grep / 读文件 / 图片编码等)必须在线程池里执行, -不能占住事件循环线程。否则一次大范围 grep 会卡住整个 FastAPI 进程 -(其它会话 SSE 卡顿、/health 可能超时、多个工具只能排队)。 - -测量方式:起一个「心跳 ticker」协程,每 10ms 自增一次计数;随后调用工具。 -若工具阻塞事件循环,ticker 在工具执行期间拿不到执行机会,ticks 不增长。 -""" - -import asyncio -import time - -import pytest - -from app.agent.assist.explore_readonly import _invoke_inner -from app.agent.assist.tool_loop import _invoke_tool_fn - -HEARTBEAT_INTERVAL_S = 0.01 -BLOCKING_TOOL_SLEEP_S = 0.3 -# 300ms 内每 10ms 一次,理论上限约 30 次;留足余量断言 >= 15。 -MIN_EXPECTED_TICKS = 15 - - -class Heartbeat: - """事件循环心跳计数器:记录 interval 期间循环获得了多少次执行机会。""" - - def __init__(self) -> None: - self.ticks = 0 - self._task: asyncio.Task | None = None - self._running = False - - async def _ticker(self) -> None: - while self._running: - await asyncio.sleep(HEARTBEAT_INTERVAL_S) - self.ticks += 1 - - async def __aenter__(self) -> "Heartbeat": - self._running = True - self._task = asyncio.create_task(self._ticker()) - # 让 ticker 先进入 sleep,确保后续测量覆盖工具的整个执行期 - await asyncio.sleep(0) - return self - - async def __aexit__(self, *exc_info: object) -> None: - self._running = False - if self._task is not None: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - - -def _blocking_tool() -> str: - """模拟一次较重的同步扫描(读盘 + 正则),期间不释放执行权。""" - time.sleep(BLOCKING_TOOL_SLEEP_S) - return "blocking tool done" - - -async def _blocking_coroutine_tool() -> str: - """异步工具:本身就会 await,不构成阻塞。""" - await asyncio.sleep(BLOCKING_TOOL_SLEEP_S) - return "async tool done" - - -# ── 主循环:_invoke_tool_fn ────────────────────────────────────────────── - - -class TestInvokeToolFnDoesNotBlockLoop: - async def test_sync_tool_does_not_block_event_loop(self): - """同步工具在线程池执行:执行期间事件循环仍在心跳。""" - async with Heartbeat() as hb: - result = await _invoke_tool_fn( - "blocking", {}, {"blocking": _blocking_tool} - ) - - assert "blocking tool done" in result - assert hb.ticks >= MIN_EXPECTED_TICKS, ( - f"事件循环被同步工具阻塞:{BLOCKING_TOOL_SLEEP_S}s 内仅 {hb.ticks} 次心跳" - ) - - async def test_coroutine_tool_still_runs_on_loop(self): - """协程工具走 await 分支,行为与改造前一致。""" - async with Heartbeat() as hb: - result = await _invoke_tool_fn( - "async_tool", {}, {"async_tool": _blocking_coroutine_tool} - ) - - assert "async tool done" in result - assert hb.ticks >= MIN_EXPECTED_TICKS - - async def test_sync_tool_returning_awaitable_is_awaited(self): - """边界:同步可调用对象返回 awaitable 时,仍要被正确 await。""" - - def sync_returning_coroutine() -> object: - return _blocking_coroutine_tool() - - result = await _invoke_tool_fn( - "hybrid", {}, {"hybrid": sync_returning_coroutine} - ) - assert "async tool done" in result - - async def test_unknown_tool_returns_error_json(self): - """未知工具仍返回结构化错误,不抛异常。""" - result = await _invoke_tool_fn("nope", {}, {}) - assert "未知工具" in result - - async def test_tool_exception_becomes_error_json(self): - """工具抛异常时被捕获为结构化错误。""" - - def boom() -> str: - raise RuntimeError("工具炸了") - - result = await _invoke_tool_fn("boom", {}, {"boom": boom}) - assert "工具执行失败" in result - - -# ── 子代理:_invoke_inner ─────────────────────────────────────────────── - - -class TestInvokeInnerDoesNotBlockLoop: - async def test_subagent_sync_tool_does_not_block_event_loop(self): - """子代理内层的同步工具同样必须走线程池。""" - async with Heartbeat() as hb: - result = await _invoke_inner( - "blocking", {}, {"blocking": _blocking_tool} - ) - - assert "blocking tool done" in result - assert hb.ticks >= MIN_EXPECTED_TICKS, ( - f"子代理内层同步工具阻塞事件循环:" - f"{BLOCKING_TOOL_SLEEP_S}s 内仅 {hb.ticks} 次心跳" - ) - - async def test_subagent_coroutine_tool_still_runs_on_loop(self): - """子代理内层的协程工具走 await 分支。""" - async with Heartbeat() as hb: - result = await _invoke_inner( - "async_tool", {}, {"async_tool": _blocking_coroutine_tool} - ) - - assert "async tool done" in result - assert hb.ticks >= MIN_EXPECTED_TICKS - - -# ── 并行语义:真并发 + 保序 ────────────────────────────────────────────── - - -class TestParallelToolsConcurrency: - async def test_parallel_sync_tools_actually_overlap(self): - """多个同步只读工具应真正并发:总耗时接近单次而非累加。 - - 未走线程池时,3 个 0.3s 的工具会串行花掉约 0.9s; - 走线程池后应接近 0.3s。用宽松阈值避免机器抖动导致的 flaky。 - """ - tools = {f"tool{i}": _blocking_tool for i in range(3)} - - started = time.perf_counter() - await asyncio.gather( - *[_invoke_tool_fn(name, {}, tools) for name in tools], - ) - elapsed = time.perf_counter() - started - - serial_cost = BLOCKING_TOOL_SLEEP_S * 3 - assert elapsed < serial_cost * 0.7, ( - f"三个同步工具疑似仍串行:耗时 {elapsed:.2f}s,串行基线 {serial_cost:.2f}s" - ) - - async def test_parallel_results_preserve_call_order(self): - """gather 结果必须按传入顺序返回,保证消息历史保序。""" - - def make_tool(tag: str, delay: float): - def _tool() -> str: - time.sleep(delay) - return tag - - return _tool - - # 故意让靠前的调用更慢,验证不是按完成顺序返回 - tools = { - "slow": make_tool("first", 0.20), - "fast": make_tool("second", 0.02), - } - names = ["slow", "fast"] - results = await asyncio.gather( - *[_invoke_tool_fn(name, {}, tools) for name in names], - ) - - assert results[0] == "first", "先发起的调用未排在前面,结果顺序被完成顺序污染" - assert results[1] == "second" - - -if __name__ == "__main__": # pragma: no cover - pytest.main([__file__, "-v"]) diff --git a/vendor/local-agent/tests/test_tool_dispatcher.py b/vendor/local-agent/tests/test_tool_dispatcher.py deleted file mode 100644 index ba66b93..0000000 --- a/vendor/local-agent/tests/test_tool_dispatcher.py +++ /dev/null @@ -1,98 +0,0 @@ -import json - -from app.agent.tool_dispatcher import resolve_round_tool_calls, split_resolved_calls -from app.agent.tool_invocation import normalize_api_tool_calls -from app.agent.tools.tool_registry_meta import ToolRunner, get_tool_runner - - -def test_get_tool_runner_write_is_proposal() -> None: - assert get_tool_runner("write_workspace_file") is ToolRunner.PROPOSAL - - -def test_get_tool_runner_batch_is_async() -> None: - assert get_tool_runner("auto_annotate") is ToolRunner.ASYNC - - -def test_get_tool_runner_terminal_is_async() -> None: - """本地 MCP 的 start_terminal_command 必须走 ASYNC(批准后前端执行)""" - assert get_tool_runner("start_terminal_command") is ToolRunner.ASYNC - - -def test_split_terminal_command_goes_async_pending() -> None: - from app.agent.tool_invocation import ResolvedToolCall - - calls = [ - ResolvedToolCall( - "t9", - "start_terminal_command", - {"command": "git", "args": ["status"]}, - "api", - ), - ] - split = split_resolved_calls(calls) - assert len(split.async_pending) == 1 - assert split.async_pending[0].name == "start_terminal_command" - assert split.async_pending[0].arguments["command"] == "git" - - -def test_resolve_api_only_tool_calls() -> None: - api = [{"id": "t1", "name": "auto_annotate", "args": {"user_request": "标注"}}] - calls = resolve_round_tool_calls(api_tool_calls=api) - assert len(calls) == 1 - assert calls[0].name == "auto_annotate" - assert calls[0].source == "api" - - -def test_resolve_empty_api_returns_empty() -> None: - calls = resolve_round_tool_calls(api_tool_calls=[]) - assert calls == [] - - -def test_resolve_skips_completed() -> None: - api = [{"id": "t1", "name": "auto_annotate", "args": {}}] - calls = resolve_round_tool_calls( - api_tool_calls=api, - completed_tools=frozenset({"t1"}), - ) - assert calls == [] - - -def test_resolve_allows_same_name_new_id() -> None: - api = [{"id": "t2", "name": "auto_annotate", "args": {"user_request": "再标"}}] - calls = resolve_round_tool_calls( - api_tool_calls=api, - completed_tools=frozenset({"t1"}), - ) - assert len(calls) == 1 - assert calls[0].tool_call_id == "t2" - - -def test_split_immediate_and_async() -> None: - from app.agent.tool_invocation import ResolvedToolCall - - calls = [ - ResolvedToolCall("t1", "write_workspace_file", {"relative_path": "a.md", "content": "x"}, "api"), - ResolvedToolCall("t2", "auto_annotate", {"user_request": "标注"}, "api"), - ] - split = split_resolved_calls(calls) - assert len(split.immediate) == 1 - assert split.immediate[0].name == "write_workspace_file" - assert len(split.async_pending) == 1 - assert split.async_pending[0].name == "auto_annotate" - - -def test_build_tool_result_shape() -> None: - from app.agent.tools.tool_result import build_tool_result, parse_tool_result - - raw = build_tool_result( - ok=True, - tool="write_workspace_file", - status="proposal_ready", - summary="提案已生成", - proposal_pending=True, - ) - data = parse_tool_result(raw) - assert data is not None - assert data["ok"] is True - assert data["proposal_pending"] is True - assert json.loads(raw)["tool"] == "write_workspace_file" diff --git a/vendor/local-agent/tests/test_tool_fn_map.py b/vendor/local-agent/tests/test_tool_fn_map.py deleted file mode 100644 index abb1e6c..0000000 --- a/vendor/local-agent/tests/test_tool_fn_map.py +++ /dev/null @@ -1,125 +0,0 @@ -"""tool_fn_map 需纳入 MCP 风格(仅 coroutine)工具。""" - -import inspect -import json - -from langchain_core.tools import StructuredTool - -from app.agent.tools.registry import tool_fn_map -from app.agent.tools.tool_result import stringify_tool_output - - -def test_tool_fn_map_keeps_sync_func() -> None: - def ping(x: str = "") -> str: - return f"pong:{x}" - - tool = StructuredTool.from_function(func=ping, name="ping", description="ping") - mapping = tool_fn_map([tool]) - assert mapping["ping"] is ping - assert mapping["ping"](x="a") == "pong:a" - - -def test_tool_fn_map_includes_coroutine_only_tool() -> None: - async def memory_create(topic_file: str, content: str, index_line: str) -> str: - return json.dumps({"ok": True, "topic_file": topic_file}) - - tool = StructuredTool( - name="memory_create", - description="create topic", - coroutine=memory_create, - args_schema={ - "type": "object", - "properties": { - "topic_file": {"type": "string"}, - "content": {"type": "string"}, - "index_line": {"type": "string"}, - }, - }, - ) - mapping = tool_fn_map([tool]) - assert "memory_create" in mapping - assert inspect.iscoroutinefunction(mapping["memory_create"]) - - -async def test_coroutine_only_tool_ainvoke_via_fn_map() -> None: - async def memory_create(topic_file: str, content: str, index_line: str) -> str: - return json.dumps( - {"ok": True, "created": True, "topic_file": topic_file}, - ensure_ascii=False, - ) - - tool = StructuredTool( - name="memory_create", - description="create topic", - coroutine=memory_create, - args_schema={ - "type": "object", - "properties": { - "topic_file": {"type": "string"}, - "content": {"type": "string"}, - "index_line": {"type": "string"}, - }, - }, - ) - fn = tool_fn_map([tool])["memory_create"] - result = await fn( - topic_file="annotated-files.md", - content="# x", - index_line="- [已标文件](topics/annotated-files.md)", - ) - text = stringify_tool_output(result) - assert "未知工具" not in text - payload = json.loads(text) - assert payload["ok"] is True - assert payload["topic_file"] == "annotated-files.md" - - -def test_stringify_tool_output_unwraps_content_blocks() -> None: - from langchain_core.messages import ToolMessage - - blocks = [{"type": "text", "text": '{"ok": true, "created": true}'}] - assert stringify_tool_output((blocks, {"structured_content": {}})) == ( - '{"ok": true, "created": true}' - ) - assert stringify_tool_output(blocks) == '{"ok": true, "created": true}' - assert stringify_tool_output("plain") == "plain" - assert stringify_tool_output(None) == "" - assert ( - stringify_tool_output(ToolMessage(content='{"ok": true}', tool_call_id="c1")) - == '{"ok": true}' - ) - - -async def test_coroutine_content_and_artifact_via_fn_map() -> None: - """贴近 MCP 适配器:coroutine 返回 (content_blocks, artifact)。""" - - async def memory_create(topic_file: str, content: str, index_line: str): - payload = json.dumps( - {"ok": True, "created": True, "topic_file": topic_file}, - ensure_ascii=False, - ) - return ([{"type": "text", "text": payload}], {"structured_content": None}) - - tool = StructuredTool( - name="memory_create", - description="create topic", - coroutine=memory_create, - args_schema={ - "type": "object", - "properties": { - "topic_file": {"type": "string"}, - "content": {"type": "string"}, - "index_line": {"type": "string"}, - }, - }, - response_format="content_and_artifact", - ) - fn = tool_fn_map([tool])["memory_create"] - result = await fn( - topic_file="annotated-files.md", - content="# x", - index_line="- [已标文件](topics/annotated-files.md)", - ) - payload = json.loads(stringify_tool_output(result)) - assert payload["ok"] is True - assert payload["topic_file"] == "annotated-files.md" diff --git a/vendor/local-agent/tests/test_tool_invocation.py b/vendor/local-agent/tests/test_tool_invocation.py deleted file mode 100644 index 0a11f65..0000000 --- a/vendor/local-agent/tests/test_tool_invocation.py +++ /dev/null @@ -1,55 +0,0 @@ -import json - -from app.agent.tool_invocation import ( - normalize_api_tool_calls, -) - - -def test_normalize_api_tool_calls_standard() -> None: - api = [{"id": "t1", "name": "auto_annotate", "args": {"user_request": "标注"}}] - resolved = normalize_api_tool_calls(api_tool_calls=api) - assert len(resolved) == 1 - assert resolved[0].name == "auto_annotate" - assert resolved[0].source == "api" - - -def test_normalize_skips_completed_api_calls() -> None: - api = [{"id": "t1", "name": "auto_annotate", "args": {}}] - resolved = normalize_api_tool_calls( - api_tool_calls=api, - completed_tools=frozenset({"t1"}), - ) - assert resolved == [] - - -def test_normalize_allows_same_name_with_new_id() -> None: - api = [ - {"id": "t1", "name": "auto_annotate", "args": {}}, - {"id": "t2", "name": "auto_annotate", "args": {"user_request": "再标"}}, - ] - resolved = normalize_api_tool_calls( - api_tool_calls=api, - completed_tools=frozenset({"t1"}), - ) - assert len(resolved) == 1 - assert resolved[0].tool_call_id == "t2" - - -def test_normalize_empty_api_calls() -> None: - resolved = normalize_api_tool_calls(api_tool_calls=[]) - assert resolved == [] - - -def test_normalize_with_string_args_coerces_to_dict() -> None: - api = [ - {"id": "t1", "name": "read_workspace_file", "args": json.dumps({"relative_path": "test.py"})} - ] - resolved = normalize_api_tool_calls(api_tool_calls=api) - assert len(resolved) == 1 - assert resolved[0].arguments["relative_path"] == "test.py" - - -def test_normalize_skips_empty_name() -> None: - api = [{"id": "t1", "name": "", "args": {}}] - resolved = normalize_api_tool_calls(api_tool_calls=api) - assert resolved == [] diff --git a/vendor/local-agent/tests/test_tool_loop.py b/vendor/local-agent/tests/test_tool_loop.py deleted file mode 100644 index 6bbee43..0000000 --- a/vendor/local-agent/tests/test_tool_loop.py +++ /dev/null @@ -1,793 +0,0 @@ -"""测试 ToolLoopRunner:单轮/多轮 mock LLM,含 tool call 解析与执行。""" - -import pytest -from app.agent.assist.tool_loop import ToolLoopRunner -from app.agent.assist.proposal_streamer import ProposalStreamInterceptor - - -# ── Helpers ────────────────────────────────────────────────────────────── - -class MockChatOpenAI: - """Mock ChatOpenAI,astream 返回预设 chunk 序列。""" - def __init__(self, chunks): - self._chunks = chunks - - def bind_tools(self, tools, tool_choice=None): - return self - - async def astream(self, messages): - for chunk in self._chunks: - yield chunk - - async def ainvoke(self, messages): - # tool_choice="any" retry 使用 - return self._chunks[-1] if self._chunks else type("MockMsg", (), {"tool_calls": [], "content": ""})() - - -class MockChunk: - """Mock AIMessageChunk。""" - def __init__(self, content="", tool_call_chunks=None, tool_calls=None, additional_kwargs=None): - self.content = content - self.tool_call_chunks = tool_call_chunks or [] - self.tool_calls = tool_calls - self.additional_kwargs = additional_kwargs or {} - - def __add__(self, other): - # merge tool_calls - merged_tool_calls = (self.tool_calls or []) + (other.tool_calls or []) - return MockChunk( - content=(self.content or "") + (other.content or ""), - tool_call_chunks=self.tool_call_chunks + other.tool_call_chunks, - tool_calls=merged_tool_calls, - additional_kwargs={**self.additional_kwargs, **other.additional_kwargs}, - ) - - -async def _collect_events(async_iter): - return [e async for e in async_iter] - - -async def _never_cancel() -> bool: - """取消检查桩:会话永不取消。""" - return False - - -# ── Tests ─────────────────────────────────────────────────────────────── - -class TestToolLoopStreamChunks: - async def test_stream_chunks_yields_text_delta(self): - """LLM 返回纯文本,应产出 text_delta。""" - llm = MockChatOpenAI([MockChunk(content="hello")]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - interceptor = ProposalStreamInterceptor() - - events = await _collect_events(loop.stream_chunks([], interceptor)) - text_events = [e for e in events if e.type == "text_delta"] - assert len(text_events) == 1 - assert text_events[0].content == "hello" - - async def test_stream_chunks_respects_cancellation(self): - cancelled = [False] - - async def check_cancel(): - cancelled[0] = True - return True - - llm = MockChatOpenAI([MockChunk(content="hello")]) - loop = ToolLoopRunner(llm, [], {}, None, check_cancel, "test") - interceptor = ProposalStreamInterceptor() - - events = await _collect_events(loop.stream_chunks([], interceptor)) - assert len(events) == 0 # 第一个 chunk 前就被取消了 - - async def test_stream_chunks_yields_reasoning_delta(self): - llm = MockChatOpenAI([ - MockChunk(content="", additional_kwargs={"reasoning_content": "thinking..."}) - ]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - interceptor = ProposalStreamInterceptor() - - events = await _collect_events(loop.stream_chunks([], interceptor)) - reasoning_events = [e for e in events if e.type == "reasoning_delta"] - assert len(reasoning_events) >= 1 - - async def test_stream_chunks_does_not_emit_placeholder_reasoning(self): - llm = MockChatOpenAI([ - MockChunk( - content="", - tool_call_chunks=[{"name": "read_workspace_file", "args": ""}], - ) - ]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - interceptor = ProposalStreamInterceptor() - - events = await _collect_events(loop.stream_chunks([], interceptor)) - reasoning_events = [e for e in events if e.type == "reasoning_delta"] - assert reasoning_events == [] - assert all( - "正在分析需求" not in (e.content or "") - for e in events - ) - - -class TestToolLoopExecuteRound: - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_no_tool_calls_returns_empty(self): - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - gather = self._make_gathered(tool_calls=[]) - interceptor = ProposalStreamInterceptor() - - events = await _collect_events( - loop.execute_round(gather, "hello", [], interceptor) - ) - assert len(events) == 0 - - async def test_execute_round_runs_coroutine_only_tool(self): - """MCP 风格工具只有 coroutine:执行层必须 await,不能报未知工具。""" - import json - - from langchain_core.tools import StructuredTool - - from app.agent.tools.registry import tool_fn_map - - async def memory_create( - topic_file: str, content: str, index_line: str - ) -> str: - return json.dumps( - {"ok": True, "created": True, "topic_file": topic_file}, - ensure_ascii=False, - ) - - tool = StructuredTool( - name="memory_create", - description="create topic", - coroutine=memory_create, - args_schema={ - "type": "object", - "properties": { - "topic_file": {"type": "string"}, - "content": {"type": "string"}, - "index_line": {"type": "string"}, - }, - }, - ) - fn_map = tool_fn_map([tool]) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [tool], fn_map, None, _never_cancel, "test") - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "memory_create", - "args": { - "topic_file": "annotated-files.md", - "content": "# x", - "index_line": "- [已标文件](topics/annotated-files.md)", - }, - } - ] - ) - interceptor = ProposalStreamInterceptor() - events = await _collect_events( - loop.execute_round(gather, "", [], interceptor) - ) - results = [e for e in events if e.type == "tool_result"] - assert len(results) == 1 - assert "未知工具" not in (results[0].result or "") - payload = json.loads(results[0].result or "{}") - assert payload.get("ok") is True - assert payload.get("topic_file") == "annotated-files.md" - - async def test_execute_round_unwraps_mcp_content_and_artifact(self): - import json - - from langchain_core.tools import StructuredTool - - from app.agent.tools.registry import tool_fn_map - - async def memory_create( - topic_file: str, content: str, index_line: str - ): - payload = json.dumps( - {"ok": True, "created": True, "topic_file": topic_file}, - ensure_ascii=False, - ) - return ( - [{"type": "text", "text": payload}], - {"structured_content": None}, - ) - - tool = StructuredTool( - name="memory_create", - description="create topic", - coroutine=memory_create, - args_schema={ - "type": "object", - "properties": { - "topic_file": {"type": "string"}, - "content": {"type": "string"}, - "index_line": {"type": "string"}, - }, - }, - response_format="content_and_artifact", - ) - loop = ToolLoopRunner( - MockChatOpenAI([]), - [tool], - tool_fn_map([tool]), - None, - _never_cancel, - "test", - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "memory_create", - "args": { - "topic_file": "annotated-files.md", - "content": "# x", - "index_line": "- idx", - }, - } - ] - ) - events = await _collect_events( - loop.execute_round(gather, "", [], ProposalStreamInterceptor()) - ) - results = [e for e in events if e.type == "tool_result"] - assert len(results) == 1 - payload = json.loads(results[0].result or "{}") - assert payload.get("ok") is True - assert payload.get("topic_file") == "annotated-files.md" - - async def test_execute_round_already_completed_appends_stub_and_continues(self): - import json - - from app.agent.assist.tool_loop import TOOL_CALLS_ALREADY_COMPLETED - - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - loop.mark_completed({"a1"}) - gather = self._make_gathered( - tool_calls=[ - { - "id": "a1", - "name": "auto_annotate", - "args": {"user_request": "标注"}, - } - ], - content="现在执行自动标注。", - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - assert [e.type for e in events] == [TOOL_CALLS_ALREADY_COMPLETED] - assert not any(e.type == "tool_start" for e in events) - assert len(messages) == 2 - payload = json.loads(messages[1].content) - assert payload["status"] == "already_completed" - assert payload["tool"] == "auto_annotate" - - -class TestToolLoopPhaseGate: - """阶段门禁:await_confirm 禁止写入;verify 禁止重复标注已落盘路径。""" - - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_await_confirm_blocks_auto_annotate(self): - import json - - from app.agent.assist.task_phase import derive_task_phase - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ProposalStateInput(path="data/2.jpg", kind="annotation", status="pending")] - ) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner( - llm, [], {}, None, _never_cancel, "test", task_phase_ctx=ctx - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/3.jpg"]}, - } - ] - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - # 被门禁拦截:tool_start + tool_result(phase_blocked),不发射 tool_pending - assert [e.type for e in events] == ["tool_start", "tool_result"] - result_event = next(e for e in events if e.type == "tool_result") - payload = json.loads(result_event.result or "{}") - assert payload["status"] == "phase_blocked" - assert "Keep All" in payload["summary"] - # AIMessage + 错误 ToolMessage 都已入列,供下一轮模型调整 - assert len(messages) == 2 - - async def test_verify_blocks_reannotate_applied_path(self): - import json - - from app.agent.assist.task_phase import derive_task_phase - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ProposalStateInput(path="data/2.jpg", kind="annotation", status="applied")] - ) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner( - llm, [], {}, None, _never_cancel, "test", task_phase_ctx=ctx - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/2.jpg"]}, - } - ] - ) - events = await _collect_events( - loop.execute_round(gather, "", [], ProposalStreamInterceptor()) - ) - assert [e.type for e in events] == ["tool_start", "tool_result"] - result_event = next(e for e in events if e.type == "tool_result") - payload = json.loads(result_event.result or "{}") - assert payload["status"] == "phase_blocked" - assert "data/2.jpg" in payload["summary"] - - async def test_verify_allows_new_path_annotation(self): - from app.agent.assist.task_phase import derive_task_phase - from app.schemas.agent import ProposalStateInput - - ctx = derive_task_phase( - [ProposalStateInput(path="data/2.jpg", kind="annotation", status="applied")] - ) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner( - llm, [], {}, None, _never_cancel, "test", task_phase_ctx=ctx - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/9.jpg"]}, - } - ] - ) - events = await _collect_events( - loop.execute_round(gather, "", [], ProposalStreamInterceptor()) - ) - # 新路径放行:发射 tool_pending - assert any(e.type == "tool_pending" for e in events) - - -class TestToolLoopPseudoRetry: - """正文伪代码兜底:模型写了 auto_annotate(...) 文本但没发起真实 tool_call。""" - - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_pseudo_text_triggers_forced_tool_call(self): - from langchain_core.tools import StructuredTool - - def _auto_annotate_stub(user_request: str) -> str: - return "stub" - - tool = StructuredTool.from_function( - func=_auto_annotate_stub, - name="auto_annotate", - description="自动标注", - ) - # ainvoke(tool_choice="any" 强制重试)返回携带 tool_calls 的 chunk - forced_chunk = MockChunk( - tool_calls=[ - { - "id": "forced-1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/2.jpg"]}, - } - ] - ) - llm = MockChatOpenAI([forced_chunk]) - loop = ToolLoopRunner(llm, [tool], {}, None, _never_cancel, "补标") - # 正文写了伪代码但 gathered 无 tool_calls - gather = self._make_gathered( - tool_calls=[], content="现在调用 auto_annotate(paths=['data/2.jpg']) 补标。" - ) - messages: list = [] - events = await _collect_events( - loop.execute_round( - gather, - "现在调用 auto_annotate(paths=['data/2.jpg']) 补标。", - messages, - ProposalStreamInterceptor(), - ) - ) - # 强制重试成功:发射 tool_pending - assert any(e.type == "tool_pending" for e in events) - # 合成的 AIMessage 携带 tool_calls 入列,保证 ToolMessage 归属 - from langchain_core.messages import AIMessage - - ai_msgs = [m for m in messages if isinstance(m, AIMessage)] - assert ai_msgs and ai_msgs[0].tool_calls[0]["name"] == "auto_annotate" - - async def test_plain_text_without_tool_mention_not_forced(self): - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - gather = self._make_gathered(tool_calls=[], content="你好") - events = await _collect_events( - loop.execute_round(gather, "你好", [], ProposalStreamInterceptor()) - ) - assert events == [] - assert loop.tool_choice_retries == 0 - - -class TestToolLoopAsyncDedupe: - """同一轮内多个 auto_annotate 合并为一次批量;mutate_annotation 同范围去重。""" - - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_same_paths_auto_annotate_coalesced(self): - """同 paths 的重复 auto_annotate 先被合并(coalesced),只发一次 pending。""" - import json - - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - gather = self._make_gathered( - tool_calls=[ - { - "id": "c1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/2.jpg"]}, - }, - { - "id": "c2", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/2.jpg"]}, - }, - ] - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - pending = [e for e in events if e.type == "tool_pending"] - assert len(pending) == 1 - assert len(pending[0].client_tool_calls or []) == 1 - dup_results = [ - e for e in events if e.type == "tool_result" and e.tool_call_id == "c2" - ] - assert len(dup_results) == 1 - payload = json.loads(dup_results[0].result or "{}") - assert payload["status"] == "coalesced" - - async def test_multiple_auto_annotate_merged_into_one(self): - """5 个不同 paths 的 auto_annotate 合并为一次批量,其余回 coalesced。""" - import json - - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - gather = self._make_gathered( - tool_calls=[ - { - "id": f"c{i}", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": [f"data/{i}.jpg"]}, - } - for i in (2, 4, 5, 6, 7) - ] - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - pending = [e for e in events if e.type == "tool_pending"] - assert len(pending) == 1 - calls = pending[0].client_tool_calls or [] - assert len(calls) == 1 - merged_args = calls[0].arguments - assert merged_args["paths"] == [ - "data/2.jpg", - "data/4.jpg", - "data/5.jpg", - "data/6.jpg", - "data/7.jpg", - ] - assert merged_args["all_files"] is False - coalesced = [ - e - for e in events - if e.type == "tool_result" and "coalesced" in (e.result or "") - ] - assert len(coalesced) == 4 - - async def test_mutate_annotation_same_scope_deduped(self): - """mutate_annotation 不合并,但同范围重复仍去重。""" - import json - - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [], {}, None, _never_cancel, "test") - gather = self._make_gathered( - tool_calls=[ - { - "id": "m1", - "name": "mutate_annotation", - "args": {"user_request": "改标签", "paths": ["data/7.jpg"]}, - }, - { - "id": "m2", - "name": "mutate_annotation", - "args": {"user_request": "改标签", "paths": ["data/7.jpg"]}, - }, - ] - ) - events = await _collect_events( - loop.execute_round(gather, "", [], ProposalStreamInterceptor()) - ) - pending = [e for e in events if e.type == "tool_pending"] - assert len(pending) == 1 - assert len(pending[0].client_tool_calls or []) == 1 - dup = [ - e for e in events if e.type == "tool_result" and e.tool_call_id == "m2" - ] - assert len(dup) == 1 - payload = json.loads(dup[0].result or "{}") - assert payload["status"] == "duplicate_call" - - -class TestToolLoopSameRoundGate: - """同轮顺序不变量:标注写入与工作区写入不能同轮。""" - - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_write_and_annotate_same_round_write_blocked(self): - import json - - from langchain_core.tools import StructuredTool - - def _write_stub(relative_path: str, content: str) -> str: - return "stub" - - write_tool = StructuredTool.from_function( - func=_write_stub, - name="write_workspace_file", - description="写文件", - ) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner( - llm, [write_tool], {"write_workspace_file": _write_stub}, None, _never_cancel, "test" - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": {"relative_path": "reports/r.md", "content": "# r"}, - }, - { - "id": "a1", - "name": "auto_annotate", - "args": {"user_request": "补标", "paths": ["data/2.jpg"]}, - }, - ] - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - # 文件写入被拦,标注发出 tool_pending - blocked = [ - e - for e in events - if e.type == "tool_result" and e.tool_call_id == "w1" - ] - assert len(blocked) == 1 - payload = json.loads(blocked[0].result or "{}") - assert payload["status"] == "phase_blocked" - assert "Keep All" in payload["summary"] - assert any(e.type == "tool_pending" for e in events) - - async def test_write_alone_not_blocked(self): - from langchain_core.tools import StructuredTool - - def _write_stub(relative_path: str, content: str) -> str: - return "stub" - - write_tool = StructuredTool.from_function( - func=_write_stub, - name="write_workspace_file", - description="写文件", - ) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner( - llm, [write_tool], {"write_workspace_file": _write_stub}, None, _never_cancel, "test" - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "w1", - "name": "write_workspace_file", - "args": {"relative_path": "reports/r.md", "content": "# r"}, - } - ] - ) - events = await _collect_events( - loop.execute_round(gather, "", [], ProposalStreamInterceptor()) - ) - # 无标注写入时,文件写入正常执行(未知工具错误,而非 phase_blocked) - results = [e for e in events if e.type == "tool_result"] - assert len(results) == 1 - assert "phase_blocked" not in (results[0].result or "") - - -class TestParallelExploreReadonly: - def _make_gathered(self, tool_calls=None, content=""): - return MockChunk(content=content, tool_calls=tool_calls) - - async def test_same_round_explores_overlap_and_keep_tool_ids(self): - import asyncio - - from app.schemas.agent import StreamEventPayload - - class OverlapRunner: - def __init__(self): - self.active = 0 - self.max_active = 0 - - async def stream(self, *, query, focus_path, parent_tool_id): - self.active += 1 - self.max_active = max(self.max_active, self.active) - await asyncio.sleep(0.05) - yield StreamEventPayload( - type="subagent_start", - tool_call_id=parent_tool_id, - query=query, - focus_path=focus_path, - ) - yield StreamEventPayload( - type="subagent_done", - tool_call_id=parent_tool_id, - summary=f"ok:{query}", - status="done", - ) - self.active -= 1 - - runner = OverlapRunner() - loop = ToolLoopRunner( - MockChatOpenAI([]), - [], - {"explore_readonly": runner}, - None, - _never_cancel, - "test", - ) - gather = self._make_gathered( - tool_calls=[ - { - "id": "e1", - "name": "explore_readonly", - "args": {"query": "查 A"}, - }, - { - "id": "e2", - "name": "explore_readonly", - "args": {"query": "查 B"}, - }, - ] - ) - messages: list = [] - events = await _collect_events( - loop.execute_round(gather, "", messages, ProposalStreamInterceptor()) - ) - assert runner.max_active == 2 - starts = [e for e in events if e.type == "subagent_start"] - dones = [e for e in events if e.type == "subagent_done"] - results = [e for e in events if e.type == "tool_result"] - assert {e.tool_call_id for e in starts} == {"e1", "e2"} - assert {e.tool_call_id for e in dones} == {"e1", "e2"} - assert [e.tool_call_id for e in results] == ["e1", "e2"] - assert len(messages) == 3 - assert [m.tool_call_id for m in messages[1:]] == ["e1", "e2"] - - -class TestProposalDismissedOnToolError: - """工具报错但拦截器已为该路径出卡时,必须补发 dismissed 终态收卡。""" - - async def _run(self, streamed_call_id: str | None): - import json - - from langchain_core.tools import StructuredTool - - from app.agent.tools.registry import tool_fn_map - - async def str_replace_stub( - relative_path: str = "", - old_string: str = "", - new_string: str = "", - replace_all: bool = False, - ) -> str: - return json.dumps( - { - "ok": False, - "tool": "str_replace_workspace_file", - "status": "error", - "summary": "未找到 old_string", - }, - ensure_ascii=False, - ) - - tool = StructuredTool( - name="str_replace_workspace_file", - description="edit stub", - coroutine=str_replace_stub, - args_schema={ - "type": "object", - "properties": { - "relative_path": {"type": "string"}, - "old_string": {"type": "string"}, - "new_string": {"type": "string"}, - "replace_all": {"type": "boolean"}, - }, - }, - ) - fn_map = tool_fn_map([tool]) - llm = MockChatOpenAI([]) - loop = ToolLoopRunner(llm, [tool], fn_map, None, _never_cancel, "test") - - interceptor = ProposalStreamInterceptor() - if streamed_call_id: - # 模拟流式期间拦截器已为该调用发出 file_proposal_start - class _StartChunk: - tool_call_chunks = [ - { - "index": 0, - "name": "str_replace_workspace_file", - "args": '{"relative_path": "a.py"}', - "id": streamed_call_id, - } - ] - interceptor.on_chunk(_StartChunk()) - - gather = MockChunk( - content="", - tool_calls=[ - { - "id": "c1", - "name": "str_replace_workspace_file", - "args": { - "relative_path": "a.py", - "old_string": "x", - "new_string": "y", - }, - } - ], - ) - messages: list = [] - return await _collect_events( - loop.execute_round(gather, "", messages, interceptor) - ) - - async def test_streamed_then_error_emits_dismissed(self): - events = await self._run("c1") - finals = [e for e in events if e.type == "file_proposal"] - assert len(finals) == 1 - assert finals[0].status == "dismissed" - assert finals[0].image_path == "a.py" - - async def test_not_streamed_error_emits_no_final(self): - events = await self._run(None) - assert [e for e in events if e.type == "file_proposal"] == [] diff --git a/vendor/local-agent/tests/test_tool_registry_work_mode.py b/vendor/local-agent/tests/test_tool_registry_work_mode.py deleted file mode 100644 index eefb98b..0000000 --- a/vendor/local-agent/tests/test_tool_registry_work_mode.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for editor/annotation work mode tool filtering.""" - -from __future__ import annotations - -import uuid - -import pytest - -from app.agent.tools.registry import ANNOTATION_TOOL_NAMES, build_tools_p1 -from app.core.config import Settings -from app.models.user import User -from app.schemas.agent import ClientContextInput - - -@pytest.fixture -def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: - monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-unit-tests") - monkeypatch.setenv("POSTGRES_PASSWORD", "test") - monkeypatch.setenv( - "DATABASE_URL", - "postgresql+asyncpg://lr_agent:test@localhost:5432/lr_agent", - ) - monkeypatch.setenv("REDIS_PASSWORD", "test") - monkeypatch.setenv("REDIS_URL", "redis://:test@localhost:6379/0") - return Settings() - - -@pytest.fixture -def user() -> User: - return User( - id=uuid.uuid4(), - email="test@example.com", - email_verified=True, - username="tester", - display_name="Tester", - password_hash="x", - is_active=True, - ) - - -def test_editor_mode_excludes_annotation_tools(user: User, settings: Settings) -> None: - ctx = ClientContextInput( - workspace_root="/tmp/ws", - work_mode="editor", - ) - names = {tool.name for tool in build_tools_p1(user, ctx, settings=settings)} - assert ANNOTATION_TOOL_NAMES.isdisjoint(names) - assert "explore_readonly" in names - - -def test_annotation_mode_includes_annotation_tools( - user: User, settings: Settings -) -> None: - ctx = ClientContextInput( - workspace_root="/tmp/ws", - work_mode="annotation", - ) - names = {tool.name for tool in build_tools_p1(user, ctx, settings=settings)} - assert ANNOTATION_TOOL_NAMES.issubset(names) - assert "explore_readonly" in names diff --git a/vendor/local-agent/tests/test_vision_bootstrap.py b/vendor/local-agent/tests/test_vision_bootstrap.py deleted file mode 100644 index 7fe64b1..0000000 --- a/vendor/local-agent/tests/test_vision_bootstrap.py +++ /dev/null @@ -1,53 +0,0 @@ -"""测试 VisionAutoLoader 决策逻辑。""" - -from app.agent.assist.vision_bootstrap import VisionAutoLoader - - -def test_bootstrap_removed_only_fallback_remains(): - """pre-LLM 视觉预加载已移除:看图由模型自行调用工具,系统仅在首轮无 tool call 时兜底。""" - assert not hasattr(VisionAutoLoader, "try_bootstrap") - assert hasattr(VisionAutoLoader, "try_fallback") - - -def test_should_load_false_when_resume(): - loader = VisionAutoLoader( - vision_fn=None, - provider_is_vision=True, - settings=None, - client_context=None, - user_content="", - ) - assert loader.should_load(is_resume=True) is False - - -def test_should_load_false_when_no_vision_provider(): - loader = VisionAutoLoader( - vision_fn=None, - provider_is_vision=False, - settings=None, - client_context=None, - user_content="data/1.jpg", - ) - assert loader.should_load(is_resume=False) is False - - -def test_should_load_false_when_vision_fn_is_none(): - loader = VisionAutoLoader( - vision_fn=None, - provider_is_vision=True, - settings=None, - client_context=None, - user_content="data/1.jpg", - ) - assert loader.should_load(is_resume=False) is False - - -def test_should_load_true_with_image_path_in_user_content(): - loader = VisionAutoLoader( - vision_fn=lambda x: x, # mock - provider_is_vision=True, - settings=None, - client_context=None, - user_content="看看 data/1.jpg 这张图", - ) - assert loader.should_load(is_resume=False) is True diff --git a/vendor/local-agent/tests/test_workspace_file_tools.py b/vendor/local-agent/tests/test_workspace_file_tools.py deleted file mode 100644 index e97492d..0000000 --- a/vendor/local-agent/tests/test_workspace_file_tools.py +++ /dev/null @@ -1,621 +0,0 @@ -"""Tests for workspace path resolution and file read tools.""" - -from __future__ import annotations - -import json -import uuid -from pathlib import Path - -import pytest - -from app.agent.tools.workspace_file_reader import ( - VISION_PATH_MARKER, - extract_vision_path_from_tool_result, - read_image_for_vision_tool, - read_workspace_text_file, -) -from app.agent.tools.workspace_path import allowed_roots, resolve_workspace_file -from app.core.config import Settings -from app.models.user import User -from app.schemas.agent import ClientContextInput - - -@pytest.fixture -def registry_user() -> User: - return User( - id=uuid.uuid4(), - email="test@example.com", - email_verified=True, - username="tester", - display_name="Tester", - password_hash="x", - is_active=True, - ) - - -@pytest.fixture -def workspace(tmp_path: Path) -> Path: - root = tmp_path / "workspace" - root.mkdir() - (root / "src").mkdir() - (root / "src" / "main.py").write_text( - "def hello():\n print('hello')\n\nclass Foo:\n pass\n", - encoding="utf-8", - ) - (root / "notes.md").write_text("# Title\n", encoding="utf-8") - (root / "secret").mkdir() - (root / "secret" / "outside.txt").write_text("nope", encoding="utf-8") - return root - - -@pytest.fixture -def client_context(workspace: Path) -> ClientContextInput: - return ClientContextInput( - workspace_root=str(workspace), - active_file_path=str(workspace / "src" / "main.py"), - active_relative_path="src/main.py", - ) - - -@pytest.fixture -def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: - monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-unit-tests") - monkeypatch.setenv("POSTGRES_PASSWORD", "test") - monkeypatch.setenv( - "DATABASE_URL", - "postgresql+asyncpg://lr_agent:test@localhost:5432/lr_agent", - ) - monkeypatch.setenv("REDIS_PASSWORD", "test") - monkeypatch.setenv("REDIS_URL", "redis://:test@localhost:6379/0") - return Settings() - - -def test_allowed_roots_deduplicates(workspace: Path) -> None: - ctx = ClientContextInput( - workspace_root=str(workspace), - project_directory_path=str(workspace), - ) - roots = allowed_roots(ctx) - assert len(roots) == 1 - - -def test_resolve_relative_path(client_context: ClientContextInput) -> None: - resolved, err = resolve_workspace_file(client_context, "src/main.py") - assert err == "" - assert resolved is not None - assert resolved.name == "main.py" - - -def test_resolve_active_file_when_path_empty(client_context: ClientContextInput) -> None: - resolved, err = resolve_workspace_file(client_context, "") - assert err == "" - assert resolved is not None - assert resolved.name == "main.py" - - -def test_reject_path_traversal(client_context: ClientContextInput) -> None: - resolved, err = resolve_workspace_file(client_context, "../secret/outside.txt") - assert resolved is None - assert ".." in err or "未找到" in err - - -def test_read_workspace_text(client_context: ClientContextInput, settings: Settings) -> None: - content = read_workspace_text_file(client_context, "notes.md", settings=settings) - assert "Title" in content - assert content.startswith("文件:") - assert " 1|# Title" in content - - -def test_read_workspace_line_range(client_context: ClientContextInput, settings: Settings) -> None: - content = read_workspace_text_file( - client_context, - "src/main.py", - settings=settings, - start_line=2, - end_line=3, - ) - assert "行范围:L2-L3" in content - assert "print('hello')" in content - assert " 2|" in content - assert "class Foo" not in content - - -def test_read_workspace_invalid_line_range( - client_context: ClientContextInput, - settings: Settings, -) -> None: - content = read_workspace_text_file( - client_context, - "src/main.py", - settings=settings, - start_line=10, - end_line=2, - ) - assert "无效行范围" in content - - -def _write_numbered_file(path: Path, count: int) -> None: - path.write_text( - "".join(f"line-{i}\n" for i in range(1, count + 1)), - encoding="utf-8", - ) - - -def test_read_line_range_beyond_max_lines( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - """行范围超出 max_lines 预截断时仍能读到(流式窗口)。""" - _write_numbered_file(workspace / "big.py", 300) - small = settings.model_copy(update={"agent_read_file_max_lines": 50}) - content = read_workspace_text_file( - client_context, - "big.py", - settings=small, - start_line=120, - end_line=125, - ) - assert "行范围:L120-L125" in content - assert "line-123" in content - assert " 120|line-120" in content - - -def test_read_line_range_beyond_max_bytes( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - """行范围超出 max_bytes 预截断时仍能读到。""" - _write_numbered_file(workspace / "long.py", 300) - small = settings.model_copy(update={"agent_read_file_max_bytes": 128}) - content = read_workspace_text_file( - client_context, - "long.py", - settings=small, - start_line=250, - end_line=255, - ) - assert "line-253" in content - assert " 250|line-250" in content - - -def test_read_line_range_window_capped_by_max_lines( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - """窗口超过 max_lines 时截断并提示可继续分段。""" - _write_numbered_file(workspace / "wide.py", 300) - small = settings.model_copy(update={"agent_read_file_max_lines": 10}) - content = read_workspace_text_file( - client_context, - "wide.py", - settings=small, - start_line=5, - end_line=100, - ) - assert "行窗口已截断" in content - assert " 5|line-5" in content - assert " 14|line-14" in content - assert "line-15" not in content - - -def test_read_line_range_start_beyond_eof( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - _write_numbered_file(workspace / "short.py", 5) - content = read_workspace_text_file( - client_context, - "short.py", - settings=settings, - start_line=100, - ) - assert "起始行超出文件末尾" in content - assert "共 5 行" in content - - -def test_read_truncated_hint_points_to_line_range( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - """无行范围且被截断时,提示可用 start_line/end_line 分段读取。""" - _write_numbered_file(workspace / "huge.py", 300) - small = settings.model_copy(update={"agent_read_file_max_bytes": 128}) - content = read_workspace_text_file(client_context, "huge.py", settings=small) - assert "内容已截断" in content - assert "start_line" in content - - -def test_read_image_for_vision_returns_marker( - workspace: Path, -) -> None: - from PIL import Image - - img_path = workspace / "photo.jpg" - Image.new("RGB", (8, 8), color=(255, 0, 0)).save(img_path, format="JPEG") - ctx = ClientContextInput(workspace_root=str(workspace)) - raw = read_image_for_vision_tool(ctx, "photo.jpg", provider_is_vision=True) - data = json.loads(raw) - assert data["ok"] is True - assert VISION_PATH_MARKER in data - path = extract_vision_path_from_tool_result("read_image_for_vision", raw) - assert path == str(img_path.resolve()) - - -def test_read_image_requires_vision_model(client_context: ClientContextInput) -> None: - result = read_image_for_vision_tool( - client_context, - "src/main.py", - provider_is_vision=False, - ) - assert "视觉" in result - - -def test_read_document_docx( - workspace: Path, - settings: Settings, -) -> None: - pytest.importorskip("docx") - from docx import Document - - doc_path = workspace / "readme.docx" - doc = Document() - doc.add_paragraph("Hello DOCX") - doc.save(doc_path) - - from app.agent.tools.workspace_file_reader import read_document_file - - ctx = ClientContextInput(workspace_root=str(workspace)) - text = read_document_file(ctx, "readme.docx", settings=settings) - assert "Hello DOCX" in text - - -def test_write_path_rejects_lr_agent_dir(client_context: ClientContextInput) -> None: - from app.agent.tools.workspace_path import resolve_workspace_write_path - - resolved, err = resolve_workspace_write_path( - client_context, - ".lr-agent/annotations/files/abc.json", - ) - assert resolved is None - assert "auto_annotate" in err - assert "mutate_annotation" in err - - -def test_write_path_allows_normal_file(client_context: ClientContextInput) -> None: - from app.agent.tools.workspace_path import resolve_workspace_write_path - - resolved, err = resolve_workspace_write_path(client_context, "reports/summary.md") - assert err == "" - assert resolved is not None - assert resolved.name == "summary.md" - - -def test_str_replace_unique_match(client_context: ClientContextInput) -> None: - from app.agent.tools.workspace_file_reader import str_replace_workspace_file_tool - import json - - raw = str_replace_workspace_file_tool( - client_context, - "src/main.py", - "print('hello')", - "print('hi')", - ) - data = json.loads(raw) - assert data["ok"] is True - assert "print('hi')" in data["content"] - assert data["operation"] == "write" - - -def test_str_replace_requires_unique_match(client_context: ClientContextInput, workspace) -> None: - from app.agent.tools.workspace_file_reader import str_replace_workspace_file_tool - import json - - (workspace / "dup.txt").write_text("foo\nfoo\n", encoding="utf-8") - raw = str_replace_workspace_file_tool(client_context, "dup.txt", "foo", "bar") - data = json.loads(raw) - assert data["ok"] is False - assert "出现" in data["summary"] - - -def test_delete_workspace_file_proposal(client_context: ClientContextInput) -> None: - from app.agent.tools.workspace_file_reader import delete_workspace_file_tool - import json - - raw = delete_workspace_file_tool(client_context, "notes.md") - data = json.loads(raw) - assert data["ok"] is True - assert data["operation"] == "delete" - assert data["relative_path"].endswith("notes.md") - - -# ── 同轮提案内容接力(str_replace 基线跟随未落盘提案)───────────────────── - -def _pending() -> dict[str, tuple[str | None, str]]: - return {} - - -def test_str_replace_relays_pending_proposal_within_turn( - client_context: ClientContextInput, - workspace: Path, -) -> None: - """同一轮两次 str_replace:第二份提案应累积第一处修改,磁盘保持不变。""" - from app.agent.tools.workspace_file_reader import str_replace_workspace_file_tool - - pending = _pending() - first = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "print('hello')", - "print('hi')", - pending_proposals=pending, - ) - ) - assert first["ok"] is True - - second = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "class Foo:", - "class Bar:", - pending_proposals=pending, - ) - ) - assert second["ok"] is True - assert "print('hi')" in second["content"] - assert "class Bar:" in second["content"] - assert "def hello():" in second["content"] - - # 两份提案均不落盘 - disk = (workspace / "src" / "main.py").read_text(encoding="utf-8") - assert "print('hello')" in disk - assert "class Foo:" in disk - - -def test_str_replace_discards_stale_pending_on_disk_change( - client_context: ClientContextInput, - workspace: Path, -) -> None: - """提案生成后磁盘又被改动(如用户手动保存):退回磁盘原文,旧提案内容丢弃。""" - from app.agent.tools.workspace_file_reader import str_replace_workspace_file_tool - - pending = _pending() - first = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "print('hello')", - "print('hi')", - pending_proposals=pending, - ) - ) - assert first["ok"] is True - - target = workspace / "src" / "main.py" - target.write_text( - "def hello():\n print('hello')\n\nclass Foo:\n pass\nuser_edit = 1\n", - encoding="utf-8", - ) - - second = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "class Foo:", - "class Bar:", - pending_proposals=pending, - ) - ) - assert second["ok"] is True - assert "user_edit = 1" in second["content"] - assert "print('hi')" not in second["content"] - - -def test_str_replace_baseline_follows_write_proposal( - client_context: ClientContextInput, - workspace: Path, -) -> None: - """write 提案之后同一轮内 str_replace:基线应为 write 提案内容而非磁盘。""" - from app.agent.tools.workspace_file_reader import ( - str_replace_workspace_file_tool, - write_workspace_file_tool, - ) - - pending = _pending() - wrote = json.loads( - write_workspace_file_tool( - client_context, - "reports/new.py", - "alpha\nbeta\n", - pending_proposals=pending, - ) - ) - assert wrote["ok"] is True - - patched = json.loads( - str_replace_workspace_file_tool( - client_context, - "reports/new.py", - "beta", - "BETA", - pending_proposals=pending, - ) - ) - assert patched["ok"] is True - assert patched["content"] == "alpha\nBETA\n" - assert not (workspace / "reports" / "new.py").exists() - - -def test_delete_invalidates_pending_proposal( - client_context: ClientContextInput, - workspace: Path, -) -> None: - """删除提案后,同路径的未落盘修改提案内容不再作为基线。""" - from app.agent.tools.workspace_file_reader import ( - delete_workspace_file_tool, - str_replace_workspace_file_tool, - ) - - pending = _pending() - first = json.loads( - str_replace_workspace_file_tool( - client_context, - "notes.md", - "# Title", - "# Renamed", - pending_proposals=pending, - ) - ) - assert first["ok"] is True - - deleted = json.loads( - delete_workspace_file_tool( - client_context, - "notes.md", - pending_proposals=pending, - ) - ) - assert deleted["ok"] is True - resolved, _ = resolve_workspace_file(client_context, "notes.md") - assert resolved is not None - assert str(resolved) not in pending - - -def test_str_replace_not_found_hints_pending_baseline( - client_context: ClientContextInput, -) -> None: - """基线来自未落盘提案且未命中时,错误信息提示基线差异。""" - from app.agent.tools.workspace_file_reader import str_replace_workspace_file_tool - - pending = _pending() - first = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "print('hello')", - "print('hi')", - pending_proposals=pending, - ) - ) - assert first["ok"] is True - - miss = json.loads( - str_replace_workspace_file_tool( - client_context, - "src/main.py", - "print('hello')", - "print('again')", - pending_proposals=pending, - ) - ) - assert miss["ok"] is False - assert "提案" in miss["summary"] - - -def test_registry_tools_share_pending_relay( - workspace: Path, - registry_user: object, - settings: Settings, -) -> None: - """registry 构建的工具闭包共享同一份 per-turn 接力缓存。""" - from app.agent.tools.registry import build_tools_by_name_set - - ctx = ClientContextInput(workspace_root=str(workspace)) - tools = build_tools_by_name_set( - registry_user, - ctx, - frozenset({"str_replace_workspace_file"}), - settings=settings, - ) - assert len(tools) == 1 - fn = tools[0].func - - first = json.loads( - fn("src/main.py", "print('hello')", "print('hi')") - ) - assert first["ok"] is True - second = json.loads( - fn("src/main.py", "class Foo:", "class Bar:") - ) - assert second["ok"] is True - assert "print('hi')" in second["content"] - assert "class Bar:" in second["content"] - - -# ── move_workspace_file(移动/重命名提案)──────────────────────────────── - -def _move_tool(): - from app.agent.tools.workspace_file_reader import move_workspace_file_tool - - return move_workspace_file_tool - - -def test_move_file_proposal(client_context: ClientContextInput) -> None: - move = _move_tool() - data = json.loads(move(client_context, "notes.md", "docs/notes.md")) - assert data["ok"] is True - assert data["operation"] == "rename" - assert data["old_path"].endswith("notes.md") - assert data["relative_path"].endswith("docs/notes.md") - assert data["content"] == "" - - -def test_move_file_source_must_exist(client_context: ClientContextInput) -> None: - move = _move_tool() - data = json.loads(move(client_context, "missing.md", "docs/missing.md")) - assert data["ok"] is False - - -def test_move_file_rejects_existing_target( - client_context: ClientContextInput, workspace: Path -) -> None: - (workspace / "docs").mkdir() - (workspace / "docs" / "notes.md").write_text("taken", encoding="utf-8") - move = _move_tool() - data = json.loads(move(client_context, "notes.md", "docs/notes.md")) - assert data["ok"] is False - assert "已存在" in data["summary"] - - -def test_move_file_same_path_rejected(client_context: ClientContextInput) -> None: - move = _move_tool() - data = json.loads(move(client_context, "notes.md", "notes.md")) - assert data["ok"] is False - - -def test_move_file_rejects_lr_agent_target( - client_context: ClientContextInput, -) -> None: - move = _move_tool() - data = json.loads( - move(client_context, "notes.md", ".lr-agent/memory/notes.md") - ) - assert data["ok"] is False - - -def test_move_file_invalidates_pending_proposals( - client_context: ClientContextInput, -) -> None: - from app.agent.tools.workspace_file_reader import write_workspace_file_tool - - move = _move_tool() - pending: dict[str, tuple[str | None, str]] = {} - write = json.loads( - write_workspace_file_tool( - client_context, "notes.md", "new content", pending_proposals=pending - ) - ) - assert write["ok"] is True - assert pending - moved = json.loads( - move(client_context, "notes.md", "docs/notes.md", pending_proposals=pending) - ) - assert moved["ok"] is True - assert pending == {} - diff --git a/vendor/local-agent/tests/test_workspace_memory.py b/vendor/local-agent/tests/test_workspace_memory.py deleted file mode 100644 index bf55a35..0000000 --- a/vendor/local-agent/tests/test_workspace_memory.py +++ /dev/null @@ -1,57 +0,0 @@ -"""工作区记忆注入:开关控制 prompt,不再走偏好记忆语义。""" - -from app.agent.context_snapshot import build_assist_system_prompt -from app.schemas.agent import ClientContextInput - - -def _build_prompt(client_context: ClientContextInput | None) -> str: - return build_assist_system_prompt( - client_context, - model="test-model", - provider_label="测试", - supports_vision=False, - ) - - -def test_enabled_with_empty_index_still_injects_block() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - workspace_memory_enabled=True, - memory_index=None, - ) - prompt = _build_prompt(ctx) - - assert "【工作区记忆】" in prompt - assert "尚无工作区记忆文件" in prompt - assert "memory_create" in prompt - assert "必须更新" not in prompt - assert "已保存的记忆" not in prompt - assert "长期价值" not in prompt - - -def test_enabled_with_index_injects_index() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - workspace_memory_enabled=True, - memory_index="- [进度](topics/progress.md):已标 3/10", - ) - prompt = _build_prompt(ctx) - - assert "【工作区记忆】" in prompt - assert "topics/progress.md" in prompt - assert "memory_write" in prompt - assert "memory_read" in prompt - assert "必须更新" not in prompt - assert "用户规范" in prompt - - -def test_disabled_skips_memory_block_even_if_index_present() -> None: - ctx = ClientContextInput( - workspace_root="/ws", - workspace_memory_enabled=False, - memory_index="- leftover", - ) - prompt = _build_prompt(ctx) - - assert "【工作区记忆】" not in prompt - assert "leftover" not in prompt diff --git a/vendor/local-agent/tests/test_workspace_search.py b/vendor/local-agent/tests/test_workspace_search.py deleted file mode 100644 index 8a88af1..0000000 --- a/vendor/local-agent/tests/test_workspace_search.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Tests for workspace grep and list directory tools.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from app.agent.tools.workspace_path import resolve_workspace_directory -from app.agent.tools.workspace_search import grep_workspace, list_workspace_directory -from app.core.config import Settings -from app.schemas.agent import ClientContextInput - - -@pytest.fixture -def workspace(tmp_path: Path) -> Path: - root = tmp_path / "workspace" - root.mkdir() - (root / "src").mkdir() - (root / "src" / "main.py").write_text( - "def hello():\n print('hello')\n\nclass Foo:\n pass\n", - encoding="utf-8", - ) - (root / "src" / "utils.ts").write_text( - "export const AgentMode = 'ask';\n", - encoding="utf-8", - ) - (root / "notes.md").write_text("# Title\n", encoding="utf-8") - (root / ".git").mkdir() - (root / ".git" / "config").write_text("[core]\n", encoding="utf-8") - (root / "secret").mkdir() - (root / "secret" / "outside.txt").write_text("hidden", encoding="utf-8") - return root - - -@pytest.fixture -def client_context(workspace: Path) -> ClientContextInput: - return ClientContextInput( - workspace_root=str(workspace), - active_file_path=str(workspace / "src" / "main.py"), - active_relative_path="src/main.py", - ) - - -@pytest.fixture -def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: - monkeypatch.setenv("SECRET_KEY", "test-secret-key-for-unit-tests") - monkeypatch.setenv("POSTGRES_PASSWORD", "test") - monkeypatch.setenv( - "DATABASE_URL", - "postgresql+asyncpg://lr_agent:test@localhost:5432/lr_agent", - ) - monkeypatch.setenv("REDIS_PASSWORD", "test") - monkeypatch.setenv("REDIS_URL", "redis://:test@localhost:6379/0") - return Settings() - - -def test_grep_finds_matches(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace(client_context, "class Foo", settings=settings) - assert "src/main.py" in result - assert "class Foo" in result - - -def test_grep_no_match(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace(client_context, "NonExistentSymbolXYZ", settings=settings) - assert "未找到匹配" in result - - -def test_grep_glob_filter(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace( - client_context, - "AgentMode", - glob_pattern="*.ts", - settings=settings, - ) - assert "utils.ts" in result - assert "main.py" not in result - - -def test_grep_scoped_to_directory(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace(client_context, "hello", path="src", settings=settings) - assert "src/main.py" in result - assert "notes.md" not in result - - -def test_grep_max_results(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace( - client_context, - ".", - max_results=1, - settings=settings, - ) - assert "结果已截断" in result - - -def test_grep_rejects_traversal(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace( - client_context, - "hidden", - path="../secret", - settings=settings, - ) - assert ".." in result or "未找到" in result or "路径" in result - - -def test_grep_skips_git_dir(client_context: ClientContextInput, settings: Settings) -> None: - result = grep_workspace(client_context, "\\[core\\]", settings=settings) - assert ".git/config" not in result - - -def test_grep_includes_useful_dot_dirs( - client_context: ClientContextInput, - workspace: Path, - settings: Settings, -) -> None: - """.github 等有用点目录参与搜索(只有 .git / node_modules 等显式名单被跳过)。""" - workflows = workspace / ".github" / "workflows" - workflows.mkdir(parents=True) - (workflows / "ci.yml").write_text("name: ci-pipeline\n", encoding="utf-8") - result = grep_workspace(client_context, "ci-pipeline", settings=settings) - assert ".github/workflows/ci.yml" in result - - -def test_list_directory_root(client_context: ClientContextInput, settings: Settings) -> None: - result = list_workspace_directory(client_context, "", settings=settings) - assert "src" in result - assert "directory" in result - assert "notes.md" in result - assert ".git" not in result - - -def test_list_directory_subdir(client_context: ClientContextInput, settings: Settings) -> None: - result = list_workspace_directory(client_context, "src", settings=settings) - assert "main.py" in result - assert "utils.ts" in result - - -def test_list_directory_rejects_traversal(client_context: ClientContextInput, settings: Settings) -> None: - result = list_workspace_directory(client_context, "../secret", settings=settings) - assert ".." in result or "未找到" in result - - -def test_glob_workspace_py_files(client_context: ClientContextInput, settings: Settings) -> None: - from app.agent.tools.workspace_search import glob_workspace - - result = glob_workspace(client_context, "**/*.py", settings=settings) - assert "src/main.py" in result - assert "utils.ts" not in result - - -def test_glob_requires_pattern(client_context: ClientContextInput, settings: Settings) -> None: - from app.agent.tools.workspace_search import glob_workspace - - result = glob_workspace(client_context, "", settings=settings) - assert "glob_pattern" in result - - -def test_resolve_workspace_directory_empty_path( - client_context: ClientContextInput, - workspace: Path, -) -> None: - resolved, err = resolve_workspace_directory(client_context, "") - assert err == "" - assert resolved is not None - assert resolved.resolve() == workspace.resolve() diff --git a/vendor/local-agent/tests/test_workspace_text_extensions.py b/vendor/local-agent/tests/test_workspace_text_extensions.py deleted file mode 100644 index 00128ac..0000000 --- a/vendor/local-agent/tests/test_workspace_text_extensions.py +++ /dev/null @@ -1,43 +0,0 @@ -from app.agent.tools.workspace_text_extensions import ( - is_allowed_text_extension, - is_blocked_text_extension, -) - - -def test_jsonl_allowed(): - assert not is_blocked_text_extension(".jsonl") - assert is_allowed_text_extension(".jsonl") - - -def test_png_blocked(): - assert is_blocked_text_extension(".png") - assert not is_allowed_text_extension(".png") - - -def test_extensionless_allowed(): - assert not is_blocked_text_extension("") - assert is_allowed_text_extension("") - - -def test_script_and_executable_suffixes_blocked_for_write(): - for ext in ( - ".bat", - ".cmd", - ".ps1", - ".psm1", - ".vbs", - ".hta", - ".scr", - ".com", - ".jar", - ".reg", - ".lnk", - ".xyz", - ): - assert is_blocked_text_extension(ext), ext - assert not is_allowed_text_extension(ext), ext - - -def test_common_code_suffixes_allowed_for_write(): - for ext in (".py", ".ts", ".tsx", ".json", ".yaml", ".sh"): - assert is_allowed_text_extension(ext), ext From 36a7cf08c8bb331fe365651a009bdb4399153ad2 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 03:10:05 +0800 Subject: [PATCH 11/20] =?UTF-8?q?chore(agent):=20=E6=9B=B4=E6=96=B0=20fork?= =?UTF-8?q?=20=E5=86=92=E7=83=9F=E6=96=AD=E8=A8=80=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=85=9C=E5=BA=95=E5=AD=97=E6=AE=B5=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electronForkSmoke 的 /chat/cancel 断言从「骨架 501」更新为「已实现 200 + {ok:true}」。三处 SSE 字段兜底补充注释,说明主字段为 camelCase、snake_case 分支仅用于历史数据渲染(运行时不会产出),保留而不删除。全部测试 935/935。 Co-authored-by: Cursor --- docs/agent-exec-e2e-tests.md | 166 ------------------- scripts/agent-baseline/electronForkSmoke.cjs | 11 +- src/renderer/context/AgentChatContext.tsx | 1 + src/renderer/services/agentChatStore.ts | 9 +- src/renderer/services/agentJobRegistry.ts | 1 + 5 files changed, 18 insertions(+), 170 deletions(-) delete mode 100644 docs/agent-exec-e2e-tests.md diff --git a/docs/agent-exec-e2e-tests.md b/docs/agent-exec-e2e-tests.md deleted file mode 100644 index 5e52779..0000000 --- a/docs/agent-exec-e2e-tests.md +++ /dev/null @@ -1,166 +0,0 @@ -# Agent 执行能力端到端测试指南(Skill 脚本 + 终端命令) - -本文档给出在真实应用中验证 Agent 执行能力的可复制测试用例。以 `docx` skill -(`~/.agents/skills/docx`)为载体,覆盖三条链路:**Skill 脚本执行**(SYNC)、 -**受限终端命令**(ASYNC + 聊天内批准)、以及两者的组合。 - -自动化回归(不依赖 LLM)见 `src/main/skills/docxWorkflow.test.ts` -(夹具 skill:`src/main/skills/__fixtures__/docx-skill/`)。 - -## 前置条件 - -- 应用已启动,Agent 面板绑定了一个工作区(任意含测试文件的目录)。 -- `~/.agents/skills/docx` 存在(本机已装)。 -- 本机现状:应用 venv 缺 `defusedxml`/`python-docx`;PATH 上有 node/npm/pandoc。 - -## 场景 1:终端命令 + 批准条(依赖准备) - -**提示词:** - -``` -帮我给应用自带的 Python 运行时安装 docx 处理依赖,执行: -"C:\Users\user\AppData\Roaming\lr-agent\runtimes\local-agent-venv\Scripts\python.exe" -m pip install defusedxml python-docx -装完告诉我结果。 -``` - -**期望行为:** - -1. 模型调用 `start_terminal_command`,命令卡片出现在聊天中,状态「等待批准」, - 参数区显示完整命令; -2. 用户点「批准执行」→ 卡片进入「进行中」,pip 下载进度**流式滚动**显示在输出区; -3. 进程退出后卡片状态「已完成(exit 0)」; -4. 模型 resume 后汇报安装结果。 - -**观察点:** - -- 点「拒绝」路径:另发一条命令,点拒绝 → 模型应收到「用户拒绝执行该命令」并 - 说明未执行,不会出现执行副作用。 -- 危险命令路径:让模型跑 `rm -rf x` 或含 `|` 的命令 → 应直接被门禁拒绝 - (denied_command / shell_syntax_unsupported),连批准卡片都不出现。 - -**通过标准:** venv 里 `import docx`、`import defusedxml` 成功;批准前无任何 -进程产生。 - -## 场景 2:创建 docx(Skill + 写文件 + 终端组合) - -**提示词:** - -``` -用 docx skill 在工作区生成一份《LR-Agent 执行能力验证报告》.docx, -要求:一个一级标题、一段引言、一个两行两列的表格。生成后打开确认能读出内容。 -``` - -**期望工具序列:** - -1. `read_agent_skill(skill_name="docx")` 读 SKILL.md; -2. 创建路径走 docx-js:`write_workspace_file` 写生成脚本(如 `make_report.js`); -3. `start_terminal_command`(**批准卡片**)执行 `npm install docx`(workspace 内 - 本地安装即可,不需要 -g); -4. `start_terminal_command`(**批准卡片**)执行 `node make_report.js`; -5. 读取校验:`start_terminal_command` 执行 `pandoc report.docx -t markdown`。 - -**通过标准:** 工作区出现 .docx;pandoc 能读出标题、引言与表格内容; -npm install / node 的实时输出都曾在卡片上流式显示。 - -## 场景 3:修改 docx(Skill 脚本执行为主) - -**提示词:** - -``` -读取工作区里的《LR-Agent 执行能力验证报告》.docx,在文末追加一段 -"修改记录:2026-09-13 由 Agent 追加",保存为 report-v2.docx。 -``` - -**期望工具序列:** - -1. `read_agent_skill` + `list_agent_skill_files` 确认编辑路径; -2. `run_agent_skill_script(skill_name="docx", script="scripts/office/unpack.py", args=[...])` - 解包 docx —— 卡片标签显示「运行脚本 docx/scripts/office/unpack.py」; -3. 文件工具(read/str_replace/write)修改 `word/document.xml`; -4. `run_agent_skill_script` 执行 `scripts/office/pack.py` 重新打包; -5. `run_agent_skill_script` 执行 `scripts/office/validate.py` 校验。 - -**通过标准:** 全程**不出现**终端批准卡片(脚本执行走 SYNC 白名单,无需确认); -report-v2.docx 存在且 pandoc 能读出追加段落。 - -**注意:** unpack/pack 只依赖标准库 + defusedxml(场景 1 已装);涉及 -LibreOffice 的脚本(soffice.py 转 PDF 等)本机没有 soffice,会失败——这属于 -环境缺依赖,模型应报告错误而不是静默跳过。 - -## 场景 4:长任务与中止 - -**提示词:** - -``` -在工作区执行一个耗时任务:node -e "console.log('start'); setInterval(()=>{},1000)" -跑起来之后把它停掉。 -``` - -**期望行为:** - -1. 批准后命令持续运行(无退出),卡片保持「进行中」; -2. 模型调用 `kill_terminal_job(job_id=...)` 终止 → 输出区出现终态 - 「已被终止(exit N/A)」; -3. 或者等 300s 默认超时 → 状态「超时被强制终止」。 - -**通过标准:** 任务结束后进程树上没有残留 node 进程(任务管理器确认)。 - -## 通用检查清单 - -- [ ] 所有终端命令执行前都出现过批准卡片,拒绝时无副作用 -- [ ] `run_agent_skill_script` 只能执行 scripts/ 下的脚本(模型尝试 SKILL.md - 本身或 references/ 下的文件应得到 invalid_path) -- [ ] 输出超长时卡片与工具结果都有截断标记,不撑爆上下文 -- [ ] 拒绝/门禁拒绝时模型收到结构化错误并如实向用户转述 - ---- - -## 附:同步工具不再阻塞事件循环(性能基线) - -同步工具(grep / 读文件 / 图片编码等)统一经 `asyncio.to_thread` 在线程池执行, -目的是**不让一次大范围扫描卡住整个 FastAPI 进程**(同进程内其它会话的 SSE 流、 -`/health` 健康检查、其它工具的调度都会受连带影响)。顺带让只读白名单里的多个 -工具真正重叠执行(`_invoke_tool_fn`、`_invoke_inner` 两处同源逻辑都已改造)。 - -### 回归测试 - -`vendor/local-agent/tests/test_tool_blocking.py` 用「事件循环心跳计数器」量化: -起一个每 10ms 自增的 ticker 协程,再调用工具;若工具阻塞事件循环,ticker -在工具执行期间拿不到执行机会。 - -```bash -cd vendor/local-agent -python -m pytest tests/test_tool_blocking.py -v -``` - -### 一次性测量脚本 - -```bash -# 从仓库根执行;对比「直接调用」与「走线程池」两种模式 -python vendor/local-agent/scripts/measure_grep_blocking.py <工作区根目录> "def\s+\w+" -``` - -### 实测数据(本机,中等规模仓库 ~50ms 级 grep) - -| 场景 | 调用耗时 | 事件循环心跳 | 事件循环最大阻塞 | -| --- | --- | --- | --- | -| 改造前:同步工具直接调用 | 62.5 ms | **0 次** | 全程阻塞(采样器一次没跑到) | -| 改造后:经 `_invoke_tool_fn` 走线程池 | 59.8 ms | **3 次** | 11.9 ms | -| 桩工具 `time.sleep(0.3)`(改造前) | 300 ms | **0 次** | 全程阻塞 | -| 桩工具 `time.sleep(0.3)`(改造后) | 300 ms | **19 次** | 循环保持响应 | - -**读法**:改造前「心跳 0 次」是关键信号 —— 采样器在整个工具执行期间一次都没 -被调度到,说明事件循环被完全占住。改造后循环能正常心跳。 - -### 收益边界(避免误读) - -- **主要收益是「不阻塞」而不是「更快」**。单轮对话的墙钟时间由 LLM 流式输出 - 主导(秒级),一次 grep 是几十毫秒,端到端耗时的差异基本测不出来。 -- **GIL 决定并发上限**:文件读取与目录遍历是真并行(系统调用释放 GIL); - 而 `grep` 的逐行 `regex.search()` 不释放 GIL,所以上表里改造后仍有约 12ms - 的残余延迟 —— 这是「半并行」的真实体现,不是实现缺陷。 -- 因此**不引入 `ProcessPoolExecutor`**:跨进程传路径配置、启动开销、结果回传的 - 成本,对这个场景不划算。 -- **视觉工具 `read_image_for_vision` 仍不进并行白名单**:它要往消息流里追加一条 - 多模态消息(附图),并行会让附图与 `tool_calls` 顺序错位;它还有 - `vision_bootstrapped` 循环状态副作用。改造只让它「不卡」,不改变「不并行」。 diff --git a/scripts/agent-baseline/electronForkSmoke.cjs b/scripts/agent-baseline/electronForkSmoke.cjs index 2884eb2..a4c16ed 100644 --- a/scripts/agent-baseline/electronForkSmoke.cjs +++ b/scripts/agent-baseline/electronForkSmoke.cjs @@ -175,7 +175,7 @@ app.whenReady().then(async () => { } try { - // /chat/cancel 是 POST 路由;用 POST 才能到达骨架并返回 501 + // /chat/cancel 现已实现:应返回 200 {ok:true} const withAuth = await get( port, '/api/v1/agent/chat/cancel', @@ -183,10 +183,15 @@ app.whenReady().then(async () => { 'POST', ); check( - withAuth.status === 501, - '带 token 通过鉴权并到达路由骨架 (501)', + withAuth.status === 200, + '带 token 通过鉴权并到达已实现路由 (200)', `got ${withAuth.status}`, ); + check( + JSON.parse(withAuth.body || '{}').ok === true, + '/chat/cancel 返回 {ok:true}', + withAuth.body, + ); } catch (err) { check(false, '带 token 访问业务路由', err.message); } diff --git a/src/renderer/context/AgentChatContext.tsx b/src/renderer/context/AgentChatContext.tsx index c240cca..fb330df 100644 --- a/src/renderer/context/AgentChatContext.tsx +++ b/src/renderer/context/AgentChatContext.tsx @@ -1129,6 +1129,7 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { [sessionId]: { ...session, contextSummary: event.summary, + // 主字段为 camelCase;snake_case 为历史数据兜底 summaryUpToMessageId: ((event as Record).summaryUpToMessageId as string | undefined) ?? diff --git a/src/renderer/services/agentChatStore.ts b/src/renderer/services/agentChatStore.ts index 324e732..570a48f 100644 --- a/src/renderer/services/agentChatStore.ts +++ b/src/renderer/services/agentChatStore.ts @@ -486,7 +486,13 @@ export function mergeMessagesFromRemote( return merged; } -/** 兼容 model_dump 与 to_sse_dict 两种 SSE 字段名。 */ +/** + * 取提案事件的显示路径。 + * + * 主字段是 `suggestedRelativePath`(`to_sse_dict` 的 camelCase 契约,也就是运行时 + * 实际发送的字段)。其余分支是历史 `model_dump` snake_case 形态的**防御性兜底**: + * 运行时不会产出它们,保留是为了让旧的历史 SSE 数据仍能正确渲染。 + */ export function resolveFileProposalPath( event: Record, ): string { @@ -620,6 +626,7 @@ function resolveFileProposalOldPath( const EXPLORE_READONLY_TOOL = 'explore_readonly'; function eventToolCallId(event: StreamEvent | Record): string { + // 主字段为 camelCase;snake_case 为历史数据的防御性兜底(运行时不会产出) const rec = event as Record; return ( (rec.toolCallId as string | undefined) ?? diff --git a/src/renderer/services/agentJobRegistry.ts b/src/renderer/services/agentJobRegistry.ts index 39f7f0b..078daaa 100644 --- a/src/renderer/services/agentJobRegistry.ts +++ b/src/renderer/services/agentJobRegistry.ts @@ -205,6 +205,7 @@ function pendingToolCallsFromEvent( event: StreamEvent, ): ClientToolCall[] | null { if (event.type === 'tool_pending') { + // 运行时同时发送 clientToolCalls 与 toolCalls;snake_case 为历史数据兜底 const e = event as Record; return (e.toolCalls ?? e.client_tool_calls) as ClientToolCall[] | null; } From 4c117ae27ffaf215e2216bbecc74cd3c559bdee4 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 11:34:44 +0800 Subject: [PATCH 12/20] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=95=85=E9=9A=9C=E9=9D=99=E9=BB=98=E3=80=81=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E9=94=99=E8=AF=AF=E7=A0=81=E3=80=81=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=B4=A9=E6=BA=83=E5=A4=B1=E8=81=94=E4=B8=8E=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=96=87=E6=A1=A3=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四类缺陷: 1) 流式故障静默(src/main/agent/loop/assistLoop.ts、sse.ts):主循环的 astream 缺 try/catch,LLM 失败时异常被 SSE 层吞掉、客户端读到 EOF 后合成 done,用户只看到回复被截断而无任何提示。新增 sse.error 事件与 streamAssist 兜底外壳;取消(isCancelled/AbortError)不报错,避免「点了停止却弹错误」。 2) 凭据错误码误报(src/main/agent/annotate/common.ts):requireCredentials 抛出的 400 provider_credentials_required 被 httpFromLlmError 落进 502 兜底分支,把「凭据没填」报成「上游坏了」。改为 AnnotateHttpError 原样透传,并读取 LlmRequestError.status 正确映射上游 4xx。 3) 运行时崩溃后永久失联(src/main/agent/host.ts):退出后无人重启且渲染层缓存 baseUrl,后续请求全部打向死端口。新增指数退避自动重启(500ms→8s、上限 5 次、稳定 30s 清零),启动阶段失败同样计入预算;stopAgentRuntime 置 intentionalStop 并取消待触发定时器。 4) 标注文档错位(src/renderer/context/AnnotationWorkspaceContext.tsx):加载流水线分开赋值 currentPairRef/loadedMetaRef/dirtyRef,在 React 异步提交窗口内防抖保存会把 A 的文档写进 B 的槽位,导致 filePath/source/annotations 全部错位。改为同步原子写 ref、加载前取消悬挂保存,并在 persistFromRefs 加「meta.filePath 与 relPath 必须一致」守卫(不一致则跳过保存而非写坏数据);加载时就地修正历史脏 filePath,使其可继续保存。annotationDoc 返回时按请求路径对齐 filePath,避免把历史脏数据当作工具字段错位反馈给模型。 验证:tsc 通过;jest 935/935 + 新增 16 个用例(退避策略 9、标注文档 7);golden diff 11/11 无回归。 Co-authored-by: Cursor --- src/main/agent/annotate/common.ts | 22 +- src/main/agent/host.test.ts | 71 +++++ src/main/agent/host.ts | 263 +++++++++++++----- src/main/agent/loop/assistLoop.ts | 30 ++ src/main/agent/sse.ts | 11 + src/main/agent/tools/annotationDoc.test.ts | 126 +++++++++ src/main/agent/tools/annotationDoc.ts | 9 +- .../context/AnnotationWorkspaceContext.tsx | 49 +++- 8 files changed, 515 insertions(+), 66 deletions(-) create mode 100644 src/main/agent/host.test.ts create mode 100644 src/main/agent/tools/annotationDoc.test.ts diff --git a/src/main/agent/annotate/common.ts b/src/main/agent/annotate/common.ts index 78c1e20..eb12aa1 100644 --- a/src/main/agent/annotate/common.ts +++ b/src/main/agent/annotate/common.ts @@ -188,12 +188,25 @@ export class AnnotateHttpError extends Error { * - 错误信息含 `response_format` / `json_schema` → 400 + 中文提示 * (说明该模型不支持结构化输出) * - 其它 → 502,信息截断到 500 字符 + * + * 额外保证:**已是 `AnnotateHttpError` 的错误原样透传**。 + * 否则 `requireCredentials` 抛出的 400 `provider_credentials_required` 会落进 + * 502 兜底分支——把「凭据没填」误报成「上游坏了」,前端拿到的错误语义就错了。 */ export function httpFromLlmError(err: unknown, status?: number): AnnotateHttpError { + if (err instanceof AnnotateHttpError) return err; + const message = err instanceof Error ? err.message : String(err); const lower = message.toLowerCase(); - if (status !== undefined && status >= 400 && status < 500) { + // 上游 LLM 的 4xx(`LlmRequestError` 自带 status)语义是「请求有问题」,统一转 400; + // 5xx 与网络错误走 502。 + const upstreamStatus = status ?? readStatusCode(err); + if ( + upstreamStatus !== undefined && + upstreamStatus >= 400 && + upstreamStatus < 500 + ) { return new AnnotateHttpError(400, message || 'llm_bad_request'); } if (lower.includes('response_format') || lower.includes('json_schema')) { @@ -205,6 +218,13 @@ export function httpFromLlmError(err: unknown, status?: number): AnnotateHttpErr return new AnnotateHttpError(502, message.slice(0, 500) || 'llm_invoke_failed'); } +/** 读取错误对象上的数字状态码(`LlmRequestError` 与 `AnnotateHttpError` 都有)。 */ +function readStatusCode(err: unknown): number | undefined { + if (!err || typeof err !== 'object') return undefined; + const value = (err as { status?: unknown }).status; + return typeof value === 'number' ? value : undefined; +} + /** 校验 provider 凭据是否齐全(对齐 `_require_direct_llm` 的前置检查)。 */ export function requireCredentials(params: { api_key: string; diff --git a/src/main/agent/host.test.ts b/src/main/agent/host.test.ts new file mode 100644 index 0000000..acea799 --- /dev/null +++ b/src/main/agent/host.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + * + * 只测**纯逻辑**(退避策略)。`host.ts` 的顶层会 import `electron`, + * 因此用模块 mock 把它挡掉——本用例不触碰任何真实进程。 + */ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => '/tmp' }, + BrowserWindow: { getAllWindows: () => [] }, + utilityProcess: { fork: jest.fn() }, +})); + +// eslint-disable-next-line import/first +import { computeRestartDelayMs, shouldGiveUpRestart } from './host'; + +describe('computeRestartDelayMs:指数退避', () => { + it('第 1 次为基准延迟 500ms', () => { + expect(computeRestartDelayMs(1)).toBe(500); + }); + + it('逐次翻倍', () => { + expect(computeRestartDelayMs(2)).toBe(1000); + expect(computeRestartDelayMs(3)).toBe(2000); + expect(computeRestartDelayMs(4)).toBe(4000); + expect(computeRestartDelayMs(5)).toBe(8000); + }); + + it('超过上限时封顶 10s', () => { + expect(computeRestartDelayMs(6)).toBe(10_000); + expect(computeRestartDelayMs(20)).toBe(10_000); + }); + + it('非正数按基准延迟处理(防御非法入参)', () => { + expect(computeRestartDelayMs(0)).toBe(500); + expect(computeRestartDelayMs(-3)).toBe(500); + }); + + it('序列单调不减', () => { + const seq = [1, 2, 3, 4, 5, 6, 7].map(computeRestartDelayMs); + for (let i = 1; i < seq.length; i += 1) { + expect(seq[i]).toBeGreaterThanOrEqual(seq[i - 1]); + } + }); +}); + +describe('shouldGiveUpRestart:重启预算', () => { + it('前 4 次仍会重试', () => { + for (const attempt of [1, 2, 3, 4]) { + expect(shouldGiveUpRestart(attempt)).toBe(false); + } + }); + + it('第 5 次达到上限', () => { + expect(shouldGiveUpRestart(5)).toBe(true); + }); + + it('超过上限同样放弃', () => { + expect(shouldGiveUpRestart(9)).toBe(true); + }); + + it('预算内总等待时间约 15.5s(5 次退避之和)', () => { + const total = [1, 2, 3, 4].reduce( + (sum, attempt) => sum + computeRestartDelayMs(attempt), + 0, + ); + // 500 + 1000 + 2000 + 4000 = 7500ms(第 5 次是判定放弃、不再等待) + expect(total).toBe(7_500); + }); +}); diff --git a/src/main/agent/host.ts b/src/main/agent/host.ts index 5d189d3..bfd5326 100644 --- a/src/main/agent/host.ts +++ b/src/main/agent/host.ts @@ -31,6 +31,34 @@ import { /** 等待运行时上报 ready 的超时。 */ const READY_TIMEOUT_MS = 20_000; +/** + * 意外退出后的自动重启策略:指数退避 + 次数上限。 + * + * 为什么需要:渲染层会缓存 baseUrl/token(`src/renderer/config.ts`), + * 运行时退出后若无人重启,后续请求会全部打向已死的端口——表现为 + * 「用着用着突然 network error 且不再恢复」。 + * 自动重启会**换上新端口**并推送 `running` 状态,渲染层据此失效缓存。 + */ +const RESTART_BASE_DELAY_MS = 500; +const RESTART_MAX_DELAY_MS = 10_000; +const RESTART_MAX_ATTEMPTS = 5; +/** 稳定运行超过该时长即认为本次重启成功,重置退避计数。 */ +const RESTART_STABLE_MS = 30_000; + +/** 第 `attempt`(从 1 起)次重启的等待时长:500ms → 1s → 2s → 4s → 8s(封顶 10s)。 */ +export function computeRestartDelayMs(attempt: number): number { + if (attempt <= 1) return RESTART_BASE_DELAY_MS; + return Math.min( + RESTART_BASE_DELAY_MS * 2 ** (attempt - 1), + RESTART_MAX_DELAY_MS, + ); +} + +/** 是否已耗尽重启预算。 */ +export function shouldGiveUpRestart(attempt: number): boolean { + return attempt >= RESTART_MAX_ATTEMPTS; +} + interface RuntimeRefs { child: UtilityProcess; bridge: Bridge; @@ -40,7 +68,28 @@ interface RuntimeRefs { let refs: RuntimeRefs | null = null; let starting: Promise | null = null; -let stopping = false; +/** 主进程/用户主动要求停止:置位后意外退出不再触发自动重启。 */ +let intentionalStop = false; +/** 连续重启计数(稳定运行 RESTART_STABLE_MS 后归零)。 */ +let restartAttempt = 0; +/** 待触发的重启定时器。 */ +let restartTimer: ReturnType | null = null; +/** 「稳定运行」计时器。 */ +let stableTimer: ReturnType | null = null; + +function cancelRestartTimer(): void { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } +} + +function clearStableTimer(): void { + if (stableTimer) { + clearTimeout(stableTimer); + stableTimer = null; + } +} function pushStatus(status: LocalAgentServiceStatus): void { for (const win of BrowserWindow.getAllWindows()) { @@ -86,68 +135,11 @@ export async function startAgentRuntime(): Promise { if (refs) return `http://127.0.0.1:${refs.port}/api/v1`; if (starting) return starting; - starting = (async () => { - const entry = resolveRuntimeEntry(); - const token = randomBytes(32).toString('hex'); - - pushStatus({ state: 'starting', baseUrl: null }); - - const child = utilityProcess.fork(entry, [], { - serviceName: 'lr-agent-runtime', - stdio: 'pipe', - env: { - ...process.env, - [AGENT_TOKEN_ENV]: token, - // 端口 0:由系统分配,运行时把实际端口回报,避免主进程预分配时的竞态 - LR_AGENT_LOCAL_PORT: '0', - [AGENT_DEBUG_ENV]: app.isPackaged ? '0' : '1', - }, - }); - - forwardStream(child.stdout, 'info'); - forwardStream(child.stderr, 'error'); - - const bridge = createBridge({ - endpoint: makeEndpoint(child), - side: 'h', - handlers: createMainRpcHandlers(), - onLog: (level, message) => { - if (level === 'error') console.error('[agentRuntime/rpc]', message); - else console.warn('[agentRuntime/rpc]', message); - }, - }); + // 主动启动:清除「已停止」意图并取消待触发的重启,避免与计时器互抢 + intentionalStop = false; + cancelRestartTimer(); - try { - const port = await waitForReady(child, READY_TIMEOUT_MS); - refs = { child, bridge, port, token }; - - child.on('exit', (code) => { - const wasStopping = stopping; - stopping = false; - bridge.dispose('runtime exited'); - if (refs?.child === child) refs = null; - pushStatus({ - state: 'stopped', - baseUrl: null, - message: wasStopping ? undefined : `Agent 运行时已退出 (code ${code})`, - }); - }); - - const baseUrl = `http://127.0.0.1:${port}/api/v1`; - console.log(`[agentRuntime] 已启动于 ${baseUrl}`); - pushStatus({ state: 'running', baseUrl }); - return baseUrl; - } catch (err) { - bridge.dispose('startup failed'); - child.kill(); - pushStatus({ - state: 'error', - baseUrl: null, - message: err instanceof Error ? err.message : String(err), - }); - throw err; - } - })(); + starting = spawnRuntime(); try { return await starting; @@ -156,6 +148,146 @@ export async function startAgentRuntime(): Promise { } } +/** + * 拉起一次运行时并等待就绪。 + * + * 失败时**不自行重试**(是否重试由 `scheduleRestart` 决定),但会把状态推成 + * `error`,让渲染层及时得到反馈。 + */ +async function spawnRuntime(): Promise { + const entry = resolveRuntimeEntry(); + const token = randomBytes(32).toString('hex'); + + pushStatus({ state: 'starting', baseUrl: null }); + + const child = utilityProcess.fork(entry, [], { + serviceName: 'lr-agent-runtime', + stdio: 'pipe', + env: { + ...process.env, + [AGENT_TOKEN_ENV]: token, + // 端口 0:由系统分配,运行时把实际端口回报,避免主进程预分配时的竞态 + LR_AGENT_LOCAL_PORT: '0', + [AGENT_DEBUG_ENV]: app.isPackaged ? '0' : '1', + }, + }); + + forwardStream(child.stdout, 'info'); + forwardStream(child.stderr, 'error'); + + const bridge = createBridge({ + endpoint: makeEndpoint(child), + side: 'h', + handlers: createMainRpcHandlers(), + onLog: (level, message) => { + if (level === 'error') console.error('[agentRuntime/rpc]', message); + else console.warn('[agentRuntime/rpc]', message); + }, + }); + + try { + const port = await waitForReady(child, READY_TIMEOUT_MS); + refs = { child, bridge, port, token }; + + // 退出处理:主动停止推 stopped,意外退出触发自动重启 + child.on('exit', (code) => handleRuntimeExit(child, code)); + + const baseUrl = `http://127.0.0.1:${port}/api/v1`; + console.log(`[agentRuntime] 已启动于 ${baseUrl}`); + pushStatus({ state: 'running', baseUrl }); + scheduleStableReset(); + return baseUrl; + } catch (err) { + bridge.dispose('startup failed'); + child.kill(); + pushStatus({ + state: 'error', + baseUrl: null, + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** 运行时进程退出:区分「主动停止」与「意外崩溃」。 */ +function handleRuntimeExit(child: UtilityProcess, code: number): void { + const wasIntentional = intentionalStop; + const bridge = refs?.bridge; + if (refs?.child === child) refs = null; + bridge?.dispose('runtime exited'); + clearStableTimer(); + + if (wasIntentional) { + pushStatus({ state: 'stopped', baseUrl: null }); + return; + } + + scheduleRestart(code); +} + +/** 稳定运行 RESTART_STABLE_MS 后清零退避计数(说明本次重启是成功的)。 */ +function scheduleStableReset(): void { + clearStableTimer(); + stableTimer = setTimeout(() => { + stableTimer = null; + if (restartAttempt > 0) { + console.log( + `[agentRuntime] 已稳定运行 ${RESTART_STABLE_MS}ms,清零重启计数(此前 ${restartAttempt} 次)`, + ); + restartAttempt = 0; + } + }, RESTART_STABLE_MS); + stableTimer.unref?.(); +} + +/** + * 意外退出后的自动重启(指数退避 + 上限)。 + * + * @param code 退出码;传 `null` 表示上一次重启在**启动阶段**就失败(没有退出码) + */ +function scheduleRestart(code: number | null): void { + restartAttempt += 1; + const exitLabel = code === null ? '启动失败' : `code ${code}`; + + if (shouldGiveUpRestart(restartAttempt)) { + console.error( + `[agentRuntime] 连续 ${restartAttempt} 次异常退出(最后 ${exitLabel}),放弃自动重启`, + ); + pushStatus({ + state: 'error', + baseUrl: null, + message: + `Agent 运行时连续 ${restartAttempt} 次异常退出(最后 ${exitLabel}),` + + '已停止自动重启,请重启应用。', + }); + restartAttempt = 0; + return; + } + + const delay = computeRestartDelayMs(restartAttempt); + console.warn( + `[agentRuntime] ${exitLabel},${delay}ms 后发起第 ${restartAttempt} 次重启`, + ); + pushStatus({ + state: 'starting', + baseUrl: null, + message: `Agent 运行时已退出(${exitLabel}),正在第 ${restartAttempt} 次重启…`, + }); + + restartTimer = setTimeout(() => { + restartTimer = null; + if (intentionalStop) return; + // 复用 startAgentRuntime:若这次也失败(spawn 失败 / 就绪超时), + // 同样计入退避预算并继续重试 + void startAgentRuntime().catch((err) => { + console.error('[agentRuntime] 自动重启失败:', err); + if (intentionalStop) return; + scheduleRestart(null); + }); + }, delay); + restartTimer.unref?.(); +} + function makeEndpoint(child: UtilityProcess): MessageEndpoint { // 运行时的 `shutdown` 是「先优雅关闭、超时再 kill」,期间仍可能有 RPC 回包。 // 用显式标志而不是探测方法存在性来判断通道是否还可用。 @@ -219,8 +351,13 @@ function waitForReady(child: UtilityProcess, timeoutMs: number): Promise /** 停止运行时。幂等。 */ export function stopAgentRuntime(): void { + // 先置「主动停止」意图并取消待触发的重启,否则退出会被当成崩溃而重新拉起 + intentionalStop = true; + cancelRestartTimer(); + clearStableTimer(); + restartAttempt = 0; + if (!refs) return; - stopping = true; const { child, bridge } = refs; refs = null; try { diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index dcc87f3..4f64532 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -195,9 +195,39 @@ function kindOf(tools: Map, name: string): ToolKind { * 运行 Assist 工具循环,产出 SSE 事件序列。 * * 调用方负责把事件序列化为 SSE 帧,并在流末尾追加 `done` 帧。 + * + * 本函数是**故障兜底外壳**:内层循环抛出的异常(最典型是 LLM 请求失败) + * 会被转成一条 `error` 事件。若不这样做,异常会被 SSE 层吞掉、客户端读到 EOF + * 后合成 `done`,用户只会看到回复被截断而没有任何提示。 + * + * 用户主动取消(`isCancelled()` 为真)不算故障,静默结束即可——渲染层会按 + * Cancelled 处理,报错反而会出现「点了停止却弹错误」的干扰。 */ export async function* streamAssist( params: AssistLoopParams, +): AsyncGenerator { + try { + yield* streamAssistInner(params); + } catch (err) { + if (params.isCancelled() || isAbortError(err)) return; + console.error( + '[agentRuntime] Assist 循环异常终止:', + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + yield sse.error(err instanceof Error ? err.message : '模型请求失败'); + } +} + +/** abort(取消)导致的异常不应被当作故障上报。 */ +function isAbortError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const name = (err as { name?: unknown }).name; + return name === 'AbortError'; +} + +/** 工具循环主体(异常由 `streamAssist` 统一兜底)。 */ +async function* streamAssistInner( + params: AssistLoopParams, ): AsyncGenerator { const { llm, tools, settings, isCancelled, signal } = params; diff --git a/src/main/agent/sse.ts b/src/main/agent/sse.ts index e05d750..c6a2286 100644 --- a/src/main/agent/sse.ts +++ b/src/main/agent/sse.ts @@ -182,6 +182,17 @@ export const DONE_FRAME = 'data: {"type": "done"}\n\n'; /** 便于构造事件的辅助函数(只覆盖运行时会发射的类型)。 */ export const sse = { preparing: (stage: string): StreamEventPayload => ({ type: 'preparing', stage }), + /** + * 流中途失败。 + * + * 渲染层对 `error` 事件的处理是完整的(`agentJobRegistry` 置 job 为 Error、 + * `AgentChatContext` 置消息 status='error' 并显示 `translateError(message)`), + * 因此运行时不发该事件会让失败**静默退化为正常结束**: + * SSE 层吞掉异常 → 客户端读到 EOF → 合成 `done` → 用户只看到回复被截断、零提示。 + * + * 只在**非取消**的真实故障时发射;取消由渲染层按 Cancelled 处理,不应报错。 + */ + error: (message: string): StreamEventPayload => ({ type: 'error', message }), textDelta: (content: string): StreamEventPayload => ({ type: 'text_delta', content, diff --git a/src/main/agent/tools/annotationDoc.test.ts b/src/main/agent/tools/annotationDoc.test.ts new file mode 100644 index 0000000..9547fb7 --- /dev/null +++ b/src/main/agent/tools/annotationDoc.test.ts @@ -0,0 +1,126 @@ +/** + * @jest-environment node + */ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import crypto from 'node:crypto'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { readFileAnnotationTool } from './annotationDoc'; +import type { ClientContextLike } from './workspacePath'; + +let projectDir: string; + +beforeEach(async () => { + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lr-ann-doc-')); +}); + +afterEach(async () => { + await fs.remove(projectDir); +}); + +/** 与 annotationDataStore 完全一致的寻址算法。 */ +function fileKeyOf(relativePath: string): string { + const normalized = relativePath.split(/[/\\]/).filter(Boolean).join('/'); + return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex'); +} + +/** 在磁盘写入一份标注文档。 */ +async function writeDoc(relativePath: string, doc: unknown): Promise { + const dir = path.join(projectDir, '.lr-agent', 'annotations', 'files'); + await fs.ensureDir(dir); + await fs.writeJson(path.join(dir, `${fileKeyOf(relativePath)}.json`), doc, { + spaces: 2, + }); +} + +const ctx = (): ClientContextLike => ({ + workspaceRoot: projectDir, + projectDirectoryPath: projectDir, +}); + +describe('readFileAnnotationTool:返回权威归属路径', () => { + it('文档内 filePath 正确时原样返回', async () => { + await writeDoc('data/1.jpg', { + schemaVersion: 1, + projectId: 'p1', + filePath: 'data/1.jpg', + annotations: [], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const raw = await readFileAnnotationTool(ctx(), 'data/1.jpg'); + expect(JSON.parse(raw).filePath).toBe('data/1.jpg'); + }); + + it('文档内 filePath 错位时按请求路径修正(历史脏数据兜底)', async () => { + // 模拟防抖错配历史遗留:data/1.jpg 的槽位里存着 data/7.jpg 的归属路径 + await writeDoc('data/1.jpg', { + schemaVersion: 1, + projectId: 'p1', + filePath: 'data/7.jpg', + annotations: [], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const raw = await readFileAnnotationTool(ctx(), 'data/1.jpg'); + const parsed = JSON.parse(raw); + + expect(parsed.filePath).toBe('data/1.jpg'); + // 其它字段不应被改动 + expect(parsed.projectId).toBe('p1'); + expect(parsed.schemaVersion).toBe(1); + }); + + it('缺失 filePath 字段时补齐为请求路径', async () => { + await writeDoc('data/2.jpg', { + schemaVersion: 1, + projectId: 'p1', + annotations: [], + }); + + const raw = await readFileAnnotationTool(ctx(), 'data/2.jpg'); + expect(JSON.parse(raw).filePath).toBe('data/2.jpg'); + }); + + it('反斜杠路径归一后仍能命中同一文档', async () => { + await writeDoc('data/3.jpg', { + schemaVersion: 1, + projectId: 'p1', + filePath: 'data/3.jpg', + annotations: [], + }); + + const raw = await readFileAnnotationTool(ctx(), 'data\\3.jpg'); + expect(JSON.parse(raw).filePath).toBe('data/3.jpg'); + }); + + it('文档不存在时给出中文提示', async () => { + const raw = await readFileAnnotationTool(ctx(), 'data/nope.jpg'); + expect(raw).toContain('未找到'); + }); + + it('未绑定项目目录时提示', async () => { + const raw = await readFileAnnotationTool(null, 'data/1.jpg'); + expect(raw).toContain('未绑定项目目录'); + }); + + it('分页提示与切片同时生效', async () => { + await writeDoc('data/4.jpg', { + schemaVersion: 1, + projectId: 'p1', + filePath: 'data/4.jpg', + annotations: Array.from({ length: 5 }, (_unused, i) => ({ id: `a${i}` })), + }); + + const raw = await readFileAnnotationTool(ctx(), 'data/4.jpg', { + offset: 1, + limit: 2, + }); + expect(raw).toContain('共 5 条标注'); + expect(JSON.parse(raw.slice(raw.indexOf('{'))).annotations).toEqual([ + { id: 'a1' }, + { id: 'a2' }, + ]); + }); +}); diff --git a/src/main/agent/tools/annotationDoc.ts b/src/main/agent/tools/annotationDoc.ts index 51b1b3a..03283e5 100644 --- a/src/main/agent/tools/annotationDoc.ts +++ b/src/main/agent/tools/annotationDoc.ts @@ -52,7 +52,14 @@ export async function readFileAnnotationTool( return '标注文件格式无效。'; } - const record = doc as Record; + let record = doc as Record; + // 文档内的 `filePath` 是冗余字段(权威归属由存储键决定,即入参 rel)。 + // 历史数据可能因旧的防抖错配残留错误值;这里强制对齐,避免把脏数据 + // 当作「工具返回字段错位」反馈给模型与用户。 + if (record.filePath !== rel) { + record = { ...record, filePath: rel }; + } + let note = ''; const annotations = record.annotations; diff --git a/src/renderer/context/AnnotationWorkspaceContext.tsx b/src/renderer/context/AnnotationWorkspaceContext.tsx index cbe6845..bc0b326 100644 --- a/src/renderer/context/AnnotationWorkspaceContext.tsx +++ b/src/renderer/context/AnnotationWorkspaceContext.tsx @@ -811,6 +811,23 @@ export function AnnotationWorkspaceProvider({ const ann = annotationsRef.current; let meta = loadedMetaRef.current; + // 一致性守卫:`relPath` 与 `meta.filePath` 必须指向同一文件。 + // + // 二者分别来自 `currentPairRef` 与 `loadedMetaRef`。加载流水线切换文件时, + // 这两个 ref 与 `dirtyRef` 是被分开赋值的,因此存在「新 rel + 旧 meta」的 + // 短暂时序窗口;若防抖保存恰好落在窗口内,会把 A 的文档写进 B 的槽位, + // 造成 filePath / source / annotations 全部错位(静默损坏用户标注数据)。 + // + // 这里选择**跳过这次保存**而不是写入:脏数据仍留在内存中,用户继续操作会 + // 重新触发保存;而下一次加载完成时 ref 已同步,不会再误判。 + if (meta.filePath !== relPath) { + console.warn( + '[annotation] 跳过保存:文档归属路径与当前文件不一致', + { metaFilePath: meta.filePath, relPath }, + ); + return; + } + setSaving(true); try { const hintPath = opts?.statsPathOverride ?? currentPairRef.current.abs; @@ -1254,6 +1271,13 @@ export function AnnotationWorkspaceProvider({ const runLoadPipeline = async () => { setLoadError(null); + // 取消前一文件遗留的防抖保存:否则它可能在本文件加载完成后触发, + // 用写死闭包里的旧 rel 覆盖新文件(或反之)。 + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = undefined; + } + const prev = currentPairRef.current; if ( dirtyRef.current && @@ -1280,7 +1304,19 @@ export function AnnotationWorkspaceProvider({ if (cancelled) return; if (parsed) { - const { annotations: ann, ...meta } = parsed; + const { annotations: ann, ...parsedMeta } = parsed; + + // 权威归属路径由请求决定(存储键即相对路径的 hash),文档内的 `filePath` + // 只是冗余字段。历史数据可能因防抖错配残留错误的 filePath;这里就地修正, + // 既避免下面的「一致性守卫」永久跳过该文件的保存,也让下次保存顺手修好磁盘。 + let meta = parsedMeta; + if (meta.filePath !== rel) { + console.warn('[annotation] 修正文档归属路径', { + docFilePath: meta.filePath, + actualRelPath: rel, + }); + meta = { ...meta, filePath: rel }; + } let stale = false; if ( @@ -1301,6 +1337,11 @@ export function AnnotationWorkspaceProvider({ clearHistory(); setDirty(false); setSourceStale(stale); + // 同步写 ref(与 currentPairRef 同一时刻生效),闭合「React 异步提交期间 + // persistFromRefs 读到新 rel + 旧 meta/annotations」的时序窗口。 + loadedMetaRef.current = meta; + annotationsRef.current = ann; + dirtyRef.current = false; currentPairRef.current = { rel, abs: activeFilePath }; applyPendingAgentNavigation(rel, parsed, stats); } else { @@ -1310,6 +1351,9 @@ export function AnnotationWorkspaceProvider({ clearHistory(); setDirty(false); setSourceStale(false); + loadedMetaRef.current = meta; + annotationsRef.current = []; + dirtyRef.current = false; currentPairRef.current = { rel, abs: activeFilePath }; applyPendingAgentNavigation(rel, null, stats); } @@ -1319,6 +1363,9 @@ export function AnnotationWorkspaceProvider({ setAnnotations([]); setLoadedDocMeta(null); clearHistory(); + loadedMetaRef.current = null; + annotationsRef.current = []; + dirtyRef.current = false; currentPairRef.current = { rel, abs: activeFilePath }; setDirty(false); } From 8624414d4fdb81f6b43fd1d18e8d3358a5b736e7 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 11:43:59 +0800 Subject: [PATCH 13/20] =?UTF-8?q?fix(agent):=20=E6=B8=B2=E6=9F=93=E5=B1=82?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=9C=8D=E5=8A=A1=E5=9C=B0=E5=9D=80=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E8=87=AA=E6=84=88=EF=BC=8C=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E9=87=8D=E5=90=AF=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐上一提交的配套缺陷:运行时会换端口与 token,但渲染层把地址缓存为模块级常量且永不失效,导致重启后请求全部打向已死端口且无法自愈(表现为 network error 后必须手动刷新页面)。 config.ts:新增 invalidateLocalAgentAuth/hasLocalAgentAuth;localAgentFetch 增加一次自愈重试——网络异常时清缓存并重新向主进程解析地址后重试(覆盖「旧端口已死」与「此前回退到默认端口」两种情形),401 且此前发过 token 时同样重试(覆盖「端口复用但 token 已换」);用户取消不重试;重试后仍失败统一抛「本机 Agent 服务未就绪」,不再把裸 Failed to fetch 漏给 UI。重试按 URL 改写 protocol+host,5 个调用点无需改动。 EnvironmentContext:订阅 localAgent:status 时失效缓存,使主进程推送的 running(新端口)立即生效。 docs/agent-protocol.md:error 事件的生产者边界由「仅渲染层合成」更正为「运行时在非取消故障时也会发射」,并说明取消不报错的语义。 验证:tsc 通过;jest 962/962(新增 config 缓存自愈 11 例);golden diff 11/11。 Co-authored-by: Cursor --- docs/agent-protocol.md | 15 +- src/renderer/config.test.ts | 208 ++++++++++++++++++++ src/renderer/config.ts | 107 +++++++++- src/renderer/context/EnvironmentContext.tsx | 7 +- 4 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 src/renderer/config.test.ts diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 1c26350..3fa4735 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -136,7 +136,8 @@ type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_pro | 事件 | 生产者 | |---|---| | `preparing`、`text_delta`、`reasoning_delta`、`tool_start`、`tool_result`、`tool_pending`、`file_proposal_start`、`file_proposal_delta`、`file_edit_delta`、`file_proposal`、`subagent_*`、`done` | **运行时** | -| `awaiting_confirmation`、`terminal_approval`、`terminal_approval_done`、`terminal_output`、`annotation_progress`、`annotation_proposal`、`error` | **渲染层合成** | +| `awaiting_confirmation`、`terminal_approval`、`terminal_approval_done`、`terminal_output`、`annotation_progress`、`annotation_proposal` | **渲染层合成** | +| `error` | **两侧都会发射**:渲染层在 fetch 失败 / HTTP 非 2xx / body 为空时合成;运行时的 Assist 循环在**非取消**的真实故障(最典型是 LLM 请求失败)时也会发射,否则失败会被静默降级为正常结束 | | `context_updated`、`route_decided` | 已声明但**全仓库无发射点**(死类型,仅类型与调试日志消费) | | `document_proposal` | `@deprecated` 旧类型,运行时从不发射;历史消息加载时归一为 `file_proposal`。序列化特判保留以兼容旧数据 | | `tool_calls_already_completed` | **内部信号**,被上层消费,**不出 SSE** | @@ -323,7 +324,17 @@ type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_pro | `type` | `"error"` | | `message` | 错误信息 | -**运行时从不发射**。由渲染层在 fetch 失败 / HTTP 非 2xx / body 为空时合成。 +**两个来源**: + +1. **运行时**——Assist 循环的故障兜底外壳(`src/main/agent/loop/assistLoop.ts` 的 `streamAssist`)。 + 内层循环抛出的异常(最典型是 LLM 请求失败)会被转成一条 `error` 事件。 + 若不这样做,异常会被 SSE 层吞掉、客户端读到 EOF 后合成 `done`,用户只会看到回复被截断 + 而没有任何提示。 + + **取消不报错**:`isCancelled()` 为真或异常为 `AbortError` 时静默结束——渲染层会按 + Cancelled 处理,报错反而会出现「点了停止却弹错误」的干扰。 + +2. **渲染层**——fetch 失败 / HTTP 非 2xx / body 为空时合成。 ### 2.4 `arguments` 字段的类型不统一(不要统一) diff --git a/src/renderer/config.test.ts b/src/renderer/config.test.ts new file mode 100644 index 0000000..e06062e --- /dev/null +++ b/src/renderer/config.test.ts @@ -0,0 +1,208 @@ +/** + * @jest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + hasLocalAgentAuth, + invalidateLocalAgentAuth, + localAgentFetch, + resolveLocalAgentAuth, +} from './config'; + +/** + * `localAgentFetch` 内部的地址解析用的是默认重试参数(10 × 500ms ≈ 5s), + * 因此「IPC 不可用」这类用例天然需要更长时间。 + */ +jest.setTimeout(30_000); + +/** 安装一个可控的 `window.electron.localAgent.getBaseUrl`。 */ +function installIpc( + handler: () => Promise<{ url: string; token: string } | null>, +): void { + (globalThis as unknown as { window: unknown }).window = { + electron: { localAgent: { getBaseUrl: handler } }, + }; +} + +/** 让 IPC 始终返回同一地址。 */ +function ipcReturning(url: string, token: string): void { + installIpc(async () => ({ url, token })); +} + +beforeEach(() => { + invalidateLocalAgentAuth(); + delete (globalThis as { fetch?: unknown }).fetch; +}); + +afterEach(() => { + invalidateLocalAgentAuth(); + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { fetch?: unknown }).fetch; + jest.restoreAllMocks(); +}); + +const FAST = { retries: 0, intervalMs: 0 }; + +describe('invalidateLocalAgentAuth / hasLocalAgentAuth', () => { + it('初始无缓存', () => { + expect(hasLocalAgentAuth()).toBe(false); + }); + + it('解析成功后进入有缓存状态', async () => { + ipcReturning('http://127.0.0.1:1111/api/v1', 't1'); + await resolveLocalAgentAuth(FAST); + expect(hasLocalAgentAuth()).toBe(true); + }); + + it('失效后回到无缓存状态', async () => { + ipcReturning('http://127.0.0.1:1111/api/v1', 't1'); + await resolveLocalAgentAuth(FAST); + invalidateLocalAgentAuth(); + expect(hasLocalAgentAuth()).toBe(false); + }); + + it('IPC 不可用时不写入缓存(回退默认端口不算缓存)', async () => { + installIpc(async () => null); + const auth = await resolveLocalAgentAuth(FAST); + expect(auth.token).toBeNull(); + expect(hasLocalAgentAuth()).toBe(false); + }); +}); + +describe('localAgentFetch:网络异常自愈', () => { + it('首次失败后清缓存、换新地址重试一次', async () => { + let calls = 0; + let asked = 0; + installIpc(async () => { + asked += 1; + // 第一次给旧地址,第二次给新地址(模拟运行时重启换了端口) + return asked === 1 + ? { url: 'http://127.0.0.1:1111/api/v1', token: 'old' } + : { url: 'http://127.0.0.1:2222/api/v1', token: 'new' }; + }); + + const seen: string[] = []; + (globalThis as unknown as { fetch: unknown }).fetch = async (url: string) => { + calls += 1; + seen.push(url); + if (calls === 1) throw new TypeError('Failed to fetch'); + return { status: 200 } as Response; + }; + + const response = await localAgentFetch( + 'http://127.0.0.1:1111/api/v1/agent/chat/stream', + ); + + expect(response.status).toBe(200); + expect(calls).toBe(2); + // 重试应改写为新 host,路径保持不变 + expect(seen[0]).toBe('http://127.0.0.1:1111/api/v1/agent/chat/stream'); + expect(seen[1]).toBe('http://127.0.0.1:2222/api/v1/agent/chat/stream'); + }); + + it('重试仍失败时抛明确文案(替代裸 Failed to fetch)', async () => { + ipcReturning('http://127.0.0.1:1111/api/v1', 't1'); + (globalThis as unknown as { fetch: unknown }).fetch = async () => { + throw new TypeError('Failed to fetch'); + }; + + await expect( + localAgentFetch('http://127.0.0.1:1111/api/v1/agent/chat/cancel'), + ).rejects.toThrow('本机 Agent 服务未就绪'); + }); + + it('无法重新解析地址时不重试,直接抛文案', async () => { + installIpc(async () => null); + let calls = 0; + (globalThis as unknown as { fetch: unknown }).fetch = async () => { + calls += 1; + throw new TypeError('Failed to fetch'); + }; + + await expect( + localAgentFetch('http://127.0.0.1:1111/api/v1/agent/chat/cancel'), + ).rejects.toThrow('本机 Agent 服务未就绪'); + // 只尝试了一次:没有可用地址就不该白白重试 + expect(calls).toBe(1); + }); + + it('用户取消(signal.aborted)不重试', async () => { + ipcReturning('http://127.0.0.1:1111/api/v1', 't1'); + const controller = new AbortController(); + controller.abort(); + + let calls = 0; + (globalThis as unknown as { fetch: unknown }).fetch = async () => { + calls += 1; + throw new DOMException('Aborted', 'AbortError'); + }; + + await expect( + localAgentFetch('http://127.0.0.1:1111/api/v1/agent/chat/stream', { + signal: controller.signal, + }), + ).rejects.toThrow(); + expect(calls).toBe(1); + }); +}); + +describe('localAgentFetch:401 自愈', () => { + it('401 且此前发过 token 时换新 token 重试', async () => { + let asked = 0; + installIpc(async () => { + asked += 1; + return asked === 1 + ? { url: 'http://127.0.0.1:1111/api/v1', token: 'old' } + : { url: 'http://127.0.0.1:1111/api/v1', token: 'new' }; + }); + + const tokens: Array = []; + let calls = 0; + (globalThis as unknown as { fetch: unknown }).fetch = async ( + _url: string, + init?: { headers?: Headers }, + ) => { + calls += 1; + tokens.push(init?.headers?.get('Authorization') ?? undefined); + return { status: calls === 1 ? 401 : 200 } as Response; + }; + + const response = await localAgentFetch( + 'http://127.0.0.1:1111/api/v1/agent/chat/cancel', + ); + + expect(response.status).toBe(200); + expect(tokens[0]).toBe('Bearer old'); + expect(tokens[1]).toBe('Bearer new'); + }); + + it('401 但从未持有 token 时不重试(重试也无济于事)', async () => { + installIpc(async () => null); + let calls = 0; + (globalThis as unknown as { fetch: unknown }).fetch = async () => { + calls += 1; + return { status: 401 } as Response; + }; + + const response = await localAgentFetch( + 'http://127.0.0.1:1111/api/v1/agent/chat/cancel', + ); + expect(response.status).toBe(401); + expect(calls).toBe(1); + }); + + it('非 401 的失败响应原样返回(不做无谓重试)', async () => { + ipcReturning('http://127.0.0.1:1111/api/v1', 't1'); + let calls = 0; + (globalThis as unknown as { fetch: unknown }).fetch = async () => { + calls += 1; + return { status: 502 } as Response; + }; + + const response = await localAgentFetch( + 'http://127.0.0.1:1111/api/v1/agent/chat/stream', + ); + expect(response.status).toBe(502); + expect(calls).toBe(1); + }); +}); diff --git a/src/renderer/config.ts b/src/renderer/config.ts index c580aed..36b6f01 100644 --- a/src/renderer/config.ts +++ b/src/renderer/config.ts @@ -11,7 +11,12 @@ export const API_BASE_URL = process.env.API_BASE_URL ?? 'http://localhost:8000/api/v1'; -/** 本地 Agent 服务默认地址(IPC 不可用时的回退,对应 local_main.py 默认端口) */ +/** + * 本地 Agent 服务默认地址。 + * + * 仅在 IPC 不可用(如纯 Web 调试)时作为回退;**注意此时没有 token**。 + * 该回退值**不会被缓存**,因此下一次请求仍会重新尝试 IPC。 + */ export const LOCAL_AGENT_DEFAULT_BASE_URL = 'http://127.0.0.1:8765/api/v1'; export const REMEMBERED_EMAIL_KEY = 'lr-agent:remembered-email'; @@ -42,6 +47,26 @@ export function resolveApiBaseUrl(): string { let cachedLocalAgentBaseUrl: string | null = null; let cachedLocalAgentToken: string | null = null; +/** + * 失效本地 Agent 的地址与 token 缓存。 + * + * 什么时候必须调用:**运行时重启会换随机端口与新 token**(见 + * `src/main/agent/host.ts` 的自动重启),此时旧缓存会让后续请求全部打向 + * 已死端口——表现为「突然 network error 且不再恢复」。 + * + * 调用方:`EnvironmentContext` 订阅 `localAgent:status` 时(主进程推 + * `running` 即代表换过端口),以及本模块在连接失败/401 时的自愈重试。 + */ +export function invalidateLocalAgentAuth(): void { + cachedLocalAgentBaseUrl = null; + cachedLocalAgentToken = null; +} + +/** 是否已持有可用的本地 Agent 地址(不含未缓存的默认回退)。 */ +export function hasLocalAgentAuth(): boolean { + return cachedLocalAgentBaseUrl !== null; +} + async function queryLocalAgentEndpoint(): Promise<{ url: string; token: string; @@ -99,20 +124,90 @@ export async function resolveLocalAgentBaseUrl(options?: { return baseUrl; } +/** + * 清缓存并重新向主进程查询地址。 + * + * @returns 真正从 IPC 拿到的 baseUrl;仍不可用时返回 null(不回退默认端口, + * 因为那个地址此时已知不可用,重试只会白费一次请求) + */ +async function refreshLocalAgentAuth(): Promise { + invalidateLocalAgentAuth(); + await resolveLocalAgentAuth(); + return cachedLocalAgentBaseUrl; +} + +/** + * 把请求 URL 的 origin 换成新的 base(路径与查询保持不变)。 + * + * 这样调用方无需感知重试:它们仍然用「解析出的 baseUrl + 路径」拼 URL, + * 重试时由本模块统一改写主机与端口。 + */ +function rewriteBase(input: string, newBaseUrl: string): string { + try { + const target = new URL(input, 'http://127.0.0.1'); + const base = new URL(newBaseUrl); + target.protocol = base.protocol; + target.host = base.host; + return target.toString(); + } catch { + return input; + } +} + +/** 本地服务不可达时的统一文案(替代裸的 `Failed to fetch`)。 */ +const UNAVAILABLE_MESSAGE = '本机 Agent 服务未就绪,请稍后重试或重启应用'; + /** * 携带本地服务 Bearer token 的 fetch 封装。 * 所有发往 LR-Agent-local 的请求都应经此发出(否则服务返回 401)。 + * + * **带一次自愈重试**:运行时可能刚崩溃重启过(换了端口与 token), + * 而渲染层仍持有旧缓存。命中以下两种情形时清缓存、重新解析、重试一次: + * + * 1. **网络异常**(连接被拒 / 端口关闭)——旧端口已死,或此前回退到了 + * 默认端口;此时即便没有缓存也值得重试,因为运行时可能已经重启完毕。 + * 2. **401 且此前发过 token**——端口没变但 token 已随重启更换。 + * + * 用户主动取消(`signal.aborted`)不重试,避免掩盖取消语义。 */ export async function localAgentFetch( input: string, init?: RequestInit, ): Promise { - if (!cachedLocalAgentToken) { + if (!cachedLocalAgentBaseUrl) { await resolveLocalAgentAuth(); } - const headers = new Headers(init?.headers); - if (cachedLocalAgentToken && !headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${cachedLocalAgentToken}`); + const hadToken = cachedLocalAgentToken !== null; + + const send = (url: string): Promise => { + const headers = new Headers(init?.headers); + if (cachedLocalAgentToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${cachedLocalAgentToken}`); + } + return fetch(url, { ...init, headers }); + }; + + let response: Response; + try { + response = await send(input); + } catch (err) { + if (init?.signal?.aborted) throw err; + const recovered = await refreshLocalAgentAuth(); + if (!recovered) throw new Error(UNAVAILABLE_MESSAGE); + try { + return await send(rewriteBase(input, recovered)); + } catch (retryErr) { + // 重试也失败:统一成明确文案,避免裸的 `Failed to fetch` 漏到 UI + if (init?.signal?.aborted) throw retryErr; + throw new Error(UNAVAILABLE_MESSAGE); + } + } + + // 端口复用但 token 已更换:重试一次拿新 token + if (response.status === 401 && hadToken) { + const recovered = await refreshLocalAgentAuth(); + if (recovered) return send(rewriteBase(input, recovered)); } - return fetch(input, { ...init, headers }); + + return response; } diff --git a/src/renderer/context/EnvironmentContext.tsx b/src/renderer/context/EnvironmentContext.tsx index e2c4aaa..21ba28c 100644 --- a/src/renderer/context/EnvironmentContext.tsx +++ b/src/renderer/context/EnvironmentContext.tsx @@ -20,7 +20,7 @@ import { saveEnvSettings, startEnvironmentInstall, } from '../services/environmentService'; -import { setApiBaseUrlOverride } from '../config'; +import { invalidateLocalAgentAuth, setApiBaseUrlOverride } from '../config'; interface EnvironmentContextValue { status: EnvironmentStatus | null; @@ -67,11 +67,14 @@ export function EnvironmentProvider({ children }: { children: ReactNode }) { refresh().catch(() => undefined); }, [refresh]); - // 订阅本地 Agent 服务状态(启动/退出推送) + // 订阅本地 Agent 服务状态(启动/退出/重启推送) useEffect(() => { if (!window.electron?.localAgent?.onStatus) return undefined; const unsubscribe = window.electron.localAgent.onStatus((next) => { setLocalAgentService(next); + // 运行时每次启动都会换随机端口与新 token,因此任何状态变更都要 + // 丢弃渲染层缓存的地址与凭据 —— 否则重启后会一直打向已死端口。 + invalidateLocalAgentAuth(); }); return unsubscribe; }, []); From f6aab861f378bea8e988e716e4a75974e9137f13 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 13:17:22 +0800 Subject: [PATCH 14/20] =?UTF-8?q?feat(agent):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=86=99=E5=85=A5=E4=B8=8E=E6=A0=87=E6=B3=A8=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=85=8D=E7=A1=AE=E8=AE=A4=E7=9B=B4=E6=8E=A5=E8=90=BD=E7=9B=98?= =?UTF-8?q?=EF=BC=8C=E6=92=A4=E9=94=80=E5=8D=87=E7=BA=A7=E4=B8=BA=E4=B8=80?= =?UTF-8?q?=E7=AD=89=E5=85=AC=E6=B0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按「结果可审阅性 × 错误代价」分层,而非「是否文件改写」: 免确认(直接落盘)—— 工作区文件写入/替换/删除/移动(4 个 PROPOSAL 工具)与标注编辑(mutate_annotation)。前者 diff 可扫读、指令明确;后者是定点修改且有改前快照兜底。两者都随时可撤销。 保留确认 —— 标注生成(auto_annotate)。由模型推理产出、审阅需逐框看图、replace_matching 会覆盖已有标注,错误代价最高。 实现: 1. agentProposalApply 新增 selectAutoApplicableProposals / applyAutoApplicableProposals,按 sourceKind==='mutation' 与 file_proposal 筛选;必须等 contentFinalized 为真才落盘(否则会写入空内容/半截内容);sourceKind 缺失时用与 agentChatStore 同一份 inferAnnotationProposalKind 做内容推断(该函数已提到 pipelineKinds.ts 共用)。 2. AgentChatContext 以响应式 effect 触发自动应用(覆盖重启恢复与历史重放),带重入守卫与失败去重(失败保持 pending 并提示,不静默吞掉、不反复刷错)。 3. agentJobRegistry 的 formatAnnotationToolResult 新增 autoApplied:mutate_annotation 报 proposal_pending=false + file_written=true,因此不再进入 AwaitingConfirm 断点(该断点现仅由标注生成与终端审批触发)。 4. 撤销入口显式化:复用已有 undoAssistantChanges,落到消息的 Files Changed 汇总卡右上角与消息工具栏。 5. checkpoint 每会话上限 20 → 50,避免免确认后写入变多时最早(也最可能需回滚)的快照被先行清除。 文案同步:fileProposal 的工具结果、registry 的工具描述、contextService 的 resume 提示、协议文档与类型注释均改为「已直接落盘、可撤销」。 验证:tsc 通过;jest 977/977(新增 15 例:筛选 9、格式化 3、checkpoint 上限 1 等);golden diff 11/11 —— 其中 proposal-write / proposal-edit 的 tool_result 文案差异属本次有意变更,已在 KNOWN_DIVERGENCES 中以**字段级**豁免记录(其余字段、事件顺序与条数仍严格校验),并用负向测试确认 gate 未被削弱。 Co-authored-by: Cursor --- docs/agent-protocol.md | 55 +++++- scripts/agent-baseline/README.md | 17 +- scripts/agent-baseline/diffSse.mjs | 88 +++++++-- src/main/agent/context/contextService.ts | 4 +- src/main/agent/tools/fileProposal.ts | 18 +- src/main/agent/tools/registry.ts | 14 +- .../checkpoint/turnCheckpointStore.test.ts | 33 ++++ src/main/checkpoint/turnCheckpointStore.ts | 9 +- .../agent/AgentAssistantMessage.tsx | 25 +++ .../agent/AgentFilesChangedSummary.css | 31 +++ .../agent/AgentFilesChangedSummary.tsx | 44 ++++- src/renderer/context/AgentChatContext.tsx | 124 ++++++++++++ src/renderer/services/agentChatStore.ts | 21 +-- src/renderer/services/agentJobRegistry.ts | 37 +++- .../services/agentProposalApply.test.ts | 178 ++++++++++++++++++ src/renderer/services/agentProposalApply.ts | 103 ++++++++++ .../services/annotationAgent/pipelineKinds.ts | 25 +++ .../annotationMutationBatchJob.test.ts | 53 +++++- src/shared/agentTypes.ts | 4 +- 19 files changed, 805 insertions(+), 78 deletions(-) diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 3fa4735..b2747b4 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -209,6 +209,20 @@ type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_pro 触发工具 = runner 为 `ASYNC` 的三个:`auto_annotate`、`mutate_annotation`、`start_terminal_command`。 发射前会先为每个调用各发一条 `tool_start`。 +**注意:`tool_pending` 之后是否进入用户确认断点,取决于该工具是否免确认。** + +| 工具 | 落盘方式 | 是否进入 `awaiting_confirmation` | +|---|---|---| +| `write_workspace_file` / `str_replace_workspace_file` / `delete_workspace_file` / `move_workspace_file` | 渲染层收到定稿的 `file_proposal`(`contentFinalized === true`)后**直接落盘** | 否 | +| `mutate_annotation`(标注编辑) | 渲染层自动落盘;工具结果报 `proposal_pending: false` | 否 | +| `auto_annotate`(标注生成) | 提案保留 `pending`,等用户 Keep All | **是** | +| `start_terminal_command` | 需聊天内批准 | 是(终端审批流程) | + +免确认的依据是「结果可审阅性 × 错误代价」:文件 diff 可扫读、标注编辑是指令明确的定点修改, +两者都随时可通过改前快照撤销;而标注生成由模型推理产出、审阅需逐框看图、`replace_matching` +还会覆盖已有标注,因此保留确认。详见 `src/renderer/services/agentProposalApply.ts` 的 +`selectAutoApplicableProposals`。 + #### `subagent_start` | 字段 | 说明 | @@ -526,8 +540,8 @@ client_context 非空 AND ( | Runner | 语义 | 执行位置 | |---|---|---| | `SYNC` | 本轮在运行时内同步执行完毕 | 运行时 | -| `PROPOSAL` | 运行时校验并生成提案事件,**落盘由 Electron 在用户确认后执行** | 运行时生成提案 / Electron 落盘 | -| `ASYNC` | 暂停循环,由 Electron 执行后 resume | Electron | +| `PROPOSAL` | 运行时校验并生成提案事件,**Electron 在提案定稿后直接落盘**(免确认,可撤销) | 运行时生成提案 / Electron 落盘 | +| `ASYNC` | 暂停循环,由 Electron 执行后 resume;是否进入用户确认断点见 §2.3 的 `tool_pending` | Electron | **迁移后应收归为单一真源**:当前分类在三处手写镜像(Python `tool_registry_meta.py`、 `src/shared/agentToolKinds.ts`、`src/shared/agentTypes.ts` 的 `CLIENT_TOOL_NAME_SET`), @@ -687,14 +701,45 @@ Agent 工具集之外(理由见 §5.3 注释)。Python 侧 `fn_map` 只包 1. 越界调用从「未知工具」变成真实执行 2. `tool_choice="any"` 兜底**错误触发**——它的前置条件之一正是「该工具在当前工具集内」 +### 5.5 免确认落盘(提案免确认改造) + +**提案事件(`file_proposal` / `annotation_proposal`)在运行时侧仍是统一的「变更描述」, +但渲染层对它们的处理分两档。** + +| 提案来源 | 渲染层行为 | 依据 | +|---|---|---| +| `file_proposal`(4 个写入工具) | 等 `contentFinalized === true` 后**直接落盘** | diff 可扫读、指令明确、错误代价低 | +| `annotation_proposal` 且 `sourceKind === 'mutation'` | **直接落盘** | 指令明确的定点修改(改标签/删框/改内容),有改前快照兜底 | +| `annotation_proposal` 且来源为批量标注 | 保留 `pending`,**等用户 Keep All** | 模型推理产出、审阅需逐框看图、`replace_matching` 会覆盖已有标注 | + +实现要点(`src/renderer/services/agentProposalApply.ts` 的 `selectAutoApplicableProposals`): + +1. **必须等 `contentFinalized`**:`file_proposal_start` 阶段 `content` 是空串且 + `contentFinalized` 为 false(内容随后由 `file_proposal_delta` 流式补齐)。 + 此时落盘会写入空内容 / 半截内容。缺失该字段的历史数据一律视为未定稿(保守)。 +2. **`sourceKind` 优先,内容推断回退**:历史数据可能缺 `sourceKind`,此时按提案内容推断 + (只含 `delete`/`patch` 即标注编辑),与 `agentChatStore` 设置 `sourceKind` 用的是 + **同一份实现**(`pipelineKinds.ts` 的 `inferAnnotationProposalKind`)。 +3. **失败保持 `pending`**:写盘失败时不静默吞掉,block 仍为待确认态,卡片可用 + Keep All / Undo 手动处理;同时标记该 ref 以免流式期间反复重试刷提示。 +4. **撤销是一等公民**:每次落盘前都会捕获改前快照(`captureProposalCheckpoint`), + 因此可直接撤销。撤销入口两处:消息的 Files Changed 汇总卡右上角、消息工具栏。 + +**因此 `awaiting_confirmation` 只由标注生成(`auto_annotate`)与终端审批触发。** + ### 6.0 已记录的差异 以下差异是**有意保留**的,不属于回归。`scripts/agent-baseline/diffSse.mjs` 的 -`KNOWN_DIVERGENCES` 只豁免明确列出的**事件类型**,其余任何偏差仍会导致 gate 失败。 +`KNOWN_DIVERGENCES` 支持两种粒度,且都只豁免**明确列出的内容**(其余字段、事件顺序与 +条数仍严格校验),因此任何偏差仍会导致 gate 失败: + +1. `extraEventTypes`:允许候选侧多出某类事件 +2. `fieldOverrides`:忽略某事件类型下指定字段的差异 -| 场景 | 差异 | 原因 | +| 场景 | 豁免内容 | 原因 | |---|---|---| -| `reasoning` | 运行时会发出 `reasoning_delta`,Python 侧不发 | Python 用的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,因此 `stream_adapter.py` 的该分支从不触发。Node 侧直接读原始 delta,正常透出。这是**能力增强**(前端 `agentChatStore` 已完整消费该事件),保留而不回退 | +| `reasoning` | 候选侧多出的 `reasoning_delta` 事件 | Python 用的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,因此 `stream_adapter.py` 的该分支从不触发。Node 侧直接读原始 delta,正常透出。这是**能力增强**(前端 `agentChatStore` 已完整消费该事件),保留而不回退 | +| `proposal-write` / `proposal-edit` | `tool_result` 事件的 `result` 字段 | 见 §5.5 免确认落盘:文件写入不再等用户 Keep All,工具结果文案相应由「文件尚未写入磁盘;用户确认后才会落盘」改为「已直接写入磁盘(无需确认),用户可撤销」 | --- diff --git a/scripts/agent-baseline/README.md b/scripts/agent-baseline/README.md index 0171d53..99cf95f 100644 --- a/scripts/agent-baseline/README.md +++ b/scripts/agent-baseline/README.md @@ -89,17 +89,26 @@ node scripts/agent-baseline/diffSse.mjs \ ## 已记录的差异 `diffSse.mjs` 里有 `KNOWN_DIVERGENCES` 表,用于记录**有意保留**的行为差异。 -它只豁免明确列出的**事件类型**,其余任何偏差仍会导致 gate 失败——避免「整场景跳过」 -让这个 gate 失效。 +它支持两种粒度,且都**只豁免明确列出的内容**,其余任何偏差仍会导致 gate 失败 +——避免「整场景跳过」让这个 gate 失效: + +1. `extraEventTypes`:允许候选侧多出(基准侧没有)的**事件类型** +2. `fieldOverrides`:某事件类型下**指定字段**的差异被忽略(两侧同时剔除后再比对, + 其余字段、事件顺序与条数仍严格校验) 当前记录: -| 场景 | 豁免的事件类型 | 原因 | +| 场景 | 豁免内容 | 原因 | |---|---|---| -| `reasoning` | 候选侧多出的 `reasoning_delta` | Python 的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,故从不发该事件;Node 直接读原始 delta,属能力增强,前端已完整消费 | +| `reasoning` | 候选侧多出的 `reasoning_delta` 事件 | Python 的 `langchain-openai` 1.2.2 不把 provider 的 `reasoning_content` 透出到 `additional_kwargs`,故从不发该事件;Node 直接读原始 delta,属能力增强,前端已完整消费 | +| `proposal-write` | `tool_result` 事件的 `result` 字段 | 提案免确认改造:文件写入由「等用户 Keep All 才落盘」改为「直接落盘 + 可撤销」,工具结果文案随之从「文件尚未写入磁盘…」改为「内容已直接写入磁盘(无需确认)…可撤销」 | +| `proposal-edit` | 同上 | 同上 | 新增豁免时必须同时更新 `docs/agent-protocol.md` 的「已记录的差异」小节,并在 PR 说明中给出理由。 +可随时用负向测试验证 gate 未被削弱:篡改任一**非豁免**字段(如 `text_delta.content`) +或删除一个事件,diff 必须报错。 + ## 场景与实现阶段的对应 抓取覆盖 11 个场景。迁移过程中某些场景会因对应阶段尚未实现而失败,属预期: diff --git a/scripts/agent-baseline/diffSse.mjs b/scripts/agent-baseline/diffSse.mjs index f20ea83..ef3a8ac 100644 --- a/scripts/agent-baseline/diffSse.mjs +++ b/scripts/agent-baseline/diffSse.mjs @@ -41,13 +41,20 @@ const ONLY = argOf('--only', '') /** * 已记录的、有意为之的差异。 * - * 只豁免**明确列出的**事件类型,其余任何偏差仍会导致失败——避免这个 gate - * 因为「整场景跳过」而失效。 + * 支持两种粒度的豁免,且**只豁免明确列出的内容**,其余任何偏差仍会导致失败 + * —— 避免这个 gate 因为「整场景跳过」而失效。 * - * `reasoning`:Python 侧(langchain-openai 1.2.2)不会把 provider 的 - * `reasoning_content` 透出到 `additional_kwargs`,因此从不发 `reasoning_delta`; - * Node 侧直接读原始 delta,会正常透出。这是**能力增强**(前端 `agentChatStore` - * 已完整消费该事件),保留而不回退。 + * 1. `extraEventTypes`:允许候选侧多出(基准侧没有)的事件类型。 + * 2. `fieldOverrides`:某事件类型下**指定字段**的差异被忽略(其余字段仍严格比对)。 + * + * - `reasoning`:Python 侧(langchain-openai 1.2.2)不会把 provider 的 + * `reasoning_content` 透出到 `additional_kwargs`,因此从不发 `reasoning_delta`; + * Node 侧直接读原始 delta,会正常透出。这是**能力增强**(前端 `agentChatStore` + * 已完整消费该事件),保留而不回退。 + * - `proposal-write` / `proposal-edit`:提案免确认改造后,文件写入由「等用户 + * Keep All 才落盘」改为「直接落盘 + 可撤销」,因此工具结果里的文案从 + * 「文件尚未写入磁盘;用户确认(Keep All)后才会落盘」改为「已直接写入磁盘 + * (无需确认)…可撤销」。事件类型、顺序、条数与其余字段仍逐字校验。 */ const KNOWN_DIVERGENCES = { reasoning: { @@ -55,19 +62,72 @@ const KNOWN_DIVERGENCES = { extraEventTypes: ['reasoning_delta'], note: 'Node 正常透出 reasoning_content;Python 受 langchain-openai 限制未透出', }, + 'proposal-write': { + fieldOverrides: [ + { + type: 'tool_result', + field: 'result', + note: '免确认改造:文件写入工具结果文案改为「已直接落盘、可撤销」', + }, + ], + }, + 'proposal-edit': { + fieldOverrides: [ + { + type: 'tool_result', + field: 'result', + note: '免确认改造:文件写入工具结果文案改为「已直接落盘、可撤销」', + }, + ], + }, }; /** 按已记录的豁免过滤候选事件(仅移除明确列出的类型)。 */ function applyKnownDivergences(id, baseline, candidate) { const rule = KNOWN_DIVERGENCES[id]; if (!rule) return { candidate, applied: null }; + const extraTypes = rule.extraEventTypes ?? []; + if (extraTypes.length === 0) return { candidate, applied: null }; const before = candidate.length; - const filtered = candidate.filter( - (event) => !rule.extraEventTypes.includes(event?.type), - ); + const filtered = candidate.filter((event) => !extraTypes.includes(event?.type)); return { candidate: filtered, - applied: before === filtered.length ? null : `${rule.note}(移除 ${before - filtered.length} 条)`, + applied: + before === filtered.length + ? null + : `${rule.note}(移除 ${before - filtered.length} 条)`, + }; +} + +/** 某场景下指定事件类型的豁免字段。 */ +function exemptedFields(id, eventType) { + const rule = KNOWN_DIVERGENCES[id]; + if (!rule?.fieldOverrides) return []; + return rule.fieldOverrides + .filter((o) => o.type === eventType) + .map((o) => o.field); +} + +/** + * 剔除已豁免的字段(两侧同时剔除后再比较),使其余字段仍受严格校验。 + * + * @returns `{ events, note }`;有字段被剔除时 note 非空 + */ +function stripExemptedFields(id, events) { + const notes = new Set(); + const stripped = events.map((event) => { + const fields = exemptedFields(id, event?.type); + if (fields.length === 0) return event; + const copy = { ...event }; + for (const field of fields) delete copy[field]; + for (const override of KNOWN_DIVERGENCES[id].fieldOverrides) { + if (override.type === event?.type) notes.add(override.note); + } + return copy; + }); + return { + events: stripped, + note: notes.size > 0 ? [...notes].join(';') : null, }; } @@ -171,11 +231,15 @@ function main() { a, b, ); - const diffs = diffScenario(id, a, bFiltered); + // 字段级豁免:两侧同时剔除后再比较,其余字段仍严格校验 + const aStripped = stripExemptedFields(id, a); + const bStripped = stripExemptedFields(id, bFiltered); + const diffs = diffScenario(id, aStripped.events, bStripped.events); if (diffs.length === 0) { passed += 1; if (!QUIET) { - const suffix = appliedNote ? ` [已记录差异: ${appliedNote}]` : ''; + const note = appliedNote ?? bStripped.note ?? aStripped.note; + const suffix = note ? ` [已记录差异: ${note}]` : ''; console.log(`✓ ${id} (${a.length} events)${suffix}`); } } else { diff --git a/src/main/agent/context/contextService.ts b/src/main/agent/context/contextService.ts index c709154..1797a66 100644 --- a/src/main/agent/context/contextService.ts +++ b/src/main/agent/context/contextService.ts @@ -17,8 +17,8 @@ export const CHAT_SYSTEM_PROMPT = export const RESUME_NEXT_HINT = '客户端工具已结束。不要重复调用刚才同一个 tool_call。' + - '若 proposal_pending=true:提案未 Keep All、未写盘,不要声称已标注/已删除/已写入。' + - '改或删已有标注必须调用 mutate_annotation。' + + '若 proposal_pending=true(标注生成提案):尚未 Keep All、未写盘,不要声称已标注完成。' + + '标注编辑(mutate_annotation)已自动落盘,不要重复调用;如需修正请用 mutate_annotation 定向修改。' + '若还要给其他文件做新标注,再调用 auto_annotate 并传入新的 paths。' + '不要 memory_write progress.md 或 annotated-files.md。'; diff --git a/src/main/agent/tools/fileProposal.ts b/src/main/agent/tools/fileProposal.ts index 41c92d0..552ad8e 100644 --- a/src/main/agent/tools/fileProposal.ts +++ b/src/main/agent/tools/fileProposal.ts @@ -132,9 +132,9 @@ export function writeWorkspaceFileTool( const title = titleFromPath(resolved); const summary = - `已生成文件提案:${relDisplay}。` + - '文件尚未写入磁盘;用户确认(Keep All)后才会落盘。' + - '请勿在回复中声称文件已创建或已保存。'; + `已创建文件:${relDisplay}。` + + '内容已直接写入磁盘(无需确认),用户可在对话中撤销本次修改。' + + '可以在回复中告知用户文件已创建。'; if (pendingProposals) pendingProposals.set(resolved, [null, content]); @@ -254,8 +254,8 @@ export function strReplaceWorkspaceFileTool( const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); const title = titleFromPath(resolved); const summary = - `已生成补丁提案:${relDisplay}(替换 ${replaceAll ? count : 1} 处)。` + - '文件尚未写入磁盘;用户确认(Keep All)后才会落盘。'; + `已替换 ${relDisplay} 中的 ${replaceAll ? count : 1} 处内容。` + + '修改已直接写入磁盘(无需确认),用户可在对话中撤销本次修改。'; return buildToolResult({ ok: true, @@ -305,8 +305,8 @@ export function deleteWorkspaceFileTool( const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); const title = `删除 ${path.basename(resolved)}`; const summary = - `已生成删除提案:${relDisplay}。` + - '文件尚未删除;用户确认(Keep All)后才会从磁盘移除。'; + `已删除 ${relDisplay}。` + + '删除已直接生效(无需确认),用户可在对话中撤销本次修改。'; if (pendingProposals) pendingProposals.delete(resolved); @@ -379,8 +379,8 @@ export function moveWorkspaceFileTool( const newDisplay = relativeDisplayPath(clientContext, target, newRaw); const title = `移动 ${path.basename(resolved)}`; const summary = - `已生成移动/重命名提案:${oldDisplay} → ${newDisplay}。` + - '文件尚未改动;用户确认(Keep All)后才会在磁盘执行。'; + `已移动/重命名:${oldDisplay} → ${newDisplay}。` + + '变更已直接生效(无需确认),用户可在对话中撤销本次修改。'; if (pendingProposals) { pendingProposals.delete(resolved); diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index b1dfcbe..ebdecaf 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -14,8 +14,8 @@ * 不在每个工具描述中重复。 * * 3. **执行器类型(kind)是三方共享的单一真源**。`sync` 在运行时内执行; - * `proposal` 由运行时生成提案、Electron 在用户确认后落盘;`async` 暂停循环、 - * 由 Electron 执行后 resume(见协议文档 §5.1)。 + * `proposal` 由运行时生成提案事件、Electron **直接落盘**(免确认,可撤销); + * `async` 暂停循环、由 Electron 执行后 resume(见协议文档 §5.1)。 */ import { z } from 'zod'; @@ -500,7 +500,7 @@ export function buildTools(): ToolDefinition[] { name: 'write_workspace_file', description: '在工作区内创建或覆写文本/代码文件(如 .md .py .ts)。' + - '生成提案,用户 Keep All 后才落盘。' + + '内容会直接写入磁盘,无需用户确认;用户可在对话中撤销。' + '只用于工作区文档与代码,不能用来保存标注。' + '删文件请用 delete_workspace_file,不要写入空内容;' + '移动/重命名文件请用 move_workspace_file。', @@ -535,8 +535,8 @@ export function buildTools(): ToolDefinition[] { { name: 'delete_workspace_file', description: - '删除工作区内的文本/代码文件,生成删除提案。' + - '用户确认 Keep All 后才从磁盘移除。不能删除标注。', + '删除工作区内的文本/代码文件。' + + '删除会直接生效,无需用户确认;用户可在对话中撤销。不能删除标注。', kind: 'proposal', argsSchema: DeleteFileArgs, execute: (args, ctx) => @@ -549,8 +549,8 @@ export function buildTools(): ToolDefinition[] { { name: 'move_workspace_file', description: - '移动或重命名工作区内的文本/代码文件,生成移动提案。' + - '用户确认 Keep All 后才在磁盘执行。' + + '移动或重命名工作区内的文本/代码文件。' + + '变更会直接生效,无需用户确认;用户可在对话中撤销。' + '禁止用「读出内容再写到新路径」来移动文件。', kind: 'proposal', argsSchema: MoveFileArgs, diff --git a/src/main/checkpoint/turnCheckpointStore.test.ts b/src/main/checkpoint/turnCheckpointStore.test.ts index defc411..fdf0c22 100644 --- a/src/main/checkpoint/turnCheckpointStore.test.ts +++ b/src/main/checkpoint/turnCheckpointStore.test.ts @@ -139,4 +139,37 @@ describe('turnCheckpointStore', () => { expect(hashContent('abc')).toBe(hashContent('abc')); expect(hashContent('abc')).not.toBe(hashContent('abd')); }); + + /** + * 免确认改造把每会话上限从 20 提到 50:一轮对话内可能有更多次直接落盘, + * 上限过低会把最早、也最可能需要回滚的快照先行清掉。 + * + * 这里只验证「不清掉 40 个」这一关键边界,避免逐次建 51 个快照拖慢测试。 + */ + it('keeps up to 40 checkpoints in a session (limit raised to 50)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ckpt-prune-')); + try { + await writeScopedTextFile(root, 'notes.md', 'v0'); + // 同一 session 下建 40 个不同的 blockIndex 快照 + for (let i = 0; i < 40; i += 1) { + // eslint-disable-next-line no-await-in-loop + await captureCheckpoint( + { ...ref, blockIndex: i }, + 'file', + { workspaceRoot: root }, + { filePaths: ['notes.md'] }, + ); + } + const sessionDir = path.join(userDataDir, 'agent-checkpoints', 'sess-1'); + const msgDirs = await fs.readdir(sessionDir); + let blocks = 0; + for (const msgDir of msgDirs) { + // eslint-disable-next-line no-await-in-loop + blocks += (await fs.readdir(path.join(sessionDir, msgDir))).length; + } + expect(blocks).toBe(40); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/main/checkpoint/turnCheckpointStore.ts b/src/main/checkpoint/turnCheckpointStore.ts index e43750d..f7d782b 100644 --- a/src/main/checkpoint/turnCheckpointStore.ts +++ b/src/main/checkpoint/turnCheckpointStore.ts @@ -14,7 +14,14 @@ import { } from '../workspace/workspaceWrite'; const ID_SEGMENT = /^[A-Za-z0-9._-]+$/; -const MAX_CHECKPOINTS_PER_SESSION = 20; +/** + * 每会话保留的 checkpoint 上限。 + * + * 免确认改造后,文件写入与标注编辑会直接落盘(不再等用户逐次确认), + * 一轮对话内可能产生更多 checkpoints。上限过低会让 `pruneSession` 把 + * **最早、也最可能需要回滚**的那些快照清掉,因此从 20 提高到 50。 + */ +const MAX_CHECKPOINTS_PER_SESSION = 50; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export type CheckpointKind = 'annotation' | 'file'; diff --git a/src/renderer/components/agent/AgentAssistantMessage.tsx b/src/renderer/components/agent/AgentAssistantMessage.tsx index 772c266..f4f0992 100644 --- a/src/renderer/components/agent/AgentAssistantMessage.tsx +++ b/src/renderer/components/agent/AgentAssistantMessage.tsx @@ -36,6 +36,7 @@ import AgentFileChangeBlock from './AgentFileChangeBlock'; import AgentAnnotationChangeBlock from './AgentAnnotationChangeBlock'; import AgentFilesChangedSummary from './AgentFilesChangedSummary'; import { shouldSkipRedundantProposalText } from './agentAssistantRenderUtils'; +import { messageCanUndo } from '../../services/turnCheckpoint'; interface AgentAssistantMessageProps { message: ChatMessage; @@ -146,9 +147,11 @@ export default function AgentAssistantMessage({ applyingAllPending, dismissAllPendingChanges, dismissingAllPending, + undoAssistantChanges, } = useAgentChat(); const [thoughtCollapsed, setThoughtCollapsed] = useState(true); + const [undoingChanges, setUndoingChanges] = useState(false); const isStreaming = message.status === 'streaming'; // awaiting_confirmation 是第三种活跃态:turn 暂停在 HITL 断点, @@ -304,6 +307,16 @@ export default function AgentAssistantMessage({ dismissAllPendingChanges().catch(() => undefined); }, [dismissAllPendingChanges]); + const handleUndoChanges = useCallback(() => { + setUndoingChanges(true); + undoAssistantChanges(message.id) + .catch(() => undefined) + .finally(() => setUndoingChanges(false)); + }, [message.id, undoAssistantChanges]); + + // 免确认改造后,已落盘的改动靠这里回退;无快照可回滚时不显示入口 + const canUndoChanges = useMemo(() => messageCanUndo(message), [message]); + const renderBlock = (block: MessageBlock, index: number) => { if ((block as { type: string }).type === 'mode_suggestion') { return null; @@ -507,6 +520,18 @@ export default function AgentAssistantMessage({ > + {canUndoChanges ? ( + + ) : null} )} diff --git a/src/renderer/components/agent/AgentFilesChangedSummary.css b/src/renderer/components/agent/AgentFilesChangedSummary.css index f1fba7c..bb88a6d 100644 --- a/src/renderer/components/agent/AgentFilesChangedSummary.css +++ b/src/renderer/components/agent/AgentFilesChangedSummary.css @@ -23,6 +23,13 @@ user-select: none; } +.agent-files-changed__actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .agent-files-changed__review { flex-shrink: 0; padding: 0 4px; @@ -38,6 +45,30 @@ color: var(--vscode-foreground, #cccccc); } +/* 撤销入口:免确认改造后文件写入/标注编辑直接落盘,撤销成为主要回退手段 */ +.agent-files-changed__undo { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding: 0 4px; + border: none; + background: transparent; + color: var(--agent-surface-muted-text); + font-size: inherit; + font-weight: 500; + cursor: pointer; +} + +.agent-files-changed__undo:hover:not(:disabled) { + color: var(--vscode-foreground, #cccccc); +} + +.agent-files-changed__undo:disabled { + opacity: 0.6; + cursor: default; +} + .agent-files-changed__list { display: flex; flex-direction: column; diff --git a/src/renderer/components/agent/AgentFilesChangedSummary.tsx b/src/renderer/components/agent/AgentFilesChangedSummary.tsx index 418a47c..50d441e 100644 --- a/src/renderer/components/agent/AgentFilesChangedSummary.tsx +++ b/src/renderer/components/agent/AgentFilesChangedSummary.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { VscodeIcon } from '@vscode-elements/react-elements'; import type { ChatMessage } from '../../../shared/agentTypes'; import { isFileProposalBlock } from '../../../shared/agentTypes'; import FileTypeIcon from '../FileTypeIcon'; @@ -10,6 +11,7 @@ import { collectMessageChangeItems, type MessageChangeItem, } from '../../services/agentProposalApply'; +import { messageCanUndo } from '../../services/turnCheckpoint'; import { computeFileProposalDiffStats } from '../../utils/workspaceFileRead'; import { useOpenAgentChange } from './useOpenAgentChange'; import './AgentFilesChangedSummary.css'; @@ -33,16 +35,26 @@ function resolveItemStats( export default function AgentFilesChangedSummary({ message, }: AgentFilesChangedSummaryProps) { - const { updateMessageBlocks } = useAgentChat(); + const { updateMessageBlocks, undoAssistantChanges } = useAgentChat(); const { activeProject } = useAnnotation(); const { rootPath } = useApp(); const { openFileProposal, openAnnotationProposal } = useOpenAgentChange(); const [expanded, setExpanded] = useState(false); + const [undoing, setUndoing] = useState(false); const [computedStats, setComputedStats] = useState< Record >({}); const items = useMemo(() => collectMessageChangeItems(message), [message]); + // 仅当本消息的已应用改动**全部**留有改前快照时可撤销(部分缺失会让回滚不完整) + const canUndo = useMemo(() => messageCanUndo(message), [message]); + + const handleUndo = useCallback(() => { + setUndoing(true); + undoAssistantChanges(message.id) + .catch(() => undefined) + .finally(() => setUndoing(false)); + }, [message.id, undoAssistantChanges]); useEffect(() => { const missing = items.filter( @@ -152,13 +164,27 @@ export default function AgentFilesChangedSummary({ {items.length} Files Changed - +
+ + {canUndo ? ( + + ) : null} +
{visible.map((item) => { diff --git a/src/renderer/context/AgentChatContext.tsx b/src/renderer/context/AgentChatContext.tsx index fb330df..8d007d7 100644 --- a/src/renderer/context/AgentChatContext.tsx +++ b/src/renderer/context/AgentChatContext.tsx @@ -69,10 +69,12 @@ import { } from '../services/agentChatStore'; import { applyAllPendingProposals, + applyAutoApplicableProposals, applyProposalRefs, collectPendingProposals, countPendingProposals, dismissPendingProposals, + selectAutoApplicableProposals, } from '../services/agentProposalApply'; import { dispatchMutationsAppliedEvent } from '../services/annotationProposalApply'; import { @@ -206,6 +208,11 @@ interface AgentChatContextValue { const AgentChatContext = createContext(null); +/** 自动应用去重键:同一提案块在会话内的稳定标识。 */ +function refKey(ref: { messageId: string; blockIndex: number }): string { + return `${ref.messageId}#${ref.blockIndex}`; +} + /** * 输入框草稿独立成一个 context: * 它每敲一个字都会变,若挂在 AgentChatContext 的 value 上会让 @@ -2512,6 +2519,123 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { updateMessageBlocks, ]); + /** + * 免确认自动落盘(工作区文件写入 + 标注编辑)。 + * + * 由「提案定稿」这一状态变化驱动,而非侵入 job 循环 —— 这样重启恢复、 + * 历史消息重放等场景同样覆盖,也不依赖事件到达顺序。 + * + * 幂等:应用成功后 block 变 `applied`,不再命中 `selectAutoApplicableProposals`; + * 失败时保持 `pending`,卡片仍可用 Keep All / Undo 手动处理。 + * + * 注意 `selectAutoApplicableProposals` 内部要求文件提案的 `contentFinalized` + * 为真,否则会在内容尚未流式完成时写入空内容。 + */ + const autoApplyingRef = useRef(false); + /** + * 自动应用失败过的提案(`messageId#blockIndex`)。 + * + * 失败后 block 保持 `pending`,若不加去重则每次 state 变化(流式期间很频繁) + * 都会重试并重复弹出错误提示。这里记录后跳过,改由用户通过 Keep All 手动重试 + * —— 手动路径会明确展示失败原因,不会刷提示。 + */ + const autoApplyFailedRef = useRef>(new Set()); + + /** 跳过已尝试自动应用但失败的提案,避免重复弹错。 */ + const isAutoApplySkipped = useCallback( + (ref: { messageId: string; blockIndex: number }): boolean => + autoApplyFailedRef.current.has(refKey(ref)), + [], + ); + + const runAutoApply = useCallback(async (): Promise => { + const sessionId = stateRef.current.activeSessionId; + if (!sessionId) return; + const messagesMap = stateRef.current.messagesBySession[sessionId] ?? {}; + const ids = resolveSessionMessageIds( + stateRef.current.sessions[sessionId], + messagesMap, + ); + const messages = ids + .map((id) => messagesMap[id]) + .filter((message): message is ChatMessage => Boolean(message)); + + const autoRefs = selectAutoApplicableProposals(messages, isAutoApplySkipped); + if (autoRefs.length === 0) return; + + const filePaths = autoRefs.flatMap((ref) => { + const block = messages.find((msg) => msg.id === ref.messageId)?.blocks[ + ref.blockIndex + ]; + return block && isFileProposalBlock(block) + ? [block.suggestedRelativePath] + : []; + }); + + const result = await applyAutoApplicableProposals({ + sessionId, + messages, + project: activeProject ?? null, + workspaceRoot: rootPath, + updateBlock: (messageId, blockIndex, patch) => { + updateMessageBlocks(sessionId, messageId, (blocks) => + blocks.map((b, i) => + i === blockIndex ? ({ ...b, ...patch } as MessageBlock) : b, + ), + ); + }, + onSyncWarning: (message) => { + showToast(message, { type: 'info' }); + }, + isSkipped: isAutoApplySkipped, + }); + + if (result.applied > 0) { + if (result.missingCheckpoints > 0) { + showToast('未能保存改前快照,这些改动将无法撤销', { type: 'info' }); + } + await syncWorkspaceFactMemory(activeProject); + clearFilePreviewSession(); + markWorkspaceTextFilesChanged(filePaths); + } + if (result.errors.length > 0) { + // 标灰这批 ref,避免自动重试刷屏;保留 pending 交由用户手动处理 + for (const ref of autoRefs) autoApplyFailedRef.current.add(refKey(ref)); + showToast(`自动应用失败:${result.errors[0]}`, { type: 'error' }); + } + }, [activeProject, isAutoApplySkipped, rootPath, showToast, updateMessageBlocks]); + + useEffect(() => { + if (autoApplyingRef.current) return; + const sessionId = state.activeSessionId; + if (!sessionId) return; + const messagesMap = state.messagesBySession[sessionId] ?? {}; + const ids = resolveSessionMessageIds( + state.sessions[sessionId], + messagesMap, + ); + const messages = ids + .map((id) => messagesMap[id]) + .filter((message): message is ChatMessage => Boolean(message)); + // 廉价前置判断:无待自动应用的提案(或仅剩已知失败的)时完全不进入异步流程 + if ( + selectAutoApplicableProposals(messages, isAutoApplySkipped).length === 0 + ) { + return; + } + + autoApplyingRef.current = true; + runAutoApply() + .catch((err) => { + showToast(resolveErrorMessage(err, '自动应用变更失败'), { + type: 'error', + }); + }) + .finally(() => { + autoApplyingRef.current = false; + }); + }, [isAutoApplySkipped, state, runAutoApply, showToast]); + const openPanelTabs = useMemo(() => { const sessionTabs: AgentPanelTab[] = state.openTabIds.map((sessionId) => ({ kind: 'session', diff --git a/src/renderer/services/agentChatStore.ts b/src/renderer/services/agentChatStore.ts index 570a48f..35ee102 100644 --- a/src/renderer/services/agentChatStore.ts +++ b/src/renderer/services/agentChatStore.ts @@ -18,7 +18,10 @@ import { } from '../../shared/agentTypes'; import { isLrAgentRelativePath } from '../../shared/workspacePathGuards'; import type { AnnotationBatchProposal } from '../../shared/annotationAgentTypes'; -import { normalizePipelineKindsInBlocks } from './annotationAgent/pipelineKinds'; +import { + inferAnnotationProposalKind, + normalizePipelineKindsInBlocks, +} from './annotationAgent/pipelineKinds'; import { upsertPipelineSteps, buildPipelineStepFromProgressEvent, @@ -375,19 +378,6 @@ export function normalizeHistoricalMessages( return next; } -function inferAnnotationProposalKind( - proposal: AnnotationBatchProposal, -): 'batch' | 'mutation' { - const mutationOps = new Set(['delete', 'patch']); - if ( - proposal.changes.length > 0 && - proposal.changes.every((change) => mutationOps.has(change.operation)) - ) { - return 'mutation'; - } - return 'batch'; -} - function applyAnnotationProgressToBlocks( blocks: MessageBlock[], event: Extract, @@ -1105,8 +1095,7 @@ export function applyStreamEventToBlocks( } if (event.type === 'annotation_proposal') { - const proposalKind = inferAnnotationProposalKind(event.proposal); - for (let i = 0; i < next.length; i += 1) { + const proposalKind = inferAnnotationProposalKind(event.proposal); for (let i = 0; i < next.length; i += 1) { const b = next[i]; if (b.type === 'annotation_pipeline') { next[i] = { diff --git a/src/renderer/services/agentJobRegistry.ts b/src/renderer/services/agentJobRegistry.ts index 078daaa..57ee399 100644 --- a/src/renderer/services/agentJobRegistry.ts +++ b/src/renderer/services/agentJobRegistry.ts @@ -160,12 +160,26 @@ export function formatAnnotationToolResult(options: { fileStats?: AnnotationProposalFileStat[]; omittedCount?: number; omittedPaths?: string[]; + /** + * 提案由前端直接落盘,不进入用户确认流程(免确认改造)。 + * + * 标注编辑(`mutate_annotation`)走这条路径:前端会在收到提案后自动应用, + * 因此这里必须报告 `proposal_pending: false`,否则 job 会错误地停在 + * AwaitingConfirm 断点等一个永远不会发生的用户确认。 + * + * 标注生成(`auto_annotate`)**不传本项**——它仍需用户审阅后 Keep All。 + */ + autoApplied?: boolean; }): string { - const pendingNote = options.hasProposal - ? '已生成待确认提案(未写盘)。' - : options.tool === 'mutate_annotation' - ? '未生成提案,不要对用户说已删除或已修改。' - : '未生成提案,不要对用户说已标注完成。'; + // 只有真的产出了提案才谈得上"已应用/待确认" + const autoApplied = Boolean(options.autoApplied && options.hasProposal); + const pendingNote = autoApplied + ? '已生成变更并直接应用。' + : options.hasProposal + ? '已生成待确认提案(未写盘)。' + : options.tool === 'mutate_annotation' + ? '未生成提案,不要对用户说已删除或已修改。' + : '未生成提案,不要对用户说已标注完成。'; const fileStatsText = options.fileStats?.length ? ` ${formatFileStatsText(options.fileStats)}。` : ''; @@ -175,17 +189,18 @@ export function formatAnnotationToolResult(options: { ANNOTATION_BATCH_MAX_FILES, ); const truncationText = truncationNote ? ` ${truncationNote}` : ''; - const summary = options.hasProposal - ? `${pendingNote}${options.summary}${fileStatsText}${truncationText}` - : `${options.summary} ${pendingNote}${truncationText}`; + const summary = + options.hasProposal || autoApplied + ? `${pendingNote}${options.summary}${fileStatsText}${truncationText}` + : `${options.summary} ${pendingNote}${truncationText}`; return formatClientToolResult({ status: options.status, tool: options.tool, user_request: options.userRequest, summary: summary.trim(), message: summary.trim(), - file_written: false, - proposal_pending: options.hasProposal, + file_written: autoApplied, + proposal_pending: autoApplied ? false : options.hasProposal, files: options.fileStats, }); } @@ -670,6 +685,8 @@ async function runClientTool( summary: mutateResult.summary, hasProposal: mutateResult.hasProposal, fileStats: mutateResult.fileStats, + // 标注编辑免确认:提案随后由前端自动落盘,不进入 AwaitingConfirm 断点 + autoApplied: true, }); } catch { return formatAnnotationToolResult({ diff --git a/src/renderer/services/agentProposalApply.test.ts b/src/renderer/services/agentProposalApply.test.ts index ac51db1..85e5c48 100644 --- a/src/renderer/services/agentProposalApply.test.ts +++ b/src/renderer/services/agentProposalApply.test.ts @@ -3,11 +3,13 @@ import type { AnnotationBatchProposal } from '../../shared/annotationAgentTypes' import { annotationChangeDiffStats, applyAllPendingProposals, + applyAutoApplicableProposals, collectMessageChangeItems, collectPendingChangeItems, collectPendingProposals, countPendingProposals, dismissPendingProposals, + selectAutoApplicableProposals, } from './agentProposalApply'; import { patchAgentMessageBlockRemote } from './agentChatApi'; @@ -371,3 +373,179 @@ describe('dismissPendingProposals', () => { }); }); }); + +/** + * 免确认改造:只有「文件写入/删除/移动」与「标注编辑(mutation)」自动落盘, + * 标注生成(batch)仍需用户确认。 + */ +describe('selectAutoApplicableProposals', () => { + const fileBlock = (overrides: Record = {}) => + ({ + type: 'file_proposal', + title: 'A', + content: 'hello', + suggestedRelativePath: 'a.txt', + status: 'pending', + contentFinalized: true, + ...overrides, + }) as ChatMessage['blocks'][number]; + + const annotationBlock = (sourceKind?: string) => + ({ + type: 'annotation_proposal', + status: 'pending', + sourceKind, + proposal: { + id: 'prop-1', + projectId: 'p1', + summary: 's', + changes: [{ relativePath: 'data/1.jpg', operation: 'delete', deleteIds: ['a'] }], + }, + }) as unknown as ChatMessage['blocks'][number]; + + it('选中内容已定稿的文件提案', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [fileBlock()]), + ]); + expect(refs).toEqual([{ messageId: 'msg-1', blockIndex: 0, kind: 'file' }]); + }); + + it('跳过尚未定稿的文件提案(避免写入空内容)', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [fileBlock({ contentFinalized: false })]), + ]); + expect(refs).toEqual([]); + }); + + it('跳过缺失 contentFinalized 的旧数据(保守处理)', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [fileBlock({ contentFinalized: undefined })]), + ]); + expect(refs).toEqual([]); + }); + + it('选中 sourceKind=mutation 的标注提案', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [annotationBlock('mutation')]), + ]); + expect(refs).toEqual([ + { messageId: 'msg-1', blockIndex: 0, kind: 'annotation' }, + ]); + }); + + it('跳过 sourceKind=batch 的标注提案(标注生成仍需确认)', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [annotationBlock('batch')]), + ]); + expect(refs).toEqual([]); + }); + + it('sourceKind 缺失时按内容推断:仅 delete/patch 视为标注编辑', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [annotationBlock(undefined)]), + ]); + expect(refs).toEqual([ + { messageId: 'msg-1', blockIndex: 0, kind: 'annotation' }, + ]); + }); + + it('非 pending 的提案一律跳过', () => { + const refs = selectAutoApplicableProposals([ + assistantMessage('msg-1', [ + fileBlock({ status: 'applied' }), + fileBlock({ status: 'dismissed' }), + ]), + ]); + expect(refs).toEqual([]); + }); + + it('忽略非 assistant 消息', () => { + const userMessage = { + ...assistantMessage('msg-1', [fileBlock()]), + role: 'user' as const, + }; + expect(selectAutoApplicableProposals([userMessage])).toEqual([]); + }); + + it('isSkipped 谓词可排除已失败的提案', () => { + const refs = selectAutoApplicableProposals( + [assistantMessage('msg-1', [fileBlock()])], + (ref) => ref.blockIndex === 0, + ); + expect(refs).toEqual([]); + }); +}); + +describe('applyAutoApplicableProposals', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('只应用免确认的子集,不触碰标注生成提案', async () => { + const messages = [ + assistantMessage('msg-1', [ + { + type: 'file_proposal', + title: 'A', + content: 'hello', + suggestedRelativePath: 'a.txt', + status: 'pending', + contentFinalized: true, + }, + { + type: 'annotation_proposal', + status: 'pending', + sourceKind: 'batch', + proposal: { + id: 'prop-1', + projectId: 'p1', + summary: 's', + changes: [{ relativePath: 'data/1.jpg', operation: 'append', annotations: [{}] }], + }, + }, + ] as unknown as ChatMessage['blocks']), + ]; + + const updateBlock = jest.fn(); + const result = await applyAutoApplicableProposals({ + sessionId: 'sess-1', + messages, + project: null, + workspaceRoot: '/tmp/project', + updateBlock, + }); + + expect(result.applied).toBe(1); + // 只更新了文件提案那一条,标注生成仍保持 pending + expect(updateBlock).toHaveBeenCalledTimes(1); + expect(updateBlock.mock.calls[0][1]).toBe(0); + }); + + it('无可自动应用提案时不写盘', async () => { + const messages = [ + assistantMessage('msg-1', [ + { + type: 'annotation_proposal', + status: 'pending', + sourceKind: 'batch', + proposal: { + id: 'prop-1', + projectId: 'p1', + summary: 's', + changes: [{ relativePath: 'data/1.jpg', operation: 'append', annotations: [{}] }], + }, + }, + ] as unknown as ChatMessage['blocks']), + ]; + + const result = await applyAutoApplicableProposals({ + sessionId: 'sess-1', + messages, + project: null, + workspaceRoot: '/tmp/project', + updateBlock: jest.fn(), + }); + + expect(result).toEqual({ applied: 0, errors: [], missingCheckpoints: 0 }); + }); +}); diff --git a/src/renderer/services/agentProposalApply.ts b/src/renderer/services/agentProposalApply.ts index f12667a..7fae025 100644 --- a/src/renderer/services/agentProposalApply.ts +++ b/src/renderer/services/agentProposalApply.ts @@ -17,6 +17,7 @@ import { getAnnotationWorkspaceAgentSnapshot } from './annotationAgentBridge'; import { isAgentDocumentWriteEnabled } from './agentFeatureFlags'; import { patchAgentMessageBlockRemote } from './agentChatApi'; import { markWorkspaceTextFilesChanged } from './agentFilePreviewStore'; +import { inferAnnotationProposalKind } from './annotationAgent/pipelineKinds'; import { captureProposalCheckpoint, discardProposalCheckpoint, @@ -312,6 +313,108 @@ export function countPendingProposals(messages: ChatMessage[]): number { return collectPendingProposals(messages).length; } +/** + * 筛选出**可以自动应用**的待确认提案(免确认改造)。 + * + * 分层的依据是「结果可审阅性 × 错误代价」,而不是「是否文件改写」: + * + * - `file_proposal`(工作区文件写入/替换/删除/移动):diff 可扫读、指令明确、 + * 错误代价低 → 直接落盘,用户事后可撤销。 + * - `annotation_proposal` 且 `sourceKind === 'mutation'`(标注编辑):同样是指令 + * 明确的定点修改,且有 checkpoint 兜底 → 直接落盘。 + * - `annotation_proposal` 且来源为批量标注(`batch` 或未设置):**必须保留确认**。 + * 它由预训练模型推理产出,审阅需逐框看图、`replace_matching` 还会覆盖已有标注, + * 错误代价最高。 + * + * 返回的 refs 可直接交给 `applyProposalRefs` —— 它已支持在一个循环里同时处理两类, + * 且内部保证「捕获改前快照 → 写盘 → 记录 afterHash」的顺序。 + */ +export function selectAutoApplicableProposals( + messages: ChatMessage[], + /** + * 可选的跳过谓词。用于排除「已尝试自动应用但失败」的提案, + * 避免流式期间每次状态变化都重试并重复弹错。 + */ + isSkipped?: (ref: PendingProposalRef) => boolean, +): PendingProposalRef[] { + const refs: PendingProposalRef[] = []; + for (const msg of messages) { + if (msg.role !== 'assistant') continue; + msg.blocks.forEach((block, blockIndex) => { + if (block.type === 'annotation_proposal' && block.status === 'pending') { + // 仅标注编辑自动落盘。`sourceKind` 优先;历史数据缺失该字段时 + // 按提案内容回退推断(只含 delete/patch 即标注编辑), + // 与 `agentChatStore` 设置 sourceKind 的判据保持同一份实现。 + const kind = + block.sourceKind ?? inferAnnotationProposalKind(block.proposal); + if (kind !== 'mutation') return; + const ref: PendingProposalRef = { + messageId: msg.id, + blockIndex, + kind: 'annotation', + }; + if (isSkipped?.(ref)) return; + refs.push(ref); + } else if (isFileProposalBlock(block) && block.status === 'pending') { + // 必须等 content 定稿:`file_proposal_start` 阶段 content 尚为空串且 + // `contentFinalized` 为 false,此时落盘会写入空内容 / 半截内容。 + // 缺失该字段的历史数据一律视为未定稿(保守),留给用户手动确认。 + if (!block.contentFinalized) return; + const ref: PendingProposalRef = { + messageId: msg.id, + blockIndex, + kind: 'file', + }; + if (isSkipped?.(ref)) return; + refs.push(ref); + } + }); + } + return refs; +} + +/** 是否存在需要自动应用的提案(供触发侧做廉价的空判断)。 */ +export function hasAutoApplicableProposals(messages: ChatMessage[]): boolean { + return selectAutoApplicableProposals(messages).length > 0; +} + +/** + * 自动应用可免确认的提案。 + * + * 与 `applyAllPendingProposals` 的区别:只挑选 `selectAutoApplicableProposals` + * 认定的子集,不触碰等用户确认的批量标注提案。 + * + * 失败时**不吞异常**:调用方需要据此保持 block 为 pending 并提示用户, + * 避免「以为已落盘、实际没写」的静默错误。 + */ +export async function applyAutoApplicableProposals(options: { + sessionId: string; + messages: ChatMessage[]; + project: AnnotationProject | null; + workspaceRoot?: string | null; + updateBlock: ( + messageId: string, + blockIndex: number, + patch: Partial, + ) => void; + onSyncWarning?: (message: string) => void; + /** 跳过谓词,透传给 `selectAutoApplicableProposals`。 */ + isSkipped?: (ref: PendingProposalRef) => boolean; +}): Promise<{ + applied: number; + errors: string[]; + missingCheckpoints: number; +}> { + const refs = selectAutoApplicableProposals( + options.messages, + options.isSkipped, + ); + if (refs.length === 0) { + return { applied: 0, errors: [], missingCheckpoints: 0 }; + } + return applyProposalRefs({ ...options, refs }); +} + export function dismissPendingProposals(options: { sessionId: string; messages: ChatMessage[]; diff --git a/src/renderer/services/annotationAgent/pipelineKinds.ts b/src/renderer/services/annotationAgent/pipelineKinds.ts index 47a23c5..8f58aae 100644 --- a/src/renderer/services/annotationAgent/pipelineKinds.ts +++ b/src/renderer/services/annotationAgent/pipelineKinds.ts @@ -1,5 +1,30 @@ import type { MessageBlock, PipelineKind } from '../../../shared/agentTypes'; import { isFileProposalBlock } from '../../../shared/agentTypes'; +import type { AnnotationBatchProposal } from '../../../shared/annotationAgentTypes'; + +/** + * 按提案内容推断来源类型。 + * + * 判定依据:标注编辑(`mutate_annotation`)只产生 `delete` / `patch`; + * 批量标注(`auto_annotate`)使用 `append` / `replace` / `replace_bboxes`。 + * + * 该推断同时用于两处: + * - 给提案块设置 `sourceKind`(`agentChatStore`) + * - 免确认改造中筛选可自动应用的提案(`agentProposalApply`) + * 因此放在本模块共用,避免两处逻辑漂移。 + */ +export function inferAnnotationProposalKind( + proposal: AnnotationBatchProposal, +): 'batch' | 'mutation' { + const mutationOps = new Set(['delete', 'patch']); + if ( + proposal.changes.length > 0 && + proposal.changes.every((change) => mutationOps.has(change.operation)) + ) { + return 'mutation'; + } + return 'batch'; +} export const PIPELINE_TITLES: Record< PipelineKind, diff --git a/src/renderer/services/annotationMutationBatchJob.test.ts b/src/renderer/services/annotationMutationBatchJob.test.ts index c264aca..a911f7f 100644 --- a/src/renderer/services/annotationMutationBatchJob.test.ts +++ b/src/renderer/services/annotationMutationBatchJob.test.ts @@ -7,7 +7,10 @@ import type { StreamEvent } from '../../shared/agentTypes'; import { runAnnotationMutationJob } from './annotationAgent/mutationOrchestrator'; import type { MutationProgressEvent } from './annotationAgent/mutationOrchestrator'; import { startAnnotationMutationJob } from './annotationMutationBatchJob'; -import { formatAnnotationToolResult } from './agentJobRegistry'; +import { + formatAnnotationToolResult, + toolResultHasPendingProposal, +} from './agentJobRegistry'; jest.mock('./annotationAgent/mutationOrchestrator', () => ({ runAnnotationMutationJob: jest.fn(), @@ -196,4 +199,52 @@ describe('formatAnnotationToolResult', () => { expect(String(parsed.summary)).toContain('data/101.jpg'); expect(String(parsed.summary)).toContain('可再调用 auto_annotate'); }); + + // 免确认改造:标注编辑(mutate_annotation)自动落盘,不再进入确认断点 + it('reports auto-applied for annotation edits (no confirm breakpoint)', () => { + const raw = formatAnnotationToolResult({ + status: 'completed', + tool: 'mutate_annotation', + userRequest: '删框', + summary: '删除无标签框', + hasProposal: true, + autoApplied: true, + }); + const parsed = JSON.parse(raw) as Record; + expect(parsed.proposal_pending).toBe(false); + expect(parsed.file_written).toBe(true); + expect(String(parsed.summary)).toContain('已生成变更并直接应用'); + // 不再出现「未写盘」这类会误导模型的说法 + expect(String(parsed.summary)).not.toContain('未写盘'); + // 关键:不得触发 AwaitingConfirm 断点 + expect(toolResultHasPendingProposal(raw)).toBe(false); + }); + + it('autoApplied 但未产出提案时仍报告未写盘', () => { + const raw = formatAnnotationToolResult({ + status: 'skipped', + tool: 'mutate_annotation', + userRequest: '删框', + summary: '未能识别', + hasProposal: false, + autoApplied: true, + }); + const parsed = JSON.parse(raw) as Record; + expect(parsed.proposal_pending).toBe(false); + expect(parsed.file_written).toBe(false); + expect(String(parsed.summary)).toContain('不要对用户说已删除'); + }); + + it('标注生成仍进入确认断点(autoApplied 不传)', () => { + const raw = formatAnnotationToolResult({ + status: 'completed', + tool: 'auto_annotate', + userRequest: '标注', + summary: '生成 3 个框', + hasProposal: true, + }); + const parsed = JSON.parse(raw) as Record; + expect(parsed.proposal_pending).toBe(true); + expect(toolResultHasPendingProposal(raw)).toBe(true); + }); }); diff --git a/src/shared/agentTypes.ts b/src/shared/agentTypes.ts index 5225a80..4839432 100644 --- a/src/shared/agentTypes.ts +++ b/src/shared/agentTypes.ts @@ -10,7 +10,7 @@ export enum JobState { Streaming = 'streaming', ToolPending = 'tool_pending', Resuming = 'resuming', - /** 提案已生成、等待用户 Keep All/Dismiss;确认后自动续跑 */ + /** 仅标注生成提案需用户 Keep All/Dismiss;确认后自动续跑(文件写入与标注编辑免确认) */ AwaitingConfirm = 'awaiting_confirm', Done = 'done', Error = 'error', @@ -327,7 +327,7 @@ export type StreamEvent = toolCalls: ClientToolCall[]; } | { - /** 客户端工具已生成待确认提案,job 暂停等待 Keep All/Dismiss */ + /** 存在待确认的标注生成提案,job 暂停等待 Keep All/Dismiss(文件写入与标注编辑免确认) */ type: 'awaiting_confirmation'; } | { From dadc93d30f1e1a744e3811a7f8e2120ffc64ee88 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 13:46:53 +0800 Subject: [PATCH 15/20] =?UTF-8?q?fix(agent):=20=E8=87=AA=E5=8A=A8=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E6=94=B9=E4=B8=BA=E5=B7=A5=E5=85=B7=E5=86=85=20await?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=B8=85=E7=90=86=E8=AF=AF=E5=AF=BC=E6=80=A7?= =?UTF-8?q?=E7=9A=84=20Keep=20All=20=E6=8E=AA=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 竞态修复(方案 B):标注工具是 ASYNC,每次调用会发 tool_pending 并 resume;resume 前会同步构建 proposal_states,若此时提案仍是 pending,阶段机会误判 AWAIT_CONFIRM,把紧随其后的 auto_annotate(重新标注)拦掉——表现为「删除已落盘、但重新标注被拒」。改为 ClientToolContext 提供 applyAutoApplicable 回调,客户端工具在产出提案后 await 它完成落盘,再据实组合工具结果(成功→proposal_pending=false;失败→保持 true 并如实报告,job 停在确认断点等用户处理,绝不谎报已写盘)。AgentChatContext 中的 effect 退化为兜底(覆盖重启恢复与历史重放),并把落盘逻辑提为 flushAutoApplicable 供两条路径共用。 2) 措辞清理:模型此前会提到 Keep All,因为 RESUME_APPLIED_NEXT_HINT 声称「用户已 Keep All」、VERIFY 阶段提示写「已由用户 Keep All 并写盘」——但免确认后落盘由系统完成。改为「改动已落盘/标注变更已落盘」,AWAIT_CONFIRM 文案明确指向「标注生成提案」(该断点现在只由标注生成与终端审批触发)。同步修正 4 处「经用户确认落盘」与若干过时注释。 3) UI:删除 Files Changed 卡片里重复的撤销入口,只保留消息工具栏的撤销图标。 验证:tsc 通过;jest 979/979(新增自动落盘失败与优先级 2 例,修正 1 处措辞断言);golden diff 11/11。 Co-authored-by: Cursor --- docs/agent-protocol.md | 32 ++- src/main/agent/context/contextService.ts | 21 +- src/main/agent/loop/assistLoop.test.ts | 2 +- src/main/agent/loop/assistLoop.ts | 2 +- src/main/agent/loop/taskPhase.ts | 24 +- src/main/agent/tools/fileProposal.ts | 2 +- src/main/agent/tools/workspacePath.ts | 2 +- .../agent/AgentFilesChangedSummary.css | 31 --- .../agent/AgentFilesChangedSummary.tsx | 44 +-- src/renderer/context/AgentChatContext.tsx | 258 ++++++++++-------- src/renderer/services/agentJobRegistry.ts | 92 +++++-- .../annotationMutationBatchJob.test.ts | 35 +++ 12 files changed, 318 insertions(+), 227 deletions(-) diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index b2747b4..ba75637 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -720,13 +720,39 @@ Agent 工具集之外(理由见 §5.3 注释)。Python 侧 `fn_map` 只包 2. **`sourceKind` 优先,内容推断回退**:历史数据可能缺 `sourceKind`,此时按提案内容推断 (只含 `delete`/`patch` 即标注编辑),与 `agentChatStore` 设置 `sourceKind` 用的是 **同一份实现**(`pipelineKinds.ts` 的 `inferAnnotationProposalKind`)。 -3. **失败保持 `pending`**:写盘失败时不静默吞掉,block 仍为待确认态,卡片可用 - Keep All / Undo 手动处理;同时标记该 ref 以免流式期间反复重试刷提示。 +3. **失败保持 `pending`**:写盘失败时不静默吞掉,block 仍为待确认态,可手动处理; + 同时标记该 ref 以免流式期间反复重试刷提示。 4. **撤销是一等公民**:每次落盘前都会捕获改前快照(`captureProposalCheckpoint`), - 因此可直接撤销。撤销入口两处:消息的 Files Changed 汇总卡右上角、消息工具栏。 + 因此可直接撤销。撤销入口位于**消息工具栏**的撤销图标(仅当本消息全部已应用改动 + 都留有快照时显示)。 **因此 `awaiting_confirmation` 只由标注生成(`auto_annotate`)与终端审批触发。** +#### 落盘时机:客户端工具内 await,而非依赖渲染时序 + +标注工具是 `ASYNC`——每次调用都会发 `tool_pending` 结束 HTTP 轮次,随后 resume。 +**落盘必须发生在 resume 之前**,否则会踩到一个竞态: + +``` +tool_pending → 渲染层执行 → 产出提案 block(status=pending) + → resume 前同步构建 proposal_states → 阶段机误判 AWAIT_CONFIRM + → 模型随后调用的 auto_annotate(重新标注)被门禁拦掉 +``` + +(表现为「删除已落盘,但重新标注被拒」,同时工具结果却声称已应用——自相矛盾。) + +因此 `ClientToolContext` 提供 `applyAutoApplicable` 回调,客户端工具在产出提案后 +**`await` 它完成落盘**,再据实组合工具结果: + +| 情形 | `proposal_pending` | `file_written` | 是否进入确认断点 | +|---|---|---|---| +| 落盘成功 | `false` | `true` | 否 | +| 落盘失败 | `true` | `false` | **是**(用户手动处理) | +| `auto_annotate`(不自动落盘) | `true` | `false` | **是** | + +`AgentChatContext` 里的 effect 退化为**兜底**:覆盖重启恢复、历史消息重放等 +没有真实工具调用的场景;主路径已处理的提案会变 `applied`,effect 直接空转返回。 + ### 6.0 已记录的差异 以下差异是**有意保留**的,不属于回归。`scripts/agent-baseline/diffSse.mjs` 的 diff --git a/src/main/agent/context/contextService.ts b/src/main/agent/context/contextService.ts index 1797a66..37655ac 100644 --- a/src/main/agent/context/contextService.ts +++ b/src/main/agent/context/contextService.ts @@ -17,18 +17,22 @@ export const CHAT_SYSTEM_PROMPT = export const RESUME_NEXT_HINT = '客户端工具已结束。不要重复调用刚才同一个 tool_call。' + - '若 proposal_pending=true(标注生成提案):尚未 Keep All、未写盘,不要声称已标注完成。' + - '标注编辑(mutate_annotation)已自动落盘,不要重复调用;如需修正请用 mutate_annotation 定向修改。' + + '若 proposal_pending=true(标注生成提案):尚未确认、未写盘,不要声称已标注完成;' + + '用一两句说明提案内容,提示用户在提案卡片上确认后才会落盘。' + + '标注编辑(mutate_annotation)与文件写入已自动落盘,不要重复调用;' + + '如需修正请用对应工具再次修改(用户可撤销)。' + '若还要给其他文件做新标注,再调用 auto_annotate 并传入新的 paths。' + '不要 memory_write progress.md 或 annotated-files.md。'; export const RESUME_APPLIED_NEXT_HINT = - '客户端工具已结束,用户已 Keep All:提案已写盘,file_written=true。' + + '客户端工具已结束,改动已落盘(file_written=true):' + + '标注编辑与文件写入由系统直接应用,无需用户确认;' + + '标注生成提案则是用户已确认后落盘。' + '可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。' + '报告中的每个数字必须来自工具返回或提案明细,禁止估算。' + - '不要对已写盘文件重复调用 auto_annotate 整文件重标;' + + '不要对已落盘文件重复调用 auto_annotate 整文件重标;' + '核对发现错标/漏标/重复框时,用 mutate_annotation 定向修正(带上 paths 或 annotation_ids)。' + - '提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。' + + '禁止再要求用户确认或查看提案。' + '不要 memory_write progress.md 或 annotated-files.md。'; export interface ClientToolResultInput { @@ -61,8 +65,11 @@ export function unansweredToolCallIds(messages: ChatMessage[]): Set { /** * 为 resume 的客户端工具结果补上 `next_hint`(已有则保留)。 * - * `proposalsApplied`(用户已 Keep All)时同步修正 `proposal_pending` / `file_written`, - * 避免模型把已落盘提案误判为未确认。 + * `proposalsApplied`(阶段为 VERIFY,即相关提案已落盘)时同步修正 + * `proposal_pending` / `file_written`,避免模型把已落盘提案误判为未确认。 + * + * 注意:落盘既可能来自用户确认(标注生成),也可能来自系统自动应用 + * (文件写入 / 标注编辑免确认),因此提示词不应声称「用户已 Keep All」。 */ export function enrichResumeToolResult( result: string, diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts index fc45a76..c25e049 100644 --- a/src/main/agent/loop/assistLoop.test.ts +++ b/src/main/agent/loop/assistLoop.test.ts @@ -401,7 +401,7 @@ describe('阶段门禁与工具集', () => { ); const toolResult = events.find((e) => e.type === 'tool_result'); - expect(toolResult?.result).toContain('未确认的标注提案'); + expect(toolResult?.result).toContain('未确认的标注生成提案'); }); it('模式路由:Ask 模式剔除写入工具', () => { diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 4f64532..7e31b30 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -238,7 +238,7 @@ async function* streamAssistInner( const clientToolResults = params.clientToolResults ?? []; const isResume = clientToolResults.length > 0; - // 阶段为 verify 表示用户已 Keep All、提案已落盘 + // 阶段为 verify 表示标注变更已落盘(系统自动应用或用户确认) const proposalsApplied = params.taskPhaseContext?.phase === 'verify'; if (isResume) { diff --git a/src/main/agent/loop/taskPhase.ts b/src/main/agent/loop/taskPhase.ts index 8e59769..d3515ef 100644 --- a/src/main/agent/loop/taskPhase.ts +++ b/src/main/agent/loop/taskPhase.ts @@ -175,9 +175,9 @@ export function checkCallAllowed( if (ANNOTATION_WRITE_TOOLS.has(name) || WORKSPACE_WRITE_TOOLS.has(name)) { const pending = [...ctx.pendingAnnotationPaths].sort().join('、'); return ( - `当前存在未确认的标注提案(${pending}),用户尚未 Keep All,提案未写盘。` + + `当前存在未确认的标注生成提案(${pending}),尚未写盘。` + '禁止标注写入与文件写入操作。请用一两句说明提案内容,' + - '提示用户 Keep All 或关闭提案后再继续。' + '提示用户在提案卡片上确认(或关闭提案)后再继续。' ); } return null; @@ -189,7 +189,7 @@ export function checkCallAllowed( if (coerceBool(arguments_.all_files)) { return ( - `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + `以下文件本轮已完成标注并已落盘:${appliedText}。` + '禁止 all_files=true 的全量重标;如确需对其他文件标注,请用 paths 明确指定。' ); } @@ -199,7 +199,7 @@ export function checkCallAllowed( if (name === 'auto_annotate') { if (callPaths.size === 0) { return ( - `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + `以下文件本轮已完成标注并已落盘:${appliedText}。` + 'auto_annotate 未指明 paths/scope_hint,无法确认是否重复标注已落盘文件,' + '请用 paths 明确指定要标注的新文件。' ); @@ -209,7 +209,7 @@ export function checkCallAllowed( ); if (overlap.length > 0) { return ( - `以下文件本轮已完成标注并经用户确认落盘:${overlap.sort().join('、')}。` + + `以下文件本轮已完成标注并已落盘:${overlap.sort().join('、')}。` + '请勿重复标注。如核对后认为确需重标,必须向用户说明检测到的不一致' + '(检测到几个框、标签是什么、与预期差在哪),' + '并给出确切的下一步指令(如「请说:重新标注 data/x.jpg」),由用户发起。' @@ -222,7 +222,7 @@ export function checkCallAllowed( const annotationIds = coerceStrList(arguments_.annotation_ids); if (callPaths.size === 0 && annotationIds.length === 0) { return ( - `以下文件本轮已完成标注并经用户确认落盘:${appliedText}。` + + `以下文件本轮已完成标注并已落盘:${appliedText}。` + 'mutate_annotation 未指明 paths 或 annotation_ids,无法确认是否修改已落盘标注,' + '请明确指定目标。' ); @@ -241,23 +241,23 @@ export function phasePromptBlock(ctx: TaskPhaseContext | null): string { if (ctx.phase === 'await_confirm') { const paths = [...ctx.pendingAnnotationPaths].sort().join('、'); return ( - '【任务阶段】等待用户确认标注提案\n' + - `- 以下文件的标注提案未确认、未写盘:${paths}\n` + + '【任务阶段】等待确认标注生成提案\n' + + `- 以下文件的标注生成提案未确认、未写盘:${paths}\n` + '- 禁止调用标注写入与文件写入工具;禁止声称已标注/已修改/已写入。\n' + '- 不要用 read_file_annotation 验证提案内容(磁盘仍是旧态,以提案台账为准)。\n' + - '- 用一两句说明提案内容,提示用户 Keep All 或关闭提案。' + '- 用一两句说明提案内容,提示用户确认后再继续。' ); } if (ctx.phase === 'verify') { const paths = [...ctx.appliedAnnotationPaths].sort().join('、'); return ( - '【任务阶段】标注提案已确认落盘\n' + - `- 以下文件的标注提案已由用户 Keep All 并写盘:${paths}\n` + + '【任务阶段】标注变更已落盘\n' + + `- 以下文件的标注变更已写盘:${paths}\n` + '- 可用 read_file_annotation 核对落盘结果;如任务要求报告,用 write_workspace_file 生成。\n' + '- 禁止对上述路径重复调用 auto_annotate 整文件重标;核对发现错标/漏标/重复框时,' + '用 mutate_annotation 定向修正,并带上 paths 或 annotation_ids。\n' + - '- 提案已确认落盘,禁止再要求用户确认、Keep All 或查看提案。\n' + + '- 变更已落盘,禁止再要求用户确认、查看提案或声称需要确认。\n' + '- 报告中的每个数字必须来自工具返回或提案明细,禁止估算或凭印象填写。\n' + '- 工具完成后用一两句确认即可,不要重复输出报告全文。' ); diff --git a/src/main/agent/tools/fileProposal.ts b/src/main/agent/tools/fileProposal.ts index 552ad8e..75ad941 100644 --- a/src/main/agent/tools/fileProposal.ts +++ b/src/main/agent/tools/fileProposal.ts @@ -6,7 +6,7 @@ * * **这些工具不落盘**:只校验路径与内容,产出一个带内部标记 `__doc_proposal__` * 的结果;调度层据此发出 `file_proposal_start` / `file_proposal_delta` / - * `file_proposal` 事件,真正写盘由 Electron 在用户 Keep All 后执行。 + * `file_proposal` 事件,真正写盘由 Electron 在提案定稿后直接执行(免确认,可撤销)。 * * **基线接力(pendingProposals)**:同一轮里对同一文件的多次修改必须累积成一份提案。 * `str_replace` 以最新**提案内容**为基线,而不是磁盘原文——否则第二份提案基于未含 diff --git a/src/main/agent/tools/workspacePath.ts b/src/main/agent/tools/workspacePath.ts index 1771756..50f7181 100644 --- a/src/main/agent/tools/workspacePath.ts +++ b/src/main/agent/tools/workspacePath.ts @@ -6,7 +6,7 @@ * * **根顺序至关重要**:项目目录优先,其次工作区根。前端 `applyFileBlock` 的写入根 * 同样是 `project.directoryPath ?? workspaceRoot`;若两侧顺序不一致,提案的 - * relative_display_path 会算出不同结果,Keep All 会把文件写到另一个根下 + * relative_display_path 会算出不同结果,落盘会把文件写到另一个根下 * (历史隐患:同一文件在两个根各落一份),并且前端按路径匹配提案块时会裂成两张卡片。 * * 错误文案需与 Python 侧一致——它们会作为工具结果回灌给模型。 diff --git a/src/renderer/components/agent/AgentFilesChangedSummary.css b/src/renderer/components/agent/AgentFilesChangedSummary.css index bb88a6d..f1fba7c 100644 --- a/src/renderer/components/agent/AgentFilesChangedSummary.css +++ b/src/renderer/components/agent/AgentFilesChangedSummary.css @@ -23,13 +23,6 @@ user-select: none; } -.agent-files-changed__actions { - display: inline-flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - .agent-files-changed__review { flex-shrink: 0; padding: 0 4px; @@ -45,30 +38,6 @@ color: var(--vscode-foreground, #cccccc); } -/* 撤销入口:免确认改造后文件写入/标注编辑直接落盘,撤销成为主要回退手段 */ -.agent-files-changed__undo { - display: inline-flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - padding: 0 4px; - border: none; - background: transparent; - color: var(--agent-surface-muted-text); - font-size: inherit; - font-weight: 500; - cursor: pointer; -} - -.agent-files-changed__undo:hover:not(:disabled) { - color: var(--vscode-foreground, #cccccc); -} - -.agent-files-changed__undo:disabled { - opacity: 0.6; - cursor: default; -} - .agent-files-changed__list { display: flex; flex-direction: column; diff --git a/src/renderer/components/agent/AgentFilesChangedSummary.tsx b/src/renderer/components/agent/AgentFilesChangedSummary.tsx index 50d441e..418a47c 100644 --- a/src/renderer/components/agent/AgentFilesChangedSummary.tsx +++ b/src/renderer/components/agent/AgentFilesChangedSummary.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { VscodeIcon } from '@vscode-elements/react-elements'; +import { useEffect, useMemo, useState } from 'react'; import type { ChatMessage } from '../../../shared/agentTypes'; import { isFileProposalBlock } from '../../../shared/agentTypes'; import FileTypeIcon from '../FileTypeIcon'; @@ -11,7 +10,6 @@ import { collectMessageChangeItems, type MessageChangeItem, } from '../../services/agentProposalApply'; -import { messageCanUndo } from '../../services/turnCheckpoint'; import { computeFileProposalDiffStats } from '../../utils/workspaceFileRead'; import { useOpenAgentChange } from './useOpenAgentChange'; import './AgentFilesChangedSummary.css'; @@ -35,26 +33,16 @@ function resolveItemStats( export default function AgentFilesChangedSummary({ message, }: AgentFilesChangedSummaryProps) { - const { updateMessageBlocks, undoAssistantChanges } = useAgentChat(); + const { updateMessageBlocks } = useAgentChat(); const { activeProject } = useAnnotation(); const { rootPath } = useApp(); const { openFileProposal, openAnnotationProposal } = useOpenAgentChange(); const [expanded, setExpanded] = useState(false); - const [undoing, setUndoing] = useState(false); const [computedStats, setComputedStats] = useState< Record >({}); const items = useMemo(() => collectMessageChangeItems(message), [message]); - // 仅当本消息的已应用改动**全部**留有改前快照时可撤销(部分缺失会让回滚不完整) - const canUndo = useMemo(() => messageCanUndo(message), [message]); - - const handleUndo = useCallback(() => { - setUndoing(true); - undoAssistantChanges(message.id) - .catch(() => undefined) - .finally(() => setUndoing(false)); - }, [message.id, undoAssistantChanges]); useEffect(() => { const missing = items.filter( @@ -164,27 +152,13 @@ export default function AgentFilesChangedSummary({ {items.length} Files Changed -
- - {canUndo ? ( - - ) : null} -
+
{visible.map((item) => { diff --git a/src/renderer/context/AgentChatContext.tsx b/src/renderer/context/AgentChatContext.tsx index 8d007d7..dac7bfe 100644 --- a/src/renderer/context/AgentChatContext.tsx +++ b/src/renderer/context/AgentChatContext.tsx @@ -927,6 +927,144 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { [updateMessage], ); + // ── 免确认提案的自动落盘(文件写入 + 标注编辑)────────────────────── + // + // 两条调用路径,职责不同: + // 1. 客户端工具路径(主路径):runClientTool 产出提案后 **await** 本函数, + // 据此在 resume 前完成落盘,并如实汇报 proposal_pending / file_written。 + // 2. 兜底 effect(次路径):覆盖重启恢复、历史消息重放等没有真实工具调用的场景。 + + /** 防止 effect 与工具路径并发进入。 */ + const autoApplyingRef = useRef(false); + + /** + * 自动落盘失败的提案(`messageId#blockIndex`)。 + * + * 失败后 block 保持 `pending`,若不加去重则每次 state 变化(流式期间很频繁) + * 都会重试并重复弹出错误提示。记录后跳过,改由用户通过 Keep All 手动重试。 + */ + const autoApplyFailedRef = useRef>(new Set()); + + /** 跳过已尝试自动落盘但失败的提案,避免重复弹错。 */ + const isAutoApplySkipped = useCallback( + (ref: { messageId: string; blockIndex: number }): boolean => + autoApplyFailedRef.current.has(refKey(ref)), + [], + ); + + /** + * 落盘当前会话中所有「免确认」提案,返回实际应用条数与错误。 + * + * 幂等:成功后 block 变 `applied`,不再命中 `selectAutoApplicableProposals`。 + * `selectAutoApplicableProposals` 同时要求文件提案的 `contentFinalized` 为真, + * 否则会在内容尚未流式完成时写入空内容。 + */ + const flushAutoApplicable = useCallback( + async ( + sessionIdOverride?: string, + ): Promise<{ applied: number; errors: string[] }> => { + const sessionId = sessionIdOverride ?? stateRef.current.activeSessionId; + if (!sessionId) return { applied: 0, errors: [] }; + + const messagesMap = stateRef.current.messagesBySession[sessionId] ?? {}; + const ids = resolveSessionMessageIds( + stateRef.current.sessions[sessionId], + messagesMap, + ); + const messages = ids + .map((id) => messagesMap[id]) + .filter((message): message is ChatMessage => Boolean(message)); + + const autoRefs = selectAutoApplicableProposals( + messages, + isAutoApplySkipped, + ); + if (autoRefs.length === 0) return { applied: 0, errors: [] }; + + const filePaths = autoRefs.flatMap((ref) => { + const block = messages.find((msg) => msg.id === ref.messageId)?.blocks[ + ref.blockIndex + ]; + return block && isFileProposalBlock(block) + ? [block.suggestedRelativePath] + : []; + }); + + const result = await applyAutoApplicableProposals({ + sessionId, + messages, + project: activeProject ?? null, + workspaceRoot: rootPath, + updateBlock: (messageId, blockIndex, patch) => { + updateMessageBlocks(sessionId, messageId, (blocks) => + blocks.map((b, i) => + i === blockIndex ? ({ ...b, ...patch } as MessageBlock) : b, + ), + ); + }, + onSyncWarning: (message) => { + showToast(message, { type: 'info' }); + }, + isSkipped: isAutoApplySkipped, + }); + + if (result.applied > 0) { + if (result.missingCheckpoints > 0) { + showToast('未能保存改前快照,这些改动将无法撤销', { type: 'info' }); + } + await syncWorkspaceFactMemory(activeProject); + clearFilePreviewSession(); + markWorkspaceTextFilesChanged(filePaths); + } + if (result.errors.length > 0) { + // 标灰这批 ref,避免兜底 effect 反复重试刷屏;保留 pending 交用户手动处理 + for (const ref of autoRefs) autoApplyFailedRef.current.add(refKey(ref)); + showToast(`自动应用失败:${result.errors[0]}`, { type: 'error' }); + } + + return { applied: result.applied, errors: result.errors }; + }, + [ + activeProject, + isAutoApplySkipped, + rootPath, + showToast, + updateMessageBlocks, + ], + ); + + /** + * 兜底 effect:主路径(客户端工具内 await)之外的状态变化也能落盘。 + * + * 主路径已处理的提案会变 `applied`,因此这里通常直接空转返回。 + */ + useEffect(() => { + if (autoApplyingRef.current) return; + const sessionId = state.activeSessionId; + if (!sessionId) return; + const messagesMap = state.messagesBySession[sessionId] ?? {}; + const ids = resolveSessionMessageIds( + state.sessions[sessionId], + messagesMap, + ); + const messages = ids + .map((id) => messagesMap[id]) + .filter((message): message is ChatMessage => Boolean(message)); + // 廉价前置判断:无待落盘的提案(或仅剩已知失败的)时完全不进入异步流程 + if ( + selectAutoApplicableProposals(messages, isAutoApplySkipped).length === 0 + ) { + return; + } + + autoApplyingRef.current = true; + flushAutoApplicable(sessionId) + .catch(() => undefined) + .finally(() => { + autoApplyingRef.current = false; + }); + }, [isAutoApplySkipped, state, flushAutoApplicable]); + /** * 点击 Keep All / Undo 时立即把 awaiting 消息切回 streaming(带阶段提示), * 消除「apply 写盘 + resume 建连」期间 UI 停在「等待确认」的空窗。 @@ -1962,6 +2100,9 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { (message): message is ChatMessage => Boolean(message), ), ), + // 让客户端工具在产出「免确认」提案后能立即落盘, + // 从而在 resume 前完成写盘并如实汇报结果(避免阶段机误判 AWAIT_CONFIRM) + applyAutoApplicable: () => flushAutoApplicable(sessionId), } : null; @@ -2519,123 +2660,6 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { updateMessageBlocks, ]); - /** - * 免确认自动落盘(工作区文件写入 + 标注编辑)。 - * - * 由「提案定稿」这一状态变化驱动,而非侵入 job 循环 —— 这样重启恢复、 - * 历史消息重放等场景同样覆盖,也不依赖事件到达顺序。 - * - * 幂等:应用成功后 block 变 `applied`,不再命中 `selectAutoApplicableProposals`; - * 失败时保持 `pending`,卡片仍可用 Keep All / Undo 手动处理。 - * - * 注意 `selectAutoApplicableProposals` 内部要求文件提案的 `contentFinalized` - * 为真,否则会在内容尚未流式完成时写入空内容。 - */ - const autoApplyingRef = useRef(false); - /** - * 自动应用失败过的提案(`messageId#blockIndex`)。 - * - * 失败后 block 保持 `pending`,若不加去重则每次 state 变化(流式期间很频繁) - * 都会重试并重复弹出错误提示。这里记录后跳过,改由用户通过 Keep All 手动重试 - * —— 手动路径会明确展示失败原因,不会刷提示。 - */ - const autoApplyFailedRef = useRef>(new Set()); - - /** 跳过已尝试自动应用但失败的提案,避免重复弹错。 */ - const isAutoApplySkipped = useCallback( - (ref: { messageId: string; blockIndex: number }): boolean => - autoApplyFailedRef.current.has(refKey(ref)), - [], - ); - - const runAutoApply = useCallback(async (): Promise => { - const sessionId = stateRef.current.activeSessionId; - if (!sessionId) return; - const messagesMap = stateRef.current.messagesBySession[sessionId] ?? {}; - const ids = resolveSessionMessageIds( - stateRef.current.sessions[sessionId], - messagesMap, - ); - const messages = ids - .map((id) => messagesMap[id]) - .filter((message): message is ChatMessage => Boolean(message)); - - const autoRefs = selectAutoApplicableProposals(messages, isAutoApplySkipped); - if (autoRefs.length === 0) return; - - const filePaths = autoRefs.flatMap((ref) => { - const block = messages.find((msg) => msg.id === ref.messageId)?.blocks[ - ref.blockIndex - ]; - return block && isFileProposalBlock(block) - ? [block.suggestedRelativePath] - : []; - }); - - const result = await applyAutoApplicableProposals({ - sessionId, - messages, - project: activeProject ?? null, - workspaceRoot: rootPath, - updateBlock: (messageId, blockIndex, patch) => { - updateMessageBlocks(sessionId, messageId, (blocks) => - blocks.map((b, i) => - i === blockIndex ? ({ ...b, ...patch } as MessageBlock) : b, - ), - ); - }, - onSyncWarning: (message) => { - showToast(message, { type: 'info' }); - }, - isSkipped: isAutoApplySkipped, - }); - - if (result.applied > 0) { - if (result.missingCheckpoints > 0) { - showToast('未能保存改前快照,这些改动将无法撤销', { type: 'info' }); - } - await syncWorkspaceFactMemory(activeProject); - clearFilePreviewSession(); - markWorkspaceTextFilesChanged(filePaths); - } - if (result.errors.length > 0) { - // 标灰这批 ref,避免自动重试刷屏;保留 pending 交由用户手动处理 - for (const ref of autoRefs) autoApplyFailedRef.current.add(refKey(ref)); - showToast(`自动应用失败:${result.errors[0]}`, { type: 'error' }); - } - }, [activeProject, isAutoApplySkipped, rootPath, showToast, updateMessageBlocks]); - - useEffect(() => { - if (autoApplyingRef.current) return; - const sessionId = state.activeSessionId; - if (!sessionId) return; - const messagesMap = state.messagesBySession[sessionId] ?? {}; - const ids = resolveSessionMessageIds( - state.sessions[sessionId], - messagesMap, - ); - const messages = ids - .map((id) => messagesMap[id]) - .filter((message): message is ChatMessage => Boolean(message)); - // 廉价前置判断:无待自动应用的提案(或仅剩已知失败的)时完全不进入异步流程 - if ( - selectAutoApplicableProposals(messages, isAutoApplySkipped).length === 0 - ) { - return; - } - - autoApplyingRef.current = true; - runAutoApply() - .catch((err) => { - showToast(resolveErrorMessage(err, '自动应用变更失败'), { - type: 'error', - }); - }) - .finally(() => { - autoApplyingRef.current = false; - }); - }, [isAutoApplySkipped, state, runAutoApply, showToast]); - const openPanelTabs = useMemo(() => { const sessionTabs: AgentPanelTab[] = state.openTabIds.map((sessionId) => ({ kind: 'session', diff --git a/src/renderer/services/agentJobRegistry.ts b/src/renderer/services/agentJobRegistry.ts index 57ee399..7b4aa9f 100644 --- a/src/renderer/services/agentJobRegistry.ts +++ b/src/renderer/services/agentJobRegistry.ts @@ -115,6 +115,18 @@ export interface ClientToolContext { conversationTranscript?: string; /** 当前会话未 Keep All 的标注提案 changes,供 mutate 叠到磁盘工作集 */ pendingAnnotationChanges?: AnnotationBatchChange[]; + /** + * 立即落盘「免确认」提案(文件写入 + 标注编辑),并返回实际应用条数。 + * + * 由 `AgentChatContext` 提供。**客户端工具产出提案后必须 await 它**,原因有二: + * + * 1. **正确性**:resume 前会同步构建 `client_context.proposal_states`, + * 若提案此时仍是 `pending`,阶段机会误判为 AWAIT_CONFIRM 并拦掉后续 + * `auto_annotate`(表现为「删除已落盘,但重新标注被拒」)。 + * 2. **诚实性**:工具结果里的 `proposal_pending` / `file_written` 必须反映 + * 真实落盘结果,不能在写盘之前就声称「已应用」。 + */ + applyAutoApplicable?: () => Promise<{ applied: number; errors: string[] }>; } /** @@ -161,25 +173,39 @@ export function formatAnnotationToolResult(options: { omittedCount?: number; omittedPaths?: string[]; /** - * 提案由前端直接落盘,不进入用户确认流程(免确认改造)。 + * 提案已由前端**成功**直接落盘,不进入用户确认流程(免确认改造)。 * - * 标注编辑(`mutate_annotation`)走这条路径:前端会在收到提案后自动应用, - * 因此这里必须报告 `proposal_pending: false`,否则 job 会错误地停在 - * AwaitingConfirm 断点等一个永远不会发生的用户确认。 + * 标注编辑(`mutate_annotation`)走这条路径:调用方在产出提案后 + * **await 落盘**,成功才传 true。 * * 标注生成(`auto_annotate`)**不传本项**——它仍需用户审阅后 Keep All。 */ autoApplied?: boolean; + /** + * 尝试自动落盘但失败,提案已保留为待用户确认。 + * + * 必须与 `autoApplied` 区分:此时**不能**声称已写盘(否则是谎报), + * 而应如实告知失败并让用户手动处理——`proposal_pending` 保持 true, + * job 会停在 AwaitingConfirm 断点等用户确认。 + */ + autoApplyFailed?: boolean; }): string { - // 只有真的产出了提案才谈得上"已应用/待确认" - const autoApplied = Boolean(options.autoApplied && options.hasProposal); + // 只有真的产出了提案才谈得上「已应用/待确认」 + const hasProposal = options.hasProposal; + const autoApplied = Boolean(options.autoApplied && hasProposal); + const autoApplyFailed = Boolean( + options.autoApplyFailed && hasProposal && !autoApplied, + ); + const pendingNote = autoApplied ? '已生成变更并直接应用。' - : options.hasProposal - ? '已生成待确认提案(未写盘)。' - : options.tool === 'mutate_annotation' - ? '未生成提案,不要对用户说已删除或已修改。' - : '未生成提案,不要对用户说已标注完成。'; + : autoApplyFailed + ? '自动落盘失败,提案已保留待用户确认(未写盘)。' + : hasProposal + ? '已生成待确认提案(未写盘)。' + : options.tool === 'mutate_annotation' + ? '未生成提案,不要对用户说已删除或已修改。' + : '未生成提案,不要对用户说已标注完成。'; const fileStatsText = options.fileStats?.length ? ` ${formatFileStatsText(options.fileStats)}。` : ''; @@ -189,10 +215,9 @@ export function formatAnnotationToolResult(options: { ANNOTATION_BATCH_MAX_FILES, ); const truncationText = truncationNote ? ` ${truncationNote}` : ''; - const summary = - options.hasProposal || autoApplied - ? `${pendingNote}${options.summary}${fileStatsText}${truncationText}` - : `${options.summary} ${pendingNote}${truncationText}`; + const summary = hasProposal + ? `${pendingNote}${options.summary}${fileStatsText}${truncationText}` + : `${options.summary} ${pendingNote}${truncationText}`; return formatClientToolResult({ status: options.status, tool: options.tool, @@ -200,7 +225,7 @@ export function formatAnnotationToolResult(options: { summary: summary.trim(), message: summary.trim(), file_written: autoApplied, - proposal_pending: autoApplied ? false : options.hasProposal, + proposal_pending: autoApplied ? false : hasProposal, files: options.fileStats, }); } @@ -675,6 +700,37 @@ async function runClientTool( providerBaseUrl, providerModel, }); + + // 免确认:提案产出后**立即落盘**,再据实汇报结果。 + // + // 必须在 resume 之前完成:resume 时会同步构建 `proposal_states`, + // 若此刻仍是 pending,阶段机会误判为 AWAIT_CONFIRM,把紧接着的 + // auto_annotate(重新标注)拦掉。 + const canAutoApply = typeof ctx.applyAutoApplicable === 'function'; + let autoApplied = false; + let autoApplyFailed = false; + if (mutateResult.hasProposal) { + if (canAutoApply) { + try { + const flush = await ctx.applyAutoApplicable!(); + autoApplied = flush.applied > 0; + autoApplyFailed = !autoApplied; + if (autoApplyFailed && flush.errors.length > 0) { + console.warn( + '[LR-Agent] 标注编辑自动落盘失败:', + flush.errors[0], + ); + } + } catch (err) { + autoApplyFailed = true; + console.warn('[LR-Agent] 标注编辑自动落盘异常:', err); + } + } else { + // 没有落盘通道(理论上不会发生):保守保留为待确认,绝不谎报已写盘 + autoApplyFailed = true; + } + } + return formatAnnotationToolResult({ status: mutateResult.status === 'completed' @@ -685,8 +741,8 @@ async function runClientTool( summary: mutateResult.summary, hasProposal: mutateResult.hasProposal, fileStats: mutateResult.fileStats, - // 标注编辑免确认:提案随后由前端自动落盘,不进入 AwaitingConfirm 断点 - autoApplied: true, + autoApplied, + autoApplyFailed, }); } catch { return formatAnnotationToolResult({ diff --git a/src/renderer/services/annotationMutationBatchJob.test.ts b/src/renderer/services/annotationMutationBatchJob.test.ts index a911f7f..e60ef79 100644 --- a/src/renderer/services/annotationMutationBatchJob.test.ts +++ b/src/renderer/services/annotationMutationBatchJob.test.ts @@ -235,6 +235,41 @@ describe('formatAnnotationToolResult', () => { expect(String(parsed.summary)).toContain('不要对用户说已删除'); }); + // 方案 B:自动落盘失败时必须如实汇报,不得谎报已写盘 + it('自动落盘失败时如实报告并保持待确认断点', () => { + const raw = formatAnnotationToolResult({ + status: 'completed', + tool: 'mutate_annotation', + userRequest: '删框', + summary: '删除 1 个框', + hasProposal: true, + autoApplied: false, + autoApplyFailed: true, + }); + const parsed = JSON.parse(raw) as Record; + expect(parsed.file_written).toBe(false); + // 提案仍是 pending,因此 job 应停在确认断点等用户处理 + expect(parsed.proposal_pending).toBe(true); + expect(toolResultHasPendingProposal(raw)).toBe(true); + expect(String(parsed.summary)).toContain('自动落盘失败'); + expect(String(parsed.summary)).not.toContain('已生成变更并直接应用'); + }); + + it('autoApplied 优先于 autoApplyFailed(两者同时传时视为成功)', () => { + const raw = formatAnnotationToolResult({ + status: 'completed', + tool: 'mutate_annotation', + userRequest: '删框', + summary: '删除 1 个框', + hasProposal: true, + autoApplied: true, + autoApplyFailed: true, + }); + const parsed = JSON.parse(raw) as Record; + expect(parsed.file_written).toBe(true); + expect(parsed.proposal_pending).toBe(false); + }); + it('标注生成仍进入确认断点(autoApplied 不传)', () => { const raw = formatAnnotationToolResult({ status: 'completed', From 485419c91c6d10ae7cd5d9327d77ec62e2438aa1 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 15:52:08 +0800 Subject: [PATCH 16/20] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=20MCP=20?= =?UTF-8?q?SSE=20=E8=B0=83=E7=94=A8=E3=80=81=E4=BB=BB=E5=8A=A1=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=97=A8=E7=A6=81=E4=B8=8E=E5=BF=AB=E7=85=A7=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=EF=BC=8C=E6=96=B0=E5=A2=9E=E7=BB=88=E7=AB=AF=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运行时与协议:LlmClient 增加网络层重试(指数退避,取消/超时/HTTP 错误不重试);MCP 客户端区分传输类型,SSE 复用发现连接供 invoke 使用(会话绑定服务端不再「列出成功、调用失败」),连接加超时兜底、清缓存时统一关闭;新增内置 async 工具 start_terminal_command(锁定工作区 cwd,需用户批准)。 任务阶段门禁:pplied 只看本轮且只看生成类操作(append/replace/replace_bboxes),修复「先删除再重新标注」被自身前置删除拦死;pending 仍按全会话聚合。proposal_states 新增 sourceKind / inCurrentTurn 判据并贯通 schema 与前端账本。 标注与快照:新增标注文档 ilePath 冗余字段幂等修复(归属真源是存储键),避免加载时改写字节导致 Undo 误判脏;检查点只要捕获成功即可回滚, estore 支持 orce 跳过完整性与脏检查。 预标注体验:模型下拉空态给出可读原因,检测模式声明与权重路径证据矛盾时内联告警;补充相应单测与协议文档。 Co-authored-by: Cursor --- docs/agent-protocol.md | 38 +++- src/main/agent/llm/client.ts | 100 ++++++--- src/main/agent/loop/assistLoop.test.ts | 163 +++++++++++++- src/main/agent/loop/assistLoop.ts | 34 ++- src/main/agent/loop/modeRouter.ts | 3 + src/main/agent/loop/taskPhase.test.ts | 198 ++++++++++++++++++ src/main/agent/loop/taskPhase.ts | 41 +++- src/main/agent/mcp/client.sse.test.ts | 114 ++++++++++ src/main/agent/mcp/client.ts | 109 ++++++++-- src/main/agent/schemas.ts | 33 ++- src/main/agent/tools/registry.ts | 33 +++ .../annotationDataStore.repair.test.ts | 116 ++++++++++ src/main/annotation/annotationDataStore.ts | 62 ++++++ src/main/checkpoint/handlers.ts | 9 +- .../checkpoint/turnCheckpointStore.test.ts | 82 ++++++++ src/main/checkpoint/turnCheckpointStore.ts | 52 +++-- src/main/main.ts | 14 ++ src/main/mcp/server.ts | 4 +- src/main/preload.ts | 13 ++ .../pretrainedModelHandlers.ts | 1 + .../pretrainedModels/pretrainedModelStore.ts | 8 + .../agent/AgentExplorationBlock.tsx | 17 +- src/renderer/components/agent/AgentPanel.css | 9 + .../components/agent/AgentReasoningBlock.css | 20 +- .../components/agent/AgentReasoningBlock.tsx | 10 +- .../components/agent/AgentToolCallBlock.tsx | 10 +- .../annotation/PreAnnotToolbarSection.css | 7 + .../annotation/PreAnnotToolbarSection.tsx | 21 ++ .../PretrainedModelFormModal.css | 9 + .../PretrainedModelFormModal.tsx | 16 ++ .../pretrainedModels/PretrainedModelList.tsx | 9 + src/renderer/context/AgentChatContext.tsx | 19 +- src/renderer/context/AnnotationContext.tsx | 32 +++ .../context/PretrainedModelsContext.tsx | 4 + src/renderer/services/agentClientContext.ts | 2 + src/renderer/services/agentProposalApply.ts | 13 +- .../services/annotationDataService.ts | 17 ++ .../services/pretrainedModelService.ts | 1 + src/renderer/services/proposalLedger.test.ts | 103 +++++++++ src/renderer/services/proposalLedger.ts | 37 +++- src/renderer/services/turnCheckpoint.ts | 36 +++- .../utils/preAnnotModelFilter.test.ts | 138 ++++++++++++ src/renderer/utils/preAnnotModelFilter.ts | 63 ++++++ src/shared/agentTypes.ts | 16 ++ src/shared/preAnnotTypes.test.ts | 133 ++++++++++++ src/shared/preAnnotTypes.ts | 46 ++++ 46 files changed, 1899 insertions(+), 116 deletions(-) create mode 100644 src/main/agent/loop/taskPhase.test.ts create mode 100644 src/main/agent/mcp/client.sse.test.ts create mode 100644 src/main/annotation/annotationDataStore.repair.test.ts create mode 100644 src/renderer/utils/preAnnotModelFilter.test.ts create mode 100644 src/shared/preAnnotTypes.test.ts diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index ba75637..5d72bdd 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -480,6 +480,8 @@ client_context 非空 AND ( | `status` | `"pending" \| "applied" \| "dismissed" \| "undone"` | `"pending"` | | `operation` | string? | `null` | | `annotation_ids` | string[] | `[]` | +| `source_kind` | `"batch" \| "mutation" \| "report"`? | `null` | +| `in_current_turn` | boolean | `true` | **`McpServerInput`** @@ -547,7 +549,7 @@ client_context 非空 AND ( `src/shared/agentToolKinds.ts`、`src/shared/agentTypes.ts` 的 `CLIENT_TOOL_NAME_SET`), 必须收敛为一处,避免漂移。 -### 5.2 18 个内置工具 +### 5.2 19 个内置工具 #### 只读检索类(SYNC,6 个) @@ -596,27 +598,26 @@ client_context 非空 AND ( |---|---| | `explore_readonly` | `query: string(≥1)`、`focus_path: string?` | -#### 客户端工具(ASYNC 存根,2 个) +#### 客户端工具(ASYNC 存根,3 个) | 工具 | 参数 | |---|---| | `auto_annotate` | `user_request: string(≥1)`、`paths: string[]?`、`all_files: bool?`、`scope_hint: string?`、`write_mode: string?`、`conf_threshold: float? (0..1)`、`iou_threshold: float? (0..1)`、`model_id: string?`、`include_classes: string[]?`、`exclude_classes: string[]?`、`use_vision_mapping: bool?` | | `mutate_annotation` | `user_request: string(≥1)`、`paths: string[]?`、`annotation_ids: string[]?` | +| `start_terminal_command` | `command: string`、`args: string[]?`、`timeout_ms: int? (1..600000)` | -**注意**:这两个工具在运行时侧**没有执行体**,注册的是永远不会被本地调用的 stub。真正执行在 -`src/renderer/services/agentJobRegistry.ts`。 - -> 另有 `start_terminal_command`(ASYNC),由本地 MCP Server -> (`src/main/mcp/server.ts`)暴露,不在上述 18 个内置工具内。 +**注意**:这三个工具在运行时侧**没有执行体**,注册的是永远不会被本地调用的 stub。真正执行在 +`src/renderer/services/agentJobRegistry.ts`(终端命令经 `runTerminalCommandTool` 弹出聊天内批准条后, +经 IPC 由主进程 `commandJobManager` 执行)。 ### 5.3 工具集分组 | 集合 | 内容 | |---|---| -| `FULL_TOOL_SET` | 14 个:全部只读 + 全部提案写入 + 2 个标注客户端工具 | -| `WRITE_TOOL_NAMES` | `auto_annotate`、`mutate_annotation`、4 个提案写入工具 | +| `FULL_TOOL_SET` | 15 个:全部只读 + 全部提案写入 + 3 个异步客户端工具 | +| `WRITE_TOOL_NAMES` | `auto_annotate`、`mutate_annotation`、`start_terminal_command`、4 个提案写入工具 | | `ASK_TOOL_SET` | `FULL_TOOL_SET − WRITE_TOOL_NAMES`(纯只读) | -| `LIGHT_TOOL_SET` | 只读 + 提案写入(**无标注工具**),用于编辑器 / 纯工作区 | +| `LIGHT_TOOL_SET` | 只读 + 提案写入 + `start_terminal_command`(**无标注工具**),用于编辑器 / 纯工作区 | **工具集选择规则**: @@ -863,11 +864,26 @@ else: |---|---| | 无 `proposal_states` | `null`(不启用门禁) | | 存在 `kind="annotation" && status="pending"` | `AWAIT_CONFIRM` | -| 存在 `kind="annotation" && status="applied"` | `VERIFY` | +| 存在 `kind="annotation" && status="applied"` **且本轮、且为生成类操作** | `VERIFY` | | 其余 | `null` | `gating_enabled` 仅在 `AWAIT_CONFIRM` / `VERIFY` 时为真。 +`PENDING` 与 `APPLIED` 的聚合口径**故意不同**: + +| 集合 | 作用域 | 理由 | +|---|---|---| +| `pending` | 全会话 | 未确认提案在磁盘上确实不存在,跨轮也必须拦住写入 | +| `applied` | **仅本轮**,且仅 `append` / `replace` / `replace_bboxes` | 表达「别在同一轮里重复标注刚标过的文件」(防循环) | + +两个判据都不可省: + +- **生成类操作过滤**:`delete` / `patch` 不产生标注,若计入 `applied`, + 「先删除再重新标注」会被自己的前置删除动作拦死。 +- **轮次过滤**:若按全会话聚合,同一会话内任何后续重标都会被历史提案永久拦截, + 而阶段提示词恰恰让模型引导用户「重新发起请求」(如「请说:重新标注 data/x.jpg」)。 +- `applied_annotation_ids` 仍按**全会话**收集,供 `mutate_annotation` 定向修正识别已知标注。 + 拦截规则: - **`AWAIT_CONFIRM`**:禁止全部标注写入工具 + 全部工作区写入工具 diff --git a/src/main/agent/llm/client.ts b/src/main/agent/llm/client.ts index 8a64d04..aceed50 100644 --- a/src/main/agent/llm/client.ts +++ b/src/main/agent/llm/client.ts @@ -79,10 +79,16 @@ export interface LlmClientOptions { temperature?: number; /** 测试注入点。 */ fetchImpl?: typeof fetch; + /** 网络层失败重试次数(默认 2,即最多 3 次尝试)。 */ + maxRetries?: number; + /** 重试退避基数(毫秒,默认 300)。 */ + retryBaseDelayMs?: number; } const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_TEMPERATURE = 0.7; +const DEFAULT_MAX_RETRIES = 2; +const DEFAULT_RETRY_BASE_DELAY_MS = 300; /** 调用失败时抛出,携带 HTTP 状态与响应体片段以便上层分流。 */ export class LlmRequestError extends Error { @@ -97,9 +103,38 @@ export class LlmRequestError extends Error { } } +/** + * 仅「未收到任何响应」的网络层错误可重试;取消/超时/HTTP 状态码错误一律不重试。 + * + * - `LlmRequestError` 表示已拿到 4xx/5xx 响应,重试大概率相同,跳过。 + * - 沿 cause 链排查:某些 Node 版本会把 AbortError/TimeoutError 包成 + * `TypeError: fetch failed`,必须逐层看 cause,否则会把取消/超时误判成可重试。 + */ +function isRetryableNetworkError(err: unknown): boolean { + if (err instanceof LlmRequestError) return false; + if (!(err instanceof Error)) return false; + + let cur: unknown = err; + for (let depth = 0; cur && depth < 4; depth += 1) { + const e = cur as Error; + if (e.name === 'AbortError' || e.name === 'TimeoutError') return false; + cur = e.cause; + } + return true; +} + export class LlmClient { private readonly options: Required< - Pick + Pick< + LlmClientOptions, + | 'apiKey' + | 'baseUrl' + | 'model' + | 'timeoutMs' + | 'temperature' + | 'maxRetries' + | 'retryBaseDelayMs' + > >; private readonly fetchImpl: typeof fetch; @@ -112,6 +147,8 @@ export class LlmClient { model: options.model, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, temperature: options.temperature ?? DEFAULT_TEMPERATURE, + maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + retryBaseDelayMs: options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS, }; this.fetchImpl = options.fetchImpl ?? fetch; } @@ -143,30 +180,45 @@ export class LlmClient { body: Record, signal?: AbortSignal, ): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.options.timeoutMs); - // 上游取消也要能中断请求 - const onAbort = (): void => controller.abort(); - signal?.addEventListener('abort', onAbort); - - try { - const res = await this.fetchImpl(`${this.options.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.options.apiKey}`, - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new LlmRequestError(res.status, text); + const maxRetries = this.options.maxRetries; + const baseDelay = this.options.retryBaseDelayMs; + + for (let attempt = 0; ; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.options.timeoutMs); + // 上游取消也要能中断请求 + const onAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onAbort); + + try { + const res = await this.fetchImpl(`${this.options.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.options.apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new LlmRequestError(res.status, text); + } + return res; + } catch (err) { + // 仅「尚未收到任何响应」的网络层错误可重试;HTTP 状态码错误 / 取消 / + // 超时一律不重试(避免无谓重放与把取消误判为故障)。 + const canRetry = + attempt < maxRetries && + isRetryableNetworkError(err) && + !signal?.aborted; + if (!canRetry) throw err; + const delay = Math.min(baseDelay * 2 ** attempt, 2000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); } - return res; - } finally { - clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); } } diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts index c25e049..64a6feb 100644 --- a/src/main/agent/loop/assistLoop.test.ts +++ b/src/main/agent/loop/assistLoop.test.ts @@ -5,7 +5,7 @@ * jsdom 环境不提供这些。 */ import { describe, expect, it } from '@jest/globals'; -import { LlmClient, type ToolSpec } from '../llm/client'; +import { LlmClient, LlmRequestError, type ToolSpec } from '../llm/client'; import { buildTools, toToolSpec } from '../tools/registry'; import { DEFAULT_AGENT_SETTINGS } from '../config'; import { createStandaloneImageService } from '../services/imageService'; @@ -268,6 +268,54 @@ describe('Assist 循环:异步客户端工具', () => { // arguments 是对象(与 tool_start 的字符串不同,属既成事实) expect(typeof pending?.clientToolCalls?.[0].arguments).toBe('object'); }); + + it('start_terminal_command 作为 async 工具发 tool_pending 而非同步执行', async () => { + // 工具集路由:编辑器/标注 Agent 可用,Ask 模式剔除 + expect(LIGHT_TOOL_SET.has('start_terminal_command')).toBe(true); + expect(FULL_TOOL_SET.has('start_terminal_command')).toBe(true); + const ask = resolveAssistToolSet({ + hasProjectSnapshot: true, + agentMode: 'chat', + isEditor: false, + hasWorkspace: true, + }); + expect(ask.has('start_terminal_command')).toBe(false); + + const { fetchImpl } = makeFetch([ + { + toolCalls: [ + { + id: 'tc-term', + name: 'start_terminal_command', + args: { command: 'git', args: ['status'] }, + }, + ], + }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '查看 git 状态' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const types = events.map((e) => e.type); + expect(types).toContain('tool_pending'); + expect(types[types.length - 1]).toBe('tool_pending'); + // 不得出现同步执行的 tool_result(那说明走了 MCP callTool 的 approval_required 路径) + expect(types).not.toContain('tool_result'); + + const pending = events.find((e) => e.type === 'tool_pending'); + expect(pending?.clientToolCalls?.[0].name).toBe('start_terminal_command'); + expect(pending?.clientToolCalls?.[0].arguments).toEqual({ + command: 'git', + args: ['status'], + }); + }); }); describe('Assist 循环:tool_choice="any" 兜底', () => { @@ -426,3 +474,116 @@ describe('阶段门禁与工具集', () => { expect(none.size).toBe(0); }); }); + +describe('LLM 客户端:网络失败重试', () => { + /** 构造一个合法的 SSE 流式响应(单段文本)。 */ + function streamResponse(text: string): Response { + const frames = [ + `data: ${JSON.stringify({ choices: [{ delta: { role: 'assistant', content: '' } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`, + 'data: [DONE]\n\n', + ].join(''); + return new Response(frames, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + /** 前 `failTimes` 次抛错、之后返回正常流式响应的 fetch 替身。 */ + function makeFlakyFetch(opts: { + failTimes: number; + error: () => Error; + successText?: string; + }): { fetchImpl: typeof fetch; callCount: () => number } { + let index = 0; + const impl = (async () => { + const i = index; + index += 1; + if (i < opts.failTimes) throw opts.error(); + return streamResponse(opts.successText ?? '成功'); + }) as unknown as typeof fetch; + return { fetchImpl: impl, callCount: () => index }; + } + + /** 关闭退避延迟的客户端,保证测试不等待 300ms+。 */ + function makeRetryClient(fetchImpl: typeof fetch): LlmClient { + return new LlmClient({ + apiKey: 'k', + baseUrl: 'http://mock/v1', + model: 'm', + fetchImpl, + retryBaseDelayMs: 0, + }); + } + + async function drainText(llm: LlmClient): Promise { + let text = ''; + for await (const delta of llm.streamChat({ + messages: [{ role: 'user', content: 'hi' }], + })) { + if (delta.content) text += delta.content; + } + return text; + } + + it('首字节前失败一次后自动重试成功', async () => { + const { fetchImpl, callCount } = makeFlakyFetch({ + failTimes: 1, + error: () => new TypeError('fetch failed'), + successText: '最终回答', + }); + + const text = await drainText(makeRetryClient(fetchImpl)); + expect(text).toBe('最终回答'); + expect(callCount()).toBe(2); + }); + + it('连续网络失败达到默认上限(maxRetries=2)后抛出', async () => { + const { fetchImpl, callCount } = makeFlakyFetch({ + failTimes: 100, + error: () => new TypeError('fetch failed'), + }); + + await expect(drainText(makeRetryClient(fetchImpl))).rejects.toThrow(); + expect(callCount()).toBe(3); + }); + + it('AbortError 不重试(取消不应被误判为故障重放)', async () => { + const { fetchImpl, callCount } = makeFlakyFetch({ + failTimes: 100, + error: () => Object.assign(new Error('aborted'), { name: 'AbortError' }), + }); + + await expect(drainText(makeRetryClient(fetchImpl))).rejects.toThrow(); + expect(callCount()).toBe(1); + }); + + it('AbortError 藏在 cause 链里也不重试', async () => { + const { fetchImpl, callCount } = makeFlakyFetch({ + failTimes: 100, + error: () => { + const wrapped = new TypeError('fetch failed'); + (wrapped as { cause?: unknown }).cause = Object.assign( + new Error('The operation was aborted.'), + { name: 'AbortError' }, + ); + return wrapped; + }, + }); + + await expect(drainText(makeRetryClient(fetchImpl))).rejects.toThrow(); + expect(callCount()).toBe(1); + }); + + it('HTTP 状态码错误(LlmRequestError)不重试', async () => { + let index = 0; + const impl = (async () => { + index += 1; + return new Response('server error', { status: 500 }); + }) as unknown as typeof fetch; + + await expect(drainText(makeRetryClient(impl))).rejects.toThrow(LlmRequestError); + expect(index).toBe(1); + }); +}); diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 7e31b30..7a91cf9 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -210,19 +210,39 @@ export async function* streamAssist( yield* streamAssistInner(params); } catch (err) { if (params.isCancelled() || isAbortError(err)) return; - console.error( - '[agentRuntime] Assist 循环异常终止:', - err instanceof Error ? (err.stack ?? err.message) : String(err), - ); - yield sse.error(err instanceof Error ? err.message : '模型请求失败'); + console.error('[agentRuntime] Assist 循环异常终止:', describeError(err)); + yield sse.error(describeError(err)); } } /** abort(取消)导致的异常不应被当作故障上报。 */ function isAbortError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; - const name = (err as { name?: unknown }).name; - return name === 'AbortError'; + let cur: unknown = err; + for (let depth = 0; cur && depth < 4; depth += 1) { + const e = cur as { name?: unknown; cause?: unknown }; + if (e.name === 'AbortError') return true; + cur = e.cause; + } + return false; +} + +/** 递归展开 cause 链,把底层 code(如 ECONNRESET)带进错误文案,便于诊断。 */ +function describeError(err: unknown, depth = 0): string { + if (err == null || depth > 4) return String(err); + if (err instanceof AggregateError) { + return `${err.name}: ${err.message} [${err.errors + .map((e) => describeError(e, depth + 1)) + .join(' | ')}]`; + } + if (err instanceof Error) { + const { cause, code } = err as { cause?: unknown; code?: unknown }; + const meta = code ? ` (code=${String(code)})` : ''; + return `${err.name}: ${err.message}${meta}${ + cause ? ` <- ${describeError(cause, depth + 1)}` : '' + }`; + } + return String(err); } /** 工具循环主体(异常由 `streamAssist` 统一兜底)。 */ diff --git a/src/main/agent/loop/modeRouter.ts b/src/main/agent/loop/modeRouter.ts index 7d6905b..a20d119 100644 --- a/src/main/agent/loop/modeRouter.ts +++ b/src/main/agent/loop/modeRouter.ts @@ -20,6 +20,7 @@ export const LIGHT_TOOL_SET: ReadonlySet = new Set([ 'str_replace_workspace_file', 'delete_workspace_file', 'move_workspace_file', + 'start_terminal_command', 'get_lr_agent_help', 'describe_annotation_project', 'read_file_annotation', @@ -40,6 +41,7 @@ export const FULL_TOOL_SET: ReadonlySet = new Set([ 'move_workspace_file', 'auto_annotate', 'mutate_annotation', + 'start_terminal_command', 'get_lr_agent_help', 'describe_annotation_project', 'read_file_annotation', @@ -50,6 +52,7 @@ export const FULL_TOOL_SET: ReadonlySet = new Set([ export const WRITE_TOOL_NAMES: ReadonlySet = new Set([ 'auto_annotate', 'mutate_annotation', + 'start_terminal_command', 'write_workspace_file', 'str_replace_workspace_file', 'delete_workspace_file', diff --git a/src/main/agent/loop/taskPhase.test.ts b/src/main/agent/loop/taskPhase.test.ts new file mode 100644 index 0000000..429d88a --- /dev/null +++ b/src/main/agent/loop/taskPhase.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from '@jest/globals'; +import { checkCallAllowed, deriveTaskPhase } from './taskPhase'; + +/** 构造一条已应用的标注提案状态。 */ +function applied( + path: string, + operation: string, + extra: { inCurrentTurn?: boolean; annotationIds?: string[] } = {}, +) { + return { + path, + kind: 'annotation' as const, + status: 'applied' as const, + operation, + inCurrentTurn: extra.inCurrentTurn ?? true, + annotationIds: extra.annotationIds, + }; +} + +describe('deriveTaskPhase:applied 门禁口径', () => { + /** + * 回归(用户实测的阻塞场景):先删除再重新标注。 + * + * 删除不产生标注,却被计入 `appliedAnnotationPaths`,于是紧接着的 + * `auto_annotate` 命中 overlap 被拦 —— 任务被自己的前置删除动作锁死。 + */ + it('仅删除(本轮)不构成「已标注」,不启用 verify 门禁', () => { + const ctx = deriveTaskPhase([ + applied('data/1.jpg', 'delete'), + applied('data/2.jpg', 'delete'), + applied('data/3.jpg', 'delete'), + ]); + expect(ctx).toBeNull(); + }); + + it('仅修改(patch)同样不构成「已标注」', () => { + expect(deriveTaskPhase([applied('data/1.jpg', 'patch')])).toBeNull(); + }); + + it('本轮的生成类操作才进入 verify 门禁', () => { + const ctx = deriveTaskPhase([applied('data/1.jpg', 'append')]); + expect(ctx?.phase).toBe('verify'); + expect([...(ctx?.appliedAnnotationPaths ?? [])]).toEqual(['data/1.jpg']); + }); + + it('replace / replace_bboxes 同属生成类', () => { + const ctx = deriveTaskPhase([ + applied('data/1.jpg', 'replace'), + applied('data/2.jpg', 'replace_bboxes'), + ]); + expect([...(ctx?.appliedAnnotationPaths ?? [])].sort()).toEqual([ + 'data/1.jpg', + 'data/2.jpg', + ]); + }); + + /** + * 回归:跨轮的历史提案不得阻塞新一轮的重新标注。 + * + * 阶段提示词让模型引导用户「重新发起请求」(如「请说:重新标注 data/x.jpg」), + * 在全会话聚合的旧口径下那条路径根本走不通 —— 门禁不区分轮次, + * 同一会话内任何后续重标都会被历史提案永久拦截。 + */ + it('跨轮的生成类操作不启用门禁(用户已显式重新下达指令)', () => { + expect( + deriveTaskPhase([ + applied('data/1.jpg', 'append', { inCurrentTurn: false }), + ]), + ).toBeNull(); + }); + + it('本轮生成 + 跨轮生成:只按本轮判定', () => { + const ctx = deriveTaskPhase([ + applied('data/1.jpg', 'append', { inCurrentTurn: false }), + applied('data/2.jpg', 'append'), + ]); + expect([...(ctx?.appliedAnnotationPaths ?? [])]).toEqual(['data/2.jpg']); + }); + + it('跨轮的删除与本轮生成可共存', () => { + const ctx = deriveTaskPhase([ + applied('data/1.jpg', 'delete', { inCurrentTurn: false }), + applied('data/2.jpg', 'append'), + ]); + expect([...(ctx?.appliedAnnotationPaths ?? [])]).toEqual(['data/2.jpg']); + }); + + /** pending 必须保持全会话口径:未确认提案在磁盘上确实不存在。 */ + it('跨轮的 pending 仍然启用 await_confirm', () => { + const ctx = deriveTaskPhase([ + { + path: 'data/1.jpg', + kind: 'annotation', + status: 'pending', + operation: 'append', + inCurrentTurn: false, + }, + ]); + expect(ctx?.phase).toBe('await_confirm'); + expect([...(ctx?.pendingAnnotationPaths ?? [])]).toEqual(['data/1.jpg']); + }); + + it('无提案历史时不启用门禁(纯问答零行为变化)', () => { + expect(deriveTaskPhase([])).toBeNull(); + expect(deriveTaskPhase(null)).toBeNull(); + expect(deriveTaskPhase(undefined)).toBeNull(); + }); + + it('dismissed 状态不参与任何门禁', () => { + expect( + deriveTaskPhase([ + { path: 'data/1.jpg', kind: 'annotation', status: 'dismissed' }, + ]), + ).toBeNull(); + }); + + /** appliedAnnotationIds 保持全会话(供定向修正识别已知标注),不随轮次收窄。 */ + it('跨轮的标注 id 仍计入 appliedAnnotationIds', () => { + const ctx = deriveTaskPhase([ + applied('data/1.jpg', 'append', { + inCurrentTurn: false, + annotationIds: ['a1'], + }), + ]); + // 跨轮生成不进 applied 路径集合(门禁关闭)… + expect(ctx).toBeNull(); + // …但本轮有生成时,跨轮 id 仍应可查 + const ctx2 = deriveTaskPhase([ + applied('data/1.jpg', 'append', { + inCurrentTurn: false, + annotationIds: ['old-1'], + }), + applied('data/2.jpg', 'append', { annotationIds: ['new-1'] }), + ]); + expect([...(ctx2?.appliedAnnotationIds ?? [])].sort()).toEqual([ + 'new-1', + 'old-1', + ]); + }); +}); + +describe('checkCallAllowed:verify 阶段的 auto_annotate 判定', () => { + const verifyCtx = deriveTaskPhase([applied('data/1.jpg', 'append')])!; + + it('同路径重复标注被拦(门禁未被削弱)', () => { + const blocked = checkCallAllowed( + 'auto_annotate', + { paths: ['data/1.jpg'] }, + verifyCtx, + ); + expect(blocked).toContain('请勿重复标注'); + }); + + it('路径归一化后仍能命中(./ 前缀与反斜杠)', () => { + expect( + checkCallAllowed( + 'auto_annotate', + { paths: ['./data\\1.jpg'] }, + verifyCtx, + ), + ).toContain('请勿重复标注'); + }); + + it('未涉及的路径放行', () => { + expect( + checkCallAllowed('auto_annotate', { paths: ['data/9.jpg'] }, verifyCtx), + ).toBeNull(); + }); + + it('all_files=true 仍被拦截', () => { + expect( + checkCallAllowed('auto_annotate', { all_files: true }, verifyCtx), + ).toContain('禁止 all_files=true'); + }); + + it('未指明 paths 时要求明确指定', () => { + expect(checkCallAllowed('auto_annotate', {}, verifyCtx)).toContain( + '未指明 paths', + ); + }); + + it('mutate_annotation 的定向修正放行', () => { + expect( + checkCallAllowed( + 'mutate_annotation', + { paths: ['data/1.jpg'], annotation_ids: ['a1'] }, + verifyCtx, + ), + ).toBeNull(); + }); + + it('删除后重标:删除不产生门禁,auto_annotate 放行', () => { + const ctx = deriveTaskPhase([applied('data/1.jpg', 'delete')]); + expect( + checkCallAllowed('auto_annotate', { paths: ['data/1.jpg'] }, ctx), + ).toBeNull(); + }); +}); diff --git a/src/main/agent/loop/taskPhase.ts b/src/main/agent/loop/taskPhase.ts index d3515ef..eb3a5d6 100644 --- a/src/main/agent/loop/taskPhase.ts +++ b/src/main/agent/loop/taskPhase.ts @@ -26,17 +26,31 @@ export const WORKSPACE_WRITE_TOOLS: ReadonlySet = new Set([ ]); export type TaskPhase = - | 'scan' - | 'annotate' - | 'await_confirm' - | 'verify' - | 'report'; + 'scan' | 'annotate' | 'await_confirm' | 'verify' | 'report'; + +/** + * 会「产生标注」的操作。 + * + * 只有这些操作才能证明某文件在本轮已被标注;`patch` / `delete` 是编辑已有标注, + * 不产生任何框,若也计入 `appliedAnnotationPaths`,「先删除再重新标注」会在 + * `verify` 阶段被自己的前置删除动作拦死(auto_annotate 被拒,任务无法完成)。 + */ +const GENERATIVE_ANNOTATION_OPS: ReadonlySet = new Set([ + 'append', + 'replace', + 'replace_bboxes', +]); export interface ProposalStateLike { path: string; kind?: string; status?: string; + operation?: string | null; annotationIds?: string[]; + /** 提案来源流水线(batch / mutation),仅作诊断与提示,门禁以 operation 为准 */ + sourceKind?: string | null; + /** 是否属于当前对话轮;缺省视为本轮(保守:仍参与门禁) */ + inCurrentTurn?: boolean; } export interface TaskPhaseContext { @@ -63,6 +77,14 @@ export function normalizeRelPath(path: string): string { * * 返回 `null` 表示不启用门禁:无 `proposal_states`(旧客户端 / 纯问答 / 无提案历史), * 或所有提案均为 dismissed(用户关闭提案,重新规划)。 + * + * `pending` 与 `applied` 的聚合口径**故意不同**: + * + * - `pending`(await_confirm 门禁):按**全会话**聚合。未确认提案在磁盘上确实 + * 不存在,跨轮也必须拦住写入,否则会「报告跑在落盘前」或覆盖未决变更。 + * - `applied`(verify 门禁):只看**本轮**,且只看生成类操作。它表达的是 + * 「别在同一轮里重复标注刚标过的文件」(防循环);跨轮时用户已显式重新 + * 下达指令,必须放行 —— 阶段提示词也正是让模型引导用户重新发起请求。 */ export function deriveTaskPhase( proposalStates: ProposalStateLike[] | null | undefined, @@ -77,10 +99,13 @@ export function deriveTaskPhase( if (state.kind !== 'annotation') continue; if (state.status === 'pending') pending.add(normalizeRelPath(state.path)); if (state.status === 'applied') { - applied.add(normalizeRelPath(state.path)); + // 生成的标注 id 无论是否本轮都可作为「已知标注」供定向修正使用 for (const id of state.annotationIds ?? []) { if (id) appliedIds.add(id); } + if (state.inCurrentTurn === false) continue; + if (!GENERATIVE_ANNOTATION_OPS.has(state.operation ?? '')) continue; + applied.add(normalizeRelPath(state.path)); } } @@ -144,7 +169,9 @@ export function coerceBool(value: unknown): boolean { } /** 从标注工具参数中提取目标路径集合(paths 优先,兼容 scope_hint)。 */ -export function extractCallPaths(arguments_: Record): Set { +export function extractCallPaths( + arguments_: Record, +): Set { const paths = new Set(); for (const item of coerceStrList(arguments_.paths)) { paths.add(normalizeRelPath(item)); diff --git a/src/main/agent/mcp/client.sse.test.ts b/src/main/agent/mcp/client.sse.test.ts new file mode 100644 index 0000000..407d82f --- /dev/null +++ b/src/main/agent/mcp/client.sse.test.ts @@ -0,0 +1,114 @@ +/** + * @jest-environment node + * + * 验证 MCP 客户端对 SSE 与 streamable_http 两种传输的连接生命周期差异: + * - SSE 是有状态长连接:发现工具时建立的连接要复用给 tools/call, + * 不能每次调用都重新 connect(否则「按会话绑定」的服务端列出工具成功、调用失败)。 + * - streamable_http 无状态:发现连接用完即关,调用按需建连(现状)。 + */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { clearMcpToolsCache, loadMcpToolsFromServers } from './client'; + +jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: jest.fn(), +})); +jest.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ + SSEClientTransport: jest.fn(), +})); +jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: jest.fn(), +})); + +const ClientMock = Client as unknown as jest.Mock; + +/** 构造一个 mock 客户端实例,每个用例独立,close 计数互不污染。 */ +function mockClientInstance() { + return { + connect: jest.fn<() => Promise>().mockResolvedValue(undefined), + listTools: jest.fn<() => Promise>().mockResolvedValue({ + tools: [ + { + name: 'echo', + description: 'echo tool', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }), + callTool: jest + .fn<() => Promise>() + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }), + close: jest.fn<() => Promise>().mockResolvedValue(undefined), + }; +} + +const sseServer = { + id: 'srv-sse', + url: 'https://example.com/sse', + transport: 'sse' as const, + headers: {}, + disabledTools: [], +}; + +const httpServer = { + id: 'srv-http', + url: 'https://example.com/mcp', + transport: 'streamable_http' as const, + headers: {}, + disabledTools: [], +}; + +beforeEach(() => { + // 先清理缓存(会关闭上一用例遗留的 SSE 连接),再清空 mock 计数。 + clearMcpToolsCache(); + ClientMock.mockClear(); +}); + +describe('SSE 连接复用', () => { + it('发现时建连一次,工具调用复用同一条连接(不重新 connect、不 close)', async () => { + const instance = mockClientInstance(); + ClientMock.mockImplementation(() => instance); + + const tools = await loadMcpToolsFromServers({ + remoteServers: [sseServer], + ttlSeconds: 0, + }); + + expect(tools).toHaveLength(1); + expect(tools[0]?.name).toBe('echo'); + // 发现阶段:建连一次 + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(instance.listTools).toHaveBeenCalledTimes(1); + + // 调用工具:复用发现连接,不重新建连 + const result = await tools[0]!.execute!({}, undefined as never); + expect(result).toBe('ok'); + expect(instance.callTool).toHaveBeenCalledTimes(1); + expect(ClientMock).toHaveBeenCalledTimes(1); + // 复用连接既不在发现结束、也不在调用结束时被关闭 + expect(instance.close).not.toHaveBeenCalled(); + }); +}); + +describe('streamable_http 按需建连(保持现状)', () => { + it('发现连接用完即关,工具调用重新建连', async () => { + const instance = mockClientInstance(); + ClientMock.mockImplementation(() => instance); + + const tools = await loadMcpToolsFromServers({ + remoteServers: [httpServer], + ttlSeconds: 0, + }); + + expect(tools).toHaveLength(1); + // 发现阶段:建连一次,发现后关闭 + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(instance.close).toHaveBeenCalledTimes(1); + + // 调用工具:按需重建一条连接,用完再关 + await tools[0]!.execute!({}, undefined as never); + expect(ClientMock).toHaveBeenCalledTimes(2); + expect(instance.callTool).toHaveBeenCalledTimes(1); + expect(instance.close).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/main/agent/mcp/client.ts b/src/main/agent/mcp/client.ts index 33be2c8..87fb8ae 100644 --- a/src/main/agent/mcp/client.ts +++ b/src/main/agent/mcp/client.ts @@ -15,9 +15,12 @@ * 3. **名字去重**。`_infer_mcp_capability` 当前对全部本地 MCP 工具返回 null, * 因此实际去重靠「名字已在前面 server 出现过则跳过」。 * - * 与 Python 的一处实现差异:工具调用**按需建连**(每次 `callTool` 独立握手), - * 不长期持有连接。这样无需管理连接生命周期与失效重连,代价是每次调用多一次 - * `initialize` 往返——对 HTTP 传输可接受,且换来无状态、无泄漏。 + * 与 Python 的一处实现差异:工具调用的连接策略按传输区分—— + * - `streamable_http` 无状态,**按需建连**(每次 `callTool` 独立握手), + * 发现连接用完即关,换来无状态、无泄漏; + * - `sse` 是有状态长连接,**复用发现时建立的连接**做后续 `callTool`, + * 避免每次调用重新 `initialize` 握手(按会话绑定的服务端,如 ModelScope + * 托管 MCP,会「列出工具成功、调用却失败」)。SSE 连接在刷新/清空缓存时关闭。 */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -77,6 +80,15 @@ const toolsCache = new Map(); const refreshing = new Set(); /** 后台刷新任务,持有强引用避免被回收。 */ const refreshTasks = new Set>(); +/** + * 复用的 SSE 长连接(key → Client)。 + * + * SSE 是有状态长连接:发现工具时建立的连接要保留给后续 `tools/call` 复用, + * 否则每次调用都重新 `initialize` 握手,遇到「按会话绑定」的服务端 + * (如 ModelScope 托管 MCP)会列出工具成功、调用却失败。streamable_http 无状态, + * 仍走按需建连,不在此登记。刷新/清空缓存时统一关闭,避免泄漏。 + */ +const openClients = new Map(); /** 缓存键:url + transport + 排序后的 headers。 */ export function cacheKey(conn: McpConnection): string { @@ -92,10 +104,33 @@ export function clearMcpToolsCache(): void { toolsCache.clear(); refreshing.clear(); refreshTasks.clear(); + for (const client of openClients.values()) { + void client.close().catch(() => { + /* 关闭失败不阻断 */ + }); + } + openClients.clear(); } // ── 连接与调用 ────────────────────────────────────────────────────── +/** 给一个 Promise 加整体超时;超时后拒绝并清理定时器。 */ +async function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + /** 建立客户端并初始化。 */ async function connect(conn: McpConnection): Promise { const client = new Client( @@ -118,7 +153,21 @@ async function connect(conn: McpConnection): Promise { : undefined, }); - await client.connect(transport); + // SSE 的 start() 会等待服务端 `endpoint` 事件,若服务端不发则无限挂起; + // 用 conn.timeoutMs 兜底(默认 DISCOVER_TIMEOUT_MS,probe 用更短的 PROBE_TIMEOUT_MS)。 + const timeoutMs = conn.timeoutMs ?? DISCOVER_TIMEOUT_MS; + try { + await withTimeout( + client.connect(transport), + timeoutMs, + `连接 MCP server 超时(${timeoutMs}ms):${conn.url}`, + ); + } catch (err) { + await client.close().catch(() => { + /* 关闭失败不阻断 */ + }); + throw err; + } return client; } @@ -127,14 +176,27 @@ async function connect(conn: McpConnection): Promise { * * 失败时抛异常,由调用方决定跳过(单台失败隔离)。 */ -async function discoverTools( - conn: McpConnection, - dsn: string, -): Promise { +async function discoverTools(conn: McpConnection): Promise { const client = await connect(conn); + // SSE 是有状态长连接:发现时建立的连接保留给 invoke 复用; + // streamable_http 无状态,发现连接用完即关,invoke 按需建连。 + const reuseConnection = conn.transport === 'sse'; + const key = cacheKey(conn); try { const listed = await client.listTools(); - return (listed.tools ?? []).map((tool) => ({ + + if (reuseConnection) { + // 登记新连接前关闭上一次的同源连接(刷新场景),避免泄漏 + const previous = openClients.get(key); + if (previous && previous !== client) { + await previous.close().catch(() => { + /* 关闭失败不阻断 */ + }); + } + openClients.set(key, client); + } + + const tools = (listed.tools ?? []).map((tool) => ({ name: tool.name, description: tool.description ?? '', // MCP 的 inputSchema 就是 JSON Schema,直接透传 @@ -143,8 +205,7 @@ async function discoverTools( properties: {}, }, invoke: async (args: Record): Promise => { - // 按需建连:避免长期持有连接带来的生命周期与重连问题 - const callClient = await connect(conn); + const callClient = reuseConnection ? client : await connect(conn); try { const result = await callClient.callTool({ name: tool.name, @@ -152,17 +213,27 @@ async function discoverTools( }); return stringifyMcpResult(result); } finally { - await callClient.close().catch(() => { - /* 关闭失败不阻断 */ - }); + if (!reuseConnection) { + await callClient.close().catch(() => { + /* 关闭失败不阻断 */ + }); + } } }, })); - } finally { + + if (!reuseConnection) { + await client.close().catch(() => { + /* 关闭失败不阻断 */ + }); + } + return tools; + } catch (err) { + openClients.delete(key); await client.close().catch(() => { /* 关闭失败不阻断 */ }); - void dsn; + throw err; } } @@ -339,7 +410,7 @@ export async function loadMcpToolsFromServers( if (pending.size > 0) { const entries = [...pending.entries()]; const results = await Promise.allSettled( - entries.map(([name, conn]) => discoverTools(conn, name)), + entries.map(([, conn]) => discoverTools(conn)), ); entries.forEach(([name, conn], index) => { const result = results[index]; @@ -409,7 +480,7 @@ function scheduleRefresh( const task = (async () => { try { - const tools = await discoverTools(conn, name); + const tools = await discoverTools(conn); if (ttlSeconds > 0) { toolsCache.set(key, { tools, @@ -451,7 +522,7 @@ export async function probeMcpTools(params: { const headers = params.headers ?? {}; if (Object.keys(headers).length > 0) conn.headers = { ...headers }; - const tools = await discoverTools(conn, 'mcp-probe'); + const tools = await discoverTools(conn); return tools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/src/main/agent/schemas.ts b/src/main/agent/schemas.ts index 34b9d82..b167af7 100644 --- a/src/main/agent/schemas.ts +++ b/src/main/agent/schemas.ts @@ -81,7 +81,9 @@ function strOrNull(value: unknown): string | null { } function strArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : []; } /** 解析标注项目快照(snake_case → 内部形态)。 */ @@ -96,7 +98,9 @@ function parseSnapshot(value: unknown): AnnotationProjectSnapshot | null { modality: str(raw.modality), annotationType: str(raw.annotation_type), labels: Array.isArray(raw.labels) - ? (raw.labels.filter((l) => asRecord(l)) as Array>) + ? (raw.labels.filter((l) => asRecord(l)) as Array< + Record + >) : [], detectionModels: Array.isArray(raw.detection_models) ? (raw.detection_models.filter((m) => asRecord(m)) as Array< @@ -139,7 +143,9 @@ function parseClientContext(value: unknown): ParsedClientContext | null { id: str(item.id), url: str(item.url), transport: - item.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const), + item.transport === 'sse' + ? ('sse' as const) + : ('streamable_http' as const), headers: (asRecord(item.headers) as Record | null) ?? {}, disabledTools: strArray(item.disabled_tools), @@ -166,7 +172,11 @@ function parseClientContext(value: unknown): ParsedClientContext | null { path: str(item.path), kind: str(item.kind) || 'annotation', status: str(item.status) || 'pending', + operation: strOrNull(item.operation), annotationIds: strArray(item.annotation_ids), + // 缺失时按最保守解读:来源未知(不参与生成类判定)、视为本轮 + sourceKind: strOrNull(item.source_kind), + inCurrentTurn: item.in_current_turn !== false, })) : [], }; @@ -180,8 +190,13 @@ function parseMessages(value: unknown): ChatMessage[] { for (const item of value) { const raw = asRecord(item); if (!raw) continue; - const role = raw.role; - if (role !== 'user' && role !== 'assistant' && role !== 'system' && role !== 'tool') { + const { role } = raw; + if ( + role !== 'user' && + role !== 'assistant' && + role !== 'system' && + role !== 'tool' + ) { continue; } @@ -270,7 +285,9 @@ export function parseChatStreamRequest(rawBody: string): ParsedChatRequest { userContent, systemPrompt: strOrNull(raw.system_prompt), contextSummary: strOrNull(raw.context_summary), - contextSummaryUpToMessageId: strOrNull(raw.context_summary_up_to_message_id), + contextSummaryUpToMessageId: strOrNull( + raw.context_summary_up_to_message_id, + ), clientContext: parseClientContext(raw.client_context), clientToolResults: parseClientToolResults(raw.client_tool_results), clientJobId, @@ -286,7 +303,7 @@ export function hasToolContext(ctx: ParsedClientContext | null): boolean { if (!ctx) return false; return Boolean( (ctx.workspaceRoot ?? '').trim() || - ctx.activeAnnotationProjectId || - ctx.annotationProjectSnapshot, + ctx.activeAnnotationProjectId || + ctx.annotationProjectSnapshot, ); } diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index ebdecaf..6bb53e0 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -189,6 +189,28 @@ const MutateAnnotationArgs = z.object({ annotation_ids: z.array(z.string()).nullish().describe('画布选中或用户指定的标注 id。'), }); +// 参数名与 `src/main/mcp/server.ts` 的 start_terminal_command 保持一致, +// 前端 runTerminalCommandTool 按 command / args / timeout_ms 读取。 +const StartTerminalCommandArgs = z.object({ + command: z + .string() + .describe( + 'Executable name or path, e.g. "git". Shell builtins and shell syntax (|, >, &&) are not supported', + ), + args: z + .array(z.string()) + .max(128) + .optional() + .describe('Arguments passed verbatim as argv, e.g. ["status", "--short"]'), + timeout_ms: z + .number() + .int() + .positive() + .max(600_000) + .optional() + .describe('Kill the command after this many ms (default 300000, max 600000)'), +}); + const ReadFileAnnotationArgs = z.object({ relative_path: z.string().describe('文件相对路径(如 data/2.jpg)'), annotation_offset: z.number().int().default(0).describe('分页起始偏移'), @@ -578,6 +600,17 @@ export function buildTools(): ToolDefinition[] { kind: 'async', argsSchema: MutateAnnotationArgs, }, + { + name: 'start_terminal_command', + description: + 'Run a terminal command in the current workspace (cwd is locked to it). ' + + 'Runs WITHOUT a shell: no pipes, redirections or command chains — split into multiple calls instead. ' + + 'Every execution requires explicit user approval in the chat UI before it starts, and destructive commands are rejected outright. ' + + 'Returns a job id; the tool result delivered to you contains the exit code and an output tail. ' + + 'Use read_terminal_output for the full/incremental output and kill_terminal_job to abort a long-running job.', + kind: 'async', + argsSchema: StartTerminalCommandArgs, + }, { name: 'explore_readonly', description: diff --git a/src/main/annotation/annotationDataStore.repair.test.ts b/src/main/annotation/annotationDataStore.repair.test.ts new file mode 100644 index 0000000..63870c9 --- /dev/null +++ b/src/main/annotation/annotationDataStore.repair.test.ts @@ -0,0 +1,116 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { + readAnnotationDocJson, + repairAnnotationDocFilePaths, + writeAnnotationDocJson, +} from './annotationDataStore'; + +let projectDir: string; + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ann-repair-')); +}); + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +describe('repairAnnotationDocFilePaths', () => { + it('重写与存储键不符的 filePath', async () => { + // 历史缺陷留下的状态:文档存在 data/1.jpg 的槽位,但内部 filePath 指向别的文件 + await writeAnnotationDocJson(projectDir, 'data/1.jpg', { + projectId: 'p1', + filePath: 'data/7.jpg', + annotations: [{ id: 'a1' }], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const result = await repairAnnotationDocFilePaths(projectDir); + + expect(result.scanned).toBe(1); + expect(result.repaired).toEqual([ + { relativePath: 'data/1.jpg', storedFilePath: 'data/7.jpg' }, + ]); + + const doc = (await readAnnotationDocJson(projectDir, 'data/1.jpg')) as { + filePath?: string; + annotations?: unknown[]; + }; + expect(doc.filePath).toBe('data/1.jpg'); + // 只改归属字段,标注内容必须原样保留 + expect(doc.annotations).toEqual([{ id: 'a1' }]); + }); + + it('补写缺失的 filePath', async () => { + await writeAnnotationDocJson(projectDir, 'data/2.jpg', { + projectId: 'p1', + annotations: [], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const result = await repairAnnotationDocFilePaths(projectDir); + expect(result.repaired).toEqual([ + { relativePath: 'data/2.jpg', storedFilePath: null }, + ]); + }); + + it('幂等:已一致时不写盘也不上报', async () => { + await writeAnnotationDocJson(projectDir, 'data/3.jpg', { + projectId: 'p1', + filePath: 'data/3.jpg', + annotations: [], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const first = await repairAnnotationDocFilePaths(projectDir); + expect(first.repaired).toEqual([]); + + const second = await repairAnnotationDocFilePaths(projectDir); + expect(second).toEqual({ scanned: 1, repaired: [] }); + }); + + it('无索引时安全返回空结果', async () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'ann-empty-')); + try { + await expect(repairAnnotationDocFilePaths(empty)).resolves.toEqual({ + scanned: 0, + repaired: [], + }); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + }); + + it('索引存在但文档缺失时跳过', async () => { + await writeAnnotationDocJson(projectDir, 'data/4.jpg', { + projectId: 'p1', + filePath: 'data/9.jpg', + annotations: [], + updatedAt: '2026-01-01T00:00:00.000Z', + }); + const index = JSON.parse( + await fs.readFile( + path.join(projectDir, '.lr-agent', 'annotations', 'index.json'), + 'utf8', + ), + ) as { files: Record }; + // 手动把文档文件删掉,索引仍保留该条目 + await fs.remove( + path.join( + projectDir, + '.lr-agent', + 'annotations', + 'files', + `${index.files['data/4.jpg'].fileKey}.json`, + ), + ); + + await expect(repairAnnotationDocFilePaths(projectDir)).resolves.toEqual({ + scanned: 0, + repaired: [], + }); + }); +}); diff --git a/src/main/annotation/annotationDataStore.ts b/src/main/annotation/annotationDataStore.ts index d7acabc..44635bb 100644 --- a/src/main/annotation/annotationDataStore.ts +++ b/src/main/annotation/annotationDataStore.ts @@ -216,3 +216,65 @@ export async function writeAnnotationDocJson( await writeIndex(projectDir, index); } + +/** 单份文档的 `filePath` 与存储键不符时的修复记录。 */ +export interface AnnotationFilePathRepair { + relativePath: string; + storedFilePath: string | null; +} + +export interface AnnotationFilePathRepairResult { + /** 索引中的文档总数(含文档缺失而跳过的) */ + scanned: number; + repaired: AnnotationFilePathRepair[]; +} + +/** + * 把每份标注文档内的 `filePath` 重写为它在索引中的相对路径。 + * + * 归属真源是「存储键 = 相对路径的 hash」,文档内的 `filePath` 只是冗余字段。 + * 历史上防抖错配会把别的文件路径写进该字段;渲染层加载时会就地修正,但只有 + * 被打开过的文件才会顺带修好磁盘,其余长期残留 —— 而加载时的修正本身会改写 + * 字节,导致 `checkpoint.afterHash` 失配、Undo 被判定为「文件已改动」而拒绝。 + * + * 只改 `filePath`,不动 `source` 与 `annotations`:磁盘实测显示这些文档的 + * 归属键、`source` 元数据与标注数量都与索引一致,仅该冗余字段陈旧。 + * 若将来发现 `source` 与索引不符,那才是真正的错配,需另行处理。 + * + * 幂等:已一致时不会写盘。 + */ +export async function repairAnnotationDocFilePaths( + projectDir: string, +): Promise { + const index = await readIndex(projectDir); + if (!index) return { scanned: 0, repaired: [] }; + + const repaired: AnnotationFilePathRepair[] = []; + let scanned = 0; + + for (const entry of Object.values(index.files)) { + const dp = docPath(projectDir, entry.fileKey); + if (!(await fs.pathExists(dp))) continue; + let doc: unknown; + try { + doc = await fs.readJson(dp); + } catch { + continue; // 损坏文档交由其他流程处理,不在此处吞掉 + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) continue; + scanned += 1; + + const record = doc as { filePath?: unknown }; + const stored = typeof record.filePath === 'string' ? record.filePath : null; + if (stored === entry.relativePath) continue; + + await fs.writeJson( + dp, + { ...(doc as Record), filePath: entry.relativePath }, + { spaces: 2 }, + ); + repaired.push({ relativePath: entry.relativePath, storedFilePath: stored }); + } + + return { scanned, repaired }; +} diff --git a/src/main/checkpoint/handlers.ts b/src/main/checkpoint/handlers.ts index 9335ca0..93a2fab 100644 --- a/src/main/checkpoint/handlers.ts +++ b/src/main/checkpoint/handlers.ts @@ -55,8 +55,13 @@ export function registerCheckpointHandlers(): void { ipcMain.handle( 'agent:checkpoint:restore', - (_event, payload: CheckpointRef & CheckpointRoots) => { - return restoreCheckpoint(asRef(payload), asRoots(payload)); + ( + _event, + payload: CheckpointRef & CheckpointRoots & { force?: boolean }, + ) => { + return restoreCheckpoint(asRef(payload), asRoots(payload), { + force: payload.force === true, + }); }, ); diff --git a/src/main/checkpoint/turnCheckpointStore.test.ts b/src/main/checkpoint/turnCheckpointStore.test.ts index fdf0c22..e707ea5 100644 --- a/src/main/checkpoint/turnCheckpointStore.test.ts +++ b/src/main/checkpoint/turnCheckpointStore.test.ts @@ -7,6 +7,7 @@ import { writeScopedTextFile } from '../workspace/workspaceWrite'; import { captureCheckpoint, hashContent, + hasCheckpoint, recordCheckpointAfter, restoreCheckpoint, } from './turnCheckpointStore'; @@ -172,4 +173,85 @@ describe('turnCheckpointStore', () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + /** + * 回归:`recordAfter` 失败(未写入 afterHash)时快照必须仍然可用。 + * + * 历史实现会在 recordAfter 抛错时 discard 快照,于是一次瞬时失败就让 Undo + * 入口永久消失且无法补救。还原只需要 `beforeMissing` + blob,`afterHash` + * 仅服务于脏检查,因此「无 afterHash」不该等于「无快照」。 + */ + it('treats a snapshot without afterHash as existing and force-restorable', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ckpt-force-')); + try { + await writeScopedTextFile(root, 'notes.md', 'before'); + await captureCheckpoint( + ref, + 'file', + { workspaceRoot: root }, + { filePaths: ['notes.md'] }, + ); + await writeScopedTextFile(root, 'notes.md', 'after'); + // 故意不调用 recordCheckpointAfter:模拟 recordAfter 失败 + + await expect(hasCheckpoint(ref)).resolves.toBe(true); + + // 默认仍拒绝(快照不完整),但提供强制通道 + const refused = await restoreCheckpoint(ref, { workspaceRoot: root }); + expect(refused).toEqual({ ok: false, error: 'checkpoint_incomplete' }); + + const forced = await restoreCheckpoint( + ref, + { workspaceRoot: root }, + { force: true }, + ); + expect(forced.ok).toBe(true); + expect(await fs.readFile(path.join(root, 'notes.md'), 'utf8')).toBe( + 'before', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + /** + * 回归:文件在应用之后又被改动(脏),强制回滚应能覆盖。 + * + * 「脏」的常见成因是正常操作而非用户手改 —— 例如加载流水线修正文档内陈旧的 + * `filePath` 后写回。若不允许覆盖,Undo 会因这类良性写入而永久失效。 + */ + it('force restore overrides the dirty check', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ckpt-force-dirty-')); + try { + await writeScopedTextFile(root, 'notes.md', 'before'); + await captureCheckpoint( + ref, + 'file', + { workspaceRoot: root }, + { filePaths: ['notes.md'] }, + ); + await writeScopedTextFile(root, 'notes.md', 'after'); + await recordCheckpointAfter(ref, { workspaceRoot: root }); + await writeScopedTextFile(root, 'notes.md', 'benign-rewrite'); + + const refused = await restoreCheckpoint(ref, { workspaceRoot: root }); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error).toBe('checkpoint_dirty'); + expect(refused.dirtyPaths).toEqual(['notes.md']); + } + + const forced = await restoreCheckpoint( + ref, + { workspaceRoot: root }, + { force: true }, + ); + expect(forced.ok).toBe(true); + expect(await fs.readFile(path.join(root, 'notes.md'), 'utf8')).toBe( + 'before', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/main/checkpoint/turnCheckpointStore.ts b/src/main/checkpoint/turnCheckpointStore.ts index f7d782b..88a5358 100644 --- a/src/main/checkpoint/turnCheckpointStore.ts +++ b/src/main/checkpoint/turnCheckpointStore.ts @@ -275,11 +275,9 @@ export async function discardCheckpoint(ref: CheckpointRef): Promise { export async function hasCheckpoint(ref: CheckpointRef): Promise { const manifest = await readManifest(resolveBlockDir(ref)); - return Boolean( - manifest && - manifest.entries.length > 0 && - manifest.entries.every((entry) => Boolean(entry.afterHash)), - ); + // 不要求 afterHash 齐全:`beforeMissing` + blob 已足够还原,「改后哈希」只用于 + // 脏检查。若因 recordAfter 失败就判定「无快照」,会让 Undo 入口凭空消失。 + return Boolean(manifest && manifest.entries.length > 0); } async function currentHash( @@ -300,32 +298,46 @@ async function currentHash( return hashContent(read.content); } +/** + * 还原到改前状态。 + * + * 默认执行两道安全检查:快照必须完整(`afterHash` 齐全),且当前文件必须与 + * 「改后状态」逐字节一致(否则说明之后又被人改动)。 + * + * `force: true` 同时跳过这两道检查,用于用户已确认「就用这个快照覆盖」的场景。 + * 还原本身只需要 `beforeMissing` 与 blob,`afterHash` 只服务于脏检查, + * 因此强制还原在功能上是完备的 —— 这是快照因后续写入而「永久失效」的唯一出路。 + */ export async function restoreCheckpoint( ref: CheckpointRef, roots: CheckpointRoots, + options?: { force?: boolean }, ): Promise { const dir = resolveBlockDir(ref); const manifest = await readManifest(dir); if (!manifest) { return { ok: false, error: 'checkpoint_not_found' }; } - if (manifest.entries.some((entry) => !entry.afterHash)) { - return { ok: false, error: 'checkpoint_incomplete' }; - } - const dirtyPaths: string[] = []; - for (const entry of manifest.entries) { - const current = await currentHash(entry, roots); - if (current !== entry.afterHash) { - dirtyPaths.push(entry.path); + if (!options?.force) { + if (manifest.entries.some((entry) => !entry.afterHash)) { + return { ok: false, error: 'checkpoint_incomplete' }; + } + + const dirtyPaths: string[] = []; + for (const entry of manifest.entries) { + const current = await currentHash(entry, roots); + if (current !== entry.afterHash) { + dirtyPaths.push(entry.path); + } + } + if (dirtyPaths.length > 0) { + return { + ok: false, + error: 'checkpoint_dirty', + dirtyPaths, + }; } - } - if (dirtyPaths.length > 0) { - return { - ok: false, - error: 'checkpoint_dirty', - dirtyPaths, - }; } const restoredPaths: string[] = []; diff --git a/src/main/main.ts b/src/main/main.ts index d6fbf23..012786d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -65,6 +65,7 @@ import { } from './annotation/annotationStore'; import { readAnnotationDocJson, + repairAnnotationDocFilePaths, writeAnnotationDocJson, } from './annotation/annotationDataStore'; import { runAnnotationExport } from './annotation/annotationExportEngine'; @@ -548,6 +549,19 @@ ipcMain.handle( }, ); +ipcMain.handle( + 'annotation:repairDocFilePaths', + async (_event, projectDir: string) => { + authorizeRoot(projectDir); + try { + return await repairAnnotationDocFilePaths(projectDir); + } catch (err) { + console.error('[annotation] 修复文档归属路径失败:', err); + return { scanned: 0, repaired: [] }; + } + }, +); + ipcMain.handle( 'annotation:writeFileAnnotationDoc', async ( diff --git a/src/main/mcp/server.ts b/src/main/mcp/server.ts index 7a044db..461dd68 100644 --- a/src/main/mcp/server.ts +++ b/src/main/mcp/server.ts @@ -352,8 +352,8 @@ function createMcpServer(): McpServer { 'Kill the command after this many ms (default 300000, max 600000)', ), }, - // Agent loop 以 ASYNC runner 分派本工具(TOOL_RUNNERS),不会走到这里; - // handler 仅在其他 MCP 客户端直调时说明真实入口。 + // 内部 Agent 通过内置 async 工具(registry.ts 的 start_terminal_command)走 + // tool_pending → 前端批准链路,不会走到这里;此 handler 仅拦截外部 MCP 客户端直调。 async () => mcpJson({ ok: false, diff --git a/src/main/preload.ts b/src/main/preload.ts index 61e3cc3..c1dd857 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -172,6 +172,7 @@ const electronHandler = { | 'modelType' | 'checkpointPath' | 'configPath' + | 'detectionMode' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' @@ -253,6 +254,16 @@ const electronHandler = { projectDir, relativePath, ), + /** + * 修复标注文档内陈旧的 `filePath`(归属真源是存储键)。 + * 幂等,返回修复明细;已一致时不写盘。 + */ + repairDocFilePaths: ( + projectDir: string, + ): Promise<{ + scanned: number; + repaired: Array<{ relativePath: string; storedFilePath: string | null }>; + }> => ipcRenderer.invoke('annotation:repairDocFilePaths', projectDir), writeFileAnnotationDoc: ( projectDir: string, relativePath: string, @@ -619,6 +630,8 @@ const electronHandler = { blockIndex: number; projectDir?: string; workspaceRoot?: string; + /** true 时跳过「快照完整 / 未被后续改动」两道检查,用于用户确认覆盖 */ + force?: boolean; }): Promise<{ ok: boolean; restoredPaths?: string[]; diff --git a/src/main/pretrainedModels/pretrainedModelHandlers.ts b/src/main/pretrainedModels/pretrainedModelHandlers.ts index 1f42593..1a66268 100644 --- a/src/main/pretrainedModels/pretrainedModelHandlers.ts +++ b/src/main/pretrainedModels/pretrainedModelHandlers.ts @@ -28,6 +28,7 @@ export default function registerPretrainedModelHandlers(): void { | 'modelType' | 'checkpointPath' | 'configPath' + | 'detectionMode' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' diff --git a/src/main/pretrainedModels/pretrainedModelStore.ts b/src/main/pretrainedModels/pretrainedModelStore.ts index bc9de56..f3e223c 100644 --- a/src/main/pretrainedModels/pretrainedModelStore.ts +++ b/src/main/pretrainedModels/pretrainedModelStore.ts @@ -12,6 +12,7 @@ import { type PretrainedModelValidationResult, type Sam2ScanResult, } from '../../shared/pretrainedModelTypes'; +import { describeDetectionModeMismatch } from '../../shared/preAnnotTypes'; export type { KeypointAssetScanResult, @@ -363,6 +364,7 @@ export async function validatePretrainedModel( | 'modelType' | 'checkpointPath' | 'configPath' + | 'detectionMode' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' @@ -390,6 +392,12 @@ export async function validatePretrainedModel( if (!checkpointPath.toLowerCase().endsWith('.pt')) { warnings.push('权重文件扩展名不是 .pt'); } + // 检测模式写错会让模型在标注界面静默消失(详见 describeDetectionModeMismatch)。 + const mismatch = describeDetectionModeMismatch({ + detectionMode: model.detectionMode, + checkpointPath, + }); + if (mismatch) warnings.push(mismatch); } else if (model.modelType === 'keypoint_estimation') { const backend = model.keypointBackend; if (backend === 'yolo_pose') { diff --git a/src/renderer/components/agent/AgentExplorationBlock.tsx b/src/renderer/components/agent/AgentExplorationBlock.tsx index 6710517..daf0e07 100644 --- a/src/renderer/components/agent/AgentExplorationBlock.tsx +++ b/src/renderer/components/agent/AgentExplorationBlock.tsx @@ -6,6 +6,7 @@ import { } from '../../services/toolDisplayUtils'; import type { ToolCallBlock } from './explorationRenderUtils'; import AgentScrollablePre from './AgentScrollablePre'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './AgentReasoningBlock.css'; interface AgentExplorationBlockProps { @@ -47,7 +48,19 @@ export default function AgentExplorationBlock({ {label} {!collapsed && ( -
+ + `${tool.id}:${tool.status}:${tool.arguments.length}:${ + tool.result?.length ?? 0 + }`, + ) + .join('|')} + contentClassName="agent-reasoning-body agent-collapsible-scroll" + > {tools.map((tool) => { const toolLabel = formatToolCallLabel(tool.name, tool.arguments); const toolExpanded = expandedToolIds[tool.id] ?? false; @@ -90,7 +103,7 @@ export default function AgentExplorationBlock({
); })} -
+ )} ); diff --git a/src/renderer/components/agent/AgentPanel.css b/src/renderer/components/agent/AgentPanel.css index 56bcfb0..43ba2c5 100644 --- a/src/renderer/components/agent/AgentPanel.css +++ b/src/renderer/components/agent/AgentPanel.css @@ -22,6 +22,15 @@ --agent-font-meta: var(--lr-ui-font-meta); --agent-font-caption: var(--lr-ui-font-caption); --agent-font-code: var(--lr-ui-font-small); + + /* 思考输出用更淡的前景色,与正文拉开层次 */ + --agent-reasoning-foreground: color-mix( + in srgb, + var(--vscode-descriptionForeground, #9d9d9d) 88%, + transparent + ); + /* 思考 / 工具调用展开体的统一高度上限,超出后内部滚动 */ + --agent-collapsible-max-height: 280px; } [data-theme='light'] .agent-panel-root { diff --git a/src/renderer/components/agent/AgentReasoningBlock.css b/src/renderer/components/agent/AgentReasoningBlock.css index 759d07d..ab58867 100644 --- a/src/renderer/components/agent/AgentReasoningBlock.css +++ b/src/renderer/components/agent/AgentReasoningBlock.css @@ -23,12 +23,18 @@ margin-top: 6px; padding-left: 2px; font-size: var(--agent-font-small); - color: var(--vscode-descriptionForeground, #9d9d9d); + color: var( + --agent-reasoning-foreground, + var(--vscode-descriptionForeground, #9d9d9d) + ); } .agent-reasoning-body .agent-markdown { font-size: var(--agent-font-small); line-height: 1.5; + /* AgentMarkdown 自带 color: var(--vscode-foreground), + 不显式继承会让上面的淡色失效 */ + color: inherit; } /* Tool blocks: borderless, aligned with pipeline / reasoning */ @@ -126,3 +132,15 @@ .agent-terminal-btn:hover { filter: brightness(1.15); } + +/* ── 展开体限高滚动(思考 / 工具调用 / 探索) ── + 作为 OverlayVerticalScrollArea 的 contentClassName 使用。 + 注意:工作历史("Worked for …")容器本身不限高,由内部各块自己限高, + 否则整段历史会出现一个把折叠标签也卷进去的滚动条。 + 置于文件末尾,保证 padding-right 能覆盖 .agent-tool-body 的 padding 简写。 */ +.agent-collapsible-scroll { + /* 给右侧垂直滑块留出空隙,避免压住文字 */ + padding-right: 10px; + /* 滚到边界后不再把滚动接力给外层消息列表 */ + overscroll-behavior: contain; +} diff --git a/src/renderer/components/agent/AgentReasoningBlock.tsx b/src/renderer/components/agent/AgentReasoningBlock.tsx index ff87b27..ef46a41 100644 --- a/src/renderer/components/agent/AgentReasoningBlock.tsx +++ b/src/renderer/components/agent/AgentReasoningBlock.tsx @@ -1,6 +1,7 @@ import { VscodeIcon } from '@vscode-elements/react-elements'; import type { MessageBlock } from '../../types/agent'; import AgentMarkdown from './AgentMarkdown'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './AgentReasoningBlock.css'; interface AgentReasoningBlockProps { @@ -34,9 +35,14 @@ export default function AgentReasoningBlock({ {label} {!block.collapsed && block.content.trim() && ( -
+ -
+ )} ); diff --git a/src/renderer/components/agent/AgentToolCallBlock.tsx b/src/renderer/components/agent/AgentToolCallBlock.tsx index 0d0c932..2d05058 100644 --- a/src/renderer/components/agent/AgentToolCallBlock.tsx +++ b/src/renderer/components/agent/AgentToolCallBlock.tsx @@ -6,6 +6,7 @@ import { } from '../../services/toolDisplayUtils'; import { resolveTerminalApproval } from '../../services/terminalApproval'; import AgentScrollablePre from './AgentScrollablePre'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './AgentReasoningBlock.css'; /* shared tool + reasoning tokens */ interface AgentToolCallBlockProps { @@ -69,7 +70,12 @@ export default function AgentToolCallBlock({ )} {!block.collapsed && ( -
+
参数
{block.arguments || '{}'} @@ -86,7 +92,7 @@ export default function AgentToolCallBlock({ {displayResult}
)} -
+ )} ); diff --git a/src/renderer/components/annotation/PreAnnotToolbarSection.css b/src/renderer/components/annotation/PreAnnotToolbarSection.css index ab0882c..0c783ef 100644 --- a/src/renderer/components/annotation/PreAnnotToolbarSection.css +++ b/src/renderer/components/annotation/PreAnnotToolbarSection.css @@ -36,3 +36,10 @@ height: 14px; flex-shrink: 0; } + +.preannot-model-hint { + max-width: 460px; + font-size: var(--lr-ui-font-caption); + line-height: 1.4; + color: var(--vscode-editorWarning-foreground, #cca700); +} diff --git a/src/renderer/components/annotation/PreAnnotToolbarSection.tsx b/src/renderer/components/annotation/PreAnnotToolbarSection.tsx index acba7be..4efe4e2 100644 --- a/src/renderer/components/annotation/PreAnnotToolbarSection.tsx +++ b/src/renderer/components/annotation/PreAnnotToolbarSection.tsx @@ -22,6 +22,7 @@ import { isPoseResult, } from '../../../shared/preAnnotTypes'; import { + describeNoEligibleModelReason, getEligiblePreAnnotModels, loadSavedPreAnnotModelId, pickDefaultPreAnnotModel, @@ -106,6 +107,19 @@ export default function PreAnnotToolbarSection({ [eligibleModels, selectedModelId], ); + // 仅在空态时计算,避免每次渲染都扫全量模型 + const noModelReason = useMemo( + () => + eligibleModels.length === 0 + ? describeNoEligibleModelReason( + annotationType, + models, + mode === 'keypoint' ? activeTemplateId : undefined, + ) + : null, + [annotationType, eligibleModels.length, models, mode, activeTemplateId], + ); + useEffect(() => { if (!activeProject || eligibleModels.length === 0) { setSelectedModelId(''); @@ -319,6 +333,7 @@ export default function PreAnnotToolbarSection({ value={selectedModelId} disabled={controlsDisabled || eligibleModels.length === 0} aria-label="选择预训练模型" + title={noModelReason ?? undefined} onChange={(event) => { const target = event.target as HTMLElement & { value?: string }; if (typeof target.value === 'string') { @@ -339,6 +354,12 @@ export default function PreAnnotToolbarSection({ )} + {noModelReason ? ( + + {noModelReason} + + ) : null} + {running ? : null} {(mode === 'bbox' || mode === 'rotated_bbox') && ( diff --git a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.css b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.css index 1d44ad2..e68335e 100644 --- a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.css +++ b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.css @@ -70,6 +70,15 @@ color: var(--vscode-descriptionForeground, #9d9d9d); } +.pretrained-model-form-warning { + margin: 8px 0 0; + padding-left: 8px; + border-left: 2px solid var(--vscode-editorWarning-foreground, #cca700); + font-size: var(--lr-ui-font-caption); + line-height: 1.45; + color: var(--vscode-editorWarning-foreground, #cca700); +} + .pretrained-model-type-options { display: flex; flex-direction: column; diff --git a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx index b6afaa1..5a4a2b2 100644 --- a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx +++ b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx @@ -12,6 +12,7 @@ import { type ObjectDetectionMode, } from '../../types/pretrainedModel'; import { KEYPOINT_TEMPLATES } from '../../types/keypointTemplate'; +import { describeDetectionModeMismatch } from '../../../shared/preAnnotTypes'; import { pickModelFile, pickSam2RootDirectory, @@ -289,6 +290,7 @@ export default function PretrainedModelFormModal({ modelType: form.modelType, checkpointPath: form.checkpointPath, configPath: form.configPath, + detectionMode: form.detectionMode, keypointBackend: form.keypointBackend, keypointTemplateIds: form.keypointTemplateIds, auxiliaryPaths: form.auxiliaryPaths, @@ -335,6 +337,15 @@ export default function PretrainedModelFormModal({ ? KEYPOINT_BACKEND_PRESETS[form.keypointBackend].hint : null; + // 检测模式写错会让模型在标注界面静默消失,故在保存前就实时提示 + const detectionModeWarning = + form.modelType === 'object_detection' + ? describeDetectionModeMismatch({ + detectionMode: form.detectionMode, + checkpointPath: form.checkpointPath, + }) + : null; + const checkpointLabel = form.modelType === 'keypoint_estimation' && form.keypointBackend === 'mediapipe_hand' @@ -603,6 +614,11 @@ export default function PretrainedModelFormModal({ ))} + {detectionModeWarning ? ( +

+ {detectionModeWarning} +

+ ) : null} )} diff --git a/src/renderer/components/pretrainedModels/PretrainedModelList.tsx b/src/renderer/components/pretrainedModels/PretrainedModelList.tsx index 700ec11..8bcbd81 100644 --- a/src/renderer/components/pretrainedModels/PretrainedModelList.tsx +++ b/src/renderer/components/pretrainedModels/PretrainedModelList.tsx @@ -8,6 +8,7 @@ import { getModelDisplayName, } from '../../types/pretrainedModel'; import { KEYPOINT_TEMPLATES } from '../../types/keypointTemplate'; +import { inferDetectionMode } from '../../../shared/preAnnotTypes'; import PretrainedModelMenuPortal from './PretrainedModelMenuPortal'; import './PretrainedModelList.css'; @@ -128,6 +129,14 @@ export default function PretrainedModelList({
{PRETRAINED_MODEL_TYPE_LABELS[model.modelType]} + {model.modelType === 'object_detection' && ( + <> + {' · '} + {inferDetectionMode(model) === 'obb' + ? '旋转框 (OBB)' + : '普通矩形框 (Detect)'} + + )} {model.keypointBackend && ( <> · {KEYPOINT_BACKEND_LABELS[model.keypointBackend]} )} diff --git a/src/renderer/context/AgentChatContext.tsx b/src/renderer/context/AgentChatContext.tsx index dac7bfe..c043a7a 100644 --- a/src/renderer/context/AgentChatContext.tsx +++ b/src/renderer/context/AgentChatContext.tsx @@ -86,8 +86,10 @@ import { collectAppliedProposalRefs, collectUndoneProposalRefs, confirmDirtyWorkspaceIfNeeded, + confirmForceRestore, decideEditRollback, formatRestoreError, + isForceRestorableError, messageCanReapply, messageCanUndo, restoreAppliedCheckpoints, @@ -2350,12 +2352,27 @@ export function AgentChatProvider({ children }: { children: ReactNode }) { if (!confirmDirtyWorkspaceIfNeeded(restorePaths, activeProject?.id)) { return; } - const restored = await restoreAppliedCheckpoints({ + let restored = await restoreAppliedCheckpoints({ refs, project: activeProject ?? null, workspaceRoot: rootPath, newestFirst: true, }); + // 快照可能因「后续正常写入」而判定为脏(例如加载流水线修正 filePath 后写回), + // 这类失败允许用户确认后强制覆盖,否则 Undo 会被永久锁死。 + if ( + !restored.ok && + isForceRestorableError(restored.error) && + confirmForceRestore(restored) + ) { + restored = await restoreAppliedCheckpoints({ + refs, + project: activeProject ?? null, + workspaceRoot: rootPath, + newestFirst: true, + force: true, + }); + } if (!restored.ok) { showToast(formatRestoreError(restored), { type: 'error' }); return; diff --git a/src/renderer/context/AnnotationContext.tsx b/src/renderer/context/AnnotationContext.tsx index 2708ce6..4924c40 100644 --- a/src/renderer/context/AnnotationContext.tsx +++ b/src/renderer/context/AnnotationContext.tsx @@ -20,6 +20,7 @@ import { updateAnnotationProject, validateProjectDirectory, } from '../services/annotationProjectStore'; +import { repairAnnotationDocFilePaths } from '../services/annotationDataService'; import { useApp } from './AppContext'; import { useToast } from './ToastContext'; import { setWorkModeExternal } from './workModeBridge'; @@ -28,6 +29,14 @@ const STORAGE_KEYS = { lastAnnotationProjectId: 'lr-agent:lastAnnotationProjectId', }; +/** + * 已尝试过 `filePath` 修复的项目目录(每次运行只做一次)。 + * + * 放在模块级而非 ref:AnnotationProvider 可能因路由/工作模式重挂载, + * 而修复本身是幂等的,没必要重复发起 IPC。 + */ +const repairedDocPathDirs = new Set(); + interface AnnotationContextValue { projects: AnnotationProject[]; activeProject: AnnotationProject | null; @@ -129,6 +138,29 @@ export function AnnotationProvider({ children }: { children: ReactNode }) { }; }, []); + // 一次性修复标注文档内陈旧的 `filePath`。 + // + // 该残留会让加载流水线的「就地修正」改写字节,使 checkpoint 的 `afterHash` + // 失配 —— 于是 Undo 被判「文件已改动」而拒绝,且快照会永久失效。 + // 这里在项目列表就绪后统一修好,之后的正常保存不会再产生新差异。 + useEffect(() => { + for (const project of projects) { + const dir = project.directoryPath; + if (!dir || repairedDocPathDirs.has(dir)) continue; + repairedDocPathDirs.add(dir); + repairAnnotationDocFilePaths(dir) + .then((result) => { + if (result.repaired.length > 0) { + console.warn( + `[annotation] 已修正 ${result.repaired.length} 份标注文档的归属路径`, + result.repaired, + ); + } + }) + .catch(() => undefined); + } + }, [projects]); + const openCreateWizard = useCallback(() => { setCreateWizardOpen(true); }, []); diff --git a/src/renderer/context/PretrainedModelsContext.tsx b/src/renderer/context/PretrainedModelsContext.tsx index a01e6c3..1858187 100644 --- a/src/renderer/context/PretrainedModelsContext.tsx +++ b/src/renderer/context/PretrainedModelsContext.tsx @@ -159,6 +159,10 @@ export function buildEmptyModel( isDefault: false, checkpointPath: '', configPath: modelType === 'image_segmentation' ? '' : undefined, + // 必须显式初始化:表单以 `form.detectionMode ?? 'detect'` 渲染选中态, + // 若这里留空则 UI 显示「普通矩形框」而落盘为 undefined,之后由文件名 + // 推断接管(文件名含 obb 就变 OBB),导致显示态与生效态不一致。 + detectionMode: modelType === 'object_detection' ? 'detect' : undefined, keypointBackend: modelType === 'keypoint_estimation' ? keypointBackend : undefined, keypointTemplateIds: diff --git a/src/renderer/services/agentClientContext.ts b/src/renderer/services/agentClientContext.ts index 26db895..08d6095 100644 --- a/src/renderer/services/agentClientContext.ts +++ b/src/renderer/services/agentClientContext.ts @@ -52,6 +52,8 @@ export function buildApiClientContext( status: state.status, operation: state.operation ?? null, annotation_ids: state.annotationIds ?? [], + source_kind: state.sourceKind ?? null, + in_current_turn: state.inCurrentTurn !== false, })), }; } diff --git a/src/renderer/services/agentProposalApply.ts b/src/renderer/services/agentProposalApply.ts index 7fae025..b2c7f56 100644 --- a/src/renderer/services/agentProposalApply.ts +++ b/src/renderer/services/agentProposalApply.ts @@ -484,13 +484,22 @@ async function withProposalCheckpoint( } try { const value = await write(); - let hasCheckpoint = false; + // 只要**捕获**成功就算「有快照」,可回滚:还原依赖 `beforeMissing` + blob, + // `afterHash`(由 recordAfter 写入)只用于脏检查,缺失时可走强制还原。 + // 若这里以 recordAfter 成败为准,一次瞬时失败就会让 Undo 入口永久消失。 + const hasCheckpoint = captured; if (captured && checkpoint) { - hasCheckpoint = await recordProposalCheckpointAfter({ + const recorded = await recordProposalCheckpointAfter({ ref: checkpoint, project: options.project, workspaceRoot: checkpoint.workspaceRoot, }); + if (!recorded) { + console.warn( + '[checkpoint] 改后哈希未记录,回滚将跳过脏检查', + checkpoint, + ); + } } return { value, hasCheckpoint }; } catch (err) { diff --git a/src/renderer/services/annotationDataService.ts b/src/renderer/services/annotationDataService.ts index b3b18fb..151c4ba 100644 --- a/src/renderer/services/annotationDataService.ts +++ b/src/renderer/services/annotationDataService.ts @@ -24,3 +24,20 @@ export async function writeFileAnnotationDoc( sourceHint, ); } + +/** + * 修复标注文档内陈旧的 `filePath`(归属真源是存储键)。 + * + * 这些残留会让加载流水线的「就地修正」改写字节,进而使 checkpoint 的 + * `afterHash` 失配、Undo 被判定为「文件已改动」而拒绝。返回修复明细,幂等。 + */ +export async function repairAnnotationDocFilePaths( + projectDir: string, +): Promise<{ + scanned: number; + repaired: Array<{ relativePath: string; storedFilePath: string | null }>; +}> { + const bridge = window.electron?.annotation?.repairDocFilePaths; + if (!bridge) return { scanned: 0, repaired: [] }; + return bridge(projectDir); +} diff --git a/src/renderer/services/pretrainedModelService.ts b/src/renderer/services/pretrainedModelService.ts index 30681d9..fa0487f 100644 --- a/src/renderer/services/pretrainedModelService.ts +++ b/src/renderer/services/pretrainedModelService.ts @@ -27,6 +27,7 @@ export async function validatePretrainedModelPaths( | 'modelType' | 'checkpointPath' | 'configPath' + | 'detectionMode' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' diff --git a/src/renderer/services/proposalLedger.test.ts b/src/renderer/services/proposalLedger.test.ts index 9ac0e78..08b4978 100644 --- a/src/renderer/services/proposalLedger.test.ts +++ b/src/renderer/services/proposalLedger.test.ts @@ -229,6 +229,8 @@ describe('buildProposalStates', () => { status: 'applied', operation: 'append', annotationIds: [], + sourceKind: 'batch', + inCurrentTurn: true, }, { path: 'data/4.jpg', @@ -236,6 +238,8 @@ describe('buildProposalStates', () => { status: 'applied', operation: 'append', annotationIds: [], + sourceKind: 'batch', + inCurrentTurn: true, }, ]); }); @@ -259,6 +263,7 @@ describe('buildProposalStates', () => { kind: 'file', status: 'pending', operation: 'write', + inCurrentTurn: true, }, ]); }); @@ -294,6 +299,8 @@ describe('buildProposalStates', () => { ]); expect(states[0].status).toBe('undone'); expect(states[0].operation).toBe('delete'); + // 单一 delete 变更 → 判定为标注编辑来源 + expect(states[0].sourceKind).toBe('mutation'); }); it('collects annotation ids from annotations, deleteIds and patches', () => { @@ -357,4 +364,100 @@ describe('buildProposalStates', () => { expect(states[0].annotationIds).toEqual(['ann-1', 'ann-2']); expect(states[1].annotationIds).toEqual(['ann-9']); }); + + /** + * 轮次标记:只有「最后一条 user 消息及之后」的提案算本轮。 + * + * 门禁的 `applied` 只认本轮,否则同一会话内任何后续重新标注都会被历史提案 + * 永久拦截 —— 而阶段提示词恰恰让模型引导用户重新发起请求。 + */ + it('marks proposals before the last user message as not in current turn', () => { + const userMessage: ChatMessage = { + id: 'u1', + sessionId: 'sess-1', + role: 'user', + blocks: [{ type: 'text', content: '重新标注' }], + status: 'done', + providerId: 'p1', + model: 'm1', + createdAt: 1, + updatedAt: 1, + }; + const states = buildProposalStates([ + assistantMessage('m1', [ + { + type: 'annotation_proposal', + status: 'applied', + proposal: { + id: 'p-old', + projectId: 'proj', + summary: 'old', + changes: [ + { + relativePath: 'data/1.jpg', + absolutePath: '/p/data/1.jpg', + operation: 'append', + annotations: [], + }, + ], + stats: { kind: 'generic', processed: 1, succeeded: 1, skipped: 0 }, + createdAt: 1, + }, + }, + ]), + userMessage, + assistantMessage('m2', [ + { + type: 'annotation_proposal', + status: 'applied', + proposal: { + id: 'p-new', + projectId: 'proj', + summary: 'new', + changes: [ + { + relativePath: 'data/1.jpg', + absolutePath: '/p/data/1.jpg', + operation: 'delete', + deleteIds: ['a'], + }, + ], + stats: { kind: 'generic', processed: 1, succeeded: 1, skipped: 0 }, + createdAt: 2, + }, + }, + ]), + ]); + + expect(states).toHaveLength(2); + expect(states[0].inCurrentTurn).toBe(false); + expect(states[1].inCurrentTurn).toBe(true); + }); + + it('keeps inCurrentTurn true when no user message exists', () => { + const states = buildProposalStates([ + assistantMessage('m1', [ + { + type: 'annotation_proposal', + status: 'pending', + proposal: { + id: 'p1', + projectId: 'proj', + summary: 'x', + changes: [ + { + relativePath: 'data/1.jpg', + absolutePath: '/p/data/1.jpg', + operation: 'append', + annotations: [], + }, + ], + stats: { kind: 'generic', processed: 1, succeeded: 1, skipped: 0 }, + createdAt: 1, + }, + }, + ]), + ]); + expect(states[0].inCurrentTurn).toBe(true); + }); }); diff --git a/src/renderer/services/proposalLedger.ts b/src/renderer/services/proposalLedger.ts index ef2797b..d4caaa5 100644 --- a/src/renderer/services/proposalLedger.ts +++ b/src/renderer/services/proposalLedger.ts @@ -1,6 +1,7 @@ import type { ChatMessage, ProposalStateEntry } from '../../shared/agentTypes'; import { isFileProposalBlock } from '../../shared/agentTypes'; import type { AnnotationBatchChange } from '../../shared/annotationAgentTypes'; +import { inferAnnotationProposalKind } from './annotationAgent/pipelineKinds'; function unlabeledCount(change: AnnotationBatchChange): number { return (change.annotations ?? []).filter((ann) => !ann.labelId).length; @@ -92,18 +93,45 @@ export function collectPendingAnnotationChanges( return changes; } +/** + * 当前轮的起始下标:最后一条 user 消息。 + * + * 发送请求前用户消息已写入会话,因此「最后一条 user 消息及之后」即本轮。 + * 找不到 user 消息(历史数据异常)时退回 0,等价于不过滤。 + */ +function currentTurnStartIndex(messages: ChatMessage[]): number { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role === 'user') return i; + } + return 0; +} + /** * 提案结构化状态(与 buildProposalLedger 同源),注入 client_context.proposal_states, * 供后端任务阶段机推导门禁。undone 透传,后端按非约束状态处理。 + * + * 除 `path/kind/status` 外还带两个判据,用于修正门禁的两个缺陷: + * + * - `sourceKind`:区分生成/编辑。删除与修改不产生标注,不应算作 + * 「该文件已标注」,否则「先删除再重新标注」会被前置删除动作拦死。 + * - `inCurrentTurn`:`applied` 只在本轮生效。否则同一会话内**任何**后续 + * 重新标注请求都会被历史提案永久拦截 —— 而阶段提示词恰恰让模型 + * 引导用户「重新发起请求」,那条路径必须是通的。 + * + * `pending` 仍按全会话聚合:未确认提案在磁盘上确实不存在,跨轮也必须拦住写入。 */ export function buildProposalStates( messages: ChatMessage[], ): ProposalStateEntry[] { const states: ProposalStateEntry[] = []; - for (const msg of messages) { - if (msg.role !== 'assistant') continue; + const turnStart = currentTurnStartIndex(messages); + messages.forEach((msg, messageIndex) => { + if (msg.role !== 'assistant') return; + const inCurrentTurn = messageIndex >= turnStart; for (const block of msg.blocks) { if (block.type === 'annotation_proposal') { + const sourceKind = + block.sourceKind ?? inferAnnotationProposalKind(block.proposal); for (const change of block.proposal.changes) { const annotationIds: string[] = []; if (change.annotations) { @@ -125,6 +153,8 @@ export function buildProposalStates( status: block.status, operation: change.operation, annotationIds, + sourceKind, + inCurrentTurn, }); } } else if (isFileProposalBlock(block)) { @@ -133,9 +163,10 @@ export function buildProposalStates( kind: 'file', status: block.status, operation: block.operation ?? 'write', + inCurrentTurn, }); } } - } + }); return states; } diff --git a/src/renderer/services/turnCheckpoint.ts b/src/renderer/services/turnCheckpoint.ts index a9ae214..4f22907 100644 --- a/src/renderer/services/turnCheckpoint.ts +++ b/src/renderer/services/turnCheckpoint.ts @@ -35,6 +35,7 @@ type CheckpointBridge = { blockIndex: number; projectDir?: string; workspaceRoot?: string; + force?: boolean; }) => Promise<{ ok: boolean; restoredPaths?: string[]; @@ -101,8 +102,10 @@ export async function recordProposalCheckpointAfter(options: { }); return true; } catch (err) { + // 不销毁快照:`afterHash` 只服务于「脏检查」,缺失它并不妨碍用 + // `beforeMissing` + blob 还原。历史上这里会 discard,导致一次 recordAfter + // 失败就把刚捕获的改前快照删除,Undo 入口彻底消失且事后无法补救。 console.warn('[checkpoint] recordAfter failed', err); - await discardProposalCheckpoint(options.ref); return false; } } @@ -200,6 +203,29 @@ export function isSkippableRestoreError(error: string): boolean { ); } +/** + * 是否可通过「强制覆盖」补救的回滚失败。 + * + * 这两类失败的共同点是:快照本身可用(`beforeMissing` + blob 齐全), + * 失败仅来自安全校验。而安全校验的失效原因常常是**正常的后续操作** + * (例如加载流水线修正 `filePath` 后写回、用户再次编辑), + * 若不允许覆盖,Undo 就等于永久不可用。 + */ +export function isForceRestorableError(error: string): boolean { + return error === 'checkpoint_dirty' || error === 'checkpoint_incomplete'; +} + +/** 强制回滚前向用户说明代价;返回 false 表示放弃。 */ +export function confirmForceRestore(result: RestoreCheckpointsResult): boolean { + if (result.ok) return true; + const paths = (result.dirtyPaths ?? []).filter(Boolean).join('、'); + const detail = + result.error === 'checkpoint_dirty' + ? `以下文件在本轮修改之后又有新的改动:${paths || '相关文件'}。强制回滚会一并丢弃这些后续改动。` + : '改前快照缺少「改后哈希」,无法校验文件是否被改动过。强制回滚会直接按改前内容覆盖当前文件。'; + return window.confirm(`${detail}是否仍要回滚?`); +} + export function collectAppliedProposalRefs( messages: ChatMessage[], messageIds?: string[], @@ -300,6 +326,8 @@ export async function restoreAppliedCheckpoints(options: { workspaceRoot?: string | null; newestFirst?: boolean; continueOnSkippable?: boolean; + /** 跳过「快照完整 / 未被后续改动」检查(需先获得用户确认) */ + force?: boolean; }): Promise { const restore = getBridge()?.restore; if (!restore) { @@ -315,7 +343,11 @@ export async function restoreAppliedCheckpoints(options: { let skipped = 0; const roots = resolveCheckpointRoots(options.project, options.workspaceRoot); for (const ref of ordered) { - const result = await restore({ ...ref, ...roots }); + const result = await restore({ + ...ref, + ...roots, + ...(options.force ? { force: true } : {}), + }); if (!result.ok) { const error = result.error ?? 'restore_failed'; if (options.continueOnSkippable && isSkippableRestoreError(error)) { diff --git a/src/renderer/utils/preAnnotModelFilter.test.ts b/src/renderer/utils/preAnnotModelFilter.test.ts new file mode 100644 index 0000000..7cad3bb --- /dev/null +++ b/src/renderer/utils/preAnnotModelFilter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from '@jest/globals'; +import type { PretrainedModelConfig } from '../types/pretrainedModel'; +import { + describeNoEligibleModelReason, + getEligiblePreAnnotModels, +} from './preAnnotModelFilter'; + +/** 只填筛选相关字段的最小模型。 */ +function model(patch: Partial): PretrainedModelConfig { + return { + id: 'm1', + name: 'M', + modelType: 'object_detection', + enabled: true, + isDefault: false, + checkpointPath: 'D:/models/yolov8n.pt', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...patch, + }; +} + +describe('getEligiblePreAnnotModels', () => { + it('矩形框只接受 Detect,排除 OBB 与停用项', () => { + const detect = model({ id: 'detect', checkpointPath: 'D:/m/yolov8n.pt' }); + const obb = model({ + id: 'obb', + checkpointPath: 'D:/m/yolov8n.pt', + detectionMode: 'obb', + }); + const disabled = model({ id: 'off', enabled: false }); + + const eligible = getEligiblePreAnnotModels('bbox', [detect, obb, disabled]); + expect(eligible.map((m) => m.id)).toEqual(['detect']); + }); + + it('旋转框只接受 OBB', () => { + const detect = model({ id: 'detect' }); + const obb = model({ id: 'obb', detectionMode: 'obb' }); + + const eligible = getEligiblePreAnnotModels('rotated_bbox', [detect, obb]); + expect(eligible.map((m) => m.id)).toEqual(['obb']); + }); + + it('多边形只接受分割模型', () => { + const det = model({ id: 'det' }); + const sam = model({ id: 'sam', modelType: 'image_segmentation' }); + + const eligible = getEligiblePreAnnotModels('polygon', [det, sam]); + expect(eligible.map((m) => m.id)).toEqual(['sam']); + }); + + it('关键点要求绑定当前骨架模板', () => { + const person = model({ + id: 'person', + modelType: 'keypoint_estimation', + keypointTemplateIds: ['person_coco'], + }); + const hand = model({ + id: 'hand', + modelType: 'keypoint_estimation', + keypointTemplateIds: ['hand'], + }); + + expect( + getEligiblePreAnnotModels('keypoint', [person, hand], 'person_coco').map( + (m) => m.id, + ), + ).toEqual(['person']); + // 未选模板时不额外过滤 + expect( + getEligiblePreAnnotModels('keypoint', [person, hand]).map((m) => m.id), + ).toEqual(['person', 'hand']); + }); +}); + +describe('describeNoEligibleModelReason', () => { + it('复现真实故障:唯一的检测模型被标成 OBB,矩形框下给出可操作原因', () => { + const models = [ + model({ + checkpointPath: 'C:/Users/user/Desktop/yolov8/yolov8n.pt', + detectionMode: 'obb', + }), + ]; + // 前提:筛选结果确实为空,否则提示没有意义 + expect(getEligiblePreAnnotModels('bbox', models)).toHaveLength(0); + + const reason = describeNoEligibleModelReason('bbox', models); + expect(reason).toContain('旋转框 (OBB)'); + expect(reason).toContain('检测模式'); + }); + + it('全部停用时提示去启用', () => { + const models = [model({ enabled: false })]; + expect(describeNoEligibleModelReason('bbox', models)).toContain('停用'); + }); + + it('完全没有模型时提示去添加', () => { + expect(describeNoEligibleModelReason('bbox', [])).toContain('尚未配置'); + }); + + it('旋转框下检测模型都是 Detect 时给出对应原因', () => { + const models = [model({ checkpointPath: 'D:/m/yolov8n.pt' })]; + expect(getEligiblePreAnnotModels('rotated_bbox', models)).toHaveLength(0); + expect(describeNoEligibleModelReason('rotated_bbox', models)).toContain( + '普通矩形框', + ); + }); + + it('多边形缺少 SAM2 时提示配置分割模型', () => { + const models = [model({ checkpointPath: 'D:/m/yolov8n.pt' })]; + expect(describeNoEligibleModelReason('polygon', models)).toContain('SAM2'); + }); + + it('关键点模型未绑定当前模板时点明原因', () => { + const models = [ + model({ + modelType: 'keypoint_estimation', + keypointTemplateIds: ['hand'], + }), + ]; + expect( + describeNoEligibleModelReason('keypoint', models, 'person_coco'), + ).toContain('骨架模板'); + }); + + it('关键点无任何关键点模型时提示配置', () => { + const models = [model({ checkpointPath: 'D:/m/yolov8n.pt' })]; + expect(describeNoEligibleModelReason('keypoint', models)).toContain( + '关键点估计模型', + ); + }); + + it('存在可用模型时返回 null', () => { + const models = [model({ checkpointPath: 'D:/m/yolov8n.pt' })]; + expect(describeNoEligibleModelReason('bbox', models)).toBeNull(); + }); +}); diff --git a/src/renderer/utils/preAnnotModelFilter.ts b/src/renderer/utils/preAnnotModelFilter.ts index 213a285..92a3cb7 100644 --- a/src/renderer/utils/preAnnotModelFilter.ts +++ b/src/renderer/utils/preAnnotModelFilter.ts @@ -51,6 +51,69 @@ export function pickDefaultPreAnnotModel( const STORAGE_PREFIX = 'lr-agent-preannot-model:'; +/** + * 「无可用模型」时的人类可读原因。 + * + * 下拉框的筛选条件同时包含模型类型、检测模式与骨架模板绑定,任一不匹配都会让 + * 模型消失;其中检测模式最容易踩坑(模型在左侧列表里完全正常,却因 + * `detectionMode` 被写成了另一种模式而无法在对应标注类型下使用)。这里把原因 + * 讲清楚,避免用户只看到一个空下拉框而无从排查。 + */ +export function describeNoEligibleModelReason( + annotationType: ImageAnnotationType, + models: PretrainedModelConfig[], + activeTemplateId?: string, +): string | null { + // 有可用模型时本函数不适用(调用方只应在空态下询问),提前返回保证语义自洽 + if ( + getEligiblePreAnnotModels(annotationType, models, activeTemplateId).length > + 0 + ) { + return null; + } + + const enabled = models.filter((m) => m.enabled); + if (enabled.length === 0) { + return models.length > 0 + ? '预训练模型均已停用,请在左侧「预训练模型」面板中启用。' + : '尚未配置预训练模型,请在左侧「预训练模型」面板中添加。'; + } + + const detectionModels = enabled.filter( + (m) => m.modelType === 'object_detection', + ); + const detectCount = detectionModels.filter( + (m) => inferDetectionMode(m) === 'detect', + ).length; + const obbCount = detectionModels.length - detectCount; + + switch (annotationType) { + case 'bbox': + return obbCount > 0 + ? '已启用的目标检测模型都是「旋转框 (OBB)」。矩形框标注需要普通检测 (Detect) 权重,请在模型配置中改选检测模式,或补充 Detect 权重。' + : '未启用目标检测 (YOLO) 模型,请在左侧「预训练模型」面板中配置。'; + case 'rotated_bbox': + return detectCount > 0 + ? '已启用的目标检测模型都是「普通矩形框 (Detect)」。旋转框标注需要 OBB 权重,请在模型配置中改选检测模式,或补充 OBB 权重。' + : '未启用目标检测 (YOLO) 模型,请在左侧「预训练模型」面板中配置。'; + case 'polygon': + return '未启用 SAM2 分割模型,请在左侧「预训练模型」面板中配置。'; + case 'keypoint': { + const hasKeypointModel = enabled.some( + (m) => m.modelType === 'keypoint_estimation', + ); + if (!hasKeypointModel) { + return '未启用关键点估计模型,请在左侧「预训练模型」面板中配置。'; + } + return activeTemplateId + ? '已启用的关键点模型未绑定当前骨架模板,请检查模型的模板绑定,或切换骨架模板。' + : null; + } + default: + return null; + } +} + export function loadSavedPreAnnotModelId(projectId: string): string | null { try { return localStorage.getItem(`${STORAGE_PREFIX}${projectId}`); diff --git a/src/shared/agentTypes.ts b/src/shared/agentTypes.ts index 4839432..fa2ca52 100644 --- a/src/shared/agentTypes.ts +++ b/src/shared/agentTypes.ts @@ -410,6 +410,22 @@ export interface ProposalStateEntry { operation?: string | null; /** 该提案涉及的标注实例 id(append/replace 的 annotations、delete 的 deleteIds、patch 的 patches) */ annotationIds?: string[]; + /** + * 提案来源流水线。 + * + * 阶段门禁据此区分「生成标注」与「编辑标注」:只有生成类操作 + * (`append`/`replace`/`replace_bboxes`)才算「该文件本轮已标注」。 + * 删除/修改并不产生标注,若也计入,会让「先删除再重新标注」被自己的 + * 前置删除动作永久拦死。 + */ + sourceKind?: PipelineKind | null; + /** + * 是否属于当前对话轮(最后一条 user 消息及之后的消息)。 + * + * 门禁的 `applied` 判定只认本轮:跨轮时用户已显式重新下达指令, + * 应当放行。反之 `pending`(未确认提案)跨轮仍然有效,故不做此过滤。 + */ + inCurrentTurn?: boolean; } /** 全局 Agent Skill 目录条目(catalog,name 为目录名,注入 prompt)。 */ diff --git a/src/shared/preAnnotTypes.test.ts b/src/shared/preAnnotTypes.test.ts new file mode 100644 index 0000000..d44bba2 --- /dev/null +++ b/src/shared/preAnnotTypes.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from '@jest/globals'; +import type { PretrainedModelConfig } from './pretrainedModelTypes'; +import { + describeDetectionModeMismatch, + inferDetectionMode, +} from './preAnnotTypes'; + +/** 只填检测模式推断相关字段的最小模型。 */ +function model(patch: Partial): PretrainedModelConfig { + return { + id: 'm1', + name: 'YOLOv8', + modelType: 'object_detection', + enabled: true, + isDefault: false, + checkpointPath: 'D:/models/yolov8n.pt', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...patch, + }; +} + +describe('inferDetectionMode', () => { + it('显式值优先于文件名(真实故障场景:yolov8n.pt 被标成 obb)', () => { + expect( + inferDetectionMode( + model({ + checkpointPath: 'C:/Users/user/Desktop/yolov8/yolov8n.pt', + detectionMode: 'obb', + }), + ), + ).toBe('obb'); + }); + + it('显式 detect 压过含 obb 的文件名', () => { + expect( + inferDetectionMode( + model({ + checkpointPath: 'D:/models/best-obb.pt', + detectionMode: 'detect', + }), + ), + ).toBe('detect'); + }); + + it('未设置时按文件名推断 obb', () => { + expect( + inferDetectionMode(model({ checkpointPath: 'D:/models/yolov8n-obb.pt' })), + ).toBe('obb'); + }); + + it('未设置且文件名无 obb 时回退 detect', () => { + expect( + inferDetectionMode( + model({ + checkpointPath: 'D:/models/yolov8n.pt', + detectionMode: undefined, + }), + ), + ).toBe('detect'); + }); +}); + +describe('describeDetectionModeMismatch', () => { + it('检出「OBB 声明 + 普通检测权重名」的矛盾', () => { + const message = describeDetectionModeMismatch({ + detectionMode: 'obb', + checkpointPath: 'C:\\Users\\user\\Desktop\\yolov8\\yolov8n.pt', + }); + expect(message).not.toBeNull(); + expect(message).toContain('旋转框 (OBB)'); + // 必须点明后果,否则用户不知道模型为何在标注界面消失 + expect(message).toContain('矩形框'); + }); + + it('检出「Detect 声明 + obb 文件名」的矛盾', () => { + expect( + describeDetectionModeMismatch({ + detectionMode: 'detect', + checkpointPath: 'D:/models/yolov8n-obb.pt', + }), + ).toContain('普通矩形框'); + }); + + it('模式与文件名一致时不告警', () => { + expect( + describeDetectionModeMismatch({ + detectionMode: 'obb', + checkpointPath: 'D:/models/yolov8n-obb.pt', + }), + ).toBeNull(); + expect( + describeDetectionModeMismatch({ + detectionMode: 'detect', + checkpointPath: 'D:/models/yolov8n.pt', + }), + ).toBeNull(); + }); + + it('模式未设置或路径为空时不做判断', () => { + expect( + describeDetectionModeMismatch({ + detectionMode: undefined, + checkpointPath: 'D:/models/yolov8n.pt', + }), + ).toBeNull(); + expect( + describeDetectionModeMismatch({ + detectionMode: 'obb', + checkpointPath: '', + }), + ).toBeNull(); + }); + + it('自训练的 OBB 权重(仅目录含 obb)不误报', () => { + // ultralytics 的 OBB 训练输出常为 runs/obb/train/weights/best.pt + expect( + describeDetectionModeMismatch({ + detectionMode: 'obb', + checkpointPath: 'D:/runs/obb/train/weights/best.pt', + }), + ).toBeNull(); + }); + + it('obb 必须是独立词元,避免 "bobby" 之类误判', () => { + expect( + describeDetectionModeMismatch({ + detectionMode: 'obb', + checkpointPath: 'C:/Users/bobby/models/yolov8n.pt', + }), + ).not.toBeNull(); + }); +}); diff --git a/src/shared/preAnnotTypes.ts b/src/shared/preAnnotTypes.ts index 9bffa42..07bf688 100644 --- a/src/shared/preAnnotTypes.ts +++ b/src/shared/preAnnotTypes.ts @@ -130,3 +130,49 @@ export function inferDetectionMode( const base = model.checkpointPath.replace(/\\/g, '/').split('/').pop() ?? ''; return /obb/i.test(base) ? 'obb' : 'detect'; } + +function checkpointBasename(filePath: string | undefined): string { + return (filePath ?? '').replace(/\\/g, '/').split('/').pop() ?? ''; +} + +/** + * 路径中是否存在 obb 证据(作为独立词元,避免 "bobby" 之类的误判)。 + * + * 用完整路径而非仅文件名:自训练权重常命名为 `best.pt`,其 OBB 身份只体现在 + * 目录上(如 ultralytics 的 `runs/obb/train/weights/`)。 + */ +function pathLooksObb(filePath: string | undefined): boolean { + return /\bobb\b/i.test(filePath ?? ''); +} + +/** + * 检测模式声明与路径证据是否矛盾。 + * + * `detectionMode` 决定模型出现在「矩形框」还是「旋转框」预标注下拉框 + * (见 `getEligiblePreAnnotModels`)。一旦与权重类型不符,模型会在标注界面 + * 静默消失(下拉框只显示「无可用模型」),而配置列表看不出异常 —— 极难自查。 + * + * 返回告警文案;一致或信息不足时返回 null。配置表单内联提示与保存校验共用 + * 此判据,避免两处逻辑漂移。注意这不构成硬错误:权重命名不受约束,故只作提醒、 + * 不阻断保存。 + */ +export function describeDetectionModeMismatch(model: { + detectionMode?: ObjectDetectionMode; + checkpointPath?: string; +}): string | null { + const declared = model.detectionMode; + if (declared !== 'detect' && declared !== 'obb') return null; + if (!checkpointBasename(model.checkpointPath)) return null; + + const looksObb = pathLooksObb(model.checkpointPath); + if (declared === 'obb' && !looksObb) { + return ( + '检测模式为旋转框 (OBB),但文件名与路径都不含 obb。若这是普通检测权重,' + + '需改选「普通矩形框 (Detect)」,否则该模型不会出现在矩形框标注的模型列表里。' + ); + } + if (declared === 'detect' && looksObb) { + return '文件名或路径含 obb,但检测模式选的是「普通矩形框 (Detect)」,请确认是否选错。'; + } + return null; +} From bb4c6e66930eb45018596e1466e09e9d7b193a05 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 16:21:02 +0800 Subject: [PATCH 17/20] =?UTF-8?q?chore(lint):=20=E6=B8=85=E6=8E=89?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=94=92=E4=B8=8B=E7=9A=84=20330=20=E4=B8=AA?= =?UTF-8?q?=20lint=20error=EF=BC=8C=E5=90=8C=E6=AD=A5=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 npm run lint 一直是红的(330 error / 248 warning、涉及 61 个文件), 根因是迁移期新写的文件从未经过 prettier 校验——该分支此前一次 CI 都没跑过。 其中 325 个可自动修,本次用 eslint --fix 一次性清干净。 剩余 6 个为不可自动修,逐一最小改动处理(语义等价): - heuristic.ts: 两处 chosen = arr[0] 改数组解构 - proposalStreamer.ts: indexOfSequence 的带标签循环改为 matched 标志位; mergeArgs 中「已见前缀重复投递 → 忽略」的空分支补注释(no-empty 允许含注释块) - llm/client.ts: 3 个类同属单一协议层(错误契约 / 客户端 / 流式累积器), 拆分只会切碎契约,故加带理由的文件级 max-classes-per-file 豁免 prettier 之后 lint 从 330 error 降到 0;248 个 warning (no-console 142 / react-hooks/exhaustive-deps 52 / no-unused-vars 41)按现状保留,不阻塞 CI。 顺带修 lockfile 与 package.json 不同步:zod 只加进了 package.json 而 package-lock.json 未更新(root deps 缺失,仅 node_modules 里有), npm install 重新生成后 +zod 一行。修掉后 CI 才具备切换到 npm ci 的前提。 验证:npm run lint 0 error;npm exec tsc 通过;npx jest --ci 113 suites / 1036 tests 全通过。 Co-authored-by: Cursor --- package-lock.json | 3 +- src/main/agent/annotate/annotate.test.ts | 53 +++++--- .../agent/annotate/annotationSupport.test.ts | 122 +++++++++++++----- src/main/agent/annotate/common.ts | 16 ++- src/main/agent/annotate/generate.ts | 7 +- src/main/agent/annotate/heuristic.ts | 8 +- src/main/agent/annotate/labelPool.ts | 7 +- src/main/agent/annotate/mapLabels.ts | 38 ++++-- src/main/agent/annotate/mutation.ts | 11 +- src/main/agent/annotate/schemas.ts | 9 +- src/main/agent/annotate/scope.ts | 39 ++++-- src/main/agent/annotate/validation.ts | 8 +- src/main/agent/bridge.test.ts | 4 +- src/main/agent/bridge.ts | 7 +- src/main/agent/config.ts | 5 +- src/main/agent/context/contextService.ts | 12 +- src/main/agent/context/multimodal.ts | 3 +- src/main/agent/context/snapshot.ts | 9 +- src/main/agent/context/systemPrompt.ts | 10 +- src/main/agent/handlers/annotation.ts | 29 +++-- src/main/agent/handlers/chatStream.ts | 13 +- src/main/agent/handlers/index.ts | 3 +- src/main/agent/handlers/mcpProbe.ts | 25 +++- src/main/agent/host.ts | 9 +- src/main/agent/llm/client.ts | 55 +++++--- src/main/agent/loop/assistLoop.test.ts | 41 ++++-- src/main/agent/loop/assistLoop.ts | 52 ++++++-- src/main/agent/loop/proposalStreamer.test.ts | 101 ++++++++++++--- src/main/agent/loop/proposalStreamer.ts | 25 +++- src/main/agent/mcp/client.test.ts | 5 +- src/main/agent/mcp/client.ts | 11 +- src/main/agent/protocol.ts | 2 +- src/main/agent/routes.ts | 9 +- src/main/agent/runtime.ts | 13 +- src/main/agent/server.test.ts | 6 +- src/main/agent/server.ts | 24 ++-- src/main/agent/services/imageService.ts | 34 +++-- src/main/agent/sse.ts | 16 ++- src/main/agent/subagent/exploreReadonly.ts | 23 +++- src/main/agent/tools/annotationDoc.ts | 20 ++- src/main/agent/tools/documentReader.test.ts | 20 ++- src/main/agent/tools/documentReader.ts | 33 +++-- src/main/agent/tools/fileProposal.ts | 32 +++-- src/main/agent/tools/fileReader.ts | 25 ++-- src/main/agent/tools/fileSearch.ts | 24 ++-- src/main/agent/tools/registry.ts | 112 +++++++++++----- src/main/agent/tools/result.ts | 7 +- src/main/agent/tools/vision.ts | 5 +- src/main/agent/tools/workspacePath.ts | 15 ++- src/main/agent/vision/autoLoader.test.ts | 82 +++++++++--- src/main/agent/vision/autoLoader.ts | 21 ++- src/main/agentImage/handlers.ts | 38 ++++-- src/main/agentImage/imageOps.test.ts | 37 ++++-- src/main/agentImage/imageOps.ts | 13 +- src/main/env/envStatus.ts | 5 +- src/main/workspace/workspaceOperations.ts | 22 +++- .../environment/EnvironmentWizard.tsx | 10 +- src/renderer/config.test.ts | 13 +- .../context/AnnotationWorkspaceContext.tsx | 8 +- src/renderer/services/agentChatStore.ts | 3 +- src/renderer/services/agentJobRegistry.ts | 2 +- .../services/agentProposalApply.test.ts | 20 ++- 62 files changed, 1021 insertions(+), 413 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69016a1..5eab151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "seti-icons": "^0.0.4", - "sql.js": "^1.14.1" + "sql.js": "^1.14.1", + "zod": "^4.4.3" }, "devDependencies": { "@electron/rebuild": "^3.7.1", diff --git a/src/main/agent/annotate/annotate.test.ts b/src/main/agent/annotate/annotate.test.ts index 84bcf0e..02a2d3f 100644 --- a/src/main/agent/annotate/annotate.test.ts +++ b/src/main/agent/annotate/annotate.test.ts @@ -11,11 +11,7 @@ import { sanitizeJsonValue, sanitizeUnicodeText, } from './common'; -import { - dumpMutationOperation, - filterPaths, - labelNamesOf, -} from './mutation'; +import { dumpMutationOperation, filterPaths, labelNamesOf } from './mutation'; import { contentToText } from './generate'; import { assertComposePayloadSize, @@ -45,9 +41,11 @@ describe('extractJsonObject:LLM 文本解析', () => { }); it('从夹杂说明文字的响应中抓取 JSON', () => { - expect(extractJsonObject('好的,结果如下:\n{"a": 1}\n希望有帮助')).toEqual({ - a: 1, - }); + expect(extractJsonObject('好的,结果如下:\n{"a": 1}\n希望有帮助')).toEqual( + { + a: 1, + }, + ); }); it('贪婪匹配到最后一个 }(尾部有游离 } 时会解析失败)', () => { @@ -131,9 +129,9 @@ describe('sanitizeUnicodeText:孤立代理清洗', () => { describe('sanitizeJsonValue:递归清洗', () => { it('清洗嵌套对象与数组中的字符串', () => { - expect( - sanitizeJsonValue({ a: ['x\uD800', { b: 'y\ufffd' }] }), - ).toEqual({ a: ['x', { b: 'y' }] }); + expect(sanitizeJsonValue({ a: ['x\uD800', { b: 'y\ufffd' }] })).toEqual({ + a: ['x', { b: 'y' }], + }); }); it('清洗键名', () => { @@ -165,7 +163,9 @@ describe('httpFromLlmError:错误分流', () => { }); it('提及 json_schema 时同样映射为 400', () => { - expect(httpFromLlmError(new Error('json_schema not supported')).status).toBe(400); + expect( + httpFromLlmError(new Error('json_schema not supported')).status, + ).toBe(400); }); it('其它错误映射为 502', () => { @@ -308,13 +308,18 @@ describe('contentToText:LLM 内容抽取', () => { }); it('内容块数组拼接 text 部件', () => { - expect(contentToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe( - 'ab', - ); + expect( + contentToText([ + { type: 'text', text: 'a' }, + { type: 'text', text: 'b' }, + ]), + ).toBe('ab'); }); it('数组中非 text 部件跳过', () => { - expect(contentToText([{ type: 'text', text: 'a' }, { type: 'image' }])).toBe('a'); + expect( + contentToText([{ type: 'text', text: 'a' }, { type: 'image' }]), + ).toBe('a'); }); it('null / undefined 返回空串', () => { @@ -330,7 +335,9 @@ describe('quality compose:payload 校验与消息构造', () => { it('超过 512KB 抛 compose_payload_too_large', () => { const huge = { blob: 'x'.repeat(600_000) }; - expect(() => assertComposePayloadSize(huge)).toThrow('compose_payload_too_large'); + expect(() => assertComposePayloadSize(huge)).toThrow( + 'compose_payload_too_large', + ); }); it('消息含固定系统提示与 JSON 载荷', () => { @@ -339,7 +346,9 @@ describe('quality compose:payload 校验与消息构造', () => { expect(messages[0].content).toBe(QUALITY_REPORT_COMPOSE_SYSTEM); expect(messages[1].content).toContain('【质量指标与发现 JSON】'); expect(messages[1].content).toContain('"total": 5'); - expect(messages[1].content).toContain('请基于以上数据撰写完整 Markdown 质量报告。'); + expect(messages[1].content).toContain( + '请基于以上数据撰写完整 Markdown 质量报告。', + ); }); it('超过 48000 字符时截断并加提示', () => { @@ -374,7 +383,10 @@ describe('请求 schema:约束对齐', () => { MutationPrepareRequestSchema.parse({ provider_id: 'p' }), ).toThrow(); expect(() => - MutationPrepareRequestSchema.parse({ provider_id: 'p', user_request: '' }), + MutationPrepareRequestSchema.parse({ + provider_id: 'p', + user_request: '', + }), ).toThrow(); }); @@ -388,7 +400,8 @@ describe('请求 schema:约束对齐', () => { it('quality compose 的 compose_payload 默认空对象', () => { expect( - QualityReportComposeRequestSchema.parse({ provider_id: 'p' }).compose_payload, + QualityReportComposeRequestSchema.parse({ provider_id: 'p' }) + .compose_payload, ).toEqual({}); }); diff --git a/src/main/agent/annotate/annotationSupport.test.ts b/src/main/agent/annotate/annotationSupport.test.ts index 83d260c..12543f0 100644 --- a/src/main/agent/annotate/annotationSupport.test.ts +++ b/src/main/agent/annotate/annotationSupport.test.ts @@ -99,7 +99,10 @@ describe('normalizeDetectionLabel', () => { describe('expandDetectionAliases', () => { it('中文关键词展开为英文别名', () => { - expect(expandDetectionAliases(['篮球'])).toEqual(['sports ball', 'basketball']); + expect(expandDetectionAliases(['篮球'])).toEqual([ + 'sports ball', + 'basketball', + ]); }); it('未命中的词原样作为别名输出', () => { @@ -124,7 +127,10 @@ describe('inferAnnotationScopeFromText:范围推断', () => { it('「不要标注篮球」推断出排除项', () => { const scope = inferAnnotationScopeFromText('不要标注篮球'); - expect(scope.exclude_detection_labels).toEqual(['sports ball', 'basketball']); + expect(scope.exclude_detection_labels).toEqual([ + 'sports ball', + 'basketball', + ]); }); it('取分隔符前的第一段作为范围词', () => { @@ -190,7 +196,9 @@ describe('inferAnnotationScopeFromText:范围推断', () => { }); it('exclude_label_names 推断恒为空', () => { - expect(inferAnnotationScopeFromText('不要 dog').exclude_label_names).toEqual([]); + expect( + inferAnnotationScopeFromText('不要 dog').exclude_label_names, + ).toEqual([]); }); it('空文本返回全默认', () => { @@ -225,9 +233,9 @@ describe('mergeAnnotationScope', () => { }); it('null 输入等价空 scope', () => { - expect(mergeAnnotationScope(null, '只标注猫').include_detection_labels).toEqual([ - 'cat', - ]); + expect( + mergeAnnotationScope(null, '只标注猫').include_detection_labels, + ).toEqual(['cat']); }); }); @@ -286,7 +294,13 @@ describe('YOLO_COCO_CLASS_NAMES', () => { }); it('含关键项', () => { - for (const name of ['person', 'car', 'sports ball', 'dining table', 'face']) { + for (const name of [ + 'person', + 'car', + 'sports ball', + 'dining table', + 'face', + ]) { expect(YOLO_COCO_CLASS_NAMES.has(name)).toBe(true); } }); @@ -298,15 +312,15 @@ describe('YOLO_COCO_CLASS_NAMES', () => { describe('labelsRequireVisionMapping', () => { it('全部未命中标准类名时返回 true', () => { - expect(labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'James' }])).toBe( - true, - ); + expect( + labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'James' }]), + ).toBe(true); }); it('任一命中时返回 false', () => { - expect(labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'person' }])).toBe( - false, - ); + expect( + labelsRequireVisionMapping([{ name: 'Curry' }, { name: 'person' }]), + ).toBe(false); }); it('无有效标签名时返回 false', () => { @@ -337,14 +351,17 @@ describe('heuristicMapBoxes', () => { }); it('class_name 为空时回退 detection_label', () => { - const result = heuristicMapBoxes([{ detection_label: 'person' }], candidates); + const result = heuristicMapBoxes( + [{ detection_label: 'person' }], + candidates, + ); expect(result[0].label_id).toBe('l1'); }); it('精确匹配大小写不敏感', () => { - expect(heuristicMapBoxes([{ class_name: 'CAR' }], candidates)[0].label_id).toBe( - 'l2', - ); + expect( + heuristicMapBoxes([{ class_name: 'CAR' }], candidates)[0].label_id, + ).toBe('l2'); }); it('无匹配时 label_id 为空且 reason 为兜底文案', () => { @@ -356,7 +373,10 @@ describe('heuristicMapBoxes', () => { it('唯一部分匹配(双向子串)', () => { const result = heuristicMapBoxes( [{ class_name: 'sports' }], - [{ id: 'l1', name: 'sports ball' }, { id: 'l2', name: 'car' }], + [ + { id: 'l1', name: 'sports ball' }, + { id: 'l2', name: 'car' }, + ], ); expect(result[0].label_id).toBe('l1'); expect(result[0].reason).toContain('部分匹配'); @@ -365,7 +385,10 @@ describe('heuristicMapBoxes', () => { it('部分匹配多命中时不选', () => { const result = heuristicMapBoxes( [{ class_name: 'a' }], - [{ id: 'l1', name: 'abc' }, { id: 'l2', name: 'abd' }], + [ + { id: 'l1', name: 'abc' }, + { id: 'l2', name: 'abd' }, + ], ); expect(result[0].label_id).toBe(''); }); @@ -373,7 +396,10 @@ describe('heuristicMapBoxes', () => { it('OCR 唯一命中', () => { const result = heuristicMapBoxes( [{ class_name: 'unknown' }], - [{ id: 'l1', name: 'curry' }, { id: 'l2', name: 'jokic' }], + [ + { id: 'l1', name: 'curry' }, + { id: 'l2', name: 'jokic' }, + ], '球员 curry 在场上', ); expect(result[0].label_id).toBe('l1'); @@ -383,7 +409,10 @@ describe('heuristicMapBoxes', () => { it('OCR 多命中时不选', () => { const result = heuristicMapBoxes( [{ class_name: 'unknown' }], - [{ id: 'l1', name: 'curry' }, { id: 'l2', name: 'jokic' }], + [ + { id: 'l1', name: 'curry' }, + { id: 'l2', name: 'jokic' }, + ], 'curry 与 jokic', ); expect(result[0].label_id).toBe(''); @@ -495,7 +524,9 @@ describe('validateVisionMappings', () => { (i) => i.code === MappingIssueCode.REASON_NAME_MISMATCH, ); expect(mismatch).toBeDefined(); - expect(mismatch?.message).toBe("reason 指向 'Curry',但 label_id 对应 'James'"); + expect(mismatch?.message).toBe( + "reason 指向 'Curry',但 label_id 对应 'James'", + ); }); it('reason 提到两个候选名时不报 mismatch', () => { @@ -610,43 +641,63 @@ describe('findLabelNamesInText', () => { describe('shouldRunPreflight', () => { it('off 恒不跑', () => { expect( - shouldRunPreflight('off', { candidateCount: 100, boxCount: 1, minExtra: 0 }), + shouldRunPreflight('off', { + candidateCount: 100, + boxCount: 1, + minExtra: 0, + }), ).toBe(false); }); it('always 在框数 > 0 时恒跑', () => { expect( - shouldRunPreflight('always', { candidateCount: 1, boxCount: 1, minExtra: 99 }), + shouldRunPreflight('always', { + candidateCount: 1, + boxCount: 1, + minExtra: 99, + }), ).toBe(true); }); it('框数为 0 时一律不跑', () => { expect( - shouldRunPreflight('always', { candidateCount: 10, boxCount: 0, minExtra: 0 }), + shouldRunPreflight('always', { + candidateCount: 10, + boxCount: 0, + minExtra: 0, + }), ).toBe(false); }); it('auto 需候选数比框数多出至少 minExtra', () => { expect( - shouldRunPreflight('auto', { candidateCount: 5, boxCount: 3, minExtra: 2 }), + shouldRunPreflight('auto', { + candidateCount: 5, + boxCount: 3, + minExtra: 2, + }), ).toBe(true); expect( - shouldRunPreflight('auto', { candidateCount: 4, boxCount: 3, minExtra: 2 }), + shouldRunPreflight('auto', { + candidateCount: 4, + boxCount: 3, + minExtra: 2, + }), ).toBe(false); }); }); describe('coordsAreNormalized', () => { it('全部 <= 1.5 视为归一化', () => { - expect(coordsAreNormalized([{ x: 0.1, y: 0.2, width: 0.3, height: 0.4 }])).toBe( - true, - ); + expect( + coordsAreNormalized([{ x: 0.1, y: 0.2, width: 0.3, height: 0.4 }]), + ).toBe(true); }); it('任一 > 1.5 视为像素坐标', () => { - expect(coordsAreNormalized([{ x: 100, y: 0.2, width: 0.3, height: 0.4 }])).toBe( - false, - ); + expect( + coordsAreNormalized([{ x: 100, y: 0.2, width: 0.3, height: 0.4 }]), + ).toBe(false); }); it('非法值视为像素坐标', () => { @@ -694,6 +745,9 @@ describe('SCOPE_KEYWORD_TO_DETECTION', () => { }); it('篮球映射到两个别名', () => { - expect(SCOPE_KEYWORD_TO_DETECTION['篮球']).toEqual(['sports ball', 'basketball']); + expect(SCOPE_KEYWORD_TO_DETECTION['篮球']).toEqual([ + 'sports ball', + 'basketball', + ]); }); }); diff --git a/src/main/agent/annotate/common.ts b/src/main/agent/annotate/common.ts index eb12aa1..740c96a 100644 --- a/src/main/agent/annotate/common.ts +++ b/src/main/agent/annotate/common.ts @@ -110,7 +110,8 @@ export async function invokeJsonModel( .map((m) => (typeof m.content === 'string' ? m.content : '')) .filter((part) => part.length > 0); - const systemCandidates = systemParts.length > 0 ? systemParts : ['你是结构化 JSON 助手。']; + const systemCandidates = + systemParts.length > 0 ? systemParts : ['你是结构化 JSON 助手。']; const systemText = [systemCandidates[0], options.extraInstruction] .filter((p) => p && p.length > 0) .join('\n'); @@ -193,7 +194,10 @@ export class AnnotateHttpError extends Error { * 否则 `requireCredentials` 抛出的 400 `provider_credentials_required` 会落进 * 502 兜底分支——把「凭据没填」误报成「上游坏了」,前端拿到的错误语义就错了。 */ -export function httpFromLlmError(err: unknown, status?: number): AnnotateHttpError { +export function httpFromLlmError( + err: unknown, + status?: number, +): AnnotateHttpError { if (err instanceof AnnotateHttpError) return err; const message = err instanceof Error ? err.message : String(err); @@ -215,7 +219,10 @@ export function httpFromLlmError(err: unknown, status?: number): AnnotateHttpErr '当前模型不支持结构化输出,请使用普通对话模型或更换提供商', ); } - return new AnnotateHttpError(502, message.slice(0, 500) || 'llm_invoke_failed'); + return new AnnotateHttpError( + 502, + message.slice(0, 500) || 'llm_invoke_failed', + ); } /** 读取错误对象上的数字状态码(`LlmRequestError` 与 `AnnotateHttpError` 都有)。 */ @@ -254,7 +261,8 @@ export function requireCredentials(params: { */ export function pyRepr(value: unknown): string { if (value === null || value === undefined) return 'None'; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'number' || typeof value === 'boolean') + return String(value); const text = String(value); const hasSingle = text.includes("'"); diff --git a/src/main/agent/annotate/generate.ts b/src/main/agent/annotate/generate.ts index aa97a7f..77e5e62 100644 --- a/src/main/agent/annotate/generate.ts +++ b/src/main/agent/annotate/generate.ts @@ -85,7 +85,9 @@ function isLoadFailure(error: string | undefined): boolean { } /** 读取图片原始字节(路径优先,base64 兜底)。 */ -async function loadRawBytes(body: LlmGenerateRequest): Promise { +async function loadRawBytes( + body: LlmGenerateRequest, +): Promise { const abs = (body.image_absolute_path ?? '').trim(); if (abs) { try { @@ -98,7 +100,8 @@ async function loadRawBytes(body: LlmGenerateRequest): Promise= 0 ? raw.slice(comma + 1) : raw; + const payload = + raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; return Uint8Array.from(Buffer.from(payload, 'base64')); } return null; diff --git a/src/main/agent/annotate/heuristic.ts b/src/main/agent/annotate/heuristic.ts index 82548af..4e3aae0 100644 --- a/src/main/agent/annotate/heuristic.ts +++ b/src/main/agent/annotate/heuristic.ts @@ -46,7 +46,9 @@ export function heuristicMapBoxes( // 精确索引:同名后者覆盖前者 const byName = new Map>(); for (const item of labelCandidates) { - const name = String(item.name ?? '').trim().toLowerCase(); + const name = String(item.name ?? '') + .trim() + .toLowerCase(); if (name) byName.set(name, item); } @@ -78,7 +80,7 @@ export function heuristicMapBoxes( return detLower.includes(name) || name.includes(detLower); }); if (partial.length === 1) { - chosen = partial[0]; + [chosen] = partial; reason = `检测类名与标签 ${pyRepr(chosen.name)} 部分匹配`; } } @@ -89,7 +91,7 @@ export function heuristicMapBoxes( ocrLower.includes(String(n.name ?? '').toLowerCase()), ); if (nameHits.length === 1) { - chosen = nameHits[0]; + [chosen] = nameHits; reason = `OCR 含标签名 ${pyRepr(chosen.name)}`; } } diff --git a/src/main/agent/annotate/labelPool.ts b/src/main/agent/annotate/labelPool.ts index 064771e..0d19b4d 100644 --- a/src/main/agent/annotate/labelPool.ts +++ b/src/main/agent/annotate/labelPool.ts @@ -156,8 +156,11 @@ export interface ResolvePoolParams { export async function resolveEffectiveLabelCandidates( params: ResolvePoolParams, ): Promise { - const scoped = filterLabelCandidatesByScope([...params.allCandidates], params.scope); - let source: LabelPoolResult['source'] = + const scoped = filterLabelCandidatesByScope( + [...params.allCandidates], + params.scope, + ); + const source: LabelPoolResult['source'] = scoped.length < params.allCandidates.length ? 'scope' : 'full'; const excluded: string[] = []; diff --git a/src/main/agent/annotate/mapLabels.ts b/src/main/agent/annotate/mapLabels.ts index 2a2347a..1230cb2 100644 --- a/src/main/agent/annotate/mapLabels.ts +++ b/src/main/agent/annotate/mapLabels.ts @@ -74,7 +74,9 @@ interface CropRegion { * * 任一维度 > 1.5 视为像素坐标;任一值无法转数字也视为像素(返回 false)。 */ -export function coordsAreNormalized(boxes: Array>): boolean { +export function coordsAreNormalized( + boxes: Array>, +): boolean { for (const b of boxes) { for (const key of ['x', 'y', 'width', 'height']) { const value = Number(b[key] ?? 0); @@ -122,7 +124,9 @@ interface VisionMapBoxParams { } /** 对单个裁剪区域调用视觉 LLM。 */ -async function visionMapBoxWithCrop(params: VisionMapBoxParams): Promise { +async function visionMapBoxWithCrop( + params: VisionMapBoxParams, +): Promise { const validIds = new Set(params.candidates.map((c) => String(c.id ?? ''))); const retryBlock = params.retryNote.trim() @@ -152,7 +156,10 @@ async function visionMapBoxWithCrop(params: VisionMapBoxParams): Promise(); for (const m of mappings) { const idx = Number(m.box_index ?? 0); - if (options.excludeBox !== undefined && idx === options.excludeBox) continue; + if (options.excludeBox !== undefined && idx === options.excludeBox) + continue; const lid = String(m.label_id ?? '').trim(); if (lid) used.add(lid); } @@ -301,7 +309,8 @@ async function visionMapWithValidationRetry( }); let retryRounds = 0; - if (!params.validate || params.maxRetries <= 0) return { mappings, retryRounds }; + if (!params.validate || params.maxRetries <= 0) + return { mappings, retryRounds }; const regionByIndex = new Map(params.regions.map((r) => [r.box_index, r])); @@ -314,9 +323,9 @@ async function visionMapWithValidationRetry( }); if (result.ok) break; - const retryIndices = [...new Set(result.issues.map((i) => i.boxIndex))].sort( - (a, b) => a - b, - ); + const retryIndices = [ + ...new Set(result.issues.map((i) => i.boxIndex)), + ].sort((a, b) => a - b); if (retryIndices.length === 0) break; retryRounds += 1; @@ -326,7 +335,11 @@ async function visionMapWithValidationRetry( for (const boxIndex of retryIndices) { const used = usedLabelIds(mappings, { excludeBox: boxIndex }); const currentLid = String(byIndex.get(boxIndex)?.label_id ?? ''); - const boxCandidates = candidatesForRetryBox(params.candidates, used, currentLid); + const boxCandidates = candidatesForRetryBox( + params.candidates, + used, + currentLid, + ); const boxIssues: MappingIssue[] = result.issuesForBox(boxIndex); overrides.set(boxIndex, { candidates: boxCandidates, @@ -391,7 +404,7 @@ export async function mapDetectionBoxesToLabelsUnified( params.scope instanceof AnnotationScope ? params.scope : AnnotationScope.fromPayload(params.scope); - const settings = params.settings; + const { settings } = params; const scopedCandidates = filterLabelCandidatesByScope( [...params.labelCandidates], @@ -450,8 +463,7 @@ export async function mapDetectionBoxesToLabelsUnified( let scopeNote = ''; if (scopeModel.isRestricted()) { const summary = - scopeModel.scope_summary || - pythonJsonDumps(scopeModel.toPayload()); + scopeModel.scope_summary || pythonJsonDumps(scopeModel.toPayload()); scopeNote = `\n用户标注范围:${summary}`; } @@ -470,7 +482,7 @@ export async function mapDetectionBoxesToLabelsUnified( signal: params.signal, }); - const candidates = pool.candidates; + const { candidates } = pool; validIds = new Set(candidates.map((c) => String(c.id ?? ''))); const normalized = coordsAreNormalized(normalizedBoxes as never); diff --git a/src/main/agent/annotate/mutation.ts b/src/main/agent/annotate/mutation.ts index 82c2a8d..efa160b 100644 --- a/src/main/agent/annotate/mutation.ts +++ b/src/main/agent/annotate/mutation.ts @@ -184,7 +184,9 @@ type MutationOperation = z.infer; * 等价于 Pydantic 的 `model_dump()`:**输出全部字段**,未设值者为 `null`, * 嵌套模型递归展开为普通对象。 */ -export function dumpMutationOperation(op: MutationOperation): Record { +export function dumpMutationOperation( + op: MutationOperation, +): Record { return { relative_path: op.relative_path, mutation_kind: op.mutation_kind, @@ -252,7 +254,9 @@ export function filterPaths( ): string[] { const byPath = new Map(); for (const c of candidates) { - const key = String(c.relative_path ?? '').trim().replace(/\\/g, '/'); + const key = String(c.relative_path ?? '') + .trim() + .replace(/\\/g, '/'); if (key) byPath.set(key, c); } @@ -300,7 +304,8 @@ export async function prepareMutationAnnotation( const labels = params.labelNames.slice(0, 50).join(', ') || '(无)'; const selectedIds = (params.selectedAnnotationIds ?? []).join(', ') || '(无)'; - const transcript = (params.conversationTranscript ?? '').trim() || '(无历史)'; + const transcript = + (params.conversationTranscript ?? '').trim() || '(无历史)'; const human = `【对话上下文】\n${transcript}\n\n` + diff --git a/src/main/agent/annotate/schemas.ts b/src/main/agent/annotate/schemas.ts index ce0e1ee..2c6b5b2 100644 --- a/src/main/agent/annotate/schemas.ts +++ b/src/main/agent/annotate/schemas.ts @@ -61,7 +61,9 @@ export const MutationPrepareRequestSchema = AnnotationLlmBase.extend({ project: AnnotationProjectSnapshotSchema.nullish(), }); -export type MutationPrepareRequest = z.infer; +export type MutationPrepareRequest = z.infer< + typeof MutationPrepareRequestSchema +>; /** `POST /agent/annotation/map-detection-boxes` */ export const MapDetectionBoxesRequestSchema = AnnotationLlmBase.extend({ @@ -106,10 +108,7 @@ export type QualityReportComposeRequest = z.infer< >; /** 把 JSON 文本解析为指定 schema;失败时抛出 zod 错误。 */ -export function parseRequest( - rawBody: string, - schema: z.ZodType, -): T { +export function parseRequest(rawBody: string, schema: z.ZodType): T { let parsed: unknown; try { parsed = rawBody ? JSON.parse(rawBody) : {}; diff --git a/src/main/agent/annotate/scope.ts b/src/main/agent/annotate/scope.ts index 29b213b..78b17a0 100644 --- a/src/main/agent/annotate/scope.ts +++ b/src/main/agent/annotate/scope.ts @@ -40,10 +40,12 @@ export const SCOPE_KEYWORD_TO_DETECTION: Record = { * * 注意:JS 的 `.` 默认不匹配换行,与 Python 一致,故无需 `s` 标志。 */ -const ONLY_ANNOTATE_RE = /(?:只|仅)(?:标注|标|检测|框选|识别|打框|标出)?(.{1,24})/g; +const ONLY_ANNOTATE_RE = + /(?:只|仅)(?:标注|标|检测|框选|识别|打框|标出)?(.{1,24})/g; /** 「不要/排除」范围提取正则(可选动词组比 ONLY 少三项)。 */ -const EXCLUDE_RE = /(?:不要|别|排除|不包括|无需|不用)(?:标注|标|检测|框选)?(.{1,24})/g; +const EXCLUDE_RE = + /(?:不要|别|排除|不包括|无需|不用)(?:标注|标|检测|框选)?(.{1,24})/g; /** 范围模型。 */ export class AnnotationScope { @@ -59,15 +61,20 @@ export class AnnotationScope { /** 从任意输入构造(null / payload / 普通对象)。 */ static fromPayload( - payload: AnnotationScopePayload | Record | null | undefined, + payload: + AnnotationScopePayload | Record | null | undefined, ): AnnotationScope { const scope = new AnnotationScope(); if (!payload) return scope; const record = payload as Record; scope.scope_summary = typeof record.scope_summary === 'string' ? record.scope_summary : ''; - scope.include_detection_labels = coerceStrList(record.include_detection_labels); - scope.exclude_detection_labels = coerceStrList(record.exclude_detection_labels); + scope.include_detection_labels = coerceStrList( + record.include_detection_labels, + ); + scope.exclude_detection_labels = coerceStrList( + record.exclude_detection_labels, + ); scope.include_label_names = coerceStrList(record.include_label_names); scope.exclude_label_names = coerceStrList(record.exclude_label_names); return scope; @@ -86,10 +93,10 @@ export class AnnotationScope { isRestricted(): boolean { return Boolean( this.scope_summary.trim() || - this.include_detection_labels.length || - this.exclude_detection_labels.length || - this.include_label_names.length || - this.exclude_label_names.length, + this.include_detection_labels.length || + this.exclude_detection_labels.length || + this.include_label_names.length || + this.exclude_label_names.length, ); } } @@ -219,7 +226,11 @@ export function inferAnnotationScopeFromText( } for (const name of options.labelNames ?? []) { - if (name && text.includes(name) && ['只', '仅'].some((k) => text.includes(k))) { + if ( + name && + text.includes(name) && + ['只', '仅'].some((k) => text.includes(k)) + ) { includeLabels.push(name); } } @@ -291,8 +302,12 @@ export function filterLabelCandidatesByScope( return candidates; } - const include = new Set(scope.include_label_names.map((n) => n.toLowerCase())); - const exclude = new Set(scope.exclude_label_names.map((n) => n.toLowerCase())); + const include = new Set( + scope.include_label_names.map((n) => n.toLowerCase()), + ); + const exclude = new Set( + scope.exclude_label_names.map((n) => n.toLowerCase()), + ); const filtered: Array> = []; for (const candidate of candidates) { diff --git a/src/main/agent/annotate/validation.ts b/src/main/agent/annotate/validation.ts index 1f58af5..cc1abc0 100644 --- a/src/main/agent/annotate/validation.ts +++ b/src/main/agent/annotate/validation.ts @@ -57,7 +57,9 @@ function nameToId( ): Map { const out = new Map(); for (const c of candidates) { - const name = String(c.name ?? '').trim().toLowerCase(); + const name = String(c.name ?? '') + .trim() + .toLowerCase(); const id = String(c.id ?? '').trim(); if (name && id) out.set(name, id); } @@ -129,7 +131,9 @@ export interface ValidateParams { } /** 校验视觉映射结果。 */ -export function validateVisionMappings(params: ValidateParams): ValidationResult { +export function validateVisionMappings( + params: ValidateParams, +): ValidationResult { const ids = params.validIds && params.validIds.size > 0 ? params.validIds diff --git a/src/main/agent/bridge.test.ts b/src/main/agent/bridge.test.ts index 2572ff4..5ae3d4d 100644 --- a/src/main/agent/bridge.test.ts +++ b/src/main/agent/bridge.test.ts @@ -54,7 +54,9 @@ describe('agent RPC bridge', () => { }, }); - await expect(host.call('image:crop', { boxes: [1, 2, 3] })).resolves.toEqual({ + await expect( + host.call('image:crop', { boxes: [1, 2, 3] }), + ).resolves.toEqual({ count: 3, }); diff --git a/src/main/agent/bridge.ts b/src/main/agent/bridge.ts index 6ac8f08..10f06c2 100644 --- a/src/main/agent/bridge.ts +++ b/src/main/agent/bridge.ts @@ -131,7 +131,12 @@ export function createBridge(options: BridgeOptions): Bridge { if (disposed) return; const msg: AgentMessage = ok ? { type: 'rpc:response', id, ok: true, result } - : { type: 'rpc:response', id, ok: false, error: error ?? 'unknown error' }; + : { + type: 'rpc:response', + id, + ok: false, + error: error ?? 'unknown error', + }; endpoint.postMessage(msg); } diff --git a/src/main/agent/config.ts b/src/main/agent/config.ts index 637b808..37d5c47 100644 --- a/src/main/agent/config.ts +++ b/src/main/agent/config.ts @@ -88,7 +88,10 @@ export function loadAgentSettings(): AgentSettings { }; return { ...DEFAULT_AGENT_SETTINGS, - maxToolRounds: num('LR_AGENT_MAX_TOOL_ROUNDS', DEFAULT_AGENT_SETTINGS.maxToolRounds), + maxToolRounds: num( + 'LR_AGENT_MAX_TOOL_ROUNDS', + DEFAULT_AGENT_SETTINGS.maxToolRounds, + ), readFileMaxBytes: num( 'LR_AGENT_READ_FILE_MAX_BYTES', DEFAULT_AGENT_SETTINGS.readFileMaxBytes, diff --git a/src/main/agent/context/contextService.ts b/src/main/agent/context/contextService.ts index 37655ac..a3c6002 100644 --- a/src/main/agent/context/contextService.ts +++ b/src/main/agent/context/contextService.ts @@ -115,7 +115,11 @@ export function appendClientToolResultsToMessages( }); if (unanswered.has(ctr.toolCallId)) { - messages.push({ role: 'tool', content: result, toolCallId: ctr.toolCallId }); + messages.push({ + role: 'tool', + content: result, + toolCallId: ctr.toolCallId, + }); unanswered.delete(ctr.toolCallId); continue; } @@ -139,7 +143,11 @@ export function appendClientToolResultsToMessages( content: '', toolCalls: [{ id: ctr.toolCallId, name: ctr.name, args }], }); - messages.push({ role: 'tool', content: result, toolCallId: ctr.toolCallId }); + messages.push({ + role: 'tool', + content: result, + toolCallId: ctr.toolCallId, + }); } return messages; diff --git a/src/main/agent/context/multimodal.ts b/src/main/agent/context/multimodal.ts index 11b6d94..39b4b1b 100644 --- a/src/main/agent/context/multimodal.ts +++ b/src/main/agent/context/multimodal.ts @@ -59,4 +59,5 @@ export async function buildMultimodalUserMessage( } /** 附图注入时使用的固定提示文本(与 Python 一致)。 */ -export const VISION_ATTACHMENT_TEXT = '【附图】请根据上图回答用户关于该图片的问题。'; +export const VISION_ATTACHMENT_TEXT = + '【附图】请根据上图回答用户关于该图片的问题。'; diff --git a/src/main/agent/context/snapshot.ts b/src/main/agent/context/snapshot.ts index d39062e..1c3926b 100644 --- a/src/main/agent/context/snapshot.ts +++ b/src/main/agent/context/snapshot.ts @@ -147,8 +147,9 @@ export function formatSnapshotForPrompt( .filter((m) => m && typeof m === 'object') .map( (m) => - `- ${String(m.id ?? '')}: ${String(m.name ?? '')}` + - (m.is_default ? ' (默认)' : ''), + `- ${String(m.id ?? '')}: ${String(m.name ?? '')}${ + m.is_default ? ' (默认)' : '' + }`, ); lines.push('可用检测模型(object_detection):'); lines.push(...(modelLines.length ? modelLines : ['- (未配置或未传入)'])); @@ -156,7 +157,9 @@ export function formatSnapshotForPrompt( if (ANNOTATION_KNOWN_TYPES.has(annotationType)) { const extra = ANNOTATION_TYPE_EXTRAS[annotationType]; - lines.push(extra ? `${ANNOTATION_SCOPE_HINT} ${extra}` : ANNOTATION_SCOPE_HINT); + lines.push( + extra ? `${ANNOTATION_SCOPE_HINT} ${extra}` : ANNOTATION_SCOPE_HINT, + ); } return lines.join('\n'); diff --git a/src/main/agent/context/systemPrompt.ts b/src/main/agent/context/systemPrompt.ts index e6ad155..a0fa2c3 100644 --- a/src/main/agent/context/systemPrompt.ts +++ b/src/main/agent/context/systemPrompt.ts @@ -120,14 +120,16 @@ export function buildAssistSystemPrompt(params: { if (instructions) base = `${base}\n\n【项目指令】\n${instructions}`; // 稳定块(skills)先于动态块(记忆、台账) - const skillsBlock = formatSkillsCatalogBlock(clientContext.skillsCatalog ?? []); + const skillsBlock = formatSkillsCatalogBlock( + clientContext.skillsCatalog ?? [], + ); if (skillsBlock) base = `${base}\n\n${skillsBlock}`; if (clientContext.workspaceMemoryEnabled) { const memoryIndex = (clientContext.memoryIndex ?? '').trim(); - const indexBlock = memoryIndex - ? memoryIndex - : '(尚无工作区记忆文件。进度与已标文件将在用户确认或保存标注后由系统生成。)'; + const indexBlock = + memoryIndex || + '(尚无工作区记忆文件。进度与已标文件将在用户确认或保存标注后由系统生成。)'; base = `${base}\n\n【工作区记忆】\n${indexBlock}\n` + '这是本标注任务的工作区记忆,可有多个 Markdown 文件。' + diff --git a/src/main/agent/handlers/annotation.ts b/src/main/agent/handlers/annotation.ts index b98a6d7..90b628a 100644 --- a/src/main/agent/handlers/annotation.ts +++ b/src/main/agent/handlers/annotation.ts @@ -26,10 +26,7 @@ import { QualityReportComposeRequestSchema, } from '../annotate/schemas'; import { mapDetectionBoxesToLabelsUnified } from '../annotate/mapLabels'; -import { - labelNamesOf, - prepareMutationAnnotation, -} from '../annotate/mutation'; +import { labelNamesOf, prepareMutationAnnotation } from '../annotate/mutation'; import { llmGenerate } from '../annotate/generate'; import { assertComposePayloadSize, @@ -106,12 +103,18 @@ export function createMutationPrepareHandler(deps: RuntimeDeps): RouteHandler { return async (req) => { // 功能开关:关闭时返回 403(对齐 Python) if (!deps.settings.mutationEnabled) { - return { kind: 'json', status: 403, body: { detail: 'agent_mutation_disabled' } }; + return { + kind: 'json', + status: 403, + body: { detail: 'agent_mutation_disabled' }, + }; } let body; try { - body = MutationPrepareRequestSchema.parse(JSON.parse(req.rawBody || '{}')); + body = MutationPrepareRequestSchema.parse( + JSON.parse(req.rawBody || '{}'), + ); } catch (err) { return { kind: 'json', @@ -178,7 +181,11 @@ export function createQualityComposeHandler(deps: RuntimeDeps): RouteHandler { requireCredentials(body); } catch (err) { if (err instanceof AnnotateHttpError) { - return { kind: 'json', status: err.status, body: { detail: err.message } }; + return { + kind: 'json', + status: err.status, + body: { detail: err.message }, + }; } return { kind: 'json', @@ -206,11 +213,15 @@ export function createQualityComposeHandler(deps: RuntimeDeps): RouteHandler { } /** `POST /agent/annotation/map-detection-boxes` */ -export function createMapDetectionBoxesHandler(deps: RuntimeDeps): RouteHandler { +export function createMapDetectionBoxesHandler( + deps: RuntimeDeps, +): RouteHandler { return async (req) => { let body; try { - body = MapDetectionBoxesRequestSchema.parse(JSON.parse(req.rawBody || '{}')); + body = MapDetectionBoxesRequestSchema.parse( + JSON.parse(req.rawBody || '{}'), + ); } catch (err) { return { kind: 'json', diff --git a/src/main/agent/handlers/chatStream.ts b/src/main/agent/handlers/chatStream.ts index c42aa6f..4b547c0 100644 --- a/src/main/agent/handlers/chatStream.ts +++ b/src/main/agent/handlers/chatStream.ts @@ -57,7 +57,11 @@ export function createChatStreamHandler(deps: RuntimeDeps): RouteHandler { body = parseChatStreamRequest(req.rawBody); } catch (err) { if (err instanceof RequestParseError) { - return { kind: 'json', status: err.status, body: { detail: err.message } }; + return { + kind: 'json', + status: err.status, + body: { detail: err.message }, + }; } throw err; } @@ -88,8 +92,11 @@ export function createChatCancelHandler(): RouteHandler { return (req) => { let clientJobId = ''; try { - const parsed = JSON.parse(req.rawBody || '{}') as { client_job_id?: unknown }; - if (typeof parsed.client_job_id === 'string') clientJobId = parsed.client_job_id; + const parsed = JSON.parse(req.rawBody || '{}') as { + client_job_id?: unknown; + }; + if (typeof parsed.client_job_id === 'string') + clientJobId = parsed.client_job_id; } catch { /* 解析失败按未找到处理 */ } diff --git a/src/main/agent/handlers/index.ts b/src/main/agent/handlers/index.ts index ae9da9e..ae0fc05 100644 --- a/src/main/agent/handlers/index.ts +++ b/src/main/agent/handlers/index.ts @@ -31,7 +31,6 @@ export function buildImplementedRoutes( createMutationPrepareHandler(deps), [ANNOTATION_HANDLER_KEYS.mapDetectionBoxes]: createMapDetectionBoxesHandler(deps), - [ANNOTATION_HANDLER_KEYS.qualityCompose]: - createQualityComposeHandler(deps), + [ANNOTATION_HANDLER_KEYS.qualityCompose]: createQualityComposeHandler(deps), }); } diff --git a/src/main/agent/handlers/mcpProbe.ts b/src/main/agent/handlers/mcpProbe.ts index 33e80d4..3942c86 100644 --- a/src/main/agent/handlers/mcpProbe.ts +++ b/src/main/agent/handlers/mcpProbe.ts @@ -28,26 +28,41 @@ export const mcpProbeHandler: RouteHandler = async (req) => { const url = typeof body.url === 'string' ? body.url.trim() : ''; // 与 Python 一致:仅接受 http(s) if (!url.startsWith('http://') && !url.startsWith('https://')) { - return { kind: 'json', body: { ok: false, error: '仅支持 http(s) MCP 端点' } }; + return { + kind: 'json', + body: { ok: false, error: '仅支持 http(s) MCP 端点' }, + }; } const transport = body.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const); const headers: Record = {}; - if (body.headers && typeof body.headers === 'object' && !Array.isArray(body.headers)) { - for (const [key, value] of Object.entries(body.headers as Record)) { + if ( + body.headers && + typeof body.headers === 'object' && + !Array.isArray(body.headers) + ) { + for (const [key, value] of Object.entries( + body.headers as Record, + )) { if (typeof value === 'string') headers[key] = value; } } try { const tools = await probeMcpTools({ url, transport, headers }); - return { kind: 'json', body: { ok: true, tools: tools.map((t) => t.name) } }; + return { + kind: 'json', + body: { ok: true, tools: tools.map((t) => t.name) }, + }; } catch (err) { return { kind: 'json', - body: { ok: false, error: err instanceof Error ? err.message : String(err) }, + body: { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, }; } }; diff --git a/src/main/agent/host.ts b/src/main/agent/host.ts index bfd5326..7d57270 100644 --- a/src/main/agent/host.ts +++ b/src/main/agent/host.ts @@ -106,7 +106,9 @@ function pushStatus(status: LocalAgentServiceStatus): void { */ export function resolveRuntimeEntry(): string { const mainFile = path.basename(__filename); - const suffix = mainFile.startsWith('main') ? mainFile.slice('main'.length) : '.js'; + const suffix = mainFile.startsWith('main') + ? mainFile.slice('main'.length) + : '.js'; return path.join(__dirname, `agentRuntime${suffix}`); } @@ -318,7 +320,10 @@ function makeEndpoint(child: UtilityProcess): MessageEndpoint { }; } -function waitForReady(child: UtilityProcess, timeoutMs: number): Promise { +function waitForReady( + child: UtilityProcess, + timeoutMs: number, +): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanup(); diff --git a/src/main/agent/llm/client.ts b/src/main/agent/llm/client.ts index aceed50..d1859e8 100644 --- a/src/main/agent/llm/client.ts +++ b/src/main/agent/llm/client.ts @@ -14,6 +14,12 @@ * 本模块只负责「把请求发出去、把增量取回来」。 */ +/* eslint-disable max-classes-per-file -- 本文件是单一协议层模块: + * `LlmRequestError`(错误契约)、`LlmClient`(请求/流式解析)、 + * `ToolCallAccumulator`(流式 tool_call 分片累积)三者互相耦合 + * (客户端抛错误、并靠累积器拼参数),拆文件只会把这份契约切碎, + * 反而降低可读性。 */ + /** 消息内容可以是纯文本,或多模态部件数组。 */ export type MessageContent = | string @@ -93,6 +99,7 @@ const DEFAULT_RETRY_BASE_DELAY_MS = 300; /** 调用失败时抛出,携带 HTTP 状态与响应体片段以便上层分流。 */ export class LlmRequestError extends Error { readonly status: number; + readonly body: string; constructor(status: number, body: string) { @@ -180,26 +187,32 @@ export class LlmClient { body: Record, signal?: AbortSignal, ): Promise { - const maxRetries = this.options.maxRetries; + const { maxRetries } = this.options; const baseDelay = this.options.retryBaseDelayMs; for (let attempt = 0; ; attempt += 1) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.options.timeoutMs); + const timer = setTimeout( + () => controller.abort(), + this.options.timeoutMs, + ); // 上游取消也要能中断请求 const onAbort = (): void => controller.abort(); signal?.addEventListener('abort', onAbort); try { - const res = await this.fetchImpl(`${this.options.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.options.apiKey}`, + const res = await this.fetchImpl( + `${this.options.baseUrl}/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.options.apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, }, - body: JSON.stringify(body), - signal: controller.signal, - }); + ); if (!res.ok) { const text = await res.text().catch(() => ''); throw new LlmRequestError(res.status, text); @@ -235,7 +248,12 @@ export class LlmClient { maxTokens?: number; temperature?: number; }): AsyncGenerator { - const body = this.buildBody({ ...params, stream: true, maxTokens: params.maxTokens, temperature: params.temperature }); + const body = this.buildBody({ + ...params, + stream: true, + maxTokens: params.maxTokens, + temperature: params.temperature, + }); const res = await this.post(body, params.signal); const bodyStream = res.body; if (!bodyStream) throw new Error('LLM 响应没有 body'); @@ -319,7 +337,7 @@ function serializeMessage(message: ChatMessage): Record { /** 从流式 chunk 提取增量;结构不符时返回 null(容错)。 */ function parseChunk(chunk: unknown): LlmDelta | null { if (!chunk || typeof chunk !== 'object') return null; - const choices = (chunk as { choices?: unknown }).choices; + const { choices } = chunk as { choices?: unknown }; if (!Array.isArray(choices) || choices.length === 0) return null; const first = choices[0] as { delta?: Record; @@ -356,14 +374,15 @@ function parseCompletion(json: unknown): AssistantTurn { json && typeof json === 'object' ? ((json as { choices?: unknown }).choices as unknown[] | undefined)?.[0] : undefined; - const message = ( - choice as { message?: Record } | undefined - )?.message; + const message = (choice as { message?: Record } | undefined) + ?.message; if (!message) return { content: '', reasoning: '', toolCalls: [] }; const content = typeof message.content === 'string' ? message.content : ''; const reasoning = - typeof message.reasoning_content === 'string' ? message.reasoning_content : ''; + typeof message.reasoning_content === 'string' + ? message.reasoning_content + : ''; const rawCalls = Array.isArray(message.tool_calls) ? (message.tool_calls as Array<{ @@ -382,7 +401,9 @@ function parseCompletion(json: unknown): AssistantTurn { } /** 参数解析失败时退化为 `{}`(对齐 Python `normalize_api_tool_calls` 的兜底)。 */ -export function safeParseArgs(raw: string | undefined | null): Record { +export function safeParseArgs( + raw: string | undefined | null, +): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw); diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts index 64a6feb..ffef01d 100644 --- a/src/main/agent/loop/assistLoop.test.ts +++ b/src/main/agent/loop/assistLoop.test.ts @@ -10,7 +10,11 @@ import { buildTools, toToolSpec } from '../tools/registry'; import { DEFAULT_AGENT_SETTINGS } from '../config'; import { createStandaloneImageService } from '../services/imageService'; import { deriveTaskPhase } from './taskPhase'; -import { FULL_TOOL_SET, LIGHT_TOOL_SET, resolveAssistToolSet } from './modeRouter'; +import { + FULL_TOOL_SET, + LIGHT_TOOL_SET, + resolveAssistToolSet, +} from './modeRouter'; import { streamAssist } from './assistLoop'; import type { StreamEventPayload } from '../sse'; @@ -43,7 +47,10 @@ function makeFetch( tool_calls: (turn.toolCalls ?? []).map((tc) => ({ id: tc.id, type: 'function', - function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + function: { + name: tc.name, + arguments: JSON.stringify(tc.args), + }, })), }, finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop', @@ -89,7 +96,10 @@ function makeFetch( frames.push( `data: ${JSON.stringify({ choices: [ - { delta: {}, finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop' }, + { + delta: {}, + finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop', + }, ], })}\n\n`, ); @@ -149,10 +159,7 @@ describe('Assist 循环:纯文本', () => { }), ); - expect(events.map((e) => e.type)).toEqual([ - 'preparing', - 'text_delta', - ]); + expect(events.map((e) => e.type)).toEqual(['preparing', 'text_delta']); expect(events[1].content).toBe('你好'); }); }); @@ -161,9 +168,7 @@ describe('Assist 循环:工具集隔离', () => { it('调用集合外的工具返回「未知工具」,且不发 tool_result 之外的副作用', async () => { const { fetchImpl } = makeFetch([ { - toolCalls: [ - { id: 'tc1', name: 'describe_client_context', args: {} }, - ], + toolCalls: [{ id: 'tc1', name: 'describe_client_context', args: {} }], }, { text: '完成' }, ]); @@ -325,7 +330,11 @@ describe('Assist 循环:tool_choice="any" 兜底', () => { { nonStream: true, toolCalls: [ - { id: 'forced', name: 'auto_annotate', args: { user_request: '标注' } }, + { + id: 'forced', + name: 'auto_annotate', + args: { user_request: '标注' }, + }, ], }, ]); @@ -431,7 +440,11 @@ describe('阶段门禁与工具集', () => { const { fetchImpl } = makeFetch([ { toolCalls: [ - { id: 'tc1', name: 'write_workspace_file', args: { relative_path: 'a.md', content: 'x' } }, + { + id: 'tc1', + name: 'write_workspace_file', + args: { relative_path: 'a.md', content: 'x' }, + }, ], }, { text: '完成' }, @@ -583,7 +596,9 @@ describe('LLM 客户端:网络失败重试', () => { return new Response('server error', { status: 500 }); }) as unknown as typeof fetch; - await expect(drainText(makeRetryClient(impl))).rejects.toThrow(LlmRequestError); + await expect(drainText(makeRetryClient(impl))).rejects.toThrow( + LlmRequestError, + ); expect(index).toBe(1); }); }); diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index 7a91cf9..da095d3 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -93,7 +93,8 @@ const PHASE_BLOCKED_STATUS = 'phase_blocked'; const DUPLICATE_CALL_SUMMARY = '该工具本轮已用相同参数调用,已跳过重复调用。'; /** 同轮多个 auto_annotate 被合并后的反馈。 */ -const COALESCED_SUMMARY = '同一轮中的多个 auto_annotate 已合并为一次批量调用,本调用已跳过。'; +const COALESCED_SUMMARY = + '同一轮中的多个 auto_annotate 已合并为一次批量调用,本调用已跳过。'; /** 已执行过的调用被重复发起时的反馈文案。 */ const ALREADY_COMPLETED_SUMMARY = @@ -173,9 +174,7 @@ export interface AssistLoopParams { /** 单轮执行结果,用于驱动外层循环。 */ type RoundOutcome = - | { kind: 'continued' } - | { kind: 'finished' } - | { kind: 'pending' }; + { kind: 'continued' } | { kind: 'finished' } | { kind: 'pending' }; /** * 可变的轮次结果容器。 @@ -262,7 +261,8 @@ async function* streamAssistInner( const proposalsApplied = params.taskPhaseContext?.phase === 'verify'; if (isResume) { - for (const result of clientToolResults) completedTools.add(result.toolCallId); + for (const result of clientToolResults) + completedTools.add(result.toolCallId); appendClientToolResultsToMessages(messages, clientToolResults, { userContent: params.userContent, proposalsApplied, @@ -390,7 +390,8 @@ async function* streamAssistInner( if (budgetExhausted) { messages.push({ role: 'user', - content: '工具调用预算已用完。请基于以上进展直接给出最终回答,不要再调用工具。', + content: + '工具调用预算已用完。请基于以上进展直接给出最终回答,不要再调用工具。', }); for await (const delta of llm.streamChat({ messages, signal })) { if (isCancelled()) return; @@ -506,7 +507,9 @@ async function* executeRound( messages.push({ role: 'assistant', content: fullText, - toolCalls: apiToolCalls.length ? apiToolCalls : resolved.map(toChatToolCall), + toolCalls: apiToolCalls.length + ? apiToolCalls + : resolved.map(toChatToolCall), }); /** 被拦下的调用:发 tool_start + tool_result 并回灌 ToolMessage。 */ @@ -538,7 +541,14 @@ async function* executeRound( const deferred = new Set(); if (hasAnnotationWrite) { for (const call of resolved) { - if (['write_workspace_file', 'str_replace_workspace_file', 'delete_workspace_file', 'move_workspace_file'].includes(call.name)) { + if ( + [ + 'write_workspace_file', + 'str_replace_workspace_file', + 'delete_workspace_file', + 'move_workspace_file', + ].includes(call.name) + ) { deferred.add(call.toolCallId); } } @@ -554,7 +564,11 @@ async function* executeRound( if (gatingEnabled(taskPhaseContext)) { for (const call of resolved) { if (deferred.has(call.toolCallId)) continue; - const reason = checkCallAllowed(call.name, call.arguments, taskPhaseContext); + const reason = checkCallAllowed( + call.name, + call.arguments, + taskPhaseContext, + ); if (reason) { gatedOut.add(call.toolCallId); yield* blocked(call, PHASE_BLOCKED_STATUS, reason); @@ -640,7 +654,9 @@ async function* executeRound( ); if (parallelCalls.length > 0) { const results = await Promise.all( - parallelCalls.map((call) => invokeTool(call, tools, toolSet, toolContext)), + parallelCalls.map((call) => + invokeTool(call, tools, toolSet, toolContext), + ), ); parallelCalls.forEach((call, index) => { prefetched.set(call.toolCallId, results[index]); @@ -667,7 +683,10 @@ async function* executeRound( // explore 调用:允许多个并行(事件交错),完成后统一补 tool_result if (exploreCalls.length > 0) { - const finals = new Map(); + const finals = new Map< + string, + { summary: string; status: 'done' | 'error' } + >(); const streams = exploreCalls.map((call) => trackedExplore({ call, @@ -999,7 +1018,8 @@ async function* streamToolExecution( const streamedPath = streamedPathsByCallId.get(call.toolCallId); if (streamedPath) { yield sse.fileProposal({ - summary: String(call.arguments.relative_path ?? streamedPath) || streamedPath, + summary: + String(call.arguments.relative_path ?? streamedPath) || streamedPath, content: '', relativePath: streamedPath, mode: call.name === STR_REPLACE_TOOL_NAME ? 'edit' : 'write', @@ -1015,7 +1035,7 @@ async function* streamToolExecution( // 注意:`.lr-agent` 路径的终态事件**仍然发射**(与流式拦截的 suppress 不同) if (!isLrAgentRelative(relPath)) { - const operation = docProposal.operation; + const { operation } = docProposal; const omitForPath = streamedPaths?.has(relPath) ?? false; if (operation !== 'delete' && !omitForPath) { @@ -1027,7 +1047,11 @@ async function* streamToolExecution( }); // 定稿补齐时分 200 字符一片 const CHUNK_SIZE = 200; - for (let offset = 0; offset < fullContent.length; offset += CHUNK_SIZE) { + for ( + let offset = 0; + offset < fullContent.length; + offset += CHUNK_SIZE + ) { yield sse.fileProposalDelta({ content: fullContent.slice(offset, offset + CHUNK_SIZE), relativePath: relPath, diff --git a/src/main/agent/loop/proposalStreamer.test.ts b/src/main/agent/loop/proposalStreamer.test.ts index 3c70d54..fc0a108 100644 --- a/src/main/agent/loop/proposalStreamer.test.ts +++ b/src/main/agent/loop/proposalStreamer.test.ts @@ -74,11 +74,17 @@ describe('extractJsonString:半截 JSON 解析', () => { }); it('key 出现但值尚未开始时返回 null', () => { - expect(extractJsonString('{"a":', 'a')).toEqual({ value: null, closed: false }); + expect(extractJsonString('{"a":', 'a')).toEqual({ + value: null, + closed: false, + }); }); it('解码标准转义', () => { - const { value } = extractJsonString('{"a": "l1\\nl2\\tt\\"q\\"\\\\s"}', 'a'); + const { value } = extractJsonString( + '{"a": "l1\\nl2\\tt\\"q\\"\\\\s"}', + 'a', + ); expect(value).toBe('l1\nl2\tt"q"\\s'); }); @@ -87,7 +93,9 @@ describe('extractJsonString:半截 JSON 解析', () => { }); it('解码 \\uXXXX', () => { - expect(extractJsonString('{"a": "\\u4f60\\u597d"}', 'a').value).toBe('你好'); + expect(extractJsonString('{"a": "\\u4f60\\u597d"}', 'a').value).toBe( + '你好', + ); }); it('\\u 序列不完整时暂停解析(返回未闭合)', () => { @@ -113,7 +121,10 @@ describe('extractJsonString:半截 JSON 解析', () => { }); it('空字符串值为空串而非 null', () => { - expect(extractJsonString('{"a": ""}', 'a')).toEqual({ value: '', closed: true }); + expect(extractJsonString('{"a": ""}', 'a')).toEqual({ + value: '', + closed: true, + }); }); it('按码点解码,emoji 不丢字', () => { @@ -131,7 +142,9 @@ describe('提案拦截器:write 工具', () => { const events = run(fragment(0, 'tc1', 'write_workspace_file', ARGS, 7)); const types = events.map((e) => e.type); expect(types[0]).toBe('file_proposal_start'); - expect(types.filter((t) => t === 'file_proposal_delta').length).toBeGreaterThan(0); + expect( + types.filter((t) => t === 'file_proposal_delta').length, + ).toBeGreaterThan(0); }); it('增量拼接后等于完整内容', () => { @@ -165,13 +178,17 @@ describe('提案拦截器:write 工具', () => { const interceptor = new ProposalStreamInterceptor(CTX); for (const chunk of chunks) { const events = interceptor.onChunk([chunk]); - expect(events.filter((e) => e.type === 'file_proposal_delta').length) - .toBeLessThanOrEqual(1); + expect( + events.filter((e) => e.type === 'file_proposal_delta').length, + ).toBeLessThanOrEqual(1); } }); it('含 emoji 的内容增量拼接后完整还原', () => { - const args = JSON.stringify({ relative_path: 'a.md', content: '前😀后🎉尾' }); + const args = JSON.stringify({ + relative_path: 'a.md', + content: '前😀后🎉尾', + }); const events = run(fragment(0, 'tc1', 'write_workspace_file', args, 3)); const content = events .filter((e) => e.type === 'file_proposal_delta') @@ -191,14 +208,18 @@ describe('提案拦截器:edit 工具', () => { }); it('start 带 mode/operation = edit', () => { - const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 6)); + const events = run( + fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 6), + ); const start = events.find((e) => e.type === 'file_proposal_start'); expect(start?.mode).toBe('edit'); expect(start?.operation).toBe('edit'); }); it('file_edit_delta 的 old/new 增量可拼接还原', () => { - const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + const events = run( + fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4), + ); const deltas = events.filter((e) => e.type === 'file_edit_delta'); expect(deltas.length).toBeGreaterThan(0); @@ -209,12 +230,16 @@ describe('提案拦截器:edit 工具', () => { }); it('不发 file_proposal_delta(edit 走 edit_delta)', () => { - const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + const events = run( + fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4), + ); expect(events.some((e) => e.type === 'file_proposal_delta')).toBe(false); }); it('oldDelta/newDelta 至少一个非空才发事件', () => { - const events = run(fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4)); + const events = run( + fragment(0, 'tc1', 'str_replace_workspace_file', ARGS, 4), + ); for (const event of events.filter((e) => e.type === 'file_edit_delta')) { const hasDelta = Boolean(event.oldDelta) || Boolean(event.newDelta); expect(hasDelta).toBe(true); @@ -228,11 +253,16 @@ describe('提案拦截器:.lr-agent 抑制', () => { relative_path: '.lr-agent/scratch.md', content: '内部草稿', }); - expect(run(fragment(0, 'tc1', 'write_workspace_file', args, 5))).toHaveLength(0); + expect( + run(fragment(0, 'tc1', 'write_workspace_file', args, 5)), + ).toHaveLength(0); }); it('被抑制的调用不出现在 streamedPathsByCallId', () => { - const args = JSON.stringify({ relative_path: '.lr-agent/scratch.md', content: 'x' }); + const args = JSON.stringify({ + relative_path: '.lr-agent/scratch.md', + content: 'x', + }); const interceptor = new ProposalStreamInterceptor(CTX); for (const chunk of fragment(0, 'tc1', 'write_workspace_file', args, 5)) { interceptor.onChunk([chunk]); @@ -245,13 +275,25 @@ describe('提案拦截器:状态重置与 provider 兼容', () => { it('同一 tc_idx 上换 id 时重置状态(避免残留 rel_path 错标)', () => { const interceptor = new ProposalStreamInterceptor(CTX); const first = JSON.stringify({ relative_path: 'a.md', content: 'AAA' }); - for (const chunk of fragment(0, 'tc1', 'write_workspace_file', first, 100)) { + for (const chunk of fragment( + 0, + 'tc1', + 'write_workspace_file', + first, + 100, + )) { interceptor.onChunk([chunk]); } const second = JSON.stringify({ relative_path: 'b.md', content: 'BBB' }); let sawSecondStart = false; - for (const chunk of fragment(0, 'tc2', 'write_workspace_file', second, 100)) { + for (const chunk of fragment( + 0, + 'tc2', + 'write_workspace_file', + second, + 100, + )) { for (const event of interceptor.onChunk([chunk])) { const dict = toSseDict(event); if ( @@ -268,7 +310,13 @@ describe('提案拦截器:状态重置与 provider 兼容', () => { it('kind 从 write 变为 edit 时重置状态', () => { const interceptor = new ProposalStreamInterceptor(CTX); const writeArgs = JSON.stringify({ relative_path: 'a.md', content: 'x' }); - for (const chunk of fragment(0, 'tc1', 'write_workspace_file', writeArgs, 100)) { + for (const chunk of fragment( + 0, + 'tc1', + 'write_workspace_file', + writeArgs, + 100, + )) { interceptor.onChunk([chunk]); } const editArgs = JSON.stringify({ @@ -293,7 +341,12 @@ describe('提案拦截器:状态重置与 provider 兼容', () => { const collected: string[] = []; for (let i = 0; i < 3; i += 1) { for (const event of interceptor.onChunk([ - { index: 0, id: 'tc1', name: 'write_workspace_file', argsFragment: args }, + { + index: 0, + id: 'tc1', + name: 'write_workspace_file', + argsFragment: args, + }, ])) { if (event.type === 'file_proposal_delta') { collected.push(event.content as string); @@ -318,10 +371,18 @@ describe('提案拦截器:streamedPathsByCallId', () => { it('返回已出卡的 callId → 显示路径', () => { const args = JSON.stringify({ relative_path: 'dir/x.md', content: 'c' }); const interceptor = new ProposalStreamInterceptor(CTX); - for (const chunk of fragment(0, 'call-abc', 'write_workspace_file', args, 5)) { + for (const chunk of fragment( + 0, + 'call-abc', + 'write_workspace_file', + args, + 5, + )) { interceptor.onChunk([chunk]); } - expect(interceptor.streamedPathsByCallId().get('call-abc')).toBe('dir/x.md'); + expect(interceptor.streamedPathsByCallId().get('call-abc')).toBe( + 'dir/x.md', + ); expect(interceptor.collectedPaths().has('dir/x.md')).toBe(true); }); diff --git a/src/main/agent/loop/proposalStreamer.ts b/src/main/agent/loop/proposalStreamer.ts index ea72119..1253ba6 100644 --- a/src/main/agent/loop/proposalStreamer.ts +++ b/src/main/agent/loop/proposalStreamer.ts @@ -52,7 +52,10 @@ interface ProposalState { suppressed?: boolean; } -function newState(callId = '', kind: 'write' | 'edit' = 'write'): ProposalState { +function newState( + callId = '', + kind: 'write' | 'edit' = 'write', +): ProposalState { return { argsBuf: '', titleSent: false, @@ -173,11 +176,15 @@ export function extractJsonString( /** 在码点数组中查找子序列,返回起始下标;未找到返回 -1。 */ function indexOfSequence(haystack: string[], needle: string[]): number { if (needle.length === 0) return 0; - outer: for (let i = 0; i + needle.length <= haystack.length; i += 1) { + for (let i = 0; i + needle.length <= haystack.length; i += 1) { + let matched = true; for (let j = 0; j < needle.length; j += 1) { - if (haystack[i + j] !== needle[j]) continue outer; + if (haystack[i + j] !== needle[j]) { + matched = false; + break; + } } - return i; + if (matched) return i; } return -1; } @@ -195,7 +202,7 @@ function mergeArgs(state: ProposalState, tcArgs: string): void { if (tcArgs.startsWith(buf)) { state.argsBuf = tcArgs; } else if (buf.startsWith(tcArgs)) { - return; + // 已见前缀的重复投递:保持现有 buf 不变(此处刻意不做任何处理) } else { state.argsBuf = buf + tcArgs; } @@ -269,7 +276,10 @@ export class ProposalStreamInterceptor { if (isLrAgentRelative(relPath)) { state.suppressed = true; } else { - state.relPath = normalizeWriteDisplayPath(this.clientContext, relPath); + state.relPath = normalizeWriteDisplayPath( + this.clientContext, + relPath, + ); events.push( sse.fileProposalStart({ summary: state.relPath, @@ -362,7 +372,8 @@ export class ProposalStreamInterceptor { const callId = state.callId || ''; streamed.set( callId, - state.relPath || normalizeWriteDisplayPath(this.clientContext, relPath), + state.relPath || + normalizeWriteDisplayPath(this.clientContext, relPath), ); } } diff --git a/src/main/agent/mcp/client.test.ts b/src/main/agent/mcp/client.test.ts index 6fd7cb8..8f2907c 100644 --- a/src/main/agent/mcp/client.test.ts +++ b/src/main/agent/mcp/client.test.ts @@ -237,7 +237,10 @@ describe('stringifyMcpResult:结果收拢', () => { it('过滤空文本部件', () => { expect( stringifyMcpResult({ - content: [{ type: 'text', text: 'a' }, { type: 'text', text: '' }], + content: [ + { type: 'text', text: 'a' }, + { type: 'text', text: '' }, + ], }), ).toBe('a'); }); diff --git a/src/main/agent/mcp/client.ts b/src/main/agent/mcp/client.ts index 87fb8ae..5070a10 100644 --- a/src/main/agent/mcp/client.ts +++ b/src/main/agent/mcp/client.ts @@ -26,8 +26,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import type { ToolDefinition } from '../tools/registry'; import { z } from 'zod'; +import type { ToolDefinition } from '../tools/registry'; /** 连接参数。 */ export interface McpConnection { @@ -247,7 +247,7 @@ export function stringifyMcpResult(result: unknown): string { if (typeof result === 'string') return result; if (typeof result === 'object') { - const content = (result as { content?: unknown }).content; + const { content } = result as { content?: unknown }; if (Array.isArray(content)) { const parts: string[] = []; for (const item of content) { @@ -295,7 +295,9 @@ export function localConnection( /** 远程 MCP Server 连接参数:URL **原样使用**(不拼 /mcp),协议按配置。 */ export function remoteConnection(server: McpServerInput): McpConnection { const transport = - server.transport === 'sse' ? ('sse' as const) : ('streamable_http' as const); + server.transport === 'sse' + ? ('sse' as const) + : ('streamable_http' as const); const conn: McpConnection = { url: (server.url ?? '').trim(), transport, @@ -514,8 +516,7 @@ export async function probeMcpTools(params: { }): Promise<{ name: string; description: string }[]> { const conn: McpConnection = { url: params.url.trim(), - transport: - params.transport === 'sse' ? 'sse' : 'streamable_http', + transport: params.transport === 'sse' ? 'sse' : 'streamable_http', timeoutMs: PROBE_TIMEOUT_MS, sseReadTimeoutMs: PROBE_SSE_READ_TIMEOUT_MS, }; diff --git a/src/main/agent/protocol.ts b/src/main/agent/protocol.ts index 3029bc1..2ca40b2 100644 --- a/src/main/agent/protocol.ts +++ b/src/main/agent/protocol.ts @@ -98,7 +98,7 @@ export type AgentChannel = (typeof AGENT_CHANNELS)[keyof typeof AGENT_CHANNELS]; /** 判断是否为合法的运行时消息(防御 parentPort 上的脏数据)。 */ export function isAgentMessage(value: unknown): value is AgentMessage { if (!value || typeof value !== 'object') return false; - const type = (value as { type?: unknown }).type; + const { type } = value as { type?: unknown }; return ( type === 'rpc:request' || type === 'rpc:response' || diff --git a/src/main/agent/routes.ts b/src/main/agent/routes.ts index ace4847..5022534 100644 --- a/src/main/agent/routes.ts +++ b/src/main/agent/routes.ts @@ -43,10 +43,13 @@ export function buildRoutes( routes[`POST ${AGENT_ROUTE_PATHS.chatStream}`] = notImplemented; routes[`POST ${AGENT_ROUTE_PATHS.chatCancel}`] = notImplemented; routes[`POST ${AGENT_ROUTE_PATHS.mcpProbe}`] = notImplemented; - routes[`POST ${AGENT_ROUTE_PATHS.annotationMutationPrepare}`] = notImplemented; - routes[`POST ${AGENT_ROUTE_PATHS.annotationMapDetectionBoxes}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.annotationMutationPrepare}`] = + notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.annotationMapDetectionBoxes}`] = + notImplemented; routes[`POST ${AGENT_ROUTE_PATHS.annotationLlmGenerate}`] = notImplemented; - routes[`POST ${AGENT_ROUTE_PATHS.qualityReportComposeStream}`] = notImplemented; + routes[`POST ${AGENT_ROUTE_PATHS.qualityReportComposeStream}`] = + notImplemented; return { ...routes, ...overrides }; } diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index b07dee8..d5bfcbf 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -40,7 +40,8 @@ interface ParentPortLike { } function getParentPort(): ParentPortLike | null { - const port = (process as unknown as { parentPort?: ParentPortLike }).parentPort; + const port = (process as unknown as { parentPort?: ParentPortLike }) + .parentPort; return port ?? null; } @@ -99,7 +100,10 @@ async function main(): Promise { const token = process.env[AGENT_TOKEN_ENV] ?? ''; if (!token) { // 与 Python 版一致:token 未配置时失败关闭,不启动服务 - log('error', `${AGENT_TOKEN_ENV} 未设置,拒绝启动(token 未配置时不得对外提供服务)`); + log( + 'error', + `${AGENT_TOKEN_ENV} 未设置,拒绝启动(token 未配置时不得对外提供服务)`, + ); process.exitCode = 1; return; } @@ -131,7 +135,8 @@ async function main(): Promise { } log('info', `就绪,baseUrl=http://${HOST}:${server.port}/api/v1`); - if (debug) log('info', `模式=${parentPort ? 'utilityProcess' : 'standalone'}`); + if (debug) + log('info', `模式=${parentPort ? 'utilityProcess' : 'standalone'}`); // 独立模式下把端口打到 stdout,便于脚本解析 if (!parentPort) { @@ -156,7 +161,7 @@ async function main(): Promise { if (parentPort) { // 主进程经消息触发关闭 const endpointOnMessage = (event: { data: unknown }): void => { - const data = event.data; + const { data } = event; if (isAgentMessage(data) && data.type === 'shutdown') { void shutdown('shutdown message').then(() => process.exit(0)); } diff --git a/src/main/agent/server.test.ts b/src/main/agent/server.test.ts index 8b78029..89d2e73 100644 --- a/src/main/agent/server.test.ts +++ b/src/main/agent/server.test.ts @@ -19,7 +19,11 @@ function request( headers?: Record; body?: string; } = {}, -): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { +): Promise<{ + status: number; + headers: http.IncomingHttpHeaders; + body: string; +}> { return new Promise((resolve, reject) => { const req = http.request( { diff --git a/src/main/agent/server.ts b/src/main/agent/server.ts index b09f579..a80edf1 100644 --- a/src/main/agent/server.ts +++ b/src/main/agent/server.ts @@ -40,7 +40,9 @@ export type RouteResult = | { kind: 'not_implemented' } | { kind: 'method_not_allowed' }; -export type RouteHandler = (req: RouteRequest) => Promise | RouteResult; +export type RouteHandler = ( + req: RouteRequest, +) => Promise | RouteResult; export interface AgentServerOptions { host: string; @@ -132,10 +134,7 @@ export async function startAgentServer( const path = url.pathname; // ── CORS ────────────────────────────────────────────── - const origin = resolveCorsOrigin( - req.headers.origin, - options.corsOrigins, - ); + const origin = resolveCorsOrigin(req.headers.origin, options.corsOrigins); if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); @@ -169,11 +168,10 @@ export async function startAgentServer( const pathExists = Object.keys(options.routes).some( (key) => key.slice(key.indexOf(' ') + 1) === path, ); - sendJson( - res, - pathExists ? 405 : 404, - { detail: pathExists ? 'method_not_allowed' : 'not_found', path }, - ); + sendJson(res, pathExists ? 405 : 404, { + detail: pathExists ? 'method_not_allowed' : 'not_found', + path, + }); return; } @@ -241,7 +239,11 @@ export async function startAgentServer( }; } -function sendJson(res: http.ServerResponse, status: number, body: unknown): void { +function sendJson( + res: http.ServerResponse, + status: number, + body: unknown, +): void { const payload = JSON.stringify(body); res.statusCode = status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); diff --git a/src/main/agent/services/imageService.ts b/src/main/agent/services/imageService.ts index 93da987..ad34ba1 100644 --- a/src/main/agent/services/imageService.ts +++ b/src/main/agent/services/imageService.ts @@ -81,10 +81,10 @@ export interface ImageService { } /** 读取图片字节:绝对路径优先,其次 base64。 */ -function loadBytes(params: { - absolutePath?: string; - base64?: string; -}): { bytes: Buffer; error?: string } { +function loadBytes(params: { absolutePath?: string; base64?: string }): { + bytes: Buffer; + error?: string; +} { const abs = (params.absolutePath ?? '').trim(); if (abs) { try { @@ -93,13 +93,17 @@ function loadBytes(params: { } return { bytes: fs.readFileSync(abs) }; } catch (err) { - return { bytes: Buffer.alloc(0), error: `读取图片失败:${errMessage(err)}` }; + return { + bytes: Buffer.alloc(0), + error: `读取图片失败:${errMessage(err)}`, + }; } } const raw = (params.base64 ?? '').trim(); if (raw) { const comma = raw.indexOf(','); - const payload = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; + const payload = + raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; return { bytes: Buffer.from(payload, 'base64') }; } return { bytes: Buffer.alloc(0), error: '未提供图片路径或 base64 数据。' }; @@ -129,7 +133,8 @@ export function probeByHeader(params: ImageProbeParams): ImageProbeResult { export function createImageService(bridge: Bridge | null): ImageService { const unavailable = (): { ok: false; error: string } => ({ ok: false, - error: '图像编码需要在 Electron 环境中运行(当前为独立模式,仅支持读取尺寸与格式)。', + error: + '图像编码需要在 Electron 环境中运行(当前为独立模式,仅支持读取尺寸与格式)。', }); return { @@ -142,7 +147,10 @@ export function createImageService(bridge: Bridge | null): ImageService { // 文件头无法识别时才让主进程用 nativeImage 兜底 if (!bridge) return byHeader; try { - return await bridge.call(AGENT_CHANNELS.imageProbe, params); + return await bridge.call( + AGENT_CHANNELS.imageProbe, + params, + ); } catch { return byHeader; } @@ -150,12 +158,18 @@ export function createImageService(bridge: Bridge | null): ImageService { async toJpegDataUrl(params) { if (!bridge) return unavailable(); - return bridge.call(AGENT_CHANNELS.imageDataUrl, params); + return bridge.call( + AGENT_CHANNELS.imageDataUrl, + params, + ); }, async cropBatch(params) { if (!bridge) return unavailable(); - return bridge.call(AGENT_CHANNELS.imageCropBatch, params); + return bridge.call( + AGENT_CHANNELS.imageCropBatch, + params, + ); }, }; } diff --git a/src/main/agent/sse.ts b/src/main/agent/sse.ts index c6a2286..dd8f269 100644 --- a/src/main/agent/sse.ts +++ b/src/main/agent/sse.ts @@ -181,7 +181,10 @@ export const DONE_FRAME = 'data: {"type": "done"}\n\n'; /** 便于构造事件的辅助函数(只覆盖运行时会发射的类型)。 */ export const sse = { - preparing: (stage: string): StreamEventPayload => ({ type: 'preparing', stage }), + preparing: (stage: string): StreamEventPayload => ({ + type: 'preparing', + stage, + }), /** * 流中途失败。 * @@ -201,7 +204,11 @@ export const sse = { type: 'reasoning_delta', content, }), - toolStart: (toolCallId: string, name: string, args: unknown): StreamEventPayload => ({ + toolStart: ( + toolCallId: string, + name: string, + args: unknown, + ): StreamEventPayload => ({ type: 'tool_start', toolCallId, name, @@ -275,7 +282,10 @@ export const sse = { query: params.query, focusPath: params.focusPath ?? null, }), - subagentTextDelta: (toolCallId: string, content: string): StreamEventPayload => ({ + subagentTextDelta: ( + toolCallId: string, + content: string, + ): StreamEventPayload => ({ type: 'subagent_text_delta', toolCallId, content, diff --git a/src/main/agent/subagent/exploreReadonly.ts b/src/main/agent/subagent/exploreReadonly.ts index 1e2db04..c7093ca 100644 --- a/src/main/agent/subagent/exploreReadonly.ts +++ b/src/main/agent/subagent/exploreReadonly.ts @@ -22,11 +22,12 @@ import { sse, type StreamEventPayload } from '../sse'; import type { ChatMessage, LlmClient, ToolSpec } from '../llm/client'; -import { - ToolCallAccumulator, -} from '../llm/client'; +import { ToolCallAccumulator } from '../llm/client'; import { buildToolResult, formatToolResultForDisplay } from '../tools/result'; -import { resolveRoundToolCalls, type ResolvedToolCall } from '../loop/toolInvocation'; +import { + resolveRoundToolCalls, + type ResolvedToolCall, +} from '../loop/toolInvocation'; import type { ToolDefinition, ToolContext } from '../tools/registry'; export const EXPLORE_READONLY_TOOL_NAME = 'explore_readonly'; @@ -68,7 +69,9 @@ export const SUBAGENT_SYSTEM = `你是只读查阅子代理。只用只读工具 const INNER_RESULT_LIMIT = 4000; /** 允许的内层工具名集合。 */ -export function resolveExploreInnerNames(includeAnnotationReads: boolean): Set { +export function resolveExploreInnerNames( + includeAnnotationReads: boolean, +): Set { const names = new Set(EXPLORE_READONLY_BASE_INNER); if (includeAnnotationReads) { for (const name of EXPLORE_READONLY_ANNOTATION_INNER) names.add(name); @@ -301,8 +304,14 @@ export async function* streamExploreReadonly( args: c.arguments, }); - const resultText = await invokeInner(c, params.innerTools, params.toolContext); - const display = truncateInnerResult(formatToolResultForDisplay(resultText)); + const resultText = await invokeInner( + c, + params.innerTools, + params.toolContext, + ); + const display = truncateInnerResult( + formatToolResultForDisplay(resultText), + ); let stepStatus: 'done' | 'error' = 'done'; try { diff --git a/src/main/agent/tools/annotationDoc.ts b/src/main/agent/tools/annotationDoc.ts index 03283e5..25d6ccf 100644 --- a/src/main/agent/tools/annotationDoc.ts +++ b/src/main/agent/tools/annotationDoc.ts @@ -13,7 +13,11 @@ */ import { readAnnotationDocRaw } from '../../annotation/annotationDataStore'; -import { normalizeRelativePath, projectDirectory, type ClientContextLike } from './workspacePath'; +import { + normalizeRelativePath, + projectDirectory, + type ClientContextLike, +} from './workspacePath'; /** 结果总体字符上限(对齐 Python 的 40_000)。 */ const MAX_RESULT_CHARS = 40_000; @@ -62,11 +66,14 @@ export async function readFileAnnotationTool( let note = ''; - const annotations = record.annotations; + const { annotations } = record; if (Array.isArray(annotations)) { const total = annotations.length; const start = Math.max(0, Math.floor(options.offset ?? 0)); - const limit = Math.max(1, Math.min(Math.floor(options.limit ?? 200), MAX_LIMIT)); + const limit = Math.max( + 1, + Math.min(Math.floor(options.limit ?? 200), MAX_LIMIT), + ); const end = Math.min(start + limit, total); if (start > 0 || end < total) { record.annotations = annotations.slice(start, end); @@ -79,9 +86,10 @@ export async function readFileAnnotationTool( // 带缩进:Python 的 indent=2 与 JSON.stringify(v, null, 2) 的分隔符行为一致 let text = JSON.stringify(record, null, 2); if (text.length > MAX_RESULT_CHARS) { - text = - text.slice(0, MAX_RESULT_CHARS) + - '\n…[结果已截断,请用 annotation_offset/annotation_limit 分页读取]'; + text = `${text.slice( + 0, + MAX_RESULT_CHARS, + )}\n…[结果已截断,请用 annotation_offset/annotation_limit 分页读取]`; } return note + text; } diff --git a/src/main/agent/tools/documentReader.test.ts b/src/main/agent/tools/documentReader.test.ts index 7013813..35816fa 100644 --- a/src/main/agent/tools/documentReader.test.ts +++ b/src/main/agent/tools/documentReader.test.ts @@ -6,9 +6,14 @@ import zlib from 'node:zlib'; import { extractPdfText, extractTextFromContentStream } from './documentReader'; /** 构造一个含单页、内容流可指定压缩方式的最小 PDF。 */ -function buildPdf(content: string, options: { compress?: boolean } = {}): Buffer { +function buildPdf( + content: string, + options: { compress?: boolean } = {}, +): Buffer { const contentBytes = Buffer.from(content, 'latin1'); - const streamData = options.compress ? zlib.deflateSync(contentBytes) : contentBytes; + const streamData = options.compress + ? zlib.deflateSync(contentBytes) + : contentBytes; const filter = options.compress ? ' /Filter /FlateDecode' : ''; const parts: string[] = []; @@ -23,7 +28,10 @@ function buildPdf(content: string, options: { compress?: boolean } = {}): Buffer '3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n', ); // 4: Contents(二进制部分用 latin1 拼接以保字节) - const head = Buffer.from(`4 0 obj\n<< /Length ${streamData.length}${filter} >>\nstream\n`, 'latin1'); + const head = Buffer.from( + `4 0 obj\n<< /Length ${streamData.length}${filter} >>\nstream\n`, + 'latin1', + ); const tail = Buffer.from('\nendstream\nendobj\n', 'latin1'); parts.push(''); const prefix = Buffer.from(parts.join(''), 'latin1'); @@ -74,9 +82,9 @@ describe('extractTextFromContentStream:文本算子', () => { }); it('处理嵌套括号', () => { - expect( - extractTextFromContentStream(Buffer.from('BT (a(b)c) Tj ET')), - ).toBe('a(b)c'); + expect(extractTextFromContentStream(Buffer.from('BT (a(b)c) Tj ET'))).toBe( + 'a(b)c', + ); }); it('解码十六进制字符串', () => { diff --git a/src/main/agent/tools/documentReader.ts b/src/main/agent/tools/documentReader.ts index b978cbe..04b822a 100644 --- a/src/main/agent/tools/documentReader.ts +++ b/src/main/agent/tools/documentReader.ts @@ -73,7 +73,10 @@ export async function readDocumentFile( let meta: string; try { if (suffix === '.pdf') { - const extracted = extractPdfText(fs.readFileSync(resolved), options.maxPages); + const extracted = extractPdfText( + fs.readFileSync(resolved), + options.maxPages, + ); text = extracted.text; meta = `PDF 共 ${extracted.total} 页,已提取前 ${extracted.extracted} 页`; } else { @@ -129,7 +132,10 @@ interface PdfExtractResult { * `/Contents` 引用 → 解压内容流 → 按文本算子抽取文字。页顺序取对象在文件中出现的 * 顺序,对绝大多数文档与页树顺序一致。 */ -export function extractPdfText(bytes: Buffer, maxPages: number): PdfExtractResult { +export function extractPdfText( + bytes: Buffer, + maxPages: number, +): PdfExtractResult { // 用 latin1 解析结构:字节保真,且不会因二进制内容破坏索引 const raw = bytes.toString('latin1'); @@ -228,7 +234,9 @@ function collectPageContentRefs(objects: Map): number[][] { pages.push({ objNum, refs: [] }); continue; } - const refs = [...m[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => Number(r[1])); + const refs = [...m[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => + Number(r[1]), + ); pages.push({ objNum, refs }); } @@ -367,7 +375,12 @@ function readLiteralString( // 最多三位八进制 let oct = ''; let k = i + 1; - while (k < text.length && oct.length < 3 && text[k] >= '0' && text[k] <= '7') { + while ( + k < text.length && + oct.length < 3 && + text[k] >= '0' && + text[k] <= '7' + ) { oct += text[k]; k += 1; } @@ -397,12 +410,14 @@ function readLiteralString( } /** 读取十六进制字符串 `<...>`。 */ -function readHexString(text: string, start: number): { value: string; next: number } { +function readHexString( + text: string, + start: number, +): { value: string; next: number } { const end = text.indexOf('>', start + 1); - const hex = (end === -1 ? text.slice(start + 1) : text.slice(start + 1, end)).replace( - /[^0-9a-fA-F]/g, - '', - ); + const hex = ( + end === -1 ? text.slice(start + 1) : text.slice(start + 1, end) + ).replace(/[^0-9a-fA-F]/g, ''); const padded = hex.length % 2 === 0 ? hex : `${hex}0`; const bytes = Buffer.from(padded, 'hex'); return { diff --git a/src/main/agent/tools/fileProposal.ts b/src/main/agent/tools/fileProposal.ts index 75ad941..e0bc2ad 100644 --- a/src/main/agent/tools/fileProposal.ts +++ b/src/main/agent/tools/fileProposal.ts @@ -115,7 +115,7 @@ export function writeWorkspaceFileTool( summary: `无法写入文件:${resolveResult.error}`, }); } - const resolved = resolveResult.resolved; + const { resolved } = resolveResult; const relDisplay = relativeDisplayPath(clientContext, resolved, relativePath); const suffix = path.extname(resolved).toLowerCase(); @@ -170,13 +170,16 @@ export function strReplaceWorkspaceFileTool( summary, }); - let resolveResult = resolveWorkspaceFile(clientContext, relativePath); - let resolved = resolveResult.resolved; + const resolveResult = resolveWorkspaceFile(clientContext, relativePath); + let { resolved } = resolveResult; if (resolved === null) { // 文件在磁盘上不存在:若同路径已有未落盘的 write 提案,则以提案为基线继续 if (pendingProposals) { - const writeResult = resolveWorkspaceWritePath(clientContext, relativePath); + const writeResult = resolveWorkspaceWritePath( + clientContext, + relativePath, + ); if ( writeResult.resolved !== null && pendingProposals.has(writeResult.resolved) @@ -233,9 +236,10 @@ export function strReplaceWorkspaceFileTool( const firstLine = oldString.split(/\r\n|\r|\n/)[0]?.slice(0, 80) ?? ''; return failure( `未找到 old_string(首行:${firstLine})。` + - '常见原因:缩进/空白字符与原文不一致,或该处内容已变化。' + - '请先用 read_workspace_file 核对目标段落,原样复制(含缩进)后重试。' + - hint, + `常见原因:缩进/空白字符与原文不一致,或该处内容已变化。` + + `请先用 read_workspace_file 核对目标段落,原样复制(含缩进)后重试。${ + hint + }`, ); } if (count > 1 && !replaceAll) { @@ -290,7 +294,7 @@ export function deleteWorkspaceFileTool( if (fileResult.resolved === null) { return failure(`无法删除文件:${fileResult.error}`); } - const resolved = fileResult.resolved; + const { resolved } = fileResult; const writeResult = resolveWorkspaceWritePath(clientContext, relativePath); if (writeResult.resolved === null) { @@ -344,7 +348,7 @@ export function moveWorkspaceFileTool( if (fileResult.resolved === null) { return failure(`无法移动文件:${fileResult.error}`); } - const resolved = fileResult.resolved; + const { resolved } = fileResult; const suffix = path.extname(resolved).toLowerCase(); if (notAllowedSuffix(suffix)) { @@ -425,7 +429,11 @@ export function extractDocProposalFromToolResult( if (!record[DOC_PROPOSAL_MARKER]) return null; let operation = String(record.operation ?? 'write'); - if (operation !== 'write' && operation !== 'delete' && operation !== 'rename') { + if ( + operation !== 'write' && + operation !== 'delete' && + operation !== 'rename' + ) { operation = 'write'; } @@ -461,7 +469,9 @@ function replaceFirstOccurrence( ): string { const idx = haystack.indexOf(needle); if (idx === -1) return haystack; - return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length); + return ( + haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length) + ); } function replaceAllOccurrences( diff --git a/src/main/agent/tools/fileReader.ts b/src/main/agent/tools/fileReader.ts index 319399d..70522aa 100644 --- a/src/main/agent/tools/fileReader.ts +++ b/src/main/agent/tools/fileReader.ts @@ -18,10 +18,7 @@ import fs from 'fs-extra'; import path from 'path'; -import { - resolveWorkspaceFile, - type ClientContextLike, -} from './workspacePath'; +import { resolveWorkspaceFile, type ClientContextLike } from './workspacePath'; /** 单行展示上限:防止 minified / 单行巨型文件撑爆工具结果。 */ const MAX_LINE_DISPLAY_CHARS = 2000; @@ -154,8 +151,7 @@ export function universalLines(text: string): string[] { /** 解码结果。 */ type DecodeOutcome = - | { ok: true; text: string } - | { ok: false; reason: 'binary' | 'not_utf8' }; + { ok: true; text: string } | { ok: false; reason: 'binary' | 'not_utf8' }; /** * 探测并解码 UTF-8 文本。 @@ -168,7 +164,10 @@ function decodeUtf8(probe: Buffer, payload: Buffer): DecodeOutcome { try { const text = new TextDecoder('utf-8', { fatal: true }).decode(payload); // 对齐 Python 的 utf-8-sig:剥离 BOM - return { ok: true, text: text.charCodeAt(0) === 0xfeff ? text.slice(1) : text }; + return { + ok: true, + text: text.charCodeAt(0) === 0xfeff ? text.slice(1) : text, + }; } catch { return { ok: false, reason: 'not_utf8' }; } @@ -212,7 +211,13 @@ export function readWorkspaceTextFile( } if (startLine != null || endLine != null) { - return readRangedText(resolved, size, startLine ?? null, endLine ?? null, maxLines); + return readRangedText( + resolved, + size, + startLine ?? null, + endLine ?? null, + maxLines, + ); } let raw: Buffer; @@ -249,7 +254,9 @@ export function readWorkspaceTextFile( } header += '---\n'; - return header + lines.map((line, index) => numberLine(1 + index, line)).join('\n'); + return ( + header + lines.map((line, index) => numberLine(1 + index, line)).join('\n') + ); } /** 行范围读取。 */ diff --git a/src/main/agent/tools/fileSearch.ts b/src/main/agent/tools/fileSearch.ts index 71f1624..a1541c5 100644 --- a/src/main/agent/tools/fileSearch.ts +++ b/src/main/agent/tools/fileSearch.ts @@ -188,7 +188,9 @@ function tryDecodeUtf8(raw: Buffer): string | null { } /** Python `str.splitlines()` 的等价(与 fileReader 保持一致)。 */ -const EXTRA_BOUNDARIES = new Set([0x0b, 0x0c, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029]); +const EXTRA_BOUNDARIES = new Set([ + 0x0b, 0x0c, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029, +]); function splitLinesForGrep(text: string): string[] { const out: string[] = []; @@ -235,7 +237,11 @@ export interface GrepOptions { export async function grepWorkspace( clientContext: ClientContextLike | null | undefined, pattern: string, - options: GrepOptions & { path?: string; globPattern?: string; caseInsensitive?: boolean }, + options: GrepOptions & { + path?: string; + globPattern?: string; + caseInsensitive?: boolean; + }, ): Promise { const needle = (pattern ?? '').trim(); if (!needle) return '请提供搜索 pattern(正则表达式)。'; @@ -250,10 +256,7 @@ export async function grepWorkspace( const roots = allowedRoots(clientContext); if (roots.length === 0) return '未绑定工作区或项目目录,无法搜索。'; - const limit = Math.min( - Math.max(options.maxResults, 1), - options.maxResults, - ); + const limit = Math.min(Math.max(options.maxResults, 1), options.maxResults); const maxFiles = options.maxFilesScanned; const globPattern = options.globPattern ?? '*'; const rawPath = (options.path ?? '').trim(); @@ -268,7 +271,11 @@ export async function grepWorkspace( if (dirResult.resolved === null) { return fileResult.error || dirResult.error || '路径无效。'; } - filesToSearch = await iterSearchFiles(dirResult.resolved, globPattern, maxFiles); + filesToSearch = await iterSearchFiles( + dirResult.resolved, + globPattern, + maxFiles, + ); } } else { for (const root of roots) { @@ -322,7 +329,8 @@ export async function grepWorkspace( const scope = rawPath ? normalizeRelativePath(rawPath) : 'workspace'; let header = `搜索:${needle}\n范围:${scope}\n`; - if (globPattern && globPattern !== '*') header += `文件匹配:${globPattern}\n`; + if (globPattern && globPattern !== '*') + header += `文件匹配:${globPattern}\n`; if (results.length === 0) { return `${header}未找到匹配。\n`; diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index 6bb53e0..00c471a 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -83,7 +83,10 @@ export interface ToolDefinition { * 执行体。`proposal` 与 `async` 类工具没有执行体——前者由调度层生成提案, * 后者交给 Electron 执行。 */ - execute?: (args: Record, ctx: ToolContext) => string | Promise; + execute?: ( + args: Record, + ctx: ToolContext, + ) => string | Promise; } /** 剥掉 JSON Schema 中的 title(含嵌套),对齐 Pydantic 的 `_strip_titles`。 */ @@ -91,7 +94,9 @@ export function stripSchemaTitles(node: unknown): unknown { if (Array.isArray(node)) return node.map(stripSchemaTitles); if (node && typeof node === 'object') { const out: Record = {}; - for (const [key, value] of Object.entries(node as Record)) { + for (const [key, value] of Object.entries( + node as Record, + )) { if (key === 'title') continue; out[key] = stripSchemaTitles(value); } @@ -107,7 +112,10 @@ export function stripSchemaTitles(node: unknown): unknown { * `additionalProperties`。 */ export function toParametersSchema(schema: z.ZodType): Record { - const raw = z.toJSONSchema(schema, { io: 'input' }) as Record; + const raw = z.toJSONSchema(schema, { io: 'input' }) as Record< + string, + unknown + >; delete raw.$schema; const cleaned = stripSchemaTitles(raw) as Record; delete cleaned.additionalProperties; @@ -134,7 +142,10 @@ export function toToolSpec(tool: ToolDefinition): ToolSpec { // 描述文本需与 registry.py 的 Field(description=...) 逐字一致。 const AutoAnnotateArgs = z.object({ - user_request: z.string().min(1).describe('本轮标注任务说明(按用户意图归纳)'), + user_request: z + .string() + .min(1) + .describe('本轮标注任务说明(按用户意图归纳)'), paths: z .array(z.string()) .default([]) @@ -173,7 +184,10 @@ const AutoAnnotateArgs = z.object({ .array(z.string()) .nullish() .describe('只保留这些检测类名的框(如 ["person"])'), - exclude_classes: z.array(z.string()).nullish().describe('排除这些检测类名的框'), + exclude_classes: z + .array(z.string()) + .nullish() + .describe('排除这些检测类名的框'), use_vision_mapping: z .boolean() .nullish() @@ -186,7 +200,10 @@ const MutateAnnotationArgs = z.object({ .array(z.string()) .nullish() .describe('要修改的文件相对路径。有明确文件时必须填写。'), - annotation_ids: z.array(z.string()).nullish().describe('画布选中或用户指定的标注 id。'), + annotation_ids: z + .array(z.string()) + .nullish() + .describe('画布选中或用户指定的标注 id。'), }); // 参数名与 `src/main/mcp/server.ts` 的 start_terminal_command 保持一致, @@ -208,7 +225,9 @@ const StartTerminalCommandArgs = z.object({ .positive() .max(600_000) .optional() - .describe('Kill the command after this many ms (default 300000, max 600000)'), + .describe( + 'Kill the command after this many ms (default 300000, max 600000)', + ), }); const ReadFileAnnotationArgs = z.object({ @@ -218,7 +237,10 @@ const ReadFileAnnotationArgs = z.object({ }); const ReadWorkspaceFileArgs = z.object({ - relative_path: z.string().default('').describe('相对路径;为空时使用当前打开的文件'), + relative_path: z + .string() + .default('') + .describe('相对路径;为空时使用当前打开的文件'), start_line: z.number().int().nullish().describe('起始行(1-indexed,含)'), end_line: z.number().int().nullish().describe('结束行(1-indexed,含)'), }); @@ -232,11 +254,17 @@ const GrepArgs = z.object({ const GlobArgs = z.object({ glob_pattern: z.string().describe('glob 表达式,如 **/*.py、src/**/*.ts'), - relative_dir: z.string().default('').describe('起始目录;为空时从工作区根开始'), + relative_dir: z + .string() + .default('') + .describe('起始目录;为空时从工作区根开始'), }); const ListDirArgs = z.object({ - relative_dir: z.string().default('').describe('目录相对路径;为空时列出工作区根'), + relative_dir: z + .string() + .default('') + .describe('目录相对路径;为空时列出工作区根'), }); const HelpArgs = z.object({ @@ -244,11 +272,17 @@ const HelpArgs = z.object({ }); const ReadImageArgs = z.object({ - relative_path: z.string().default('').describe('图片相对路径;为空时使用当前打开的图片'), + relative_path: z + .string() + .default('') + .describe('图片相对路径;为空时使用当前打开的图片'), }); const ReadDocumentArgs = z.object({ - relative_path: z.string().default('').describe('文档相对路径;为空时使用当前打开的文件'), + relative_path: z + .string() + .default('') + .describe('文档相对路径;为空时使用当前打开的文件'), }); const WriteFileArgs = z.object({ @@ -317,7 +351,9 @@ function describeContext(clientContext: ClientContextLike | null): string { return parts.join('\n'); } -function describeAnnotationProject(clientContext: ClientContextLike | null): string { +function describeAnnotationProject( + clientContext: ClientContextLike | null, +): string { const snapshot = clientContext?.annotationProjectSnapshot; if (!clientContext || !snapshot) { return '当前未绑定标注项目快照。请确认用户已在标注任务中打开项目。'; @@ -326,13 +362,13 @@ function describeAnnotationProject(clientContext: ClientContextLike | null): str projectId: String((snapshot as Record).projectId ?? ''), name: String((snapshot as Record).name ?? ''), modality: String((snapshot as Record).modality ?? ''), - annotationType: String((snapshot as Record).annotationType ?? ''), + annotationType: String( + (snapshot as Record).annotationType ?? '', + ), labels: (snapshot as Record).labels as - | Array> - | undefined, + Array> | undefined, detectionModels: (snapshot as Record).detectionModels as - | Array> - | undefined, + Array> | undefined, }); } @@ -393,7 +429,8 @@ export function buildTools(): ToolDefinition[] { description: '获取 LR-Agent 应用功能说明,可选 topic 关键词', kind: 'sync', argsSchema: HelpArgs, - execute: (args) => getLrAgentHelp(args.topic as string | null | undefined), + execute: (args) => + getLrAgentHelp(args.topic as string | null | undefined), }, { name: 'describe_client_context', @@ -439,12 +476,16 @@ export function buildTools(): ToolDefinition[] { kind: 'sync', argsSchema: ReadWorkspaceFileArgs, execute: (args, ctx) => - readWorkspaceTextFile(ctx.clientContext, String(args.relative_path ?? ''), { - maxBytes: ctx.settings.readFileMaxBytes, - maxLines: ctx.settings.readFileMaxLines, - startLine: args.start_line as number | null | undefined, - endLine: args.end_line as number | null | undefined, - }), + readWorkspaceTextFile( + ctx.clientContext, + String(args.relative_path ?? ''), + { + maxBytes: ctx.settings.readFileMaxBytes, + maxLines: ctx.settings.readFileMaxLines, + startLine: args.start_line as number | null | undefined, + endLine: args.end_line as number | null | undefined, + }, + ), }, { name: 'grep_workspace', @@ -501,10 +542,14 @@ export function buildTools(): ToolDefinition[] { kind: 'sync', argsSchema: ReadImageArgs, execute: (args, ctx) => - readImageForVisionTool(ctx.clientContext, String(args.relative_path ?? ''), { - providerIsVision: ctx.providerIsVision, - imageService: ctx.imageService, - }), + readImageForVisionTool( + ctx.clientContext, + String(args.relative_path ?? ''), + { + providerIsVision: ctx.providerIsVision, + imageService: ctx.imageService, + }, + ), }, { name: 'read_document_file', @@ -644,13 +689,18 @@ export function buildToolSpecs( } /** 构造统一失败结果(供调度层复用)。 */ -export function toolFailure(tool: string, status: string, summary: string): string { +export function toolFailure( + tool: string, + status: string, + summary: string, +): string { return buildToolResult({ ok: false, tool, status, summary }); } /** 宽松的整数取值:非数字时回退默认值(模型可能传字符串或省略)。 */ function asInt(value: unknown, fallback: number): number { - if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === 'number' && Number.isFinite(value)) + return Math.trunc(value); if (typeof value === 'string') { const parsed = Number(value.trim()); if (Number.isFinite(parsed)) return Math.trunc(parsed); diff --git a/src/main/agent/tools/result.ts b/src/main/agent/tools/result.ts index f8d66af..b18d6de 100644 --- a/src/main/agent/tools/result.ts +++ b/src/main/agent/tools/result.ts @@ -52,7 +52,8 @@ export function formatToolResultForDisplay(resultText: string): string { } catch { return resultText; } - if (!data || typeof data !== 'object' || Array.isArray(data)) return resultText; + if (!data || typeof data !== 'object' || Array.isArray(data)) + return resultText; const record = data as Record; if (record.summary) return String(record.summary); if (record.message) return String(record.message); @@ -60,7 +61,9 @@ export function formatToolResultForDisplay(resultText: string): string { } /** 解析工具结果 JSON;非对象返回 null。 */ -export function parseToolResult(resultText: string): Record | null { +export function parseToolResult( + resultText: string, +): Record | null { try { const data = JSON.parse(resultText); return data && typeof data === 'object' && !Array.isArray(data) diff --git a/src/main/agent/tools/vision.ts b/src/main/agent/tools/vision.ts index 6d31d6f..6b1b9ce 100644 --- a/src/main/agent/tools/vision.ts +++ b/src/main/agent/tools/vision.ts @@ -14,10 +14,7 @@ import path from 'path'; import { buildToolResult, VISION_PATH_MARKER } from './result'; -import { - resolveWorkspaceFile, - type ClientContextLike, -} from './workspacePath'; +import { resolveWorkspaceFile, type ClientContextLike } from './workspacePath'; import { IMAGE_SUFFIXES } from './fileReader'; import type { ImageService } from '../services/imageService'; diff --git a/src/main/agent/tools/workspacePath.ts b/src/main/agent/tools/workspacePath.ts index 50f7181..5349a1a 100644 --- a/src/main/agent/tools/workspacePath.ts +++ b/src/main/agent/tools/workspacePath.ts @@ -169,7 +169,10 @@ export function resolveWorkspaceFile( const roots = allowedRoots(clientContext); if (roots.length === 0) { - return { resolved: null, error: '未绑定工作区或项目目录,无法读取本地文件。' }; + return { + resolved: null, + error: '未绑定工作区或项目目录,无法读取本地文件。', + }; } if (path.isAbsolute(raw)) { @@ -212,7 +215,10 @@ export function resolveWorkspaceDirectory( const raw = (dirPath ?? '').trim(); const roots = allowedRoots(clientContext); if (roots.length === 0) { - return { resolved: null, error: '未绑定工作区或项目目录,无法访问本地目录。' }; + return { + resolved: null, + error: '未绑定工作区或项目目录,无法访问本地目录。', + }; } if (!raw) { @@ -268,7 +274,10 @@ export function resolveWorkspaceWritePath( const roots = allowedRoots(clientContext); if (roots.length === 0) { - return { resolved: null, error: '未绑定工作区或项目目录,无法写入本地文件。' }; + return { + resolved: null, + error: '未绑定工作区或项目目录,无法写入本地文件。', + }; } if (path.isAbsolute(raw)) { diff --git a/src/main/agent/vision/autoLoader.test.ts b/src/main/agent/vision/autoLoader.test.ts index b7b76be..7baf094 100644 --- a/src/main/agent/vision/autoLoader.test.ts +++ b/src/main/agent/vision/autoLoader.test.ts @@ -11,7 +11,10 @@ import { pickVisionRelativePath, visionRelativeFromUserText, } from './autoLoader'; -import { buildMultimodalUserMessage, VISION_ATTACHMENT_TEXT } from '../context/multimodal'; +import { + buildMultimodalUserMessage, + VISION_ATTACHMENT_TEXT, +} from '../context/multimodal'; import { createImageService } from '../services/imageService'; import type { ImageService } from '../services/imageService'; import { DEFAULT_AGENT_SETTINGS } from '../config'; @@ -29,7 +32,11 @@ afterEach(async () => { }); /** 写一个最小 PNG(文件名可指定)。 */ -async function writePng(relative: string, width = 640, height = 480): Promise { +async function writePng( + relative: string, + width = 640, + height = 480, +): Promise { const buf = Buffer.alloc(33); buf.writeUInt32BE(0x89504e47, 0); buf.writeUInt32BE(0x0d0a1a0a, 4); @@ -53,7 +60,12 @@ function fakeEncoder(): ImageService { return { ok: true, width: 640, height: 480, format: 'PNG' }; }, async toJpegDataUrl() { - return { ok: true, dataUrl: 'data:image/jpeg;base64,AAAA', width: 640, height: 480 }; + return { + ok: true, + dataUrl: 'data:image/jpeg;base64,AAAA', + width: 640, + height: 480, + }; }, async cropBatch() { return { ok: true, images: [] }; @@ -91,11 +103,15 @@ async function collect( describe('visionRelativeFromUserText:从用户文本解析图片路径', () => { it('解析行首的图片路径', () => { - expect(visionRelativeFromUserText('data/1.jpg 里有什么')).toBe('data/1.jpg'); + expect(visionRelativeFromUserText('data/1.jpg 里有什么')).toBe( + 'data/1.jpg', + ); }); it('解析空白后的图片路径', () => { - expect(visionRelativeFromUserText('看看 data/2.png 这张图')).toBe('data/2.png'); + expect(visionRelativeFromUserText('看看 data/2.png 这张图')).toBe( + 'data/2.png', + ); }); it('解析「『引号后的图片路径', () => { @@ -128,7 +144,10 @@ describe('visionRelativeFromUserText:从用户文本解析图片路径', () => describe('pickVisionRelativePath 与 canBootstrapVision', () => { it('优先取 active_relative_path', () => { expect( - pickVisionRelativePath({ workspaceRoot: workspace, activeRelativePath: 'a/b.png' }), + pickVisionRelativePath({ + workspaceRoot: workspace, + activeRelativePath: 'a/b.png', + }), ).toBe('a/b.png'); }); @@ -152,7 +171,9 @@ describe('pickVisionRelativePath 与 canBootstrapVision', () => { }); describe('VisionAutoLoader.shouldLoad', () => { - const params = (overrides: Partial[0]> = {}) => ({ + const params = ( + overrides: Partial[0]> = {}, + ) => ({ settings: DEFAULT_AGENT_SETTINGS, clientContext: ctx(), providerIsVision: true, @@ -170,14 +191,18 @@ describe('VisionAutoLoader.shouldLoad', () => { }); it('provider 不支持视觉时不触发', () => { - expect(new VisionAutoLoader(params({ providerIsVision: false })).shouldLoad(false)).toBe( - false, - ); + expect( + new VisionAutoLoader(params({ providerIsVision: false })).shouldLoad( + false, + ), + ).toBe(false); }); it('文本无图片路径时不触发', () => { expect( - new VisionAutoLoader(params({ userContent: '帮我看看目录' })).shouldLoad(false), + new VisionAutoLoader(params({ userContent: '帮我看看目录' })).shouldLoad( + false, + ), ).toBe(false); }); }); @@ -188,7 +213,10 @@ describe('VisionAutoLoader.tryFallback', () => { const messages: ChatMessage[] = []; const loader = new VisionAutoLoader({ settings: DEFAULT_AGENT_SETTINGS, - clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + clientContext: { + workspaceRoot: workspace, + activeRelativePath: 'data/opened.png', + }, providerIsVision: true, userContent: '这张图里有什么', imageService: fakeEncoder(), @@ -201,7 +229,9 @@ describe('VisionAutoLoader.tryFallback', () => { expect(events[1].type).toBe('tool_result'); // 工具 id 形态与 Python 一致 - expect(String(events[0].toolCallId)).toMatch(/^lr-vision-fallback-[0-9a-f]{10}$/); + expect(String(events[0].toolCallId)).toMatch( + /^lr-vision-fallback-[0-9a-f]{10}$/, + ); // 追加 ToolMessage + 附图 HumanMessage expect(messages).toHaveLength(2); @@ -244,7 +274,10 @@ describe('VisionAutoLoader.tryFallback', () => { await writePng('data/opened.png'); const loader = new VisionAutoLoader({ settings: DEFAULT_AGENT_SETTINGS, - clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + clientContext: { + workspaceRoot: workspace, + activeRelativePath: 'data/opened.png', + }, providerIsVision: true, userContent: '看图', imageService: fakeEncoder(), @@ -258,7 +291,11 @@ describe('VisionAutoLoader.tryFallback', () => { // 应是缩进 JSON(含换行),而不是被折叠成 message 单行文本 expect(result).toContain('\n'); // 且保留结构化字段 - expect(JSON.parse(result)).toMatchObject({ ok: true, width: 640, height: 480 }); + expect(JSON.parse(result)).toMatchObject({ + ok: true, + width: 640, + height: 480, + }); }); it('无编码能力时仍追加纯文本消息(退化不抛异常)', async () => { @@ -269,7 +306,10 @@ describe('VisionAutoLoader.tryFallback', () => { const messages: ChatMessage[] = []; const loader = new VisionAutoLoader({ settings: DEFAULT_AGENT_SETTINGS, - clientContext: { workspaceRoot: workspace, activeRelativePath: 'data/opened.png' }, + clientContext: { + workspaceRoot: workspace, + activeRelativePath: 'data/opened.png', + }, providerIsVision: true, userContent: '看图', imageService: standalone, @@ -308,7 +348,11 @@ describe('buildMultimodalUserMessage', () => { }; const msg = await buildMultimodalUserMessage( 'text', - { imageAbsolutePath: path.join(workspace, 'data/x.png'), maxEdge: 1280, jpegQuality: 85 }, + { + imageAbsolutePath: path.join(workspace, 'data/x.png'), + maxEdge: 1280, + jpegQuality: 85, + }, failing, ); expect(msg.content).toBe('text'); @@ -350,6 +394,8 @@ describe('buildMultimodalUserMessage', () => { }); it('附图提示文本固定', () => { - expect(VISION_ATTACHMENT_TEXT).toBe('【附图】请根据上图回答用户关于该图片的问题。'); + expect(VISION_ATTACHMENT_TEXT).toBe( + '【附图】请根据上图回答用户关于该图片的问题。', + ); }); }); diff --git a/src/main/agent/vision/autoLoader.ts b/src/main/agent/vision/autoLoader.ts index 251d19a..b622ebf 100644 --- a/src/main/agent/vision/autoLoader.ts +++ b/src/main/agent/vision/autoLoader.ts @@ -15,11 +15,22 @@ import { randomUUID } from 'crypto'; import { sse, type StreamEventPayload } from '../sse'; import type { ChatMessage } from '../llm/client'; -import { buildMultimodalUserMessage, VISION_ATTACHMENT_TEXT } from '../context/multimodal'; +import { + buildMultimodalUserMessage, + VISION_ATTACHMENT_TEXT, +} from '../context/multimodal'; import { IMAGE_SUFFIXES } from '../tools/fileReader'; -import { resolveWorkspaceFile, type ClientContextLike } from '../tools/workspacePath'; +import { + resolveWorkspaceFile, + type ClientContextLike, +} from '../tools/workspacePath'; import { readImageForVisionTool } from '../tools/vision'; -import { extractVisionPath, formatToolResultForDisplay, stripInternalMarkers } from '../tools/result';import type { ImageService } from '../services/imageService'; +import { + extractVisionPath, + formatToolResultForDisplay, + stripInternalMarkers, +} from '../tools/result'; +import type { ImageService } from '../services/imageService'; import type { AgentSettings } from '../config'; /** @@ -124,7 +135,9 @@ export class VisionAutoLoader { // 注意:视觉结果显示用的是「剥离内部标记后的完整缩进 JSON」, // **不是** formatToolResultForDisplay(那会只取 summary/message); // 与 Python 的 format_vision_tool_result_for_display 对齐 - const displayResult = visionPath ? stripInternalMarkers(resultText) : resultText; + const displayResult = visionPath + ? stripInternalMarkers(resultText) + : resultText; yield sse.toolResult(toolId, displayResult); messages.push({ diff --git a/src/main/agentImage/handlers.ts b/src/main/agentImage/handlers.ts index b8cbe25..fcb19a1 100644 --- a/src/main/agentImage/handlers.ts +++ b/src/main/agentImage/handlers.ts @@ -65,10 +65,10 @@ export interface CropBatchResult { } /** 加载图片字节:路径优先,其次 base64。 */ -function loadImageBytes(params: { - absolutePath?: string; - base64?: string; -}): { bytes: Buffer; error?: string } { +function loadImageBytes(params: { absolutePath?: string; base64?: string }): { + bytes: Buffer; + error?: string; +} { const abs = (params.absolutePath ?? '').trim(); if (abs) { try { @@ -77,18 +77,25 @@ function loadImageBytes(params: { } return { bytes: fs.readFileSync(abs) }; } catch (err) { - return { bytes: Buffer.alloc(0), error: `读取图片失败:${errMessage(err)}` }; + return { + bytes: Buffer.alloc(0), + error: `读取图片失败:${errMessage(err)}`, + }; } } const raw = (params.base64 ?? '').trim(); if (raw) { // 剥离 data URL 前缀 const comma = raw.indexOf(','); - const payload = raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; + const payload = + raw.startsWith('data:') && comma >= 0 ? raw.slice(comma + 1) : raw; try { return { bytes: Buffer.from(payload, 'base64') }; } catch (err) { - return { bytes: Buffer.alloc(0), error: `解码 base64 失败:${errMessage(err)}` }; + return { + bytes: Buffer.alloc(0), + error: `解码 base64 失败:${errMessage(err)}`, + }; } } return { bytes: Buffer.alloc(0), error: '未提供图片路径或 base64 数据。' }; @@ -105,7 +112,7 @@ export function probeImage(params: { }): ImageProbeResult { const loaded = loadImageBytes(params); if (loaded.error) return { ok: false, error: loaded.error }; - const bytes = loaded.bytes; + const { bytes } = loaded; if (bytes.length === 0) return { ok: false, error: '图片数据为空。' }; const header: ImageHeaderInfo | null = parseImageHeader(bytes); @@ -214,10 +221,13 @@ export function imageToJpegDataUrl(params: { if (image.isEmpty()) { // nativeImage 无法解码(如 GIF/WebP 变体):退化为原字节直传 const header = parseImageHeader(loaded.bytes); - const suffix = path.extname((params.absolutePath ?? '').trim()).toLowerCase(); - const mime = mimeFromSuffix(suffix) !== 'application/octet-stream' - ? mimeFromSuffix(suffix) - : formatToMime(header?.format); + const suffix = path + .extname((params.absolutePath ?? '').trim()) + .toLowerCase(); + const mime = + mimeFromSuffix(suffix) !== 'application/octet-stream' + ? mimeFromSuffix(suffix) + : formatToMime(header?.format); return { ok: true, dataUrl: toDataUrl(mime, loaded.bytes), @@ -325,7 +335,9 @@ export function cropImageBatch(params: { }); } cropped = flattenOntoWhite(cropped); - images.push(toDataUrl('image/jpeg', cropped.toJPEG(clampQuality(params.quality)))); + images.push( + toDataUrl('image/jpeg', cropped.toJPEG(clampQuality(params.quality))), + ); } return { ok: true, images }; diff --git a/src/main/agentImage/imageOps.test.ts b/src/main/agentImage/imageOps.test.ts index f48e6f4..2d3f91b 100644 --- a/src/main/agentImage/imageOps.test.ts +++ b/src/main/agentImage/imageOps.test.ts @@ -25,12 +25,29 @@ function pngBytes(width: number, height: number): Buffer { /** 构造最小 JPEG(SOI + SOF0 + EOI)。 */ function jpegBytes(width: number, height: number): Buffer { return Buffer.from([ - 0xff, 0xd8, // SOI - 0xff, 0xc0, 0x00, 0x11, 0x08, - (height >> 8) & 0xff, height & 0xff, - (width >> 8) & 0xff, width & 0xff, - 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, - 0xff, 0xd9, // EOI + 0xff, + 0xd8, // SOI + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x11, + 0x00, + 0x02, + 0x11, + 0x01, + 0x03, + 0x11, + 0x01, + 0xff, + 0xd9, // EOI ]); } @@ -101,11 +118,9 @@ describe('parseImageHeader:文件头解析', () => { it('JPEG 跳过非 SOF 段(DHT/DAC)', () => { // SOI + DHT(0xC4,无帧信息)+ SOF0 + EOI const buf = Buffer.from([ - 0xff, 0xd8, - 0xff, 0xc4, 0x00, 0x04, 0x00, 0x00, - 0xff, 0xc0, 0x00, 0x11, 0x08, 0x01, 0x2c, 0x01, 0x90, - 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, - 0xff, 0xd9, + 0xff, 0xd8, 0xff, 0xc4, 0x00, 0x04, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, + 0x08, 0x01, 0x2c, 0x01, 0x90, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, + 0x03, 0x11, 0x01, 0xff, 0xd9, ]); const info = parseImageHeader(buf); expect(info).toEqual({ width: 400, height: 300, format: 'JPEG' }); diff --git a/src/main/agentImage/imageOps.ts b/src/main/agentImage/imageOps.ts index 4c64790..00e7f26 100644 --- a/src/main/agentImage/imageOps.ts +++ b/src/main/agentImage/imageOps.ts @@ -73,7 +73,11 @@ function parseJpeg(buf: Buffer): ImageHeaderInfo | null { continue; } // 无长度字段的标记 - if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + if ( + marker === 0xd8 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { offset += 2; continue; } @@ -158,7 +162,12 @@ function parseBmp(buf: Buffer): ImageHeaderInfo | null { /** ICO:目录中第一个条目的宽高(0 表示 256)。 */ function parseIco(buf: Buffer): ImageHeaderInfo | null { if (buf.length < 22) return null; - if (buf[0] !== 0x00 || buf[1] !== 0x00 || buf[2] !== 0x01 || buf[3] !== 0x00) { + if ( + buf[0] !== 0x00 || + buf[1] !== 0x00 || + buf[2] !== 0x01 || + buf[3] !== 0x00 + ) { return null; } const count = readUInt16LE(buf, 4); diff --git a/src/main/env/envStatus.ts b/src/main/env/envStatus.ts index 8af23d8..4406346 100644 --- a/src/main/env/envStatus.ts +++ b/src/main/env/envStatus.ts @@ -33,10 +33,7 @@ function isUsablePython(pythonPath: string | null): boolean { } } -function isVenvPath( - target: 'inference', - pythonPath: string | null, -): boolean { +function isVenvPath(target: 'inference', pythonPath: string | null): boolean { if (!pythonPath) return false; const venv = getVenvPythonPath(target); return ( diff --git a/src/main/workspace/workspaceOperations.ts b/src/main/workspace/workspaceOperations.ts index 773dfe5..0de2ece 100644 --- a/src/main/workspace/workspaceOperations.ts +++ b/src/main/workspace/workspaceOperations.ts @@ -20,7 +20,10 @@ import { writeScopedTextFile, } from './workspaceWrite'; -export const WORKSPACE_FORBIDDEN = { success: false, error: 'forbidden_root' } as const; +export const WORKSPACE_FORBIDDEN = { + success: false, + error: 'forbidden_root', +} as const; export interface WorkspaceWriteResult { success: boolean; @@ -50,7 +53,8 @@ export async function writeWorkspaceTextFile(payload: { relativePath: string; content: string; }): Promise { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + if (!isWithinAuthorizedRoot(payload?.rootDir)) + return { ...WORKSPACE_FORBIDDEN }; const result = await writeScopedTextFile( payload.rootDir, payload.relativePath, @@ -66,7 +70,8 @@ export async function readWorkspaceTextFile(payload: { rootDir: string; relativePath: string; }): Promise { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + if (!isWithinAuthorizedRoot(payload?.rootDir)) + return { ...WORKSPACE_FORBIDDEN }; return readScopedTextFile(payload.rootDir, payload.relativePath); } @@ -74,8 +79,12 @@ export async function deleteWorkspaceTextFile(payload: { rootDir: string; relativePath: string; }): Promise { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; - const result = await deleteScopedTextFile(payload.rootDir, payload.relativePath); + if (!isWithinAuthorizedRoot(payload?.rootDir)) + return { ...WORKSPACE_FORBIDDEN }; + const result = await deleteScopedTextFile( + payload.rootDir, + payload.relativePath, + ); if (result.success) { broadcastFileSystemChanged( path.join(payload.rootDir, path.dirname(payload.relativePath)), @@ -89,7 +98,8 @@ export async function moveWorkspaceTextFile(payload: { relativePath: string; newRelativePath: string; }): Promise { - if (!isWithinAuthorizedRoot(payload?.rootDir)) return { ...WORKSPACE_FORBIDDEN }; + if (!isWithinAuthorizedRoot(payload?.rootDir)) + return { ...WORKSPACE_FORBIDDEN }; const result = await moveScopedTextFile( payload.rootDir, payload.relativePath, diff --git a/src/renderer/components/environment/EnvironmentWizard.tsx b/src/renderer/components/environment/EnvironmentWizard.tsx index 5dcefb6..9fb0ed7 100644 --- a/src/renderer/components/environment/EnvironmentWizard.tsx +++ b/src/renderer/components/environment/EnvironmentWizard.tsx @@ -574,14 +574,18 @@ export default function EnvironmentWizard(): ReactElement | null { )}
{status.localAgent.serviceUrl && ( -
+
{status.localAgent.serviceUrl}
)}
- Agent 编排(Assist 工具循环、标注与质量报告编排、Skills 与 MCP 调用) - 已内置在应用中,随主程序一起启动,无需安装 Python 或额外依赖。 + Agent 编排(Assist 工具循环、标注与质量报告编排、Skills 与 MCP + 调用) 已内置在应用中,随主程序一起启动,无需安装 Python + 或额外依赖。
)} diff --git a/src/renderer/config.test.ts b/src/renderer/config.test.ts index e06062e..6475148 100644 --- a/src/renderer/config.test.ts +++ b/src/renderer/config.test.ts @@ -1,7 +1,14 @@ /** * @jest-environment node */ -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; import { hasLocalAgentAuth, invalidateLocalAgentAuth, @@ -82,7 +89,9 @@ describe('localAgentFetch:网络异常自愈', () => { }); const seen: string[] = []; - (globalThis as unknown as { fetch: unknown }).fetch = async (url: string) => { + (globalThis as unknown as { fetch: unknown }).fetch = async ( + url: string, + ) => { calls += 1; seen.push(url); if (calls === 1) throw new TypeError('Failed to fetch'); diff --git a/src/renderer/context/AnnotationWorkspaceContext.tsx b/src/renderer/context/AnnotationWorkspaceContext.tsx index bc0b326..3f2f9d4 100644 --- a/src/renderer/context/AnnotationWorkspaceContext.tsx +++ b/src/renderer/context/AnnotationWorkspaceContext.tsx @@ -821,10 +821,10 @@ export function AnnotationWorkspaceProvider({ // 这里选择**跳过这次保存**而不是写入:脏数据仍留在内存中,用户继续操作会 // 重新触发保存;而下一次加载完成时 ref 已同步,不会再误判。 if (meta.filePath !== relPath) { - console.warn( - '[annotation] 跳过保存:文档归属路径与当前文件不一致', - { metaFilePath: meta.filePath, relPath }, - ); + console.warn('[annotation] 跳过保存:文档归属路径与当前文件不一致', { + metaFilePath: meta.filePath, + relPath, + }); return; } diff --git a/src/renderer/services/agentChatStore.ts b/src/renderer/services/agentChatStore.ts index 35ee102..5a2a1df 100644 --- a/src/renderer/services/agentChatStore.ts +++ b/src/renderer/services/agentChatStore.ts @@ -1095,7 +1095,8 @@ export function applyStreamEventToBlocks( } if (event.type === 'annotation_proposal') { - const proposalKind = inferAnnotationProposalKind(event.proposal); for (let i = 0; i < next.length; i += 1) { + const proposalKind = inferAnnotationProposalKind(event.proposal); + for (let i = 0; i < next.length; i += 1) { const b = next[i]; if (b.type === 'annotation_pipeline') { next[i] = { diff --git a/src/renderer/services/agentJobRegistry.ts b/src/renderer/services/agentJobRegistry.ts index 7b4aa9f..4fba138 100644 --- a/src/renderer/services/agentJobRegistry.ts +++ b/src/renderer/services/agentJobRegistry.ts @@ -191,7 +191,7 @@ export function formatAnnotationToolResult(options: { autoApplyFailed?: boolean; }): string { // 只有真的产出了提案才谈得上「已应用/待确认」 - const hasProposal = options.hasProposal; + const { hasProposal } = options; const autoApplied = Boolean(options.autoApplied && hasProposal); const autoApplyFailed = Boolean( options.autoApplyFailed && hasProposal && !autoApplied, diff --git a/src/renderer/services/agentProposalApply.test.ts b/src/renderer/services/agentProposalApply.test.ts index 85e5c48..c6308e7 100644 --- a/src/renderer/services/agentProposalApply.test.ts +++ b/src/renderer/services/agentProposalApply.test.ts @@ -399,7 +399,9 @@ describe('selectAutoApplicableProposals', () => { id: 'prop-1', projectId: 'p1', summary: 's', - changes: [{ relativePath: 'data/1.jpg', operation: 'delete', deleteIds: ['a'] }], + changes: [ + { relativePath: 'data/1.jpg', operation: 'delete', deleteIds: ['a'] }, + ], }, }) as unknown as ChatMessage['blocks'][number]; @@ -500,7 +502,13 @@ describe('applyAutoApplicableProposals', () => { id: 'prop-1', projectId: 'p1', summary: 's', - changes: [{ relativePath: 'data/1.jpg', operation: 'append', annotations: [{}] }], + changes: [ + { + relativePath: 'data/1.jpg', + operation: 'append', + annotations: [{}], + }, + ], }, }, ] as unknown as ChatMessage['blocks']), @@ -532,7 +540,13 @@ describe('applyAutoApplicableProposals', () => { id: 'prop-1', projectId: 'p1', summary: 's', - changes: [{ relativePath: 'data/1.jpg', operation: 'append', annotations: [{}] }], + changes: [ + { + relativePath: 'data/1.jpg', + operation: 'append', + annotations: [{}], + }, + ], }, }, ] as unknown as ChatMessage['blocks']), From 2f63a48a92b3b1ac3c6bb6e925de053927c75a20 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 16:26:20 +0800 Subject: [PATCH 18/20] =?UTF-8?q?ci:=20=E7=A7=BB=E9=99=A4=E9=9A=8F?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E8=BF=81=E7=A7=BB=E5=A4=B1=E6=95=88=E7=9A=84?= =?UTF-8?q?=20Python=20=E7=BC=96=E6=8E=92=20CI=EF=BC=8C=E6=94=B9=20Node-on?= =?UTF-8?q?ly=20=E5=B9=B6=E6=8B=86=E5=BC=80=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编排已迁到 Node 运行时,但 workflow 一行没动,导致两处必挂: - python-envs 的 npm run fetch-python-runtimes 脚本与 fetchPythonRuntimes.mjs 都已删除(本地实测 Missing script),后续 smoke-test 连坐 skip; 即便过了,scripts/ciInstallAndTest.mjs 也硬引用已删的 vendor/local-agent/requirements.txt 与 assets/python-runtime/py312。 - frontend 的 Cache embedded Python runtimes 步指向不存在的路径, hashFiles 取到空哈希,退化为空转。 test.yml 重写为 5 个互不依赖的 job(原先 lint 一挂就把 typecheck / test 全 skip,一次只能拿到三分之一信号): - lint / typecheck / test / package 拆开,各自独立给信号 - test 先跑 npm run build,因为 jest 的 setupFiles 里 check-build-exists.ts 会断言 dist 产物存在 - 新增 baseline-selftest:Agent 运行时的协议契约测试,此前完全没进 CI; 该脚本零外部依赖(scenarios.mjs 无 import),故不装 node_modules - python-envs 重做为 inference-lite:不再建 venv 装 torch/sam2 跑 90 分钟 冒烟(嵌入式运行时已移除,产品语义也变成用户自备 conda),改为零依赖静态校验 新增 scripts/ciCheckInferenceManifests.mjs,把原来只靠注释互相声称的 「envInstaller.ts 与 requirements 保持字面同步」变成 CI 可验证的断言: SAM-2 改写链真能命中、torch 索引 URL 与清单本地版本标签一致、 cpu/gpu 两份清单除 torch 系列外零漂移、VERIFY_IMPORTS 每个 import 名 都能落到发行包、Python 编排残留引用不得回归、推理服务仍在打包清单内。 清理死代码:删 scripts/ciInstallAndTest.mjs;.eslintignore 移除 assets/python-runtime/ 与 .ci-venvs/;.gitignore 移除对应段; envInstaller.ts 头部注释不再声称使用嵌入式运行时。 CodeQL 注释对齐(python 矩阵现在只覆盖 vendor/inference)。 验证:npm run lint 0 error;npm exec tsc 通过;npm run build 成功 (产出 dist/main/agentRuntime.js);npx jest --ci 113 suites / 1036 tests 全通过;npm run baseline:selftest 全通过;新检查脚本全通过 (并已实测其在残留引用存在时确实报错)。 Co-authored-by: Cursor --- .eslintignore | 2 - .github/workflows/codeql-analysis.yml | 4 +- .github/workflows/test.yml | 128 ++++++++--- .gitignore | 4 - scripts/ciCheckInferenceManifests.mjs | 292 ++++++++++++++++++++++++++ scripts/ciInstallAndTest.mjs | 160 -------------- src/main/env/envInstaller.ts | 6 +- 7 files changed, 393 insertions(+), 203 deletions(-) create mode 100644 scripts/ciCheckInferenceManifests.mjs delete mode 100644 scripts/ciInstallAndTest.mjs diff --git a/.eslintignore b/.eslintignore index a087f0a..1d14ec2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,9 +23,7 @@ release/build .erb/dll # 打包/CI 产物与第三方源码,不走前端 eslint -assets/python-runtime/ vendor/ -.ci-venvs/ .idea npm-debug.log.* diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ee12bef..0548477 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,9 @@ jobs: strategy: fail-fast: false matrix: - # vendor/local-agent 是核心业务逻辑(FastAPI + agent),必须纳入扫描 + # javascript:Electron 主/渲染进程 + Node 版 Agent 运行时(编排逻辑)。 + # python:仅覆盖 vendor/inference 预标注推理服务;编排侧的 vendor/local-agent + # 已随迁移删除,不再有 FastAPI 业务代码。 language: [ 'javascript', 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb775cd..8c5f458 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,48 +2,85 @@ name: Test on: [push, pull_request] +# 编排(Agent)已迁到 Node 运行时,Python 只保留可选的**预标注推理**环境, +# 且需用户自备 conda 解释器(嵌入式 Python 运行时已移除)。 +# 因此这里不再有「下载嵌入式运行时 + 建 venv + pip 装 torch 跑冒烟」的重型 job, +# 推理侧改由 inference-lite 做零依赖的清单/契约校验。 +# +# job 刻意拆开且互不依赖:原先 lint 失败会把 typecheck / test 一并 skip, +# 一次只能拿到三分之一信号。 jobs: - frontend: + lint: runs-on: windows-latest - timeout-minutes: 60 + timeout-minutes: 30 steps: - name: Check out Git repository uses: actions/checkout@v4 - - name: Install Node.js and NPM + - name: Install Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm - - name: Cache embedded Python runtimes - uses: actions/cache@v4 - with: - path: assets/python-runtime - key: python-runtime-win-x64-${{ hashFiles('scripts/fetchPythonRuntimes.mjs') }} - - name: npm install run: npm install - - name: npm run package - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run package - - name: npm run lint run: npm run lint + typecheck: + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: npm install + run: npm install + - name: Typecheck run: npm exec tsc + test: + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: npm install + run: npm install + + # jest 的 setupFiles 里有 .erb/scripts/check-build-exists.ts,会断言 + # dist/main/main.js 与 dist/renderer/renderer.js 存在,故必须先构建。 + # 这里只需 npm run build(不跑 electron-builder),比 package 便宜得多。 + - name: Build bundles required by jest + run: npm run build + - name: npm test - run: npm test + run: npm test -- --ci - python-envs: + # Agent 运行时的协议契约测试:起 mock LLM server 校验 SSE chunk 序列与 + # tool_call 分片重组。零外部依赖(scenarios.mjs 无 import),故不装 node_modules。 + baseline-selftest: runs-on: windows-latest - timeout-minutes: 90 + timeout-minutes: 15 steps: - name: Check out Git repository @@ -54,25 +91,50 @@ jobs: with: node-version: 22 - - name: Cache embedded Python runtimes - uses: actions/cache@v4 + - name: npm run baseline:selftest + run: npm run baseline:selftest + + # 预标注推理仍是发布产物(见 package.json 的 extraResources),但它依赖的 + # 嵌入式运行时已移除,无法再在 CI 里建环境冒烟。这里只做零依赖的静态校验: + # 依赖清单与 envInstaller.ts 常量是否同步、打包清单是否仍含推理服务等。 + inference-lite: + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v5 with: - path: assets/python-runtime - key: python-runtime-win-x64-${{ hashFiles('scripts/fetchPythonRuntimes.mjs') }} + python-version: '3.12' + + - name: 语法检查 vendor/inference + run: python -m compileall -q vendor/inference - - name: Cache pip wheels - uses: actions/cache@v4 + - name: 依赖清单与安装器常量一致性 + run: node scripts/ciCheckInferenceManifests.mjs + + package: + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js and NPM + uses: actions/setup-node@v4 with: - path: .ci-pip-cache - key: pip-wheels-win-${{ hashFiles('vendor/local-agent/requirements.txt', 'vendor/inference/requirements-cpu.txt') }} + node-version: 22 + cache: npm + + - name: npm install + run: npm install - - name: Fetch embedded Python runtimes + - name: npm run package env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run fetch-python-runtimes - - - name: Install envs and smoke-test - env: - PIP_CACHE_DIR: ${{ github.workspace }}/.ci-pip-cache - run: node scripts/ciInstallAndTest.mjs + run: npm run package diff --git a/.gitignore b/.gitignore index 13d782b..3d3e2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -86,10 +86,6 @@ __pycache__/ # 嵌入式 Python 运行时已移除(Agent 编排迁到 Node,不再需要) -# CI 后续安装冒烟(scripts/ciInstallAndTest.mjs) -.ci-venvs/ -.ci-pip-cache/ - # ZCode 会话工作区缓存 .zcode/ diff --git a/scripts/ciCheckInferenceManifests.mjs b/scripts/ciCheckInferenceManifests.mjs new file mode 100644 index 0000000..9ba8ac7 --- /dev/null +++ b/scripts/ciCheckInferenceManifests.mjs @@ -0,0 +1,292 @@ +/** + * CI 轻量检查:推理侧(vendor/inference)依赖清单与安装器常量的一致性。 + * + * 背景:Agent 编排已迁到 Node 运行时,原 `python-envs` job(装 torch + + * ultralytics + sam2 做 import 冒烟,90 分钟超时)失去了前提——它依赖的嵌入式 + * Python 运行时已移除,产品语义也变成「用户自备 conda 环境」。但推理服务仍是 + * 发布产物的一部分,此前只靠 `envInstaller.ts` 与 `ciInstallAndTest.mjs` 的 + * 注释互相声称"保持字面同步",没有任何 CI 把关。 + * + * 本脚本把那句注释变成可验证的断言,成本近乎为零(无需装任何 Python 依赖): + * 1. 安装器里的 SAM-2 改写链在真实清单上确实能命中 + * 2. torch 索引 URL 与清单中的 torch 系列本地版本标签一致 + * 3. cpu / gpu 两份清单除 torch 系列与索引外完全一致(防单边漂移) + * 4. VERIFY_IMPORTS 里每个 import 名都能在清单里找到对应发行包 + * 5. Python 编排退场的残留引用不得回归 + * 6. 推理服务仍被计入打包产物,且必需模块文件齐全 + * + * 用法: node scripts/ciCheckInferenceManifests.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const INSTALLER = path.join(root, 'src', 'main', 'env', 'envInstaller.ts'); +const ENV_TYPES = path.join(root, 'src', 'shared', 'envTypes.ts'); +const RUNTIME_MANAGER = path.join(root, 'src', 'main', 'env', 'runtimeManager.ts'); +const PKG = path.join(root, 'package.json'); +const INFERENCE_DIR = path.join(root, 'vendor', 'inference'); + +let failures = 0; +function check(ok, label, detail = '') { + if (ok) { + console.log(` ✓ ${label}`); + } else { + failures += 1; + console.log(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); + } +} + +/** 从 TS 源码里取 `const NAME = '...'` 的字符串字面量(支持跨行拼接)。 */ +function stringConst(source, name) { + const re = new RegExp( + `const\\s+${name}\\s*=\\s*((?:'(?:[^'\\\\]|\\\\.)*'\\s*\\+?\\s*)+)`, + ); + const m = source.match(re); + if (!m) return null; + const parts = [...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((p) => p[1]); + return parts.length > 0 ? parts.join('') : null; +} + +// ── 解析 requirements 清单 ────────────────────────────────────────── + +/** + * 解析 requirements.txt。 + * + * 返回 `options`(`--xxx` 开头的 pip 参数)与 `packages`(发行包名 → 版本说明)。 + * 兼容两处历史写法:`name==version` 与 PEP 508 直链 `Name @ url`。 + */ +function parseRequirements(content) { + const options = []; + const packages = new Map(); + for (const rawLine of content.replace(/^\uFEFF/, '').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('-')) { + options.push(line); + continue; + } + const at = line.indexOf(' @ '); + if (at > 0) { + packages.set(line.slice(0, at).trim(), `@ ${line.slice(at + 3).trim()}`); + continue; + } + const eq = line.indexOf('=='); + if (eq > 0) { + packages.set(line.slice(0, eq).trim(), line.slice(eq + 2).trim()); + continue; + } + packages.set(line, '*'); + } + return { options, packages }; +} + +/** 索引 URL 的本地版本标签:`.../whl/cu121` → `cu121`。 */ +function indexTag(url) { + const seg = url.replace(/\/+$/, '').split('/').pop(); + return seg || ''; +} + +/** `--extra-index-url ` 中声明的索引。 */ +function declaredIndex(options) { + const entry = options.find((o) => o.startsWith('--extra-index-url')); + if (!entry) return null; + return entry.split(/\s+/)[1] ?? null; +} + +// ── 读取输入 ──────────────────────────────────────────────────────── + +const installerSource = fs.readFileSync(INSTALLER, 'utf8'); +const envTypesSource = fs.readFileSync(ENV_TYPES, 'utf8'); +const runtimeManagerSource = fs.readFileSync(RUNTIME_MANAGER, 'utf8'); +const pkg = JSON.parse(fs.readFileSync(PKG, 'utf8')); + +const SAM2_GIT_URL = stringConst(installerSource, 'SAM2_GIT_URL'); +const SAM2_ARCHIVE_URL = stringConst(installerSource, 'SAM2_ARCHIVE_URL'); +const TORCH_GPU_INDEX = stringConst(installerSource, 'TORCH_GPU_INDEX'); +const TORCH_CPU_INDEX = stringConst(installerSource, 'TORCH_CPU_INDEX'); +const verifyImportsMatch = installerSource.match( + /const\s+VERIFY_IMPORTS[^=]*=\s*\{([\s\S]*?)\};/, +); +const verifyImports = verifyImportsMatch + ? (verifyImportsMatch[1].match(/inference\s*:\s*'([^']*)'/)?.[1] ?? null) + : null; + +const manifests = { + cpu: parseRequirements( + fs.readFileSync(path.join(INFERENCE_DIR, 'requirements-cpu.txt'), 'utf8'), + ), + gpu: parseRequirements( + fs.readFileSync(path.join(INFERENCE_DIR, 'requirements-gpu.txt'), 'utf8'), + ), +}; + +console.log('[ciCheckInferenceManifests] 推理依赖清单一致性'); + +// ── 1. 安装器常量存在,且与清单对得上 ────────────────────────────── + +console.log('\n1) 安装器常量'); +check(!!SAM2_GIT_URL, 'envInstaller.ts 定义 SAM2_GIT_URL'); +check(!!SAM2_ARCHIVE_URL, 'envInstaller.ts 定义 SAM2_ARCHIVE_URL'); +check(!!TORCH_CPU_INDEX, 'envInstaller.ts 定义 TORCH_CPU_INDEX'); +check(!!TORCH_GPU_INDEX, 'envInstaller.ts 定义 TORCH_GPU_INDEX'); + +// ── 2. SAM-2 改写链在真实清单上确实命中 ──────────────────────────── + +console.log('\n2) SAM-2 git+https → 归档 zip 改写链'); +if (SAM2_GIT_URL && SAM2_ARCHIVE_URL) { + for (const variant of ['cpu', 'gpu']) { + const sam2 = [...manifests[variant].packages].find(([name]) => + /^sam-?2$/i.test(name), + ); + // sanitizeRequirements 用 `line.includes(SAM2_GIT_URL)` 判断,二者必须真能匹配 + check( + Boolean(sam2) && sam2[1].includes(SAM2_GIT_URL), + `${variant} 清单的 SAM-2 行含 SAM2_GIT_URL(改写分支能命中)`, + sam2 ? sam2[1] : '未找到 SAM-2 依赖', + ); + // 归档 URL 不该已经写在清单里,否则改写是空操作、语义漂移 + check( + Boolean(sam2) && !sam2[1].includes(SAM2_ARCHIVE_URL.split(' ').pop()), + `${variant} 清单未预先写入归档 URL(改写非空操作)`, + ); + } +} + +// ── 3. torch 索引与本地版本标签一致 ──────────────────────────────── + +console.log('\n3) torch 索引 URL ↔ 本地版本标签'); +for (const [variant, index] of [ + ['cpu', TORCH_CPU_INDEX], + ['gpu', TORCH_GPU_INDEX], +]) { + const { options, packages } = manifests[variant]; + const tag = index ? indexTag(index) : ''; + + check( + Boolean(index) && declaredIndex(options) === index, + `${variant} 清单声明的 extra-index-url 等于 TORCH_${variant.toUpperCase()}_INDEX`, + `期望 ${index},实际 ${declaredIndex(options)}`, + ); + + for (const name of ['torch', 'torchvision']) { + const version = packages.get(name); + check( + typeof version === 'string' && version.endsWith(`+${tag}`), + `${variant} 清单 ${name} 的本地版本标签为 +${tag}`, + `实际 ${version}`, + ); + } +} + +// ── 4. 两份清单除 torch 系列外必须一致 ───────────────────────────── + +console.log('\n4) cpu / gpu 清单差异范围'); +const TORCH_FAMILY = new Set(['torch', 'torchvision']); +const namesOf = (variant) => + [...manifests[variant].packages.keys()].sort().join('\n'); +check( + namesOf('cpu') === namesOf('gpu'), + 'cpu / gpu 的发行包集合完全相同(差异只允许在版本标签)', +); +const versionDrift = [...manifests.cpu.packages].filter(([name, version]) => { + if (TORCH_FAMILY.has(name)) return false; + return manifests.gpu.packages.get(name) !== version; +}); +check( + versionDrift.length === 0, + '非 torch 系列的版本钉法在两份清单中一致', + versionDrift.map(([n]) => n).join(', '), +); + +// ── 5. VERIFY_IMPORTS 的 import 名都能落到清单里的发行包 ─────────── + +console.log('\n5) VERIFY_IMPORTS 覆盖'); +// import 名与 pip 发行包名并不总是同名,这里维护已知映射 +const IMPORT_TO_DIST = { + torch: 'torch', + torchvision: 'torchvision', + ultralytics: 'ultralytics', + cv2: 'opencv-python', + PIL: 'pillow', + sam2: 'SAM-2', +}; +if (verifyImports) { + for (const mod of verifyImports.split(',').map((s) => s.trim())) { + const dist = IMPORT_TO_DIST[mod]; + check( + Boolean(dist), + `import ${mod} 有对应的发行包映射(新增依赖时请同步 IMPORT_TO_DIST)`, + ); + if (!dist) continue; + const hit = [...manifests.cpu.packages.keys()].some( + (name) => name.toLowerCase() === dist.toLowerCase(), + ); + check(hit, `${mod} → ${dist} 存在于清单中`); + } +} else { + check(false, 'envInstaller.ts 中可解析出 VERIFY_IMPORTS.inference'); +} + +// ── 6. Python 编排退场的残留引用不得回归 ─────────────────────────── + +console.log('\n6) 已移除能力的残留引用'); +check( + !/local-agent/.test(installerSource), + 'envInstaller.ts 不再提及 local-agent', +); +check( + !/python-runtime/.test(installerSource), + 'envInstaller.ts 不再提及 python-runtime', +); +// 只断言类型声明本身:文件里保留「原先还有 'local-agent'」这类历史说明是 +// 刻意为之(解释为何收敛),不该被当成残留引用。 +const installTargetDecl = + envTypesSource.match(/export\s+type\s+InstallTarget\s*=\s*([^;]+);/)?.[1] ?? ''; +check( + Boolean(installTargetDecl) && !/'local-agent'/.test(installTargetDecl), + "shared/envTypes.ts 的 InstallTarget 声明不再含 'local-agent'", + `实际 ${installTargetDecl.trim()}`, +); +check( + !/'local-agent'/.test(runtimeManagerSource), + 'runtimeManager.ts 的 TARGET_PYTHON_VERSION 不再含 local-agent', +); +check( + !pkg.scripts['fetch-python-runtimes'], + 'package.json 不再有 fetch-python-runtimes 脚本', +); +check( + !fs.existsSync(path.join(root, 'scripts', 'fetchPythonRuntimes.mjs')), + 'scripts/fetchPythonRuntimes.mjs 已删除', +); + +// ── 7. 推理服务仍属打包产物,且模块文件齐全 ──────────────────────── + +console.log('\n7) 推理服务打包完整性'); +const extraResources = JSON.stringify(pkg.build?.extraResources ?? []); +check( + extraResources.includes('vendor/inference'), + 'package.json 的 extraResources 仍包含 vendor/inference', +); +for (const rel of [ + 'server.py', + 'runners/__init__.py', + 'runners/dispatch.py', + 'runners/keypoint.py', + 'runners/runtime.py', + 'runners/sam2_runner.py', + 'runners/yolo.py', +]) { + check(fs.existsSync(path.join(INFERENCE_DIR, rel)), `vendor/inference/${rel} 存在`); +} + +console.log( + failures + ? `\n[ciCheckInferenceManifests] 失败 ${failures} 项` + : '\n[ciCheckInferenceManifests] 全部通过', +); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/ciInstallAndTest.mjs b/scripts/ciInstallAndTest.mjs deleted file mode 100644 index 7bf23d2..0000000 --- a/scripts/ciInstallAndTest.mjs +++ /dev/null @@ -1,160 +0,0 @@ -/** - * CI 后续安装冒烟:复刻向导的 venv / pip / verify,不启动 Electron。 - * - * 与 src/main/env/envInstaller.ts 保持字面同步: - * SAM2_GIT_URL / SAM2_ARCHIVE_URL / TORCH_CPU_INDEX / VERIFY_IMPORTS - * - * 用法: node scripts/ciInstallAndTest.mjs - * 前置: 已执行 npm run fetch-python-runtimes(assets/python-runtime/py312) - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const SAM2_GIT_URL = 'git+https://github.com/facebookresearch/sam2.git'; -const SAM2_ARCHIVE_URL = - 'SAM-2 @ https://github.com/facebookresearch/sam2/archive/refs/heads/main.zip'; -const TORCH_CPU_INDEX = 'https://download.pytorch.org/whl/cpu'; -const VERIFY_IMPORTS = { - 'local-agent': 'fastapi, uvicorn, langchain_core', - inference: 'torch, torchvision, ultralytics, cv2, PIL, sam2', -}; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const runtimeRoot = path.join(root, 'assets', 'python-runtime'); -const venvRoot = path.join(root, '.ci-venvs'); - -function sanitizeRequirements(content) { - const lines = []; - for (const rawLine of content.replace(/^\uFEFF/, '').split(/\r?\n/)) { - const line = rawLine.trim(); - lines.push(line.includes(SAM2_GIT_URL) ? SAM2_ARCHIVE_URL : line); - } - return lines.join('\n'); -} - -function run(python, args, cwd, label) { - console.log(`\n=== ${label} ===`); - console.log(`> ${python} ${args.join(' ')}`); - const result = spawnSync(python, args, { - cwd, - stdio: 'inherit', - windowsHide: true, - env: process.env, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error(`${label} 失败 (exit ${result.status})`); - } -} - -function venvPython(venvDir) { - return path.join(venvDir, 'Scripts', 'python.exe'); -} - -function ensureRuntime(versionDir) { - const pythonExe = path.join(runtimeRoot, versionDir, 'python.exe'); - if (!fs.existsSync(pythonExe)) { - throw new Error( - `嵌入式运行时未就绪: ${pythonExe}。请先运行 npm run fetch-python-runtimes`, - ); - } - return pythonExe; -} - -function createVenv(runtimePython, venvDir, label) { - fs.rmSync(venvDir, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(venvDir), { recursive: true }); - run(runtimePython, ['-m', 'venv', venvDir], root, `${label}: 创建 venv`); - const python = venvPython(venvDir); - if (!fs.existsSync(python)) { - throw new Error(`${label}: venv 创建后未找到 ${python}`); - } - run(python, ['-m', 'pip', 'install', '--upgrade', 'pip', 'wheel'], root, `${label}: 升级 pip`); - return python; -} - -function installLocalAgent() { - const runtimePython = ensureRuntime('py312'); - const venvDir = path.join(venvRoot, 'local-agent'); - const python = createVenv(runtimePython, venvDir, 'local-agent'); - const requirements = path.join(root, 'vendor', 'local-agent', 'requirements.txt'); - if (!fs.existsSync(requirements)) { - throw new Error(`依赖清单不存在: ${requirements}`); - } - run( - python, - ['-m', 'pip', 'install', '-r', requirements], - root, - 'local-agent: pip install', - ); - run( - python, - ['-c', `import ${VERIFY_IMPORTS['local-agent']}; print('verify ok')`], - root, - 'local-agent: import verify', - ); - run( - python, - ['-m', 'pytest'], - path.join(root, 'vendor', 'local-agent'), - 'local-agent: pytest', - ); -} - -function installInference() { - const runtimePython = ensureRuntime('py312'); - const venvDir = path.join(venvRoot, 'inference'); - const python = createVenv(runtimePython, venvDir, 'inference'); - const requirements = path.join( - root, - 'vendor', - 'inference', - 'requirements-cpu.txt', - ); - if (!fs.existsSync(requirements)) { - throw new Error(`依赖清单不存在: ${requirements}`); - } - const prepared = path.join(venvRoot, 'inference-requirements.txt'); - fs.writeFileSync( - prepared, - sanitizeRequirements(fs.readFileSync(requirements, 'utf8')), - 'utf8', - ); - run( - python, - [ - '-m', - 'pip', - 'install', - '-r', - prepared, - '--extra-index-url', - TORCH_CPU_INDEX, - ], - root, - 'inference: pip install (cpu)', - ); - run( - python, - ['-c', `import ${VERIFY_IMPORTS.inference}; print('verify ok')`], - root, - 'inference: import verify', - ); -} - -function main() { - installLocalAgent(); - installInference(); - console.log('\n后续安装冒烟全部通过。'); -} - -try { - main(); -} catch (err) { - console.error(`[ciInstallAndTest] ${err.message}`); - process.exit(1); -} diff --git a/src/main/env/envInstaller.ts b/src/main/env/envInstaller.ts index 93029a2..9bddf45 100644 --- a/src/main/env/envInstaller.ts +++ b/src/main/env/envInstaller.ts @@ -1,8 +1,8 @@ /** - * 环境一键安装器。 + * 环境一键安装器(仅预标注推理:`vendor/inference`)。 * - * 使用嵌入式 Python 运行时(python-build-standalone)为 local-agent / inference - * 创建独立 venv 并安装依赖,阶段推进: + * Agent 编排已迁到 Node 运行时,不再需要 Python 环境;嵌入式 Python 运行时 + * 亦已移除,因此这里只为用户自行准备的解释器创建 venv 并安装依赖,阶段推进: * prepare-runtime → create-venv → install-deps → verify → done/failed/cancelled * * 安装全程仅由用户在向导中手动触发(无静默安装),进度通过 From 457333ecc424c5cca38a7e174c85ef13e55640e5 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 16:28:47 +0800 Subject: [PATCH 19/20] =?UTF-8?q?docs:=20SECURITY.md=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Node=20=E8=BF=90=E8=A1=8C=E6=97=B6=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=20Agent=20=E8=BF=90=E8=A1=8C=E6=97=B6=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E7=9A=84=E5=AD=98=E5=9C=A8=E6=80=A7=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY.md 通篇仍是 Python 编排时代的描述,其中『本地 Agent(vendor/local-agent) 经 FastAPI require_local_token 鉴权』与『cd vendor/local-agent && python -m pytest』 两处已经跑不了。按现状改写: - 头部定位改为 Electron + Node 版 Agent 运行时 - 本地服务鉴权:改为 utilityProcess.fork 启动 agentRuntime.js、随机 token 经 LR_AGENT_LOCAL_TOKEN 注入、server.ts 除 /health 外常量时间 Bearer 校验、 未配置 token 时拒绝启动(与迁移前同一失败关闭语义) - 写盘白名单去掉已不存在的 Python 副本引用,唯一真源仍是 src/shared/workspaceTextExtensions.ts - 测试一节改为实际的 npm run lint / npm exec tsc / npm test / baseline:selftest, 并说明推理侧(vendor/inference)无单测、由 CI 做语法与清单校验 - 反序列化审计去掉 vendor/local-agent(其替代实现是 TS,由 javascript 矩阵覆盖) 另:.erb/scripts/check-build-exists.ts 原先只断言 dist/main/main.js 与 dist/renderer/renderer.js。Agent 运行时是独立 webpack 入口,缺失时只在运行期 才失败,构建与 jest 都无感,故补上 agentRuntime.js 的断言 (已实测:移走产物后 jest 立即以明确提示失败)。 Co-authored-by: Cursor --- .erb/scripts/check-build-exists.ts | 15 ++++++++++++++ SECURITY.md | 32 ++++++++++++++++++------------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/.erb/scripts/check-build-exists.ts b/.erb/scripts/check-build-exists.ts index 3afa868..9bf38b5 100644 --- a/.erb/scripts/check-build-exists.ts +++ b/.erb/scripts/check-build-exists.ts @@ -7,6 +7,10 @@ import webpackPaths from '../configs/webpack.paths'; const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); +const agentRuntimePath = path.join( + webpackPaths.distMainPath, + 'agentRuntime.js', +); if (!fs.existsSync(mainPath)) { throw new Error( @@ -16,6 +20,17 @@ if (!fs.existsSync(mainPath)) { ); } +// Agent 运行时是独立的 webpack 入口(见 .erb/configs/webpack.config.main.*.ts +// 的 agentRuntime),由主进程 utilityProcess.fork 启动。缺失时只在运行期才失败, +// 构建期完全无感,故在这里一并断言。 +if (!fs.existsSync(agentRuntimePath)) { + throw new Error( + chalk.whiteBright.bgRed.bold( + 'The Agent runtime bundle is not built yet. Build it by running "npm run build:main"', + ), + ); +} + if (!fs.existsSync(rendererPath)) { throw new Error( chalk.whiteBright.bgRed.bold( diff --git a/SECURITY.md b/SECURITY.md index fb249b6..d847c5e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # 安全说明与加固记录 -本文件记录 LR-Agent 桌面端(Electron + 本地 `vendor/local-agent`)的安全加固措施、 +本文件记录 LR-Agent 桌面端(Electron + Node 版 Agent 运行时)的安全加固措施、 残余风险以及尚未闭环的运维事项。改动代码时请同步维护本文件。 ## 已实施加固 @@ -49,16 +49,17 @@ - 本地 MCP Server(`src/main/mcp/server.ts`):每次启动生成随机 token,`/mcp` 校验 `Authorization: Bearer`,并校验 `Host` 仅为本机、拒绝带 `Origin` 的跨源请求;`/health` 保持开放。 -- 本地 Agent(`vendor/local-agent`):Electron 主进程通过环境变量 `LR_AGENT_LOCAL_TOKEN` - 注入随机 token,FastAPI 侧 `app/core/deps.py#require_local_token` 对全部 `/api/v1` 路由做 - 常量时间校验;未配置 token 时失败关闭(仅 `/health` 可用)。 +- 本地 Agent 运行时(`src/main/agent/`):Electron 主进程以 `utilityProcess.fork` 启动 + `agentRuntime.js`(`host.ts`),生成随机 token 并经环境变量 `LR_AGENT_LOCAL_TOKEN` + 注入,同时绑定随机端口。服务侧 `server.ts` 对除 `/health` 外的全部路由做常量时间 + Bearer 校验;**未配置 token 时拒绝启动**,不对外提供服务(`runtime.ts`,与迁移前的 + Python 版保持同一失败关闭语义)。 渲染层所有发往本地 Agent 的请求统一经 `src/renderer/config.ts#localAgentFetch` 携带 Bearer token。 - CORS 启动校验:`allow_credentials=True` 时禁止 `cors_origins` 含 `*`。 ### 5. 文件写入策略 -- 写盘/编辑改为「文本扩展名白名单」(`src/shared/workspaceTextExtensions.ts` 与 - `vendor/local-agent/app/agent/tools/workspace_text_extensions.py` 保持同步): +- 写盘/编辑改为「文本扩展名白名单」(唯一真源:`src/shared/workspaceTextExtensions.ts`): 白名单之外的扩展名一律禁止写入,脚本/可执行类型(`.bat`、`.cmd`、`.ps1`、`.vbs`、 `.hta`、`.scr`、`.jar`、`.reg`、`.lnk` 等)被明确拒绝。 - `src/main/workspace/workspaceWrite.ts` 在 `path.resolve` 之外补充 `fs.realpath` 校验, @@ -71,10 +72,13 @@ ## 测试 -- TS:`npm test`(含 secretStore 加解密与失败关闭、MCP `/mcp` 401/403/放行、写盘白名单与 - 扩展名拦截、符号链接逃逸、深度链接校验与用后即清)。 -- Python:`cd vendor/local-agent && python -m pytest`(含 `tests/test_local_auth.py` - 的 401/放行用例、`tests/test_workspace_text_extensions.py` 白名单用例)。 +- 全量单测:`npm test`(含 secretStore 加解密与失败关闭、MCP `/mcp` 401/403/放行、写盘白名单与 + 扩展名拦截、符号链接逃逸、深度链接校验与用后即清、本地 Agent 运行时的 401/放行与常量时间比较)。 +- 静态检查:`npm run lint`、`npm exec tsc`。 +- Agent 协议契约:`npm run baseline:selftest`(起 mock LLM server,校验 SSE chunk 序列与 + tool_call 分片重组)。 +- 推理侧(`vendor/inference`):无单测覆盖,CI 以 `python -m compileall` 做语法检查, + 并以 `node scripts/ciCheckInferenceManifests.mjs` 校验依赖清单与 `envInstaller.ts` 常量同步。 - 手工验证建议:dev 下确认新 CSP 不破坏 HMR/React Refresh;构造含 `javascript:` 链接的 `.docx` 确认点击无脚本执行;用错误 token 调 `/mcp` 与本地 Agent 接口确认 401。 @@ -101,8 +105,10 @@ ### `vendor/inference` 反序列化审计 -- 审计结论(2026-09):当前 `vendor/inference` 与 `vendor/local-agent` 中**未发现** +- 审计结论(2026-09):当前 `vendor/inference` 中**未发现** `torch.load`、`pickle.load`、`np.load(allow_pickle=True)`、`yaml.load(`、`eval(`、`exec(`、 - `os.system` 等高风险调用。 + `os.system` 等高风险调用。(编排侧 `vendor/local-agent` 已随迁移删除,其替代实现是 + TypeScript,由 CodeQL 的 `javascript` 矩阵覆盖。) - 后续政策:新增模型加载必须使用 `torch.load(..., weights_only=True)` 或 `safetensors`, - 禁止加载不可信 checkpoint;引入前需补 CodeQL 扫描(已加入 `python` 语言矩阵)。 + 禁止加载不可信 checkpoint;引入前需补 CodeQL 扫描(`python` 语言矩阵当前即用于覆盖 + `vendor/inference`)。 From e19defa7ff60a53770512ae906424c0c29361e31 Mon Sep 17 00:00:00 2001 From: MarisaMagic Date: Sat, 19 Sep 2026 16:45:57 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(agent):=20=E4=B8=8D=E5=86=8D=E5=9B=9E?= =?UTF-8?q?=E4=BC=A0=E5=BC=82=E5=B8=B8=E6=B6=88=E6=81=AF=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E4=B8=A4=E6=9D=A1=20CodeQL=20=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL 的 code-scanning 门禁(check 名也叫 CodeQL,与 workflow run 的 success 是两回事)在 PR 上一直红,3 个告警自分支首次推送 485419c 即存在,与本次 CI 改造 无关(只是 prettier 格式化让 server.ts 的行号移了一位)。 真问题(已修):路由 handler 抛出未预期异常时,500 响应把 err.message 回传给 调用方,命中 js/stack-trace-exposure —— 异常消息常夹带绝对路径与依赖库内部片段。 改为响应体只含 { detail: 'internal_error' },完整消息只进服务端日志 (onLog → 主进程),开发期看日志即可。 没有采用「加开关按环境回传」的方案:那样污点流在静态上依然存在,告警不会消失。 误报(按 query id 排除,附理由): - js/cors-misconfiguration-for-credentials:本仓库的 CORS 实现正是该查询官方文档 给出的 GOOD 写法 —— Origin 必须命中显式白名单才回显(未命中连 ACAO 头都不设), 且启动期 assertCorsConfig 拒绝 '*'。CodeQL 看不穿 resolveCorsOrigin 这层 helper。 - js/clear-text-logging:唯一命中点是本地冒烟脚本 electronForkSmoke.cjs(不参与 打包),被判「敏感」的实际只有 HTTP 状态码与 {"ok":true} 这类响应体。 新增 .github/codeql/codeql-config.yml 按 query id 排除这两条,不改动查询集(保持 默认 code-scanning suite),每条排除附理由与依据链接;workflow 以 config-file 引用。 SECURITY.md 同步记录该排除策略与「错误详情不外泄」的约定。 验证:npm run lint 0 error;npm exec tsc 通过;npx jest --ci 113 suites / 1036 tests 全通过(server.test.ts 新增用例断言 500 响应体不含内部细节、 但完整消息仍进服务端日志)。 Co-authored-by: Cursor --- .github/codeql/codeql-config.yml | 30 +++++++++++++++++++++++++++ .github/workflows/codeql-analysis.yml | 3 +++ SECURITY.md | 13 ++++++++++++ src/main/agent/server.test.ts | 26 +++++++++++++++++------ src/main/agent/server.ts | 5 ++++- 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..fc4f39b --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,30 @@ +# CodeQL 配置(由 .github/workflows/codeql-analysis.yml 的 config-file 引用)。 +# +# 这里只按 query id 排除**已确认的误报**,不动查询集——保持 GitHub 默认的 +# code-scanning suite,避免「顺手放宽扫描面」这种隐性降级。 +# +# 每条排除都必须写明理由与依据链接。若将来代码结构调整使查询不再误报, +# 应删掉对应条目,而不是让它常年躺着。 +name: "LR-Agent CodeQL 配置" + +query-filters: + # 误报:CORS 凭据传输配置 + # + # src/main/agent/server.ts 的实现正是该查询官方文档给出的「GOOD」写法: + # 请求 Origin 必须命中显式白名单才回显(`resolveCorsOrigin`:未命中返回 + # null,连 ACAO 头都不设),并且 `assertCorsConfig` 在服务启动阶段就拒绝 + # 含 '*' 的配置,因此不存在「反射任意 Origin + Allow-Credentials: true」。 + # CodeQL 无法看穿 resolveCorsOrigin 这层 helper,仍按反射请求值上报。 + # https://codeql.github.com/codeql-query-help/javascript/js-cors-misconfiguration-for-credentials/ + - exclude: + id: js/cors-misconfiguration-for-credentials + + # 误报:明文记录敏感信息 + # + # 唯一命中点是 scripts/agent-baseline/electronForkSmoke.cjs——本地冒烟脚本, + # 不参与打包(见 package.json 的 extraResources)。被判定为「敏感数据」的 + # 实际只有 HTTP 状态码与 {"ok":true} 这类响应体;CodeQL 把「带 Authorization + # 头的请求的响应对象」整体视为凭据源,连带其状态码一并算作敏感。 + # https://codeql.github.com/codeql-query-help/javascript/js-clear-text-logging/ + - exclude: + id: js/clear-text-logging diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0548477..205ea2e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -48,6 +48,9 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + # 只用于排除已确认的误报(按 query id),不改动查询集; + # 每条排除的理由与依据见该文件内的注释。 + config-file: ./.github/codeql/codeql-config.yml # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/SECURITY.md b/SECURITY.md index d847c5e..a09a9eb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,9 @@ Python 版保持同一失败关闭语义)。 渲染层所有发往本地 Agent 的请求统一经 `src/renderer/config.ts#localAgentFetch` 携带 Bearer token。 - CORS 启动校验:`allow_credentials=True` 时禁止 `cors_origins` 含 `*`。 +- 错误详情不外泄:路由 handler 抛出未预期异常时,响应体只回 + `{ detail: 'internal_error' }`,**不回传 `err.message`**(异常消息常夹带绝对路径与 + 依赖库内部片段)。完整消息只进服务端日志(`onLog` → 主进程),需要细节时看日志。 ### 5. 文件写入策略 @@ -112,3 +115,13 @@ - 后续政策:新增模型加载必须使用 `torch.load(..., weights_only=True)` 或 `safetensors`, 禁止加载不可信 checkpoint;引入前需补 CodeQL 扫描(`python` 语言矩阵当前即用于覆盖 `vendor/inference`)。 + +### CodeQL 误报排除 + +- `.github/codeql/codeql-config.yml` 里按 query id 排除了两条**已确认的误报**: + `js/cors-misconfiguration-for-credentials`(本仓库的 CORS 实现正是该查询文档给出的 + 「GOOD」写法:显式白名单 + 启动期拒绝 `*`,CodeQL 无法看穿 `resolveCorsOrigin` 这层 + helper)与 `js/clear-text-logging`(唯一命中点是本地冒烟脚本 + `scripts/agent-baseline/electronForkSmoke.cjs`,被判定"敏感"的只有 HTTP 状态码)。 +- 该文件只做排除、不改查询集;每条排除都附理由与依据链接。若代码结构调整后不再误报, + 应删除对应条目。 diff --git a/src/main/agent/server.test.ts b/src/main/agent/server.test.ts index 89d2e73..f59e64b 100644 --- a/src/main/agent/server.test.ts +++ b/src/main/agent/server.test.ts @@ -55,12 +55,16 @@ const started: AgentServer[] = []; async function boot( overrides: Record = {}, + options: { + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; + } = {}, ): Promise { const server = await startAgentServer({ host: '127.0.0.1', port: 0, token: TOKEN, corsOrigins: ['http://localhost:1212'], + onLog: options.onLog, routes: { 'POST /api/v1/agent/echo': (req) => ({ kind: 'json', @@ -159,18 +163,28 @@ describe('agent runtime HTTP server', () => { expect(res.status).toBe(405); }); - it('handler 抛异常返回 500 而不是崩溃', async () => { - const port = await boot({ - 'POST /api/v1/agent/boom': () => { - throw new Error('boom'); + it('handler 抛异常返回 500 而不是崩溃,且不回传异常消息', async () => { + const logs: string[] = []; + const port = await boot( + { + 'POST /api/v1/agent/boom': () => { + throw new Error('/secret/abs/path: 内部细节'); + }, }, - }); + { onLog: (_level, message) => logs.push(message) }, + ); const res = await request(port, '/api/v1/agent/boom', { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` }, }); expect(res.status).toBe(500); - expect(JSON.parse(res.body).message).toBe('boom'); + const body = JSON.parse(res.body); + expect(body.detail).toBe('internal_error'); + expect(body.message).toBeUndefined(); + // 不把内部细节写进响应体 + expect(res.body).not.toContain('内部细节'); + // 但运维侧信息不丢:完整消息仍在服务端日志里 + expect(logs.some((line) => line.includes('内部细节'))).toBe(true); }); }); diff --git a/src/main/agent/server.ts b/src/main/agent/server.ts index a80edf1..e3644a4 100644 --- a/src/main/agent/server.ts +++ b/src/main/agent/server.ts @@ -196,8 +196,11 @@ export async function startAgentServer( }); } catch (err) { const message = err instanceof Error ? err.message : String(err); + // 完整信息只进服务端日志:异常消息常夹带绝对路径、依赖库内部片段等, + // 回传给调用方属于信息暴露(CodeQL js/stack-trace-exposure)。 + // 开发期需要细节时看运行时日志(onLog → 主进程 / 终端),不要经 HTTP 回传。 log('error', `路由 ${method} ${path} 抛出异常: ${message}`); - sendJson(res, 500, { detail: 'internal_error', message }); + sendJson(res, 500, { detail: 'internal_error' }); return; }