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

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