## 缺口(部署后实测才发现,是我自己引入的)
第一版 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 条相对路径被标出
471 lines
15 KiB
Go
471 lines
15 KiB
Go
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))
|
||
}
|