Files
HomeAgent/internal/agent/core/multimodal_departed_test.go
JianFeeeee a02d8b00a5 feat(multimodal): 多模态工具移出内核为内置插件 + get_plugin_tools 收编为内核服务
## 背景

README 第一原则:「内核职责限定在 LLM 编排、记忆管理与知识检索;
所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。
describe_image / ocr_image / transcribe_audio 读本地文件却住在内核进程内
——无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。

## 本次改动

### 1. 多模态工具移出内核(internal/plugins/multimodal)

- 新增内置插件,注册 describe_image / transcribe_audio / ocr_image
- 媒体来源支持 path(设备/工具回传,如 screensee 的 file)与 digest(CAS)
- 失败一律以 error 上抛,不渲染成普通文本(延续 screensee 的失败传播纪律)
- ocr_image 的描述明说「不是专用 OCR 引擎」:它只是用视觉模型读字,
  真 OCR 在 internal/nlp 的 ONNX 未接入。名字承诺大于实现会让模型误判精度。

内核侧:删除 media.go 整文件(9 函数,全部零引用)、3 个 dispatch case、
3 个工具定义。

**留在内核**:input 通路(用户直传媒体直接拼进本轮 LLM 请求)——
那是「输入即上下文」,不是工具调用。

### 2. get_plugin_tools 收编为内核完整服务

原先它只收 StageHost + IOManager 两处,而三个媒体工具、memory_*、
knowledge_*、spawn_child 等全是内核 buildToolDefs 里直接 append 的
——一条都数不到。agent 能调这些工具,却调 get_plugin_tools 看不见它们。

- 以 buildToolDefs()(发给模型的那份工具表)为单一真相源
- 新增 builtinToolOwner 显式归属表:内置工具恒为 core
- resolveToolPlugin 的下划线前缀猜测限定在「该前缀是已注册插件」时才采纳,
  避免 ocr_image→"ocr"、memory_commit→"memory" 这类幽灵归属(7 处调用点依赖它)

## 判据

新增 plugin_tools_service_test.go、multimodal_departed_test.go、
multimodal/plugin_test.go。三个结构判据经变异自证会红:

1. 提示词改回「请使用 describe_image 工具」→ 仍出现该名 ⇒ 红
   ★ 这条抓到了真问题:eventloop.go 的媒体提示词仍在叫模型调旧工具名,
   而媒体 block 其实已内联给模型,且新工具需要 path/digest(用户直传都没有)
   ⇒ 模型照做必然失败。已修:改为「已随本消息附上,请直接查看」。
2. 内核为多模态加一个 os.ReadFile → 红(比「名字没出现」更强)
3. core 反向 import 插件 → 红(迁移而非删除,且守住分层)

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race ./internal/agent/core/ ./internal/plugins/multimodal/ 无数据竞争
- 部署后:39 插件加载、0 panic/fatal、0 非空 error
- 三个工具注册归属为 plugin=multimodal(不是 inferToolPlugin 猜的 describe)
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-multimodal-plugin-20261006-090541/
- 二进制备份 /tmp/homed.bak-mmplugin

一并包含此前已验证未提交的三处静默失效修复:
- MCP MCPCallResult.IsError 丢失(空结果伪装成功)
- DeleteEntity 谎报删除
- screensee 谎报成功 + 截图诊断(尺寸/magic/sha)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 09:07:18 +08:00

139 lines
4.6 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.

