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:
2026-09-12 17:38:32 +08:00
parent 3a6d572020
commit 10ff50e899
5 changed files with 404 additions and 60 deletions

View 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。
//
// **声明必须与实现一致**:原先声明了 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
}

View File

@ -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

View File

@ -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,

View File

@ -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)
}
// ─── 工具实现 ───

View File

@ -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"
}