Files
HomeAgent/internal/plugins/mcp/calltool_error_test.go
JianFeeeee a02d8b00a5 feat(multimodal): 多模态工具移出内核为内置插件 + get_plugin_tools 收编为内核服务
## 背景

README 第一原则:「内核职责限定在 LLM 编排、记忆管理与知识检索;
所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。
describe_image / ocr_image / transcribe_audio 读本地文件却住在内核进程内
——无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。

## 本次改动

### 1. 多模态工具移出内核(internal/plugins/multimodal)

- 新增内置插件,注册 describe_image / transcribe_audio / ocr_image
- 媒体来源支持 path(设备/工具回传,如 screensee 的 file)与 digest(CAS)
- 失败一律以 error 上抛,不渲染成普通文本(延续 screensee 的失败传播纪律)
- ocr_image 的描述明说「不是专用 OCR 引擎」:它只是用视觉模型读字,
  真 OCR 在 internal/nlp 的 ONNX 未接入。名字承诺大于实现会让模型误判精度。

内核侧:删除 media.go 整文件(9 函数,全部零引用)、3 个 dispatch case、
3 个工具定义。

**留在内核**:input 通路(用户直传媒体直接拼进本轮 LLM 请求)——
那是「输入即上下文」,不是工具调用。

### 2. get_plugin_tools 收编为内核完整服务

原先它只收 StageHost + IOManager 两处,而三个媒体工具、memory_*、
knowledge_*、spawn_child 等全是内核 buildToolDefs 里直接 append 的
——一条都数不到。agent 能调这些工具,却调 get_plugin_tools 看不见它们。

- 以 buildToolDefs()(发给模型的那份工具表)为单一真相源
- 新增 builtinToolOwner 显式归属表:内置工具恒为 core
- resolveToolPlugin 的下划线前缀猜测限定在「该前缀是已注册插件」时才采纳,
  避免 ocr_image→"ocr"、memory_commit→"memory" 这类幽灵归属(7 处调用点依赖它)

## 判据

新增 plugin_tools_service_test.go、multimodal_departed_test.go、
multimodal/plugin_test.go。三个结构判据经变异自证会红:

1. 提示词改回「请使用 describe_image 工具」→ 仍出现该名 ⇒ 红
   ★ 这条抓到了真问题:eventloop.go 的媒体提示词仍在叫模型调旧工具名,
   而媒体 block 其实已内联给模型,且新工具需要 path/digest(用户直传都没有)
   ⇒ 模型照做必然失败。已修:改为「已随本消息附上,请直接查看」。
2. 内核为多模态加一个 os.ReadFile → 红(比「名字没出现」更强)
3. core 反向 import 插件 → 红(迁移而非删除,且守住分层)

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race ./internal/agent/core/ ./internal/plugins/multimodal/ 无数据竞争
- 部署后:39 插件加载、0 panic/fatal、0 非空 error
- 三个工具注册归属为 plugin=multimodal(不是 inferToolPlugin 猜的 describe)
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-multimodal-plugin-20261006-090541/
- 二进制备份 /tmp/homed.bak-mmplugin

一并包含此前已验证未提交的三处静默失效修复:
- MCP MCPCallResult.IsError 丢失(空结果伪装成功)
- DeleteEntity 谎报删除
- screensee 谎报成功 + 截图诊断(尺寸/magic/sha)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 09:07:18 +08:00

130 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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 应报错")
}
}