Files
MailUI4Agents/server/internal/mcp/support.go
JianFeeeee d1099526ad fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点
## 缺口(部署后实测才发现,是我自己引入的)

第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:

    222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
    而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出

那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。

只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。

## 修法

`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。

marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:

    同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
    而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
    改一处忘另一处就会出现这种自相矛盾,且没有任何报错。

## 判据(2 格)

    TestFlattenAnnotatesEachCandidate  桥内部目录/相对路径能分类 + 带说明;
                                        真工作区不得被误标(否则全是噪声)
    TestClassifyPathAgreesWithRepo     handler 与 repo 口径必须逐条一致

## 顺带

第一版 flatten 本身已验证有效(生产实测):

    flatten=1 → 222 条候选、66 个工作区
    /home/program/agentmail 125 条 · /root 16 条 · root 2 条
    ⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」

    path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
2026-10-02 16:06:00 +08:00

471 lines
15 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/agentmail/gateway/internal/handler"
)
// httptestRecorder 造一个响应收集器。
func httptestRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() }
// jsonUnmarshal 是 json.Unmarshal 的薄封装(工具文件里用起来更短)。
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// newMultipartRequest 造一个 multipart/form-data 请求。
func newMultipartRequest(ctx context.Context, body []byte, contentType string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/v1/attachments", bytes.NewReader(body))
r.Header.Set("Content-Type", contentType)
return r.WithContext(ctx)
}
// buildUploadForm 组装上传用的 multipart 表单。
//
// 只**读**文件(打开 + 拷贝),不写。它读的是调用方给出的路径 ——
// 见 tool_impls.go 里 upload_attachment 的注释:这是一处「工具参数即
// 文件系统访问」,只读,且只对本服务自己跑在本机这件事成立(远程
// Streamable HTTP 部署时读的是**网关机器**的路径,不是客户端的)。
func buildUploadForm(path, filename string) ([]byte, string, error) {
if strings.TrimSpace(path) == "" {
return nil, "", fmt.Errorf("缺少 file_path")
}
f, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("打不开 %s:%v", path, err)
}
defer f.Close()
if filename == "" {
filename = filepath.Base(path)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// 服务端字段名是 "file"(FormFile("file"))—— 写错名字会得到
// "表单里没有 file 字段" 这种看不懂的错。
part, err := mw.CreateFormFile("file", filename)
if err != nil {
return nil, "", err
}
if _, err := io.Copy(part, f); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), mw.FormDataContentType(), nil
}
// summarize 把 handler 的成功响应转成模型读的文本。
//
// 为什么不直接把 JSON 甩给模型:
// - read_inbox 一次可能返回几十封,每封都带邮件正文缩略 —— 纯 JSON 里
// 转义与字段名会占掉大量 token,而且模型容易看错行(哪个是 subject、
// 哪个是 from)。
// - 人类与模型都需要**能扫读**的形状。
//
// 所以这里按已知的响应形状做小渲染;认不出来的形状回退到原始 JSON
// (宁可费 token,也不要把信息丢了 —— 那会让模型以为邮件是空的)。
func summarize(name string, payload map[string]any) (string, error) {
switch name {
case "read_inbox":
return summarizeInbox(payload), nil
case "read_mail":
return summarizeMail(payload), nil
case "read_thread":
return summarizeThread(payload), nil
case "send_mail":
return summarizeSend(payload), nil
case "forward_mail":
return summarizeSend(payload), nil
case "upload_attachment":
return summarizeUpload(payload), nil
case "suggest_address":
return summarizeSuggest(payload), nil
case "list_contacts":
return summarizeContacts(payload), nil
case "session_participants":
return summarizeParticipants(payload), nil
}
return prettyJSON(payload), nil
}
func prettyJSON(payload map[string]any) string {
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Sprintf("%v", payload)
}
return string(b)
}
func str(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
// summarizeInbox 渲染收件箱:每封一行,带 id(下一步要用)与是否有附件。
func summarizeInbox(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
total := numberOf(payload["total"])
if len(mails) == 0 {
if total > 0 {
return fmt.Sprintf("(本会话/工作区内没有符合条件的未读,但 total=%d)", total)
}
return "收件箱为空。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 封未读(total=%d):\n\n", len(mails), total)
for _, it := range mails {
m, ok := it.(map[string]any)
if !ok {
continue
}
from := firstNonEmpty(str(m, "from_name"), str(m, "from"), "(未知)")
subj := firstNonEmpty(str(m, "subject"), "(无主题)")
id := str(m, "mail_id")
flag := ""
if n, _ := m["attachments"].([]any); len(n) > 0 {
flag = fmt.Sprintf(" [附件 %d]", len(n))
}
if ws := str(m, "session_workspace"); ws != "" {
flag += " " + ws
}
fmt.Fprintf(&b, "· %s — %s (id: %s)%s\n", from, subj, id, flag)
}
b.WriteString("\n用 read_mail(mail_id=…) 读正文。")
return b.String()
}
// summarizeMail 渲染单封:头部 + 正文 + 附件清单。
func summarizeMail(payload map[string]any) string {
m := payload
if inner, ok := payload["mail"].(map[string]any); ok {
m = inner
}
var b strings.Builder
fmt.Fprintf(&b, "发件人:%s\n主题:%s\n邮件 ID:%s\n日期:%s\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
firstNonEmpty(str(m, "created_at"), str(m, "sent_at")))
if parent := str(m, "parent_mail_id"); parent != "" {
fmt.Fprintf(&b, "(这是对 %s 的回复)\n", parent)
}
b.WriteString("\n---\n")
b.WriteString(str(m, "body"))
if atts, ok := m["attachments"].([]any); ok && len(atts) > 0 {
b.WriteString("\n\n附件:")
for _, a := range atts {
am, _ := a.(map[string]any)
fmt.Fprintf(&b, "\n· %s (attachment_id: %s, %s)",
firstNonEmpty(str(am, "filename"), "(未命名)"),
str(am, "attachment_id"),
humanSize(numberOf(am["size_bytes"])))
}
}
return b.String()
}
// summarizeThread 渲染线索:按时间顺序每封一段。
func summarizeThread(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
if mails == nil {
mails, _ = payload["thread"].([]any)
}
if len(mails) == 0 {
return "线索里没有邮件。"
}
var b strings.Builder
fmt.Fprintf(&b, "线索共 %d 封:\n\n", len(mails))
for _, it := range mails {
m, _ := it.(map[string]any)
fmt.Fprintf(&b, "── %s · %s(id: %s)\n%s\n\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
strings.TrimSpace(str(m, "body")))
}
return b.String()
}
func summarizeSend(payload map[string]any) string {
id := str(payload, "mail_id")
sid := str(payload, "session_id")
var b strings.Builder
b.WriteString("已发送")
if id != "" {
fmt.Fprintf(&b, "(mail_id: %s)", id)
}
if sid != "" {
fmt.Fprintf(&b, ",落在会话 %s", sid)
}
if relay := str(payload, "duplicate_relay"); relay != "" {
fmt.Fprintf(&b, "。注意:%s", relay)
}
return b.String()
}
func summarizeUpload(payload map[string]any) string {
// UploadAttachment 返回的是 {attachment:{...}} 这种**嵌套**形状
// —— 按顶层解会得到空 id(homeagent 与 zcode 都踩过)。
att, _ := payload["attachment"].(map[string]any)
if att == nil {
att = payload
}
return fmt.Sprintf("已上传 %s,attachment_id: %s(%s)。在 send_mail 的 attachment_ids 里带上它才会随邮件发出。",
firstNonEmpty(str(att, "filename"), "(未命名)"),
str(att, "attachment_id"),
humanSize(numberOf(att["size_bytes"])))
}
func summarizeSuggest(payload map[string]any) string {
kind := str(payload, "kind")
// flatten 出来的形状(2026-10-02 寻址报告 A):addresses + candidates。
// 必须单独渲染 —— 它没有 suggestions 字段,走下面的分支只会回一句
// 「(没有 session_flat 建议)」,模型拿不到任何地址,等于白问。
if kind == "session_flat" {
cands, _ := payload["candidates"].([]any)
if len(cands) == 0 {
return "没有可列出的会话(对方可能从未与你通信过)。" +
"若要开新线索,用 send_mail 指定 **.new** 结尾的地址 —— " +
"但请先确认工作区:root 与 /root 是两个不同工作区。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 条可投递地址(每条自带工作区,直接放进 send_mail 的 to):\n\n", len(cands))
for _, it := range cands {
m, _ := it.(map[string]any)
flag := ""
if numberOf(m["unread"]) > 0 {
flag = fmt.Sprintf(" ★%d 未读", int(numberOf(m["unread"])))
}
// ★ 逐条把「这个工作区不该选」标出来(实测 222 条候选里 37 条
// 落在桥内部目录)。标注只在 paths[] 里的话,模型得自己把两个
// 数组对照才认得出 —— 那就是 §C 噪声换个位置复活。
warn := ""
if k := str(m, "path_kind"); k == "bridge-internal" {
warn = " ⚠ 桥内部目录,不是项目工作区"
} else if note := str(m, "path_note"); note != "" {
warn = " ⚠ " + note
}
fmt.Fprintf(&b, "· %s\n 工作区:%s%s\n 标题:%s%s\n",
str(m, "address"), str(m, "path"), warn,
firstNonEmpty(str(m, "title"), "(无标题)"), flag)
}
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n⚠ 工作区候选里的坑(别选它们):\n" + notes)
}
}
return b.String()
}
// path 形状:把「这不是工作区」与「这是相对路径」标出来。
if kind == "path" {
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
var b strings.Builder
b.WriteString("可选工作目录:\n")
for _, it := range paths {
m, _ := it.(map[string]any)
mark := ""
if str(m, "kind") == "bridge-internal" {
mark = " ⚠ 桥内部目录,不是项目工作区"
}
if note := str(m, "note"); note != "" && mark == "" {
mark = " ⚠ " + note
}
fmt.Fprintf(&b, "· %s%s\n", str(m, "path"), mark)
}
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n" + notes)
}
b.WriteString("\n不知道该用哪个 → 加 flatten=true 一次拿到该收件人的全部可投递地址。")
return b.String()
}
}
suggestions, _ := payload["suggestions"].([]any)
if len(suggestions) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
items := make([]string, 0, len(suggestions))
for _, s := range suggestions {
if str, ok := s.(string); ok {
items = append(items, str)
}
}
if len(items) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
return fmt.Sprintf("可选的%s:\n%s", kind, strings.Join(items, "\n"))
}
func summarizeContacts(payload map[string]any) string {
contacts, _ := payload["contacts"].([]any)
if len(contacts) == 0 {
return "没有参与过任何会话。用 suggest_address 查可投递地址。"
}
var b strings.Builder
fmt.Fprintf(&b, "共 %d 条会话:\n\n", len(contacts))
for _, it := range contacts {
c, _ := it.(map[string]any)
unread := numberOf(c["unread_count"])
flag := ""
if unread > 0 {
flag = fmt.Sprintf(" ★ %d 未读", unread)
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n session_id: %s\n",
firstNonEmpty(str(c, "peer"), "(未知)"),
firstNonEmpty(str(c, "subject"), "(无主题)"),
flag,
firstNonEmpty(str(c, "address"), "(无可寻址地址)"),
str(c, "session_id"))
}
return b.String()
}
func summarizeParticipants(payload map[string]any) string {
parts, _ := payload["participants"].([]any)
if len(parts) == 0 {
return "会话里没有其他参与方。"
}
var b strings.Builder
b.WriteString("参与方:\n")
for _, it := range parts {
p, _ := it.(map[string]any)
flag := ""
if !boolOf(p["replied"]) {
flag = "(还没回应)"
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n",
firstNonEmpty(str(p, "role"), "?"),
firstNonEmpty(str(p, "name"), "(未知)"),
flag,
firstNonEmpty(str(p, "address"), "(无)"))
}
return b.String()
}
// ---- 小工具 ----
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func numberOf(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case json.Number:
n, _ := t.Int64()
return int(n)
}
return 0
}
func boolOf(v any) bool {
b, _ := v.(bool)
return b
}
func humanSize(n int) string {
switch {
case n <= 0:
return "0 B"
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
// agentNameOfRequest 供 handler 包在需要时取身份。
//
// 单独一个 invoke:DownloadAttachment 的响应体是**附件字节**而不是 JSON,
// 走通用的 invoke 会把二进制当 JSON 解析(静默得到 nil),
// 于是成功时也会报"解析不出 error"。所以这里自己收集。
func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[string]any, string) {
// 路径参数 id 必须注入(DownloadAttachment 用 pathUUID → chi.URLParam)。
// 注入后**不要再** WithContext(ctx) —— 那会覆盖掉 RouteContext。
r := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
rec := httptest.NewRecorder()
handler.DownloadAttachment(rec, r)
body := rec.Body.Bytes()
if rec.Code < 200 || rec.Code >= 300 {
var out map[string]any
_ = json.Unmarshal(body, &out)
return rec.Code, out, string(body)
}
// 落盘(建父目录)。写失败要**报错**而不是假装成功 ——
// 模型会以为文件在那儿,然后 upload_attachment 打不开它。
if dir := filepath.Dir(savePath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "建目录失败:" + err.Error()}, ""
}
}
if err := os.WriteFile(savePath, body, 0o644); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "写文件失败:" + err.Error()}, ""
}
return http.StatusOK, map[string]any{
"saved_path": savePath,
"size_bytes": len(body),
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
}, ""
}
// pathWarnings 汇总「path 候选里哪些不该选」。
//
// 为什么值得单独做(2026-10-02 寻址报告 B/C 实测):64 条候选里 33 条是
// 桥的内部会话目录,且 root 与 /root 外观只差一个斜杠却是两个不同工作区。
// 混在列表里给模型挑,选中即**静默**投进错误线索(投递返回 200)。
func pathWarnings(paths []any) string {
var internal, relative []string
for _, it := range paths {
m, _ := it.(map[string]any)
if str(m, "kind") == "bridge-internal" {
internal = append(internal, str(m, "path"))
} else if ok, isBool := m["is_absolute"].(bool); isBool && !ok {
relative = append(relative, str(m, "path"))
}
}
var b strings.Builder
if len(internal) > 0 {
fmt.Fprintf(&b, "· %d 条是 Agent 桥的内部会话目录(投到那里会把会话的工作区变成它):%s\n",
len(internal), firstFew(internal, 3))
}
if len(relative) > 0 {
fmt.Fprintf(&b, "· %d 条是相对路径(与同名绝对路径是两个不同工作区):%s\n",
len(relative), firstFew(relative, 3))
}
return b.String()
}
func firstFew(items []string, n int) string {
if len(items) <= n {
return strings.Join(items, ", ")
}
return strings.Join(items[:n], ", ") + fmt.Sprintf(" …(共 %d 条)", len(items))
}