# 工具调用契约与工具序列设计 > **前置**:本文建立在《输入调度器设计》(`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 被 `{…}` 包裹时才是安全的**—— 这一点是本设计的关键,不加包裹会立刻出问题(见下)。 ⚠️ **每个 tool 必须是合法 JSON**(键要带引号):`{"tool":"cmd_run","args":{...},"as":"x"} ;` 写成 `{tool:cmd_run,...}` 是**非法 JSON**,内核用 `encoding/json` 解析会直接失败。 (这条是 P1 实现时判据跑出来的真实缺陷,不是假想。) #### 切分规则(已实测) `;` **仅在 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.2** | 工具错误类型化(`ErrToolNotFound` 哨兵 + 修父 io 吞错误) | — | ✅ 已完成 | | **0.5** | 补多 tool_call 批内路径的判据(§6.6 注) | — | ✅ 已完成 | | **1** | 结果契约 + 参数预校验(§4) | — | ✅ 已完成(1a/1b/1c/1d) | | **2** | **并行执行层(§6)** | 1 | ✅ 已完成(2a 落法 / 2b per-call / 2c per-tool ctx / 2d 调度保序) | | **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 三次跃迁共同的结论)。