From 8a98969fac2d59aa51bf62a4a596da4ce2d2baa2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 15:59:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor(parallel):=20=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=9A=84=E5=B9=B6=E5=8F=91=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20SDK=20=E5=90=8C=E6=9E=84=E7=9A=84=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E4=BD=93=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交(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 误伤。 --- internal/agent/core/argvalidate.go | 33 ++--- internal/agent/core/toolapi_auth_test.go | 63 +++++---- internal/agent/core/tooldefs.go | 159 +++++++++++++++-------- internal/sdk/tool.go | 27 ++++ 4 files changed, 181 insertions(+), 101 deletions(-) diff --git a/internal/agent/core/argvalidate.go b/internal/agent/core/argvalidate.go index 9eafbbd..51594fc 100644 --- a/internal/agent/core/argvalidate.go +++ b/internal/agent/core/argvalidate.go @@ -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 源码的判据才���发现漂移; +// 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 并发执行本批工具。 diff --git a/internal/agent/core/toolapi_auth_test.go b/internal/agent/core/toolapi_auth_test.go index 48c1e62..4048c01 100644 --- a/internal/agent/core/toolapi_auth_test.go +++ b/internal/agent/core/toolapi_auth_test.go @@ -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 读文件(判据用)。 diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 1119ab9..4fca493 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -4,9 +4,11 @@ import ( "fmt" "log" "strings" + "sync" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/meta" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta" ) @@ -293,25 +295,30 @@ func (a *Agent) buildToolCatalog() string { // map[string]interface{} 字面量(约 20 行/条);本助手把它压成一次调用, // 只消除重复、不改变 schema 形状——properties 原样保留(空表仍序列化为 {}), // required 为空则整个键省略。 -// toolDefOption 是内置工具定义处的声明标记。 +// toolDefOptions 是内置工具的**声明项**。 // -// ★ 形态与 SDK 的 NoMemory **完全同构**:声明写在**工具自己的定义里**, -// 内核从定义读,没有任何硬编码名单表。 +// ★ 形态照 SDK 的 NoMemory:声明是**类型**(不是塞进 required 的字符串), +// 内核只做一次聚合并缓存,不在查询时重扫工具表。 // -// toolDef("knowledge_search", "...", props, "toolParallel") // 默认串行 -// toolDef("knowledge_list", "...", props, toolParallel) // 已核实只读,可并发 -// -// 曾用错的做法:在 toolParallelSafe 里查一张 builtinParallelSafeTools -// 硬编码 map。那把声明从工具挪回了内核 —— 工具改名/新增不会自动跟着变, -// 要靠一条 grep 源码的判据才能发现漂移,而判据一改就忘。 -type toolDefOption func(map[string]interface{}) - -// toolParallel 标记该内置工具可被并发执行(只读,已核实无共享写)。 -func toolParallel(fn map[string]interface{}) { - fn["parallel_safe"] = true +// 曾用错的两种做法(都是"把声明做成运行时猜谜"): +// 1. 内核里一张 map[string]bool 硬编码名单 —— 声明从工具搬回内核, +// 工具改名不会跟着变; +// 2. 往 required 变参里塞字符串 "toolParallel" —— 拼错就静默失效, +// 编译器不报错,而"少一个工具能并发"正是最难察觉的那类问题。 +type toolDefOptions struct { + // parallel 声明该工具可被并发执行(已核实只读、无共享写)。 + parallel bool } +// parallelOpts 是"可并发"的声明项。 +func parallelOpts() toolDefOptions { return toolDefOptions{parallel: true} } + func toolDef(name, description string, properties map[string]interface{}, required ...string) map[string]interface{} { + return toolDefWith(name, description, properties, required, toolDefOptions{}) +} + +// toolDefWith 是带声明项的 toolDef。 +func toolDefWith(name, description string, properties map[string]interface{}, required []string, opts toolDefOptions) map[string]interface{} { params := map[string]interface{}{ "type": "object", "properties": properties, @@ -319,37 +326,39 @@ func toolDef(name, description string, properties map[string]interface{}, requir if len(required) > 0 { params["required"] = required } - fn := map[string]interface{}{ - "name": name, - "description": description, - "parameters": params, - } - // 并发声明走**变参 options**:不额外改签名,读工具表的老调用点一行不用动。 - for _, o := range parseToolDefOptions(required) { - if o != nil { - o(fn) - } - } - return map[string]interface{}{ - "type": "function", - "function": fn, + def := sdk.BuiltinToolDef{ + Name: name, + Description: description, + Parameters: params, + ParallelSafe: opts.parallel, } + return def.ToSchema() } -// parseToolDefOptions 从 required 变参里分离出"声明项"。 +// init 登记**全部**声明为可并发的内置工具。 // -// 为什么不单独加一个 options 变参:required 是 ...string,再加一个 -// ...toolDefOption 会让 33 个调用点里绝大多数(不需要声明的)也跟着改。 -// 混在一个变参里,声明就写在工具定义**那一行**,读代码时一眼可见。 -func parseToolDefOptions(required []string) []toolDefOption { - var out []toolDefOption - for _, r := range required { - switch r { - case "toolParallel": - out = append(out, toolParallel) - } +// 集中在这里而不是散落在各调用点,是为了可审计:一屏能看全"哪些内置工具 +// 允许并发",新增/改名时漏改会立刻被下面那条判据抓到。 +// +// ⚠️ 这份名单是**已核实无共享写**的结论,不是分类标签。 +// 任何内置工具只要引入写操作,就必须从这里移除。 +// TestBuiltinParallelDeclaredWhereDefined 双向核对:名单里的必须真声明了, +// 声明了没在名单里的也会报出来。 +func init() { + for _, name := range []string{ + // 知识库:检索与列举 + "knowledge_search", "knowledge_list", + // 人物图谱:查询与邻域读取 + "person_query", "person_network", + // 通道:列举 + "input_channels", "output_list_channels", + // 插件与来源:列举 + "get_plugin_tools", "llm_list_sources", + // 文档:检索 + "doc_query", + } { + declareParallelTool(name) } - return out } func (a *Agent) buildToolDefs() []interface{} { @@ -424,12 +433,12 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.knowledge != nil { - tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。可用 category 把搜索限定在某个分类子树内。", map[string]interface{}{ + tools = append(tools, toolDefWith("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。可用 category 把搜索限定在某个分类子树内。", map[string]interface{}{ "query": map[string]interface{}{"type": "string", "description": "查询关键词"}, "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5}, "category": map[string]interface{}{"type": "string", "description": "可选:限定在某个分类内(前缀匹配子树,如 tech 会搜 tech/go、tech/rust)。留空则搜全库"}, - }, "query", "toolParallel")) - tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}, "toolParallel")) + }, []string{"query"}, parallelOpts())) + tools = append(tools, toolDefWith("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}, nil, parallelOpts())) } if a.knowledge != nil { @@ -467,10 +476,10 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.docStore != nil { - tools = append(tools, toolDef("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{ + tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{ "query": map[string]interface{}{"type": "string", "description": "查询内容"}, "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3}, - }, "query", "toolParallel")) + }, []string{"query"}, parallelOpts())) tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{ "content": map[string]interface{}{"type": "string", "description": "文档内容"}, "summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"}, @@ -488,9 +497,9 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.social != nil { - tools = append(tools, toolDef("person_query", "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。", map[string]interface{}{ + tools = append(tools, toolDefWith("person_query", "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "人物名称"}, - }, "name", "toolParallel")) + }, []string{"name"}, parallelOpts())) tools = append(tools, toolDef("person_set_trait", "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "人物名称"}, "trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"}, @@ -501,10 +510,10 @@ func (a *Agent) buildToolDefs() []interface{} { "relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"}, "person_b": map[string]interface{}{"type": "string", "description": "人物B"}, }, "person_a", "relation", "person_b")) - tools = append(tools, toolDef("person_network", "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。", map[string]interface{}{ + tools = append(tools, toolDefWith("person_network", "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "人物名称"}, "depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2}, - }, "name", "toolParallel")) + }, []string{"name"}, parallelOpts())) } if a.pluginReg != nil && a.pluginDir != "" { @@ -512,9 +521,9 @@ func (a *Agent) buildToolDefs() []interface{} { } // 按插件动态拉取工具定义(避免全量注入提示词污染) - tools = append(tools, toolDef("get_plugin_tools", "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。", map[string]interface{}{ + tools = append(tools, toolDefWith("get_plugin_tools", "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。", map[string]interface{}{ "plugin_name": map[string]interface{}{"type": "string", "description": "插件名,如 qq / remotedevice / weather", "default": ""}, - }, "toolParallel")) + }, nil, parallelOpts())) tools = append(tools, toolDef("spawn_child", "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。\n使用时机:多个互不依赖的子任务(如同时查三个网站、分别处理多个文件)可以在**同一轮**里一次 spawn 多个子 Agent——同轮调用默认并行,子 Agent 会各自后台启动(是否真正并发取决于工具的并发安全声明)。长耗时任务(批量处理、多轮搜索)也应交给子 Agent,避免阻塞对话。注意:一次 spawn 只是一个启动动作;要立刻拿到结果仍需另一次 `child_result` 调用。", map[string]interface{}{ "task": map[string]interface{}{ @@ -534,7 +543,7 @@ func (a *Agent) buildToolDefs() []interface{} { }, "task_id")) if a.providerManager != nil { - tools = append(tools, toolDef("llm_list_sources", "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。", map[string]interface{}{}, "toolParallel")) + tools = append(tools, toolDefWith("llm_list_sources", "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。", map[string]interface{}{}, nil, parallelOpts())) tools = append(tools, toolDef("llm_set_source", "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。", map[string]interface{}{ "name": map[string]interface{}{ "type": "string", @@ -585,7 +594,7 @@ func (a *Agent) buildToolDefs() []interface{} { tools = append(tools, toolDef("output_send__"+ch.Name+"_help", "查看 "+ch.Name+" 输出通道的 meta 格式说明和 type 枚举", map[string]interface{}{})) } - tools = append(tools, toolDef("output_list_channels", "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。", map[string]interface{}{}, "toolParallel")) + tools = append(tools, toolDefWith("output_list_channels", "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。", map[string]interface{}{}, nil, parallelOpts())) // 父侧:驻留子控制面(单工具多动作,见设计 §7)。 if a.parentID == "" { @@ -623,7 +632,7 @@ func (a *Agent) buildToolDefs() []interface{} { "写了就不会再被系统自动记录;不写则本轮结束时系统自动写。", map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}}, "text")) } - tools = append(tools, toolDef("input_channels", "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、"+ + tools = append(tools, toolDefWith("input_channels", "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、"+ "各自划给了哪个 agent、容量与记忆策略。单工具多视图。", map[string]interface{}{ "view": map[string]interface{}{ "type": "string", @@ -635,7 +644,7 @@ func (a *Agent) buildToolDefs() []interface{} { "type": "string", "description": "view=detail 时必填:inputch 名", }, - }, "toolParallel")) + }, nil, parallelOpts())) if a.pendingMedia != nil { tools = append(tools, toolDef("describe_image", "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。", map[string]interface{}{ @@ -667,3 +676,43 @@ func (a *Agent) buildToolDefs() []interface{} { return tools } + +// builtinDefRegistry 汇总内置工具的声明项。 +// +// 形态照 StageHost.NoMemoryToolNames:**一次聚合**,查询不再遍历工具表。 +// 之前 builtinToolParallelSafe 每次 toolParallelSafe 调用都重跑一遍 +// buildToolDefs(),等于 O(工具数) 的重复劳动 —— 声明是静态的,没有理由每次重算。 +type builtinDefRegistry struct { + mu sync.RWMutex + byName map[string]sdk.BuiltinToolDef + built bool +} + +var builtinDefs = &builtinDefRegistry{byName: map[string]sdk.BuiltinToolDef{}} + +// declareParallelTool 在**工具定义处**登记"可并发"声明。 +// +// 由每个 toolDefWith(..., parallelOpts()) 调用点在 init 里调用 —— +// 不依赖运行时路径。 +// +// ⚠️ 曾经让 toolDefWith 在**被调用时**顺带登记,结果聚合表是空的: +// 那些工具都在 `if a.knowledge != nil` 之类的条件分支里,测试环境根本不 +// 走进去 ⇒ 声明静默丢失,而工具表里它们明明带着 parallelOpts()。 +// 症状是"判据全绿但并发能力为零"—— 判据查的是同一张空表,自证。 +func declareParallelTool(name string) { + builtinDefs.mu.Lock() + builtinDefs.byName[name] = sdk.BuiltinToolDef{Name: name, ParallelSafe: true} + builtinDefs.mu.Unlock() +} + +// concurrencySafeOf 查询内置工具的并发声明。 +// 未登记 ⇒ 不可并发(保守,与 SDK 的零值语义一致)。 +func concurrencySafeOf(name string) bool { + builtinDefs.mu.RLock() + defer builtinDefs.mu.RUnlock() + def, ok := builtinDefs.byName[name] + if !ok { + return false + } + return def.ConcurrencySafe() +} diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index 50438a0..e62bd91 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -63,6 +63,33 @@ 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 需实现它)。