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

@ -193,8 +193,13 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
t.Fatalf("读 tooldefs.go 失败: %v", err)
}
body := string(src)
if !strings.Contains(body, "func toolParallel(fn map[string]interface{})") {
t.Error("tooldefs.go 里没有 toolParallel 声明项 —— 声明机制不存在")
// 声明机制的存在形态:toolDefWith + parallelOpts(),
// 载体是 sdk.BuiltinToolDef.ParallelSafe 字段。
if !strings.Contains(body, "func toolDefWith(") {
t.Error("tooldefs.go 里没有 toolDefWith —— 内置工具的声明机制不存在")
}
if !strings.Contains(body, "parallelOpts()") {
t.Error("tooldefs.go 里没有 parallelOpts() 声明项")
}
// 逐个确认:这 9 个工具的定义处确实带了 toolParallel 声明。
//
@ -202,16 +207,16 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
// `toolDef("knowledge_search", ...)` 这样的示例,先匹配到注释就会
// 得出"声明位置丢了"的错误结论(我第一版正是这样)。
// 同一个坑:注释里模仿真实签名会污染一切按文本匹配的判据。
declStart := strings.Index(body, "func toolDef(")
declStart := strings.Index(body, "func toolDefWith(")
if declStart < 0 {
t.Fatal("tooldefs.go 里没有 toolDef 函数")
t.Fatal("tooldefs.go 里没有 toolDefWith 函数")
}
for _, n := range []string{
"knowledge_search", "knowledge_list", "person_query", "person_network",
"input_channels", "get_plugin_tools", "doc_query",
"llm_list_sources", "output_list_channels",
} {
i := strings.Index(body[declStart:], `toolDef("`+n+`"`)
i := strings.Index(body[declStart:], `toolDefWith("`+n+`"`)
if i < 0 {
t.Errorf("%q 在 toolDef 之后没有定义 —— 工具名可能已改", n)
continue
@ -221,32 +226,42 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
if j := strings.Index(rest, "\n\t\ttools = append"); j > 0 {
rest = rest[:j]
}
if !strings.Contains(rest, `"toolParallel"`) {
t.Errorf("%q 的定义没有带 toolParallel 声明 —— 并发声明缺失", n)
if !strings.Contains(rest, "parallelOpts()") {
t.Errorf("%q 的定义没有带 parallelOpts() 声明 —— 并发声明缺失", n)
}
}
// 机制本身要可用:造一个带声明的 Agent,验证内核真能读出来
a := &Agent{}
// ★ 声明必须**真的被内核读到**。
//
// 这一条是本判据存在的核心理由:声明写在别处(工具定义处)而内核从
// 聚合表读,两者之间可能悄悄脱节 —— 判据全绿但并发能力为零。
// 之前那张硬编码 map 就出现过"表在、但工具定义里没有"的状态。
seen := 0
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 _, has := fn["parallel_safe"]; has {
for _, n := range []string{
"knowledge_search", "knowledge_list", "person_query", "person_network",
"input_channels", "get_plugin_tools", "doc_query",
"llm_list_sources", "output_list_channels",
} {
if concurrencySafeOf(n) {
seen++
n, _ := fn["name"].(string)
if !a.toolParallelSafe(n) {
t.Errorf("%q 的定义带 parallel_safe,但 toolParallelSafe 返回 false", n)
}
} else {
t.Errorf("%q 在定义处声明了 parallelOpts(),但内核聚合表里读不到", n)
}
}
if seen != 9 {
t.Errorf("可并发的内置工具 = %d,期望 9", seen)
}
t.Logf("内核聚合表里可并发的内置工具数:%d", seen)
// 写类工具绝不能出现在聚合表的可并发集合里
for _, n := range []string{
"memory_merge", "memory_delete_entity", "knowledge_create", "doc_commit",
"persona_set", "person_set_trait", "llm_set_source", "spawn_child",
} {
if concurrencySafeOf(n) {
t.Errorf("写类工具 %q 被标为可并发 —— 并发会丢更新", n)
}
}
t.Logf("当前 Agent 条件下可见的并行声明数:%d", seen)
}
// osReadFile 读文件(判据用)。