From 7a566d50b734d22034528faf477f257b1c943eae Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 08:55:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(toolcall):=20=E5=B7=A5=E5=85=B7=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E3=80=8D=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=20+=20=E4=BF=AE=E7=88=B6=20io=20=E5=85=9C=E5=BA=95=E5=90=9E?= =?UTF-8?q?=E9=94=99=E8=AF=AF=20+=20=E4=BF=AE=E5=B9=B6=E8=A1=8C=20tool=5Fc?= =?UTF-8?q?all=20=E8=90=BD=E5=BA=8F=E9=9A=8F=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主线:工具调用并行化改造(阶段 0 与 0.2)。 ① flush 顺序随机(process.go) flushToolCall 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化 ⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。 对 output_send__ 这类用户可见通道,分段消息到达顺序不可复现。 改为收集 index 后 sort.Ints 再 flush(两个调用点统一走 flushAll)。 判据 stream_flush_order_test.go(8 工具 × 200 轮),已变异验证可检测。 ② 工具「不存在」类型化(io/channel.go、core/stages.go、core/toolcall.go) 工具是动态注册的,「不存在」是运行期常态而非异常。原先内核用 strings.Contains(err, "not found in any plugin") 判别——约定而非契约, 插件文案含该子串即被误判。改用哨兵 ErrToolNotFound + errors.Is (沿用仓内 ErrInputChannelUnknown 的先例)。 ⚠️ 顺带修一个静默 bug:IOManager 向父兜底时吞掉父的执行失败, 误报为「工具不存在」。后果是设备离线这类本该 retry 的失败被判为 「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。改为只传递 「确实不存在」,其余如实上抛。 「不存在」的文案改为可执行指引(get_plugin_tools / output_list_channels), 而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。 判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)、 channel_error_test.go(父失败不吞、真的不存在仍可判别)。 两者均经变异验证。回归:internal/agent/... 与 internal/plugins/... 全绿(14 包)。 设计文档:docs/zh/toolcall-contract-and-sequence-design.md 执行计划:docs/zh/toolcall-parallel-execution-plan.md --- .../toolcall-contract-and-sequence-design.md | 744 ++++++++++++++++++ docs/zh/toolcall-parallel-execution-plan.md | 239 ++++++ internal/agent/core/process.go | 29 +- internal/agent/core/stages.go | 6 +- .../agent/core/stream_flush_order_test.go | 75 ++ internal/agent/core/toolcall.go | 19 +- internal/agent/core/toolcall_error_test.go | 72 ++ internal/agent/io/channel.go | 31 +- internal/agent/io/channel_error_test.go | 70 ++ 9 files changed, 1275 insertions(+), 10 deletions(-) create mode 100644 docs/zh/toolcall-contract-and-sequence-design.md create mode 100644 docs/zh/toolcall-parallel-execution-plan.md create mode 100644 internal/agent/core/stream_flush_order_test.go create mode 100644 internal/agent/core/toolcall_error_test.go create mode 100644 internal/agent/io/channel_error_test.go diff --git a/docs/zh/toolcall-contract-and-sequence-design.md b/docs/zh/toolcall-contract-and-sequence-design.md new file mode 100644 index 0000000..95b1b88 --- /dev/null +++ b/docs/zh/toolcall-contract-and-sequence-design.md @@ -0,0 +1,744 @@ +# 工具调用契约与工具序列设计 + +> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)与 +> 《驻留式子 Agent 设计》(`docs/zh/resident-subagent-design.md`)之上。 +> +> **状态**:本文是**设计定稿**,尚未实现。实现顺序见 §9。 +> 标记:**[已定]**= 明确拍板;**[默认]**= 可逆取值,实现时在提交信息标注; +> **[待定]**= 需决策后才动手。 + +--- + +## 1. 背景:当前工具调用语义为何简陋 + +这不是"功能不够",而是**缺少契约层**。四处实证: + +| 编号 | 事实 | 位置 | 后果 | +| --- | --- | --- | --- | +| A | `Success` 硬编码 `true`,是唯一赋值点 | `task.go:757` | 工具失败时 `Success` **结构上不可能为 false** | +| B | 工具失败以 `nil` error + 错误**值**返回 | `files/plugin.go:228`、`cmd/plugin.go:135` | 上游无法区分"成功"与"失败" | +| C | `ToolDef.Parameters.required` **无任何消费方** | 声明于 plugins,内核不查 | 缺参要等工具真被调用才暴露 | +| D | 工具结果统一降为 `string` | `toolcall.go:17` | 结果里的结构化信息丢失 | + +A+B 合并的后果最重:`ToolResult.Success` 是一个**谎报字段**。这解释了为何 +`on_error` 分支在当前语义下永远走不到,也解释了条件表达式为何只能停留在 +"字符串 contains"——**没有类型可依据**。 + +C 的代价在 `toolcall.go:49` 的注释里被间接承认:参数不可用时不要拿空参数调工具, +否则模型只会看到 `"path is required"` 而看不出真因。现有补救是**在解析层拦截截断** +(`__arg_error`),但**校验本身仍然缺席**——`required` 只是发给模型看的说明书。 + +因此本文不是"加一个序列功能",而是**先补契约层,再在其上表达控制流**。 + +--- + +## 2. 目标与非目标 + +### 内核目标(本次交付) + +1. **结果契约**:结果有结构、成功标志诚实、错误可定位。 +2. **参数契约**:内核按 `Parameters` 预校验,不靠工具自觉。 +3. **并行执行**:同轮多个 tool_call 并行,**并发安全由 `ParallelSafe` 声明而非约定保证**。 + +### 插件目标(`seq` 插件,不在内核交付范围) + +4. **工具序列**:把可复用的多步流程固化为可命名、可复用、可删除的对象。 +5. **条件与变量**:组内并行、组间以具名槽传值、条件屏障。 + +### 非目标 + +- 不引入通用表达式引擎(见 §5.1 的分级取舍)。 +- 不做跨调用持久化变量(本次作用域限单次 `seq_run`)。 +- 不改变单次 tool_call 的对外协议形状(`tool_call_id` 配对语义不变)。 +- **内核不为序列开任何新接口**(见 §7 边界声明)。 + +--- + +## 3. 术语 + +| 术语 | 含义 | +| --- | --- | +| **调用**(call) | 一次 `ToolCall`:模型发起、内核执行、结果回填的最小单位 | +| **组**(group) | 序列中的一层,**组内并行、组间串行**。是并发与条件的基本单位 | +| **序列**(sequence) | 若干组的集合,命名持久化,可 `seq_run` 执行 | +| **槽**(slot) | group 的**具名**输入/输出位。`$args.<键>` 读入参,`as:<键>` 写出参;名字须在 `in`/`out` 中事先声明 | +| **嵌套调用**(nested call) | `seq_call` 的一项:`{target: "组名"|"序列名", args: {...}}`。序列以 `#` 前缀区分(`#巡检三节点`) | +| **调用栈**(call stack) | 执行期的嵌套调用链,用于**环检测**与**深度上界**(§8.3) | +| **契约**(contract) | 参数校验规则 + 结果结构 + 成功标志的集合 | + +--- + +## 4. 结果契约(地基,优先实现) + +### 4.1 结果结构 + +`ToolResult` 保持既有字段,**新增一个**结构化错误槽: + +```go +// ToolError 描述一次工具调用的失败原因。 +// 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试 +//(实测 cmd_run 失败率 34%~48%,全部源于同一个成因)。 +type ToolError struct { + Field string `json:"field,omitempty"` // 出错字段名(参数校验失败时) + Reason string `json:"reason"` // 机器可读码:required/type/unauthorized/timeout/not_found + Detail string `json:"detail,omitempty"` // 人类可读补充 + Hint string `json:"hint,omitempty"` // 可执行指引 +} +``` + +### 4.2 成功标志的诚实化 [已定] + +``` +Success = (err == nil) && (返回值不是 ToolError) +``` + +- 工具侧**约定**:失败返回 `(ToolError, nil)` 而非 `(nil, err)`——沿用仓库既有的 + `errorResult(...)` 风格(`files/plugin.go:228`),保证 error 通道仍可用于真正的内部错误。 +- 存量插件**无需改动**:它们返回的错误值会被 `isToolError()` 识别为失败; + 返回的普通 map/string 仍算成功。**判据必须同时覆盖新旧两种形态**, + 否则升级会把存量插件的"成功"误判成失败。 + +### 4.3 保留结构化值 [已定] + +`executeToolCall` 内部新增 `rawResult interface{}` 保存**未降级**的返回值, +对外的 `string` 渲染由统一函数负责(§5.2)。这是条件表达式能做字段访问的前提。 + +### 4.4 参数预校验 [已定] + +在 `executeToolCallInner` 分派**之前**执行: + +1. 逐项检查 `Parameters.required`; +2. 逐项检查 `properties` 的 `type`(`string`/`integer`/`number`/`boolean`/`array`/`object`); +3. 失败 ⇒ 返回结构化 `ToolError`,**不进入分派**。 + +**这条直接消灭 C 类浪费**:模型传错参数时拿到的是 +`{"field":"path","reason":"required","hint":"..."}`,而不是工具内部的自由文本。 + +> 兼容注意:`getBool`(`utils.go:26`)的注释记载了"实际调用里 bool/string/float 三种都出现过"。 +> 预校验**不能因此把合法的 `"true"` 判为非法**——要么按 schema 宽松放行, +> 要么让 `getBool` 的宽松行为成为 schema 的一致要求。**默认取后者**: +> schema 声明为可接受的形式,宽松解析保持现状。 + +--- + +## 5. 条件表达式 + +### 5.1 分级 [已定:本次做 L1+L2] + +| 级别 | 能力 | 依赖 | 本次 | +| --- | --- | --- | --- | +| **L1** | `$args.host != ""`、`$args.summary contains "err"`、数值/布尔比较 | 无 | ✅ | +| **L2** | `$args.result.count > 0`、`$args.result.flag` | 依赖 §4.3 保留结构化值 | ✅ | +| **L3** | `&&` / `\|\|` / `!` 组合、跨变量比较 | 需表达式引擎 | ❌ 后续 | + +仓内**无任何表达式引擎**(`go/parser` / cel-go / expr 均不存在)。L3 需要新依赖, +本次不引。 + +### 5.2 渲染规则 [已定] + +`interface{}` → 供模型阅读的文本: + +| 类型 | 渲染 | +| --- | --- | +| `string` / `number` / `bool` | 原样 | +| 对象 / 数组 | **紧凑 JSON**(`json.Marshal`) | +| `nil` | 空串 | + +**绝不用 `fmt.Sprintf("%v")`**:那会产出 `map[status:sent id:123]` 这种 Go 语法垃圾 +(`toolcall.go` 结尾正处于该形态),模型无从下手。`output.go` 里"成功回执只返回 `ok`" +的既有做法说明这个方向已被验证过。 + +--- + +## 6. 并行执行 + +### 6.1 并发安全声明 [已定] + +`ToolDef` **新增一个 bool**(零值 `false` = 不可并行 = 现状行为): + +```go +ParallelSafe bool `json:"parallel_safe,omitempty"` +``` + +**零值取"安全"的反面**是刻意的:存量插件不改一行就得到保守行为, +不会因为升级被意外并发。声明它是**责任**而非特权。 + +### 6.2 执行模型 [已定] + +- 一次 LLM 响应里的多个 tool_call:`parallel_safe` 全为真 ⇒ 并行,否则整批串行。 +- 同一 `output_send__<通道>` 的多次发送**始终保序**(用户可见消息顺序敏感)。 +- 消息落法:一个 `assistant` 消息携带**全部** tool_calls,后接 N 个 `tool` 消息, + **按 `index` 排序**。 + +### 6.3 顺带修掉的现存 bug [已定] + +`flushToolCall` 用 `for idx := range accs` 遍历 map 触发 flush, +**Go map 迭代顺序随机** ⇒ 同一批并行 tool_call 的执行顺序每次运行都可能不同。 +实测 8 次有 1 次得到 `[3 4 0 1 2]`。 + +现有测试 `TestAccumulateStreamParallelToolCallsByIndex` 用 `map[string]string` 累加比对, +**恰好绕过了顺序**,所以没测出来。改为按 index 排序即可。 + +### 6.4 两处必须解开的结构性耦合 + +| 位置 | 现状 | 改法 | +| --- | --- | --- | +| `f.StageCtx` | 单槽,每工具覆写 | 每工具独立 `StageContext`(`Extra` 的通道信息复制给每个) | +| `io.ConsumeToolBlocks` | IOManager 级单队列 | per-call 取走 | + +`ConsumeToolBlocks` 是最硬的一处:多模态插件在 3 处调用 `SetToolBlocks` +(`multimodal/plugin.go:136,246,320`),它是**全局单槽**。并发下后执行者会抢走 +前者的媒体,挂到错误的 tool 消息上——直接破坏 `task.go:818` 那条花了三轮实测 +才定下的结论。 + +**有利条件**(已核实): + +- `StageContext` 自带 `sync.RWMutex`(SDK `plugin.go:209-213`),本就为并发 handler 而生; +- `StageHost.RunStage` **内部已并行**执行各 handler(`stages.go:180-197`); +- `StageHost.ExecuteTool` 在锁**外**调 handler(`stages.go:92-94`),本身并发安全; +- SDK 是 `replace` 到本地目录(`go.mod:46`),改动不涉及跨仓协调。 + +--- + +### 6.5 提示词契约:执行顺序是模型可见语义的一部分 [已定] + +并行化**静默改变**了模型可见的契约:同一轮回复里的多个 tool_call,此前是**依次执行**, +改造后默认**并行**。而模型很可能已把「同轮多个 tool_call = 依次执行」内化。 + +**核实到的现状**:提示词对执行顺序**完全无表述**(`tooldefs.go` 的 `buildSystemPrompt` +全篇无「并行/串行/parallel/serial」字样)。唯一提到「并行」的是 `spawn_child` 的工具描述 +(`tooldefs.go:466`),它讲的是**模型该怎么做**(多个子任务应一次发出), +而非**内核会怎么跑**——恰好是当前落空的那一环。 + +#### 顺序约束(硬) + +**提示词改动绝不能先于并行执行落地**。反序(先改提示词说「默认并行」, +内核仍串行)会让提示词**对模型说谎**:模型据「并发执行」推断安全性而写出真正依赖顺序的调用。 +宁可晚改,不可错改。 + +#### 提示词要表达的四件事 + +| 要点 | 内容 | +| --- | --- | +| **默认并行** | 同一轮发出的多个 tool_call 会**同时执行** | +| **不可依赖顺序** | 不要用「第 1 个的输出当第 2 个的输入」——那必须分两轮 | +| **例外:同通道输出** | 多次 `output_send__<同一通道>` 会**保序**执行(用户可见顺序敏感) | +| **例外:非并发安全工具** | 写类工具(记忆/知识写入)不会与他人并发 | + +#### 必须改掉的旧表述 [已定] + +`spawn_child` 描述里那句「应并行 spawn 多个子 Agent,不要自己串行逐个执行」, +在改造后应改为**机制性表述**——因为它原先依赖的「内核会并行」当时并不存在, +模型只是被建议这么做、却拿到串行执行。改造后这句才真正成立。 + +### 6.6 可观测性缺口(提示词的隐含前提)[已定] + +模型无法从工具结果得知「本批实际是并发还是串行」。因此: + +- `EventToolCall` 增加 `parallel: bool` 字段(WebUI 侧可据此渲染批内分组); +- 工具结果回填顺序**按 index 升序**(§6.2),使模型读到的上下文顺序与执行顺序一致, + 避免「结果顺序 ≠ 执行顺序」诱导出错误的因果推断。 + +> 现状:仓内**无任何测试直接驱动** `PendingTools` / `ToolIdx`(多 tool_call 批内路径), +> 并行化前必须先补上这个空白,否则批内逻辑无判据可依。 + +## 7. 工具序列(**全部由插件持有**) + +> ⚠️ **边界声明**:本节描述的能力**一律由 `seq` 插件实现**, +> **内核只提供并行化这一项基础设施**(§6)。 +> 内核**不含**任何序列概念:无 group、无变量、无条件求值、无 `seq_*` 工具、无 AST。 +> +> 这么划界的原因(已核实):插件执行工具走 +> `ToolAPI.ExecuteTool`(`internal/sdk/tool_impl.go:38`)→ +> `StageHost.ExecuteTool` / `IOManager.ExecuteTool`, +> 这条路**已经在内核之外**,且**已在阶段 2 的并行执行面内**。 +> 插件持有 `RegisterTool`(`sdk/plugin.go:499`)与 `ToolAPI`, +> 足以自建完整的 group/变量/条件/调用图 —— 无需内核开任何新接口。 +> +> ⚠️ **由此产生的一条硬约束**:`ToolAPI.ExecuteTool` 这条路**不经过** +> `executeToolCallInner`,因此**缺失四道内核处理**,插件必须自行处理或由内核补齐: +> +> | 缺失项 | 位置 | 后果 | +> | --- | --- | --- | +> | **设备授权闸** | `toolcall.go:116` | 序列可指挥**任意**设备 ⇒ 授权后门 | +> | **超时** | `toolcall.go` 60s | 单工具可无限期挂住 | +> | **`__arg_error` 短路** | `toolcall.go:52` | 坏参数照常下发 | +> | **参数预校验** | 待建于阶段 1 | 无 schema 校验 | +> +> 前两项**必须**解决(安全与可用性),后两项可由插件按需自建。 + + + +### 7.1 序列文件格式 + +**插件保存的是从文本文件解析出的 AST;执行时直接按 AST 执行,不再重新解析文本。** +这意味着:文件里的 `when` / `as` 等声明在 `seq_create` 时即已完成解析与**静态校验**, +`seq_run` 阶段不重复校验、不重新解析。 + +格式为 **JSON**(显式花括号,不用缩进),理由按重要性排列: + +1. **缩进不是可靠的层级载体**。YAML 的结构完全依赖缩进,而缩进对「模型写纯文本」 + 这一场景并不稳定。⚠️ **实测(`yaml.v3` v3.0.1)**:少缩进一行**不报错**, + 而是让该 group 的 `tools` **静默脱落** —— 得到一份**合法但语义不同**的序列。 + 花括号是**显式闭合**的,删一个 `}` 会硬解析失败,层级不会被静默改变。 +2. **错字必须硬失败**。⚠️ **实测**:YAML 对未知键(`paralell: true`)**静默忽略**、 + 取默认值;JSON 开 `DisallowUnknownFields` 则直接报 `unknown field "paralell"`。 + 仓内已有这个教训的记录(`internal/plugin/manifest.go:38`: + 「本仓无 DisallowUnknownFields」而不得不**加字段**来绕开)。 + ⇒ 序列里拼错 `parallel` 会**静默**退回串行,而模型毫不知情。 +3. **它就是模型每天在写的格式**。全仓的工具参数、工具定义、事件载荷、SSE 报文 + **全是 JSON**(`get_plugin_tools` 更是直接把 `json.Marshal` 的结果喂给模型)。 + 让模型为序列换一门语法,不产生任何收益。 +4. `encoding/json` 是标准库,**零新依赖**(`yaml.v3` 仅 `cmd/waiter` / `cmd/homed` 使用)。 + +```json +{ + "name": "巡检三节点", + "description": "拉取三台节点状态,异常时展开", + "groups": [ + { + "name": "拉取单台", + "description": "拉取一台节点的 uptime 与负载", + "in": { "host": "string", "verbose": "bool" }, + "out": { "summary": "string", "load": "string" }, + "when": "$args.verbose == true", + "tools": "{tool:cmd_run,args:{command:\"ssh $args.host uptime\"},as:summary} ; {tool:cmd_run,args:{command:\"ssh $args.host top -bn1\"},as:load} ;" + }, + { + "name": "巡检全部", + "description": "对三台节点并行调用「拉取单台」", + "in": { "hosts": "array" }, + "out": { "reports": "array", "errors": "array" }, + "parallel": true, + "tools": "{tool:seq_call,args:{group:\"拉取单台\",args:{host:$args.hosts[0]}},as:reports} ; {tool:seq_call,args:{group:\"拉取单台\",args:{host:$args.hosts[1]}},as:reports} ;" + } + ] +} +``` + +#### 三个核心机制 + +| 机制 | 形式 | 语义 | +| --- | --- | --- | +| **声明** | `name` / `in` / `out` | group 拥有**独立签名**,可脱离所在序列被调用 | +| **入参** | `$args.` | 只读**本 group 的入参**,不依赖外部变量 | +| **出参** | `as:` | 写入本 group 的 `out` 声明槽 | + +**`$0` / `$1` 自动编号取消** [已定]。group 既然是带签名的可调用单元, +`as` 目标就必须是**声明过的具名槽**——否则 `as:summary` 写错(声明里没有 +`summary`)在自动编号体系里是**发现不了的**,而在具名体系里是**建组时报错**。 + +#### 具名槽带来的编译期检查 + +| 约束 | 校验时机 | +| --- | --- | +| `as:X` 但 `out` 未声明 `X` | **建组时报错**(不需要运行) | +| `$args.X` 但 `in` 未声明 `X` | **建组时报错** | +| `out` 声明了 `X` 但无任何工具 `as:X` | 警告(非错误:可能由 `when` 跳过的分支产出) | +| `in` 声明了 `X` 但从未使用 | 警告(非错误:便于演进) | + +> 这比原设计的 `$N` 体系强一个量级:原设计的“变量缺失”只能在**建整个序列时** +> 跨组推演,而具名槽是**逐组独立**校验的——单组可以脱离序列单独验证。 + +#### 组间与组内的可见性 + +| 关系 | 可见性 | +| --- | --- | +| 同一 group 内的工具 | **互相不可见**(并行 ⇒ 无确定写序) | +| group 读**自己的** `$args.*` | ✅(唯一合法入参来源) | +| group 读**别组**的 `out` | ❌ **不允许**——这正是“声明后可按名调用”的意义 | +| 调用方传参 | 调用方自己的 `out` 槽(`as:reports` 可重复写 ⇒ 累加,见下) | + +**`when` 仍只能读 `$args.*`(及组内已有变量)**,不能读别组输出—— +否则 group 就无法独立,签名失去意义。组内若需要“上一步结果”, +仍用 `as:` 写入具名槽(组内**串行**时可见)或直接分轮次。 + +#### `as` 重复的语义 [已定] + +组内并行时,同名 `as` 是数据竞争 ⇒ **建组时报错**。因此**向调用方 `out` 槽 +累加多个值**必须走另一个出口:`out` 声明为 `array` 时,**同名 `as` 允许**, +在组屏障处**按 tool 顺序确定性追加**(而非报错)。 + +这解决了上例里 `as:reports` 出现两次的需求:**声明为 `array` 即允许重复写**。 +非 array 槽重复写 ⇒ 报错。 + +#### 持久化的是 AST,不是文本 + +- `seq_create` 解析文本 → 校验 → **存 AST**(`data_dir/sequences/.json`) +- `seq_run` 读 AST 直接执行;**不碰原始文本** +- ⇒ 文本里的注释/空白/引号形式在 AST 层面已消失,**不引入执行期差异** + +**组内 `tools` 是一个字符串**:每个 tool 一个完整的 `{…}` 结构,以 `;` 分隔、 +**每个 tool 后必须跟 `;`**(含最后一个)。 + +#### 为什么用 `;` 分隔而不是 JSON 数组 + +`tools` 写成字符串而非 `[{…},{…}]`,是为了**让格式错误可被立即发现**, +而不是静默丢一个工具。但 `;` 本身**只有在 tool 被 `{…}` 包裹时才是安全的**—— +这一点是本设计的关键,不加包裹会立刻出问题(见下)。 + +#### 切分规则(已实测) + +`;` **仅在 brace/bracket 深度为 0 且不在字符串内**时才是分隔符: + +```go +// 切分要点(逐字符状态机): +// - 进入字符串时(未转义的 ")深度不再变化、';' 不切分 +// - 转义 \x 消耗下一字符 +// - depth 回到 0 的瞬间,检查其后是否紧跟 ';' —— 否则报错 +``` + +⚠️ **结构包裹是必需的,不是装饰**。仓内**线上实测的真实模型输出**里, +`command` 值**大量含分号**(取自 `stream_accumulate_test.go` 的日志原文): + +```json +{"command": "echo \"=== raw (471B) ===\"; cat /tmp/x.json; echo; echo \"=== ps ===\"; ps aux | grep -c \"[r]un.py\"; echo \"=== log ===\"; cat /tmp/run.log", "timeout": "30s"} +``` + +若裸切分,这一行会被切成 **6 个 tool**。被 `{…}` 包裹后,命令里的分号在 +**深度 > 0 或字符串内**,切分器不碰它 ⇒ 无歧义。**已实测:含 3 个与 5 个分号的 +fixture 均正确保持为 1 个 tool。** + +#### 必测的失败模式(均已实测能报错,不得静默吞工具) + +| 输入 | 结果 | +| --- | --- | +| `{…} ; ; {…} ;` | 报错:空的 tool(连续分号) | +| `{…} {…} ;` | 报错:缺少 `;`,**否则下一个 tool 被静默吞掉** | +| `{…}` (末尾无 `;`) | 报错:末尾缺少 `;` | +| `{…, "paralell":true} ;` | 报错:`unknown field "paralell"`(`DisallowUnknownFields`) | +| `{tool:"b"} ;` | 报错:JSON 语法错误 | +| 结构未闭合 / 字符串未闭合 | 报错,带**字符位置** | + +> ⚠️ **“静默吞掉一个 tool”比“报格式错”危险得多**——序列少执行一步, +> 模型却以为跑完了。这正是本仓反复吃亏的那类失败 +> (`20s` 少引号 → 静默降级 → cmd_run 失败率 34%)。因此上述每个失败模式 +> **都必须硬报错**,不得以“宽容解析”代替。 + +**路径校验**:`seq_create(file=...)` 的路径必须复用 `files` 插件的目录逃逸检查 +(`files/plugin.go:205-215`),否则模型可写任意路径。 + +### 7.2 Group 的完整字段 + +| 字段 | 必填 | 默认 | 语义 | +| --- | --- | --- | --- | +| `name` | ✅ | — | **签名名**。全局唯一、可被 `seq_call` 按名调用 | +| `description` | | — | 说明。`seq_list` 与工具目录会展示给模型 | +| `in` | | `{}` | 入参声明:`{键: 类型}`。组内用 `$args.<键>` 读取 | +| `out` | | `{}` | 出参声明:`{键: 类型}`。组内用 `as:<键>` 写入 | +| `tools` | ✅ | — | **字符串**:`;` 分隔的若干 `{…}` 结构,每个 tool 后必跟 `;`(见 §7.1) | +| `when` | | `true` | 条件屏障(L1+L2),**只可读 `$args.*`** | +| `parallel` | | `true` | 置 `false` 时组内退化为串行 | +| `timeout` | | `30s` | 本组墙钟上限 | +| `on_error` | | `abort` | `abort` / `continue` / `retry` | +| `retries` | | `0` | 仅 `on_error: retry` 时有意义 | + +**枚举字段一律开 `DisallowUnknownFields` + 显式枚举校验**:`on_error` / `retries` +写错时要报「无效取值 + 合法枚举列表」,而不是当默认值蒙过去。 + +传参形态(`seq_create(groups=[{…, "tools": "{…} ; {…} ;"}])`)与文件形态 +**产出同一 AST**,`tools` 在两侧都是那个 `;` 分隔的**字符串**。 +短序列用传参、长序列写文件——因为长参数会被 `max_tokens=4096` 截断并触发 +`__arg_error`,而"写文件"正是那条指引所说的"拆分手段"。 + +**内嵌 tool 的字段**: + +| 字段 | 必填 | 语义 | +| --- | --- | --- | +| `tool` | ✅ | 工具名(如 `cmd_run`;调用本序列内的组写 `seq_call`) | +| `args` | | 参数对象,支持 `$args.*` 引用 | +| `as` | | 写入本 group 的 `out` 具名槽;非 `array` 槽重复写 ⇒ 报错 | + +### 7.3 必定的校验规则 [已定] + +**建组时(静态,全部在 `seq_create` 完成)** + +1. `as:X` 但 `out` 未声明 `X` ⇒ 报错(具名槽的存在价值就在这条) +2. `$args.X` 但 `in` 未声明 `X` ⇒ 报错 +3. 非 `array` 的 `out` 槽被同名 `as` 写多次 ⇒ 报错(组内并行 ⇒ 数据竞争) +4. `parallel: true` 且组内含非 `parallel_safe` 工具 ⇒ 报错并指名工具 +5. `seq_call` 引用的组名不存在 ⇒ 报错(**跨组引用需全局校验**) +6. `in` 声明的键从未被使用 / `out` 声明的键无人写 ⇒ **警告**,不阻断 + +**组内运行时** + +- 组内工具**互相不可见**(并行 ⇒ 无确定写序);`when` 只读 `$args.*` +- `array` 槽的同名 `as` 在组屏障处**按 tool 顺序确定性追加** + +### 7.4 变量可见性 + +| 关系 | 可见性 | +| --- | --- | +| 同一 group 内的工具 | **互相不可见**(并行 ⇒ 无确定写序) | +| group 读**自己的** `$args.*` | ✅(唯一合法入参来源) | +| group 读**别组**的 `out` | ❌ **不允许**——这正是「声明后可按名调用」的意义 | +| 调用方传参 | 调用方自己的 `out` 槽(`array` 槽可累加,见 §7.3 规则 3) | + +**`when` 也只能读 `$args.*`**:否则 group 无法脱离序列独立,签名失去意义。 +组内若需要“上一步结果”,仍用 `as:` 写入具名槽。 + +> 这比原 `$N` 体系强一个量级:原设计的“变量缺失”只能在**建整个序列时跨组推演**, +> 而具名槽是**逐组独立**校验的——单组可脱离序列单独验证。 + +### 7.5 条件为假时 [已定] + +整组跳过,**`out` 各槽不赋值**。因为具名槽是**逐组声明**的, +构建期即可发现“该组不产出 X”⇒ 报错。 + +--- + +## 8. 六个工具 + +| 工具 | 参数 | 行为 | +| --- | --- | --- | +| `seq_create` | `name` / `groups` / `file` / `description` | 二选一(`groups` 传参 或 `file` 加载)。**静态校验全在这里** | +| `seq_list` | — | 列出全部序列:名称、组数、工具数、描述 | +| `seq_delete` | `name` | 删除 | +| `seq_run` | `name` / `args?` | 依序执行各 group(顶层 `groups` 数组的顺序即执行序);返回逐组摘要 + 最终 `out` 快照 | +| `seq_call` | `target` / `args` | **按名调用**:`target` 为组名(限本序列内)或 `#序列名`(跨序列)。可被任意 group 的 `tools` 内嵌使用;亦可被模型直接调用 | +| `seq_when_call` | `target` / `args` / `when` | **条件按名调用**:`when` 表达式为真才执行,否则跳过(不产出任何槽) | + +### 8.1 复用现有基础设施,而非重建 [已定] + +序列**必须复用内核已有的执行基础设施**,不重建一套。逐项核实结论: + +| 缺失项 | 需 agent 身份? | 复用方式 | +| --- | --- | --- | +| **超时** | ❌ 不需要 | `executeToolCall` 的 60s 外壳(`toolcall.go:33-44`)是 goroutine + `select` + `time.After`,**纯逻辑**,照抄即可 | +| **`__arg_error` 短路** | ❌ 不需要 | 判据是 `tc.Arguments["__arg_error"]`(`toolcall.go:52`)——**纯数据**,插件查同一个键 | +| **参数预校验** | ❌ 不需要 | 阶段 1 的校验器是**纯函数**(读 schema,不碰 agent 状态) | +| **设备授权闸** | ✅ **需要** | 判据是 `a.allowedOutputs`(`agent.go:96`,**per-agent 私有**) | + +#### 为什么只有授权闸需要 agent 身份 [关键事实] + +核实到真实拓扑(这修正了「插件拿不到身份」的笼统说法): + +``` +StageHost(全局单例,bootstrap.go:470 建一次) + ├── 根 agent → ToolAPI → stageHost.ExecuteTool + └── 驻留子 × N → ToolAPI → stageHost.ExecuteTool ← 同一个 handler +``` + +- `Registry` **完全没有 agent 概念**(`agentID`/`AgentID` grep 为空); +- 驻留子创建时(`resident.go:186`)**不传 `PluginReg`**,工具 handler 用父注册的; +- `StageHost.ExecuteTool(name, args)` 签名里**没有 agent 参数**。 + +⇒ **授权从来就不在这一层做。** 它只存在于 `executeToolCallInner` +(`toolcall.go:116`),即**「agent 收到模型 tool_call」这条路径上**。 + +⚠️ **由此得出的真正的缺口**(比「插件缺身份」更准确): +**任何经 `ToolAPI` 的调用都绕过了 agent 私有闸**——不只是序列。 +将来若有别的插件走 `ToolAPI` 调设备工具,会踩同一个坑。 + +#### 补法 [已定] + +给 `ToolAPI` 增**一个**方法(`internal/sdk/tool.go`,**不在 SDK 冻结范围内**—— +`third_party` 的 `ToolAPI` grep 为 0,故不触发 D3 的中版本跃迁): + +```go +// CanUse 报告「执行该工具是否被授权」(含设备授权闸)。 +// 注意:ToolAPI 不持有 agent 身份,实现方需由内核在**收到 seq_run 时** +// 把当前 agent 注入(如 context 携带或 handle 绑定)。 +// 零值实现返回 true,使未实现者行为不变。 +CanUse(ctx context.Context, toolName string, args map[string]interface{}) bool +``` + +前置配套:内核侧在**调用插件工具的入口**带上 agent 上下文。 +这是**内核与插件边界的实质变更**,故单列为 D4(§10),不在本次强推。 + +### 8.2 动态注册:工具不存在是常态 [已定] + +⚠️ **前提更正**:工具是**动态注册**的,`buildToolDefs` 每轮重建、`plgreload` 即时生效。 +因此「目标不存在」是**常态而非异常边界**——**存在性是运行期属性,不是编译期属性**。 + +已核实的三个动态形态: + +| # | 形态 | 依据 | 危险度 | +| --- | --- | --- | --- | +| 1 | 显式摘除 | `detachPlugin`(`registry.go:689`)在 Disable/Reload/Remove/StopAndUnload 摘除**全部**工具 | 预期内 | +| 2 | 崩溃摘除 | 子进程崩溃 → `plugin_health` 记录并安排重载,工具消失 | 预期内 | +| 3 | ⚠️ **换实现、名字不变** | `plgreload` 后同名工具重新注册,**实现已是另一个** | **最隐蔽** | + +第 3 条比「不存在」更危险:**调用会成功,但行为可能已变**。 +⇒ 序列**不得缓存 `ToolDef` 作为权威**,每次执行前重新查。 + +#### 规则 1:存在性校验是「提示」而非「前提」 + +建组时校验目标存在**仍有价值**(尽早报、少一次失败往返), +但**不得**据此认为运行期一定存在。§7.3 规则 5 的措辞据此理解。 + +#### 规则 2:新增 group 级 `missing` 策略 [已定] + +| 值 | 行为 | +| --- | --- | +| `fail`(默认) | 该 tool 判失败,按 `on_error` 处理 | +| `skip` | 跳过该 tool,**不产出槽**,继续 | +| `degrade` | 工具缺失时写入声明的兜底值 | + +**为什么是 group 级而非 tool 级**:同一组内的工具往往来自同一插件, +而动态性是**成组**的(插件挂掉即一批全没)。 +tool 级会让模型为同批工具重复填写。 + +⚠️ `skip` 时**不产出槽** ⇒ 依赖该槽的 `when`/模板必须能应对「槽缺失」。 +这与 §7.5「条件为假」是**同一种情形**,两条路径**统一处理**(不得各写一套)。 + +#### 规则 3:必须区分「不存在」与「执行失败」 [硬要求] + +动态注册下,`on_error` 面对两种情况必须**做不同的事**: + +| 情况 | 语义 | 处置 | +| --- | --- | --- | +| 工具不存在 | 插件大概挂了 | `missing` 策略 + **如实报缺哪个工具** | +| 工具执行失败 | 单次业务失败 | `retry` 有意义 | + +而现状**做不到**:`IOManager` 的 parent 兜底(`channel.go:655-660`) + +```go +if ret, err := parent.ExecuteTool(name, args); err == nil { + return ret, nil +} +// 父的 err 被丢弃 ⇒ 落到 return "tool X not found" +``` + +驻留子的 `childIO` 查不到时会向父兜底;若父**执行真失败**(设备离线、 +插件崩溃),该错误被丢弃,**误报为「工具不存在」**。 +⇒ 后果放大:本该 `retry` 的失败被判为「工具没了」,整组被跳过。 + +#### 规则 4:错误判别不得依赖字符串匹配 + +内核用 `strings.Contains(err, "not found in any plugin")` 判别 +(`toolcall.go:104`)——这是**约定**不是契约:插件错误文案若恰好含该子串 +即被误判,并错误 fallback 到 io。 +⇒ 引入 `ErrToolNotFound` 哨兵(`errors.Is` 判别),见 **D5**。 +本次**内核侧一并实施**(见 §4.5)。 + +#### 规则 5:`target` 形态非法 + +`seq_call` 的 `target` 为空、或既非组名也不以 `#` 开头 ⇒ **建组时报错**, +不进入运行期。(这是**形态**非法,与「目标不存在」是两回事。) + +### 8.3 跨序列调用:环检测与深度上界 [已定] + +按名调用序列让**序列之间形成调用图**,必须先解决两个硬问题。 + +**问题一:无限递归。** `#A` 的某组 `seq_call` 了 `#B`,而 `#B` 又回调 `#A` +⇒ 无限执行,且**每次都真的在调工具**(不是空转)。这不是理论风险: +`spawn_child` 之所以要硬编码黑名单(`spawn.go:117`),正是因为同类递归会打穿资源。 + +**处理**[已定]: + +| 约束 | 取值 | 依据 | +| --- | --- | --- | +| **环检测** | 建序列时对**跨序列调用图**做 DFS,检出环即报错并给出**环路径**(`#A → #B → #A`) | 构建期拒绝,优于运行期栈溢出 | +| **深度上界** | 4 层 | 沿用 `MaxInterruptFrames` 的惯例:**结构上界,不是配置项**(`scheduler.go:271`) | +| **命中运行时** | 报错并终止该分支(`on_error` 仍可接管),**不静默截断** | 静默截断会让模型以为跑完了 | + +同序列内的组间调用**不计入深度**(那是普通嵌套,不构成跨序列递归), +但仍受 §7.3 规则 5(目标必须存在)约束。 + +**问题二:授权面放大。** `#A` 能调到 `#B` 意味着**两个序列的工具面被合起来**。 +若 `#B` 含有 `#A` 无权使用的设备工具,则 `#A` 借道获得了它。 +⚠️ **此要求当前无法满足**:`ToolAPI` 不持有 agent 身份(§8.1), +序列**拿不到调用方的 `allowedOutputs`**,因而无法自行复现内核的授权判定。 +⇒ 在 **D4** 解决前,跨序列调用是**授权面放大**的既成缺口, +不得声称「已按调用方授权过滤」。此约束是 D4 必须解决的理由之一。 + +### 8.4 条件调用:`when` 求值时机与失败语义 + +`seq_when_call` 让**调用点本身**带条件。求值时机固定为:**进入前**,在调用方的 +`when` 之后、构造子调用上下文之前。 + +| 情况 | 行为 | +| --- | --- | +| `when` 为真 | 执行子调用,产出 `as:` 声明的槽 | +| `when` 为假 | **跳过**,不产出任何槽(与 §7.5 一致) | +| `when` 表达式本身**求值出错** | **报错**,不是「当作假」 | + +⚠️ 最后一条是关键:把「求值失败」降级成「条件为假」= 序列安静地少做一步, +而模型以为跑完了——与 §7.1 的「静默吞 tool」同族。**求值失败必须可见。** + +**与 `when` 字段的关系**:`when` 管「本组要不要跑」,`seq_when_call` 管 +「这次调用要不要发生」。两者**正交**,不互相替代。 + + +--- + +### 8.5 持久化落点 + +`data_dir/sequences/.json`,随 `data_dir` 迁移。`seq_*` 工具走 +`skillmgr` 的注册模式(`tools.go:13`)。 + +--- + +## 9. 实现顺序 + +### 9.1 内核主线(到并行化为止) + +| 阶段 | 内容 | 依赖 | 价值 | +| --- | --- | --- | --- | +| **0** | 修 map 迭代顺序(§6.3) | — | ✅ 已完成 | +| **0.5** | 补多 tool_call 批内路径的判据(§6.6 注) | — | 并行化的前置:批内逻辑目前无判据 | +| **1** | 结果契约 + 参数预校验(§4) | — | 独立价值:消灭 C 类浪费,模型直接看到真因 | +| **2** | **并行执行层(§6)** | 1 | **内核侧唯一交付物**;插件并行的地基 | +| **2.5** | **提示词改为「默认并行」**(§6.5) | 2 | ⚠️ 必须在并行落地**之后**,否则提示词说谎 | + +**内核主线到此结束。** §7/§8 的序列能力**不属于内核**,由 `seq` 插件实现。 + +**阶段 1 先于 2** 的理由:并行执行需要**诚实的 `Success`** 与**结构化值** +(§1 的 A/B/D)来判断结果与渲染;跳过它做并行,插件侧无从判断成败。 + +### 9.2 插件线(依赖内核主线完成) + +| 阶段 | 内容 | 依赖 | 归属 | +| --- | --- | --- | --- | +| **P1** | `seq` 插件骨架:AST 解析/静态校验/持久化(§7.1–7.3) | 内核 0.5 | 插件 | +| **P2** | 组内并行执行 + 具名槽 + 条件求值(§7.4/§7.5/§5) | 内核 2、P1 | 插件 | +| **P3** | 六个 `seq_*` 工具 + 授权闸自建(§8) | P2 | 插件 | + +**插件线可以复用内核并行面**,但**不要求内核新增任何接口**。 +若日后发现必须由内核代做(如统一授权闸),那是一次**单独的 SDK 扩展讨论**, +不在本设计范围内。 +## 10. 待定项 + +| 编号 | 问题 | 建议 | +| --- | --- | --- | +| D1 | 序列的 group 是否需要**嵌套**(group 套 group)? | 不需要。线性分层已足够;嵌套会让「可见性」规则复杂化到不可解释 | +| D2 | `parallel: false` 是否构成授权/安全后门? | 见下 | +| D3 | SDK 版本号 | 落在 1.4.0(已定,见下) | +| **D4** | **内核↔插件边界:agent 身份如何传到 `ToolAPI`**(§8.1) | `ToolAPI` 不持有 agent 身份,设备授权闸无法在 `ToolAPI` 路径生效。需给内核↔插件边界加 agent 上下文(`CanUse(ctx, …)` 或 handle 绑定)。**不解决则任何经 `ToolAPI` 的调用都绕过授权**,不只是序列 | +| **D5** | **`ErrToolNotFound` 哨兵错误**(§8.2 规则 2) | 建议内核引入,取代 `strings.Contains` 判别;本次不改,序列侧标注为待收敛 | + + +### D2 的取舍 [待定] + +`parallel: false` 是必要的逃生舱(两次同通道 `output_send__` 必须保序), +但它**绕过了 `parallel_safe` 闸门**。两条路: + +- **宽松**:`parallel: false` 无条件放行。灵活,但序列作者可把任意工具塞进串行组, + 实质上任意组合都合法——闸门形同虚设。 +- **严格**:非 `parallel_safe` 工具必须显式标 `unsafe_serial: true` 才能进组。 + 闸门有效,代价是模型多写一个字段。 + +**建议取严格**。理由:§4.2 的 `Success` 已经教给我们一件事—— +**"默认宽松 + 事后加闸"必然漏**(现有 `Success: true` 恒真就是活证据)。 + +### D3 的判定 [已定:走 1.4.0,不需新开 1.5.0] + +本次对 SDK 的改动是 `ToolDef.ParallelSafe` 与 `ToolError`,**全部是新增,无签名变更**。 +按 `docs/git-branching.md` §七.1 的先例(1.1.0 / 1.2.0 均为"全部新增,无签名变更"), +属中版本跃迁。 + +**落在 1.4.0 而非 1.5.0**,依据是核实到的事实: + +- 核心 `meta.Version = "1.4.0"`(`internal/meta/meta.go:33`), + 且注释明写"main 上此值始终是**下一个未发布中版本**"; +- 仓内**无 `release/v1.4.x` 分支**,最新 tag 是 `v1.3.12` + ⇒ 1.4.0 这一中版本**尚未发布**,正是承接本次改动的那个版本; +- SDK `meta.Version` 同为 `1.4.0`(`third_party/homeagent-sdk/meta/meta.go`), + 两仓中版本已对齐 ⇒ **本次无需再次对齐动作**。 + +⇒ 动作只是把 `SDKCompatibleVersion`(`internal/meta/meta.go:54`)从 `1.3.0` +推到 `1.4.0`,表示本内核实现了 SDK 1.4.0 的全部新增面。 + +**存量插件不需改一行、不需重编**(新增方法/字段由**插件调用、内核实现**, +不调就不受影响——这是 1.1.0 / 1.2.0 / 1.3.0 三次跃迁共同的结论)。 diff --git a/docs/zh/toolcall-parallel-execution-plan.md b/docs/zh/toolcall-parallel-execution-plan.md new file mode 100644 index 0000000..3f1acb1 --- /dev/null +++ b/docs/zh/toolcall-parallel-execution-plan.md @@ -0,0 +1,239 @@ +# 工具调用并行化改造:执行计划 + +> **设计依据**:`docs/zh/toolcall-contract-and-sequence-design.md`(本文只讲**怎么一步步做**, +> 设计取舍与实证依据在那份文档里,不重复)。 +> +> **状态**:阶段 0 已完成。阶段 0.5 起为待办。 +> **纪律**:每个阶段的「判据」必须**先写且必须能失败**,再改实现; +> 判据通过前不进入下一阶段(见文末「阶段纪律」)。 + +--- + +## 阶段总览与依赖 + +| 阶段 | 内容 | 依赖 | 状态 | +| --- | --- | --- | --- | +| **0** | 修 map 迭代顺序(flush 乱序) | — | ✅ **已完成** | +| **0.2** | **工具错误类型化(已完成)** | — | ✅ **已完成** | +| **0.5** | 补多 tool_call 批内路径判据 | — | ⬜ 待办 | +| **1** | 结果契约 + 参数预校验 | — | ⬜ 待办 | +| **2** | 并行执行层 | 1 | ⬜ 待办 | +| **2.5** | 提示词改为「默认并行」 | 2 | ⬜ 待办 | +| **3** | 序列内核 | 2 | ⬜ 待办(设计已定,实现另议) | +| **4** | `seq_*` 插件 | 3 | ⬜ 待办(设计已定,实现另议) | + +**主线是 0.5 → 1 → 2 → 2.5**。3/4 属序列特性,主线完成后另立。 + +### ⚠️ 顺序不可调换的两处 + +- **2.5 必须在 2 之后**:先改提示词说「默认并行」而内核仍串行 = 提示词对模型说谎。 +- **1 必须在 2 之前**:并行化需要「诚实的 Success」与「结构化值」来判断结果与做条件; + 先并行后补契约,等于把两处改动叠在同一段逻辑上,回归时无法定位是哪一处引起。 + +--- + +## 阶段 0 ✅ 已完成 + +**问题**:`flushToolCall` 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化 +⇒ 同一批并行 tool_call 进入 `resp.ToolCalls` 的顺序**每次运行都可能不同**。 +对 `output_send__` 这类用户可见通道 ⇒ 分段消息到达顺序不可复现。 + +**改动**:`process.go` —— 抽出闭包 `flushAll`,收集 index 后 `sort.Ints` 再 flush。 +两个调用点(`ch` 关闭、`ctx.Done()`)统一走它。 + +**判据**:`stream_flush_order_test.go` —— 8 工具 × 200 轮,断言输出严格按投递顺序。 +**已做变异验证**:退回 map 遍历后判据于 round 0 即 FAIL(实际顺序 `[tool_02…tool_00 tool_01]`)。 +修复后 core 包全绿。 + +--- + +## 阶段 0.2 ✅ 工具错误类型化(已完成) + +**问题**(两条,第二个是静默 bug): + +1. 内核用 `strings.Contains(err, "not found in any plugin")` 判别「工具不存在」 + (原 `toolcall.go:104`)——**约定**不是契约。插件错误文案若含该子串即被误判。 +2. ⚠️ `IOManager` 向父兜底时**吞掉父的执行失败**(`channel.go:655-660`), + 误报为「工具不存在」。后果放大:设备离线这类**本该 retry** 的失败被判为 + 「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。 + +**改动**: +- `io/channel.go`:新增哨兵 `ErrToolNotFound` + `ToolNotFound(name)` + `IsToolNotFound(err)` + (沿用仓内 `ErrInputChannelUnknown` 的先例)。`IOManager.ExecuteTool` 的父兜底 + 改为**只传递「确实不存在」**,其余错误如实上抛。 +- `core/stages.go`:`StageHost` 的 not-found 改用 `agentIO.ToolNotFound`。 +- `core/toolcall.go`:字符串匹配 → `agentIO.IsToolNotFound`;「不存在」时给出 + **可执行**文案(提示 `get_plugin_tools` / `output_list_channels`), + 而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。 + +**判据**:`toolcall_error_test.go`(类型化 vs 诱饵子串、%w 穿透、执行期文案)+ +`channel_error_test.go`(父失败不吞、真的不存在仍可判别)。 + +**变异验证**:退回父兜底吞噬后,`TestExecuteTool_DoesNotSwallowParentFailureAsNotFound` +FAIL(`父的执行失败被误报为『工具不存在』`);修复后全绿。 + +⚠️ **过程中的一次自伤**:我先改了 `channel.go` 却漏了 `stages.go` 的 import, +导致整包 build 失败。已修。另:第一次写判据时只覆盖了类型化,**没覆盖父兜底吞噬**, +变异后仍绿 —— 说明「判据通过」不等于「修的东西被测到」。补了 `channel_error_test.go` 才闭合。 + +--- + +## 阶段 0.5 ⬜ 补批内路径判据 + +**为什么先做**:核实到**仓内没有任何测试直接驱动 `PendingTools` / `ToolIdx`**, +即「同一批多个 tool_call」这条路径**无判据可依**。阶段 2 要改的正是这段逻辑。 + +**产出**:`internal/agent/core/toolbatch_test.go`(新建) + +1. **批内全序列可观测**:构造一个假 provider,一轮返回 2 个 tool_call, + 断言 `f.ToolsUsed` 含两个、`f.ToolResults` 有两条、`f.Msgs` 里 tool_call_id 配对完整。 +2. **配对完整性**:断言每个 tool_call_id 都有且仅有一条 `role=tool` 消息。 + (这条是并行化的安全网——一旦落法改成「一个 assistant 带全部 tool_calls」, + 它能立刻发现配对被破坏。) +3. **`content_once` 语义**:`f.ContentOnce` 保证 assistant 文本只出现一次, + 在批内多工具下必须仍然成立。 + +**判据形态**:全部为**确定性**断言,不依赖 map 随机性(阶段 0 已把乱序消除)。 + +--- + +## 阶段 1 ⬜ 结果契约 + 参数预校验 + +对应设计文档 §4。**动机**:`Success` 硬编码 `true`(`task.go:757` 唯一赋值点)、 +`required` 无消费方、结果被降级为 `string`——这三项让阶段 2/3 都缺地基。 + +### 1a. SDK 侧新增(纯新增,无签名变更) + +`third_party/homeagent-sdk/sdk/plugin.go`: + +```go +// ToolError 描述失败原因。存在的理由:失败若只表达为文本,模型无法定位到字段, +// 只能原样重试(实测 cmd_run 失败率 34%~48% 源于同一成因)。 +type ToolError struct { + Field string `json:"field,omitempty"` + Reason string `json:"reason"` // required/type/unauthorized/timeout/not_found + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` +} +``` + +- `ToolDef` 增 `ParallelSafe bool`(零值 `false` = 不可并行 = 现状行为, + 刻意让存量插件升级后得到**保守**行为)。 +- 仓内 `internal/sdk/plugin.go` 补对应别名再导出。 + +### 1b. 诚实化 Success + +`Success = (err == nil) && !isToolError(result)`。 + +**判据必须同时覆盖新旧两种形态**:存量插件返回 `map[string]interface{}{"error": ...}` +(如 `files/plugin.go:228`)要判为失败,而返回普通 map/string 仍算成功。 +**漏了后者 = 升级会把存量插件的成功误判成失败**,这是本阶段最大的回归风险。 + +### 1c. 参数预校验 + +在 `executeToolCallInner`(`toolcall.go:47`)分派**之前**执行:逐项查 `required`、 +逐项查 `properties.type`。失败返回结构化 `ToolError`,不进分派。 + +⚠️ **`getBool`(`utils.go:26`)的注释记载 bool/string/float 三种都出现过** ⇒ +校验**不能**把合法的 `"true"` 判为非法。判据必须覆盖这一点。 + +### 1d. 保留结构化值 + +`executeToolCall` 内部保留 `rawResult interface{}`(不降级为 string), +渲染交给统一函数:**紧凑 JSON,禁止 `fmt.Sprintf("%v")`**(那会产出 +`map[status:sent id:123]` 这种模型读不懂的 Go 语法)。 + +**顺带修一个静默失效**:`task.go:771` 的 `ToolResults[0].Result.(string)` 断言 +几乎恒失败(插件返回的多是 map)⇒ `after_toolcall` 阶段插件对结构化结果的改写当前无效。 + +### 阶段 1 判据 + +- `toolerror_test.go`:`isToolError` 对新旧两种形态的判定(各 3 例:失败 map / + 成功 map / 成功 string) +- `argvalidate_test.go`:缺 required、类型不符、`"true"` 宽松放行 +- 回归:core 包全绿;**存量插件 smoke**(`internal/plugins` 不得新增 FAIL) + +--- + +## 阶段 2 ⬜ 并行执行层 + +对应设计文档 §6。三处结构性改动,**按耦合从松到紧**推进: + +### 2a. 消息落法(最松,先做) + +现状:每工具一对 `assistant` + `tool` 消息。 +改为:**一个** assistant 消息携带**全部** tool_calls,后接 N 条 `tool` 消息, +**按 index 升序**。 + +**独立价值**:这本身是协议上更正确的形态(现状的「N 个 assistant 各带 1 个 tool_call」 +不表达「这是一批」)。阶段 0.5 判据 2 在此直接生效。 + +### 2b. ConsumeToolBlocks 改 per-call(最硬的耦合) + +`io/channel.go:974` 现为 **IOManager 级单队列**(取走即清空)。 +多模态插件在 3 处调用 `SetToolBlocks`(`multimodal/plugin.go:136,246,320`)。 + +**并发下会抢走彼此的媒体** ⇒ 挂到错误的 tool 消息上 ⇒ 直接破坏 `task.go:818` +那条花了三轮实测才定下的结论(媒体必须走 user message、紧跟 toolMsg)。 + +改法:blocks 按 `call_id` 归档,`ConsumeToolBlocks(callID)` 按 id 取。 + +**判据**:`toolblocks_concurrent_test.go` —— 2 个工具各自注入媒体, +断言各自拿到**自己**的块(当前必失败:第二个抢走第一个的)。 + +### 2c. StageContext 拆 per-tool + +`f.StageCtx` 是**单槽**,每工具覆写(`task.go:697-698, 714, 755, 769-771`)。 +并发下 N 个 goroutine 同写一个 ctx = 数据竞争。 + +改法:每工具一份独立 `StageContext`。`Extra` 必须**逐份复制**—— +现载有 `input_source` / `output_channel` / `media_blocks` / `media_type` +(`task.go:371-375`),`stage.go:18` 依赖 `output_channel`。 + +**判据**:`-race` 下跑 2 工具并发批,断言无 race **且**两个 `before_toolcall` +handler 各自看到正确的 `ToolCalls[0].Name`。 + +### 2d. 批次调度与保序 + +- 全批 `ParallelSafe` ⇒ 并发;否则整批串行(**整批降级,不做部分并发**—— + 部分并发的收益不抵其不可预测性)。 +- **同 `output_send__<通道>` 多次发送保序**(用户可见顺序敏感)。 + +--- + +## 阶段 2.5 ⬜ 提示词改为「默认并行」 + +⚠️ **必须在阶段 2 落地之后**(见「顺序不可调换的两处」)。 + +对应设计文档 §6.5。`buildSystemPrompt`(`tooldefs.go`)新增一段,表达四件事: +默认并行 / 不可依赖顺序 / 例外:同通道输出保序 / 例外:写类工具不并发。 + +**同时必改**:`spawn_child` 描述里那句「应并行 spawn 多个子 Agent,不要自己串行逐个执行」 +(`tooldefs.go:466`)——它在改造前是**落空**的(模型照做,内核仍串行); +改造后应改为机制性表述。 + +**判据**:提示词内容断言(`prompt_contract_test.go`)——四要点各自在文本中命中; +且**负判据**:串行阶段的提示词**不含**「默认并行」字样,防止再次跑反顺序。 + +--- + +## 阶段 3 / 4 ⬜ 序列(设计已定,实现另议) + +设计见 `docs/zh/toolcall-contract-and-sequence-design.md` §7/§8。 +依赖阶段 2(组内并行)与阶段 1(结构化条件 + 诚实 Success)。 +**待定项 D2**(`parallel: false` 是否构成闸门后门)需先拍板。 + +--- + +## 阶段纪律 [沿用本仓既有教训] + +1. **判据先写,且必须能失败**。写完先跑一次确认 FAIL,再改实现。 +2. **变异验证**:改完把修复回退一次,确认判据重新 FAIL。 + 「判据全绿」不等于「判据有效」——本仓 T23 教训(fixture 照臆测字段编, + 判据全绿而实现全错)就是跳过这一步。 +3. **判据只断言代码真实产出的字段**。我本次就栽过一次:先写了 + `assert tc.StreamIndex == i`,而 `flushToolCall` 根本不给 `ToolCall` 赋 `StreamIndex` + ⇒ 恒假信号。判据必须对着**实际输出**写。 +4. **期望值由独立来源算出**,不引用被测代码。 +5. **每阶段结束跑全包** `go test ./internal/agent/core/ -count=1`, + 并检查存量插件 smoke 无新增 FAIL。 diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 3b4ea7a..289014d 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "regexp" + "sort" "strings" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" @@ -287,13 +288,31 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag delete(accs, idx) } + // flushAll 按 index 升序 flush 所有累积中的 tool call。 + // + // ❗必须排序,不能直接 `for idx := range accs`:Go 的 map 迭代顺序是**随机化**的, + // 同一批并行 tool_call 因此会以任意顺序进入 resp.ToolCalls。对 output_send__ + // 这类**用户可见消息**通道,后果是分段消息的到达顺序每次运行都可能不同 + // (不可复现的外部行为);对 tool_call_id 配对虽无影响(靠 id 而非位置), + // 但让「同一输入产生同一执行序列」这条最基本的可复现性失守。 + // + // 排序也让判据可写:stream_flush_order_test 断言输出严格按 index 升序。 + flushAll := func() { + idxs := make([]int, 0, len(accs)) + for idx := range accs { + idxs = append(idxs, idx) + } + sort.Ints(idxs) + for _, idx := range idxs { + flushToolCall(idx) + } + } + for { select { case ck, ok := <-ch: if !ok { - for idx := range accs { - flushToolCall(idx) - } + flushAll() if lastFinish != "" { resp.FinishReason = lastFinish } @@ -357,9 +376,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag } case <-ctx.Done(): - for idx := range accs { - flushToolCall(idx) - } + flushAll() return resp, ctx.Err() } } diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index a56c31c..6ce9581 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -6,6 +6,7 @@ import ( "runtime/debug" "sync" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -93,7 +94,10 @@ func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret i handler, ok := h.tools[name] h.mu.RUnlock() if !ok { - return nil, fmt.Errorf("tool %s not found in any plugin", name) + // 类型化哨兵:工具是动态注册的,调用方需要能**精确**区分 + // 「不存在」(插件挂了吗)与「执行失败」(本次业务失败)—— 二者对 + // on_error/retry 的处置完全不同。见 agentIO.ErrToolNotFound。 + return nil, agentIO.ToolNotFound(name) } if handler == nil { return nil, fmt.Errorf("tool %s has nil handler", name) diff --git a/internal/agent/core/stream_flush_order_test.go b/internal/agent/core/stream_flush_order_test.go new file mode 100644 index 0000000..259917b --- /dev/null +++ b/internal/agent/core/stream_flush_order_test.go @@ -0,0 +1,75 @@ +package core + +import ( + "context" + "fmt" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 回归:并行多工具调用的 flush 顺序必须按上游 index 升序,与到达顺序无关。 +// +// 为什么需要这条:flushToolCall 由 `for idx := range accs` 驱动,而 Go 的 map +// 迭代顺序是**随机化**的 —— 同一批并行 tool_call 因此可能以任意顺序进入 +// resp.ToolCalls。对 output_send__ 这类**用户可见消息**通道,结果就是分段 +// 消息的到达顺序每次运行都可能不同(不可复现的外部行为)。 +// +// 判据的可执行性说明:map 随机化不是「每进程一次」,而是每次遍历都可能不同; +// 这里用「工具数 × 重复轮次」放大命中概率,让判据在修复前几乎必然失败、 +// 修复后必然通过,而不是偶尔抖一下。 +// +// ❗判据只断言**代码真实产出的字段**(ID/Name,由投递顺序决定), +// 不断言 StreamIndex —— flushToolCall 并不把 idx 写进 ToolCall +// (见 process.go 的 tc := ToolCall{ID, Name, Arguments}), +// 拿它当判据会得到一个恒真/恒假的假信号。 +func TestAccumulateStreamFlushesToolCallsInIndexOrder(t *testing.T) { + const ( + tools = 8 // 单批并行工具数 + rounds = 200 // 重复轮次,放大 map 随机化命中概率 + ) + + for round := 0; round < rounds; round++ { + ctx, cancel := context.WithCancel(context.Background()) + + ch := make(chan agentAPI.StreamChunk, 2*tools+4) + // 按 index 升序投递:第 i 个工具声明 StreamIndex=i + for i := 0; i < tools; i++ { + ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{{ + ID: fmt.Sprintf("call_%02d", i), + Type: "function", + Name: fmt.Sprintf("tool_%02d", i), + RawArguments: "{}", + StreamIndex: i, + }}} + } + ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"} + close(ch) + + resp, err := accumulateStream(ctx, ch, nil, "cli", 4096) + cancel() + if err != nil { + t.Fatalf("round %d: accumulateStream: %v", round, err) + } + if len(resp.ToolCalls) != tools { + t.Fatalf("round %d: got %d tool calls, want %d", round, len(resp.ToolCalls), tools) + } + // 判据:必须严格按投递(index)升序出现。 + for i, tc := range resp.ToolCalls { + want := fmt.Sprintf("tool_%02d", i) + if tc.Name != want { + t.Fatalf("round %d: 第 %d 个 tool call 是 %q,期望 %q;实际顺序 %v —— "+ + "map 迭代随机化导致 flush 乱序", round, i, tc.Name, want, toolNameOrder(resp.ToolCalls)) + } + } + } +} + +// toolNameOrder 提取实际到达顺序,供失败信息定位。 +func toolNameOrder(tcs []agentAPI.ToolCall) []string { + out := make([]string, len(tcs)) + for i, tc := range tcs { + out[i] = tc.Name + } + return out +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index f502aba..1a5a943 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -8,6 +8,7 @@ import ( "time" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" @@ -101,9 +102,11 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS if a.stageHost != nil { if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil { return fmt.Sprintf("%v", result) - } else if !strings.Contains(err.Error(), "not found in any plugin") { + } else if !agentIO.IsToolNotFound(err) { + // 非「不存在」= 真的执行失败,如实上报(可被 on_error/retry 处置)。 return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err) } + // 是「不存在」:继续往下走 io / 设备路径,两处都没有才报缺工具。 } // 设备类工具的**授权闸**(最小授权的缺口在这里)。 @@ -128,11 +131,25 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS } } if err != nil { + if agentIO.IsToolNotFound(err) { + // 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。 + // 文案必须让模型知道该做什么,而不是含糊的"执行失败"—— + // 后者会让模型反复重试同一个不存在的名字。 + return fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+ + "先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+ + "确认名称无误后再调用", tc.Name) + } return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err) } return fmt.Sprintf("%v", result) } +// toolNotFound / isToolNotFound 是 agentIO 哨兵在 core 侧的薄封装, +// 便于 core 内部与测试直接使用(core 依赖 io,不反向)。 +func toolNotFound(name string) error { return agentIO.ToolNotFound(name) } + +func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) } + func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string { g := a.graphMem() if g == nil { diff --git a/internal/agent/core/toolcall_error_test.go b/internal/agent/core/toolcall_error_test.go new file mode 100644 index 0000000..23a4c76 --- /dev/null +++ b/internal/agent/core/toolcall_error_test.go @@ -0,0 +1,72 @@ +package core + +import ( + "errors" + "fmt" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// 回归:工具「不存在」必须用**类型化哨兵**判别,不得依赖错误文案匹配。 +// +// 现状(toolcall.go:104): +// +// if result, err := a.stageHost.ExecuteTool(...); err == nil { ... } +// else if !strings.Contains(err.Error(), "not found in any plugin") { ... } +// +// 这是**约定**不是契约:插件的错误文案只要恰好含 "not found in any plugin" +// 这个子串,就会被误判成「工具不存在」而错误地 fallback 到 io 路径。 +// +// 动态注册(plgreload / 插件崩溃 / 卸载)让这条路径比静态场景更常走, +// 因此判别必须精确。 +func TestToolNotFoundIsTypeableNotStringMatched(t *testing.T) { + // ① 内核自己产生的「不存在」必须可被 errors.Is 判别 + err := toolNotFound("qq_get_message") + if !errors.Is(err, agentIO.ErrToolNotFound) { + t.Fatalf("内核的 not-found 错误必须包裹 ErrToolNotFound,实际: %v", err) + } + if !strings.Contains(err.Error(), "qq_get_message") { + t.Errorf("错误文案应含工具名,实际: %v", err) + } + + // ② 插件自定义错误即使**恰好含** "not found in any plugin" 子串, + // 也不得被判为「工具不存在」—— 这正是字符串匹配的缺陷。 + fake := fmt.Errorf("plugin internal: device not found in any plugin table (busy)") + if isToolNotFound(fake) { + t.Errorf("含诱饵子串的插件错误被误判为工具不存在: %v", fake) + } + + // ③ 包裹后仍能穿透 fmt.Errorf %w + wrapped := fmt.Errorf("工具 %s 执行失败: %w", "x", toolNotFound("y")) + if !errors.Is(wrapped, agentIO.ErrToolNotFound) { + t.Errorf("%%w 包裹后应仍可判别,实际: %v", wrapped) + } + + // ④ 普通执行失败不得被判为 not found + if isToolNotFound(errors.New("connection refused")) { + t.Errorf("普通执行失败被误判为工具不存在") + } +} + +// 回归:执行期遇到「工具不存在」时,必须**如实报告**而不是静默 fallback。 +// +// 场景:stageHost 抛 not-found,io 也没有 ⇒ 最终错误必须仍是 not-found, +// 且文案要让模型看出是"工具不存在/插件可能没加载",而不是含糊的"执行失败"。 +func TestExecuteToolCallReportsMissingToolClearly(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + + got := a.executeToolCall(agentAPI.ToolCall{ + ID: "c1", Name: "definitely_no_such_tool", Arguments: map[string]interface{}{}, + }, "cli") + + if !strings.Contains(got, "definitely_no_such_tool") { + t.Fatalf("错误文案应含工具名,实际: %s", got) + } + // 模型需要能据此判断该做什么(换名字 / 查 get_plugin_tools / 加载插件) + if !strings.Contains(got, "不存在") && !strings.Contains(got, "未注册") && !strings.Contains(got, "not found") { + t.Errorf("错误文案应明示『不存在/未注册』,实际: %s", got) + } +} diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index c8cced7..b25be70 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -1,6 +1,7 @@ package io import ( + "errors" "fmt" "log" "runtime/debug" @@ -10,6 +11,24 @@ import ( pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) +// ErrToolNotFound 表示「工具不存在」(未注册 / 所属插件已卸载或崩溃)。 +// +// 存在的理由:工具是**动态注册**的(buildToolDefs 每轮重建、plgreload 即时生效、 +// 插件崩溃后被摘除),因此「不存在」是运行期常态而非异常。 +// 判别它必须**类型化**:此前 core 靠 strings.Contains(err, "not found in any plugin") +// 匹配错误文案,而插件的错误文案只要恰好含该子串就会被误判为「工具不存在」 +// 并错误 fallback。errors.Is 才能精确区分「不存在」与「执行失败」—— +// 二者对 on_error 的处置完全不同(前者工具没了,后者可 retry)。 +var ErrToolNotFound = errors.New("工具不存在或未注册") + +// ToolNotFound 返回一个包裹 ErrToolNotFound 的错误,带上工具名。 +func ToolNotFound(name string) error { + return fmt.Errorf("tool %s: %w", name, ErrToolNotFound) +} + +// IsToolNotFound 报告 err 是否为「工具不存在」。 +func IsToolNotFound(err error) bool { return errors.Is(err, ErrToolNotFound) } + // ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。 type ChannelDef = pubsdk.ChannelDef @@ -651,15 +670,23 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i if len(candidates) == 0 { // 自己没这个设备工具 → 看上级(驻留子的设备工具都在父的 io 上)。 + // + // ⚠️ 父的**执行失败**不得被吞成「工具不存在」:那会让 on_error 的 + // retry 失效(本该重试的失败被判为工具没了,整组被跳过)。 + // 因此只把父的「确实不存在」继续向上传递,其余错误如实上抛。 m.mu.RLock() parent := m.parent m.mu.RUnlock() if parent != nil { - if ret, err := parent.ExecuteTool(name, args); err == nil { + ret, err := parent.ExecuteTool(name, args) + if err == nil { return ret, nil } + if !IsToolNotFound(err) { + return nil, err + } } - return nil, fmt.Errorf("tool %s not found", name) + return nil, ToolNotFound(name) } defer func() { if r := recover(); r != nil { diff --git a/internal/agent/io/channel_error_test.go b/internal/agent/io/channel_error_test.go new file mode 100644 index 0000000..8dd65c3 --- /dev/null +++ b/internal/agent/io/channel_error_test.go @@ -0,0 +1,70 @@ +package io + +import ( + "errors" + "testing" +) + +// 回归:驻留子的 IOManager 向父兜底时,父的**执行失败**不得被吞成「工具不存在」。 +// +// 原实现(channel.go:655 附近): +// +// if ret, err := parent.ExecuteTool(name, args); err == nil { return ret, nil } +// // 父的 err 被丢弃 ⇒ 落到 return "tool X not found" +// +// 后果放大:设备离线、插件崩溃这类「本该 retry 的失败」被上报为「工具没了」, +// 于是 on_error 整组跳过 —— 与「插件真的没加载」无法区分。 +func TestExecuteTool_DoesNotSwallowParentFailureAsNotFound(t *testing.T) { + parent := NewIOManager() + // 父持有一个会在执行时失败的设备:工具存在,但 Execute 报错。 + failing := &failingDevice{name: "dev", toolName: "boom_tool", err: errors.New("device offline")} + if err := parent.RegisterDevice(failing); err != nil { + t.Fatalf("注册失败: %v", err) + } + + child := NewIOManager() + child.SetParentIO(parent) + + // 工具不在 child 上 → 向父兜底;父执行失败必须**如实上抛**。 + _, err := child.ExecuteTool("boom_tool", map[string]interface{}{}) + if err == nil { + t.Fatal("期望父的失败被上抛,实际 err=nil(被吞了)") + } + if IsToolNotFound(err) { + t.Fatalf("父的执行失败被误报为『工具不存在』: %v", err) + } + if err.Error() != "device offline" { + t.Errorf("应如实上抛父的错误文案,实际: %v", err) + } +} + +// 真正的「不存在」仍须保持可判别(子与父都没有)。 +func TestExecuteTool_NotFoundStillTypeable(t *testing.T) { + parent := NewIOManager() + child := NewIOManager() + child.SetParentIO(parent) + + _, err := child.ExecuteTool("no_such_tool", map[string]interface{}{}) + if !IsToolNotFound(err) { + t.Fatalf("两级都没有时应为 ErrToolNotFound,实际: %v", err) + } +} + +// failingDevice 是一个 Execute 恒定报错的测试设备。 +type failingDevice struct { + name string + toolName string + err error +} + +func (d *failingDevice) Name() string { return d.name } +func (d *failingDevice) Type() DeviceType { return DeviceOutput } +func (d *failingDevice) Description() string { return "failing test device" } +func (d *failingDevice) Tools() []ToolDef { return []ToolDef{{Name: d.toolName}} } +func (d *failingDevice) Execute(string, map[string]interface{}) (interface{}, error) { + return nil, d.err +} +func (d *failingDevice) Start() error { return nil } +func (d *failingDevice) Stop() error { return nil } +func (d *failingDevice) OutputCapabilities() OutputCapability { return CapText } +func (d *failingDevice) ChannelDef() ChannelDef { return ChannelDef{} }