Files
MailUI4Agents/plugins/homeagent-mail-bridge/channel.go
JianFeeeee 10ff50e899 feat(homeagent): 输出通道改造 —— 通道名 email + 注入通道对齐 + 附件走路径
修的是内核的交付判定与我们对不上的问题。

## 根因

内核判断「这一轮到底有没有交付」**只认工具名前缀** `output_send__*`
(internal/agent/core/process.go 的 isOutputDeliveryTool)。而 `send_mail`
是个普通工具,长得和 read_inbox 没有区别 —— 模型用它发完信,内核不知道
回复已经交付,照常补一句「请根据以上工具结果继续。」。模型把这句话读成
「还要再做一步」,而它手里唯一能做的「一步」往往又是再发一封信。
这个自我强化循环在 QQ 上有过真实事故(单轮 34 次 output_send__qq、514 秒)。

三处改动,缺一不可:

1. **通道名 `homeagent` → `email`**(按投递介质命名,与内核自带的
   qq/webui/cli/acp/a2a 一致),内核据此拼出 `output_send__email`。
   能力位声明必须与实现一致:原先声明了 CapFile 而 handler 不读 `type`,
   于是任何 type=file 都会静默变成一封「把路径当正文」的邮件 ——
   声明一个做不到的能力比不声明更糟(调用方无从发现)。现在 caps=7
   且真的实现 text/file/image。

2. **注入来信时用通道名,不再用插件名**。内核的约定是「用回复该走的通道名
   注入」:webui 传 "webui",clawhubadapter 传它自己的通道名。我们原先两个
   参数都传 `homeagent-mail-bridge` —— 那个名字**不是一个已注册的通道**,
   于是注入来源、模型被告知要用的通道名、实际注册的通道名三者对不上。

3. **提示词补上平台特有的投递指引**(channel.go 的 deliveryHint)。
   共用的 replyInstruction 对人来信说的是「回信不用你自己发,插件会替你转发」
   —— 那句在 dsh/opencode/pi 上完全正确,在 HomeAgent 上却与内核的 persona
   (「必须显式调用 output_send__{通道名}」)相反。实测:模型听我们的、
   返回纯文本、插件兜底转发,邮件确实到了,但内核不知道已交付。
   现在两条口径对齐:优先走通道,兜底 relay 仍然生效且**不会重复发**
   (通道 handler 先 noteExplicitChannelSend,自动 relay 据此让位)。

同时按新纪律在 plg.json 声明 `sdk: "1.2.0"`;工具链据此同步了 go.mod 的
require/replace(它自己写的,含 store 里的绝对路径 —— 换机器跑一次
hmapdev build 会重新同步)。

## 验证(真模型、真网关、真邮件)

进程构建:`hmapdev build` 报「SDK 1.2.0(项目声明 sdk=1.2.0)」、
子进程模式(协议 2);go vet 干净、go test 通过。

部署:经内核自己的 pluginmgr(POST 127.0.0.1:9876/plugins,overwrite=true)
0.1.0 → 0.2.1 → 0.2.2,每次 config_kept=true;重启后 loaded/alive/心跳齐全,
插件日志「注册完成(15 个工具 + 1 个输出通道)」。

行为端到端(两封真邮件):
- 让模型列出通道 → 回信里列出 `email | text / file / image`,与注册的
  caps=7 及描述逐字一致
- 让模型「原样重复标记」→ 内核日志 `executing tool: output_send__email`,
  回流**恰好 1 封**,插件日志「模型已自行回复,跳过自动 relay」
- 让模型建文件并以 type=file 发送 → `cmd_run` → `output_send__email_help`
  → `output_send__email`,附件 chan-file-*.txt(15 字节)原样到达,relay 让位

这两条正是改造的两个动因:内核认得交付(循环被切断),以及附件走
「一个本地路径字符串」而不是 `attachment_ids=[...]` 数组
(opencode 上模型把数组写成 JSON 字符串、连试 6 次放弃整个任务的那个失败模式,
在结构上就不存在了)。
2026-09-12 17:38:32 +08:00

362 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 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。
//
// **声明必须与实现一致**:原先声明了 CapFilehandler 却完全不读 `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: textpayload 为正文)| file | imagepayload 为本地文件路径)。" +
"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=textpayload 为正文meta JSON{\"to\":…,\"subject\":…,\"reply_to\":…})。" +
"带附件时用 type=file/imagepayload 填本地文件路径 —— 不需要把附件 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/imagepayload 是路径 → 上传 → 作为附件随信发出。
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
}