Files
HomeAgent/internal/agent/core/toolcall.go
JianFeeeee bce40b5099 feat(toolcall): 按 schema 预校验参数,在分派前拦下(阶段 1c)
问题:`required` 在仓内被声明 69 处,却**无任何消费方**(内核从不读)。
校验散落在每个工具内部手写成中文字符串("path is required"),
要等工具真被调用才暴露——而模型看到这类与真因无关的报错只会原样重试
(实测 cmd_run 失败率 34%~48% 的成因)。

改动:
· core/argvalidate.go: validateToolArgs(纯函数)+ validateArgsAgainstSchema。
  ★ 校验器刻意**宽松**:只拦真正无法解析的形态,对模型实际会写的等价形态
  一律放行。依据是工具内部 getter 的既有约定(utils.go 注释:
  "实际调用里 bool/string/float 三种都出现过";unitNumberRe 修的正是
  `"20s"` 少引号那类)。**校验比工具更严就是在制造新失败**。
  · required 判据是**键存在性** + 非空字符串;显式 null 视为已提供
    (模型可能有意传 null,工具按零值处理,判成缺失即误伤)
  · boolean 全放行(getBool 的 true/"1"/"0"/"yes"/0/1 全都合法)
  · integer 接受 int/float64/"20"/"20s";string 接受含 JSON 的长文本
  · 无 schema / 无 required / 查不到 schema ⇒ 一律放行
· core/toolcall.go: 在 __arg_error 短路**之后**、分派**之前**接入。
· io/channel.go: 新增 IOManager.ToolDefOf——没有它就只校验到插件工具,
  而 cmd_run / files_write 这类**设备/通道工具会完全绕过校验**。

判据(argvalidate_test.go,7 组):
· 缺 required 被拦下并指名字段
· ★ 误伤防线:bool 传 "true"/"0"、integer 传 float64/"20"、
  显式 null、字段顺序不同 —— 全部必须放行
· 类型确实不符报 type 错误
· 无约束场景一律放行(含 args 为 nil + schema 带 required ⇒ 应拦,
  这条我最初**误放进放行组**,写完立刻发现改正)
· 错误文案含字段名/必填/改法(否则模型只会原样重试)
· 端到端:缺参时**设备真的没被调用** + 文案指名字段
· 端到端反向:参数齐备照常执行(校验不得阻塞正常路径)

变异验证(两轮):
· 关闭分派前校验 ⇒ 端到端判据 FAIL「仍进入了工具」
· 把 boolean 校验改严格 ⇒ 宽松防线 FAIL 两个子用例(误伤 "true"/"0")

过程中三次自伤:臆造 sdkToolError 别名;number 分支写了没有绑定的 x(v);
把"显式 null"先当成缺失、过度修正后又漏掉"键不存在"的判定——
最终改为「键存在性 + 非空串」双条件,null 与缺失各归其位。

回归:internal/agent/... internal/sdk/... internal/plugin/...
      internal/plugins/... 全绿(18 包)。
2026-09-27 10:56:02 +08:00

