feat(parallel): 并发安全改为声明式,并审计标注 37 个工具

把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。

## ★ 起因:提示词在跟内核不一致

阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**

并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。

## 声明形态(照 SDK,不自创)

### 插件:结构体字段
    s.RegisterTool("config_get", sdk.ToolDef{
        Name: ..., Description: ...,
        Parameters: map[string]interface{}{...},
        // 已核实只读:…
        ParallelSafe: true,      ← 插在 Parameters 之后、handler 之前
    }, p.handleGet(s))

位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。

### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。

内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。

### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
    toolDef(名字, 描述, 属性)                  // 默认串行
    toolDef(名字, 描述, 属性, "toolParallel")  // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。

## ★ 走过的弯路(都留了判据)

1. **硬编码白名单**:先在 toolParallelSafe 里查一张
   builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
   工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
   而判据一改就忘。已删,改为从定义读。

2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
   当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
   a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
   "声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。

3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
   `toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。

4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
   a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
   io 包里,根因在 tooldefs.go。已补。

5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
   823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
   (空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
   工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。

## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。

## 判据
- TestSerialOverridesParallelSafe          Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit        并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial  两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined  声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON          压测抓到的 List() 缺陷
This commit is contained in:
JianFeeeee
2026-09-27 15:15:57 +08:00
parent b1b96b788d
commit 2232d5483c
16 changed files with 907 additions and 52 deletions

View File

@ -71,9 +71,10 @@ var downloadClient = &http.Client{
//
// ★ 曾经这里是一个**包级可变全局** `var HTTPAddr`,且 Start() 会把 settings 读到的值
// **反写**回该全局。两个真实后果:
// 1. 多实例互相污染——测试并行起两个 Registry,后启动的实例会把地址写进全局,
// 先启动那个的 startHTTPServer 读到的是别人的地址(实测与生产 homed 抢 9876);
// 2. 全局读写在并发下没有同步,属数据竞态。
// 1. 多实例互相污染——测试并行起两个 Registry,后启动的实例会把地址写进全局,
// 先启动那个的 startHTTPServer 读到的是别人的地址(实测与生产 homed 抢 9876);
// 2. 全局读写在并发下没有同步,属数据竞态。
//
// 现在改为实例字段 p.httpAddr(默认值走本常量),不再有可被任意代码改写的包级状态。
const defaultHTTPAddr = "127.0.0.1:9876"
@ -168,6 +169,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
},
},
// 装插件:改磁盘与运行态,可能半安装
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
overwrite, _ := args["overwrite"].(bool)
// path 优先:它对应"agent 自己构建出产物再装"的场景(plugindev_build → plugin_install)。
@ -192,6 +195,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
"type": "object",
"properties": map[string]interface{}{},
},
// 已核实只读:只列举已装插件
ParallelSafe: true,
}, func(args map[string]interface{}) (interface{}, error) {
return p.listPlugins()
})
@ -209,6 +214,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
},
},
// 已核实只读:查运行状态
ParallelSafe: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
return p.pluginStatus(name)
@ -228,6 +235,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
"required": []string{"name"},
},
// 重启插件:改运行态
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
@ -249,6 +258,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
"required": []string{"name"},
},
// 卸插件:改磁盘与运行态
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
@ -270,6 +281,8 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
"required": []string{"name"},
},
// 已核实只读:查单个插件详情
ParallelSafe: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {