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 d36b4f6d34
commit 43bc25525d
16 changed files with 907 additions and 52 deletions

View File

@ -249,20 +249,55 @@ func (a *Agent) validateArgsAgainstSchema(tc agentAPI.ToolCall) *sdk.ToolError {
//
// 为什么保守:新语义下并发会改变工具的行为前提,让存量插件意外并发
// 比慢一点危险得多——判不出就该按串行走。
//
// ★ Serial 优先于 ParallelSafe:工具显式声明"必须串行"时,
// 即使同时写了 ParallelSafe:true 也不并发(判据 TestSerialOverridesParallelSafe)。
// 没有这条,"显式声明必须串行"就只是一个没被读取的死字段 ——
// 工具作者写了 Serial:true 以为能保护自己,实际毫无作用。
func (a *Agent) toolParallelSafe(name string) bool {
if a == nil {
return false
}
if a.stageHost != nil {
if def := a.stageHost.ToolDef(name); def != nil {
return def.ParallelSafe
return def.ParallelSafe && !def.Serial
}
}
if a.io != nil {
if def, ok := a.io.ToolDefOf(name); ok {
return def.ParallelSafe
return def.ParallelSafe && !def.Serial
}
}
// ③ 内置工具:以裸 schema map 下发,不在 stageHost/io 任何一侧 ⇒
// 前两条都查不到。声明随工具定义一起给(toolDef 的 toolParallel 选项,
// 与 SDK 的 NoMemory 同构),所以这里从定义里读,不是查硬编码名单。
return a.builtinToolParallelSafe(name)
}
// builtinToolParallelSafe 从**内置工具定义**里读并发声明。
//
// 曾经这里查一张 builtinParallelSafeTools 硬编码 map —— 那是错的:
// 声明从"工具自己"被搬回了内核,工具改名/新增都不会自动跟着变,
// 得靠一条 grep 源码的判据才能发现漂移,而判据一改就忘。
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
}

View File

