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 }