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>
This commit is contained in:
JianFeeeee
2026-10-06 09:07:18 +08:00
parent 7e4f1058ea
commit a02d8b00a5
16 changed files with 1776 additions and 438 deletions

View File

@ -1,8 +1,12 @@
package remotedevice
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"sort"
"strings"
"time"
@ -18,7 +22,9 @@ type devicectlDevice struct {
// screensee 回调:设备截屏回传后由 agent 核心消费(视觉描述)。
// 由插件 Start 注入;nil 时退化为仅返回 base64 数据。
seeHandler func(dataURL string, provider string) string
// ★ 2026-10-05 改为 (string, error):视觉描述失败必须能上抛,
// 不能渲染成普通文本(那会让 status=ok 与失败同形)。
seeHandler func(dataURL string, provider string) (string, error)
}
func (d *devicectlDevice) Name() string { return "devicectl" }
@ -361,7 +367,7 @@ func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error)
}
// SetSeeHandler 注入 screensee 的视觉描述回调(agent 核心提供)。
func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) string) {
func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) (string, error)) {
d.seeHandler = fn
}
@ -401,6 +407,19 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e
return nil, fmt.Errorf("设备截屏失败: %s", errMsg)
}
output, _ := res["output"].(string)
// ★ 2026-10-05:把回传内容**量化后记进服务端日志**。
//
// 起因:2026-10-05 生产日志里有两次 screensee 回了
// 「我目前没有收到或无法查看这张屏幕截图」
// 而服务端**没有任何记录**能回答「设备到底回传了什么」——
// GUI 侧那行 console.log 只打在它自己的控制台,服务端留不下痕迹。
// 于是排查只能靠猜(截屏为空?data URL 被截断?缩略图未就绪?)。
//
// 只记**元信息 + 极短内容指纹**,不记图片本身(日志会膨胀且含用户屏幕内容):
// · 字节数、解出来的像素尺寸(jpeg/png 头部可解)、是否非空
// · 前若干字节的 base64 片段 + SHA256 前 16 hex
// ⇒ 「回传的是不是同一张图」「是不是被截断了」一眼可判。
logScreenseePayload(id, output)
// 设备端回传 data URL(data:image/jpeg;base64,...)或裸 base64
if !strings.HasPrefix(output, "data:") {
output = "data:image/jpeg;base64," + output
@ -409,7 +428,18 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e
if d.seeHandler == nil {
return map[string]interface{}{"image_data_url": output, "note": "无视觉描述处理器,仅返回原始图像数据"}, nil
}
desc := d.seeHandler(output, provider)
desc, err := d.seeHandler(output, provider)
if err != nil {
// ★ 视觉描述失败 ⇒ 工具失败(不是「返回一段说明」)。
// 生产实证:403 被塞进 description 且 status=ok,
// agent 以为拿到了结果,于是换 provider 又试一次(再失败一次)。
d.reg.SaveResult(reqID, map[string]interface{}{
"error": err.Error(),
"device": id,
"has_image": true, // 截图确实拿到了:区分「设备没回传」与「模型看不了」
})
return nil, fmt.Errorf("设备已回传截图,但视觉描述失败: %w", err)
}
return map[string]interface{}{"description": desc}, nil
}
@ -543,12 +573,31 @@ func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}
func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
text, _ := args["text"].(string)
if text == "" {
return nil, fmt.Errorf("text required(要写入剪切板的内容)")
// ★ content 参数(判据 clipboardsue_content_test.go 钉着它)——
// 从**服务端可见的路径**读入剪切板,长文本/代码块不塞得进命令行。
// 与 GUI 侧 screensue/speakeruse 的 @<路径> 是同一类问题的同一类解法。
//
// ★ 这段实现曾因「只提交判据没提交代码」被仓库清理冲掉,
// 导致判据编译不过(undefined: clipboardsueMaxBytes)。
// 判据与实现在同一个提交里,才不会再次走丢。
content, _ := args["content"].(string)
if strings.TrimSpace(content) == "" && strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("text 或 content 至少给一个(要写入剪切板的内容)")
}
if err := d.clipboardCheck(id, "写入", "clipboardsue"); err != nil {
return nil, err
}
if strings.TrimSpace(content) != "" {
b, rerr := os.ReadFile(strings.TrimSpace(content))
if rerr != nil {
return nil, fmt.Errorf("读取 content 文件失败 %s: %w", strings.TrimSpace(content), rerr)
}
if len(b) > clipboardsueMaxBytes {
return nil, fmt.Errorf("content 文件过大 %dKB > 上限 %dKB",
len(b)/1024, clipboardsueMaxBytes/1024)
}
text = string(b)
}
reqID := newReqID()
// 命令格式:clipboardsue <文字>(GUI 端取首个空格后的全部内容作为写入文本)
if err := d.reg.PushCmd(id, reqID, "clipboardsue "+text, "homeagent"); err != nil {
@ -583,3 +632,105 @@ func truncateForPreview(s string, max int) string {
}
return string(r[:max]) + "..."
}
// clipboardsueMaxBytes 是从文件读入剪切板的大小上限(与 GUI 侧 2MB 一致)。
const clipboardsueMaxBytes = 2 * 1024 * 1024
// logScreenseePayload 把设备回传的截图**元信息**记进服务端日志。
//
// ★ 为什么必须记在服务端(2026-10-05 教训):
//
// GUI 侧那行 `[device-bridge] screensee screen=0 bytes=N` 只打在它自己的
// 控制台。生产排障时手上只有 homed 的日志 ⇒ 无法回答「设备到底回传了什么」。
// 实测就有两次 screensee 返回「我收不到截图」,而服务端无任何痕迹可查,
// 最后只能靠推测(截屏为空 / data URL 截断 / 缩略图未就绪)。
//
// ★ 只记元信息与**极短指纹**,不记图片本体:
//
// · 日志会按字节膨胀;
// · 更重要的是截图是**用户屏幕内容**,属敏感信息,不该进日志。
//
// 记什么:字节数、magic、jpeg SOF 解出的像素尺寸、base64 头 24 字符、SHA256 前 16 hex。
// 能回答:「有没有回传」「是不是合法图片」「多长」「两次是不是同一张」「会不会被截断」。
func logScreenseePayload(deviceID, dataURL string) {
if dataURL == "" {
log.Printf("[remotedevice] screensee 回传 device=%s len=0 ★ 空内容:设备端没截到图"+
"(锁屏/虚拟桌面/缩略图未就绪?)", deviceID)
return
}
raw := dataURL
if i := strings.Index(raw, ","); strings.HasPrefix(raw, "data:") && i >= 0 {
raw = raw[i+1:]
}
b, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
// ★ 解不出来本身就是关键信号:说明回传的不是合法 base64(可能被截断)。
log.Printf("[remotedevice] screensee 回传 device=%s len=%d ★ base64 解码失败: %v",
deviceID, len(raw), err)
return
}
sum := sha256.Sum256(b)
head := raw
if len(head) > 24 {
head = head[:24]
}
w, h := jpegSize(b)
log.Printf("[remotedevice] screensee 回传 device=%s bytes=%d magic=%s %dx%d b64head=%q sha=%x",
deviceID, len(b), magicName(b), w, h, head, sum[:8])
}
// magicName 认出图片类型,用于一眼判断「回传的到底是什么」。
func magicName(b []byte) string {
switch {
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF:
return "jpeg"
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
return "png"
case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"):
return "gif"
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return "webp"
case len(b) == 0:
return "empty"
default:
return "unknown"
}
}
// jpegSize 从 jpeg 的 SOF 标记解出像素尺寸;不是 jpeg 或解不出返回 0,0。
//
// 不引入 image/jpeg 全解码:这里只要尺寸,而截图可能很大,
// 全解码一次是纯浪费(GUI 已经编码过一次了)。
func jpegSize(b []byte) (int, int) {
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
return 0, 0
}
i := 2
for i+3 < len(b) {
if b[i] != 0xFF {
i++
continue
}
marker := b[i+1]
// SOF0..SOF3 / SOF5..SOF7 / SOF9..SOF11 / SOF13..SOF15 携带尺寸
if marker >= 0xC0 && marker <= 0xCF &&
marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
if i+9 < len(b) {
h := int(b[i+5])<<8 | int(b[i+6])
w := int(b[i+7])<<8 | int(b[i+8])
return w, h
}
return 0, 0
}
// 其余段按 2 字节长度跳过
if i+3 >= len(b) {
break
}
segLen := int(b[i+2])<<8 | int(b[i+3])
if segLen < 2 {
break
}
i += 2 + segLen
}
return 0, 0
}