// 结构性判据:多模态工具**已从内核退场**(2026-10-06)
//
// ## 为什么需要结构性判据
//
// 前面 plugin_test.go 验的是「插件侧行为对不对」。但这次改动的**要害**
// 不是插件能不能用,而是**内核不再持有这三个工具** ——
// 因为内核承诺零 IO,而它们要读本地文件。
//
// 行为判据抓不住这件事:哪怕把内核实现改回去,只要插件侧仍然工作,
// 功能测试照样全绿。而「内核能不能读文件」正是本仓第一原则的红线。
//
// ⇒ 这条判据直接扫源码:三个工具名不得出现在 internal/agent/core/ 的
//
// 实现文件里,也不得有 os.ReadFile 之类为它们服务。
package core
import (
"os"
"regexp"
"strings"
"testing"
)
// mediaToolNames 是已移出内核的三个工具。
var mediaToolNames = []string{"describe_image", "ocr_image", "transcribe_audio"}
// kernelImplFiles 内核的实现文件(不含 _test.go —— 判据自身要断言的东西不该自己触发)。
func kernelImplFiles(t *testing.T) []string {
t.Helper()
ents, err := os.ReadDir(".")
if err != nil {
t.Fatalf("读包目录: %v", err)
}
var out []string
for _, e := range ents {
n := e.Name()
if strings.HasSuffix(n, "_test.go") || !strings.HasSuffix(n, ".go") {
continue
}
out = append(out, n)
}
if len(out) < 5 {
t.Fatalf("只找到 %d 个实现文件,判据覆盖不足", len(out))
}
return out
}
// TestMediaToolsGoneFromKernel 钉住:内核实现里不再出现这三个工具名。
//
// ★ 这是本次改动的**核心不变量**。它对应 README 的第一原则:
//
// 内核零 IO,而这三个工具要读本地文件。
func TestMediaToolsGoneFromKernel(t *testing.T) {
for _, f := range kernelImplFiles(t) {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := string(b)
// 只看代码行:注释里可以说明「已移出」(那是给人看的)
for _, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue
}
for _, name := range mediaToolNames {
if !strings.Contains(line, name) {
continue
}
// 允许出现在「已移出」的说明性注释里(带 ★ 或 已移出 标记)
if strings.Contains(line, "已移出") || strings.Contains(line, "★") {
continue
}
t.Errorf("internal/agent/core/%s 仍出现 %q —— 它已移出为内置插件 multimodal:\n\t%s",
f, name, trimmed)
}
}
}
}
// TestKernelHasNoMediaFileReading 钉住:内核不得为多模态读本地文件。
//
// ★ 比「名字没出现」更强:任何人新写一个 os.ReadFile 来干同样的事,
//
// 这条也会红。
//
// 已知例外:context.go 的上下文窗口持久化(savePath)属**记忆子系统**,
// 已在允许清单里 —— 它不是多模态,且属于内核既有的职责。
func TestKernelHasNoMediaFileReading(t *testing.T) {
// 允许的内核磁盘读:上下文窗口持久化(记忆面)
allowed := map[string]bool{
"context.go": true, // RelevanceContext.savePath 的 load/save
}
readFile := regexp.MustCompile(`os\.(ReadFile|Open)\(`)
for _, f := range kernelImplFiles(t) {
if allowed[f] {
continue
}
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
for i, line := range strings.Split(string(b), "\n") {
if readFile.MatchString(line) && !strings.HasPrefix(strings.TrimSpace(line), "//") {
t.Errorf("internal/agent/core/%s:%d 出现磁盘读取 %q —— "+
"内核承诺零 IO(多模态读文件已移到内置插件 multimodal)",
f, i+1, strings.TrimSpace(line))
}
}
}
}
// TestMediaToolsRegisteredByPlugin 确认能力没丢:插件侧确实注册了这三个工具。
//
// ★ 与上一条成对:「内核不该有」+「插件要有」= 迁移而不是删除。
//
// 只断言前者会放过「直接删掉功能」这种退化。
func TestMediaToolsRegisteredByPlugin(t *testing.T) {
// 插件侧的注册清单在 internal/plugins/multimodal/plugin.go。
// 这里只做**跨包引用检查**(core 包不该 import multimodal,那是反向依赖)。
if strings.Contains(kernelSourceDump(t), `"gitcode.com/JianFeeeee/HomeAgent/internal/plugins/multimodal"`) {
t.Error("core 反向 import 了 multimodal 插件 —— " +
"工具由插件注册进 StageHost,核心不应知道插件实现")
}
}
func kernelSourceDump(t *testing.T) string {
t.Helper()
var sb strings.Builder
for _, f := range kernelImplFiles(t) {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
sb.Write(b)
}
return sb.String()
}