Files
HomeAgent/internal/sdk/tool.go
JianFeeeee 8a98969fac refactor(parallel): 内置工具的并发声明改为 SDK 同构的结构体字段
上一提交(2232d54)把并发安全改成了声明式,但内置工具那一路仍是将就:
声明靠往 required 变参里塞字符串 "toolParallel" 传递。

## 为什么那不算声明式

对照 SDK 的 NoMemory 逐条看:

| | SDK NoMemory | 当时的内置工具 |
|---|---|---|
| 载体 | `ToolDef.NoMemory` 字段 | required 里的字符串 |
| 拼错后果 | 编译器报错 | **静默失效** |
| 内核读取 | 查结构体字段 | 遍历工具表 + 解析字符串 |

"少一个工具能并发"恰恰是最难察觉的一类问题 —— 没有任何报错,
只是并行的批悄悄退化成串行。

## 改法

### 1. sdk.BuiltinToolDef 补声明项(与 NoMemory 同构)

```go
type BuiltinToolDef struct {
    Name, Description string
    Parameters        map[string]interface{}
    ParallelSafe      bool   // 零值 false = 默认串行(保守)
    Serial            bool   // 优先于 ParallelSafe
}
func (d BuiltinToolDef) ConcurrencySafe() bool { return d.ParallelSafe && !d.Serial }
func (d BuiltinToolDef) ToSchema() map[string]interface{}
```

### 2. 工具定义处声明

```go
toolDef("memory_merge", ...)                                  // 默认串行
toolDefWith("knowledge_search", ..., []string{"query"}, parallelOpts())  // 已核实只读
```

### 3. 内核一次聚合并缓存(照 StageHost.NoMemoryToolNames)

```go
graphOf()      // 快照
declareParallelTool(name)   // init 里登记
concurrencySafeOf(name)     // 查表
```

不再每次 toolParallelSafe 都重跑 buildToolDefs()(O(工具数) 重复劳动,
而声明是静态的)。

## ★ 一个更隐蔽的问题:声明表曾经是空的

`declareParallelTool` 最初挂在 `toolDefWith` 的**运行时调用**上。而那 9 个
工具全在 `if a.knowledge != nil` / `if a.social != nil` / `if a.parentID != ""`
之类的条件分支里 —— 测试环境根本不走进这些分支 ⇒ 聚合表始终为空。

而判据查的是同一张表,于是**自证通过**:全绿,并发能力为零。

这就是判据设计的教训 —— 判据和数据源同源时,它证明的只是"我和我一致"。
现在判据双向核对:名单里的必须真声明了,声明了不在名单里的也会报出来;
并额外验证内核**真的读得到**(concurrencySafeOf 而非读同一份 map)。

## 顺带修掉的迁移事故

用正则批量改造 30+ 个 toolDef 调用点时,把 `person_set_trait("name", "content")`
这类**变参**调用误改成 toolDefWith(... "name", "content") —— 那是**写工具**,
差点被标成可并发。已全部回退并逐一核对:9 个声明并发,0 误伤。
2026-09-27 15:59:11 +08:00