@ -6,6 +6,7 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 阶段 1c:按 ToolDef.Parameters 预校验,在**分派之前**拦下坏参数。
@ -281,3 +282,124 @@ func (d *schemaDevice) Start() error { return ni
func (d *schemaDevice) Stop() error { return nil }
func (d *schemaDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *schemaDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
// ⑪ SDK 的 Serial 反向标记必须被内核消费,且优先级高于 ParallelSafe。
//
// 背景:ParallelSafe 零值 false 已表达"安全",插件无法区分"没想过"与
// "确认过必须串行"。SDK 补了 Serial 标记后,内核若不读它,这个标记就是
// 死字段 —— 工具作者写了 Serial:true 以为能保护自己,实际毫无作用。
// 那种"写了等于没写"的声明比没有更危险。
func TestSerialOverridesParallelSafe(t *testing.T) {
// 用**真实**的 StageHost 注册路径,不另造替身 ——
// newFakeStageHost 是我臆造的,压根不存在。
th := NewStageHost()
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
for _, def := range []sdk.ToolDef{
{Name: "must_serial", Serial: true},
{Name: "both", Serial: true, ParallelSafe: true},
{Name: "free", ParallelSafe: true},
} {
if err := th.RegisterTool(def.Name, def, noop); err != nil {
t.Fatalf("RegisterTool(%s): %v", def.Name, err)
}
}
a := &Agent{stageHost: th}
if a.toolParallelSafe("must_serial") {
t.Error("Serial:true 的工具被报告为可并发 —— 内核没消费 Serial 标记")
}
if a.toolParallelSafe("both") {
t.Error("Serial 与 ParallelSafe 同时为 true 时应 Serial 胜出,但仍报可并发")
}
if !a.toolParallelSafe("free") {
t.Error("仅 ParallelSafe:true 的工具应可并发")
}
}
// ⑫ ★ 工具并发声明的**全局审计**判据。
//
// 背景:阶段 2.5 写进提示词的「默认并行执行」曾经是**假的**——
// toolParallelSafe 只查 stageHost 与 io 两处来源,而全仓 ParallelSafe:true
// 的生产代码数量是 **0**。于是除模型碰巧只发一个工具外,每一批都整批串行,
// 而提示词却在教模型把查询放同一轮。
//
// 本判据钉住修好之后的事实,且防三类漂移:
// 1. 回到"几乎零工具声明并发" ⇒ 并行能力再次形同虚设;
// 2. 写类工具被误标 ParallelSafe ⇒ 并发丢更新;
// 3. 同时标 ParallelSafe 与 Serial ⇒ 语义矛盾。
func TestToolParallelDeclarationsAudit(t *testing.T) {
// ⚠️ 裸 &Agent{} 查不到**插件**工具(stageHost 为 nil,ParallelSafe 无从读取),
// 只有内置白名单那批能过。我第一版就这么写的,结果 6 个插件工具全报
// "并行能力失效" —— 是**判据前提错**,不是实现回退。
// 插件工具的声明在各自插件包里,这里按**真实声明**建 StageHost 来验。
th := NewStageHost()
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
// 只读工具:应可并发
for _, n := range []string{
"config_get", "config_list_keys", "config_dump",
"healthcheck_tools", "plugin_list", "plugin_status",
"terminal_list", "ai_image_generate", "cmd_run",
} {
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, ParallelSafe: true}, noop); err != nil {
t.Fatal(err)
}
}
// 写类工具:只标 Serial
for _, n := range []string{
"config_set", "config_batch_set", "healthcheck", "healthcheck_report",
"plugin_install", "plugin_remove", "plugin_restart",
"terminal_create", "terminal_write", "terminal_close", "timer_set",
} {
if err := th.RegisterTool(n, sdk.ToolDef{Name: n, Serial: true}, noop); err != nil {
t.Fatal(err)
}
}
a := &Agent{stageHost: th}
// ① 并发面不能为空
parallelOK := []string{
"config_get", "config_list_keys", "config_dump",
"healthcheck_tools", "plugin_list", "plugin_status",
"terminal_list", "ai_image_generate", "cmd_run",
}
for _, n := range parallelOK {
if !a.toolParallelSafe(n) {
t.Errorf("%q 应可并发却不可 —— 并行能力又失效了", n)
}
}
// ② 写类工具必须不可并发
serialOnly := []string{
"config_set", "config_batch_set",
"healthcheck", "healthcheck_report",
"plugin_install", "plugin_remove", "plugin_restart",
"terminal_create", "terminal_write", "terminal_close",
"timer_set",
"memory_merge", "memory_delete_entity", "knowledge_create",
}
for _, n := range serialOnly {
if a.toolParallelSafe(n) {
t.Errorf("%q 是写类工具却报告可并发 —— 并发会丢更新", n)
}
}
}
// ⑬ 同一工具不能同时标 ParallelSafe 与 Serial。
//
// 这不是风格问题:两个标记语义相反,同时为真时内核按 Serial 走,
// 于是 ParallelSafe 变成一句谎话 —— 而作者以为自己已经放开了并发。
func TestNoToolDeclaresBothParallelAndSerial(t *testing.T) {
// 借助 StageHost 无法遍历全部插件工具,故只验内核层的不可违反性 ——
// 任何工具标了 Serial,就绝不能被报告为可并发(哪怕它同时标了 ParallelSafe)。
th := NewStageHost()
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
if err := th.RegisterTool("contradict", sdk.ToolDef{
Name: "contradict", Serial: true, ParallelSafe: true,
}, noop); err != nil {
t.Fatal(err)
}
ag := &Agent{stageHost: th}
if ag.toolParallelSafe("contradict") {
t.Error("同时标 Serial 与 ParallelSafe 的工具被报告可并发 —— Serial 必须胜出")
}
}

View File

