Files
HomeAgent/internal/agent/core/toolcall.go
JianFeeeee 86572d134d refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
2026-10-12 10:13:36 +08:00

1105 lines
43 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 "github.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.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)}
// ★★ 2026-10-06:describe_image / transcribe_audio / ocr_image 已移出内核,
// 改为内置插件 multimodal(见 internal/plugins/multimodal)。
// 理由:它们要读本地文件(IO),而内核承诺零 IO。
// 这三个名字现在由 StageHost 路由到插件 —— 上面 if 走不到,
// 会落到 stageHost.ExecuteTool,正是插件工具的正常路径。
}
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)
}
// 排序模式:默认相关性;显式要"最近/最新"时用 recent。
//
// ★ 为什么要分两种(实测 v4 跑分):overwrite 组考的是"新值覆盖旧值",
// 相关性排序下新旧同名实体的命中层级完全相同,只能靠时间分胜负;
// 而 casual 组问"某个服务端口是多少",要的是实词精确命中。
// 用一个排序同时服务这两类问题,必然有一边错。
sortMode := memory.ParseSortMode(fmt.Sprint(tc.Arguments["sort"]))
// 块向量召回优先。
//
// ★ 为什么要有它(这是这条路径缺失的直接后果):memory_blocks 是
// 带 vector+fingerprint 的图节点载体,但此前**没有任何召回读它** ——
// BlocksForNode 只能按端点反查,得先知道 nodeID。于是「问一个具体
// 问题」只能退到 entities 的 jieba+LIKE,实测跨维度定位 0/5
//(问「第181批的值班手册是第几版」完全答不出)。
//
// 混合而非替换:端口号(8328)、分机号(4324)这类纯数字串向量天然弱,
// 而它们恰是本项目最常问的。两条路都跑,块向量在前,符号路兜底。
if blockOut := a.recallByBlocks(query); blockOut != "" {
return blockOut
}
// ★ fingerprint 必须传:这是 recallByBlocks 失败后的兜底路,
// 不传就等于让旧向量空间的块混进结果
// (判据 TestMemoryRecall_指纹不匹配的块被跳过)。
result, err := g.RecallSorted(keywords, nil, int(depth), "", a.currentFingerprint(), sortMode)
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 个相关实体(按%s排序):", len(result.Entities), sortLabel(sortMode)))
for _, e := range result.Entities {
// ★ 带出匹配层级:此前输出是同格式平铺,模型无从判断该信哪条。
//
// 实测 v4:193 个实体平铺(13744 tokens)后,模型放弃向量检索、
// 转去 grep 知识库文件,还把"没检索到"说成"库里不存在"。
// 层级标记让最相关的几条一眼可辨。
tag := ""
switch {
case e.MatchRank == 0:
tag = ", 精确匹配"
case e.MatchRank == 1:
tag = ", 前缀匹配"
}
parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s%s)", e.Name, e.MentionCount, e.Type, tag))
}
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 写进句子文本。值是块 ID(或其唯一前缀)。
if refs := getStringSlice(m, "media_digests"); len(refs) > 0 {
t.MediaDigests = a.resolveMediaRefs(refs)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 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——没有旧引用要释放。
// ★★ 写入前取块数基线(2026-10-04)
//
// 旧表双写已停 ⇒ Commit 的 ec/rc 恒为 0,
// 拿它们判断「有没有写进去」会**永远判成没写进去**。
blocksBefore, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
edgesBefore, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
// ★ ec/rc 丢弃(2026-10-05):它们恒为 0(graph.go 里
// relationsCreated = 0 写死),拿它们当「写了几条」会**永远说 0**。
// 保留位置只是因为 commitTriplesWithMedia 的签名未变。
// 唯一可信信号是下面实测的 newBlocks / newEdges。
_, _, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil)
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
blocksAfter, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
edgesAfter, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
}
newBlocks := blocksAfter - blocksBefore
newEdges := edgesAfter - edgesBefore
// ★ 0 写入必须显式报告:提交了 N 条但一条都没落库(如实体名校验被拒)
// 却回「已写入 0 个」,模型会当成成功而永不重试 —— 实测(2026-10-01
// 跑分):metrics 端口/分机号更新全部因此静默丢失。
// ★ 判据用**新增块数**,不是 ec/rc(2026-10-04)
//
// ★★ 这条提示曾经会让模型做错事:
//
// 旧表停写后 ec/rc 恒为 0 ⇒ 每次提交都被告知
// 「全部被拒,请检查实体名写法」——
// 而写入其实**成功了**。
//
// 模型据此去改不该改的东西(把正常实体名改短、
// 加字母数字),把记忆内容改坏。
//
// ⇒ 「报假失败」比「报假成功」危险:前者会诱发破坏性动作。
if newBlocks == 0 && mb == 0 {
return fmt.Sprintf("提交了 %d 条三元组但全部被拒(未写入)。常见原因:实体名为空、过长(>50 字)、或不含字母/汉字/数字。请检查主语/宾语的写法后重试。", len(triples))
}
// ★★★ 成功分支也**不能用 ec/rc**(2026-10-05)
//
// 旧表停双写后 ec/rc 恒为 0,所以这里原本回
// 「已写入 0 个实体和 0 条关系」——**而实际写进去了**。
//
// 实测(隔离实例):一次对话写入 7 块 9 边,工具却回
// 「已写入 0 个实体和 0 条关系」与「全部被拒(未写入)」。
// 模型据此认为记忆系统坏了,回复里明说
// 「the write is being rejected」并放弃重试。
//
// ★ 与上面那个「报假失败」是同一个病的两种表现:
// 判据用 newBlocks(对)但文案用 ec/rc(错),
// 于是同一份代码里两套口径打架。
//
// 唯一可信的信号是 newBlocks(块总量差值)——
// 它直接量「库里多了几个块」,不依赖任何旧表口径。
if mb > 0 {
return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边,关联 %d 份媒体",
newBlocks, newEdges, mb)
}
return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边", newBlocks, newEdges)
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 不能为空"
}
res, err := a.memory.DeleteEntity(name)
if err != nil {
return fmt.Sprintf("删除失败: %v", err)
}
// ★ 如实报告实际删掉多少,不说「已彻底删除…及其所有关联关系」。
//
// 旧文案是谎报:底层只碰旧表、活图谱 Δ0,却回「彻底删除」。
// 模型据此认为内容已消失(不再提及或重新写入),
// 而关联边还在、召回继续命中 —— 谎报会让模型的行为跟着错。
if res.Blocks == 0 {
return fmt.Sprintf("未找到名为「%s」的块,未删除任何内容", name)
}
return fmt.Sprintf("已删除块「%s」及其 %d 条关联关系(块 %d 个)",
name, res.Edges, res.Blocks)
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 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。
//
// 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错——
// 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要
// (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。
func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef {
if a.memory == nil || len(refs) == 0 {
return nil
}
var out []knowledge.KnowledgeMediaRef
for _, r := range refs {
b, ok, err := a.memory.BlockByIDPrefix(r)
if err != nil || !ok {
log.Printf("[knowledge] 媒体块引用 %s 无法唯一解析,跳过", r)
continue
}
out = append(out, knowledge.KnowledgeMediaRef{
Digest: b.ID,
MIME: b.MIME,
Kind: string(b.Modality),
Data: b.Data,
})
}
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——文档向量会融合这些块的媒体向量,
// 图片按自己的向量被检索。值是块 ID(或其唯一前缀)。
for _, ref := range a.resolveMediaRefs(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok, err := a.memory.BlockByIDPrefix(ref); err == nil && 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)
}
}
// sortLabel 把排序模式翻成给模型看的中文标签。
func sortLabel(m memory.SortMode) string {
if m == memory.SortRecent {
return "时间倒序,最新在前"
}
return "相关性"
}
// recallByBlocks 用稠密向量召回块节点,返回格式化文本;不可用/无命中时返回空串。
//
// 返回空串让调用方无缝退回符号路 —— 这保证了「向量侧没配好」不会让
// memory_recall 整体失败(那会让模型完全失去记忆,比召回差得多)。
func (a *Agent) recallByBlocks(query string) string {
if a == nil || a.memory == nil || a.multimodalSpace == nil {
return ""
}
if !a.multimodalSpace.Loaded() {
return ""
}
vec, err := a.multimodalSpace.VectorizeDense(query)
if err != nil || len(vec) == 0 {
// 向量化失败不报错到工具层:符号路仍可用,而这条失败通常意味着
// 模型未加载/超时,报给模型只会让它以为"记忆不存在"。
return ""
}
// ★ 走**融合**入口(向量 + 符号),而不是纯向量或仲裁-only
//
// 三层根因(生产快照 1391 块实测):
//
// 1. 各向异性 已修(中心化,补上了生产调用者)
// 2. 精确串被稀释 向量模型固有限制:「13010/13011 而非 12011」
// 含精确串却召不回,而「本地网关8081」召回了
// 3. 符号路无补位 本函数修的就是这个
//
// 旧实现是「块向量在前,符号路兜底」—— 块一旦有命中就直接 return,
// 符号路的 RecallSorted **根本没被调用**。设计注释写的
//「端口号、分机号这类纯数字串向量天然弱」是对的,但没有真正生效。
//
// 而仲裁仍必须在 topK 截断**之前**(RecallBlocksFused 内部
// 已按「放大 TopK → 符号融合 → 仲裁 → 截断」实现):
// 实测旧号 4379 以 0.8127 排 top1 而新号进不了 top8,
// 事后仲裁无从挽回,因为被判取代的旧值和新值都不在候选里。
//
// ★ MinScore 传 0 而不是 blockRecallMinScore(0.5):
// 融合分数的**量纲变了** —— 精确串命中是 1.0(布尔置顶),
// 而向量加权只有 0.7×余弦。用 0.5 筛会把「精确串命中 1.0」
// 留下,却把所有纯向量候选(0.35~0.45)全筛掉 ——
// 反而丢掉向量侧的有效召回。筛选改由融合内部按路处理。
// ★ 走**带拒答的**入口 RecallBlocksGuarded。
//
// 拒答在图库层而不是这一层,是有教训的:第一版把 AbstainCheck
// 写在这里(core 层),而探针在 memory 包内直连图库,
// 于是**探针完全绕过了拒答** —— 端到端跑出 abstention 0/3,
// 但生产路径其实是有拒答的,却没人能证明它。
// 「判据测不到被测路径 ⇒ 判据等于不存在」。
//
// 拒答为什么必须在召回之前:编造的成因正是「查询符号在库里零出现」
// ⇒ 向量却仍给 0.84+ 的高分(grafana/kafka/容灾演练 三类都是)。
// 先召回再判断的话,看到的是一堆高分块 ——
// 而「分数高」本身不能证明相关。
hits, abstain, arb, err := a.memory.RecallBlocksGuarded(
memory.BlockRecallQuery{
Vector: vec,
Fingerprint: a.multimodalSpace.Fingerprint(),
TopK: blockRecallTopK,
// ★ MinScore 保留:融合内部按路应用(只约束纯向量那一路,
// 精确串/纯符号命中不受它约束)。曾误传 0 让噪声过滤失效,
// toolcall_block_test.go 的两条判据立刻红了。
MinScore: blockRecallMinScore,
}, query)
if abstain != nil {
log.Printf("[memory] abstain: 符号零命中,拒答「%s」", query)
return abstain.Notice
}
if err != nil || len(hits) == 0 {
return ""
}
if n := len(arb.Superseded); n > 0 {
// 被取代的块不返回给模型,但**记一笔**:旧值被显式作废这件事
// 本身有信息(「旧号 4379 停用」解释了为什么现在打不通),
// 而完全静默会让「记忆里为什么没有旧号」变成无解之谜。
log.Printf("[memory] recall blocks: %d 条被时序仲裁剔除(被更新的值取代)", n)
}
// 归因统计:融合到底救回了多少条 —— 这是判断它在起作用的关键读数
var bySymbol, byExact int
for _, h := range hits {
if h.ExactHit {
byExact++
} else if h.VectorHit == 0 && h.SymbolHit > 0 {
bySymbol++
}
}
if byExact+bySymbol > 0 {
log.Printf("[memory] recall blocks: 符号路补位 %d 条(精确串 %d,词法 %d)",
byExact+bySymbol, byExact, bySymbol)
}
var parts []string
parts = append(parts, fmt.Sprintf("找到 %d 条相关记忆片段:", len(hits)))
for _, h := range hits {
text := strings.TrimSpace(h.Text)
if text == "" {
continue
}
// 分数照旧给模型看(它用这个判断相关性),
// 但精确串命中统一显示 1.00 —— 它是布尔信号不是强度信号。
score := h.Score
if h.ExactHit {
score = 1.0
}
tag := "text"
if h.ExactHit {
tag = "exact"
} else if h.VectorHit == 0 && h.SymbolHit > 0 {
tag = "symbol"
}
parts = append(parts, fmt.Sprintf("- [%s %.2f] %s",
tag, score, truncateStr(text, 160)))
}
return strings.Join(parts, "\n")
}
const (
// blockRecallTopK 是块召回的条数上限。与实体路的 20 条同量级:
// 实测 193 条平铺会把模型淹没(13744 tokens / 预算 436%)。
blockRecallTopK = 8
// blockRecallMinScore 是入选下限。低于它的候选分数已无区分意义
// (生成模型主干的余弦普遍偏高,实测无关句也能到 0.83)。
blockRecallMinScore = 0.5
)
// currentFingerprint 返回**当前向量空间**的指纹,用于给兜底召回路径做隔离。
//
// ★ 为什么需要它
//
// memory_recall 的主路是 recallByBlocks(带向量空间检查),
// 失败时退到 RecallSorted —— 而 RecallSorted 是纯词法的,不查 fingerprint
// ⇒ 换向量空间后旧块会从兜底路混回结果。
// (判据 TestMemoryRecall_指纹不匹配的块被跳过 就是抓这个的)
//
// ★★ 为什么用「当前空间的指纹」而不是「库里多数取值」
//
// 我先写的版本是查库(DominantBlockFingerprint)—— **错的**:
// 库里只有旧空间的块时,多数取值就是旧指纹,
// 拿它当过滤条件等于「不<E3808C><E4B88D>滤」,旧块原样通过。
// (判据当场抓住:测试库里唯一的块是 old-fp,而当前空间是 fp1。)
//
// 隔离的语义是「只信任当前空间写入的块」,
// 判据必须来自**空间对象**而不是库 —— 库只能说明过去,不能说明现在。
func (a *Agent) currentFingerprint() string {
if a == nil || a.multimodalSpace == nil {
return ""
}
if !a.multimodalSpace.Loaded() {
return ""
}
return a.multimodalSpace.Fingerprint()
}