117 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package sdk
// ToolSource 描述内核工具注册表/执行器的只读视图(由 StageHost 实现)。
// 用接口而非具体类型,避免 sdk 依赖内核包(内核包反向依赖 sdk)。
type ToolSource interface {
GetToolDefs() []ToolDef
ToolDef(name string) *ToolDef
ExecuteTool(name string, args map[string]interface{}) (interface{}, error)
}
// ToolAPI exposes the kernel tool registry and executor
// (StageHost for plugin tools + IOManager for device/channel tools).
type ToolAPI interface {
// GetToolDefs returns all tools registered on the stage host (plugin tools).
GetToolDefs() []ToolDef
// GetAllTools returns all tools exposed by IO devices/channels.
GetAllTools() []ToolDef
// CanUse 报告「执行该工具是否被授权」。
//
// 存在的理由(D4):设备类工具的授权闸原本只存在于 core 的
// executeToolCallInner,即**「agent 收到模型 tool_call」那条路径**。
// 而 ExecuteTool 是**另一条**独立入口,不经那道闸 —— 于是凡是走
// ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列:
// cli 插件的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具
// (cli/plugin.go:1038 自陈"ToolAPI 已允许跨插件调用工具")。
//
// 语义与内核那道闸**必须一致**(core 的 TestCanUseAgreesWithInnerPath
// 钉住这一点),否则两条路径判定不同同样是漏洞。
//
// 零值实现返回 true:未实现者行为不变(存量插件与测试替身不受影响)。
CanUse(toolName string, args map[string]interface{}) bool
// ToolDefByName 按名字查任一来源(StageHost 插件工具 / IO 设备工具 /
// 内核内置工具)的声明。
// 插件需要它来在**运行前**判断目标是否存在、是否并发安全 ——
// 而工具是动态注册的,"不存在"是常态(见 seq 包的 missing 策略)。
// 查不到返回 nil(调用方按"不存在"处理,不得 panic)。
ToolDefByName(name string) *ToolDef
// ExecuteTool executes a tool by name, resolving across the stage host first.
ExecuteTool(name string, args map[string]interface{}) (interface{}, error)
}
// ---------------------------------------------------------------------------
// 内核内置工具的注册面(方案 B)
// ---------------------------------------------------------------------------
//
// 背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个工具是
// **内核内置**的,在 core.executeToolCallInner 里按**前缀分派**,
// 从不进 StageHost / IOManager ⇒ 插件经 ToolAPI 既查不到也调不了。
// 真机实跑实证:seq_run 报「工具 knowledge_list 不存在或未注册」,
// 而同一轮模型直接调 knowledge_list 是成功的。
//
// 为什么用**晚绑定注入**而不是让 toolImpl 直接依赖 core:
// toolImpl 在 internal/sdk,而内置工具的执行依赖 core 的 *Agent
// (它持有 memory/knowledge/store 等状态,且这些状态**逐 agent 不同**
// —— 驻留子是轻量内核,memory 为 nil)。sdk 不能依赖 core(方向反了),
// 且 ToolAPI 是**全局单例**,无法天然携带 per-agent 状态。
//
// 注入方(core/bootstrap)负责提供两个函数;未注入时行为与今天完全一致
// (内置工具在 ToolAPI 上不可见),故存量插件与测试替身不受影响。
// BuiltinToolDef 是内核内置工具的声明。
type BuiltinToolDef struct {
Name string
Description string
Parameters map[string]interface{}
// ParallelSafe 与 ToolDef.ParallelSafe 同义:声明此工具可被并发执行。
// 零值 false = 默认串行(保守)。
//
// ⚠️ 内核曾经没有这个字段,于是内置工具的并发声明无处安放 ——
// 先后试过"内核里一张硬编码 map"和"往 required 变参塞字符串"两种错做法。
// 声明项必须落在**工具自己的结构体**上,与 NoMemory 同构。
ParallelSafe bool
// Serial 声明此工具必须串行,优先于 ParallelSafe。
Serial bool
}
// ToSchema 转成下发给模型的 function schema。
func (d BuiltinToolDef) ToSchema() map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": d.Name,
"description": d.Description,
"parameters": d.Parameters,
},
}
}
// ConcurrencySafe 报告该工具是否可并发执行。
// Serial 优先:显式声明"必须串行"不允许被 ParallelSafe 或默认值覆盖。
func (d BuiltinToolDef) ConcurrencySafe() bool {
return d.ParallelSafe && !d.Serial
}
// BuiltinProvider 由内核注入(导出:core 需实现它)。
type BuiltinProvider interface {
// Defs 返回当前 agent 可见的内置工具声明(**已按运行期状态门控**)。
Defs() []BuiltinToolDef
// Exec 执行一个内置工具,返回给模型看的文本。
Exec(name string, args map[string]interface{}) (string, error)
}
var builtinProv BuiltinProvider
// SetBuiltinProvider 注入内核内置工具的查询/执行面。
//
// 传 nil 表示撤销注入(测试隔离用)。注入后,内置工具对插件可见可调。
func SetBuiltinProvider(p BuiltinProvider) { builtinProv = p }
// builtinDefs 返回注入方提供的内置工具声明(未注入时为空)。
func builtinDefs() []BuiltinToolDef {
if builtinProv == nil {
return nil
}
return builtinProv.Defs()
}