mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
341
internal/plugins/remotedevice/screensee_truth_test.go
Normal file
341
internal/plugins/remotedevice/screensee_truth_test.go
Normal file
@ -0,0 +1,341 @@
|
||||
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 不应被判为模型名问题")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user