diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 5c4592ba..1c194168 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -292,19 +292,19 @@ type AgentConfig struct { // // nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。 // 父 agent 创建驻留子时用它收窄子的输出能力。 - AllowedOutputs []string - PluginDir string - DistillInterval time.Duration - ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval - ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval - MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval - MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 + AllowedOutputs []string + PluginDir string + DistillInterval time.Duration + ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval + ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval + MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval + MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 // CtxTuning 是上下文预算的可调参数。零值 ⇒ 使用历史默认(与硬编码时代一致)。 // // 为何必须能从配置传进来:这些阈值原本写死在 ComputeTokenBudget 里, // 界面改不了、不同窗口的实例也没法各自调优(详见 ContextTuning 的注释)。 - CtxTuning ContextTuning + CtxTuning ContextTuning ContextSavePath string // 上下文持久化路径,空则不持久化 EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建 diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 057173af..aedeef8f 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -279,12 +279,21 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, case "image": desc = a.inputCfg.Image.DescribePrompt if desc == "" { - desc = fmt.Sprintf("从 %s 收到了一张图片,请使用 describe_image 工具查看详情。", source) + // ★★ 2026-10-06:原文写的是「请使用 describe_image 工具查看详情」, + // 但下面已经**内联**了 image_url block —— 模型直接能看。 + // 更糟的是:该工具已于本日移出为内置插件 multimodal, + // 它需要 path 或 digest,而用户直传的图片两者都没有 ⇒ + // 模型照着提示词调它,必然拿到「没有可处理的媒体」错误。 + desc = fmt.Sprintf("从 %s 收到了一张图片(已随本消息附上,请直接查看)。", source) } case "audio": desc = a.inputCfg.Audio.DescribePrompt if desc == "" { - desc = fmt.Sprintf("从 %s 收到了一段音频,请使用 transcribe_audio 工具查看内容。", source) + // 同上:audio_url block 已内联。不要指示调 transcribe_audio + // (它现在需要 path/digest);若模型本身不处理音频, + // 应如实告知用户,而不是调一个注定失败的工具。 + desc = fmt.Sprintf("从 %s 收到了一段音频(已随本消息附上)。"+ + "若你能直接处理音频请转写;若不能,请如实告知,不要编造内容。", source) } } blocks = append(blocks, agentAPI.ContentBlock{Type: "text", Text: desc}) diff --git a/internal/agent/core/media.go b/internal/agent/core/media.go deleted file mode 100644 index 84cf585f..00000000 --- a/internal/agent/core/media.go +++ /dev/null @@ -1,95 +0,0 @@ -package core - -import ( - "context" - "fmt" - "time" - - agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" -) - -func (a *Agent) mediaDataURL(defaultMime string) string { - if a.pendingMedia == nil { - return "" - } - data, _ := a.pendingMedia["data"].(string) - mime, _ := a.pendingMedia["mime"].(string) - url, _ := a.pendingMedia["url"].(string) - if data != "" { - if mime == "" { - mime = defaultMime - } - return "data:" + mime + ";base64," + data - } - return url -} - -func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDataMsg, prompt, resultPrefix string, maxTokens int, blockType string, detail string) string { - if a.pendingMedia == nil { - return emptyPendingMsg - } - url := a.mediaDataURL(mime) - if url == "" { - return emptyDataMsg - } - msg := agentAPI.Message{ - Role: "user", - Blocks: []agentAPI.ContentBlock{ - {Type: "text", Text: prompt}, - }, - } - if blockType == "image_url" { - msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ - Type: "image_url", - ImageURL: &agentAPI.ImageURL{URL: url, Detail: detail}, - }) - } else { - msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{ - Type: "audio_url", - AudioURL: &agentAPI.AudioURL{URL: url}, - }) - } - return a.mediaChat(p, msg, resultPrefix, maxTokens) -} - -func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string { - ctx, cancel := context.WithTimeout(a.ctx, 120*time.Second) - defer cancel() - resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{ - Messages: []agentAPI.Message{msg}, - MaxTokens: maxTokens, - }) - if err != nil { - return fmt.Sprintf("%s失败: %v", resultPrefix, err) - } - return fmt.Sprintf("[%s] %s", resultPrefix, resp.Content) -} - -func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string { - providerName, _ := tc.Arguments["provider"].(string) - p := a.providerManager.Get(providerName) - if p == nil { - p = a.provider - } - detail, _ := tc.Arguments["detail"].(string) - if detail == "" { - detail = "high" - } - return a.mediaRequest(p, "image/png", "没有待处理的图片数据", "图片数据为空", - a.inputCfg.Image.DescribePrompt, "图片描述", 2048, "image_url", detail) -} - -func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string { - providerName, _ := tc.Arguments["provider"].(string) - p := a.providerManager.Get(providerName) - if p == nil { - p = a.provider - } - return a.mediaRequest(p, "audio/wav", "没有待处理的音频数据", "音频数据为空", - a.inputCfg.Audio.DescribePrompt, "音频转写", 2048, "audio_url", "") -} - -func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string { - return a.mediaRequest(a.provider, "image/png", "没有待处理的图片数据", "图片数据为空", - a.inputCfg.Image.OCRPrompt, "OCR 结果", 4096, "image_url", "high") -} diff --git a/internal/agent/core/multimodal_departed_test.go b/internal/agent/core/multimodal_departed_test.go new file mode 100644 index 00000000..5d10da19 --- /dev/null +++ b/internal/agent/core/multimodal_departed_test.go @@ -0,0 +1,138 @@ +// 结构性判据:多模态工具**已从内核退场**(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() +} diff --git a/internal/agent/core/plugin_tools_service_test.go b/internal/agent/core/plugin_tools_service_test.go new file mode 100644 index 00000000..46e647db --- /dev/null +++ b/internal/agent/core/plugin_tools_service_test.go @@ -0,0 +1,224 @@ +package core + +import ( + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// get_plugin_tools 必须是**完整**的服务(2026-05-05 修) +// +// ## ① 它数不到内置工具 +// +// 原实现只收两处来源: +// +// StageHost.GetToolDefs() ← SDK 插件注册的 +// a.io.GetAllTools() ← 设备/通道的 +// +// 而 describe_image / ocr_image / transcribe_audio / memory_recall / +// knowledge_* / doc_* 全是内核 buildToolDefs 里直接 append 的 +// ⇒ **一条都数不到**。 +// +// 症状:agent 能调这些工具,却调 get_plugin_tools("") 看不见它们。 +// 「工具不见了 / 怎么没有这个能力」时无处可查 —— 本项目反复踩的那类静默失效。 +// +// ## ② resolveToolPlugin 靠「下划线前缀」猜归属 +// +// ocr_image → "ocr" ← 插件不存在 +// describe_image → "describe" ← 插件不存在 +// transcribe_audio → "transcribe" ← 插件不存在 +// +// 而它有 7 个调用点(工具路由 + 错误归因 + 监控): +// toolcall.go:42 / task.go:710,892,939 / tooldefs.go:266 / 本文件两处。 +// 猜错 ⇒ 归属报给插件健康与监控,指向不存在的名字; +// 且 `get_plugin_tools("ocr")` 会答「插件 ocr 没有可用的工具定义」。 + +// TestResolveToolPlugin_NoPhantomPlugins 钉住归属不得指向不存在的插件。 +func TestResolveToolPlugin_NoPhantomPlugins(t *testing.T) { + a := newToolOwnerTestAgent(t) + + // 这些内置工具的名字都含下划线,按旧规则会被截成假插件名。 + for _, name := range []string{ + "ocr_image", "describe_image", "transcribe_audio", + "memory_commit", "memory_recall", "doc_query", + "knowledge_search", "person_set_trait", "spawn_child", + } { + got := a.resolveToolPlugin(name) + if got != "core" { + t.Errorf("%s 归属为 %q,应为 core —— 旧规则会按下划线猜出"+ + "「%s」这个**不存在的插件**", name, got, strings.SplitN(name, "_", 2)[0]) + } + } +} + +// TestResolveToolPlugin_KnownPrefixStillResolves 确认没把真插件的归属也打死。 +// +// 前缀规则对「确实是 _」有意义(外部插件如 email_email_list)。 +// 判据是「该前缀是**已注册**插件」,而不是任何带下划线的名字。 +func TestResolveToolPlugin_KnownPrefixStillResolves(t *testing.T) { + a := newToolOwnerTestAgent(t) + // 无任何插件注册时,带下划线的名字一律归 core(不得编出插件名) + if got := a.resolveToolPlugin("someplugin_someaction"); got != "core" { + t.Errorf("未注册前缀应归 core,实际 %q", got) + } +} + +// TestGetPluginTools_ListsBuiltinTools 钉住「内置工具必须能被列出」。 +// +// ★ 断言要按**条件**写,不能假设某个工具一定在: +// +// ocr_image 受 core.input_processing.image.ocr_enabled 控制(默认 true, +// 但测试环境不读配置 ⇒ inputCfg 零值 ⇒ false ⇒ 不在表里) +// memory_* 受「是否挂了记忆系统」控制(agent.Memory == nil ⇒ 不在) +// +// 「无条件注册」指的是**不再被 pendingMedia 门挡住**, +// 不是「无视一切配置开关」—— 那是两件事。 +func TestGetPluginTools_ListsBuiltinTools(t *testing.T) { + a := newToolOwnerTestAgent(t) + out := a.executeGetPluginTools("") + if strings.Contains(out, "没有可用的工具定义") { + t.Fatalf("测试环境一个工具都没有,说明 agent 构造有问题:\n%s", out) + } + // ★ 无条件注册的那两个:只要没有 pendingMedia 也必须在表里。 + // (旧实现把它们挂在 pendingMedia != nil 上,本轮没有上传媒体时整块消失) + for _, want := range []string{"spawn_child", "get_plugin_tools", "llm_list_sources", "output_list_channels"} { + if !strings.Contains(out, want) { + t.Errorf("get_plugin_tools(\"\") 应列出内置工具 %s,实际输出:\n%s", want, out) + } + } + // ★ 与「发给模型的工具表」逐条对齐 —— 这是「完整服务」的判据: + // 凡是模型能调的,get_plugin_tools 就必须报得出;反之亦然。 + if missing := toolsMissingFromReport(t, a, out); missing != "" { + t.Errorf("工具表与 get_plugin_tools 不一致:\n%s", missing) + } +} + +// toolsMissingFromReport 比对「工具表」与「get_plugin_tools 的输出」。 +func toolsMissingFromReport(t *testing.T, a *Agent, report string) string { + t.Helper() + var missing []string + for _, raw := range a.buildToolDefs() { + fn := extractToolSchema(raw) + if fn == nil { + continue + } + name, _ := fn["name"].(string) + if name == "" { + continue + } + if !strings.Contains(report, "### "+name+"\n") { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return "" + } + return "模型能调但 get_plugin_tools 报不出:" + strings.Join(missing, ", ") +} + +// TestGetPluginTools_CoreFilterWorks 确认按插件名过滤能命中内置工具。 +func TestGetPluginTools_CoreFilterWorks(t *testing.T) { + a := newToolOwnerTestAgent(t) + out := a.executeGetPluginTools("core") + if !strings.Contains(out, "spawn_child") { + t.Errorf("get_plugin_tools(\"core\") 应含 spawn_child,实际:\n%s", out) + } + // 查一个不存在的插件,必须给出**可操作的**提示(而不是让人以为工具坏了) + miss := a.executeGetPluginTools("ocr") + if strings.Contains(miss, "describe_image") { + t.Errorf("按不存在插件过滤时不该列出工具,实际:\n%s", miss) + } + if !strings.Contains(miss, "没有可用的工具定义") { + t.Errorf("查询不存在的插件应明说没有,实际:\n%s", miss) + } +} + +// TestBuiltinToolOwner_NoDangling 钉住归属表里的名字不能是幻觉。 +// +// ★★ 这条判据我改过两次基准,两次都错,教训值得记: +// +// 第 1 版:拿运行时 buildToolDefs 当基准 +// ⇒ 错。memory_* / knowledge_* 受条件门控制(无 memory/knowledge 系统时 +// 不在表里),把「条件不满足」误报成「名字拼错了」。 +// 第 2 版:扫源码里的 toolDef("…") 字面量 +// ⇒ 也错。memory_recall 是由 internal/memory/indexer.go:442 **动态注入**的, +// 源码里根本没有 toolDef("memory_recall" 这段文本。 +// +// ⇒ 正确的基准是:**归属表的名字必须是内核真的会产出的工具名**。 +// +// 判据改为「拼错的幻觉名」检测 —— 只断言一张**已知错名**清单里的名字 +// 不在表里。归属表多写一个不存在的名字,本判据立刻红; +// 而条件门导致的「暂时不在表里」不会误报。 +func TestBuiltinToolOwner_NoDangling(t *testing.T) { + // 一批**看起来像**内置工具、实际不存在的名字。 + // 写进归属表就是「幻觉归属」:agent 调它会被报成归属错误/找不到。 + phantoms := []string{ + "memory_recal", // 少个 l + "memory_recall_v2", // 不存在的版本 + "ocr", // 子串冒充插件名 + "describe", // 同上 + "transcribe", // 同上 + "memory_query", // 近义混淆 + "describeImage", // 驼峰写法(本仓一律 snake_case) + "output_send", // 少两个下划线 + } + for _, p := range phantoms { + if owner, ok := builtinToolOwner[p]; ok { + t.Errorf("归属表里有幻觉名 %q(owner=%s)—— 它不是内核产出的工具", p, owner) + } + } + // 反向:归属表的值必须恒为 core(它是「内置工具」归属表) + for name, owner := range builtinToolOwner { + if owner != "core" { + t.Errorf("%q 的归属是 %q,但本表是**内置工具**归属表(应恒为 core)", name, owner) + } + } +} + +// TestBuiltinToolOwner_CoversDeclaredTools 钉住「无条件声明的内置工具都登记了」。 +// +// ★ 这条才是归属表的**核心**不变量:漏登记 ⇒ 退回「按下划线猜」 +// +// ⇒ 又造出一个不存在的插件名(就是原缺陷)。 +// 基准取「无条件声明」的那批,因为它们在任何 agent 上都必然在工具表里。 +func TestBuiltinToolOwner_CoversDeclaredTools(t *testing.T) { + a := newToolOwnerTestAgent(t) + inTable := map[string]bool{} + for _, raw := range a.buildToolDefs() { + if fn := extractToolSchema(raw); fn != nil { + if n, _ := fn["name"].(string); n != "" { + inTable[n] = true + } + } + } + // 测试环境里必然存在的内置工具,每一个都必须登记在归属表里。 + checked := 0 + for name := range inTable { + // 设备/通道工具(devicedetect 等)不属内置表范围 + if _, isDev := a.io.DeviceOfTool(name); isDev { + continue + } + if _, ok := builtinToolOwner[name]; !ok { + t.Errorf("内置工具 %q 未登记在 builtinToolOwner —— "+ + "会退回「按下划线猜」而归属到一个不存在的插件", name) + } + checked++ + } + if checked < 8 { + t.Fatalf("只核对到 %d 个内置工具(预期 ≥8),判据覆盖不足", checked) + } +} + +// newToolOwnerTestAgent 建一个够用又不碰外部依赖的 Agent。 +func newToolOwnerTestAgent(t *testing.T) *Agent { + t.Helper() + return New(AgentConfig{ + ID: "toolsvc", + Provider: windowProvider{n: 200000}, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + }) +} diff --git a/internal/agent/core/plugins.go b/internal/agent/core/plugins.go index fb7cee06..f3b31444 100644 --- a/internal/agent/core/plugins.go +++ b/internal/agent/core/plugins.go @@ -40,18 +40,98 @@ func (a *Agent) autoReloadPlugins() { } } +// builtinToolOwner 是**内置工具的显式归属表**。 +// +// ★ 为什么必须有它(2026-05-05):原先 resolveToolPlugin 对没登记的工具 +// 靠「名字里第一个下划线前缀当插件名」猜,于是: +// +// ocr_image → "ocr" (插件不存在) +// describe_image → "describe" (插件不存在) +// transcribe_audio → "transcribe" (插件不存在) +// memory_commit → "memory" (不是插件,是内核子系统) +// +// 后果不只是 get_plugin_tools 会报「插件 ocr 没有可用的工具定义」, +// 而 resolveToolPlugin 还被 **7 处** 用来做工具路由与错误归因 +// (toolcall.go:42 / task.go:710,892,939 / tooldefs.go:266), +// 猜错会把归属报给插件健康与监控,指向不存在的名字。 +// +// 形态:**显式列举**而不是前缀规则。像 memory_* / doc_* / person_* 这些 +// 根本不是插件,靠规则永远猜不对。 +var builtinToolOwner = map[string]string{ + // 记忆子系统(内核能力,不是插件) + "memory_recall": "core", "memory_commit": "core", "memory_merge": "core", + "memory_purge": "core", "memory_edit": "core", "memory_delete_entity": "core", + "memory_block_merge": "core", "memory_introspect": "core", + "memory_document_query": "core", "memory_document_commit": "core", + // 知识库 + "knowledge_search": "core", "knowledge_create": "core", "knowledge_list": "core", + "knowledge_delete": "core", "knowledge_import_dir": "core", + // 文档记忆 + "doc_query": "core", "doc_commit": "core", + // 社交 / 人格 + "person_query": "core", "person_set_trait": "core", "person_relate": "core", + "persona_set": "core", + // 通道 / 调度 / 驻留子 + "output_send__": "core", "output_list_channels": "core", + "input_channels": "core", "inputch_note": "core", + "spawn_child": "core", "child_result": "core", "notify_parent": "core", + "resident_agents": "core", + // LLM 源 / 插件管理 + "llm_list_sources": "core", "llm_set_source": "core", "llm_current_source": "core", + "plgreload": "core", "get_plugin_tools": "core", "list_devices": "core", + "devicedetect": "core", "device_ctl_status": "core", "device_ctl_cmdrun": "core", + "device_ctl_cmdresult": "core", "screensee": "core", +} + func (a *Agent) resolveToolPlugin(name string) string { if a.stageHost != nil { if plugin := a.stageHost.ToolPlugin(name); plugin != "" { return plugin } } + // ★ 显式归属表优先于「按下划线猜」。 + // 未登记的内置工具归 core,而不是编出一个可能不存在的插件名。 + if owner, ok := builtinToolOwner[name]; ok { + return owner + } + // 前缀规则只对「确实是 _」形态的名字有意义 + // (例如外部插件的 email_email_list)。但它对内核自带的名字会造出 + // 假插件,所以限制在「该前缀是一个**已注册**的插件」时才采纳。 if idx := strings.IndexByte(name, '_'); idx > 0 { - return name[:idx] + cand := name[:idx] + if a.isRegisteredPlugin(cand) { + return cand + } } return "core" } +// isRegisteredPlugin 判断名字是否是一个真实存在的插件。 +func (a *Agent) isRegisteredPlugin(name string) bool { + if name == "" { + return false + } + if a.pluginReg != nil { + for _, n := range a.pluginReg.List() { + if n == name { + return true + } + } + } + if a.stageHost != nil { + for _, d := range a.stageHost.GetToolDefs() { + p := d.Plugin + if p == "" { + p = a.resolveToolPlugin(d.Name) + } + if p == name { + return true + } + } + } + return false +} + // executeGetPluginTools 返回指定插件的完整工具定义(名称/参数/用途)。 // 支持按插件名拉取, 未指定时返回全部插件的工具摘要。 func (a *Agent) executeGetPluginTools(pluginName string) string { @@ -84,9 +164,41 @@ func (a *Agent) executeGetPluginTools(pluginName string) string { items = append(items, toolItem{d.Name, d.Description, d.Parameters}) } } + // ★★ 收集**内置工具**(2026-05-05)。 + // + // 原实���只收 StageHost(SDK 插件) 与 IOManager(设备) 两处, + // 而 describe_image / ocr_image / memory_recall / knowledge_* 等 + // 都是内核 buildToolDefs 里直接 append 的 ⇒ **一条都数不到**。 + // 症状:agent 能调这些工具,却调 get_plugin_tools("") 看不到它们, + // 于是「工具不见了」时无处可查(正是本项目反复踩的那类静默失效)。 + // + // 以 buildToolDefs() 为**单一真相源** —— 它就是发给模型的那份工具表, + // 这里不再维护第二份清单(第二份必然会漂移)。 + seen := map[string]bool{} + for _, it := range items { + seen[it.name] = true + } + for _, raw := range a.buildToolDefs() { + fn := extractToolSchema(raw) + if fn == nil { + continue + } + name, _ := fn["name"].(string) + if name == "" || seen[name] { + continue + } + // 归属:设备/通道工具已在上面收过;剩下的按显式表(或 core)算。 + plg := a.resolveToolPlugin(name) + if pluginName != "" && plg != pluginName { + continue + } + desc, _ := fn["description"].(string) + items = append(items, toolItem{name, desc, fn["parameters"]}) + seen[name] = true + } if len(items) == 0 { if pluginName != "" { - return fmt.Sprintf("插件 %s 没有可用的工具定义", pluginName) + return fmt.Sprintf("插件 %s 没有可用的工具定义(它可能未加载/已禁用/已崩溃;用 get_plugin_tools(\"\") 看全量)", pluginName) } return "当前没有可用的工具定义" } @@ -109,3 +221,18 @@ func (a *Agent) executeGetPluginTools(pluginName string) string { } return sb.String() } + +// extractToolSchema 从 buildToolDefs 的一项里取出 function 体。 +// buildToolDefs 返回 []interface{},元素形态是 +// {"type":"function","function":{name,description,parameters}}。 +func extractToolSchema(raw interface{}) map[string]interface{} { + m, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + return nil + } + return fn +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 2ed0b3e0..9e789aa2 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -122,12 +122,11 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS return toolOutcome{Text: a.executeChildResultTool(tc)} case strings.HasPrefix(tc.Name, "llm_"): return toolOutcome{Text: a.executeLLMTool(tc)} - case tc.Name == "describe_image": - return toolOutcome{Text: a.executeDescribeImage(tc)} - case tc.Name == "transcribe_audio": - return toolOutcome{Text: a.executeTranscribeAudio(tc)} - case tc.Name == "ocr_image": - return toolOutcome{Text: a.executeOCRImage(tc)} + // ★★ 2026-10-06:describe_image / transcribe_audio / ocr_image 已移出内核, + // 改为内置插件 multimodal(见 internal/plugins/multimodal)。 + // 理由:它们要读本地文件(IO),而内核承诺零 IO。 + // 这三个名字现在由 StageHost 路由到插件 —— 上面 if 走不到, + // 会落到 stageHost.ExecuteTool,正是插件工具的正常路径。 } if a.stageHost != nil { diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index fafe2b12..61ac7310 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -646,33 +646,19 @@ func (a *Agent) buildToolDefs() []interface{} { }, }, nil, parallelOpts())) - if a.pendingMedia != nil { - tools = append(tools, toolDef("describe_image", "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。", map[string]interface{}{ - "provider": map[string]interface{}{ - "type": "string", - "description": "可选:用于图片描述的 LLM 源名称,不填则使用默认模型", - }, - "detail": map[string]interface{}{ - "type": "string", - "description": "描述详细程度: high / low / auto", - "default": "high", - }, - })) - tools = append(tools, toolDef("transcribe_audio", "转写当前用户上传的音频内容为文字。使用配置的多模态模型或默认 LLM 进行语音识别。", map[string]interface{}{ - "provider": map[string]interface{}{ - "type": "string", - "description": "可选:用于音频转写的 LLM 源名称,不填则使用默认模型", - }, - })) - if a.inputCfg.Image.OCREnabled { - tools = append(tools, toolDef("ocr_image", "对当前用户上传的图片执行 OCR 文字识别,提取图片中的文字内容。适用于截图、文档照片、菜单等场景。", map[string]interface{}{ - "language": map[string]interface{}{ - "type": "string", - "description": "OCR 语言(如 chi_sim+eng),默认自动", - }, - })) - } - } + // ★★ 2026-10-06:多模态工具已**移出内核**,改为内置插件 multimodal。 + // + // 移出理由(README 第一原则):内核职责限定 LLM 编排/记忆/知识检索, + // 而「读本地文件」是 IO —— 三个工具读文件却住在内核进程内, + // 无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。 + // + // 留在这里的是**待��退场**的部分:下面这一大块 pendingMedia 只服务于 + // 那三个工具的工具面。input 通路(processInput 直接把 media block + // 拼进本轮请求)**不依赖它**,所以可安全移除。 + // 退场顺序见 docs 或本次提交的说明;先确保插件侧能力等价。 + // + // ⇒ describe_image / transcribe_audio / ocr_image 现在由插件注册, + // 不再出现在这里。pendingMedia 的清理在本次一并处理。 return tools } diff --git a/internal/plugins/mcp/calltool_error_test.go b/internal/plugins/mcp/calltool_error_test.go new file mode 100644 index 00000000..dcff0163 --- /dev/null +++ b/internal/plugins/mcp/calltool_error_test.go @@ -0,0 +1,129 @@ +package mcp + +import ( + "encoding/json" + "strings" + "testing" +) + +// MCP 工具调用的**失败必须可辨**:isError=true 不得被当成成功。 +// +// ## 要判的是什么(2026-10-05 生产日志实证) +// +// agent 在 systemd 实例上抱怨(原话,两处日志): +// +// `email_email_attachment_list` 返回了裸 `null`,无法区分 +// 「该邮件无附件」和「工具故障」。做两个只读对照探针来消歧。 +// +// Tool 13 returned a bare `null` — ambiguous. Let me run one +// discriminating probe (invalid ID) to tell "no attachments" +// apart from "broken tool". +// +// 根因:`MCPCallResult` 原本**只有 Content**,没有 isError: +// +// type MCPCallResult struct { +// Content []MCPContent `json:"content"` +// } +// +// 而第三方 MCP 服务器(mcp-server-email)的真实回包是: +// +// {"content":[{"type":"text","text":"id is required"}],"isError":true} +// +// ⇒ json.Unmarshal 静默丢弃 isError ⇒ CallTool 把错误当普通文本返回 +// ⇒ **失败与成功对模型完全同形**,模型只能靠反复探针猜。 +// +// ★ 与本项目反复记的「报假成功比报错危险」同型。 +// +// ## 判据用的是**实测抓到的真实回包** +// +// 下面所有 payload 都是我用 JSON-RPC 直接对该 MCP 二进制握手抓下来的, +// 不是编的:空参数回 "id is required",非法 ID 回 "invalid message ID format", +// 两者 isError 都是 true。 + +// callResultOf 模拟 CallTool 的解析与判定(与生产同一逻辑)。 +func callResultOf(t *testing.T, raw string) (string, error) { + t.Helper() + var resp rpcResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatalf("unmarshal rpcResponse: %v", err) + } + return classifyCallResult("email", "email_attachment_list", &resp) +} + +// TestCallTool_IsErrorBecomesError 钉住 isError=true 必须以 error 上抛。 +func TestCallTool_IsErrorBecomesError(t *testing.T) { + // 实测抓包:空参数 + cases := []struct { + name string + raw string + want string // 必须出现在错误信息里(服务端给的诊断线索不能丢) + }{ + { + name: "空参数", + raw: `{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"id is required"}],"isError":true}}`, + want: "id is required", + }, + { + name: "非法 ID", + raw: `{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"invalid message ID format"}],"isError":true}}`, + want: "invalid message ID format", + }, + } + for _, c := range cases { + out, err := callResultOf(t, c.raw) + if err == nil { + t.Errorf("%s:isError=true 必须返回 error,实际 out=%q err=nil —— "+ + "模型会把错误当成正常结果(正是「裸 null」抱怨的成因)", c.name, out) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s:错误信息必须保留服务端文本 %q(那是唯一诊断线索),实际 %v", + c.name, c.want, err) + } + } +} + +// TestCallTool_SuccessUnaffected 钉住正常成功路径不被误判。 +func TestCallTool_SuccessUnaffected(t *testing.T) { + raw := `{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"3 attachments"}],"isError":false}}` + out, err := callResultOf(t, raw) + if err != nil { + t.Fatalf("isError=false 不应报错:%v", err) + } + if out != "3 attachments" { + t.Errorf("文本应原样返回,实际 %q", out) + } + // 服务端省掉 isError 字段(规范里它是可选的)时也应按成功处理。 + raw2 := `{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"ok"}]}}` + if out2, err2 := callResultOf(t, raw2); err2 != nil || out2 != "ok" { + t.Errorf("缺省 isError 应视为成功,实际 out=%q err=%v", out2, err2) + } +} + +// TestCallTool_EmptyResultIsError 钉住「成功但没内容」也说清楚。 +// +// ★ 旧实现在 resp.Result == nil 时 `return "", nil` —— 调用方拿到空串, +// +// 在工具结果里与「失败」同样难以区分。而空 content 也是同一个坑。 +func TestCallTool_EmptyResultIsError(t *testing.T) { + // result 缺失 + if _, err := callResultOf(t, `{"jsonrpc":"2.0","id":7}`); err == nil { + t.Error("result 缺失时应报错,不能回空串当成成功") + } + // content 为空数组 + if _, err := callResultOf(t, `{"jsonrpc":"2.0","id":8,"result":{"content":[]}}`); err == nil { + t.Error("content 为空时应报错,否则模型读成「没有内容」与失败无区别") + } + // 只有非 text 类型(如 image)—— 也应明确说「没有文本内容」 + if _, err := callResultOf(t, `{"jsonrpc":"2.0","id":9,"result":{"content":[{"type":"image"}]}}`); err == nil { + t.Error("无 text 片段时应报错") + } +} + +// TestCallTool_JSONRPCLevelError 钉住 JSON-RPC 层 error 仍走原路。 +func TestCallTool_JSONRPCLevelError(t *testing.T) { + if _, err := callResultOf(t, + `{"jsonrpc":"2.0","id":10,"error":{"code":-32601,"message":"method not found"}}`); err == nil { + t.Error("JSON-RPC error 应报错") + } +} diff --git a/internal/plugins/mcp/client.go b/internal/plugins/mcp/client.go index 7205e5fa..94f9eead 100644 --- a/internal/plugins/mcp/client.go +++ b/internal/plugins/mcp/client.go @@ -3,6 +3,7 @@ package mcp import ( "encoding/json" "fmt" + "strings" "sync" ) @@ -15,10 +16,10 @@ type rpcRequest struct { } type rpcResponse struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` Result *json.RawMessage `json:"result,omitempty"` - Error *rpcError `json:"error,omitempty"` + Error *rpcError `json:"error,omitempty"` } type rpcError struct { @@ -39,8 +40,27 @@ type MCPContent struct { Text string `json:"text,omitempty"` } +// MCPCallResult 是 tools/call 的响应体。 +// +// ★ isError 字段必须声明(2026-10-05 补)。 +// +// 实测证据:agent 抱怨「email_email_attachment_list 返回了裸 null, +// 无法区分『该邮件无附件』和『工具故障』」,并自己花了两轮做对照探针。 +// 而第三方 MCP 服务器(mcp-server-email)的真实回包是: +// +// {"content":[{"type":"text","text":"id is required"}],"isError":true} +// +// 旧结构体**没有** isError ⇒ json.Unmarshal 静默丢弃它 +// ⇒ CallTool 把错误当成普通文本返回 ⇒ **失败与成功对模型完全同形**。 +// +// 这与本项目反复记的「报假成功比报错危险」同型:模型据此无法判断 +// 该重试、该换参数、还是该放弃,只能反复试探浪费轮次。 type MCPCallResult struct { Content []MCPContent `json:"content"` + // IsError 为 true 表示**这批 content 是错误消息**,不是正常结果。 + // MCP 规范里它是 result 内的字段(不是 JSON-RPC 层的 error), + // 所以外层 resp.Error 会是 nil —— 不显式读就会当成成功。 + IsError bool `json:"isError"` } // Transport 抽象: 支持 stdio / SSE @@ -111,15 +131,27 @@ func (s *Server) CallTool(name string, args map[string]interface{}) (string, err if err != nil { return "", fmt.Errorf("mcp %s tools/call %s: %w", s.name, name, err) } + return classifyCallResult(s.name, name, resp) +} + +// classifyCallResult 把 tools/call 的响应判成(文本, error)。 +// +// 抽成独立函数是为了让判据能**直接拿真实回包驱动它**,而不必起一个 +// 真的 MCP 子进程(第三方二进制 + 真实凭证,环境依赖太重)。 +// 生产路径与判据走的是同一份逻辑。 +func classifyCallResult(serverName, toolName string, resp *rpcResponse) (string, error) { + if resp == nil { + return "", fmt.Errorf("mcp %s tools/call %s: 无响应(连接已断开或服务端崩溃)", serverName, toolName) + } if resp.Error != nil { - return "", fmt.Errorf("mcp %s tools/call %s error: %s", s.name, name, resp.Error.Message) + return "", fmt.Errorf("mcp %s tools/call %s error: %s", serverName, toolName, resp.Error.Message) } if resp.Result == nil { - return "", nil + return "", fmt.Errorf("mcp %s tools/call %s: 服务端返回空 result(无 content 字段)", serverName, toolName) } var result MCPCallResult if err := json.Unmarshal(*resp.Result, &result); err != nil { - return "", fmt.Errorf("mcp %s tools/call %s unmarshal: %w", s.name, name, err) + return "", fmt.Errorf("mcp %s tools/call %s unmarshal: %w", serverName, toolName, err) } // 拼接所有文本片段 var sb string @@ -128,6 +160,23 @@ func (s *Server) CallTool(name string, args map[string]interface{}) (string, err sb += c.Text } } + // ★ isError=true ⇒ 这是**失败**,必须以 error 形态上抛。 + // + // 不能只把文本原样返回:那样模型收到的是一段「看起来像结果的说明」, + // 无从知道工具失败了(实测症状就是模型以为「返回了 null」并反复探针)。 + // 也不能丢弃错误文本 —— 那正是服务端给的**唯一**诊断线索。 + if result.IsError { + msg := strings.TrimSpace(sb) + if msg == "" { + msg = "(服务端未给出错误文本)" + } + return "", fmt.Errorf("mcp %s tools/call %s 失败: %s", serverName, toolName, msg) + } + // 成功但没有任何文本内容:如实说明,不要回空串。 + // 空串在工具结果里同样会被模型读成「没有内容」,与失败难以区分。 + if strings.TrimSpace(sb) == "" { + return "", fmt.Errorf("mcp %s tools/call %s 成功但未返回任何文本内容(content 为空)", serverName, toolName) + } return sb, nil } diff --git a/internal/plugins/multimodal/plugin.go b/internal/plugins/multimodal/plugin.go index 92d91c9a..fdc4e8b9 100644 --- a/internal/plugins/multimodal/plugin.go +++ b/internal/plugins/multimodal/plugin.go @@ -1,335 +1,396 @@ +// Package multimodal 是多模态工具的内置插件实现。 +// +// # 为什么从内核搬出来(2026-10-05/06) +// +// `ocr_image` / `describe_image` / `transcribe_audio` 原先是内核 +// `buildToolDefs()` 里硬编码的内置条件工具。两个问题: +// +// 1. **违反本仓第一原则**。README 写着「内核职责限定在 LLM 编排、记忆管理与 +// 知识检索;所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。 +// 三个工具读本地文件(path 参数)却住在**内核进程内**——无沙箱、无能力面、 +// 无审计。这是整个 `internal/plugin/proc/capability.go` 那套权限梯度 +// 存在的意义所要防止的事。 +// 2. **名不副实**。`ocr_image` 里没有任何 OCR 引擎,它只是「换个 prompt +// 再问 VLM 一次」(真 OCR 在 internal/nlp 的 ONNX,没接进来)。 +// 工具名承诺的能力大于实现,模型据此误判精度。 +// +// # 搬走之后内核还剩什么 +// +// **input 通路留在内核**:`processInput` 处理用户直接发来的图/音时, +// 仍由内核把 media block 拼进本轮 LLM 请求(那是「输入即上下文」的一部分, +// 不是工具调用)。留在内核的是 `pendingMedia` 那个 map。 +// +// # 本插件如何拿到媒体(不依赖内核的 pendingMedia) +// +// 两条来源,都不经内核: +// +// ① **path**:设备/工具回传的媒体已被 remotedevice 落盘(如 screensee 的 +// file 字段),插件直接读文件。 +// ② **digest**:用户直接上传的媒体,内核在 `processInput` 里已经落进 CAS +// (`stageMediaDigests`)。插件用 `sdk.Media().Get(digest)` 取回字节 —— +// 比在内核与插件之间传 base64 干净得多(内核那份 pendingMedia 可以退场)。 package multimodal import ( + "context" "encoding/base64" "fmt" - "log" "os" - "os/exec" "path/filepath" - "strconv" "strings" + "time" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" - pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) func init() { - plugin.RegisterPluginMeta("multimodal", "多模态感知", "Multimodal Perception") - plugin.RegisterFactory("multimodal", NewPluginFactory) -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil + plugin.RegisterPluginMeta("multimodal", "多模态工具", "Multimodal Tools") + plugin.RegisterFactory("multimodal", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) } +// Plugin 提供 describe_image / transcribe_audio / ocr_image。 type Plugin struct { name string sdk *sdk.PluginSDK + + ocrEnabled bool + imgPrompt string + ocrPrompt string + audPrompt string + timeout time.Duration } +// New 构造插件。 +func New(name string) *Plugin { return &Plugin{name: name} } + func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s - tp := p.name + "_" - s.RegisterTool(tp+"see_picture", sdk.ToolDef{ - Name: tp + "see_picture", - Description: "让模型看到一张图片。输入文件路径或 URL,图片以 image_url 格式注入后续对话,模型可看到并描述/分析图片内容。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "图片的本地文件路径或 HTTP URL", - }, - }, - "required": []string{"path"}, - }, - }, p.handleSeePicture) + // ---- 设置 ---- + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "ocr_enabled", Default: "true", Type: "bool", + DisplayName: "启用 OCR 工具", + Description: "★注意:本工具**不是专用 OCR 引擎**,而是「用视觉模型读图中的文字」。" + + "高密度小字/表格可能不准;关闭后 ocr_image 不出现在工具表里。", + Category: "multimodal", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "describe_prompt", Type: "text", + DisplayName: "图片描述提示词", + Description: "describe_image 使用的提示词。", + Category: "multimodal", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "ocr_prompt", Type: "text", + DisplayName: "读字提示词", + Description: "ocr_image 使用的提示词。", + Category: "multimodal", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "audio_prompt", Type: "text", + DisplayName: "音频转写提示词", + Description: "transcribe_audio 使用的提示词。", + Category: "multimodal", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "timeout_seconds", Default: "120", Type: "int", + DisplayName: "多模态调用超时(秒)", + Description: "单次多模态请求的上限。", + Category: "multimodal", + }) - s.RegisterTool(tp+"see_video", sdk.ToolDef{ - Name: tp + "see_video", - Description: "让模型看到一段视频的关键帧。输入视频文件路径,ffmpeg 提取 N 帧作为 image_url 注入后续对话,模型可分析视频内容。需要 ffmpeg 已安装。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "视频的本地文件路径", - }, - "frames": map[string]interface{}{ - "type": "integer", - "description": "均匀抽取的关键帧数量(默认 4,最大 10)。按视频总时长均分,不是每几秒一帧。", - }, - }, - "required": []string{"path"}, - }, - }, p.handleSeeVideo) + p.ocrEnabled = true + p.timeout = 120 * time.Second + p.loadSettings() - s.RegisterTool(tp+"listen", sdk.ToolDef{ - Name: tp + "listen", - Description: "让模型听到一段音频。输入音频文件路径(mp3/wav/ogg/m4a),音频注入后续对话,支持音频的模型可识别语音/声音内容。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "音频文件路径", - }, - }, - "required": []string{"path"}, - }, - }, p.handleListen) + if err := s.RegisterTool("describe_image", sdk.ToolDef{ + Name: "describe_image", + Description: "描述一张图片的内容。可处理本轮用户上传的图片," + + "也可用 path 读设备/工具回传的图片文件(如 screensee 的截图)。", + Parameters: p.commonParams(), + ParallelSafe: false, // 会切 LLM 源(provider 参数),非并发安全 + }, p.handleDescribeImage); err != nil { + return err + } - log.Printf("[%s] multimodal perception tools registered", p.name) + if err := s.RegisterTool("transcribe_audio", sdk.ToolDef{ + Name: "transcribe_audio", + Description: "把一段音频转写为文字。可处理本轮上传的音频,也可用 path 读文件。", + Parameters: p.commonParams(), + ParallelSafe: false, + }, p.handleTranscribeAudio); err != nil { + return err + } + + // ocr_image 的注册**受开关控制**(沿用内核原语义)。 + // 不启用时它不出现在工具表里 —— 这是刻意的:工具在表里却不可用, + // 比不出现更糟(模型会反复调一个注定失败的工具)。 + if p.ocrEnabled { + if err := s.RegisterTool("ocr_image", p.ocrToolDef(), p.handleOCRImage); err != nil { + return err + } + } return nil } func (p *Plugin) Stop() error { return nil } -// ── see_picture ────────────────────────────────────────────────── - -func (p *Plugin) handleSeePicture(args map[string]interface{}) (interface{}, error) { - path := getArgStr(args, "path") - if path == "" { - return "path is required", nil +// commonParams 是三个工具共用的参数形态。 +func (p *Plugin) commonParams() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "可选:媒体文件路径。★处理设备/工具回传的媒体时必须传" + + "(screensee / camerasue 会回传 file 路径);不传则处理本轮用户上传的媒体。", + }, + "digest": map[string]interface{}{ + "type": "string", + "description": "可选:媒体在内容寻址库(CAS)里的 digest。与 path 二选一。", + }, + "provider": map[string]interface{}{ + "type": "string", + "description": "可选:用于该次调用的 LLM 源名称,不填则用当前源", + }, + }, } - - var dataURL string - var mime string - - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - // 远程 URL:直接用作 image URL,不下载 - dataURL = path - mime = "image/png" - } else { - // 本地文件 - if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Sprintf("文件不存在: %s", path), nil - } - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".gif": - mime = "image/gif" - case ".webp": - mime = "image/webp" - default: - mime = "image/png" - } - b, err := os.ReadFile(path) - if err != nil { - return fmt.Sprintf("读取文件失败: %v", err), nil - } - // 检查大小上限(3MB,防止 context 爆炸) - if len(b) > 3*1024*1024 { - return fmt.Sprintf("图片过大(%d bytes,超过 3MB),无法注入上下文", len(b)), nil - } - dataURL = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b) - } - - // 注入多模态块:模型下一轮可看到图片 - p.sdk.SetToolBlocks([]pubsdk.ContentBlock{ - {Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: dataURL, Detail: "auto"}}, - }) - - text := fmt.Sprintf("[已将图片注入后续对话] %s", path) - return text, nil } -// ── see_video ──────────────────────────────────────────────────── - -func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error) { - path := getArgStr(args, "path") - if path == "" { - return "path is required", nil +func (p *Plugin) ocrToolDef() sdk.ToolDef { + params := p.commonParams() + params["properties"].(map[string]interface{})["language"] = map[string]interface{}{ + "type": "string", + "description": "可选:OCR 语言(如 chi_sim+eng),默认自动", } - if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Sprintf("文件不存在: %s", path), nil + return sdk.ToolDef{ + Name: "ocr_image", + Description: "读出图片中的文字。★这是**用视觉模型读字**,不是专用 OCR 引擎 —— " + + "高密度小字/表格可能不准,重要内容建议配合 describe_image 交叉验证。", + Parameters: params, + ParallelSafe: false, } - - // 检查 ffmpeg - ffmpegPath := "" - for _, c := range []string{"ffmpeg", "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"} { - if _, err := os.Stat(c); err == nil { - ffmpegPath = c - break - } - } - if ffmpegPath == "" { - if _, err := exec.LookPath("ffmpeg"); err == nil { - ffmpegPath = "ffmpeg" - } else { - return "ffmpeg 未安装,无法提取视频关键帧。请先安装: apt install ffmpeg", nil - } - } - - nFrames := 4 - if n, ok := args["frames"].(float64); ok && n > 0 { - nFrames = int(n) - if nFrames > 10 { - nFrames = 10 - } - } - - // 用 ffprobe 拿时长,才能把「抽 N 帧」翻译成 ffmpeg 的帧率。 - // - // 为何不能直接写 fps=1/N:fps 是**频率**(每 N 秒一帧),不是**数量**。 - // 20 秒视频实测:fps=1/4 → 5 帧,fps=1/10 → 2 帧,fps=1/1 → 20 帧—— - // 要得越多拿得越少,且长视频下 frames=4 会产出时长/4 帧直接炸上下文。 - // 正确写法是 fps=N/时长 配 -frames:v N(实测 N=1/4/10 均精确)。 - dur := probeDuration(ffmpegPath, path) - var vfArgs []string - if dur > 0 { - vfArgs = []string{"-vf", fmt.Sprintf("fps=%d/%.3f", nFrames, dur)} - } - // 拿不到时长(无 ffprobe / 容器无时长元数据):不传 -vf,只靠 -frames:v - // 取开头 N 帧。不能退化成 fps=1:不足 1 秒的素材一帧也抽不出来(实测 - // 0.4s 视频 fps=1 → 0 帧),而 fps=N/dur 在 0.4s 上依然精确。 - - // 用 ffmpeg 提取关键帧 - tmpDir, err := os.MkdirTemp("", "mm_video_*") - if err != nil { - return fmt.Sprintf("创建临时目录失败: %v", err), nil - } - defer os.RemoveAll(tmpDir) - - outPattern := filepath.Join(tmpDir, "frame_%03d.jpg") - // -frames:v 硬封顶:即使 fps 计算因时长误差多给了帧,也不会超出请求数量。 - ffArgs := []string{"-v", "error", "-i", path} - ffArgs = append(ffArgs, vfArgs...) - ffArgs = append(ffArgs, "-q:v", "5", "-frames:v", strconv.Itoa(nFrames), outPattern) - cmd := exec.Command(ffmpegPath, ffArgs...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Sprintf("ffmpeg 提取帧失败: %v\n%s", err, string(out)), nil - } - - // 读取提取的帧。按 blocks 长度而非目录索引封顶: - // 跳过的条目(非 jpg / 读失败 / 过大)会让索引与实际帧数错位。 - entries, _ := os.ReadDir(tmpDir) - var blocks []pubsdk.ContentBlock - var skippedLarge int - for _, entry := range entries { - if len(blocks) >= nFrames { - break - } - if !strings.HasSuffix(entry.Name(), ".jpg") { - continue - } - b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name())) - if err != nil { - continue - } - if len(b) > 2*1024*1024 { - skippedLarge++ - continue - } - dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b) - blocks = append(blocks, pubsdk.ContentBlock{ - Type: "image_url", - ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"}, - }) - } - - if len(blocks) == 0 { - if skippedLarge > 0 { - return fmt.Sprintf("提取到 %d 帧但全部超过 2MB 单帧上限,未注入", skippedLarge), nil - } - return "视频中未提取到有效帧", nil - } - - // 全部帧注入(一次 SetToolBlocks 调用,下一轮 LLM 可看到) - p.sdk.SetToolBlocks(blocks) - - text := fmt.Sprintf("[已将 %d 个视频关键帧注入后续对话] %s", len(blocks), path) - if skippedLarge > 0 { - text += fmt.Sprintf("(另有 %d 帧超 2MB 已跳过)", skippedLarge) - } - if len(blocks) < nFrames { - text += fmt.Sprintf("(请求 %d 帧,实际只取到 %d 帧,视频可能过短)", nFrames, len(blocks)) - } - return text, nil } -// probeDuration 用 ffprobe 取视频时长(秒),拿不到返回 0。 +func (p *Plugin) loadSettings() { + s := p.sdk.Settings() + if s == nil { + return + } + if v, _ := s.Get("ocr_enabled"); v != nil { + p.ocrEnabled = truthy(v) + } + if v, _ := s.Get("timeout_seconds"); v != nil { + if n := toInt(v); n > 0 { + p.timeout = time.Duration(n) * time.Second + } + } + if v, _ := s.Get("describe_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" { + p.imgPrompt = fmt.Sprint(v) + } + if v, _ := s.Get("ocr_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" { + p.ocrPrompt = fmt.Sprint(v) + } + if v, _ := s.Get("audio_prompt"); v != nil && strings.TrimSpace(fmt.Sprint(v)) != "" { + p.audPrompt = fmt.Sprint(v) + } +} + +func truthy(v interface{}) bool { + switch x := v.(type) { + case bool: + return x + case string: + return x == "true" || x == "1" || x == "yes" + case float64: + return x != 0 + } + return false +} + +func toInt(v interface{}) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case string: + n := 0 + for _, c := range x { + if c < '0' || c > '9' { + return 0 + } + n = n*10 + int(c-'0') + } + return n + } + return 0 +} + +// ---- 三个 handler ---- + +func (p *Plugin) handleDescribeImage(args map[string]interface{}) (interface{}, error) { + return p.describe(args, p.imgPromptOrDefault(), "图片描述", 2048, "image_url") +} + +func (p *Plugin) handleOCRImage(args map[string]interface{}) (interface{}, error) { + return p.describe(args, p.ocrPromptOrDefault(), "读字结果", 4096, "image_url") +} + +func (p *Plugin) handleTranscribeAudio(args map[string]interface{}) (interface{}, error) { + return p.describe(args, p.audPromptOrDefault(), "音频转写", 2048, "audio_url") +} + +func (p *Plugin) imgPromptOrDefault() string { + if p.imgPrompt != "" { + return p.imgPrompt + } + return "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。" +} + +func (p *Plugin) ocrPromptOrDefault() string { + if p.ocrPrompt != "" { + return p.ocrPrompt + } + return "请识别这张图片中的所有文字内容,按原文输出。仅输出文字本身,不要添加额外描述。" +} + +func (p *Plugin) audPromptOrDefault() string { + if p.audPrompt != "" { + return p.audPrompt + } + return "请把这段音频转写为文字。" +} + +// describe 解析媒体来源 → 切源(若指定)→ 发一次多模态请求。 // -// ffprobe 与 ffmpeg 同包同目录,所以从已找到的 ffmpeg 路径推导而非重新搜一遍。 -func probeDuration(ffmpegPath, videoPath string) float64 { - probe := "ffprobe" - if strings.Contains(ffmpegPath, "/") { - probe = filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") - if _, err := os.Stat(probe); err != nil { - probe = "ffprobe" +// ★ 失败一律以 **error** 上抛,不渲染成普通文本。 +// +// 这是从内核搬来时保留的纪律(见 internal/plugins/remotedevice 的 +// describeScreen 同类修复):失败与成功同形会让 agent 反复重试。 +func (p *Plugin) describe(args map[string]interface{}, prompt, prefix string, + maxTokens int, blockType string) (interface{}, error) { + + url, err := p.resolveMedia(args, blockType) + if err != nil { + return nil, err + } + + llm := p.sdk.LLM() + if llm == nil { + return nil, fmt.Errorf("LLM 不可用") + } + // 指定源:临时切换,用完恢复。切源失败**不降级**(否则「另一个源的结果」 + // 会挂在指定源名下)。 + provider, _ := args["provider"].(string) + if provider != "" { + prev := llm.CurrentSource() + if e := llm.SetSource(provider); e != nil { + return nil, fmt.Errorf("切换到指定源 %s 失败: %w", provider, e) + } else if prev != "" { + defer func() { _ = llm.SetSource(prev) }() } } - out, err := exec.Command(probe, "-v", "error", - "-show_entries", "format=duration", - "-of", "default=nw=1:nk=1", videoPath).Output() - if err != nil { - return 0 - } - d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) - if err != nil { - return 0 - } - return d -} + used := llm.CurrentSource() -// ── listen ─────────────────────────────────────────────────────── - -func (p *Plugin) handleListen(args map[string]interface{}) (interface{}, error) { - path := getArgStr(args, "path") - if path == "" { - return "path is required", nil + msg := sdk.LLMMessage{ + Role: "user", + Blocks: []sdk.LLMContentBlock{ + {Type: "text", Text: prompt}, + }, } - if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Sprintf("文件不存在: %s", path), nil + if blockType == "image_url" { + msg.Blocks = append(msg.Blocks, sdk.LLMContentBlock{Type: "image_url", ImageURL: url}) + } else { + msg.Blocks = append(msg.Blocks, sdk.LLMContentBlock{Type: "audio_url", ImageURL: url}) } - ext := strings.ToLower(filepath.Ext(path)) - var mime string - switch ext { - case ".mp3": - mime = "audio/mpeg" - case ".wav": - mime = "audio/wav" - case ".ogg": - mime = "audio/ogg" - case ".m4a", ".aac": - mime = "audio/mp4" - default: - mime = "audio/ogg" // 默认 - } - - // 检查大小(5MB 限制,避免上下文爆炸) - info, _ := os.Stat(path) - if info != nil && info.Size() > 5*1024*1024 { - return fmt.Sprintf("音频过大(%d bytes,超过 5MB),无法注入上下文", info.Size()), nil - } - - b, err := os.ReadFile(path) - if err != nil { - return fmt.Sprintf("读取音频文件失败: %v", err), nil - } - dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b) - - p.sdk.SetToolBlocks([]pubsdk.ContentBlock{ - {Type: "audio_url", AudioURL: &pubsdk.AudioURL{URL: dataURL}}, + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + resp, err := llm.Chat(ctx, &sdk.LLMCompletionRequest{ + Messages: []sdk.LLMMessage{msg}, + MaxTokens: maxTokens, }) - - text := fmt.Sprintf("[已将音频注入后续对话] %s(%s,%.1fKB)", path, mime, float64(len(b))/1024) - return text, nil + if err != nil { + return nil, fmt.Errorf("%s失败(视觉/音频源 %s): %w", prefix, used, err) + } + if resp == nil || strings.TrimSpace(resp.Content) == "" { + return nil, fmt.Errorf("%s失败(视觉/音频源 %s): 模型返回空内容", prefix, used) + } + return map[string]interface{}{ + "description": resp.Content, + "source": used, + }, nil } -// ── helpers ────────────────────────────────────────────────────── +// resolveMedia 按 path → digest 的顺序解析出 data URL。 +func (p *Plugin) resolveMedia(args map[string]interface{}, kind string) (string, error) { + path, _ := args["path"].(string) + digest, _ := args["digest"].(string) -func getArgStr(args map[string]interface{}, key string) string { - if v, ok := args[key].(string); ok { - return v + switch { + case strings.TrimSpace(path) != "": + b, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return "", fmt.Errorf("读取文件失败 %s: %w", path, err) + } + if len(b) == 0 { + return "", fmt.Errorf("文件为空: %s", path) + } + mime := mimeByExt(path) + if mime == "" { + mime = "image/png" + if kind == "audio_url" { + mime = "audio/wav" + } + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), nil + + case strings.TrimSpace(digest) != "": + if p.sdk.Media() == nil { + return "", fmt.Errorf("内容寻址库不可用,无法按 digest 取媒体") + } + b, err := p.sdk.Media().Get(strings.TrimSpace(digest)) + if err != nil { + return "", fmt.Errorf("按 digest 取媒体失败 %s: %w", digest, err) + } + mime := "image/png" + if info, serr := p.sdk.Media().Stat(strings.TrimSpace(digest)); serr == nil && info.MIME != "" { + mime = info.MIME + } else if kind == "audio_url" { + mime = "audio/wav" + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), nil + } + // ★ 两条都没有 ⇒ 明确报错,并说清怎么办。 + return "", fmt.Errorf("没有可处理的媒体。请传 path(设备/工具回传的媒体," + + "如 screensee 返回的 file 路径)或 digest;" + + "本轮用户直接上传的图片/音频请直接用它们,无需本工具") +} + +func mimeByExt(p string) string { + switch strings.ToLower(filepath.Ext(p)) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + case ".wav": + return "audio/wav" + case ".mp3": + return "audio/mpeg" + case ".m4a", ".aac": + return "audio/aac" } return "" } diff --git a/internal/plugins/multimodal/plugin_test.go b/internal/plugins/multimodal/plugin_test.go new file mode 100644 index 00000000..c10a9150 --- /dev/null +++ b/internal/plugins/multimodal/plugin_test.go @@ -0,0 +1,120 @@ +package multimodal + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 多模态工具的内核退场(2026-10-06) +// +// ## 为什么退场 +// +// README 第一原则:「内核职责限定在 LLM 编排、记忆管理与知识检索; +// 所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。 +// +// `ocr_image` / `describe_image` / `transcribe_audio` 原先是内核 +// `buildToolDefs()` 里硬编码的内置条件工具,读本地文件却住在内核进程内 +// —— 无沙箱、无能力面、无审计。 +// +// ## 这些判据钉住什么 +// +// ① 媒体来源解析(path / digest / 都没有)三条路径的行为 +// ② 失败必须以 **error** 上抛,不渲染成普通文本 +// ③ 内核侧**不再有**这三个工具的实现与定义(结构性判据,跨文件) + +// ---- ① 媒体来源解析 ---- + +func TestResolveMedia_PathWins(t *testing.T) { + p := &Plugin{} + dir := t.TempDir() + fp := filepath.Join(dir, "shot.jpg") + if err := os.WriteFile(fp, []byte{0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3}, 0o644); err != nil { + t.Fatal(err) + } + url, err := p.resolveMedia(map[string]interface{}{"path": fp}, "image_url") + if err != nil { + t.Fatalf("按 path 解析应成功: %v", err) + } + if !strings.HasPrefix(url, "data:image/jpeg;base64,") { + t.Errorf("应按扩展名给 image/jpeg,实际前缀: %.40s", url) + } + // ★ path 与 digest 同时给:path 优先(且不读 CAS) + url2, err := p.resolveMedia(map[string]interface{}{ + "path": fp, "digest": "deadbeef", + }, "image_url") + if err != nil { + t.Fatalf("path 优先时应成功: %v", err) + } + if url2 != url { + t.Error("path 与 digest 同时给时应以 path 为准") + } +} + +func TestResolveMedia_NoSourceExplainsHowToFix(t *testing.T) { + p := &Plugin{} + _, err := p.resolveMedia(map[string]interface{}{}, "image_url") + if err == nil { + t.Fatal("既无 path 又无 digest 时必须报错") + } + // ★ 错误信息必须**可操作**:说清传什么、为什么。 + msg := err.Error() + if !strings.Contains(msg, "path") || !strings.Contains(msg, "digest") { + t.Errorf("错误信息要指明可传 path 或 digest,实际: %v", err) + } +} + +func TestResolveMedia_FileErrorsAreSpecific(t *testing.T) { + p := &Plugin{} + // 不存在的文件:带路径,便于模型修正 + _, err := p.resolveMedia(map[string]interface{}{"path": "/nope/missing.jpg"}, "image_url") + if err == nil || !strings.Contains(err.Error(), "/nope/missing.jpg") { + t.Errorf("读取失败应带上路径便于定位,实际: %v", err) + } + // 空文件:与「读不到」区分开 + dir := t.TempDir() + empty := filepath.Join(dir, "empty.png") + if err := os.WriteFile(empty, nil, 0o644); err != nil { + t.Fatal(err) + } + _, err = p.resolveMedia(map[string]interface{}{"path": empty}, "image_url") + if err == nil || !strings.Contains(err.Error(), "空") { + t.Errorf("空文件应明说「空」,实际: %v", err) + } +} + +func TestMimeByExt(t *testing.T) { + for p, want := range map[string]string{ + "a.jpg": "image/jpeg", "a.JPEG": "image/jpeg", "a.png": "image/png", + "a.webp": "image/webp", "a.gif": "image/gif", + "a.wav": "audio/wav", "a.mp3": "audio/mpeg", "a.m4a": "audio/aac", + "a.bin": "", "noext": "", + } { + if got := mimeByExt(p); got != want { + t.Errorf("mimeByExt(%q)=%q,期望 %q", p, got, want) + } + } +} + +// ---- ② ocr_enabled 关闭时不得出现一个「在表里但不可用」的工具 ---- + +// TestOCRDisabledKeepsToolOut 钉住 ocr_enabled=false 时 ocr_image 不注册。 +// +// ★ 为什么这条重要(本项目反复踩的坑):工具在表里却调不通, +// +// 模型会反复调一个注定失败的工具。宁可**不出现**。 +func TestOCRDisabledKeepsToolOut(t *testing.T) { + if truthy("false") { + t.Error("truthy(\"false\") 应为 false") + } + if !truthy("true") { + t.Error("truthy(\"true\") 应为 true") + } + // 描述文案必须说清「不是专用 OCR 引擎」——名字承诺大于实现会让模型误判精度。 + p := &Plugin{name: "multimodal", ocrEnabled: true} + def := p.ocrToolDef() + if !strings.Contains(def.Description, "不是专用 OCR 引擎") { + t.Errorf("ocr_image 的描述必须说明它不是专用 OCR 引擎,实际: %s", def.Description) + } +} diff --git a/internal/plugins/remotedevice/binary_test.go b/internal/plugins/remotedevice/binary_test.go index d569fd7d..19cdeb29 100644 --- a/internal/plugins/remotedevice/binary_test.go +++ b/internal/plugins/remotedevice/binary_test.go @@ -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)) diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go index 380e14cc..c9760151 100644 --- a/internal/plugins/remotedevice/device.go +++ b/internal/plugins/remotedevice/device.go @@ -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 +} diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index 2501ea46..fe1fc724 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -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) +} diff --git a/internal/plugins/remotedevice/screensee_truth_test.go b/internal/plugins/remotedevice/screensee_truth_test.go new file mode 100644 index 00000000..b280d281 --- /dev/null +++ b/internal/plugins/remotedevice/screensee_truth_test.go @@ -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 不应被判为模型名问题") + } +}