三个不变量(各有判据钉住):
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 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
38 KiB
工具调用契约与工具序列设计
前置:本文建立在《输入调度器设计》(
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. 目标与非目标
内核目标(本次交付)
- 结果契约:结果有结构、成功标志诚实、错误可定位。
- 参数契约:内核按
Parameters预校验,不靠工具自觉。 - 并行执行:同轮多个 tool_call 并行,并发安全由
ParallelSafe声明而非约定保证。
插件目标(seq 插件,不在内核交付范围)
- 工具序列:把可复用的多步流程固化为可命名、可复用、可删除的对象。
- 条件与变量:组内并行、组间以具名槽传值、条件屏障。
非目标
- 不引入通用表达式引擎(见 §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 保持既有字段,新增一个结构化错误槽:
// 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 分派之前执行:
- 逐项检查
Parameters.required; - 逐项检查
properties的type(string/integer/number/boolean/array/object); - 失败 ⇒ 返回结构化
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 = 不可并行 = 现状行为):
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(SDKplugin.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.go60s单工具可无限期挂住 __arg_error短路toolcall.go:52坏参数照常下发 参数预校验 待建于阶段 1 无 schema 校验 前两项必须解决(安全与可用性),后两项可由插件按需自建。
7.1 序列文件格式
插件保存的是从文本文件解析出的 AST;执行时直接按 AST 执行,不再重新解析文本。
这意味着:文件里的 when / as 等声明在 seq_create 时即已完成解析与静态校验,
seq_run 阶段不重复校验、不重新解析。
格式为 JSON(显式花括号,不用缩进),理由按重要性排列:
- 缩进不是可靠的层级载体。YAML 的结构完全依赖缩进,而缩进对「模型写纯文本」
这一场景并不稳定。⚠️ 实测(
yaml.v3v3.0.1):少缩进一行不报错, 而是让该 group 的tools静默脱落 —— 得到一份合法但语义不同的序列。 花括号是显式闭合的,删一个}会硬解析失败,层级不会被静默改变。 - 错字必须硬失败。⚠️ 实测:YAML 对未知键(
paralell: true)静默忽略、 取默认值;JSON 开DisallowUnknownFields则直接报unknown field "paralell"。 仓内已有这个教训的记录(internal/plugin/manifest.go:38: 「本仓无 DisallowUnknownFields」而不得不加字段来绕开)。 ⇒ 序列里拼错parallel会静默退回串行,而模型毫不知情。 - 它就是模型每天在写的格式。全仓的工具参数、工具定义、事件载荷、SSE 报文
全是 JSON(
get_plugin_tools更是直接把json.Marshal的结果喂给模型)。 让模型为序列换一门语法,不产生任何收益。 encoding/json是标准库,零新依赖(yaml.v3仅cmd/waiter/cmd/homed使用)。
{
"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 且不在字符串内时才是分隔符:
// 切分要点(逐字符状态机):
// - 进入字符串时(未转义的 ")深度不再变化、';' 不切分
// - 转义 \x 消耗下一字符
// - depth 回到 0 的瞬间,检查其后是否紧跟 ';' —— 否则报错
⚠️ 结构包裹是必需的,不是装饰。仓内线上实测的真实模型输出里,
command 值大量含分号(取自 stream_accumulate_test.go 的日志原文):
{"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 完成)
as:X但out未声明X⇒ 报错(具名槽的存在价值就在这条)$args.X但in未声明X⇒ 报错- 非
array的out槽被同名as写多次 ⇒ 报错(组内并行 ⇒ 数据竞争) parallel: true且组内含非parallel_safe工具 ⇒ 报错并指名工具seq_call引用的组名不存在 ⇒ 报错(跨组引用需全局校验)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/AgentIDgrep 为空);- 驻留子创建时(
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 的中版本跃迁):
// 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)
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 三次跃迁共同的结论)。