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 误伤。
This commit is contained in:
JianFeeeee
2026-09-27 15:59:11 +08:00
parent 374c19246b
commit 8a98969fac
4 changed files with 181 additions and 101 deletions

View File

@ -276,29 +276,18 @@ func (a *Agent) toolParallelSafe(name string) bool {
// builtinToolParallelSafe 从**内置工具定义**里读并发声明。
//
// 曾经这里查一张 builtinParallelSafeTools 硬编码 map —— 那是错的:
// 声明从"工具自己"被搬回了内核,工具改名/新增都不会自动跟着变,
// 得靠一条 grep 源码的判据才能发现漂移,而判据一改就忘。
// 声明存在 sdk.BuiltinToolDef 的 ParallelSafe 字段上(与 NoMemory 同构),
// 由 toolDefWith 在**工具定义处**登记进 builtinDefs 聚合表。
// 这里只查表,不重扫工具定义 —— 声明是静态的,没有理由每次调用都重算。
//
// 走过的弯路(都留在注释里,因为每一种都"看起来能工作"):
// 1. 内核里一张 map[string]bool 硬编码名单:声明从工具搬回内核,
// 工具改名/新增不会跟着变,要靠 grep 源码的判据才<E68DAE><E6898D><EFBFBD>发现漂移;
// 2. 往 required 变参里塞字符串 "toolParallel":拼错静默失效,
// 编译器不报错,而"少一个工具能并发"正是最难察觉的那类问题;
// 3. 每次查询重跑 buildToolDefs():正确但 O(工具数) 重复劳动。
func (a *Agent) builtinToolParallelSafe(name string) bool {
if a == nil {
return false
}
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if n, _ := fn["name"].(string); n != name {
continue
}
safe, _ := fn["parallel_safe"].(bool)
return safe
}
return false
return concurrencySafeOf(name)
}
// batchRunnable 并发执行本批工具。