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 次放弃整个任务的那个失败模式,
在结构上就不存在了)。
This commit is contained in:
361
plugins/homeagent-mail-bridge/channel.go
Normal file
361
plugins/homeagent-mail-bridge/channel.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// ─── 工具实现 ───
|
||||
|
||||
@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user