feat(kernel): 内置工具注册进 ToolAPI 面(方案 B,补真机实跑发现的架构缺口)

真机实跑实证(独立实例 /tmp/seqtest,未触碰生产):
  seq_run 报「工具 knowledge_list 不存在或未注册」,
  而**同一轮模型直接调 knowledge_list 是成功的**。

根因:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是
**内核内置**工具,在 core.executeToolCallInner 里按**前缀分派**,
由 buildToolDefs 直接生成 schema,**从不进 StageHost / IOManager**
⇒ ToolAPI(只有插件工具 + IO 设备工具)既查不到也调不了。
后果:序列只能编排插件/设备工具,无法编排记忆/知识/文档/人物
——恰恰是最常用的能力。

方案 B 的实现:
· internal/sdk: 新增 BuiltinProvider(Defs/Exec)与 SetBuiltinProvider。
  用**晚绑定注入**而非让 toolImpl 依赖 core,理由:ToolAPI 是**全局单例**
  却需要 per-agent 数据(驻留子是轻量内核,memory 为 nil;内置工具可见性
  由 `if a.memory != nil` 等门控)。sdk 不能依赖 core(方向反了)。
· internal/sdk/tool_impl.go: ToolDefByName / ExecuteTool 补查内置工具。
  ⚠️ ExecuteTool 只在「io 确实没有该工具」时才转内置;io 的**执行失败**
  必须如实上抛 —— 否则会把「设备离线」误报成「工具不存在」,让调用方
  按 missing 策略跳过(与 P3 修过的父 io 吞错误同一族陷阱)。
  内置工具**默认不声明 ParallelSafe**(含 SQLite 写与召回)。
· internal/agent/core/builtin_toolapi.go: Agent 侧 provider。
  ★ Defs **复用 buildToolDefs 的同一批生成逻辑**(筛出不在
  StageHost/IOManager 中的那些),保证"模型看得到什么"与"插件看得到什么"
  门控完全一致 —— 避免两套语义。
  Exec 复用 executeToolCall 完整路径(授权闸 + 异常处理)。
· cmd/homed/bootstrap.go: agent 构造后注入。

★ 不会让模型看到重复工具(已核实):模型侧走 buildToolDefs
(a.io / a.stageHost **直调**),ToolAPI 只经 PluginSDK.Tool() 暴露给插件
—— 两条不重叠的路径。

判据(builtin_toolapi_test.go,6 条):
· 内置工具能从 ToolAPI 查到
· ★ 查到 ≠ 调得通:必须真的能执行
· 门控语义保持:未接 memory/knowledge 时不得声称有那些工具
· ★ 接了 knowledge 时必须可见(这正是要补的缺口)
· ToolAPI 上"不存在"必须是类型化 not-found(供 seq 的 missing 策略用)
· 内置工具默认不声明并发安全

真机复验(同一隔离实例,新二进制):
  序列 "smoke2" 执行完毕(1/1 组)— 工具 1 个
  变量槽: summary = cangjie/central-repo/agreement/...
⇒ knowledge_list 成功执行并回填具名槽。上一次的「不存在或未注册」已消除。

已知局限(记入待定):ToolAPI 单例而内置工具面是 per-agent,
多 agent 下看到的是"最近一个注入者"的面。本次不解决。
This commit is contained in:
JianFeeeee
2026-09-27 13:40:22 +08:00
parent 4fd18a2e83
commit 7e1169bcda
6 changed files with 373 additions and 5 deletions

View File

@ -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。<6C><E38082> 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
}