Files
HomeAgent/docs/zh/toolcall-contract-and-sequence-design.md
JianFeeeee 7532af7e9b feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)
三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00

750 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 工具调用契约与工具序列设计
> **前置**:本文建立在《输入调度器设计》(`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.<key>` | 只读**本 group 的入参**,不依赖外部变量 |
| **出参** | `as:<key>` | 写入本 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/<name>.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/<name>.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 三次跃迁共同的结论)。