836 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"log"
"runtime/debug"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// toolOutcome 是一次工具执行的完整结果:**文本**(给模型)与
// **原值**(给契约判断)分开携带。
//
// 为何必须分开:executeToolCall 历来只返回 string,结构化信息在这一步
// 被抹平,导致(a)ToolResult.Success 无法诚实化、(b)after_toolcall
// 阶段插件对结构化结果的改写因类型断言失败而静默失效。
type toolOutcome struct {
Text string
Raw interface{}
}
// executeToolCall 保留原签名(spawn.go 与既有测试依赖),只取文本。
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes ...string) string {
return a.executeToolCallOutcome(tc, channel, turnScenes...).Text
}
// executeToolCallOutcome 是完整形态:崩溃/超时同样以 ToolError 表达,
// 使「工具故障」与「工具报告的业务失败」在上层可区分。
func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, turnScenes ...string) (out toolOutcome) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
log.Printf("[agent] tool %s panic: %v\n%s", tc.Name, r, stack)
if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
if a.pluginHealth.recordCrash(pluginName) {
log.Printf("[agent] plugin %s exceeded crash threshold, scheduling reload", pluginName)
}
}
te := newToolError("panic", "", fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r),
"这是工具自身故障(不是你的参数问题),请勿原样重试;可换用其他工具或告知用户。")
out = toolOutcome{Text: te.Error(), Raw: te}
}
}()
done := make(chan toolOutcome, 1)
go func() {
done <- a.executeToolCallInner(tc, channel, turnScenes)
}()
select {
case result := <-done:
return result
case <-time.After(60 * time.Second):
log.Printf("[agent] tool %s timed out after 60s", tc.Name)
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("工具 %s 执行超时(60秒)", tc.Name),
"该工具本次未在时限内返回。可改用更小的任务,或换用其他工具。")
return toolOutcome{Text: te.Error(), Raw: te}
}
}
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) toolOutcome {
// 参数没法用(被 max_tokens 截断,或 JSON 写坏了):**不要**拿着空/残缺参数去调工具。
// 否则工具会报 “path is required”“command is required” 这类与真因无关的错,
// 模型看不出真因、只能原样重试(实测 cmd_run 失败率高达 34%~48%)。
// __arg_error 里带的已经是分因写好的可执行指引,直接交回模型。
if msg, ok := tc.Arguments["__arg_error"].(string); ok && msg != "" {
log.Printf("[agent] tool %s skipped: arguments unusable (truncated or malformed)", tc.Name)
return toolOutcome{Text: msg}
}
// 按 schema 预校验(阶段 1c)。放在分派**之前**:坏参数不该进到工具内部
// 再报一句与真因无关的 "path is required"——模型据此只会原样重试。
// ⚠️ 校验器刻意宽松(见 argvalidate.go):只拦真正无法解析的形态,
// 对 "true"/20/"20s" 这类宽松等价形态一律放行,避免制造新失败。
if ve := a.validateArgsAgainstSchema(tc); ve != nil {
log.Printf("[agent] tool %s rejected by schema validation: field=%s reason=%s", tc.Name, ve.Field, ve.Reason)
return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve}
}
switch {
case tc.Name == "persona_set":
return toolOutcome{Text: a.executePersonaTool(tc)}
case strings.HasPrefix(tc.Name, "memory_"):
return toolOutcome{Text: a.executeMemoryTool(tc, turnScenes)}
case strings.HasPrefix(tc.Name, "social_"):
return toolOutcome{Text: a.executeSocialTool(tc)}
case strings.HasPrefix(tc.Name, "knowledge_"):
return toolOutcome{Text: a.executeKnowledgeTool(tc)}
case strings.HasPrefix(tc.Name, "doc_"):
return toolOutcome{Text: a.executeDocTool(tc)}
case strings.HasPrefix(tc.Name, "output_send__") && strings.HasSuffix(tc.Name, "_help"):
return toolOutcome{Text: a.executeOutputSendHelp(tc)}
case strings.HasPrefix(tc.Name, "output_send__"):
return toolOutcome{Text: a.executeOutputSendTool(tc)}
case tc.Name == "output_list_channels":
return toolOutcome{Text: a.executeOutputListChannels()}
case tc.Name == "input_channels":
return toolOutcome{Text: a.executeInputChannels(tc)}
case tc.Name == "resident_agents":
return toolOutcome{Text: a.executeResidentAgents(tc)}
case tc.Name == "notify_parent":
return toolOutcome{Text: a.executeNotifyParent(tc)}
case tc.Name == "inputch_note":
return toolOutcome{Text: a.executeInputchNote(tc)}
case tc.Name == "plgreload":
return toolOutcome{Text: a.executePluginReload()}
case tc.Name == "get_plugin_tools":
pluginName, _ := tc.Arguments["plugin_name"].(string)
return toolOutcome{Text: a.executeGetPluginTools(pluginName)}
case tc.Name == "spawn_child":
return toolOutcome{Text: a.executeSpawnChild(tc, channel)}
case tc.Name == "child_result":
return toolOutcome{Text: a.executeChildResultTool(tc)}
case strings.HasPrefix(tc.Name, "llm_"):
return toolOutcome{Text: a.executeLLMTool(tc)}
case tc.Name == "describe_image":
return toolOutcome{Text: a.executeDescribeImage(tc)}
case tc.Name == "transcribe_audio":
return toolOutcome{Text: a.executeTranscribeAudio(tc)}
case tc.Name == "ocr_image":
return toolOutcome{Text: a.executeOCRImage(tc)}
}
if a.stageHost != nil {
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
// Raw 必须带上:否则结构化失败({"error":…} / ToolError)在这一步被抹平成文本,
// Success 又会退回恒真——正是阶段 1b 要修的那个洞。
return toolOutcome{Text: renderToolResult(tc.Name, result), Raw: result}
} else if !agentIO.IsToolNotFound(err) {
// 非「不存在」= 真的执行失败,如实上报(可被 on_error/retry 处置)。
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
}
// 是「不存在」:继续往下走 io / 设备路径,两处都没有才报缺工具。
}
// 设备类工具的**授权闸**(最小授权的缺口在这里)。
//
// 设备指令类工具(device_ctl_cmdrun/screensee/computeruse/...)走的是工具面,
// 而 AllowedOutputs 只作用于 output_send__<通道> —— 于是"授权"对指令类工具完全无效:
// 驻留子只要拿到 device_ctl_cmdrun 就能指挥**任意**设备。
// 这里按目标设备的通道名 device/<id> 查同一道闸:父授权了哪台设备,才允许指挥哪台。
if _, isDeviceTool := a.io.DeviceOfTool(tc.Name); isDeviceTool {
if id, _ := tc.Arguments["device_id"].(string); id != "" && !a.IsOutputAllowed("device/"+id) {
return toolOutcome{Text: fmt.Sprintf("设备 [%s] 未授权给本 agent(可用设备见 output_list_channels 的 device/<id> 通道,或 devicedetect)", id)}
}
}
if a.tracker != nil {
a.tracker.PreAction(tc.Name)
}
result, err := a.io.ExecuteTool(tc.Name, tc.Arguments)
if a.tracker != nil {
if cs := a.tracker.PostAction(tc.Name); cs != nil && len(cs.Files) > 0 {
log.Printf("[agent] tool %s changed %d files (changeset: %s)", tc.Name, len(cs.Files), cs.ID)
}
}
if err != nil {
if agentIO.IsToolNotFound(err) {
// 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。
// 文案必须让模型知道该做什么,而不是含糊的"执行失败"——
// 后者会让模型反复重试同一个不存在的名字。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+
"先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+
"确认名称无误后再调用", tc.Name)}
}
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
}
// Raw 必须带上:否则结构化失败({"error":…} / ToolError)在这一步被抹平成文本,
// Success 又会退回恒真——正是阶段 1b 要修的那个洞。
return toolOutcome{Text: renderToolResult(tc.Name, result), Raw: result}
}
// toolNotFound / isToolNotFound 是 agentIO 哨兵在 core 侧的薄封装,
// 便于 core 内部与测试直接使用(core 依赖 io,不反向)。
func toolNotFound(name string) error { return agentIO.ToolNotFound(name) }
func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) }
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
g := a.graphMem()
if g == nil {
if tc.Name == "memory_document_query" {
return a.executeDocTool(tc)
}
return "图记忆系统不可用"
}
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
requireFull := func() string {
if a.memory == nil {
return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用"
}
return ""
}
switch tc.Name {
case "memory_recall":
query, _ := tc.Arguments["query_intent"].(string)
depth, _ := tc.Arguments["depth"].(float64)
if depth <= 0 {
depth = 2
}
if query == "" {
return "请输入查询关键词"
}
// 关键词提取:支持逗号分隔和自然语言
keywords := strings.Split(query, ",")
if len(keywords) == 1 {
keywords = memory.ExtractKeywords(query)
}
result, err := g.Recall(keywords, nil, int(depth), "")
if err != nil {
return fmt.Sprintf("记忆检索失败: %v", err)
}
if len(result.Entities) == 0 && len(result.Relations) == 0 {
return "未找到相关记忆"
}
if a.indexer != nil {
names := make([]string, len(result.Entities))
for i, e := range result.Entities {
names[i] = e.Name
}
a.indexer.MarkRecalled(names...)
}
var parts []string
parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities)))
for _, e := range result.Entities {
parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type))
}
parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations)))
parts = append(parts, formatRecallRelations(result.Relations, 10)...)
// 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
//
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
// 媒体块以结构边与句子相连,需经关系→句子反查。
// 不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
if mc := a.mediaContextForRelations(result.Relations); mc != "" {
parts = append(parts, "", "关联媒体:", mc)
}
return strings.Join(parts, "\n")
case "memory_block_merge":
if msg := requireFull(); msg != "" {
return msg
}
entityA, _ := tc.Arguments["entity_a"].(string)
entityB, _ := tc.Arguments["entity_b"].(string)
rounds, _ := tc.Arguments["rounds"].(float64)
if entityA == "" || entityB == "" || rounds <= 0 {
return "entity_a、entity_b 和 rounds 不能为空"
}
if entityA > entityB {
entityA, entityB = entityB, entityA
}
key := entityA + "||" + entityB
a.noMergeMu.Lock()
a.noMergeMarkers[key] = int(rounds)
a.noMergeMu.Unlock()
return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
case "memory_commit":
triplesData, ok := tc.Arguments["triples"].([]interface{})
if !ok {
return "参数格式错误,需要 triples 数组"
}
// 场景键:模型可以在三元组里逐条给(scene 字段),也可以在工具参数
// 顶层给一次(scene 参数),后者作为本批次的默认场景。
// 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把
// 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。
batchScene := getString(tc.Arguments, "scene")
// 写侧的场景是**两条路都挂**:
// 显式声明(模型在参数里点名)优先;
// 否则挂本轮解析出的场景集合——主动声明的 + 被动涌现的。
// 只挂一条会丢东西:只挂声明则细粒度唤起丢失,只挂涌现则首次交互
// (场景还没长出来)没有兜底。
var batchScenes []string
if batchScene == "" {
batchScenes = turnScenes
}
var triples []memory.Triple
for _, td := range triplesData {
if m, ok := td.(map[string]interface{}); ok {
t := memory.Triple{
Subject: getString(m, "subject"),
Relation: getString(m, "relation"),
Object: getString(m, "object"),
SentenceText: getString(m, "sentence_text"),
Scene: getString(m, "scene"),
}
if t.Scene == "" {
t.Scene = batchScene
}
if len(t.Scenes) == 0 {
t.Scenes = batchScenes
}
// 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.MediaDigests = a.resolveMediaDigests(digests)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
}
}
if t.Subject != "" && t.Relation != "" && t.Object != "" {
triples = append(triples, t)
}
}
}
if len(triples) == 0 {
return "没有有效的三元组"
}
// remember 工具是用户/模型显式写入,不涉及归档删除,
// 因此不需要 mediaBound——没有旧引用要释放。
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil)
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
if mb > 0 {
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系,关联 %d 份媒体", ec, rc, mb)
}
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
case "memory_introspect":
if msg := requireFull(); msg != "" {
return msg
}
stats, err := a.memory.Introspect()
if err != nil {
return fmt.Sprintf("查询失败: %v", err)
}
return fmt.Sprintf("记忆统计: %v", stats)
case "memory_document_query":
return a.executeDocTool(tc)
case "memory_merge":
if msg := requireFull(); msg != "" {
return msg
}
source, _ := tc.Arguments["source"].(string)
target, _ := tc.Arguments["target"].(string)
if source == "" || target == "" {
return "source 和 target 不能为空"
}
count, err := a.memory.MergeEntities(source, target)
if err != nil {
return fmt.Sprintf("合并失败: %v", err)
}
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
case "memory_delete_entity":
if msg := requireFull(); msg != "" {
return msg
}
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name 不能为空"
}
if err := a.memory.DeleteEntity(name); err != nil {
return fmt.Sprintf("删除失败: %v", err)
}
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
case "memory_purge":
if msg := requireFull(); msg != "" {
return msg
}
criteria := make(map[string]string)
if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
criteria["subject_contains"] = v
}
if v, ok := tc.Arguments["relation_type"].(string); ok && v != "" {
criteria["relation_type"] = v
}
if v, ok := tc.Arguments["target_contains"].(string); ok && v != "" {
criteria["target_contains"] = v
}
mode, _ := tc.Arguments["mode"].(string)
if mode == "" {
mode = "soft"
}
n, err := a.memory.Purge(criteria, mode)
if err != nil {
return fmt.Sprintf("删除图记忆失败: %v", err)
}
textRemoved := 0
if a.textMem != nil {
if subj, ok := criteria["subject_contains"]; ok && subj != "" {
textRemoved, _ = a.textMem.PurgeByFilter(func(evt text.Event) bool {
return strings.Contains(evt.Source, subj) || strings.Contains(evt.Input, subj) || strings.Contains(evt.Response, subj)
})
}
}
parts := []string{fmt.Sprintf("已%s删除 %d 条图记忆关系", mode, n)}
if textRemoved > 0 {
parts = append(parts, fmt.Sprintf("清理 %d 条文本记忆日志", textRemoved))
}
return strings.Join(parts, ",")
case "memory_edit":
if msg := requireFull(); msg != "" {
return msg
}
oldSubject, _ := tc.Arguments["old_subject"].(string)
oldRelation, _ := tc.Arguments["old_relation"].(string)
oldObject, _ := tc.Arguments["old_object"].(string)
if oldSubject == "" || oldRelation == "" || oldObject == "" {
return "old_subject、old_relation、old_object 不能为空"
}
newSubject, _ := tc.Arguments["new_subject"].(string)
newRelation, _ := tc.Arguments["new_relation"].(string)
newObject, _ := tc.Arguments["new_object"].(string)
if newSubject == "" && newRelation == "" && newObject == "" {
return "至少提供一个新值(new_subject / new_relation / new_object)"
}
if newSubject == "" {
newSubject = oldSubject
}
if newRelation == "" {
newRelation = oldRelation
}
if newObject == "" {
newObject = oldObject
}
// 编辑前先精确取回旧关系:Purge 是「删旧写新」,中间那一步会把
// 置信度、场景引用、原句一起丢掉。复审心跳(reviewLoop)正是走这条路,
// 于是每次复审都把置信度重置成默认 1.0、把场景钉死的记忆打散成无场景,
// 而且没有任何日志——这类「静默降级」比报错难查得多。
var carriedConf float64
var carriedSentence, carriedScene string
if olds, ferr := a.memory.FindRelations(oldSubject, oldRelation, oldObject); ferr == nil && len(olds) > 0 {
carriedConf = olds[0].Confidence
carriedSentence = olds[0].SentenceText
if keys, serr := a.memory.ScenesOfRelation(olds[0].ID); serr == nil && len(keys) > 0 {
carriedScene = keys[0]
}
}
n, err := a.memory.Purge(map[string]string{
"subject_contains": oldSubject,
"relation_type": oldRelation,
"target_contains": oldObject,
}, "hard")
if err != nil {
return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err)
}
triples := []memory.Triple{{
Subject: newSubject,
Relation: newRelation,
Object: newObject,
Confidence: carriedConf,
SentenceText: carriedSentence,
Scene: carriedScene,
}}
ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
if err != nil {
return fmt.Sprintf("编辑图记忆失败(写入新记录): %v", err)
}
textReplaced := 0
if a.textMem != nil && oldSubject != "" {
textReplaced, _ = a.textMem.ReplaceByFilter(
func(evt text.Event) bool {
return strings.Contains(evt.Input, oldSubject) || strings.Contains(evt.Response, oldSubject)
},
func(evt text.Event) text.Event {
evt.Input = strings.ReplaceAll(evt.Input, oldSubject, newSubject)
evt.Response = strings.ReplaceAll(evt.Response, oldSubject, newSubject)
return evt
},
)
}
result := fmt.Sprintf("已编辑记忆:删除 %d 条旧关系,写入 %d 个实体 + %d 条新关系", n, ec, rc)
if textReplaced > 0 {
result += fmt.Sprintf(",更新 %d 条文本记忆日志", textReplaced)
}
return result
default:
return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
}
}
func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
if a.social == nil {
return "人物关系网不可用(social store 未初始化)"
}
switch tc.Name {
case "person_query":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "请输入人物名称"
}
profile, err := a.social.GetPerson(name)
if err != nil {
return fmt.Sprintf("查询人物失败: %v", err)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎%s 的档案", name))
if len(profile.Traits) > 0 {
parts = append(parts, "【特质】")
for k, v := range profile.Traits {
parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
}
}
if len(profile.Relations) > 0 {
parts = append(parts, "【社交关系】")
for _, r := range profile.Relations {
parts = append(parts, fmt.Sprintf(" %s —(%s)—→ %s", name, r.Relation, r.Person))
}
}
if len(profile.Traits) == 0 && len(profile.Relations) == 0 {
parts = append(parts, " (尚无记录)")
}
return strings.Join(parts, "\n")
case "person_set_trait":
name, _ := tc.Arguments["name"].(string)
trait, _ := tc.Arguments["trait"].(string)
value, _ := tc.Arguments["value"].(string)
if name == "" || trait == "" || value == "" {
return "name、trait、value 都不能为空"
}
if err := a.social.SetTrait(name, trait, value); err != nil {
return fmt.Sprintf("设置特质失败: %v", err)
}
return fmt.Sprintf("已记录:%s 的 %s = %s", name, trait, value)
case "person_relate":
personA, _ := tc.Arguments["person_a"].(string)
relation, _ := tc.Arguments["relation"].(string)
personB, _ := tc.Arguments["person_b"].(string)
if personA == "" || relation == "" || personB == "" {
return "person_a、relation、person_b 都不能为空"
}
if err := a.social.AddRelation(personA, relation, personB); err != nil {
return fmt.Sprintf("建立关系失败: %v", err)
}
return fmt.Sprintf("已记录:%s —(%s)—→ %s", personA, relation, personB)
case "person_network":
name, _ := tc.Arguments["name"].(string)
depth := int(getFloat(tc.Arguments, "depth"))
if depth <= 0 {
depth = 2
}
if name == "" {
return "请输入人物名称"
}
profiles, err := a.social.GetNetwork(name, depth)
if err != nil {
return fmt.Sprintf("查询社交网络失败: %v", err)
}
if len(profiles) == 0 {
return fmt.Sprintf("未找到 %s 的社交网络", name)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎%s 的社交网络(%d 度)", name, depth))
for _, p := range profiles {
if p.Name == name {
continue
}
parts = append(parts, fmt.Sprintf(" · %s", p.Name))
for k, v := range p.Traits {
parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
}
for _, r := range p.Relations {
if r.Person != name {
parts = append(parts, fmt.Sprintf(" —(%s)—→ %s", r.Relation, r.Person))
}
}
}
return strings.Join(parts, "\n")
default:
return fmt.Sprintf("未知的人物工具: %s", tc.Name)
}
}
// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。
//
// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了
// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。
// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。
func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef {
if a.mediaStore == nil || len(digests) == 0 {
return nil
}
var out []knowledge.KnowledgeMediaRef
for _, d := range a.resolveMediaDigests(digests) {
it, err := a.mediaStore.Stat(d)
if err != nil {
continue
}
out = append(out, knowledge.KnowledgeMediaRef{
Digest: it.Digest,
MIME: it.MIME,
Kind: string(it.Kind),
})
}
return out
}
func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if a.knowledge == nil {
return "知识库不可用"
}
switch tc.Name {
case "knowledge_search":
query, _ := tc.Arguments["query"].(string)
topK := int(getFloat(tc.Arguments, "top_k"))
if topK <= 0 {
topK = 5
}
if query == "" {
return "请输入查询关键词"
}
// 可选分类限定:把召回限制在某棵分类子树内(前缀匹配,见
// knowledge.Store.SearchIn)。不传 = 全库。
category, _ := tc.Arguments["category"].(string)
results := a.knowledge.SearchIn(query, category, topK)
if len(results) == 0 {
return "未找到相关知识"
}
var parts []string
for i, k := range results {
if i >= topK {
break
}
// Name 已是含分类的规范名("tech/go/并发"),分类前缀就在里面。
// 曾经这里再拼一次 Category,输出成 "tech/go/tech/go/并发"(实测)。
parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200)))
}
return strings.Join(parts, "\n---\n")
case "knowledge_create":
name, _ := tc.Arguments["name"].(string)
content, _ := tc.Arguments["content"].(string)
if name == "" || content == "" {
return "name 和 content 不能为空"
}
// 模型可显式关联已入库的媒体(与 doc_commit 的 media_digests 同形)。
// 这些媒体成为知识条目的一等节点:其向量会与正文向量融合,
// 使该条目能按图本身被召回,而不依赖任何生成的描述文本。
media := a.knowledgeMediaRefs(getStringSlice(tc.Arguments, "media_digests"))
if err := a.knowledge.AddWithMedia(name, content, media); err != nil {
return fmt.Sprintf("知识创建失败: %v", err)
}
if len(media) > 0 {
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符,%d 个媒体参与跨模态召回)", name, len(content), len(media))
}
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
case "knowledge_list":
tree := a.knowledge.BuildTree()
return formatTree(tree, 0)
case "knowledge_import_dir":
dir, _ := tc.Arguments["dir"].(string)
category, _ := tc.Arguments["category"].(string)
// dry_run 默认 true:导入是批量写,agent 第一次试某个目录时
// 应该先看清会写什么。默认直接写等于让它盲写一批数据。
dryRun := true
if b, ok := getBool(tc.Arguments, "dry_run"); ok {
dryRun = b
}
includeMedia := false
if b, ok := getBool(tc.Arguments, "include_media"); ok {
includeMedia = b
}
st, err := a.knowledge.ImportDir(knowledge.ImportOptions{
Dir: dir,
Category: category,
DryRun: dryRun,
IncludeMedia: includeMedia,
MaxItems: int(getFloat(tc.Arguments, "max_items")),
})
if err != nil {
return fmt.Sprintf("知识导入失败: %v", err)
}
var b strings.Builder
verb := "已导入"
if dryRun {
verb = "将导入(dry_run,未实际写入)"
}
fmt.Fprintf(&b, "%s %d 条", verb, st.Imported)
if category != "" {
fmt.Fprintf(&b, "(分类前缀 %s)", category)
}
if st.Media > 0 {
fmt.Fprintf(&b, ",含 %d 个媒体", st.Media)
}
if st.Skipped > 0 {
fmt.Fprintf(&b, ";跳过 %d", st.Skipped)
}
if st.Failed > 0 {
fmt.Fprintf(&b, ";失败 %d", st.Failed)
}
if st.Truncated {
fmt.Fprintf(&b, ";★ 超出 max_items 被截断,未导完(可调大 max_items 或分批)")
}
if len(st.Names) > 0 {
show := st.Names
if len(show) > 10 {
show = show[:10]
}
fmt.Fprintf(&b, "。知识名:%s", strings.Join(show, "、"))
if len(st.Names) > 10 {
fmt.Fprintf(&b, " …共 %d 个", len(st.Names))
}
}
// 失败原因要报给 agent:否则它只知道"失败 37 条"却无从下手。
for _, e := range st.Errors {
fmt.Fprintf(&b, "\n- %s", e)
}
return b.String()
case "knowledge_delete":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name 不能为空"
}
if err := a.knowledge.Remove(name); err != nil {
return fmt.Sprintf("知识删除失败: %v", err)
}
return fmt.Sprintf("知识「%s」已删除", name)
default:
return fmt.Sprintf("未知的知识工具: %s", tc.Name)
}
}
func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if a.docStore == nil {
return "文档记忆不可用"
}
switch tc.Name {
case "doc_query":
query, _ := tc.Arguments["query"].(string)
topK := int(getFloat(tc.Arguments, "top_k"))
if topK <= 0 {
topK = 3
}
if query == "" {
return "请输入查询内容"
}
docs := a.docStore.Consume(query, topK)
if len(docs) == 0 {
return "未找到相关文档记忆"
}
var parts []string
var refs []string
for i, d := range docs {
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
if len(d.Tags) > 0 {
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
}
content := d.Content
if len(content) > 2000 {
content = content[:2000] + "..."
}
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 截掉之后模型就不知道这篇文档带过图。
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
})
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
}
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
len(docs), strings.Join(refs, ", "))
case "doc_commit":
content, _ := tc.Arguments["content"].(string)
summary, _ := tc.Arguments["summary"].(string)
if content == "" {
return "content 不能为空"
}
if summary == "" {
summary = truncateStr(content, 100)
}
tagsRaw, _ := tc.Arguments["tags"].([]interface{})
var tags []string
for _, t := range tagsRaw {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
doc := &document.Doc{
Summary: summary,
Content: content,
Tags: tags,
Source: "manual",
}
// 模型显式关联的媒体:直接变成文档持有的一等块。
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
// 图片按自己的向量被检索。
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok := a.blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
}
if err := a.docStore.Insert(doc); err != nil {
return fmt.Sprintf("文档写入失败: %v", err)
}
if n := len(doc.Blocks); n > 0 {
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n)
}
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
default:
return fmt.Sprintf("未知的文档工具: %s", tc.Name)
}
}