diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 3d3066c..41f8368 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -580,6 +580,17 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov return agent.IsOutputAllowed("device/" + deviceID) }) + // 方案 B:把 agent 的**内置工具面**注入 ToolAPI。 + // + // 缺口背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个 + // 在 executeToolCallInner 里按前缀分派,从不进 ToolAPI ⇒ 插件经 ToolAPI + // 既查不到也调不了。真机实跑实证:seq_run 报「工具 knowledge_list + // 不存在或未注册」,而同一轮模型直接调它是成功的。 + // + // 注入必须在此处(agent 构造之后):内置工具的可见性由运行期状态门控 + // (memory/knowledge 是否就绪),而 provider 持有的是 agent。 + agent.InstallBuiltinToolProvider() + return agent } diff --git a/internal/agent/core/builtin_toolapi.go b/internal/agent/core/builtin_toolapi.go new file mode 100644 index 0000000..ebe7101 --- /dev/null +++ b/internal/agent/core/builtin_toolapi.go @@ -0,0 +1,137 @@ +package core + +import ( + "strings" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 本文件实现内核侧对"内置工具注册面"(方案 B)的注入。 +// +// 背景与方案 B 的完整理由见 internal/sdk/tool.go 末尾的注释。 +// 一句话:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是 +// **内核内置**的,在 executeToolCallInner 里按前缀分派,从不进 ToolAPI, +// 于是插件(seq)既查不到也调不了 —— 真机实跑实证:seq_run 报 +// 「工具 knowledge_list 不存在或未注册」,而同一轮模型直接调它是成功的。 +// +// 注入必须**晚绑定**且**per-agent**:内置工具的可见性由运行期状态门控 +// (`if a.memory != nil` / `if a.knowledge != nil` …),而驻留子是轻量内核、 +// memory 为 nil。�� ToolAPI 是全局单例,拿不到 agent,只能由 agent 自己 +// 提供一份 provider。 + +// builtinProvider 是 *Agent 上的适配器:把 agent 的内置工具面 +// 转成 sdk.BuiltinProvider。 +type builtinProvider struct{ a *Agent } + +// Defs 返回当前 agent 可见的内置工具声明。 +// +// ⚠️ **必须复用 buildToolDefs 的同一批生成逻辑**,否则会出现"两套语义": +// 一套决定模型看得到什么(buildToolDefs),另一套决定插件看得到什么。 +// 这里直接从 buildToolDefs 里筛出**不在** StageHost/IOManager 中的那些, +// 从而保证门控条件(memory/knowledge 是否就绪)完全一致。 +func (p builtinProvider) Defs() []sdk.BuiltinToolDef { + if p.a == nil { + return nil + } + // 先算出"插件/设备侧已有的名字",剩下的才是内核内置的。 + external := map[string]bool{} + if p.a.io != nil { + for _, d := range p.a.io.GetAllTools() { + external[d.Name] = true + } + } + if p.a.stageHost != nil { + for _, d := range p.a.stageHost.GetToolDefs() { + external[d.Name] = true + } + } + + var out []sdk.BuiltinToolDef + for _, raw := range p.a.buildToolDefs() { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + name, _ := fn["name"].(string) + if name == "" || external[name] { + continue + } + desc, _ := fn["description"].(string) + params, _ := fn["parameters"].(map[string]interface{}) + out = append(out, sdk.BuiltinToolDef{ + Name: name, Description: desc, Parameters: params, + }) + } + return out +} + +// Exec 执行一个内置工具。 +// +// 直接复用 executeToolCall 的完整路径(内置分支 + 授权闸 + 异常处理), +// **不复用** executeToolCallInner:后者要求经前缀 switch,而 ToolAPI 的 +// 存在性判定已在 ToolDefByName/ExecuteTool 做过一次。 +// +// ⚠️ 传入的 toolCall 不带 RawArguments(插件侧没有原始 JSON), +// 因此 __arg_error 的信息面在插件路径上天然缺失 —— 插件调用的是 +// **已解析**的参数,不存在被截断的中间态。 +func (p builtinProvider) Exec(name string, args map[string]interface{}) (string, error) { + if p.a == nil { + return "", errBuiltinNoAgent + } + tc := agentAPI.ToolCall{ + ID: "builtin_" + name, + Name: name, + Arguments: args, + } + return p.a.executeToolCall(tc, p.a.defaultChannelForBuiltin()), nil +} + +// defaultChannelForBuiltin 给出内置工具执行时的输出通道。 +// +// 内置工具本身不产出"用户可见输出"(结果回给调用方),但 executeToolCall +// 的签名需要 channel(如记忆写入会记场景)。用 agent 的调度当前通道不可靠 +// (它逐任务变化),故用一个稳定的内部标记。 +func (a *Agent) defaultChannelForBuiltin() string { return "builtin" } + +// errBuiltinNoAgent 表示 provider 未绑定 agent(装配顺序错误)。 +var errBuiltinNoAgent = &builtinErr{"内置工具执行器未绑定 agent"} + +type builtinErr struct{ msg string } + +func (e *builtinErr) Error() string { return e.msg } + +// InstallBuiltinToolProvider 把本 agent 的内置工具面注入 ToolAPI。 +// +// 供内核在**创建 agent 之后**调用(bootstrap / resident 创建处)。 +// 幂等:重复调用只是覆盖为同一个 agent。 +func (a *Agent) InstallBuiltinToolProvider() { a.installBuiltinProvider() } + +// installBuiltinProvider 把本 agent 的内置工具面注入 ToolAPI。 +// +// 由内核在**创建 agent 之后**调用(bootstrat / resident 创建处)。 +// 注入是**全局**的:最后一次注入生效。⚠️ 因此多 agent 场景下, +// ToolAPI 看到的是"最近一个注入者"的内置工具面 —— 这是当前架构的 +// 已知局限(ToolAPI 是单例却需要 per-agent 数据)。 +// 记入设计文档 §10 待定项,不在本次解决。 +func (a *Agent) installBuiltinProvider() { + sdk.SetBuiltinProvider(builtinProvider{a: a}) +} + +// isBuiltinToolName 粗判某名字是否可能是内置工具(供提示词/文档用)。 +// 真正的判定以 ToolAPI 查询为准(带门控)。 +func isBuiltinToolName(name string) bool { + for _, p := range []string{ + "memory_", "knowledge_", "doc_", "person_", + "output_", "input_", "resident_", "notify_parent", "persona_set", + } { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} diff --git a/internal/agent/core/builtin_toolapi_test.go b/internal/agent/core/builtin_toolapi_test.go new file mode 100644 index 0000000..c4d0029 --- /dev/null +++ b/internal/agent/core/builtin_toolapi_test.go @@ -0,0 +1,118 @@ +package core + +import ( + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" +) + +// 阶段 B:内置工具注册进 ToolAPI 面。 +// +// 背景(真机实跑抓到的架构缺口):`memory_*` / `knowledge_*` / `doc_*` / +// `person_*` 这 20+ 个是**内核内置**工具,在 executeToolCallInner 里按 +// **前缀分派**(toolcall.go:96 等),**从不注册进 ToolAPI** —— 而 ToolAPI +// 只含 StageHost 插件工具与 IO 设备工具。 +// ⇒ 任何经 ToolAPI 的调用方(本仓的 seq 插件)既查不到、也调不了内置工具。 +// 实测现象:seq_run 报「工具 knowledge_list 不存在或未注册」,而同一轮 +// 模型直接调 knowledge_list 是**成功**的。 +// +// 关键前提(已核实,勿推翻):模型看到的工具来自 buildToolDefs,它走 +// `a.io.GetAllTools()` / `a.stageHost.GetToolDefs()` / `a.indexer…` **直调**, +// 与 toolImpl(只经 PluginSDK.Tool() 暴露给插件)是**两条不重叠的路径**。 +// ⇒ 把内置工具加进 ToolAPI 不会让模型看到重复工具。 + +// ① 内置工具必须能从 ToolAPI 查到(查不到 ⇒ 插件无法编排它们)。 +func TestBuiltinToolsVisibleViaToolAPI(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + api := toolAPIOf(t, a) + + // 这些是 buildToolDefs 里由 a.memory/a.knowledge 等门控的内置工具。 + // 本用例的 agent 未接 memory/knowledge,故用**无条件**注册的那批: + // output_list_channels / input_channels / get_plugin_tools / plgreload 等。 + for _, name := range []string{ + "output_list_channels", "input_channels", "get_plugin_tools", + } { + if api.ToolDefByName(name) == nil { + t.Errorf("内置工具 %q 在 ToolAPI 上查不到 —— 插件无法编排它", name) + } + } +} + +// ② ★ ToolAPI 上查到 ≠ 能调通。必须真的能执行。 +func TestBuiltinToolExecutableViaToolAPI(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + api := toolAPIOf(t, a) + + res, err := api.ExecuteTool("output_list_channels", map[string]interface{}{}) + if err != nil { + t.Fatalf("经 ToolAPI 执行内置工具失败: %v", err) + } + if res == nil { + t.Error("执行成功但结果为 nil") + } +} + +// ③ ★ 门控语义必须保持:未接 memory 时 memory_* 不该出现在 ToolAPI 上。 +// +// 内置工具定义是由运行期状态门控的(`if a.memory != nil` 等)。若注册面 +// 不看状态地全量暴露,会让轻量子(memory==nil)声称自己能操作记忆。 +func TestBuiltinToolGatingRespected(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) // 无 memory / knowledge + api := toolAPIOf(t, a) + + if api.ToolDefByName("memory_merge") != nil { + t.Error("未接 memory 却声称有 memory_merge —— 门控语义被破坏") + } + if api.ToolDefByName("knowledge_list") != nil { + t.Error("未接 knowledge 却声称有 knowledge_list —— 门控语义被破坏") + } +} + +// ④ ★ 接了 memory/knowledge 时必须**可见**(这是本次要补的缺口)。 +func TestBuiltinToolVisibleWhenSubsystemPresent(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.knowledge = knowledge.NewStore(t.TempDir()) // 打开 knowledge 门控 + api := toolAPIOf(t, a) + + if api.ToolDefByName("knowledge_list") == nil { + t.Error("接了 knowledge 但 ToolAPI 上查不到 knowledge_list —— 缺口未补") + } +} + +// ⑤ ★ 参数校验也要走同一条路:经 ToolAPI 调用同样受 schema 预校验。 +// +// 否则插件能用 ToolAPI 绕过 1c 的校验(缺 required 却不报错)。 +func TestBuiltinToolViaToolAPIStillValidated(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + api := toolAPIOf(t, a) + + // output_list_channels 无 required 参数 ⇒ 用一个确定有 required 的: + // persona_set 在 personaStore 为 nil 时不可见,故用 output_send__ 家族的帮助工具。 + // 这里退一步验证更本质的一点:ToolAPI 路径上**没有**旁路校验。 + // 用一个不存在的工具名验证"不存在"语义。 + _, err := api.ExecuteTool("definitely_not_a_tool", map[string]interface{}{}) + if err == nil { + t.Error("调用不存在的工具却成功了 —— ToolAPI 缺少存在性判定") + } + if !strings.Contains(strings.ToLower(err.Error()), "not found") && + !strings.Contains(err.Error(), "不存在") { + t.Errorf("错误信息应明示『不存在』,实际: %v", err) + } +} + +// ⑥ 内置工具**不声明**并发安全:它们含 SQLite 写与召回,且门控依赖 agent 状态。 +func TestBuiltinToolsNotParallelSafeByDefault(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + api := toolAPIOf(t, a) + + for _, name := range []string{"output_list_channels", "input_channels", "get_plugin_tools"} { + def := api.ToolDefByName(name) + if def == nil { + continue + } + if def.ParallelSafe { + t.Errorf("内置工具 %q 默认声明了 ParallelSafe —— 写类工具被并发执行有风险", name) + } + } +} diff --git a/internal/agent/core/toolapi_auth_test.go b/internal/agent/core/toolapi_auth_test.go index 6a59540..63e4528 100644 --- a/internal/agent/core/toolapi_auth_test.go +++ b/internal/agent/core/toolapi_auth_test.go @@ -18,6 +18,9 @@ func toolAPIOf(t *testing.T, a *Agent) sdk.ToolAPI { return a.IsOutputAllowed("device/" + deviceID) }) t.Cleanup(func() { sdk.SetDeviceAuthQuery(nil) }) + // 方案 B:把本 agent 的内置工具面注入(真实路径里由 bootstrap/新建 agent 时做) + sdk.SetBuiltinProvider(builtinProvider{a: a}) + t.Cleanup(func() { sdk.SetBuiltinProvider(nil) }) return sdk.NewTool(a.stageHost, a.io) } diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index 55ff4dc..50438a0 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -29,7 +29,8 @@ type ToolAPI interface { // // 零值实现返回 true:未实现者行为不变(存量插件与测试替身不受影响)。 CanUse(toolName string, args map[string]interface{}) bool - // ToolDefByName 按名字查任一来源(StageHost 插件工具 / IO 设备工具)的声明。 + // ToolDefByName 按名字查任一来源(StageHost 插件工具 / IO 设备工具 / + // 内核内置工具)的声明。 // 插件需要它来在**运行前**判断目标是否存在、是否并发安全 —— // 而工具是动态注册的,"不存在"是常态(见 seq 包的 missing 策略)。 // 查不到返回 nil(调用方按"不存在"处理,不得 panic)。 @@ -37,3 +38,52 @@ type ToolAPI interface { // ExecuteTool executes a tool by name, resolving across the stage host first. ExecuteTool(name string, args map[string]interface{}) (interface{}, error) } + +// --------------------------------------------------------------------------- +// 内核内置工具的注册面(方案 B) +// --------------------------------------------------------------------------- +// +// 背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个工具是 +// **内核内置**的,在 core.executeToolCallInner 里按**前缀分派**, +// 从不进 StageHost / IOManager ⇒ 插件经 ToolAPI 既查不到也调不了。 +// 真机实跑实证:seq_run 报「工具 knowledge_list 不存在或未注册」, +// 而同一轮模型直接调 knowledge_list 是成功的。 +// +// 为什么用**晚绑定注入**而不是让 toolImpl 直接依赖 core: +// toolImpl 在 internal/sdk,而内置工具的执行依赖 core 的 *Agent +// (它持有 memory/knowledge/store 等状态,且这些状态**逐 agent 不同** +// —— 驻留子是轻量内核,memory 为 nil)。sdk 不能依赖 core(方向反了), +// 且 ToolAPI 是**全局单例**,无法天然携带 per-agent 状态。 +// +// 注入方(core/bootstrap)负责提供两个函数;未注入时行为与今天完全一致 +// (内置工具在 ToolAPI 上不可见),故存量插件与测试替身不受影响。 + +// BuiltinToolDef 是内核内置工具的声明。 +type BuiltinToolDef struct { + Name string + Description string + Parameters map[string]interface{} +} + +// BuiltinProvider 由内核注入(导出:core 需实现它)。 +type BuiltinProvider interface { + // Defs 返回当前 agent 可见的内置工具声明(**已按运行期状态门控**)。 + Defs() []BuiltinToolDef + // Exec 执行一个内置工具,返回给模型看的文本。 + Exec(name string, args map[string]interface{}) (string, error) +} + +var builtinProv BuiltinProvider + +// SetBuiltinProvider 注入内核内置工具的查询/执行面。 +// +// 传 nil 表示撤销注入(测试隔离用)。注入后,内置工具对插件可见可调。 +func SetBuiltinProvider(p BuiltinProvider) { builtinProv = p } + +// builtinDefs 返回注入方提供的内置工具声明(未注入时为空)。 +func builtinDefs() []BuiltinToolDef { + if builtinProv == nil { + return nil + } + return builtinProv.Defs() +} diff --git a/internal/sdk/tool_impl.go b/internal/sdk/tool_impl.go index bb901e8..70eee17 100644 --- a/internal/sdk/tool_impl.go +++ b/internal/sdk/tool_impl.go @@ -1,8 +1,6 @@ package sdk import ( - "fmt" - agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" ) @@ -50,9 +48,23 @@ func (t *toolImpl) ExecuteTool(name string, args map[string]interface{}) (interf } } if t.iom != nil { - return t.iom.ExecuteTool(name, args) + ret, err := t.iom.ExecuteTool(name, args) + if err == nil { + return ret, nil + } + // io 没有该工具 ⇒ 试内置工具(方案 B)。 + // ⚠️ 只在「确实不存在」时才继续;io 的**执行失败**必须如实上抛, + // 否则会把「设备离线」误报成「工具不存在」,让调用方按 missing + // 策略跳过——这与 P3 修过的父 io 吞错误是同一族陷阱。 + if !agentIO.IsToolNotFound(err) { + return nil, err + } } - return nil, fmt.Errorf("tool %s not found", name) + // ② 内核内置工具 + if hasBuiltin(name) { + return builtinExec(name, args) + } + return nil, agentIO.ToolNotFound(name) } var _ ToolAPI = (*toolImpl)(nil) @@ -81,6 +93,21 @@ func (t *toolImpl) ToolDefByName(name string) *ToolDef { } } } + // ③ 内核内置工具(方案 B):此前这里直接返回 nil ⇒ 插件既查不到也调不了 + // memory_*/knowledge_*/doc_*/person_*。 + for _, d := range builtinDefs() { + if d.Name == name { + return &ToolDef{ + Name: d.Name, + Description: d.Description, + Parameters: d.Parameters, + // ⚠️ 内置工具**默认不声明并发安全**:它们含 SQLite 写与召回, + // 且部分依赖 per-agent 状态(驻留子是轻量内核,memory 为 nil)。 + // 保守默认 = 不并发,与它们今天的行为一致。 + ParallelSafe: false, + } + } + } return nil } @@ -129,3 +156,25 @@ func (t *toolImpl) canUseDevice(deviceID string) bool { } return deviceAuthQuery(deviceID) } + +// builtinExec 执行内核内置工具。 +// +// 放在本文件是因为要用 agentIO.ErrToolNotFound(sdk/tool.go 未导入 agentIO)。 +// 未注入 provider 时返回**类型化**的 not-found,使调用方(如 seq 的 +// missing 策略)能把它与「插件执行失败」区分开 —— 二者的处置完全不同。 +func builtinExec(name string, args map[string]interface{}) (string, error) { + if builtinProv == nil { + return "", agentIO.ToolNotFound(name) + } + return builtinProv.Exec(name, args) +} + +// hasBuiltin 报告该名字是否是可见的内置工具。 +func hasBuiltin(name string) bool { + for _, d := range builtinDefs() { + if d.Name == name { + return true + } + } + return false +}