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