package main import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "io" "log" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) const ( defaultGateway = "http://127.0.0.1:8180" heartbeatIntval = 30 * time.Second // 契约 B-2 要求 30 秒 sseRetry = 3 * time.Second // B-5.3 explicitSends 记忆窗口:模型在一轮里发过的信, // 在该窗口内不再自动 relay 同一封。超过窗口说明那轮已经结束。 dedupWindow = 10 * time.Minute // B-1.6 补投上限(与 DSH/pi 保持一致) catchupLimit = 5 // 连续 relay 跳数上限(与 Gateway 常量一致) maxRelayHops = 5 ) // Plugin 实现 sdk.Plugin。 // // TrueAgent 是单一常驻 agent 事件循环,没有「每个对话一个 session」的概念。 // 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。 // 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。 type Plugin struct { // name 是**插件名**(homed 注册用,如 homeagent-mail-bridge)。 name string // agentName 是**AgentMail 身份**(如 homeagent)。 // // 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒 // 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge" // 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间, // 让一把密钥能注册任意名字等于让它能冒充任何人。 agentName string sdk *sdk.PluginSDK gwURL string key string keyFile string // B-1.1 密钥文件路径 client *http.Client stopCh chan struct{} stopOnce sync.Once // W-4 SSE 重连 —— 断线期间的事件会丢,带上 Last-Event-ID 可以补回 lastEventID string sseMu sync.Mutex // 保护 lastEventID // B-5.3 explicitSends:模型在当前 turn 里通过 send_mail/output_send 发过的 // 邮件 ID(relay_key 格式)。自动 relay 前查这个表,已有则让位。 // // 为什么不是按 session 隔离:TrueAgent 是单事件循环,所有邮件共享一个 turn。 // 模型如果调了 send_mail 回给发件人,那就是它自己的回复,不该再 relay。 explicitSends map[string]time.Time explicitSendsMu sync.Mutex // B-2.2 模型目录缓存 modelCatalog []string // B-1.6 补拉状态:首个成功心跳后只补一次 catchupDone bool // ─── SSE 专用 ─── // SSE 需要一个不设 Timeout 的 HTTP client:原来 p.client(60s Timeout) // 跑 SSE 长连接,每 60 秒自己掐断自己。之后 lastEventID 回退 → 重放 → // 又阻塞 → 又超时 —— 自激振荡。这个 client 只给 readSSE 用。 sseClient *http.Client // B-7.3 邮件级去重:SSE 重放会重发同一批事件,没有这层去重 // 每封邮件会被注入 agent 两遍。契约 B-7.3 要求:每封只注入一次。 // // 这只挡得住**本进程内**的重复。跨进程(homed 重启、插件子进程被换) // 靠 ledger —— 它落盘,且区分「投过」与「跑完」。 // // 有界(见 bounded.go):插件跟着 homed 长期活着,普通 map 会攒下每一封 // 处理过的邮件 id 而永远没有出口。 deliveredMails *boundedIDSet // 跨进程投递账本(见 ledger.go)。 // // 它与 deliveredMails 不是重复:后者是同一进程内 SSE 重放的快速路径, // 前者回答的是「上一个进程有没有已经把这封跑完」。 ledger *deliveryLedger // currentSessionID 是当前正在处理的邮件所属的 agentmail 会话 ID。 // // homeagent 是单事件循环(所有邮件共享一个 turn),同一时刻只处理一封信。 // 模型调 send_mail 时,Gateway 需要知道「这封信是从哪条会话里发出的」 // 才能用 InheritedMode 继承档位。SDK 的工具 handler 不传 session 上下文, // 所以靠这个字段做桥接。 currentSessionID string // currentWorkspace 是当前正在处理的那封邮件的**收件工作区**(信封的 path 位)。 // // ★ 2026-09-26:收件箱接口要求 workspace(缺了 400),而工具 handler 没有 // 独立 session 上下文,只能靠这里暂存 —— 与 currentSessionID 同一形状、 // 同一生命周期(回合开始设、结束清空)。 // // 为什么必须收窄:三维地址 `name@path.session` 的 path 位在收件箱侧此前 // 从未生效 ⇒ 在 mc 工作区干活的会话读收件箱会拿到 agentmail 的信并照着去 // 改 agentmail 的代码(用户 2026-09-26 当场指出)。 // // 我今天先部署了服务端、只修了 pi/opencode/dsh 三个桥,漏了这里 —— // 线上随即出现 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`(07:42 起)。 // 这就是「服务端先改、四个桥后改」的那半天窗口。 // // ★★ 2026-10-01:**光有它不够**(本字段的第二次补洞)。 // // 现象:homeagent 的工具是**全局注册**的(webui / a2a / 对话都能调), // 而本字段只在 InjectInputSync 期间有值 —— 于是**从对话里调 read_inbox // 必然 400**。实测 2026-10-01 14:32:26 一次真实失败;近 24h **3 次失败、0 次成功**。 // // 为什么另外三桥没这个问题:**它们的工具只在邮件驱动回合里被装配** // (pi 的 `mailTools` 在 `loadSession` 之前装,opencode/dsh 同构), // 所以 `getWorkspace()` 永远有值。**架构差异,不是某个桥忘了写。** // // 修法:`effectiveWorkspace()` —— 本字段为空时回落到**桥进程的 cwd**。 // 那个 cwd 就是**调用方的工作目录**(homed 以子进程方式按调用上下文拉起它), // 实测:对话里那个桥进程 cwd=/home/program/agentmail, // 而从插件目录拉起的那个 cwd=/home/newqqagent/plugins/homeagent-mail-bridge。 // // ⚠ 它是**默认**而不是**保证**:库里 17 个不同工作区,而进程 cwd 只反映 // 当前这一个调用方。收窄语义不变(仍只列这个工作区的信), // 拿不准时宁可少列 —— 跨工作区误读正是 2026-09-26 那个缺陷本身。 currentWorkspace string // defaultSessionID 是「非邮件轮次的默认会话」(由 /agent/session/default 问出)。 // 默认会话与工作区回落的差别在答案的来源:workspace 能从进程 cwd 得到, // 会话 UUID 不能 —— 那是服务端的 id。所以它必须问,且那个端点是纯只读的 // (没通信过就没有 ⇒ 不编 id,见 effectiveSessionID 的注释)。 defaultSessionID string // defaultSessionProbed 保证「问过了」与「答案是空」这两种情况都只问一次。 // 缺了它,每次工具调用都会重问一个答案恒为空的端点。 defaultSessionProbed bool // 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。 // 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID, // 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。 sseMaxID int64 } // ─── B-1.1 密钥解析与本地生成 ─── // // 契约要求:环境变量 → ~/.agentmail/agent.key → 本地生成一把。 // 本地生成时打印到 stderr(进 journalctl),落盘到 key 文件(0600)。 // 这样管理员拿到日志里的密钥全文去后台登记,下次重启就不再需要环境变量。 func resolveKey() (key, keyFile string) { keyFile = os.Getenv("AGENTMAIL_CONFIG_DIR") if keyFile == "" { home, _ := os.UserHomeDir() keyFile = filepath.Join(home, ".agentmail") } keyFile = filepath.Join(keyFile, "agent.key") // 1. 环境变量 if k := strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_KEY")); k != "" { return k, keyFile } // 2. 本地文件 if data, err := os.ReadFile(keyFile); err == nil { k := strings.TrimSpace(string(data)) if k != "" { return k, keyFile } } // 3. 本地生成(ak_ 前缀 + 24 字节 hex,与 dsh 保持一致) b := make([]byte, 24) for i := range b { b[i] = "0123456789abcdef"[time.Now().UnixNano()%16] time.Sleep(1) } k := "ak_" + fmt.Sprintf("%x", b) dir := filepath.Dir(keyFile) os.MkdirAll(dir, 0700) os.WriteFile(keyFile, []byte(k), 0600) // 契约 9.8:密钥打印到 stderr 进 journalctl,不走平台 logger fmt.Fprintf(os.Stderr, "[homeagent-mail-bridge] 本地生成密钥,请让管理员在 AgentMail 后台「Agent 密钥」中登记:\n%s\n文件:%s\n", k, keyFile) return k, keyFile } // ─── 工厂 ─── func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { gw := "" if v, ok := config["gateway_url"].(string); ok { gw = v } if gw == "" { gw = os.Getenv("AGENTMAIL_GATEWAY_URL") } if gw == "" { gw = defaultGateway } // AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀 agentName := "" if v, ok := config["agent_name"].(string); ok { agentName = strings.TrimSpace(v) } if agentName == "" { agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME")) } if agentName == "" { agentName = strings.TrimSuffix(name, "-mail-bridge") } return &Plugin{ name: name, agentName: agentName, gwURL: strings.TrimRight(gw, "/"), key: "", // Start() 里解析 keyFile: "", client: &http.Client{Timeout: 60 * time.Second}, sseClient: &http.Client{}, // 无超时:SSE 是长连接 stopCh: make(chan struct{}), deliveredMails: newBoundedIDSet(maxTrackedMails), explicitSends: make(map[string]time.Time), }, nil } func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s s.SetAutoRestart(true) // B-1.1 解析密钥(环境变量 → 本地文件 → 生成) p.key, p.keyFile = resolveKey() // 跨进程投递账本:子进程被换时(homed 重启 / 插件崩溃自动重启) // 内存里的 deliveredMails 全丢,只靠它防不住重复注入。 // // DataDir() 是 SDK 保证存在的插件专属目录;拿不到时退回 key 文件所在目录 // (那个目录本来就要能写)。 dataDir := "" if sett := s.Settings(); sett != nil { dataDir = sett.DataDir() } if strings.TrimSpace(dataDir) == "" { dataDir = filepath.Dir(p.keyFile) } p.ledger = openDeliveryLedger(dataDir) // ─── 注册工具 ─── // registerTool 包一层只为计数:日志里的工具数必须与实际注册数一致。 registeredToolCount := 0 registerTool := func(name string, def sdk.ToolDef, h func(map[string]interface{}) (interface{}, error)) { registeredToolCount++ s.RegisterTool(name, def, h) } registerTool("read_inbox", sdk.ToolDef{ Name: "read_inbox", Description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "status": map[string]interface{}{"type": "string", "description": "过滤条件 unread|all,默认 unread"}, "limit": map[string]interface{}{"type": "number", "description": "返回数量,默认 5"}, }, }, }, p.handleReadInbox) registerTool("read_mail", sdk.ToolDef{ Name: "read_mail", Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。", Parameters: oneStringParam("mail_id", "邮件 ID", true), }, p.handleReadMail) registerTool("send_mail", sdk.ToolDef{ Name: "send_mail", Description: "发送邮件。三维地址 name@path.session。回复来信请传 reply_to。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "to": map[string]interface{}{"type": "string", "description": "收件人三维地址"}, "subject": map[string]interface{}{"type": "string", "description": "邮件主题"}, "body": map[string]interface{}{"type": "string", "description": "邮件正文(Markdown)"}, "cc": map[string]interface{}{"type": "string", "description": "抄送"}, "reply_to": map[string]interface{}{"type": "string", "description": "回复某封邮件时传其 mail_id"}, // 字段名必须是 attachment_ids、元素必须是裸 id 字符串 —— 逐字对齐服务端 // SendMailRequest.AttachmentIDs。服务端解请求体时没开 DisallowUnknownFields, // 所以字段名错了是**静默丢附件**而不是报错:实测传 // attachments:[{"attachment_id":…}] 返回 200,那封邮件的附件数是 0。 "attachment_ids": map[string]interface{}{ "type": "array", "items": map[string]interface{}{"type": "string"}, "description": "附件 ID 列表(先用 upload_attachment 上传取得)", }, }, "required": []string{"to", "subject", "body"}, }, }, p.handleSendMail) registerTool("forward_mail", sdk.ToolDef{ Name: "forward_mail", Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"}, "to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址"}, "comment": map[string]interface{}{"type": "string", "description": "转发说明"}, "cc": map[string]interface{}{"type": "string", "description": "抄送"}, "subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"}, "session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效"}, }, "required": []string{"mail_id", "to"}, }, }, p.handleForwardMail) registerTool("upload_attachment", sdk.ToolDef{ Name: "upload_attachment", Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachment_ids 字段。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "file_path": map[string]interface{}{"type": "string", "description": "本地文件路径"}, }, "required": []string{"file_path"}, }, }, p.handleUploadAttachment) registerTool("download_attachment", sdk.ToolDef{ Name: "download_attachment", Description: "下载附件到本地。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "attachment_id": map[string]interface{}{"type": "string", "description": "附件 ID"}, "save_path": map[string]interface{}{"type": "string", "description": "保存路径"}, }, "required": []string{"attachment_id", "save_path"}, }, }, p.handleDownloadAttachment) registerTool("suggest_address", sdk.ToolDef{ Name: "suggest_address", Description: "查询可用收件人地址。不带参数给候选收件人;带 name 给工作目录;name+path 都带则给会话别名。发信前应先用它确认地址。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"}, "path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"}, }, }, }, p.handleSuggestAddress) registerTool("list_contacts", sdk.ToolDef{ Name: "list_contacts", Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。", Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false), }, p.handleListContacts) registerTool("session_participants", sdk.ToolDef{ Name: "session_participants", Description: "列出某条会话的全部参与方与各自的可投递地址,并标出谁还没回应。", Parameters: oneStringParam("session_id", "会话 ID", true), }, p.handleSessionParticipants) registerTool("read_thread", sdk.ToolDef{ Name: "read_thread", Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"}, "offset": map[string]interface{}{"type": "number", "description": "分页偏移"}, }, "required": []string{"mail_id"}, }, }, p.handleReadThread) registerTool("connect_to_server", sdk.ToolDef{ Name: "connect_to_server", Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"}, "key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"}, }, }, }, p.handleConnectToServer) // ─── 日程 / 待办 ─── // // 这一组的价值不在「记事」而在**跨进程的时间**:模型自己没法让一个进程 // 在未来某刻醒来,插件里的定时器也随 homed 重启一起消失。交给 Gateway // 之后由数据库与调度器保证,到点发一封邮件把收件方唤起来。 registerTool("create_schedule", sdk.ToolDef{ Name: "create_schedule", Description: "创建一条日程提醒。到点时 Gateway 会发一封邮件给收件方(默认是你自己)," + "因此它能跨进程重启生效 —— 比你自己记着时间可靠。" + "典型用法:稍后检查某件事、提醒另一个 Agent 交东西、周期性巡检。" + scheduleFieldsHint, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "title": map[string]interface{}{"type": "string", "description": "日程标题,会成为提醒邮件的主题"}, "event_time": map[string]interface{}{"type": "string", "description": "事件时间,RFC3339 带时区(2026-09-10T09:00:00+08:00)。不支持「明天」这类相对表述"}, "description": map[string]interface{}{"type": "string", "description": "补充说明,可作为 {description} 变量填入提醒正文"}, "reminder_text": map[string]interface{}{"type": "string", "description": "提醒邮件正文模板,支持 {title} {time} {description} 三个变量。留空用默认模板"}, "remind_before": map[string]interface{}{"type": "number", "description": "提前多少分钟提醒,默认 0(到点才提醒)"}, "recurrence": map[string]interface{}{"type": "string", "description": "重复规则,默认 none。农历用 lunar_monthly / lunar_yearly"}, "recurrence_end": map[string]interface{}{"type": "string", "description": "重复到什么时候为止,留空 = 一直重复"}, "recipients": map[string]interface{}{"type": "string", "description": "收件方,逗号分隔的三维地址(如 dsh,pi@/home/x)。省略 = 发给自己。不能设给人类用户 —— 要通知人请直接 send_mail"}, "delivery_mode": map[string]interface{}{"type": "string", "description": "多收件人时:separate(默认,各自独立会话互不可见)或 together(首个为主收件人其余抄送,共享一条线索)"}, }, "required": []string{"title", "event_time"}, }, }, p.handleCreateSchedule) registerTool("list_schedules", sdk.ToolDef{ Name: "list_schedules", Description: "列出你建的日程(只能看到自己建的)。返回每条的 ID、时间、重复规则与收件方。" + "改时间或删除前先用它查 ID。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "status": map[string]interface{}{"type": "string", "description": "过滤 active(默认)/paused/cancelled/all"}, "from": map[string]interface{}{"type": "string", "description": "起始时间,默认昨天"}, "to": map[string]interface{}{"type": "string", "description": "结束时间,默认三个月后"}, }, }, }, p.handleListSchedules) registerTool("update_schedule", sdk.ToolDef{ Name: "update_schedule", Description: "改一条日程。**只传要改的字段**,省略的保持原值 —— 不要回传全部字段," + "记错一个就会覆盖掉原有的提醒正文或收件方。" + "暂停提醒传 status=paused。" + scheduleFieldsHint, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "event_id": map[string]interface{}{"type": "string", "description": "要改哪条(用 list_schedules 查)"}, "title": map[string]interface{}{"type": "string", "description": "新标题"}, "event_time": map[string]interface{}{"type": "string", "description": "新时间,RFC3339 带时区"}, "description": map[string]interface{}{"type": "string", "description": "新说明"}, "reminder_text": map[string]interface{}{"type": "string", "description": "新的提醒正文模板"}, "remind_before": map[string]interface{}{"type": "number", "description": "新的提前分钟数"}, "recurrence": map[string]interface{}{"type": "string", "description": "新的重复规则"}, "recurrence_end": map[string]interface{}{"type": "string", "description": "新的重复终止时间"}, "recipients": map[string]interface{}{"type": "string", "description": "新收件方,逗号分隔。不能改成空"}, "delivery_mode": map[string]interface{}{"type": "string", "description": "separate 或 together"}, "status": map[string]interface{}{"type": "string", "description": "active / paused(暂停提醒)/ cancelled"}, }, "required": []string{"event_id"}, }, }, p.handleUpdateSchedule) registerTool("delete_schedule", sdk.ToolDef{ Name: "delete_schedule", Description: "删掉一条日程,之后不再提醒。只想临时停掉请用 update_schedule 传 status=paused。", Parameters: oneStringParam("event_id", "要删哪条(用 list_schedules 查)", true), }, p.handleDeleteSchedule) // 注册输出通道。 // // 名字按**投递介质**取(`email`),与内核自带的 qq / webui / cli / acp / a2a // 一致 —— 内核据此拼出 `output_send__email`。原先叫 `homeagent`(Agent 名), // 读起来像「给 homeagent 发消息」,与通道的语义(往哪儿送)不符。 // // 能力位声明必须与实现一致:原先声明了 CapFile,而 handler 不读 `type`, // 于是任何 type=file 的调用都会静默变成一封把**路径当正文**的邮件 —— // 声明一个做不到的能力,比不声明更糟(调用方无从发现)。 s.RegisterOutputChannel(outputChannelName, outputChannelCaps, outputChannelDesc, sdk.ChannelDef{}, p.handleOutputChannel) // 数量从 RegisterTool 的调用数派生,不硬编码。 // // 之前这里写死 13,而实际注册的是 11 —— 排查「工具没生效」时日志说 13、 // 平台说 11,两个数字都不可信,白花了一轮时间。加工具时忘改常量是必然的, // 所以让它没有机会写错。 log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具 + 1 个输出通道),等待 Gateway SSE", registeredToolCount) // 启动心跳 + SSE(后台 goroutine) go p.heartbeatLoop() go p.sseLoop() return nil } func (p *Plugin) Stop() error { p.stopOnce.Do(func() { close(p.stopCh) // 关账本句柄。每行写入都 Sync 过,所以不关也不丢数据 —— // 关只是为了不把 fd 泄给下一个插件实例。 if p.ledger != nil { p.ledger.close() } }) return nil } // ─── 心跳 ─── func (p *Plugin) heartbeatLoop() { // B-1.3:立即发一次心跳,不等第一个 30 秒周期 if err := p.heartbeat(); err != nil { log.Printf("[homeagent-mail-bridge] 首次心跳失败: %v", err) } ticker := time.NewTicker(heartbeatIntval) defer ticker.Stop() for { select { case <-p.stopCh: return case <-ticker.C: if err := p.heartbeat(); err != nil { // B-2.1:心跳失败不重试不报错,下一轮补上 } } } } func (p *Plugin) register() error { // ★ 2026-10-02:装配期先问一次默认会话。 // // 非邮件轮次(对话里自主调 read_mail/read_thread)时 currentSessionID 为空, // 而服务端对未声明 session_id 的读信一律 403(AgentMayReadSession 收严后)。 // 放在这里而不是取值函数里:那是**取值**,不该有 IO(我第一版塞进 // effectiveSessionID,`&Plugin{}` 构造的测试当场 nil panic)。 // // 问不到也继续:那是「没有默认会话」的正常形状(那个端点是纯只读的), // 后果只是这次读信拿 403,而不是被静默放行成越权。 p.ensureDefaultSession() body := map[string]interface{}{ "name": p.agentName, "platform": "homeagent", "workspaces": []interface{}{}, } return p.post("/agent/register", body, nil) } func (p *Plugin) heartbeat() error { payload := map[string]interface{}{} payload["mode_enforcement"] = "advisory" // homeagent 无沙箱,档位只在提示词里告知 // B-2.3:带上模型目录 if len(p.modelCatalog) > 0 { payload["models"] = p.modelCatalog } var resp struct { AllowedModels []string `json:"allowed_models"` PendingMails int `json:"pending_mails"` // PendingWorkspaces 是「哪些工作区有待补投的未读」。心跳是进程级的, // 没有"我的工作区"可言;而补投要按工作区收窄(否则重放别的活), // 所以由它给清单,补投逐个消费。 PendingWorkspaces []string `json:"pending_workspaces"` } if err := p.post("/agent/heartbeat", payload, &resp); err != nil { return err } // B-2.2:从响应读 allowed_models(Gateway 有范围配置时返回) // 这些模型被插件用于后续轮次的模型选择降级尝试 // B-1.6 / B-7:首个成功心跳后,如果有未读邮件,补拉 if !p.catchupDone { p.catchupDone = true if resp.PendingMails > 0 { go p.catchUp(resp.PendingMails, resp.PendingWorkspaces) } } return nil } // B-7 补拉:串行读取未读邮件,逐封注入 agent 事件循环。 // // 与 DSH/pi 的补拉逻辑一致: // - 上限 5 封(catchupLimit),避免重启时一次性灌入太多 // - 正序(最旧的先处理),保持时间线 // - 只补 normal 类型(permission 不补投——人在 WebUI 上看到就知道了) // - 每封之间等 InjectInputSync 返回(串行处理) func (p *Plugin) catchUp(pending int, workspaces []string) { limit := catchupLimit if pending < limit { limit = pending } // ★ 逐工作区补投,不再读一次全局收件箱。 // // 旧写法不带任何收窄 ⇒ 会把**所有工作区**的漏投一起重放。清单来自心跳的 // `pending_workspaces`(心跳是进程级的,没有"我的工作区"可言,所以由它给清单)。 // // 拿不到清单就不补投:带着空 workspace 去拉,服务端会 400。 if len(workspaces) == 0 { log.Printf("[homeagent-mail-bridge] 补投跳过:pending_mails=%d 但服务端未给出 pending_workspaces", pending) return } delivered := 0 for _, ws := range workspaces { n, err := p.catchUpWorkspace(ws, limit, pending) if err != nil { // 单个工作区失败不影响其余(与"心跳失败不报错"同一原则) log.Printf("[homeagent-mail-bridge] 补投工作区 %s 失败(不影响其余): %v", ws, err) continue } delivered += n } if delivered > 0 { log.Printf("[homeagent-mail-bridge] 补投 %d 封离线期间的邮件(共 %d 封未读,跨 %d 个工作区)", delivered, pending, len(workspaces)) } } // catchUpWorkspace 补投**一个工作区**的未读邮件,返回实际投递封数。 func (p *Plugin) catchUpWorkspace(workspace string, limit, pending int) (int, error) { var inbox struct { Mails []struct { MailID string `json:"mail_id"` FromName string `json:"from_name"` Subject string `json:"subject"` MailType string `json:"mail_type"` ReplyTo string `json:"reply_to"` FromHuman bool `json:"from_human"` ParentMailID string `json:"parent_mail_id"` SessionID string `json:"session_id"` } `json:"mails"` } url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d&workspace=%s", p.gwURL, limit, url.QueryEscape(workspace)) if err := p.get(url, &inbox); err != nil { return 0, err } delivered := 0 for _, m := range inbox.Mails { if m.MailType != "normal" { continue // permission 等非邮件驱动的不补投 } // B-7.3 / B-7.6:逗封去重。 // // `go p.catchUp(…)` 与 `go p.sseLoop()` 是两个并发 goroutine,重启时 // 窗口重叠:SSE 推一次 + 补投拉一次 = 同一封邮件注入两遍。 // 生产实测过:homeagent 的回信里写着「之前的对话时序中已经收到并 // 确认过多次了」。另三个插件的 catchUp 都有这层,只有这里漏了。 // // 必须在循环里逗封查而不是拉完一批再筛:InjectInputSync 一封要跑 // 几十秒,那期间 SSE 完全可能已经投过后面那几封。 p.sseMu.Lock() // add 返回「本次是否新加入」,于是查重与登记在同一把锁里一步完成。 fresh := p.deliveredMails.add(m.MailID) p.sseMu.Unlock() if !fresh { continue } // 跨进程去重:这次重启前那个进程可能已经把这封跑完了。 // // 这才是那次事故的真正修法:死掉的那个进程已经注入过一次, // 而 deliveredMails 随它一起消失了。ledger 落盘,能说出区别: // - 已跑完 → 真的跳过 // - 投过但未跑完(上一轮被中断)→ 仍然重投,但带上说明 // 后一条很要紧:那一轮被中断意味着发件人没收到回信,跳过它就是静默丢件。 deliver, resumed := p.ledger.claim(m.MailID) if !deliver { log.Printf("[homeagent-mail-bridge] 补投跳过 %s:已在之前的进程里处理完毕", shortID(m.MailID)) continue } if resumed { log.Printf("[homeagent-mail-bridge] 补投 %s(上一轮被中断,带说明重投)", shortID(m.MailID)) } // 注入消息。与 handleNewMail 那份的差异只在一句措辞上(这里不说 // 「把本轮工作做完」)—— 那个差异正好是上次定位重复投递的线索: // 两段提示词同时出现在上下文里,一看措辞就知道一段来自 SSE、 // 一段来自补投。 prefix := "" if resumed { prefix = resumeNote(m.MailID) } replyLine := "" if m.ParentMailID != "" { replyLine = fmt.Sprintf("回的是你那封:%s\n", m.ParentMailID) } prompt := prefix + fmt.Sprintf( "%s\n\n"+ "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ "%s\n\n%s", inboundHeadline(m.ParentMailID, m.FromHuman, true), m.FromName, m.Subject, m.MailID, replyLine, p.agentName, replyInstruction(m.FromHuman, ""), p.deliveryHint(), ) p.currentSessionID = m.SessionID // 补投这一轮同样要设工作区:模型会在这轮里调 read_inbox。 // 不设就是 400(实测 2026-09-26 07:42 起线上就在报这个)。 p.currentWorkspace = workspace reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt) p.currentSessionID = "" p.currentWorkspace = "" delivered++ if reply == "" { // B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。 p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复") p.ledger.complete(m.MailID) continue } // B-5.3:检查模型是否已经自己发过信 rk := ClampRelayKey("homeagent:" + m.MailID) p.explicitSendsMu.Lock() _, sent := p.explicitSends[rk] p.explicitSendsMu.Unlock() if sent { // 模型已经在这一轮里自己回了这封信,不再重复 relay p.ledger.complete(m.MailID) continue } // **只给人类来信自动转发**(见 relay_policy.go)。 // 与 SSE 路径同一取舍 —— 两条路径行为不一致的话,同一封邮件“走 SSE 还是 // 走补投”就能決定发件方有没有回信,而那取决于重启时机。 if d := autoRelayDecision(m.FromHuman, m.FromName); !d.relay { log.Printf("[homeagent-mail-bridge] 补投 %s 不自动转发:%s", shortID(m.MailID), d.reason) p.ledger.complete(m.MailID) continue } // B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额 if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil { // 永久失败(4xx)重试一万次也是同一个结果 —— 标完成,否则 // 每次重启都重跑一遍模型再碰同一堆墙(烧 token 且永不收敛)。 // 暂时失败(5xx / 网络)不标,下次重启重试。 if st := statusOf(err); IsPermanentFailure(st) { log.Printf("[homeagent-mail-bridge] 补投回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err) p.ledger.complete(m.MailID) continue } // 回信没发出去 —— 不标完成,下次重启重试。 log.Printf("[homeagent-mail-bridge] 补投回信暂时失败(不标完成): %v", err) continue } p.ledger.complete(m.MailID) } return delivered, nil } // ─── SSE ─── // mailEvent 是 SSE 事件里与邮件有关的字段。 // // 提成命名类型而不是三处各写一遍匿名 struct:加字段时必须三处同时改, // 而漏改一处的表现是「类型不匹配」编译错误里塞满两串几乎相同的字段列表 —— // 本轮加 from_human / in_reply_to 时就踩了一次。 type mailEvent struct { MailID string `json:"mail_id"` SessionID string `json:"session_id"` FromName string `json:"from_name"` Subject string `json:"subject"` MailType string `json:"mail_type"` Role string `json:"role"` Workspace string `json:"to_workspace"` Alias string `json:"session_alias"` ReplyAddr string `json:"reply_address"` // FromHuman 决定要不要自动回信:Agent 之间不自动回(见 relay_policy.go)。 FromHuman bool `json:"from_human"` // InReplyTo 非空 = 这封是对本方某封信的回复,不是新派的活。 InReplyTo string `json:"in_reply_to"` // PermissionMode 是会话的权限档位(plan / workspace / full)。 // homeagent 无法强制执行任何档位(advisory),只能在提示词里告知模型。 PermissionMode string `json:"permission_mode"` // Decision / DecidedBy / Note 只在 permission_decision 事件上有值。 Decision string `json:"decision"` DecidedBy string `json:"decided_by"` // Note 是人类决策时写的说明(如「不是让你拉取到 agentmail,是到 program 下」)。 // // 缺了它,模型只看到「结论:拒绝」,不知道要改什么 —— 2026-09-13 实测: // 人类写了三遍说明,模型把同一条命令换个写法又问了一遍(连问 9 次)。 Note string `json:"note"` } func (p *Plugin) sseLoop() { for { select { case <-p.stopCh: return default: } if err := p.readSSE(); err != nil { log.Printf("[homeagent-mail-bridge] SSE 断开: %v,%v 后重连", err, sseRetry) time.Sleep(sseRetry) } } } func (p *Plugin) readSSE() error { req, err := http.NewRequest("GET", p.gwURL+"/api/v1/events/stream", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+p.key) // W-4:断线期间的事件会丢,带上 Last-Event-ID 可以让 Gateway 从断点补发。 // 只上报比当前记录的更大的 ID:Gateway 重放时发的是事件原本的 ID, // 如果无条件赋值,lastEventID 会从 123 退回 116 → 下次重连又报 116 → // 又重放 —— 自激振荡的放大器。 p.sseMu.Lock() if p.lastEventID != "" { req.Header.Set("Last-Event-ID", p.lastEventID) } p.sseMu.Unlock() // sseClient 无 Timeout:p.client 有 60s Timeout,SSE 是长连接, // 每 60 秒自己掐断自己 → 重放 → 阻塞 → 超时 → 重放。 resp, err := p.sseClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("SSE HTTP %d", resp.StatusCode) } log.Printf("[homeagent-mail-bridge] SSE 已连接") // bufio.Reader 解决原来手动管理 []byte 的两个问题: // 1. 每次 buf = buf[lineStart:] 让 cap 缩小,几轮之后 len==cap, // Read 拿到零长切片 → (0, nil) → 满速空转 // 2. 手写的 line 分割逻辑有边界条件(跨次 Read 的半行处理) br := bufio.NewReader(resp.Body) for { select { case <-p.stopCh: return nil default: } line, err := br.ReadString('\n') if line != "" { p.parseSSELine(strings.TrimRight(line, "\n")) } if err != nil { if err == io.EOF { return nil } return err } } } // parseSSEID 把 "id: 123" 格式的事件 ID 解析成整数。 // 解析失败返回 0(大于 0 的 ID 才会被接受),保证不会误清状态。 func parseSSEID(raw string) int64 { var id int64 for _, c := range raw { if c >= '0' && c <= '9' { id = id*10 + int64(c-'0') } } return id } func (p *Plugin) parseSSELine(line string) { // W-4:记录 Last-Event-ID。只向前推进,不回退。 // Gateway 重放旧事件时发的是旧 ID,无条件赋值会让 lastEventID // 从 123 退回到 116 → 下次重连报 116 → 又重放 → 振荡。 if strings.HasPrefix(line, "id: ") { eid := strings.TrimPrefix(line, "id: ") id := parseSSEID(eid) p.sseMu.Lock() if id > p.sseMaxID { p.sseMaxID = id p.lastEventID = eid } p.sseMu.Unlock() return } if !strings.HasPrefix(line, "data: ") { return } raw := strings.TrimPrefix(line, "data: ") if raw == "" || raw == "{}" { return } var evt mailEvent if err := json.Unmarshal([]byte(raw), &evt); err != nil { return } if evt.MailID == "" { return } if evt.MailType == "permission_decision" { p.handlePermissionDecision(evt) return } if evt.MailType == "normal" { // B-7.3:去重。SSE 重放时同一封邮件会再出现,没有这层 // 每封邮件会被注入 agent 两遍(实测 21 次超时 → 21 次重放)。 p.sseMu.Lock() fresh := p.deliveredMails.add(evt.MailID) p.sseMu.Unlock() if !fresh { return } // 跨进程去重:上一个插件子进程可能已经把这封跑完了。 // deliveredMails 只在本进程内有效,homed 重启会把它清空 —— // 实测过一次两段几乎相同的通知堆在模型上下文里(一段来自这里的 // SSE 路径,一段来自重启后的 catchUp)。 deliver, resumed := p.ledger.claim(evt.MailID) if !deliver { log.Printf("[homeagent-mail-bridge] 邮件 %s 已在之前的进程里处理完毕,跳过", shortID(evt.MailID)) return } // InjectInputSync 会阻塞几十秒(查日志、调工具、转发 QQ), // 而它跑在 readSSE 的读循环里 —— 循环卡住期间 SSE 事件积压在 // TCP 缓冲区,卡到超时断线重连后 Gateway 全部重放一遍。 // 把处理丢到独立 goroutine:parseSSELine 立刻返回,读循环继续。 // homeagent 是单事件循环,InjectInputSync 自己会排队。 go p.handleNewMail(evt, resumed) } } // ─── 输出通道(agent 主动发信)─── // ─── 发信辅助 ─── // // `sendMail` / `sendMailRelay` 是最底层的一次发信调用,见 channel.go 的 // `sendMailFull`:输出通道、send_mail 工具与 relay 三条路径共用它, // 不会出现「通道发的邮件少一个字段」这种分叉。 func (p *Plugin) sendMail(to, subject, body, replyTo, sessionAlias string) error { if sessionAlias != "" { payload := map[string]interface{}{ "to": to, "subject": subject, "body": body, "session_alias": sessionAlias, } if replyTo != "" { payload["reply_to"] = replyTo } return p.post("/mail/send", payload, nil) } return p.sendMailFull(to, subject, body, replyTo, "", nil) } // sendMailRelay 发一封带 relay:"summary" 标记的邮件。 // // B-5.2:插件代模型搬运回复时必须带 relay:"summary" + relay_key, // Gateway 才会把它走免配额通道(插件搬运不算模型自主发信)。 // B-5.4:文本为空时不发空邮件。 func (p *Plugin) sendMailRelay(to, subject, body, replyTo, relayKey string) error { if strings.TrimSpace(body) == "" { return nil // B-5.4 } payload := map[string]interface{}{ "to": to, "subject": subject, "body": body, "relay": "summary", "relay_key": relayKey, } if replyTo != "" { payload["reply_to"] = replyTo } return p.post("/mail/send", payload, nil) } // sendFailureReply 在模型处理失败时给发件人一封告知。 // // B-6:无法处理时必须回信。发件人发了邮件后没有任何音讯是最糟的体验 —— // 他不知道邮件到了没有、模型看了没有、是卡住了还是忽略了。 // 必须带 relay:"summary" 走免配额通道,这是插件代劳不是模型自主发信。 func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) { body := fmt.Sprintf( "这是一封自动通知:您发送的主题为「%s」的邮件在处理时遇到了问题,未能产生有效回复。\n\n"+ "原因:%s\n\n"+ "请稍后重试,或通过其他方式联系。", subject, reason, ) rk := ClampRelayKey("homeagent:failure:" + replyTo) if err := p.sendMailRelay(to, "Re: "+subject, body, replyTo, rk); err != nil { log.Printf("[homeagent-mail-bridge] 失败通知发送失败: %v", err) } } // ─── 新邮件处理 ─── func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { // resumed = 上一个进程注入过这封但那一轮被中断了。 // // 必须把这件事告诉模型:不说的话它在上下文里看到两段几乎相同的指令, // 会以为人重复交代了一遍,于是可能把同一件事做两次。 prefix := "" if resumed { prefix = resumeNote(evt.MailID) log.Printf("[homeagent-mail-bridge] 邮件 %s 重投(上一轮被中断)", shortID(evt.MailID)) } replyLine := "" if evt.InReplyTo != "" { replyLine = fmt.Sprintf("回的是你那封:%s\n", evt.InReplyTo) } addrLine := "" if evt.ReplyAddr != "" { addrLine = fmt.Sprintf("回信地址:%s\n", evt.ReplyAddr) } prompt := prefix + fmt.Sprintf( "%s\n\n"+ "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n%s\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ "%s\n%s\n\n%s", inboundHeadline(evt.InReplyTo, evt.FromHuman, false), evt.FromName, evt.Subject, evt.MailID, replyLine, p.agentName, addrLine, replyInstruction(evt.FromHuman, evt.ReplyAddr), ModeBriefing(evt.PermissionMode, "advisory"), p.deliveryHint(), ) // InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。 // 工具 handler 没有独立的 session 上下文,因此在本轮处理期间暂存来源会话。 p.currentSessionID = evt.SessionID p.currentWorkspace = evt.Workspace reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt) p.currentSessionID = "" p.currentWorkspace = "" // B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话) if reply == "" { log.Printf("[homeagent-mail-bridge] 邮件 %s(来自 %s:%s)agent 无回复,发失败通知", shortID(evt.MailID), evt.FromName, evt.Subject) p.sendFailureReply(evt.FromName, evt.Subject, evt.MailID, "模型未产生回复") // 失败通知发出去了就算**处理完**:发件人得到了一个明确的交代。 // 不标的话下次重启会把同一封再投一遍 —— 而模型上一次就没回, // 重投只会再发一封相同的失败通知。 p.ledger.complete(evt.MailID) return } // B-5.3:检查模型是否已经自己发过信(通过 send_mail 或 output_send) rk := ClampRelayKey("homeagent:" + evt.MailID) p.explicitSendsMu.Lock() _, sent := p.explicitSends[rk] if sent { delete(p.explicitSends, rk) // 用过即清,不留残 } p.explicitSendsMu.Unlock() if sent { // 模型已经在这一轮里自己回了这封信,让位 log.Printf("[homeagent-mail-bridge] 邮件 %s 模型已自行回复,跳过自动 relay", shortID(evt.MailID)) p.ledger.complete(evt.MailID) return } // **只给人类来信自动转发**(见 relay_policy.go)。 // // 对方是 Agent 时它那边的插件也会自动回一封,两个模型都以为「我只要把话 // 说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮。 // 此时这一封算处理完:模型已经看过并做完了活,没有回信是设计意图而不是失败。 if d := autoRelayDecision(evt.FromHuman, evt.FromName); !d.relay { log.Printf("[homeagent-mail-bridge] 邮件 %s 本轮不自动转发:%s", shortID(evt.MailID), d.reason) p.ledger.complete(evt.MailID) return } // B-5.2:自动回信带 relay:"summary" + relay_key if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil { // 永久失败(4xx)标完成:重试不会变好,不标的话每次重启 // 都重跑一遍模型再碰同一堆墙。 if st := statusOf(err); IsPermanentFailure(st) { log.Printf("[homeagent-mail-bridge] 自动回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err) p.ledger.complete(evt.MailID) return } // 暂时失败 → **不标完成**,让下次重启能重试。 // 发件人至今一个字都没收到,这时标「已完成」就是静默丢件。 log.Printf("[homeagent-mail-bridge] 自动回信暂时失败(不标完成,下次会重试): %v", err) } else { log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply)) p.ledger.complete(evt.MailID) } // 清理过期的 explicitSends 记录 p.explicitSendsMu.Lock() for k, t := range p.explicitSends { if time.Since(t) > dedupWindow { delete(p.explicitSends, k) } } p.explicitSendsMu.Unlock() } // ─── 权限决策 ─── func (p *Plugin) handlePermissionDecision(evt mailEvent) { p.sdk.InjectText(p.name, outputChannelName, permissionDecisionPrompt(evt)) } // permissionDecisionPrompt 组装"权限结论"那段话。 // // 抽成纯函数是为了可判据:**人类的说明必须出现在这里**。2026-09-13 线上缺陷: // 人类写了「我说了让你拉取仓库到program下你听不懂吗」,而注入的提示词里只有结论 // 与决策人,模型不知道要改什么,把同一条命令换个写法又问了一遍(连问 9 次)。 func permissionDecisionPrompt(evt mailEvent) string { prompt := fmt.Sprintf( "你之前发起的权限请求已有结论:%s(决策人:%s)。请据此继续。", evt.Subject, evt.FromName, ) if note := strings.TrimSpace(evt.Note); note != "" { prompt += "\n用户的说明:" + note } return prompt } // ─── 工具实现 ─── func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, error) { status := "unread" if v, ok := args["status"].(string); ok && v != "" { status = v } limit := 5 if v, ok := args["limit"].(float64); ok && v > 0 { limit = int(v) } // ★ 会话收窄:只列**当前正在处理的那条会话**的邮件。 // // 不带 session_id 时这是整个 Agent 的收件箱,而 read_inbox 按契约会把列出来的 // 都标成已读 ⇒ A 会话的回合会把 B 会话的未读标掉,B 之后按 ?status=unread // 补投时就再也看不到那封信(静默丢信)。currentSessionID 在回合开始时设置、 // 结束时清空(见 handleNewMail/handleThread 的 InjectInputSync 前后), // 所以这里读到的就是"这次在替谁干活"。 url := p.inboxURL(status, limit) var result map[string]interface{} if err := p.get(url, &result); err != nil { return nil, err } mails, _ := result["mails"].([]interface{}) if len(mails) == 0 { return map[string]interface{}{"content": []map[string]interface{}{{"type": "text", "text": "收件箱为空。"}}}, nil } var sb strings.Builder for i, m := range mails { mail, _ := m.(map[string]interface{}) if mail == nil { continue } from, _ := mail["from_name"].(string) subj, _ := mail["subject"].(string) mid, _ := mail["mail_id"].(string) body, _ := mail["body"].(string) if body == "" { body, _ = mail["body_preview"].(string) } alias, _ := mail["session_alias"].(string) fmt.Fprintf(&sb, "[%d] %s: %s\n邮件 ID: %s\n会话: #%s\n", i+1, from, subj, mid, alias) if ccList, ok := mail["cc_list"].([]interface{}); ok && len(ccList) > 0 { names := make([]string, 0, len(ccList)) for _, c := range ccList { if cc, ok := c.(map[string]interface{}); ok { if n, ok := cc["name"].(string); ok { names = append(names, n) } } } if len(names) > 0 { fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、")) } } if atts, ok := mail["attachments"].([]interface{}); ok && len(atts) > 0 { fmt.Fprintf(&sb, "附件:\n") for _, a := range atts { if att, ok := a.(map[string]interface{}); ok { fn, _ := att["filename"].(string) sz, _ := att["size_bytes"].(float64) aid, _ := att["attachment_id"].(string) fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", fn, formatSize(int64(sz)), aid) } } } if body != "" { if len(body) > 1000 { body = body[:1000] + "..." } fmt.Fprintf(&sb, "内容: %s\n", body) } sb.WriteString("\n") } ids := make([]string, 0, len(mails)) for _, m := range mails { if mail, ok := m.(map[string]interface{}); ok { if id, ok := mail["mail_id"].(string); ok { ids = append(ids, id) } } } if len(ids) > 0 { go p.markRead(ids) } return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, }, nil } func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error) { to, _ := args["to"].(string) subj, _ := args["subject"].(string) body, _ := args["body"].(string) cc, _ := args["cc"].(string) replyTo, _ := args["reply_to"].(string) if to == "" || subj == "" || body == "" { return nil, fmt.Errorf("缺少必填字段:to, subject, body") } // B-5.3:记录模型自主发信 rk := replyTo if rk != "" { p.explicitSendsMu.Lock() p.explicitSends["homeagent:"+rk] = time.Now() p.explicitSendsMu.Unlock() } payload := map[string]interface{}{ "to": to, "subject": subj, "body": body, } if p.currentSessionID != "" { payload["from_session_id"] = p.currentSessionID } if cc != "" { payload["cc"] = cc } if replyTo != "" { payload["reply_to"] = replyTo } // 附件必须由 send_mail 带上:上传只是把文件登记成「待挂载」, // 24 小时内没有任何邮件引用它就会被 GC 清掉。 if ids := stringList(args["attachment_ids"]); len(ids) > 0 { payload["attachment_ids"] = ids } var result map[string]interface{} if err := p.post("/mail/send", payload, &result); err != nil { return nil, err } mid, _ := result["mail_id"].(string) sid, _ := result["session_id"].(string) text := fmt.Sprintf("邮件已发送(ID: %s,Session: %s)", mid, sid) if budget, ok := result["budget_remaining"].(float64); ok { text += fmt.Sprintf("。本任务剩余 %.0f 个来回", budget) } return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": text}}, }, nil } // C-14 附件上传 —— 真 multipart,不是桩。 // // 读取本地文件 → 构造 multipart/form-data → POST /api/v1/attachments。 // 返回 attachment_id,填入 send_mail 的 attachment_ids 字段。 func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{}, error) { filePath, _ := args["file_path"].(string) if filePath == "" { return nil, fmt.Errorf("缺少 file_path") } data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("读取文件失败: %v", err) } // multipart/form-data var buf bytes.Buffer writer := multipart.NewWriter(&buf) part, err := writer.CreateFormFile("file", filepath.Base(filePath)) if err != nil { return nil, fmt.Errorf("创建 multipart 失败: %v", err) } if _, err := part.Write(data); err != nil { return nil, fmt.Errorf("写入文件数据失败: %v", err) } writer.Close() req, err := http.NewRequest("POST", p.gwURL+"/api/v1/attachments", &buf) if err != nil { return nil, err } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } // 服务端返回的是 {"attachment":{…}},字段**不在**顶层。 // // 这里原先按平铺解,于是三个字段全是零值。那是最坏的一种失败:上传其实 // 成功了(HTTP 200、文件已落盘、库里已登记),没有任何一层报错,但模型 // 看到的是 `id= filename= size=0KB` —— 拿着空 id 它没法发出这个附件, // 而 24 小时后 GC 会把那个没人引用的文件清掉。 var result struct { Attachment struct { AttachmentID string `json:"attachment_id"` Filename string `json:"filename"` SizeBytes int64 `json:"size_bytes"` } `json:"attachment"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } a := result.Attachment // 解出空 id 说明响应结构又变了,必须当场报错。回一句「已上传」配一个空 id // 只会让模型接着去发信,然后收到一封没有附件的邮件 —— 那正是上面那个 bug // 之所以能存活的原因。 if a.AttachmentID == "" { return nil, fmt.Errorf("上传响应里没有 attachment_id(服务端响应结构可能已变更),附件无法发出") } text := fmt.Sprintf("附件已上传:%s(%s)。attachment_id: %s\n"+ "在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出:attachment_ids=[\"%s\"]", a.Filename, formatSize(a.SizeBytes), a.AttachmentID, a.AttachmentID) return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": text}}, }, nil } // C-14 附件下载 —— 真 octet-stream 下载。 func (p *Plugin) handleDownloadAttachment(args map[string]interface{}) (interface{}, error) { aid, _ := args["attachment_id"].(string) savePath, _ := args["save_path"].(string) if aid == "" || savePath == "" { return nil, fmt.Errorf("缺少 attachment_id 和 save_path") } req, err := http.NewRequest("GET", p.gwURL+"/api/v1/attachments/"+aid, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } // 确保目录存在 if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil { return nil, fmt.Errorf("创建目录失败: %v", err) } out, err := os.Create(savePath) if err != nil { return nil, fmt.Errorf("创建文件失败: %v", err) } defer out.Close() written, err := io.Copy(out, resp.Body) if err != nil { return nil, fmt.Errorf("写入文件失败: %v", err) } text := fmt.Sprintf("附件已下载:%s(%s)", savePath, formatSize(written)) return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": text}}, }, nil } // ─── HTTP 辅助 ─── func (p *Plugin) get(url string, out interface{}) error { req, err := http.NewRequest("GET", url, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } return json.NewDecoder(resp.Body).Decode(out) } // httpError 带状态码的 HTTP 错误。 // // 为什么要结构化:调用方需要区分「永久失败」与「暂时失败」 // (见 IsPermanentFailure)。把状态码埋在 error 文本里,调用方只能 // strings.Contains("HTTP 400") —— 那会在报文变化时静默失效。 type httpError struct { Status int Path string Body string } func (e *httpError) Error() string { return fmt.Sprintf("POST %s HTTP %d: %s", e.Path, e.Status, e.Body) } // statusOf 从 error 里取 HTTP 状态码;不是 httpError 时返回 0(按网络层错误处理)。 func statusOf(err error) int { var he *httpError if errors.As(err, &he) { return he.Status } return 0 } func (p *Plugin) post(path string, payload interface{}, out interface{}) error { data, err := json.Marshal(payload) if err != nil { return err } url := path if !strings.HasPrefix(path, "http") { url = p.gwURL + "/api/v1" + path } req, err := http.NewRequest("POST", url, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return &httpError{Status: resp.StatusCode, Path: path, Body: string(body)} } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } func (p *Plugin) put(path string, payload interface{}, out interface{}) error { data, err := json.Marshal(payload) if err != nil { return err } url := path if !strings.HasPrefix(path, "http") { url = p.gwURL + "/api/v1" + path } req, err := http.NewRequest("PUT", url, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("PUT %s HTTP %d: %s", path, resp.StatusCode, string(body)) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } func (p *Plugin) delete(path string) error { url := path if !strings.HasPrefix(path, "http") { url = p.gwURL + "/api/v1" + path } req, err := http.NewRequest("DELETE", url, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+p.key) resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("DELETE %s HTTP %d: %s", path, resp.StatusCode, string(body)) } return nil } func (p *Plugin) markRead(ids []string) { data, _ := json.Marshal(map[string]interface{}{"mail_ids": ids}) req, err := http.NewRequest("POST", p.gwURL+"/api/v1/mail/read", bytes.NewReader(data)) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.key) p.client.Do(req) } // scopeQuery 拼读类端点的**会话收窄参数**(`?session_id=…` / `&session_id=…`, // 由调用方给分隔符)。 // // 与 inboxURL 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信),但服务端现在 // 拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。为什么必须 // 有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空),不收窄时在 // TrueAgent 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 // // 不在回合里时 currentSessionID 为空,返回空串 —— 退回旧行为(服务端会记警告), // 而不是猜一个会话。 // defaultFallbackWorkspace 必须与 handler.DefaultFallbackWorkspace 一致 // (非邮件轮次没有真实工作目录,而 /tmp 是中性落点且能被寻址补投)。 const defaultFallbackWorkspace = "/tmp" func (p *Plugin) scopeQuery(sep string) string { // ★ 2026-10-02:非邮件轮次不再「整体放弃收窄」—— // // 旧写法在 currentSessionID 为空时直接返回 "",于是 URL 里既没有 session_id // 也没有 workspace。而服务端 `AgentMayReadSession` 对未声明 session_id 的 // 读信请求一律 403(2026-09-15 那个「迁移期放行」已作废,实测曾 20/20 读到别人的信)。 // // 现在:session_id 走 effectiveSessionID(信封 → 默认会话), // workspace 走 effectiveWorkspace(信封 → cwd)。两者都拿不到时才放弃。 sid := p.effectiveSessionID() if sid == "" { return "" } q := sep + "session_id=" + url.QueryEscape(sid) if ws := p.effectiveWorkspace(); ws != "" { q += "&workspace=" + url.QueryEscape(ws) } return q } // effectiveSessionID 返回本轮该声明的会话:信封优先,**其次向服务端问默认会话**。 // // # 为什么要回落(与 effectiveWorkspace 同一形状,但答案在服务端) // // 工具全局可调 ⇒ 非邮件轮次(对话里自主调 read_mail/read_thread)时 // currentSessionID 为空 ⇒ 服务端 403。而 cwd 给不出会话 UUID, // 所以只能问服务端:`GET /agent/session/default?workspace=/tmp`。 // // 那个端点是**纯只读**的(没通信过就返回 session_id=null,不建会话)—— // 所以这里拿不到是正常形状:默认收件箱本来就空,不该编一个 id 去声明。 // // 缓存:问出来的结果按进程缓存。理由是它极少变化(默认会话只在真的往 // /tmp 发过信之后才存在),而每个工具调用都问一次会给服务端添无谓压力。 // 缓存 null 也要记 —— 否则「还没有默认会话」会被反复重问。 // ⚠ 这里**不发请求**:它是取值函数,与 effectiveWorkspace 同形状。 // 我第一版把 HTTP 塞进来,于是 `&Plugin{}` 构造的测试直接 nil panic // (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」—— // 取值与 IO 混在一起,很难说清什么时候有副作用。 // // 所以由调用方在**装配期**调一次 ensureDefaultSession(见下), // 这里只读缓存值。 func (p *Plugin) effectiveSessionID() string { if sid := strings.TrimSpace(p.currentSessionID); sid != "" { return sid } return p.defaultSessionID } // ensureDefaultSession 在**装配期**问一次「我的默认会话是哪条」。 // // 只问一次的代价:若进程活到默认会话才第一次被建立(有人往 /tmp 发了信), // 它不会自动看到 —— 但那不构成正确性问题:默认值本来就是兜底, // 拿到旧值只是少看一封本该补投的信,而补投走的是 SSE 而不是这里。 // 而每次工具调用都问一次会给服务端添无谓压力,两者取轻。 func (p *Plugin) ensureDefaultSession() { if p.defaultSessionProbed || strings.TrimSpace(p.currentSessionID) != "" { return // 邮件轮次里不需要默认值;已问过也不重复问 } // ⚠ client 未就绪时**不标记已问**:那不是「问过了、答案是空」, // 而是「还没资格问」。标了就会永久缓存空值,之后再也不会问了。 // // 我第一版没这道判据,判据 `TestEnsureDefaultSessionProbesOnlyOnce` 用 // `&Plugin{client: nil}` 直接把它打 panic 出来 —— 而 register() 里就会调它, // 真的会在插件加载阶段崩掉。 if p.client == nil { return } p.defaultSessionProbed = true p.defaultSessionID = p.fetchDefaultSessionID() if p.defaultSessionID != "" { log.Printf("[homeagent-mail-bridge] 非邮件轮次默认会话: %s(%s)", p.defaultSessionID, defaultFallbackWorkspace) } } // fetchDefaultSessionID 向服务端问「我的默认会话是哪条」,失败返回空串。 func (p *Plugin) fetchDefaultSessionID() string { var out struct { SessionID string `json:"session_id"` } url := fmt.Sprintf("%s/api/v1/agent/session/default?workspace=%s", p.gwURL, url.QueryEscape(defaultFallbackWorkspace)) if err := p.get(url, &out); err != nil { // 问不到就当没有:下面的调用会不带 session_id,服务端 403。 // 那是「没收窄」被明确拒绝,而不是被静默放行 —— 与 workspace 同一原则。 log.Printf("[homeagent-mail-bridge] 取默认会话失败(本次不带 session_id): %v", err) return "" } return strings.TrimSpace(out.SessionID) } // effectiveWorkspace 返回本轮该用的工作区:**先信封,再进程 cwd**。 // // 为什么要回落:homeagent 的工具全局可调,而 currentWorkspace 只在 // InjectInputSync 期间有值 —— 只用它 ⇒ 对话里调 read_inbox 必 400 // (实测 2026-10-01:3 次失败 / 0 次成功)。 // // 为什么 cwd 是**正确**的默认值而不是随便猜:homed 按调用上下文以子进程方式 // 拉起插件,那个进程的 cwd 就是**调用方的工作目录**(实测见字段注释)。 // // ⚠ 拿不到时(cwd 不可读)返回空串,**不编造**:服务端会回 400, // 而那正是设计要的「错误可见,好过静默跨工作区拿到别处的信」。 func (p *Plugin) effectiveWorkspace() string { if ws := strings.TrimSpace(p.currentWorkspace); ws != "" { return ws } if wd, err := os.Getwd(); err == nil { return strings.TrimSpace(wd) } return "" } // inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 —— // 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。 // // session_id 只在**正在处理某条会话**时带上;不在回合里(例如桥启动时的自检) // 就是整个 Agent 的收件箱,这也是历史行为。 func (p *Plugin) inboxURL(status string, limit int) string { scope := "" if p.currentSessionID != "" { scope = "&session_id=" + url.QueryEscape(p.currentSessionID) } // ★ workspace 同样要带(见 currentWorkspace 字段的说明)。缺了服务端直接 400 // —— 那是刻意的:旧语义(不带 = 全部工作区)正是用户报的那个越界缺陷。 // 取不到时仍不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。 if ws := p.effectiveWorkspace(); ws != "" { scope += "&workspace=" + url.QueryEscape(ws) } return fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d%s", p.gwURL, status, limit, scope) }