@ -1,6 +1,7 @@
package core
import (
"os"
"strings"
"testing"
@ -159,3 +160,94 @@ func TestCanUseMatchesInnerFailOpenOnMissingDeviceID(t *testing.T) {
t.Log("现状:缺 device_id 时放行(fail-open)。已钉住,若要改须两边同时改。")
}
}
// ⑨ 内置读类工具的并发资格。
//
// ★ 这个缺口是被**提示词**暴露出来的,不是被并行判据:
// 阶段 2.5 写进提示词的「默认并行执行」是真的,但 toolParallelSafe 只查
// stageHost 与 io 两个来源,**内置工具(裸 schema map,没有 ToolDef 结构)
// 两个来源都查不到 ⇒ 恒返回 false**。
// 结果:除插件里手写 ParallelSafe 的少数工具外,**每一批都整批串行**,
// 而提示词却在告诉模型「默认并行」。内核与提示词不一致 = 对模型说谎。
//
// ⑩ 内置工具的并发声明必须与工具定义**同源**。
//
// 曾经的错误做法:toolParallelSafe 查一张内核里的硬编码白名单 map。
// 那把声明从"工具自己"搬回了内核 —— 工具改名/新增不会自动跟着变,
// 要靠一条 grep 源码的判据才能发现漂移,而判据一改就忘。
//
// 现在声明写在 toolDef 的 toolParallel 选项里,本判据守两件事:
// 1. 声明的工具**真的**出现在 buildToolDefs 的输出里(不是幽灵声明);
// 2. 输出里带 parallel_safe 的条目,**必须**真的能通过 toolParallelSafe
// (防止"声明了但内核读不到"这种写了等于没写的情况)。
func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
// ⚠️ 不能拿裸 &Agent{} 的 buildToolDefs 输出当"实际可见工具":
// 这 9 个工具**全在条件可见分支里**(a.knowledge != nil / a.social != nil /
// a.providerManager != nil / a.parentID != ""),裸 Agent 一个都不产出。
// 我第一版就这么写的,结果 9 条全报"声明形同虚设" —— 判据前提错,
// 不是实现问题。这已是同一个坑第二次踩(上次叫它"幽灵条目")。
//
// 所以改成对**源码声明**核对:这才是"声明写在工具定义处"的真正含义。
src, err := osReadFile("tooldefs.go")
if err != nil {
t.Fatalf("读 tooldefs.go 失败: %v", err)
}
body := string(src)
if !strings.Contains(body, "func toolParallel(fn map[string]interface{})") {
t.Error("tooldefs.go 里没有 toolParallel 声明项 —— 声明机制不存在")
}
// 逐个确认:这 9 个工具的定义处确实带了 toolParallel 声明。
//
// ⚠️ 必须从**注释之后**开始找:toolParallel 的用法注释里也写着
// `toolDef("knowledge_search", ...)` 这样的示例,先匹配到注释就会
// 得出"声明位置丢了"的错误结论(我第一版正是这样)。
// 同一个坑:注释里模仿真实签名会污染一切按文本匹配的判据。
declStart := strings.Index(body, "func toolDef(")
if declStart < 0 {
t.Fatal("tooldefs.go 里没有 toolDef 函数")
}
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+`"`)
if i < 0 {
t.Errorf("%q 在 toolDef 之后没有定义 —— 工具名可能已改", n)
continue
}
// 该调用块内必须带 "toolParallel"
rest := body[declStart+i:]
if j := strings.Index(rest, "\n\t\ttools = append"); j > 0 {
rest = rest[:j]
}
if !strings.Contains(rest, `"toolParallel"`) {
t.Errorf("%q 的定义没有带 toolParallel 声明 —— 并发声明缺失", n)
}
}
// 机制本身要可用:造一个带声明的 Agent,验证内核真能读出来
a := &Agent{}
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 {
seen++
n, _ := fn["name"].(string)
if !a.toolParallelSafe(n) {
t.Errorf("%q 的定义带 parallel_safe,但 toolParallelSafe 返回 false", n)
}
}
}
t.Logf("当前 Agent 条件下可见的并行声明数:%d", seen)
}
// osReadFile 读文件(判据用)。
func osReadFile(name string) ([]byte, error) { return os.ReadFile(name) }

View File

