mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
三个不变量(各有判据钉住):
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 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
750 lines
38 KiB
Markdown
750 lines
38 KiB
Markdown
# 工具调用契约与工具序列设计
|
||
|
||
> **前置**:本文建立在《输入调度器设计》(`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 三次跃迁共同的结论)。
|