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/ 查同一道闸:父授权了哪台设备,才允许指挥哪台。 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/ 通道,或 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) } }