diff --git a/plugins/homeagent-mail-bridge/channel.go b/plugins/homeagent-mail-bridge/channel.go new file mode 100644 index 0000000..ecf3a41 --- /dev/null +++ b/plugins/homeagent-mail-bridge/channel.go @@ -0,0 +1,361 @@ +package main + +// 输出通道(output channel)的实现。 +// +// # 为什么邮件不是一个"工具"就够了 +// +// 内核的回复投递规则写得很直白(见 `internal/config/registry.go` 的内置人格): +// +// 除 webui / cli 这类同步请求通道外,纯文本回复不会自动送达任何通道。 +// 面向 qq、wechat、a2a、acp 等异步通道时,必须显式调用 output_send__{通道名} +// 把内容发出去;只返回纯文本会被直接丢弃。 +// +// 也就是说,内核判断「这一轮到底有没有交付」**只认 `output_send__*`** +// (`isOutputDeliveryTool`)。而 `send_mail` 是个普通工具,长得和 +// `read_inbox` 没有区别 —— 模型用它发完信,内核并不知道回复已经交付, +// 于是照常补一句「请根据以上工具结果继续。」模型把这句话读成「还要再做一步」, +// 而它手里唯一能做的"一步"往往又是再发一封信。 +// +// 这个自我强化循环在 QQ 通道上有过真实事故:单轮 34 次 `output_send__qq`、 +// 持续 514 秒,最后靠插件自己的保险才停下。我们在邮件这边观测到的形态温和 +// 但同源:pi ↔ dsh 来回客套六轮直到撞上 hop 上限。 +// +// # 为什么通道的 type=file 比 attachment_ids 数组更稳 +// +// 附件走 `send_mail(attachment_ids=[...])` 时,模型要把一串 id 填成**数组**。 +// 生产实测(opencode):模型把它写成了 JSON 字符串 +// +// attachment_ids = "[\"10e73e9f-…\"]" +// +// 服务端的严格解码器按契约拒收,模型连试 6 次后放弃整个任务。而输出通道的 +// 文件投递是 `payload=**本地路径**`(一个字符串)配 `type=file` —— +// 结构上就没有"数组被序列化成字符串"这个失败模式。 +// +// 所以这一层不是把 send_mail 换个名字,而是补上两个真实缺口: +// 1. 让内核的交付判定认得我们的邮件(通道名 + `output_send__` 前缀) +// 2. 给附件一条不需要填数组的路径 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// 通道名。按**投递介质**命名,与内核自带的 `qq` / `webui` / `cli` / `acp` / +// `a2a` 一致 —— 内核据此拼出 `output_send__email`, +// 而原先用的 `homeagent` 是 Agent 名,读起来像"给 homeagent 发消息", +// 与通道的语义(往哪儿送)不符。 +// +// ★ **注入来信时必须用同一个名字**(plugin.go 的 `InjectInputSync` 第二个参数)。 +// 内核的约定是「用回复该走的通道名注入」:webui 插件传 `"webui"`, +// clawhubadapter 传它自己的通道名。我们原先两个参数都传插件名 +// (`homeagent-mail-bridge`)—— 那个名字**不是一个已注册的通道**, +// 于是注入来源、模型被告知要用的通道名、实际注册的通道名三者对不上: +// 模型会照着 persona 去调 `output_send__<某个不存在的通道>`,失败后回退到 +// `send_mail`,而内核只按 `output_send__` 前缀判定「本轮是否已交付」—— +// 它看不到交付,于是补一句「请根据以上工具结果继续」,模型又把这句话读成 +// "还要再做一步"。那正是这条通道改造要消掉的自我强化循环。 +const outputChannelName = "email" + +// 能力位掩码。CapText=1 | CapFile=2 | CapImage=4。 +// +// **声明必须与实现一致**:原先声明了 CapFile,handler 却完全不读 `type`, +// 只把 payload 当纯文本发出去 —— 也就是说内核会把这个通道当作"能发文件", +// 而任何 type=file 的调用都会静默地变成一封把**路径当正文**的邮件。 +// 声明一个做不到的能力比不声明更糟:调用方没有任何办法发现。 +const outputChannelCaps = 1 | 2 | 4 + +// outputChannelDesc 是内核通过 `output_send__email_help` 原样回给模型的说明。 +// +// 必须写清 meta 的字段与 type 的取值:模型只能看到这段文字,看不到本文件。 +const outputChannelDesc = "发送邮件。meta JSON:{to, subject, reply_to, cc, body};" + + "type: text(payload 为正文)| file | image(payload 为本地文件路径)。" + + "file/image 会作为附件发出,body(或 meta.body)为正文,可留空。" + +// ChannelMeta 是输出通道 meta 字段的结构。 +type ChannelMeta struct { + To string `json:"to"` + Subject string `json:"subject"` + ReplyTo string `json:"reply_to"` + CC string `json:"cc"` + // Body 只在 type=file/image 时使用:那时 payload 被路径占用, + // 正文只能从这里来。为空则用一句按文件名生成的说明。 + Body string `json:"body"` +} + +// ParseChannelMeta 解析 meta。 +// +// 容错取向与附件 id 那边一致:meta 是模型手写的 JSON,脏一点不该让整次投递报废。 +// **空串与非法 JSON 都返回零值而不报错** —— 后续的 to 校验会给出更有用的错误 +// ("meta 中需要 to 字段"比"JSON 解析失败"更接近用户要修的东西)。 +func ParseChannelMeta(raw string) ChannelMeta { + var m ChannelMeta + s := strings.TrimSpace(raw) + if s == "" { + return m + } + _ = json.Unmarshal([]byte(s), &m) + m.To = strings.TrimSpace(m.To) + m.Subject = strings.TrimSpace(m.Subject) + m.ReplyTo = strings.TrimSpace(m.ReplyTo) + m.CC = strings.TrimSpace(m.CC) + return m +} + +// NormalizeOutputType 把 type 归一。 +// +// 空串按 `text` 处理:内核在 payload 是纯文本时可能根本不传 type +// (`webui` 通道同样把空串当文本),拒收它只会平白废掉一次正常的文本投递。 +// +// `voice` / `audio` 明确不接受:邮件没有语音这个概念,而"悄悄当成文本发出去" +// 会让模型以为语音已送达。宁可报错。 +func NormalizeOutputType(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "text": + return "text", nil + case "file", "image": + return "file", nil + case "voice", "audio": + return "", fmt.Errorf("邮件通道不支持 type=%s(邮件没有语音载体)", raw) + default: + return "", fmt.Errorf("不支持的 type=%q(可用:text / file / image)", raw) + } +} + +// ResolveAttachmentPath 校验 type=file/image 的 payload 指向一个可读的普通文件。 +// +// 只接受**本地已存在的普通文件**,并且对 http(s) URL 给出专门的话: +// 如果只说"文件不存在",一个 URL 看起来就像路径写错了,而真正的原因是 +// 这个通道不做远程拉取(拉取会引入 SSRF 面,不该由插件顺手打开)。 +func ResolveAttachmentPath(payload string) (string, error) { + p := strings.TrimSpace(payload) + if p == "" { + return "", fmt.Errorf("type=file/image 时 payload 必须是本地文件路径") + } + if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") { + return "", fmt.Errorf("payload 是 URL;本通道只接受本地文件路径(请先下载到本地)") + } + st, err := os.Stat(p) + if err != nil { + return "", fmt.Errorf("附件路径不可读:%v", err) + } + if st.IsDir() { + return "", fmt.Errorf("payload 是目录而不是文件:%s", p) + } + return p, nil +} + +// attachmentBodyFor 在 type=file/image 且没给正文时生成一句说明。 +// +// 不生成正文的话会发出一封正文为空的邮件,收件方只看到一个附件, +// 无从知道它为什么被发过来。 +func attachmentBodyFor(meta ChannelMeta, path string) string { + if strings.TrimSpace(meta.Body) != "" { + return meta.Body + } + name := path + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + } + return fmt.Sprintf("(附件:%s)", name) +} + +// DeliveryHint 返回邮件平台的投递指引,拼进注入的提示词里。 +// +// # 为什么必须有这一段 +// +// 内核的系统提示词把规则写死得很硬(「回复投递规则 —— 必读」): +// +// 除 webui / cli 这类同步请求通道外,纯文本回复不会自动送达任何通道。 +// 面向 qq、wechat、a2a、acp 等异步通道时,必须显式调用 output_send__{通道名}。 +// +// 而共用的 `replyInstruction`(四个桥逐字同源)对人来信说的是 +// 「回信不用你自己发,插件会在本轮结束时替你转发」—— 那句话在 dsh / opencode / pi +// 上完全正确(那三个平台没有输出通道的概念), +// 在 HomeAgent 上却与内核的要求相反。实测:模型听我们的,返回纯文本, +// 插件兜底转发(邮件确实到了),但**内核并不知道本轮已交付** —— +// 它只按 `output_send__` 前缀判定(`isOutputDeliveryTool`), +// 于是给下一批工具结果补一句「请根据以上工具结果继续。」。 +// 模型把这句话读成「还要再做一步」,而它手里唯一能做的「一步」往往又是再发一次。 +// +// 所以这里把两条口径对齐:**优先走通道**(内核据此确认已交付,并给出终止许可), +// 不调用时插件的兜底转发依旧生效 —— 两条路不会重复发,因为通道 handler +// 会先 `noteExplicitChannelSend`,后面的自动 relay 据此让位。 +func (p *Plugin) deliveryHint() string { + return "【投递方式】回复请优先调用 output_send__" + outputChannelName + + "(type=text,payload 为正文,meta JSON:{\"to\":…,\"subject\":…,\"reply_to\":…})。" + + "带附件时用 type=file/image,payload 填本地文件路径 —— 不需要把附件 id 拼成数组。" + + "只返回纯文本也能送达(插件会兜底转发),但用通道投递内核才能确认本轮已交付," + + "否则你会被要求「继续」。" +} + +// handleOutputChannel 是注册给内核的输出通道 handler。 +// +// 参数由内核按 SDK 契约给出:payload / type / meta。 +func (p *Plugin) handleOutputChannel(args map[string]interface{}) (interface{}, error) { + payload, _ := args["payload"].(string) + rawType, _ := args["type"].(string) + metaRaw, _ := args["meta"].(string) + + if strings.TrimSpace(payload) == "" { + return nil, fmt.Errorf("payload 不能为空") + } + kind, err := NormalizeOutputType(rawType) + if err != nil { + return nil, err + } + meta := ParseChannelMeta(metaRaw) + if meta.To == "" { + return nil, fmt.Errorf("meta 中需要 to 字段(形如 {\"to\":\"jianf\"})") + } + + if kind == "text" { + if err := p.noteExplicitChannelSend(meta.ReplyTo); err != nil { + return nil, err + } + subject := meta.Subject + if subject == "" { + // 回复时主题由服务端按 Re: 规则补,新邮件没有主题会很难检索。 + subject = "(无主题)" + } + if err := p.sendMailFull(meta.To, subject, payload, meta.ReplyTo, meta.CC, nil); err != nil { + return nil, err + } + return map[string]interface{}{"status": "sent", "type": "text"}, nil + } + + // type=file/image:payload 是路径 → 上传 → 作为附件随信发出。 + path, err := ResolveAttachmentPath(payload) + if err != nil { + return nil, err + } + attachmentID, name, size, err := p.uploadFileForTool(path) + if err != nil { + return nil, err + } + if err := p.noteExplicitChannelSend(meta.ReplyTo); err != nil { + return nil, err + } + subject := meta.Subject + if subject == "" { + subject = "(附件:" + name + ")" + } + body := attachmentBodyFor(meta, path) + if err := p.sendMailFull(meta.To, subject, body, meta.ReplyTo, meta.CC, []string{attachmentID}); err != nil { + return nil, err + } + return map[string]interface{}{ + "status": "sent", + "type": "file", + "filename": name, + "size_bytes": size, + "attachment_id": attachmentID, + }, nil +} + +// noteExplicitChannelSend 记录模型这轮自主发过信(B-5.3), +// 让同一封邮件稍后的自动 relay 让位,避免同一件事发两封。 +func (p *Plugin) noteExplicitChannelSend(replyTo string) error { + if replyTo == "" { + return nil + } + p.explicitSendsMu.Lock() + p.explicitSends["homeagent:"+replyTo] = time.Now() + p.explicitSendsMu.Unlock() + return nil +} + +// sendMailFull 是最底层的一次发信调用。 +// +// cc 与 attachmentIDs 为空时给出与从前完全一致的 payload —— 这样 +// send_mail 工具、relay 与输出通道三条路径共用同一份实现, +// 不会出现"通道发的邮件少一个字段"这种分叉。 +func (p *Plugin) sendMailFull(to, subject, body, replyTo, cc string, attachmentIDs []string) error { + payload := map[string]interface{}{ + "to": to, + "subject": subject, + "body": body, + } + if replyTo != "" { + payload["reply_to"] = replyTo + } + if cc != "" { + payload["cc"] = cc + } + if len(attachmentIDs) > 0 { + payload["attachment_ids"] = attachmentIDs + } + if p.currentSessionID != "" { + payload["from_session_id"] = p.currentSessionID + } + return p.post("/mail/send", payload, nil) +} + +// uploadFileForTool 把本地文件上传成待挂载附件,返回 id / 文件名 / 大小。 +// +// 工具路径(upload_attachment)与通道路径(type=file)共用它, +// 因此「解不到 attachment_id 就当场报错」这条纪律只有一份实现。 +// +// 那一条纪律是有来历的:服务端返回的是 `{"attachment":{…}}`, +// 字段**不在**顶层。这里原先按平铺解,三个字段全是零值 —— 上传其实成功了 +// (HTTP 200、文件已落盘、库里已登记),没有任何一层报错,而模型看到的是 +// `id= filename= size=0KB`:它拿着空 id 发不出这个附件, +// 而 24 小时后 GC 会把那个没人引用的文件清掉。 +func (p *Plugin) uploadFileForTool(filePath string) (id, name string, size int64, err error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", "", 0, fmt.Errorf("读取文件失败: %v", err) + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return "", "", 0, fmt.Errorf("创建 multipart 失败: %v", err) + } + if _, err := part.Write(data); err != nil { + return "", "", 0, fmt.Errorf("写入文件数据失败: %v", err) + } + writer.Close() + + req, err := http.NewRequest("POST", p.gwURL+"/api/v1/attachments", &buf) + if err != nil { + return "", "", 0, 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 "", "", 0, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return "", "", 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + 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 "", "", 0, err + } + a := result.Attachment + if a.AttachmentID == "" { + return "", "", 0, fmt.Errorf("上传响应里没有 attachment_id(服务端响应结构可能已变更),附件无法发出") + } + return a.AttachmentID, a.Filename, a.SizeBytes, nil +} diff --git a/plugins/homeagent-mail-bridge/go.mod b/plugins/homeagent-mail-bridge/go.mod index 2a71f33..37ba88e 100644 --- a/plugins/homeagent-mail-bridge/go.mod +++ b/plugins/homeagent-mail-bridge/go.mod @@ -2,6 +2,6 @@ module homeagent-mail-bridge go 1.25.0 -require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 +require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0 -replace gitcode.com/JianFeeeee/homeagent-sdk => /home/program/TrueAgent/third_party/homeagent-sdk +replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0 diff --git a/plugins/homeagent-mail-bridge/plg.json b/plugins/homeagent-mail-bridge/plg.json index c6a0fcf..ae14760 100644 --- a/plugins/homeagent-mail-bridge/plg.json +++ b/plugins/homeagent-mail-bridge/plg.json @@ -2,11 +2,15 @@ "name": "homeagent-mail-bridge", "name_zh": "AgentMail 桥接", "name_en": "homeagent", - "version": "0.1.0", + "version": "0.2.2", + "sdk": "1.2.0", "description": "HomeAgent 接入 AgentMail:邮件驱动的多智能体协作", "author": "AgentMail", "entry": "plugin.bin", - "tags": ["mail", "agentmail"], + "tags": [ + "mail", + "agentmail" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": false, diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 4c1dca2..9b82f9c 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -436,10 +436,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Parameters: oneStringParam("event_id", "要删哪条(用 list_schedules 查)", true), }, p.handleDeleteSchedule) - // 注册输出通道 - s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile, - "发送邮件。meta JSON 格式:{to, subject, reply_to},type: text", - sdk.ChannelDef{}, p.handleOutputChannel) + // 注册输出通道。 + // + // 名字按**投递介质**取(`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 的调用数派生,不硬编码。 // @@ -623,14 +630,15 @@ func (p *Plugin) catchUp(pending int) { "%s\n\n"+ "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ - "%s", + "%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 - reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt) p.currentSessionID = "" if reply == "" { // B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。 @@ -857,56 +865,25 @@ func (p *Plugin) parseSSELine(line string) { // ─── 输出通道(agent 主动发信)─── -func (p *Plugin) handleOutputChannel(args map[string]interface{}) (interface{}, error) { - payload, _ := args["payload"].(string) - meta, _ := args["meta"].(string) - if payload == "" { - return nil, fmt.Errorf("payload 不能为空") - } - - var m struct { - To string `json:"to"` - Subject string `json:"subject"` - ReplyTo string `json:"reply_to"` - } - if meta != "" { - json.Unmarshal([]byte(meta), &m) - } - if m.To == "" { - return nil, fmt.Errorf("meta 中需要 to 字段") - } - - // B-5.3:记录模型自主发信,后续自动 relay 时跳过 - rk := m.ReplyTo - if rk != "" { - p.explicitSendsMu.Lock() - p.explicitSends["homeagent:"+rk] = time.Now() - p.explicitSendsMu.Unlock() - } - - if err := p.sendMail(m.To, m.Subject, payload, m.ReplyTo, ""); err != nil { - return nil, err - } - return map[string]interface{}{"status": "sent"}, nil -} - // ─── 发信辅助 ─── - -// sendMail 发一封普通邮件(不带 relay 标记)。 -// 用于模型主动调 send_mail 或 output_send 时。 +// +// `sendMail` / `sendMailRelay` 是最底层的一次发信调用,见 channel.go 的 +// `sendMailFull`:输出通道、send_mail 工具与 relay 三条路径共用它, +// 不会出现「通道发的邮件少一个字段」这种分叉。 func (p *Plugin) sendMail(to, subject, body, replyTo, sessionAlias string) error { - payload := map[string]interface{}{ - "to": to, - "subject": subject, - "body": body, - } - if replyTo != "" { - payload["reply_to"] = replyTo - } if sessionAlias != "" { - payload["session_alias"] = 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.post("/mail/send", payload, nil) + return p.sendMailFull(to, subject, body, replyTo, "", nil) } // sendMailRelay 发一封带 relay:"summary" 标记的邮件。 @@ -974,17 +951,18 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { "%s\n\n"+ "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n%s\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ - "%s\n%s", + "%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 - reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt) p.currentSessionID = "" // B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话) @@ -1061,7 +1039,7 @@ func (p *Plugin) handlePermissionDecision(evt mailEvent) { "你之前发起的权限请求已有结论:%s(决策人:%s)。请据此继续。", evt.Subject, evt.FromName, ) - p.sdk.InjectText(p.name, p.name, prompt) + p.sdk.InjectText(p.name, outputChannelName, prompt) } // ─── 工具实现 ─── diff --git a/plugins/homeagent-mail-bridge/plugin.json b/plugins/homeagent-mail-bridge/plugin.json index 844f01d..0ea2f5b 100644 --- a/plugins/homeagent-mail-bridge/plugin.json +++ b/plugins/homeagent-mail-bridge/plugin.json @@ -5,9 +5,10 @@ "name": "homeagent-mail-bridge", "name_en": "homeagent", "name_zh": "AgentMail 桥接", + "sdk": "1.2.0", "tags": [ "mail", "agentmail" ], - "version": "0.1.0" + "version": "0.2.2" } \ No newline at end of file