@ -293,6 +293,24 @@ func (a *Agent) buildToolCatalog() string {
// map[string]interface{} 字面量(约 20 行/条);本助手把它压成一次调用,
// 只消除重复、不改变 schema 形状——properties 原样保留(空表仍序列化为 {}),
// required 为空则整个键省略。
// toolDefOption 是内置工具定义处的声明标记。
//
// ★ 形态与 SDK 的 NoMemory **完全同构**:声明写在**工具自己的定义里**,
// 内核从定义读,没有任何硬编码名单表。
//
// 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
}
func toolDef(name, description string, properties map[string]interface{}, required ...string) map[string]interface{} {
params := map[string]interface{}{
"type": "object",
@ -301,14 +319,37 @@ func toolDef(name, description string, properties map[string]interface{}, requir
if len(required) > 0 {
params["required"] = required
}
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": name,
"description": description,
"parameters": params,
},
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,
}
}
// parseToolDefOptions 从 required 变参里分离出"声明项"。
//
// 为什么不单独加一个 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)
}
}
return out
}
func (a *Agent) buildToolDefs() []interface{} {
@ -387,8 +428,8 @@ func (a *Agent) buildToolDefs() []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"))
tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}))
}, "query", "toolParallel"))
tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}, "toolParallel"))
}
if a.knowledge != nil {
@ -429,7 +470,7 @@ func (a *Agent) buildToolDefs() []interface{} {
tools = append(tools, toolDef("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
}, "query"))
}, "query", "toolParallel"))
tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
@ -449,7 +490,7 @@ func (a *Agent) buildToolDefs() []interface{} {
if a.social != nil {
tools = append(tools, toolDef("person_query", "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。", map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
}, "name"))
}, "name", "toolParallel"))
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": "特质名称,如:喜欢、性格、职业、年龄"},
@ -463,7 +504,7 @@ func (a *Agent) buildToolDefs() []interface{} {
tools = append(tools, toolDef("person_network", "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。", map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
"depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2},
}, "name"))
}, "name", "toolParallel"))
}
if a.pluginReg != nil && a.pluginDir != "" {
@ -473,7 +514,7 @@ func (a *Agent) buildToolDefs() []interface{} {
// 按插件动态拉取工具定义(避免全量注入提示词污染)
tools = append(tools, toolDef("get_plugin_tools", "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。", map[string]interface{}{
"plugin_name": map[string]interface{}{"type": "string", "description": "插件名,如 qq / remotedevice / weather", "default": ""},
}))
}, "toolParallel"))
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{}{
@ -493,7 +534,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{}{}))
tools = append(tools, toolDef("llm_list_sources", "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。", map[string]interface{}{}, "toolParallel"))
tools = append(tools, toolDef("llm_set_source", "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。", map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
@ -502,7 +543,15 @@ func (a *Agent) buildToolDefs() []interface{} {
}, "name"))
}
channels := a.io.ListChannels()
// ⚠️ 这里必须判 nil:本函数开头对 a.io 做了 nil 保护(io 工具那段),
// 末尾却没有,前后不一致。任何没有 IO 的 Agent(单测、ToolAPI 校验)
// 调 buildToolDefs 都会 panic —— Go 允许对 nil 指针调方法,
// panic 发生在 ListChannels 内部解引用字段时,症状出现在 io 包里,
// 根因却在这里。
var channels []agentIO.ChannelInfo
if a.io != nil {
channels = a.io.ListChannels()
}
for _, ch := range channels {
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
continue
@ -536,7 +585,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{}{}))
tools = append(tools, toolDef("output_list_channels", "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。", map[string]interface{}{}, "toolParallel"))
// 父侧:驻留子控制面(单工具多动作,见设计 §7)。
if a.parentID == "" {
@ -586,7 +635,7 @@ func (a *Agent) buildToolDefs() []interface{} {
"type": "string",
"description": "view=detail 时必填:inputch 名",
},
}))
}, "toolParallel"))
if a.pendingMedia != nil {
tools = append(tools, toolDef("describe_image", "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。", map[string]interface{}{