Files
HomeAgent/internal/plugins/remotedevice/screensee_truth_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

342 lines
11 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 remotedevice
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"image"
"image/color"
"image/jpeg"
"image/png"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// screensee 的两条教训(2026-10-05 生产日志实证)
//
// ## ① 视觉描述失败必须以 error 上抛,不能渲染成普通文本
//
// 原实现:`return fmt.Sprintf("屏幕截图视觉描述失败: %v…", err)`
// 生产实测(13:51):
// {"description":"屏幕截图视觉描述失败: api error 403:
// {\"error\":{\"message\":\"model \"gpt-5.6-sol\" is not allowed…"}
// ⇒ status=ok、只有 description 字段 ⇒ **失败与成功同形**。
// agent 以为拿到了结果,于是换 provider 再试一次(又失败一次)。
//
// 与同一天修掉的 MCP `isError` 丢失是**同一个病**。
//
// ★ 教训:不能只断言「返回值里含错误文本」——
// 那样「把错误写成 description」照样通过。必须断言**它从 error 通道出来**。
// TestSeeHandler_ErrorPropagatesAsError 钉住 seeHandler 的 error 不会被吞成文本。
func TestSeeHandler_ErrorPropagatesAsError(t *testing.T) {
reg := NewRegistry()
dev := &devicectlDevice{reg: reg}
// 模拟「视觉源 403」:handler 返回 error
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
return "", errFake403
})
// 直接验契约:签名本身允许 error 通道
var f func(string, string) (string, error) = dev.seeHandler
desc, err := f("data:image/jpeg;base64,AAAA", "")
if err == nil {
t.Fatal("handler 返回 error 时,调用方必须能拿到 error(否则又会被渲染成 description)")
}
if desc != "" {
t.Errorf("失败时不应同时返回描述文本,实际 %q", desc)
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("错误信息应保留上游原文供诊断,实际 %v", err)
}
}
// TestSeeHandler_SuccessStillWorks 钉住成功路径不受影响。
func TestSeeHandler_SuccessStillWorks(t *testing.T) {
reg := NewRegistry()
dev := &devicectlDevice{reg: reg}
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
return "屏幕上是一个终端窗口", nil
})
desc, err := dev.seeHandler("data:image/jpeg;base64,AAAA", "")
if err != nil || desc == "" {
t.Fatalf("成功路径被破坏:desc=%q err=%v", desc, err)
}
}
// TestLogScreenseePayload_IdentifiesContent 钉住「回传内容可查证」。
//
// ★ 这条对应那两次无从查证的「我收不到截图」:
// 服务端日志必须能回答「设备到底回传了什么」。
// 现在记录 字节数 / magic / 像素尺寸 / base64 头 / SHA256 前缀。
func TestLogScreenseePayload_IdentifiesContent(t *testing.T) {
cases := []struct {
name string
data []byte
wantW int
wantH int
}{
{"jpeg 8x4", makeJPEG(t, 8, 4), 8, 4},
{"jpeg 1920x1080", makeJPEG(t, 1920, 1080), 1920, 1080},
}
for _, c := range cases {
url := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(c.data)
logScreenseePayload("dev1", url)
}
// png(magic 走 png 分支,尺寸解不出 → 0 0,也应正常不 panic)
pngURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(makePNG(t, 3, 3))
logScreenseePayload("dev1", pngURL)
// 空
logScreenseePayload("dev1", "")
// 非法 base64(被截断的典型形态)
logScreenseePayload("dev1", "data:image/jpeg;base64,!!!not-base64!!!")
}
// TestJpegSize 单独钉住 SOF 解析:尺寸是判断「图是不是真的截到了」的关键信号。
func TestJpegSize(t *testing.T) {
for _, dim := range [][2]int{{1, 1}, {640, 480}, {1920, 1080}, {2560, 1440}} {
b := makeJPEG(t, dim[0], dim[1])
w, h := jpegSize(b)
if w != dim[0] || h != dim[1] {
t.Errorf("%dx%d: jpegSize 给 %dx%d", dim[0], dim[1], w, h)
}
}
// 非 jpeg 一律 0,0,且不得 panic
if w, h := jpegSize([]byte("not an image")); w != 0 || h != 0 {
t.Errorf("非 jpeg 应给 0,0,实际 %dx%d", w, h)
}
if w, h := jpegSize(nil); w != 0 || h != 0 {
t.Errorf("nil 应给 0,0,实际 %dx%d", w, h)
}
// ★ 截断的 jpeg(真实故障形态):不得 panic,且不该报出假的尺寸
trunc := makeJPEG(t, 100, 100)
if w, _ := jpegSize(trunc[:20]); w != 0 {
t.Errorf("截断的 jpeg 不应报出宽度,实际 %d", w)
}
}
func TestMagicName(t *testing.T) {
if got := magicName(makeJPEG(t, 2, 2)); got != "jpeg" {
t.Errorf("jpeg 识别错: %s", got)
}
if got := magicName(makePNG(t, 2, 2)); got != "png" {
t.Errorf("png 识别错: %s", got)
}
if got := magicName(nil); got != "empty" {
t.Errorf("nil 应为 empty,实际 %s", got)
}
if got := magicName([]byte("garbage")); got != "unknown" {
t.Errorf("垃圾数据应为 unknown,实际 %s", got)
}
}
func makeJPEG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func makePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
var errFake403 = &fakeErr{"api error 403: model \"gpt-5.6-sol\" is not allowed for this key"}
type fakeErr struct{ s string }
func (e *fakeErr) Error() string { return e.s }
// ===== 以下走完整链路(真 WS + 真 registry),盯住「失败从哪个通道出来」 =====
// TestScreenseE2E_VisualFailureIsErrorNotDescription 是本次修改的**核心门禁**。
//
// ★ 为什么必须走完整链路:
//
// 我第一版判据只测 `seeHandler` 的签名(能不能返回 error)——
// 把调用点的 `if err != nil` 改成「把错误渲染成 description 文本」时,
// **判据全绿**。因为被测的是「签名允许 error」,
// 而真正的缺陷在「调用点有没有真的走 error 通道」。
// ⇒ 判据必须驱动 `dev.Execute("screensee", …)` 走完一整圈。
//
// 生产症状(2026-10-05 13:51):
//
// {"description":"屏幕截图视觉描述失败: api error 403: …"} ← status=ok
//
// 修好后应当是:err != nil,且结果里**没有** description 字段。
func TestScreenseE2E_VisualFailureIsErrorNotDescription(t *testing.T) {
reg := NewRegistry()
token := "tk-visual-fail"
reg.SetAcceptToken(func(p string) bool { return p == token })
dev := &devicectlDevice{reg: reg}
// 视觉源返回 403(MCP/screensee 生产上真实发生过)
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
return "", errFake403
})
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 设备必须先 hello+bind 才会进 registry(否则 Execute 报「设备不存在」)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"vf-dev","name":"视觉失败机","kind":"computer","caps":["screensee"]}}`))
cli.readHelloAckAndBind(t, token)
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil || op != 0x1 {
return
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] == "cmd" && msg["command"] == "screensee" {
reqID, _ := msg["req_id"].(string)
img := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(makeJPEG(t, 64, 48))
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "vf-dev",
"status": "ok", "output": img,
}))
}
}
}()
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev"})
// ★ 必须报错
if err == nil {
t.Fatalf("视觉描述失败必须以 error 上抛,实际 res=%v —— "+
"渲染成 description 会让 agent 以为拿到了结果并反复重试", res)
}
// ★ 错误信息要保留上游原文(那是唯一诊断线索)
if !strings.Contains(err.Error(), "403") {
t.Errorf("错误信息应保留上游原文,实际: %v", err)
}
// ★ 结果里绝不能有 description 字段
if m, ok := res.(map[string]interface{}); ok {
if _, has := m["description"]; has {
t.Errorf("失败时不得返回 description 字段(那是本次要修的缺陷),实际: %v", m)
}
}
}
// TestScreenseE2E_SuccessReturnsDescription 钉住成功路径没被改坏。
func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) {
reg := NewRegistry()
token := "tk-visual-ok"
reg.SetAcceptToken(func(p string) bool { return p == token })
dev := &devicectlDevice{reg: reg}
var gotLen int
dev.SetSeeHandler(func(dataURL, provider string) (string, error) {
gotLen = len(dataURL)
return "屏幕上是一个终端窗口", nil
})
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"vo-dev","name":"视觉成功机","kind":"computer","caps":["screensee"]}}`))
cli.readHelloAckAndBind(t, token)
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil || op != 0x1 {
return
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] == "cmd" && msg["command"] == "screensee" {
reqID, _ := msg["req_id"].(string)
img := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(makeJPEG(t, 64, 48))
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "vo-dev",
"status": "ok", "output": img,
}))
}
}
}()
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vo-dev"})
if err != nil {
t.Fatalf("成功路径被破坏: %v", err)
}
m, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("返回类型变了: %T", res)
}
if m["description"] != "屏幕上是一个终端窗口" {
t.Errorf("description 不对: %v", m["description"])
}
if gotLen == 0 {
t.Error("handler 没收到 dataURL")
}
}
// TestIsModelNotAllowed 钉住「模型名不被接受」的识别。
//
// ★ 这是 2026-10-05 生产那条 403 的判定入口:
//
// model "gpt-5.6-sol" is not allowed for this key
//
// 根因不是「模型不支持图」,而是**那个 key 的 model scope 只有 AUTO**
// (/etc/llmsproxy/config.yaml 的 keys[homeagent].models = [AUTO])。
// 而同一 key + base_url 下 model=AUTO 实测完全能看图 ⇒ 该回退而不是报错。
func TestIsModelNotAllowed(t *testing.T) {
// llmsproxy 的三种原文(internal/gateway/chat.go)
yes := []string{
`model "gpt-5.6-sol" is not allowed for this key`,
`{"error":{"type":"model_not_allowed","message":"model \"x\" is not allowed for this key"}}`,
`model "x" is not in this key's model scope, so it cannot use AUTO`,
}
for _, s := range yes {
if !isModelNotAllowed(errors.New(s)) {
t.Errorf("应识别为「模型名不被接受」: %s", s)
}
}
// ★ 必须**不**把其它错误误判成这一类 —— 那会触发错误的 AUTO 回退。
no := []string{
`invalid gateway api key`,
`api error 401: Unauthorized`,
`read: connection reset by peer`,
`context deadline exceeded`,
``, // nil 情形另行覆盖
}
for _, s := range no {
if isModelNotAllowed(errors.New(s)) {
t.Errorf("不应被识别为「模型名不被接受」(会误触发 AUTO 回退): %s", s)
}
}
if isModelNotAllowed(nil) {
t.Error("nil 不应被判为模型名问题")
}
}