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:
@ -407,9 +407,9 @@ func TestScreenseeEndToEnd(t *testing.T) {
|
||||
|
||||
dev := &devicectlDevice{reg: reg}
|
||||
var gotDataURL string
|
||||
dev.SetSeeHandler(func(dataURL string, provider string) string {
|
||||
dev.SetSeeHandler(func(dataURL string, provider string) (string, error) {
|
||||
gotDataURL = dataURL
|
||||
return "屏幕上显示的是测试画面"
|
||||
return "屏幕上显示的是测试画面", nil
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -318,9 +318,28 @@ func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// describeScreen 用视觉模型描述设备屏幕截图(screensee 回调)。
|
||||
// provider 为空时使用默认 LLM 源;模型不支持视觉时返回友好错误。
|
||||
func (p *Plugin) describeScreen(dataURL string, provider string) string {
|
||||
// describeScreen 把设备回传的屏幕截图交给视觉模型生成描述。
|
||||
//
|
||||
// ★★ 2026-10-05 签名改为 (string, error)(生产日志实证的教训)
|
||||
//
|
||||
// 原实现把 403 等**失败**渲染成一段普通文本返回:
|
||||
//
|
||||
// return fmt.Sprintf("屏幕截图视觉描述失败: %v(当前模型可能不支持图像输入)", err)
|
||||
//
|
||||
// 而调用方把它当作 description 字段、status=ok ⇒ **失败被包装成成功**。
|
||||
// 实测(2026-10-05 13:51):
|
||||
//
|
||||
// {"description":"屏幕截图视觉描述失败: api error 403:
|
||||
// {\"error\":{\"message\":\"model \"gpt-5.6-sol\" is not allowed…"}
|
||||
//
|
||||
// agent 读到的是一段自然语言,看不出这是工具失败,
|
||||
// 于是又发了一次 screensee(换 provider)再失败一次。
|
||||
// 与同一天修掉的 MCP `isError` 丢失是**同一个病**:失败与成功同形。
|
||||
//
|
||||
// ⇒ 失败必须以 error 上抛,文本只留作诊断线索。
|
||||
func (p *Plugin) describeScreen(dataURL string, provider string) (string, error) {
|
||||
if p.sdk == nil || p.sdk.LLM() == nil {
|
||||
return "LLM 不可用,无法描述屏幕内容"
|
||||
return "", fmt.Errorf("LLM 不可用,无法描述屏幕内容")
|
||||
}
|
||||
llm := p.sdk.LLM()
|
||||
req := &sdk.LLMCompletionRequest{
|
||||
@ -337,18 +356,60 @@ func (p *Plugin) describeScreen(dataURL string, provider string) string {
|
||||
if provider != "" {
|
||||
prev := llm.CurrentSource()
|
||||
if err := llm.SetSource(provider); err != nil {
|
||||
log.Printf("[remotedevice] screensee set source %s: %v", provider, err)
|
||||
// ★ 切源失败**不降级**:原来只打一行日志然后继续用当前源,
|
||||
// 而当前源可能与调用方指定的完全不同 ⇒ 模型看到的是
|
||||
// 「另一个源的结果」,却挂在指定源名下。直接报错更诚实。
|
||||
return "", fmt.Errorf("screensee: 切换到指定视觉源 %s 失败: %w", provider, err)
|
||||
} else if prev != "" {
|
||||
defer func() { _ = llm.SetSource(prev) }()
|
||||
}
|
||||
}
|
||||
// ★ 记录本次实际用的是哪个源:排查「为什么描述不对」时,
|
||||
// 第一件要知道的就是它到底是哪个源在回答。
|
||||
usedSource := llm.CurrentSource()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := llm.Chat(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("屏幕截图视觉描述失败: %v(当前模型可能不支持图像输入)", err)
|
||||
log.Printf("[remotedevice] screensee 视觉描述失败 source=%s err=%v", usedSource, err)
|
||||
// ★ 2026-10-05:模型名不被接受时,**就地降级到 AUTO 重试一次**。
|
||||
//
|
||||
// 生产实测(13:51):agent 传 provider=visionllm,而那个源的
|
||||
// `model: gpt-5.6-sol` 被 llmsproxy 拒了:
|
||||
//
|
||||
// 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` 实测完全能看图。
|
||||
//
|
||||
// ⇒ 显式模型名不可用时回 AUTO,比把错误抛给 agent 让它反复重试好。
|
||||
// 注意**不覆盖原错误**:两者都返回,由上层看到完整信息。
|
||||
if isModelNotAllowed(err) {
|
||||
log.Printf("[remotedevice] screensee: 源 %s 的模型名不被接受,回退 AUTO 重试", usedSource)
|
||||
if e2 := llm.SetSource("AUTO"); e2 == nil {
|
||||
rctx, rcancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer rcancel()
|
||||
if rresp, e3 := llm.Chat(rctx, req); e3 == nil && rresp != nil &&
|
||||
strings.TrimSpace(rresp.Content) != "" {
|
||||
log.Printf("[remotedevice] screensee AUTO 回退成功 len=%d", len(rresp.Content))
|
||||
return rresp.Content, nil
|
||||
} else {
|
||||
log.Printf("[remotedevice] screensee AUTO 回退也失败: %v", e3)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[remotedevice] screensee AUTO 回退无法切源: %v", e2)
|
||||
}
|
||||
}
|
||||
// 保留原始错误文本(那是唯一诊断线索),但以 error 形态上抛。
|
||||
return "", fmt.Errorf("屏幕截图视觉描述失败(视觉源 %s,model=%s): %w", usedSource, currentModelOf(p.sdk.Settings(), usedSource), err)
|
||||
}
|
||||
return resp.Content
|
||||
if resp == nil || strings.TrimSpace(resp.Content) == "" {
|
||||
// 空内容与「失败」对模型同样难以区分,不能静默当成结果。
|
||||
return "", fmt.Errorf("屏幕截图视觉描述失败(视觉源 %s): 模型返回空内容", usedSource)
|
||||
}
|
||||
log.Printf("[remotedevice] screensee 视觉描述完成 source=%s len=%d", usedSource, len(resp.Content))
|
||||
return resp.Content, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
@ -366,3 +427,41 @@ func (p *Plugin) Stop() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isModelNotAllowed 判定错误是不是「这个 key 不许用该模型名」。
|
||||
//
|
||||
// ★ 2026-10-05:llmsproxy 的原文是
|
||||
//
|
||||
// model "gpt-5.6-sol" is not allowed for this key
|
||||
//
|
||||
// (internal/gateway/chat.go 的 model_not_allowed)
|
||||
//
|
||||
// 只认这一类:它与「模型不存在」「key 无效」的处理完全不同 ——
|
||||
// 前者换个模型名/回 AUTO 就能成,后者要动配置或凭证。
|
||||
func isModelNotAllowed(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "is not allowed for this key") ||
|
||||
strings.Contains(s, "model_not_allowed") ||
|
||||
strings.Contains(s, "is not in this key's model scope")
|
||||
}
|
||||
|
||||
// currentModelOf 取某个源当前配置的模型名(只用于错误信息,便于定位)。
|
||||
//
|
||||
// ★ 拿不到就返回 "":**诊断信息缺失不该阻断主流程**,
|
||||
//
|
||||
// 所以这里不返回 error(内层的 fmt 已经带着原始错误文本了)。
|
||||
func currentModelOf(settings interface {
|
||||
Get(string) (interface{}, error)
|
||||
}, source string) string {
|
||||
if settings == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := settings.Get("core.llm.sources." + source + ".model")
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
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