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:
@ -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)
|
||||
}
|
||||
|
||||
// ─── 工具实现 ───
|
||||
|
||||
Reference in New Issue
Block a user