feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干

记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。

## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。

媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。

## 二、内核桥接层:原先在静默裁字段

`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:

- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
  而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
  → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
  于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
  (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。

规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。

标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。

## 三、跨进程链路:不接线就是全体外部插件编译失败

`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。

## 四、统一输入主干:把模态从「函数选择」降级为「字段」

`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。

媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。

最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**

`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。

## 五、模型可调用的三个工具

`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。

标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。

## 六、WebUI 上传走真实媒体链路

图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。

## 测试

- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)

压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。

测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。

存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
This commit is contained in:
JianFeeeee
2026-09-06 09:51:31 +08:00
parent e8d12db871
commit 687e5655bc
28 changed files with 3229 additions and 267 deletions

View File

@ -10,6 +10,7 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func (a *Agent) eventLoop() {
@ -125,30 +126,18 @@ func (a *Agent) handleSelfInput(msg selfInputMsg) {
if msg.channel == "" {
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
}
a.processTextInput(&agentIO.InputEvent{
a.processInput(&agentIO.InputEvent{
Source: "system",
Type: "text",
Payload: map[string]interface{}{"content": msg.text},
OutputChannel: msg.channel,
}, msg.text)
})
}
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
switch evt.Type {
case "text":
input, _ := evt.Payload["content"].(string)
if input == "" {
return
}
// 去重webui/GUI 断线重连会重放未确认消息,短窗口内同来源同内容丢弃,避免轰炸
if a.isDuplicateInput(evt.Source, input) {
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(input, 60))
return
}
a.processTextInput(evt, input)
case "image", "audio":
a.processMediaInput(evt)
case "text", "image", "audio":
a.processInput(evt)
case "event":
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
@ -162,87 +151,97 @@ func (a *Agent) handleInput(evt *agentIO.InputEvent) {
}
}
func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
start := time.Now()
a.pendingMedia = evt.Payload
defer func() { a.pendingMedia = nil }()
// inputPayload 是一次输入在「模态」这个维度上的全部内容。
//
// 拆出这个结构,是为了让 processInput 只有一条主干:模态不再决定走哪个函数,
// 只决定这里的字段填不填。此前 text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项——
// 不是因为媒体不需要,而是复制粘贴之后文本那条继续演进、媒体那条没跟上。
type inputPayload struct {
// text 是进 LLM 与记忆的文本。纯媒体输入时它是 mediaToBlocks 给的 alt 文案。
text string
// blocks 非空表示本轮带多模态内容,随当前轮的 message 一起发给模型。
blocks []agentAPI.ContentBlock
// mediaType 供插件在 stage 里判断本轮媒体的模态。
mediaType string
// captureTool 是媒体落进 CAS 时记录的来源标签。
captureTool string
}
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
// resolveInput 把 InputEvent 归一成 inputPayload。
//
// 三种来源在这里合流:
// 1. evt.Type 是 image/audio —— 用户直接发的媒体payload 里是 data/url
// 2. evt.Type 是 text 且 payload 带 media_blocks —— 插件经 IOInjector 的
// InjectInputMedia / InjectInputMediaSync / InjectInterruptMedia 注入的
// 媒体,块已经是成品;
// 3. 纯文本。
//
// 第 2 种此前无处可去:注入方把块放进 payload而文本路径不看这个键
// 于是插件注入的媒体到 payload 就断了,且不报错。
func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) {
switch evt.Type {
case "image", "audio":
blocks, alt := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
return inputPayload{
text: alt,
blocks: blocks,
mediaType: evt.Type,
captureTool: "input_" + evt.Type,
}, true
}
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
// 用户直接发来的媒体:先落进 CAS
// 不存的后果是 ContextEvent.Input 只剩一句 alt 文本
//"[从 qq 收到了 image]"base64 随 message 数组发给模型后就丢了。
a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...)
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
stageCtx.Extra = map[string]interface{}{
"media_blocks": blocks,
"media_type": evt.Type,
"input_source": evt.Source,
"output_channel": evt.OutputChannel,
text, _ := evt.Payload["content"].(string)
blocks, mediaType := injectedBlocks(evt.Payload)
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)
if text == "" && len(blocks) == 0 {
return inputPayload{}, false
}
a.injectSourceContext(stageCtx, evt)
return inputPayload{
text: text,
blocks: blocks,
mediaType: mediaType,
captureTool: "inject_" + evt.Source,
}, true
}
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return
// injectedBlocks 取出 payload 里插件注入的多模态块。
//
// 两种静态类型都要认:内核内部注入直接给 []agentAPI.ContentBlock
// 而经公共 SDK 的 IOInjector 过来的是 []pubsdk.ContentBlock。两者字段完全一致
// 但 Go 不会自动转换,只认一种的后果是另一种被静默丢弃。
func injectedBlocks(payload map[string]interface{}) ([]agentAPI.ContentBlock, string) {
var blocks []agentAPI.ContentBlock
switch v := payload["media_blocks"].(type) {
case []agentAPI.ContentBlock:
blocks = v
case []pubsdk.ContentBlock:
blocks = make([]agentAPI.ContentBlock, 0, len(v))
for _, b := range v {
nb := agentAPI.ContentBlock{Type: b.Type, Text: b.Text}
if b.ImageURL != nil {
nb.ImageURL = &agentAPI.ImageURL{URL: b.ImageURL.URL, Detail: b.ImageURL.Detail}
}
if b.AudioURL != nil {
nb.AudioURL = &agentAPI.AudioURL{URL: b.AudioURL.URL}
}
blocks = append(blocks, nb)
}
}
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": evt.Payload,
"source": evt.Source,
})
archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
if len(blocks) == 0 {
return nil, ""
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: fallback,
})
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
if err != nil {
log.Printf("[agent] process media error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: fallback, Response: resp})
return
}
elapsed := time.Since(start)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
// 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。
// 媒体描述并进 Input描述文本才是持久语义记忆blob 只是缓存。
digests := a.drainMediaDigests()
mediaEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
}
a.bindEventMedia(&mediaEvt, digests)
if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" {
mediaEvt.Input = mediaEvt.Input + "\n" + s
}
a.context.Append(mediaEvt)
a.emitResponse(evt, response)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
// 模态由块自身判定,注入方不必额外声明。图优先:一次注入里图片是主体。
mediaType := ""
for _, b := range blocks {
if b.ImageURL != nil {
return blocks, "image"
}
if b.AudioURL != nil {
mediaType = "audio"
}
}
return blocks, mediaType
}
func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
@ -298,19 +297,51 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
return blocks, alt
}
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
// processInput 是全部模态输入的唯一主干。
//
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
func (a *Agent) processInput(evt *agentIO.InputEvent) {
start := time.Now()
in, ok := a.resolveInput(evt)
if !ok {
return
}
// 去重按文本做webui/GUI 断线重连会重放未确认消息。
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
// 是同一句,拿它去重会把连发的两张图误判成重复。
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
return
}
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
}
if evt.OutputChannel == "_consolidation_" {
a.processConsolidation(evt, input)
a.processConsolidation(evt, in.text)
return
}
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
// 原始 data/url也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
//payload 里才有 data/url插件注入的是成品 block取不到原始数据。
if evt.Type == "image" || evt.Type == "audio" {
a.pendingMedia = evt.Payload
defer func() { a.pendingMedia = nil }()
}
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
// base64 随 message 数组发给模型后就丢了。
if len(in.blocks) > 0 {
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
}
noMemory := false
if v, ok := evt.Payload["no_memory"].(bool); ok {
noMemory = v
@ -331,9 +362,13 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
noMemory = true
}
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
stageCtx.Extra["input_source"] = evt.Source
stageCtx.Extra["output_channel"] = evt.OutputChannel
if len(in.blocks) > 0 {
stageCtx.Extra["media_blocks"] = in.blocks
stageCtx.Extra["media_type"] = in.mediaType
}
if noMemory {
stageCtx.NoMemory = true
}
@ -344,7 +379,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
return
}
input = stageCtx.RawMessage
input := stageCtx.RawMessage
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
cleanInput := input
@ -354,10 +389,16 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
}
}
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": input,
"source": evt.Source,
})
// upload_* 字段一并转发webui 的 EventRawInput 订阅方靠它们还原附件卡片。
// 媒体路径此前把整个 payload 塞进 content一个 map订阅方按 string 断言
// 直接失败 → 用户发的图从不出现在聊天记录里。
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
if v, ok := evt.Payload[k]; ok {
rawPayload[k] = v
}
}
a.publishEvent(events.EventRawInput, rawPayload)
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
if archived > 0 {
@ -374,7 +415,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] process error: %v", err)
log.Printf("[agent] process %s error: %v", evt.Type, err)
resp := fmt.Sprintf("处理错误: %v", err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
@ -382,11 +423,12 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
}
elapsed := time.Since(start)
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
// 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时,
// 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。
textEvt := ContextEvent{
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
// process() 里被捕获,纯文本输入也会有)。
turnEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: cleanInput,
@ -394,11 +436,11 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
ToolsUsed: toolsUsed,
ToolResults: toolResults,
}
a.bindEventMedia(&textEvt, a.drainMediaDigests())
if s := a.mediaSummaryForEvent(textEvt.Media); s != "" {
textEvt.Input = textEvt.Input + "\n" + s
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" {
turnEvt.Input = turnEvt.Input + "\n" + s
}
a.context.Append(textEvt)
a.context.Append(turnEvt)
a.emitResponse(evt, response)

View File

@ -255,6 +255,130 @@ func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
return bound
}
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
//
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id →
// media_refs。模型只知道 digest从 memory_recall 的「关联媒体」或对话里的
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用
// 静默挂不上,模型也无从察觉。
//
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
if a.mediaStore == nil || len(digests) == 0 {
return sentence
}
present := make(map[string]bool)
for _, d := range extractMediaDigests(sentence) {
present[d] = true
}
var add []string
for _, d := range digests {
if d == "" || present[shortDigest(d)] {
continue
}
// 模型给的多半是短 digest它在上下文里看到的就是短的补全成完整
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
continue
}
if line := a.mediaMarkerLine(full); line != "" {
add = append(add, line)
present[shortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
//
// 优先读 media_refs权威谁挂上去的就是谁为空时退回解析正文标记——
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
func (a *Agent) docMediaContext(docID, content string) string {
if a.mediaStore == nil {
return ""
}
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID)
if err != nil {
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err)
}
if len(digests) == 0 {
for _, short := range extractMediaDigests(content) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
digests = append(digests, full)
}
}
var lines []string
for _, d := range digests {
if line := a.mediaMarkerLine(d); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "")
}
// resolveMediaDigests 把模型给的多为短digest 补全成完整 digest。
//
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest也可能内容已被
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
func (a *Agent) resolveMediaDigests(digests []string) []string {
if a.mediaStore == nil || len(digests) == 0 {
return nil
}
seen := make(map[string]bool, len(digests))
var out []string
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
continue
}
if seen[full] {
continue
}
seen[full] = true
out = append(out, full)
}
return out
}
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
//
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
func (a *Agent) bindDocMedia(docID string, digests []string) int {
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
return 0
}
bound := 0
for _, d := range digests {
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
continue
}
bound++
}
if bound > 0 {
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
}
return bound
}
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
//
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
@ -357,19 +481,9 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
}
var parts []string
for _, d := range digests {
it, err := a.mediaStore.Stat(d)
if err != nil || it == nil {
continue
if line := a.mediaMarkerLine(d); line != "" {
parts = append(parts, line)
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
desc := it.Description
if desc == "" {
desc = "(未描述)"
}
parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc))
}
if len(parts) > 0 {
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, "")))

View File

@ -0,0 +1,587 @@
package core
import (
"path/filepath"
"strconv"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 统一输入主干processInput / resolveInput / injectedBlocks
// 模型可调用工具的媒体接线测试。
//
// 这一层此前的结构性缺陷text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项。
// 归一成一条主干后,这些行为对所有模态一致——下面的断言就是这个不变量。
func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return &Agent{mediaStore: ms}, ms
}
// ---------- injectedBlocks ----------
// 内核内部注入直接给 []agentAPI.ContentBlock经公共 SDK 的 IOInjector 过来的是
// []pubsdk.ContentBlock。两者字段一致但 Go 不会自动转换,只认一种的后果是
// 另一种被静默丢弃——插件注入的图到 payload 就断了,且不报错。
func TestInjectedBlocks_AcceptsBothStaticTypes(t *testing.T) {
t.Run("内核类型", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "text", Text: "看图"},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "data:image/png;base64,AAA"}},
},
})
if len(blocks) != 2 {
t.Fatalf("blocks = %d期望 2", len(blocks))
}
if kind != "image" {
t.Errorf("mediaType = %q期望 image", kind)
}
})
t.Run("公共SDK类型", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []pubsdk.ContentBlock{
{Type: "text", Text: "听音频"},
{Type: "audio_url", AudioURL: &pubsdk.AudioURL{URL: "data:audio/wav;base64,BBB"}},
},
})
if len(blocks) != 2 {
t.Fatalf("blocks = %d期望 2公共 SDK 类型被静默丢弃)", len(blocks))
}
if kind != "audio" {
t.Errorf("mediaType = %q期望 audio", kind)
}
// 转换必须保留 URL否则块到了模型手上是空的
if blocks[1].AudioURL == nil || blocks[1].AudioURL.URL != "data:audio/wav;base64,BBB" {
t.Errorf("AudioURL 转换丢失: %+v", blocks[1].AudioURL)
}
})
t.Run("图优先于音频", func(t *testing.T) {
_, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: "a"}},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "b"}},
},
})
if kind != "image" {
t.Errorf("mediaType = %q期望 image", kind)
}
})
t.Run("无媒体块", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{"content": "纯文本"})
if blocks != nil || kind != "" {
t.Errorf("无 media_blocks 时应返回 (nil,\"\"),实际 (%v,%q)", blocks, kind)
}
})
t.Run("ImageURL 的 Detail 透传", func(t *testing.T) {
blocks, _ := injectedBlocks(map[string]interface{}{
"media_blocks": []pubsdk.ContentBlock{
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "u", Detail: "high"}},
},
})
if len(blocks) != 1 || blocks[0].ImageURL.Detail != "high" {
t.Errorf("Detail 未透传: %+v", blocks)
}
})
}
// ---------- resolveInput ----------
func TestResolveInput_UnifiesAllModalities(t *testing.T) {
a, _ := newInputTestAgent(t)
t.Run("用户上传图片", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "qq",
Type: "image",
Payload: map[string]interface{}{"data": "AAAA", "mime": "image/png"},
})
if !ok {
t.Fatal("图片输入被判为无效")
}
if in.mediaType != "image" || in.captureTool != "input_image" {
t.Errorf("mediaType=%q captureTool=%q", in.mediaType, in.captureTool)
}
if in.text == "" {
t.Error("纯媒体输入应有 alt 文案作为文本落点")
}
if len(in.blocks) == 0 {
t.Error("图片应转成内容块")
}
})
t.Run("插件注入的媒体", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "myplugin",
Type: "text",
Payload: map[string]interface{}{
"content": "帮我看看这张图",
"media_blocks": []pubsdk.ContentBlock{
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AAA"}},
},
},
})
if !ok {
t.Fatal("带媒体的文本输入被判为无效")
}
if in.text != "帮我看看这张图" {
t.Errorf("text = %q", in.text)
}
if len(in.blocks) != 1 || in.mediaType != "image" {
t.Errorf("blocks=%d mediaType=%q —— 插件注入的媒体到 payload 就断了", len(in.blocks), in.mediaType)
}
if in.captureTool != "inject_myplugin" {
t.Errorf("captureTool = %q期望带来源便于溯源", in.captureTool)
}
})
t.Run("只带图不带字也合法", func(t *testing.T) {
_, ok := a.resolveInput(&agentIO.InputEvent{
Source: "myplugin",
Type: "text",
Payload: map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "u"}},
},
},
})
if !ok {
t.Error("只带媒体不带文本应视为有效输入(插件注入常这样)")
}
})
t.Run("文本与媒体都空才无效", func(t *testing.T) {
if _, ok := a.resolveInput(&agentIO.InputEvent{
Source: "cli",
Type: "text",
Payload: map[string]interface{}{"content": ""},
}); ok {
t.Error("空输入应被拒")
}
})
t.Run("纯文本", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "cli",
Type: "text",
Payload: map[string]interface{}{"content": "你好"},
})
if !ok || in.text != "你好" || len(in.blocks) != 0 || in.mediaType != "" {
t.Errorf("纯文本路径异常: ok=%v in=%+v", ok, in)
}
})
}
// ---------- 模型工具侧sentenceWithMediaMarkers ----------
// 模型只知道 digest从对话或 memory_recall 的「关联媒体」读到),
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。
func TestSentenceWithMediaMarkers(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
MIME: "image/png", Description: "一张紫蓝红三色带图",
})
if err != nil {
t.Fatalf("Put: %v", err)
}
t.Run("短digest补全并生成标记", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
if !strings.Contains(got, "三色带图") {
t.Errorf("描述未并入句子: %q", got)
}
if !strings.Contains(got, digest[:12]) {
t.Errorf("digest 未并入句子(反查会失效): %q", got)
}
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
if got := extractMediaDigests(got); len(got) != 1 {
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
}
})
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
if n := strings.Count(got, digest[:12]); n != 1 {
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
}
})
t.Run("空句子时标记本身充当句子", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("", []string{digest})
if got == "" {
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
}
})
t.Run("无法解析的digest被跳过", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
if got != "原句。" {
t.Errorf("不存在的 digest 不该造出标记: %q", got)
}
})
t.Run("无媒体存储时原样返回", func(t *testing.T) {
bare := &Agent{}
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
t.Errorf("无媒体存储时应原样返回: %q", got)
}
})
}
// ---------- resolveMediaDigests ----------
func TestResolveMediaDigests(t *testing.T) {
a, ms := newInputTestAgent(t)
d1, _ := ms.Put([]byte("one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("two"), media.Item{MIME: "image/png"})
got := a.resolveMediaDigests([]string{d1[:10], d2, d1, "ffffffffffff"})
if len(got) != 2 {
t.Fatalf("got = %v期望 2 条(去重 + 丢弃无法解析的)", got)
}
for _, d := range got {
if len(d) != 64 {
t.Errorf("应返回完整 digest实际 %q", d)
}
}
if a.resolveMediaDigests(nil) != nil {
t.Error("空输入应返回 nil")
}
bare := &Agent{}
if bare.resolveMediaDigests([]string{d1}) != nil {
t.Error("无媒体存储时应返回 nil")
}
}
// ---------- bindDocMedia ----------
func TestBindDocMedia(t *testing.T) {
a, ms := newInputTestAgent(t)
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
if n := a.bindDocMedia("doc_x", []string{d1, d2}); n != 2 {
t.Fatalf("绑定 %d 条,期望 2", n)
}
refs, err := ms.Refs(media.OwnerDocument, "doc_x")
if err != nil {
t.Fatalf("Refs: %v", err)
}
if len(refs) != 2 {
t.Errorf("引用 = %v期望 2 条", refs)
}
if n := a.bindDocMedia("", []string{d1}); n != 0 {
t.Error("空 docID 不该绑定")
}
bare := &Agent{}
if n := bare.bindDocMedia("doc_y", []string{d1}); n != 0 {
t.Error("无媒体存储时不该绑定")
}
}
// ---------- docMediaContext ----------
func TestDocMediaContext(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
MIME: "image/png", Description: "文档里的配图",
})
t.Run("优先用media_refs", func(t *testing.T) {
if err := ms.AddRef(digest, media.OwnerDocument, "doc_refs"); err != nil {
t.Fatalf("AddRef: %v", err)
}
got := a.docMediaContext("doc_refs", "正文里没有任何标记")
if !strings.Contains(got, "文档里的配图") {
t.Errorf("未从 media_refs 取到媒体说明: %q", got)
}
})
t.Run("无引用时回退解析正文标记", func(t *testing.T) {
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
got := a.docMediaContext("doc_legacy", content)
if !strings.Contains(got, "文档里的配图") {
t.Errorf("历史文档只有标记时应回退解析: %q", got)
}
})
t.Run("既无引用也无标记", func(t *testing.T) {
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
t.Errorf("应返回空串,实际 %q", got)
}
})
t.Run("无媒体存储", func(t *testing.T) {
bare := &Agent{}
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
}
})
}
// ---------- mediaMarkerLine ----------
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
func TestMediaMarkerLine(t *testing.T) {
a, ms := newInputTestAgent(t)
described, _ := ms.Put([]byte("with-desc"), media.Item{
MIME: "image/png", Description: "已描述的图",
})
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
t.Errorf("有描述时应带描述: %q", got)
}
// 「已入库但还没描述」与「压根没有媒体」必须可区分
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
got := a.mediaMarkerLine(bare)
if !strings.Contains(got, "(未描述)") {
t.Errorf("无描述时应有占位符: %q", got)
}
if !strings.Contains(got, shortDigest(bare)) {
t.Errorf("必须带短 digest 供反查: %q", got)
}
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
}
}
// ---------- 模型工具端到端memory_commit / doc_commit / doc_query ----------
func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ds := document.NewStore(filepath.Join(dir, "documents"))
if err := ds.Start(); err != nil {
t.Fatalf("doc store: %v", err)
}
t.Cleanup(func() { ds.Stop() })
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
emb := memory.NewStaticEmbedder("")
a := &Agent{
id: "tester",
memory: g,
docStore: ds,
mediaStore: ms,
context: NewRelevanceContext("", emb),
}
return a, ms
}
// memory_commit 带 media_digests三元组入库后必须能从句子反查回那份字节。
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
MIME: "image/png", Description: "提交时关联的图",
})
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "配色方案",
"relation": "参考",
"object": "三色带图",
"media_digests": []interface{}{digest[:12]},
},
},
},
})
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
res, err := a.memory.Recall([]string{"配色方案"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
}
refs, _ := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(res.Relations[0].SentenceID, 10))
if len(refs) != 1 || refs[0] != digest {
t.Errorf("句子引用 = %v期望 [%s]", refs, digest)
}
}
// 不带 media_digests 时行为与本特性上线前一致(不多写句子、不报错)。
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
a, _ := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
},
},
})
if strings.Contains(out, "失败") {
t.Errorf("普通提交不该失败: %q", out)
}
if strings.Contains(out, "关联") {
t.Errorf("无媒体时不该提媒体: %q", out)
}
}
// sentence_text 必须透传:丢了它,图谱就回不到原文。
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
a, _ := newToolTestAgent(t)
a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "李四",
"relation": "住在",
"object": "杭州",
"sentence_text": "李四搬到杭州已经三年了。",
},
},
},
})
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回为空")
}
if res.Relations[0].SentenceText != "李四搬到杭州已经三年了。" {
t.Errorf("SentenceText = %q", res.Relations[0].SentenceText)
}
}
// doc_commit 带 media_digests标记进正文否则检索不到+ 引用挂文档 owner否则 GC 会清)。
func TestToolDocCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
MIME: "image/png", Description: "笔记里的插图",
})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "这是一篇带图的笔记正文。",
"summary": "带图笔记",
"media_digests": []interface{}{digest[:12]},
},
})
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
docs := a.docStore.RecentDocs(5)
if len(docs) == 0 {
t.Fatal("文档未写入")
}
d := docs[0]
if !strings.Contains(d.Content, "笔记里的插图") {
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
}
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
if len(refs) != 1 || refs[0] != digest {
t.Errorf("文档引用 = %v期望 [%s]", refs, digest)
}
}
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
MIME: "image/png", Description: "检索命中的配图",
})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "紫蓝红三色带配色说明正文",
"summary": "紫蓝红三色带",
"media_digests": []interface{}{digest},
},
})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色说明", "top_k": float64(3)},
})
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
var found bool
for _, e := range a.context.Recent(10) {
if strings.Contains(e.Response, "检索命中的配图") {
found = true
}
}
if !found {
t.Error("doc_query 未把媒体说明带进上下文 —— 模型不知道这篇文档带过图")
}
}
// 无媒体存储时三个工具的行为与本特性上线前完全一致。
func TestTools_NilMediaStoreDegrades(t *testing.T) {
a, _ := newToolTestAgent(t)
a.mediaStore = nil
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "无存储", "relation": "仍可", "object": "提交",
"media_digests": []interface{}{"aabbccddeeff"},
},
},
},
})
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
}
out = a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "无媒体存储的文档",
"media_digests": []interface{}{"aabbccddeeff"},
},
})
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时文档写入不该失败: %q", out)
}
}

View File

@ -15,7 +15,7 @@ import (
//
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
//
// 1. 用户直接发图 → processMediaInput → mediaToBlocks
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"
// base64 随 message 数组发给模型后就丢了。
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
@ -123,18 +123,8 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
}
var lines []string
for _, d := range digests {
it, err := a.mediaStore.Stat(d)
if err != nil || it == nil {
continue
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
if it.Description != "" {
lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description))
} else {
lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d)))
if line := a.mediaMarkerLine(d); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
@ -143,6 +133,34 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
return "媒体内容:\n" + strings.Join(lines, "\n")
}
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
//
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
//
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。
func (a *Agent) mediaMarkerLine(digest string) string {
if a.mediaStore == nil {
return ""
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
desc := it.Description
if desc == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
desc = "(未描述)"
}
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
}
// newEventID 生成 ContextEvent 的稳定标识。
//
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,

View File

@ -185,9 +185,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
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"),
Subject: getString(m, "subject"),
Relation: getString(m, "relation"),
Object: getString(m, "object"),
SentenceText: getString(m, "sentence_text"),
}
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
// 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
}
if t.Subject != "" && t.Relation != "" && t.Object != "" {
triples = append(triples, t)
@ -199,10 +206,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
}
// remember 工具是用户/模型显式写入,不涉及归档删除,
// 因此不需要 mediaBound——没有旧引用要释放。
ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
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":
@ -521,6 +531,11 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(content) > 2000 {
content = content[:2000] + "..."
}
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
content = content + "\n关联媒体: " + mc
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
@ -556,9 +571,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
Tags: tags,
Source: "manual",
}
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
if err := a.docStore.Insert(doc); err != nil {
return fmt.Sprintf("文档写入失败: %v", err)
}
// 引用必须在拿到 doc.ID 之后挂owner_id 就是文档 id。
// 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。
bound := a.bindDocMedia(doc.ID, mediaDigests)
if bound > 0 {
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound)
}
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
default:

View File

@ -374,6 +374,11 @@ func (a *Agent) buildToolDefs() []interface{} {
"description": "标签列表",
"items": map[string]interface{}{"type": "string"},
},
"media_digests": map[string]interface{}{
"type": "array",
"description": "可选:这篇文档关联的媒体 digest对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
},
"required": []string{"content"},
},

View File

@ -29,6 +29,24 @@ func getFloat(m map[string]interface{}, key string) float64 {
return 0
}
// getStringSlice 从工具参数里取字符串数组。
//
// 需要单独一个 helper 而不是直接断言 []stringLLM 的参数经 JSON 解码后是
// []interface{},直接断言 []string 恒失败——静默拿到 nil参数像没传一样。
func getStringSlice(m map[string]interface{}, key string) []string {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []string
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
func truncateStr(s string, max int) string {
if utf8.RuneCountInString(s) <= max {
return s