Files
HomeAgent/internal/agent/core/toolcall.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

1494 lines
59 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 (
"context"
"fmt"
"log"
"runtime/debug"
"strings"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
"github.com/JianFeeeee/HologramAgent/internal/toolroute"
)
// 工具执行超时的默认值与硬上限。
const (
// defaultToolTimeout 是**未声明** Timeout 的工具沿用的值。
// 与改动前逐字一致(60s):存量插件不改一行,行为不变。
defaultToolTimeout = 60 * time.Second
// maxToolTimeout 是硬上限,防止插件声明成 0(无限)或荒唐的大值
// 把任务队列堵死。10 分钟足够覆盖已知的长任务:
// cmd_run 长命令 / healthcheck(120s)/ output_send 到子 pipeline。
maxToolTimeout = 10 * time.Minute
)
// resolveToolTimeout 取某个工具应使用的超时。
//
// 顺序:ToolDef.Timeout(插件声明)→ defaultToolTimeout。
// 声明值非法(解析失败 / ≤0 / 超上限)时**回退默认并记日志**,
// 不静默接受:写错单位(如 "130" 少了 s)是最容易犯的错,
// 让它默默变成 60s 会让人以为"声明没生效"却查不出原因。
func (a *Agent) resolveToolTimeout(toolName string, args map[string]interface{}) time.Duration {
if a.stageHost == nil {
return defaultToolTimeout
}
def := a.stageHost.ToolDef(toolName)
if def == nil {
// 内置工具(output_send__* 等)不在 StageHost 里 —— 走内置表。
def = &sdk.ToolDef{}
}
// ★ 第二条通道:调用参数里的 timeout。
//
// 有些工具的时长**不是固定的**,而是由本次调用参数决定 —— 典型是
// cmd_run(模型自己传 timeout)。只做静态声明修不好它:
// 声明 130s:模型传 timeout=10s 时白等(浪费配额)
// 声明 30s :模型传 timeout=2m 时仍被砍(就是原 bug)
// 所以这里把参数值也算进来,取**较大者**。
//
// 为什么取较大而不是直接用参数值:参数是模型给的,可能写成
// "1" 这种缺单位的(生产日志实测过)或是恶意的超大值。
// 用 max(声明值, 参数值) 并夹到硬上限,两边都兜住。
base := defaultToolTimeout
// 声明来源有两处:StageHost(插件工具 + 经 RegisterTool 注册的)
// 与内置工具表(output_send__* 等由 switch 分派的)。
// def.Timeout 为空时再查内置表。
declared := strings.TrimSpace(def.Timeout)
if declared == "" {
declared = builtinToolTimeout(toolName)
}
if declared != "" {
if d, err := time.ParseDuration(declared); err == nil && d > 0 {
base = d
} else if err != nil {
log.Printf("[agent] tool %s declares Timeout=%q which failed to parse (%v), using default %v",
toolName, declared, err, defaultToolTimeout)
}
}
if v, ok := args["timeout"].(string); ok && strings.TrimSpace(v) != "" {
if d, err := time.ParseDuration(strings.TrimSpace(v)); err == nil && d > base {
base = d
}
}
if base > maxToolTimeout {
log.Printf("[agent] tool %s timeout %v exceeds hard cap, clamped to %v", toolName, base, maxToolTimeout)
return maxToolTimeout
}
return base
}
// resolveToolTimeoutDeclared 只取静态声明(保留给需要区分来源的调用方)。
func (a *Agent) resolveToolTimeoutDeclared(toolName string) time.Duration {
if a.stageHost == nil {
return defaultToolTimeout
}
def := a.stageHost.ToolDef(toolName)
if def == nil || strings.TrimSpace(def.Timeout) == "" {
return defaultToolTimeout
}
d, err := time.ParseDuration(strings.TrimSpace(def.Timeout))
if err != nil {
log.Printf("[agent] tool %s declares Timeout=%q which failed to parse (%v), falling back to default %v",
toolName, def.Timeout, err, defaultToolTimeout)
return defaultToolTimeout
}
if d <= 0 {
log.Printf("[agent] tool %s declares Timeout=%q which is not positive, falling back to default %v",
toolName, def.Timeout, defaultToolTimeout)
return defaultToolTimeout
}
if d > maxToolTimeout {
log.Printf("[agent] tool %s declares Timeout=%v exceeding hard cap, clamped to %v",
toolName, d, maxToolTimeout)
return maxToolTimeout
}
return d
}
// fmtDuration 把时长写成给模型看的中文形式(秒/分钟)。
//
// 不用 time.Duration.String():它给出 "1m0s"、"1h30m0s" 这类紧凑写法,
// 模型读得懂,但用户看错误提示时"1m0s"不如"1 分钟"直观。
func fmtDuration(d time.Duration) string {
if d%time.Minute == 0 && d >= time.Minute {
return fmt.Sprintf("%d min", int(d/time.Minute))
}
return fmt.Sprintf("%d s", int(d/time.Second))
}
// 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("Tool %s panicked: %v", tc.Name, r),
"This is a tool-side fault, not an argument problem. Do not retry with the same input; pick another tool or tell the user.")
out = toolOutcome{Text: te.Error(), Raw: te}
}
// ★★ 出向脱敏(2026-10-18 补):工具返回值在交给模型**之前**必须清洗。
//
// ## 原缺陷
//
// 安全模块只做了**入向**回填(StageHost.ExecuteTool 里 FillMap,
// 把别名换成真凭据给插件用),**从不处理出向**。于是:
//
// 模型调 enc_shell 跑 curl → 服务端把 key 回显在响应体里
// → 工具原样返回 → f.CurResult → f.Msgs(role=tool)
// → **明文 key 直接进了模型上下文**
//
// 实测证据(2026-10-18 08:48,/home/newqqagent/log):
// `[security] 工具 enc_shell 参数回填 1 处(别名 [sec_...])` ← 入向有
// 紧随其后 `tool enc_shell result: {...}` ← 出向无
//
// ## 为什么 scrubbing 已有却不管用
//
// `scrubEventSecrets` 只在 `RelevanceContext.Append`(写上下文/记忆)
// 时运行 —— 那是**工具结果已经发出去之后**。同一轮里模型看到的是
// 未清洗的 f.Msgs。记忆侧干净、当轮泄漏,是最坏的组合:事后查不到。
//
// ## 为什么放在这个 defer 里
//
// executeToolCallOutcome 有多个 return 出口(插件工具、io 工具、
// 设备授权拒绝、不存在、panic),逐个加必然漏。defer 是唯一
// 能覆盖全部分支的位置,且 panic 路径也一起覆盖。
if out.Text != "" {
if st := security.Global(); st != nil {
if clean, evts := st.Replace(out.Text, "tool_out:"+tc.Name, security.Patterns); len(evts) > 0 {
out.Text = clean
for _, e := range evts {
log.Printf("[security] tool output %s blocked %s (pattern %s, %d bytes)",
tc.Name, e.Alias, e.Kind, e.Len)
}
}
}
}
}()
// ★ 取消必须**真的传到对端**,而不只是让本函数不再等。
//
// 旧实现只有 time.After:到点后本函数返回,但那个 goroutine 仍在跑,
// 阻塞在跨进程 Call 上且**没有任何通道能通知它**——插件侧的 handler
// 毫不知情,继续占着连接/子进程/文件句柄直到自己跑完,结果却被丢弃。
// 长工具 + 频繁超时 ⇒ 后台持续堆积。
//
// 现在把 ctx 沿执行链传下去(经 args 旁路,见 StageHost.ExecuteToolCtx),
// 由 proc 桥的 CallContext 在取消时向插件发 call.cancel。
// **插件作者一行都不用改**:handler 签名不变。
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 正常返回也要放:否则 ctx 内部的 timer/子 goroutine 会泄漏
done := make(chan toolOutcome, 1)
go func() {
done <- a.executeToolCallInner(ctx, tc, channel, turnScenes)
}()
// ★ 超时:优先用工具**自己声明**的时长,而不是一刀切 60s。
//
// 2026-10-17 生产实测的缺陷:这里原本写死 60s,与工具自己的超时配置
// **互相矛盾**且让内层形同虚设:
//
// 23:13:38 执行 cmd_run(agent 传了 130s 级 timeout 参数)
// 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
//
// 而 cmd_run 内部明明有自己的 ctx(按 timeout 参数);healthcheck
// 更是声明默认 120s —— 直接砍掉一半。output_send__*(向开发线投递,
// 子 pipeline 执行)天然就是长任务,也一起被误杀。
//
// 现在:ToolDef.Timeout 声明多久就给多久,但仍有**硬上限**
// (maxToolTimeout = 10m)兜住"插件声明成 0/无限"这种把队列堵死的写法。
toolTimeout := a.resolveToolTimeout(tc.Name, tc.Arguments)
select {
case result := <-done:
return result
case <-time.After(toolTimeout):
// 先 cancel 再返回:这一行就是"让插件停下来"的触发点。
cancel()
log.Printf("[agent] tool %s timed out after %v (cancel sent to executor)", tc.Name, toolTimeout)
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("Tool %s timed out (%s)", tc.Name, fmtDuration(toolTimeout)),
"The tool did not return within its time limit. Split into a smaller task or use another tool.")
return toolOutcome{Text: te.Error(), Raw: te}
}
}
func (a *Agent) executeToolCallInner(ctx context.Context, 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}
}
// ★ 内置工具的路由交给 device 模块(`kernelmod/device.Dispatch`)。
//
// 为什么要搬:这张"前缀 → 族处理器"的表曾是工具面的**第二份事实来源**
//(第一份是 tooldefs 的定义)。两处不同步就是"模型看得到、路由找不到"
// 或反之。现在路由只有一处,device 模块拥有它。
//
// 未挂载 device 时(独立工具、单测、特殊装配)退化为下面的本地 switch,
// 行为不变 —— 两条路径的一致性由 device_dispatch_test.go 钉住。
if a.builtinHandlers != nil {
if text, handled := toolroute.Dispatch(a.builtinHandlers, tc, turnScenes); handled {
return toolOutcome{Text: text}
}
}
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 strings.HasPrefix(tc.Name, "secret_"):
return toolOutcome{Text: a.executeSecretTool(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.ExecuteToolCtx(ctx, 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("Tool %s failed: %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("Device [%s] is not authorized for this agent (see output_list_channels for available device/<id> channels, or use 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) {
// 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。
// 文案必须让模型知道该做什么,而不是含糊的"执行失败"——
// 后者会让模型反复重试同一个不存在的名字。
// ★ 提示必须**先指向 skill**(2026-10-22)。
//
// 按需插件(auto_start=false)的工具不在初始清单里,而它的
// skill 正是"这个插件是什么、要怎么用、需要先启动什么"的唯一
// 说明(SDK autostart.go 的 OnDemandNeedsSkill 就是这条契约)。
//
// 此前这里只提 get_plugin_tools / output_list_channels,
// 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去
// 看说明书,于是只能盲目试工具名。
return toolOutcome{Text: fmt.Sprintf("Tool %s does not exist or is not registered (it may belong to a plugin that is not loaded). Handle it in this order:\n"+
"1. Check [Available skills] for a related one: if present, read it in full with skill_info(\"skill-name\") - "+
"the skill documents the plugin's correct usage, known pitfalls, and which plugin must be started first;\n"+
"2. If no skill matches, call get_plugin_tools(\"\") to list the currently available plugins and tools;\n"+
"3. For channel questions, use output_list_channels to see which channels exist.\n"+
"Only call again once you have confirmed, and do not keep retrying the same name.", tc.Name)}
}
return toolOutcome{Text: fmt.Sprintf("Tool %s failed: %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 "stable memory is unavailable"
}
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
requireFull := func() string {
if a.memory == nil {
return "this agent is a lightweight kernel: it can read and write stable memory, but maintenance (merge/delete/purge/edit/stats) is unavailable"
}
return ""
}
switch tc.Name {
case "toolresult_read":
txt, err := a.readStoredToolResult(tc.Arguments)
if err != nil {
return "toolresult_read failed: " + err.Error()
}
return txt
case "memory_recall":
query, _ := tc.Arguments["query_intent"].(string)
depth, _ := tc.Arguments["depth"].(float64)
if depth <= 0 {
depth = 2
}
if query == "" {
return "query is required"
}
// 关键词提取:支持逗号分隔和自然语言
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("memory recall failed: %v", err)
}
if len(result.Entities) == 0 && len(result.Relations) == 0 {
return "no relevant memory found"
}
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("Found %d related entities (sorted by %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 (mentioned %d times, type:%s%s)", e.Name, e.MentionCount, e.Type, tag))
}
parts = append(parts, fmt.Sprintf("Found %d relations:", 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 and rounds are required"
}
if entityA > entityB {
entityA, entityB = entityB, entityA
}
key := entityA + "||" + entityB
a.noMergeMu.Lock()
a.noMergeMarkers[key] = int(rounds)
a.noMergeMu.Unlock()
return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds))
case "memory_commit":
triplesData, ok := tc.Arguments["triples"].([]interface{})
if !ok {
return "invalid arguments: triples must be an array"
}
// 场景键:模型可以在三元组里逐条给(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 "no valid triples"
}
// remember 工具是用户/模型显式写入,不涉及归档删除,
// 因此不需要 mediaBound——没有旧引用要释放。
// ★★ 写入前取块数基线(2026-10-04)
//
// 旧表双写已停 ⇒ Commit 的 ec/rc 恒为 0,
// 拿它们判断「有没有写进去」会**永远判成没写进去**。
blocksBefore, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("memory commit failed: %v", err)
}
edgesBefore, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("memory commit failed: %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("memory commit failed: %v", err)
}
blocksAfter, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("memory commit failed: %v", err)
}
edgesAfter, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("memory commit failed: %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("Submitted %d triples but all were rejected (nothing written). Common causes: empty entity name, longer than 50 chars, or no letters/digits. Check subject/object spelling and retry.", 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("Wrote %d memory blocks and %d relation edges, linked %d media items",
newBlocks, newEdges, mb)
}
return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges)
case "memory_introspect":
if msg := requireFull(); msg != "" {
return msg
}
stats, err := a.memory.Introspect()
if err != nil {
return fmt.Sprintf("query failed: %v", err)
}
return fmt.Sprintf("memory stats: %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 and target are required"
}
count, err := a.memory.MergeEntities(source, target)
if err != nil {
return fmt.Sprintf("merge failed: %v", err)
}
return fmt.Sprintf("Merged \"%s\" into \"%s\": source deleted, %d relations redirected", source, target, count)
case "memory_delete_entity":
if msg := requireFull(); msg != "" {
return msg
}
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name is required"
}
res, err := a.memory.DeleteEntity(name)
if err != nil {
return fmt.Sprintf("delete failed: %v", err)
}
// ★ 如实报告实际删掉多少,不说「已彻底删除…及其所有关联关系」。
//
// 旧文案是谎报:底层只碰旧表、活图谱 Δ0,却回「彻底删除」。
// 模型据此认为内容已消失(不再提及或重新写入),
// 而关联边还在、召回继续命中 —— 谎报会让模型的行为跟着错。
if res.Blocks == 0 {
return fmt.Sprintf("No block named \"%s\"; nothing deleted", name)
}
return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)",
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("stable memory delete failed: %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 stable-memory relations", mode, n)}
if textRemoved > 0 {
parts = append(parts, fmt.Sprintf("purged %d text memory log entries", 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 and old_object are required"
}
newSubject, _ := tc.Arguments["new_subject"].(string)
newRelation, _ := tc.Arguments["new_relation"].(string)
newObject, _ := tc.Arguments["new_object"].(string)
if newSubject == "" && newRelation == "" && newObject == "" {
return "provide at least one new value (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("stable memory edit failed (deleting old record): %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("stable memory edit failed (writing new record): %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("Memory edited: %d old relations removed, %d entities and %d new relations written", n, ec, rc)
if textReplaced > 0 {
result += fmt.Sprintf(", %d text memory log entries updated", textReplaced)
}
return result
default:
return fmt.Sprintf("unknown memory tool: %s", tc.Name)
}
}
func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
if a.social == nil {
return "social graph is unavailable (social store not initialized)"
}
switch tc.Name {
case "person_query":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "person name is required"
}
profile, err := a.social.GetPerson(name)
if err != nil {
return fmt.Sprintf("person lookup failed: %v", err)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎Profile of %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 and value are required"
}
if err := a.social.SetTrait(name, trait, value); err != nil {
return fmt.Sprintf("setting trait failed: %v", err)
}
return fmt.Sprintf("Recorded: %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 and person_b are required"
}
if err := a.social.AddRelation(personA, relation, personB); err != nil {
return fmt.Sprintf("creating relation failed: %v", err)
}
return fmt.Sprintf("Recorded: %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 "person name is required"
}
profiles, err := a.social.GetNetwork(name, depth)
if err != nil {
return fmt.Sprintf("social network query failed: %v", err)
}
if len(profiles) == 0 {
return fmt.Sprintf("No social network found for %s", name)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎Social network of %s (%d hops)", 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("unknown person tool: %s", tc.Name)
}
}
// knowledgeMediaRefs 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。
//
// Reuses the coldcontext_commit convention: references may be prefixes; unresolved ones are skipped.
// 模型偶尔会把 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] media block ref %s could not be resolved uniquely, skipping", 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 "knowledge base is unavailable"
}
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 "query is required"
}
// 可选分类限定:把召回限制在某棵分类子树内(前缀匹配,见
// knowledge.Store.SearchIn)。不传 = 全库。
category, _ := tc.Arguments["category"].(string)
results := a.knowledge.SearchIn(query, category, topK)
if len(results) == 0 {
return "no relevant knowledge found"
}
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 and content are required"
}
// The model may attach stored media explicitly (same media_digests shape as coldcontext_commit).
// 这些媒体成为知识条目的一等节点:其向量会与正文向量融合,
// 使该条目能按图本身被召回,而不依赖任何生成的描述文本。
media := a.knowledgeMediaRefs(getStringSlice(tc.Arguments, "media_digests"))
if err := a.knowledge.AddWithMedia(name, content, media); err != nil {
return fmt.Sprintf("knowledge create failed: %v", err)
}
if len(media) > 0 {
return fmt.Sprintf("Knowledge \"%s\" created and indexed (%d chars, %d media items linked for cross-modal recall)", name, len(content), len(media))
}
return fmt.Sprintf("Knowledge \"%s\" created and indexed (%d chars)", 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("knowledge import failed: %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 is required"
}
if err := a.knowledge.Remove(name); err != nil {
return fmt.Sprintf("knowledge delete failed: %v", err)
}
return fmt.Sprintf("Knowledge \"%s\" deleted", name)
default:
return fmt.Sprintf("unknown knowledge tool: %s", tc.Name)
}
}
func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if a.docStore == nil {
return "cold context is unavailable"
}
switch tc.Name {
case "coldcontext_query":
query, _ := tc.Arguments["query"].(string)
topK := int(getFloat(tc.Arguments, "top_k"))
if topK <= 0 {
topK = 3
}
if query == "" {
return "query is required"
}
docs := a.docStore.Consume(query, topK)
if len(docs) == 0 {
return "no relevant cold context found"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
totalEvents := 0
for i, d := range docs {
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
//
// ARCHITECTURE「记忆流转」② Document 层:
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d)
if n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件。
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
content := d.Content
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("load cold context: %s", query),
Response: content,
NoMemory: true, // 同上:回填不产生新记忆
})
n = 1
}
totalEvents += n
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
// 让模型**立刻**能判断这笔召回有没有用。
//
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
// jieba 词袋,零信息量,模型无法据此判断相关性。
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
if preview := docRecallPreview(d, n, 3); preview != "" {
b.WriteString(preview)
}
}
b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents))
return b.String()
case "coldcontext_commit":
content, _ := tc.Arguments["content"].(string)
summary, _ := tc.Arguments["summary"].(string)
if content == "" {
return "content is required"
}
if summary == "" {
summary = truncateStr(content, 100)
}
doc := &document.Doc{
Summary: summary,
Content: content,
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("cold context write failed: %v", err)
}
if n := len(doc.Blocks); n > 0 {
return fmt.Sprintf("Cold context committed (id: %s, summary: %s, %d media linked)", doc.ID, summary, n)
}
return fmt.Sprintf("Cold context committed (id: %s, summary: %s)", doc.ID, summary)
default:
return fmt.Sprintf("unknown cold-context tool: %s", tc.Name)
}
}
// sortLabel 把排序模式翻成给模型看的中文标签。
func sortLabel(m memory.SortMode) string {
if m == memory.SortRecent {
return "newest first"
}
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: zero symbol hits, refusing to answer %q", 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 removed by temporal arbitration (superseded by newer values)", 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: symbol path backfilled %d (exact string %d, lexical %d)",
byExact+bySymbol, byExact, bySymbol)
}
var parts []string
parts = append(parts, fmt.Sprintf("Found %d relevant memory fragments:", 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)—— **错的**:
// 库里只有旧空间的块时,多数取值就是旧指纹,
// 拿它当过滤条件等于「不过滤」,旧块原样通过。
// (判据当场抓住:测试库里唯一的块是 old-fp,而当前空间是 fp1。)
//
// 隔离的语义是「只信任当前空间写入的块」,
// 判据必须来自**空间对象**而不是库 —— 库只能说明过去,不能说明现在。
func (a *Agent) currentFingerprint() string {
if a == nil || a.multimodalSpace == nil {
return ""
}
if !a.multimodalSpace.Loaded() {
return ""
}
return a.multimodalSpace.Fingerprint()
}
// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。
//
// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。
//
// ## 依据
//
// ARCHITECTURE「记忆流转」② Document 层的读取分支:
//
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
// 块 ID 与向量全程不变(不重建、不重算)。
//
// ## 块为什么要一起回填
//
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
func (a *Agent) restoreDocEvents(d *document.Doc) int {
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
return 0
}
n, blocks := 0, 0
for _, ev := range d.Events {
input, response, held := ev.InOut()
a.context.InsertByTimestamp(ContextEvent{
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
Source: ev.Source,
Input: input,
Response: response,
// ★ 回填事件**不参与记忆计算**,但**照常参与归档**。
//
// 为什么要标记:它们的内容刚在 L2 里被向量化过一次,
// 再算一次会让同一内容在向量空间里被重复加权(自激)。
//
// ★★ 为什么仍要归档(不能顺手关掉):
// Consume 已经把 L2 副本删了,L1 是**唯一副本**。
// 裁掉时不归档 = 三层都不在 = 内容永久丢失。
// (这个错犯过:曾用同一个 NoMemory 同时关掉计算与归档。)
NoMemory: true,
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
ToolResults: func() []ToolResultItem {
var out []ToolResultItem
for _, tr := range ev.ToolResults {
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
}
return out
}(),
Blocks: append([]memory.MemoryBlock(nil), held...),
})
n++
blocks += len(held)
}
log.Printf("[agent] doc->context: %s backfilled %d raw events one by one (%s -> %s), %d blocks migrated back to L1",
d.ID, n,
d.Events[0].Timestamp.Format("01-02 15:04:05"),
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
return n
}
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
//
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// coldcontext_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string {
if d == nil {
return ""
}
var parts []string
if d.HasEvents() {
first := d.Events[0].Timestamp
last := d.Events[len(d.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s → %s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 来源集合(去重,最多 3 个)
seen := map[string]bool{}
var srcs []string
for _, ev := range d.Events {
if ev.Source != "" && !seen[ev.Source] {
seen[ev.Source] = true
srcs = append(srcs, ev.Source)
if len(srcs) >= 3 {
break
}
}
}
if len(srcs) > 0 {
parts = append(parts, strings.Join(srcs, "/"))
}
} else {
parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04"))
if d.Source != "" {
parts = append(parts, d.Source)
}
}
parts = append(parts, fmt.Sprintf("%d events", eventCount))
if n := len(d.Blocks); n > 0 {
media := 0
for i := range d.Blocks {
if d.Blocks[i].Modality != memory.BlockText {
media++
}
}
if media > 0 {
parts = append(parts, fmt.Sprintf("with %d media items", media))
}
}
return strings.Join(parts, " · ")
}
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
//
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
// 这里只是"先给你看一眼是什么",不是全文。
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
if d == nil || maxLines <= 0 {
return ""
}
var out []string
add := func(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return len(out) < maxLines
}
out = append(out, " "+oneLinePreview(s, 100))
return len(out) < maxLines
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
if in != "" && !add(in) {
return strings.Join(out, "\n")
}
if resp != "" && !add(resp) {
return strings.Join(out, "\n")
}
}
} else {
// 存量文档:从渲染视图取前几行
for _, line := range strings.Split(d.Content, "\n") {
if !add(line) {
break
}
}
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n")
}
// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。
func oneLinePreview(s string, maxRunes int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "…"
}