feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
125
plugins/homeagent-mail-bridge/bounded.go
Normal file
125
plugins/homeagent-mail-bridge/bounded.go
Normal file
@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
// 有界去重表 —— 三个 Node 插件里 `lib/bounded.js` 的 Go 对应物。
|
||||
//
|
||||
// **不能共用那个文件**(homeagent 是 Go 子进程插件),但要解决的问题完全一样:
|
||||
// `deliveredMails` 是「这封邮件我处理过吗」的记忆,键来自 SSE 事件流,
|
||||
// 而插件跟着 homed 长期活着 —— 邮件数单调增长,键却从来没有出口。
|
||||
//
|
||||
// # 为什么 Go 侧不做 LRU
|
||||
//
|
||||
// Node 的 `Map` 保证插入顺序,所以那边「删掉再插入」就等于「移到队尾」,
|
||||
// LRU 几乎免费。Go 的 map **不保证遍历顺序**,做 LRU 要额外维护一个链表。
|
||||
//
|
||||
// 这里不值得:`deliveredMails` 防的两种重复(SSE 重放、心跳与建连之间的窗口)
|
||||
// 都发生在秒到分钟级,先进先出(丢最早插入的)与丢最久未访问的在这个场景下
|
||||
// 没有可观察的差别。而跨进程、跨天的去重本来就由 `ledger`(落盘,14 天保留期)
|
||||
// 负责,这张表只是同进程内的快速路径。
|
||||
//
|
||||
// # 为什么不是「攒满就整表清空」
|
||||
//
|
||||
// 整表清空会在那一刻把**全部**记忆丢掉,于是紧接着到达的 SSE 重放会被当成
|
||||
// 新邮件全部重投一遍 —— 一次性放大成一批重复投递。FIFO 每次只丢最老的一条,
|
||||
// 而最老的那条恰好是最不可能再出现的。
|
||||
|
||||
// maxTrackedMails 是同进程内已投递邮件 id 的记忆上限。
|
||||
//
|
||||
// 与 Node 侧的 MAX_TRACKED_MAILS 取同一个数:SSE 重放最多回放服务端环形缓冲的
|
||||
// 500 条事件,一次补拉最多 5 封(catchupLimit)。2000 是三个数量级的余量,
|
||||
// 内存代价约 200KB。
|
||||
const maxTrackedMails = 2000
|
||||
|
||||
// boundedIDSet 是一个带 FIFO 上限的字符串集合。
|
||||
//
|
||||
// 非并发安全:调用方(plugin.go)已经用 sseMu 保护着它,
|
||||
// 自带一把锁只会让「到底该拿哪把锁」变得含糊。
|
||||
type boundedIDSet struct {
|
||||
limit int
|
||||
seen map[string]struct{}
|
||||
// order 记录插入顺序,用来知道该丢谁。
|
||||
//
|
||||
// 用 slice 而不是 container/list:上限只有 2000,切片头部推进的代价
|
||||
// (一次 append + 一个下标)远小于链表节点的分配开销。
|
||||
order []string
|
||||
// head 是 order 里第一个仍然有效的下标。丢弃时只推进它,不做 order[1:] ——
|
||||
// 后者每次都要搬移整个底层数组。
|
||||
head int
|
||||
// evicted 累计淘汰条数,观测用。
|
||||
evicted int
|
||||
}
|
||||
|
||||
func newBoundedIDSet(limit int) *boundedIDSet {
|
||||
// 上限非法时回落到 1 而不是 panic:这张表是优化项,配错了应当退化成
|
||||
// 「只记得最后一条」(多几次重复投递),而不是让插件起不来。
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return &boundedIDSet{
|
||||
limit: limit,
|
||||
seen: make(map[string]struct{}, limit),
|
||||
order: make([]string, 0, limit),
|
||||
}
|
||||
}
|
||||
|
||||
// has 报告这个 id 是否已经记住过。
|
||||
func (s *boundedIDSet) has(id string) bool {
|
||||
if s == nil || id == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := s.seen[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// add 记住一个 id,并在超限时丢掉最早插入的那些。
|
||||
//
|
||||
// 返回值是「这次调用**新加入**了吗」—— 调用方常常想在一次操作里同时完成
|
||||
// 「查重」与「登记」,分两步做需要两次加锁或一段不必要的临界区。
|
||||
func (s *boundedIDSet) add(id string) bool {
|
||||
if s == nil || id == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := s.seen[id]; ok {
|
||||
// 已存在时**不**移到队尾:FIFO 语义下位置由首次插入决定。
|
||||
return false
|
||||
}
|
||||
s.seen[id] = struct{}{}
|
||||
s.order = append(s.order, id)
|
||||
for len(s.order)-s.head > s.limit {
|
||||
oldest := s.order[s.head]
|
||||
s.order[s.head] = "" // 断引用,让字符串可回收
|
||||
s.head++
|
||||
delete(s.seen, oldest)
|
||||
s.evicted++
|
||||
}
|
||||
// 前缀攒到一半以上时压实一次,否则 order 的底层数组会随插入次数无限增长
|
||||
// —— 那正是这张表本来要修的病,只是换了个地方。
|
||||
if s.head > 0 && s.head >= len(s.order)/2 {
|
||||
s.compact()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compact 把 order 重排到从 0 开始,丢掉已淘汰的前缀。
|
||||
//
|
||||
// 容量固定为 `limit*2` 而不是 `cap(live)+limit`:后者里的 `cap(live)` 是
|
||||
// 原切片剩下的容量,而 append 会不断扩容 —— 于是每次压实都把上一轮扩大后的
|
||||
// 容量继承下去,底层数组仍然单调增长(实测灌 1 万条后 cap 到 1130)。
|
||||
// 那正是这张表本来要修的病,只是从 map 换到了切片上。
|
||||
//
|
||||
// limit*2 刚好是下一次触发压实的长度(head 走到 limit 时 len == 2*limit),
|
||||
// 于是 append 在两次压实之间不会扩容。
|
||||
func (s *boundedIDSet) compact() {
|
||||
live := s.order[s.head:]
|
||||
fresh := make([]string, len(live), s.limit*2)
|
||||
copy(fresh, live)
|
||||
s.order = fresh
|
||||
s.head = 0
|
||||
}
|
||||
|
||||
// size 是当前记住的条数,观测与测试用。
|
||||
func (s *boundedIDSet) size() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s.seen)
|
||||
}
|
||||
162
plugins/homeagent-mail-bridge/bounded_test.go
Normal file
162
plugins/homeagent-mail-bridge/bounded_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这些用例钉住的是 bounded.go 的三条性质:封顶、FIFO、以及
|
||||
// 「add 的返回值就是查重结果」—— 后者让调用方能在一把锁里查重 + 登记。
|
||||
|
||||
func TestBoundedSetCapsSize(t *testing.T) {
|
||||
s := newBoundedIDSet(3)
|
||||
for _, id := range []string{"a", "b", "c", "d", "e"} {
|
||||
s.add(id)
|
||||
}
|
||||
if s.size() != 3 {
|
||||
t.Fatalf("上限之后 size 必须封顶,得到 %d —— 这正是泄露的反面", s.size())
|
||||
}
|
||||
if s.has("a") || s.has("b") {
|
||||
t.Error("最早插入的两条应当被淘汰")
|
||||
}
|
||||
for _, id := range []string{"c", "d", "e"} {
|
||||
if !s.has(id) {
|
||||
t.Errorf("%s 应当还在", id)
|
||||
}
|
||||
}
|
||||
if s.evicted != 2 {
|
||||
t.Errorf("evicted 应为 2,得到 %d", s.evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetAddReportsFreshness(t *testing.T) {
|
||||
// 调用方(plugin.go 的两处去重)依赖这个返回值在同一把锁里完成
|
||||
// 「查重 + 登记」。分两步做需要两次加锁,中间那个窗口正是原来的竞态。
|
||||
s := newBoundedIDSet(10)
|
||||
if !s.add("m1") {
|
||||
t.Error("首次 add 应当返回 true(新加入)")
|
||||
}
|
||||
if s.add("m1") {
|
||||
t.Error("重复 add 应当返回 false(已存在)")
|
||||
}
|
||||
if s.size() != 1 {
|
||||
t.Errorf("重复 add 不该占额外位置,size=%d", s.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetFIFONotLRU(t *testing.T) {
|
||||
// Go 的 map 不保证遍历顺序,所以这里是显式的 FIFO 而不是 LRU。
|
||||
// 这条用例把那个决定钉住:反复 has 不会让条目留得更久。
|
||||
s := newBoundedIDSet(2)
|
||||
s.add("a")
|
||||
s.add("b")
|
||||
for i := 0; i < 5; i++ {
|
||||
s.has("a") // 在 LRU 语义下这会保住 a
|
||||
}
|
||||
s.add("c")
|
||||
if s.has("a") {
|
||||
t.Error("FIFO 语义下最早插入的 a 应当被淘汰,has() 不刷新活跃度")
|
||||
}
|
||||
if !s.has("b") || !s.has("c") {
|
||||
t.Error("b 与 c 应当都在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetReAddDoesNotRefreshPosition(t *testing.T) {
|
||||
// 重复 add 也不改变位置(FIFO 由首次插入决定)。写成「已存在时移到队尾」
|
||||
// 会让一条被反复推送的邮件把别的条目挤出去。
|
||||
s := newBoundedIDSet(2)
|
||||
s.add("a")
|
||||
s.add("b")
|
||||
s.add("a") // 已存在,位置不变
|
||||
s.add("c")
|
||||
if s.has("a") {
|
||||
t.Error("a 是最早插入的,重复 add 不该救回它")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetIgnoresEmptyID(t *testing.T) {
|
||||
// 空 mail_id 是「事件残缺」而不是「一封 id 为空的邮件」。
|
||||
// 记住它会让第二封残缺事件被误判成重复。
|
||||
s := newBoundedIDSet(5)
|
||||
if s.add("") {
|
||||
t.Error("空 id 不该被记住")
|
||||
}
|
||||
if s.has("") {
|
||||
t.Error("空 id 永远不算已见过")
|
||||
}
|
||||
if s.size() != 0 {
|
||||
t.Errorf("空 id 不该占位置,size=%d", s.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetNilSafe(t *testing.T) {
|
||||
// 构造函数失败或字段没初始化时不该 panic —— 去重是优化项,
|
||||
// 退化成「什么都不记得」(多几次重复投递)远好过插件崩溃。
|
||||
var s *boundedIDSet
|
||||
if s.has("x") {
|
||||
t.Error("nil 上 has 应为 false")
|
||||
}
|
||||
if s.add("x") {
|
||||
t.Error("nil 上 add 应为 false")
|
||||
}
|
||||
if s.size() != 0 {
|
||||
t.Error("nil 上 size 应为 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetIllegalLimitFallsBackToOne(t *testing.T) {
|
||||
// 上限非法时回落到 1 而不是 panic 或 0。
|
||||
// 0 的后果最隐蔽:每次 add 之后立刻把自己淘汰掉 → 去重全失效且不报错。
|
||||
for _, limit := range []int{0, -1, -100} {
|
||||
s := newBoundedIDSet(limit)
|
||||
s.add("a")
|
||||
if !s.has("a") {
|
||||
t.Errorf("limit=%d:刚加入的那条必须还在(回落到 1,而不是 0)", limit)
|
||||
}
|
||||
s.add("b")
|
||||
if s.size() != 1 {
|
||||
t.Errorf("limit=%d:size 应为 1,得到 %d", limit, s.size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetCompactsOrderSlice(t *testing.T) {
|
||||
// order 切片的底层数组不能随插入次数无限增长 —— 那正是这张表要修的病,
|
||||
// 只是换了个地方(map 有界了,切片没有)。
|
||||
s := newBoundedIDSet(10)
|
||||
for i := 0; i < 10_000; i++ {
|
||||
s.add(fmt.Sprintf("mail-%d", i))
|
||||
}
|
||||
if s.size() != 10 {
|
||||
t.Fatalf("size 应当封顶在 10,得到 %d", s.size())
|
||||
}
|
||||
// 压实之后 order 的有效长度不该远大于上限
|
||||
if live := len(s.order) - s.head; live > 10 {
|
||||
t.Errorf("order 有效长度 %d 超过上限 10", live)
|
||||
}
|
||||
if len(s.order) > 10*4 {
|
||||
t.Errorf("order 底层长度 %d 相对上限 10 增长失控(压实没生效)", len(s.order))
|
||||
}
|
||||
if cap(s.order) > 10*8 {
|
||||
t.Errorf("order 容量 %d 相对上限 10 增长失控", cap(s.order))
|
||||
}
|
||||
// 最新的必须还在,最老的必须没了
|
||||
if !s.has("mail-9999") {
|
||||
t.Error("最新的那条必须还在")
|
||||
}
|
||||
if s.has("mail-0") {
|
||||
t.Error("最老的那条必须已被淘汰")
|
||||
}
|
||||
if s.evicted != 9990 {
|
||||
t.Errorf("evicted 应为 9990,得到 %d", s.evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedSetLimitMatchesNodeSide(t *testing.T) {
|
||||
// 与 Node 侧 lib/bounded.js 的 MAX_TRACKED_MAILS 取同一个数。
|
||||
// 四个平台在同一套语义下运行,一侧偷偷调小会让「重复投递」只在那个平台出现。
|
||||
if maxTrackedMails != 2000 {
|
||||
t.Errorf("maxTrackedMails 应为 2000(与 Node 侧一致),得到 %d", maxTrackedMails)
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@ -87,7 +88,10 @@ type Plugin struct {
|
||||
//
|
||||
// 这只挡得住**本进程内**的重复。跨进程(homed 重启、插件子进程被换)
|
||||
// 靠 ledger —— 它落盘,且区分「投过」与「跑完」。
|
||||
deliveredMails map[string]bool
|
||||
//
|
||||
// 有界(见 bounded.go):插件跟着 homed 长期活着,普通 map 会攒下每一封
|
||||
// 处理过的邮件 id 而永远没有出口。
|
||||
deliveredMails *boundedIDSet
|
||||
|
||||
// 跨进程投递账本(见 ledger.go)。
|
||||
//
|
||||
@ -95,6 +99,14 @@ type Plugin struct {
|
||||
// 前者回答的是「上一个进程有没有已经把这封跑完」。
|
||||
ledger *deliveryLedger
|
||||
|
||||
// currentSessionID 是当前正在处理的邮件所属的 agentmail 会话 ID。
|
||||
//
|
||||
// homeagent 是单事件循环(所有邮件共享一个 turn),同一时刻只处理一封信。
|
||||
// 模型调 send_mail 时,Gateway 需要知道「这封信是从哪条会话里发出的」
|
||||
// 才能用 InheritedMode 继承档位。SDK 的工具 handler 不传 session 上下文,
|
||||
// 所以靠这个字段做桥接。
|
||||
currentSessionID string
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
// 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID,
|
||||
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
|
||||
@ -180,7 +192,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
sseClient: &http.Client{}, // 无超时:SSE 是长连接
|
||||
stopCh: make(chan struct{}),
|
||||
deliveredMails: make(map[string]bool),
|
||||
deliveredMails: newBoundedIDSet(maxTrackedMails),
|
||||
explicitSends: make(map[string]time.Time),
|
||||
}, nil
|
||||
}
|
||||
@ -246,6 +258,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"body": map[string]interface{}{"type": "string", "description": "邮件正文(Markdown)"},
|
||||
"cc": map[string]interface{}{"type": "string", "description": "抄送"},
|
||||
"reply_to": map[string]interface{}{"type": "string", "description": "回复某封邮件时传其 mail_id"},
|
||||
// 字段名必须是 attachment_ids、元素必须是裸 id 字符串 —— 逐字对齐服务端
|
||||
// SendMailRequest.AttachmentIDs。服务端解请求体时没开 DisallowUnknownFields,
|
||||
// 所以字段名错了是**静默丢附件**而不是报错:实测传
|
||||
// attachments:[{"attachment_id":…}] 返回 200,那封邮件的附件数是 0。
|
||||
"attachment_ids": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
"description": "附件 ID 列表(先用 upload_attachment 上传取得)",
|
||||
},
|
||||
},
|
||||
"required": []string{"to", "subject", "body"},
|
||||
},
|
||||
@ -270,7 +291,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
registerTool("upload_attachment", sdk.ToolDef{
|
||||
Name: "upload_attachment",
|
||||
Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachments 字段。",
|
||||
Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachment_ids 字段。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -536,6 +557,9 @@ func (p *Plugin) catchUp(pending int) {
|
||||
// parent_mail_id 非空 = 这封是回信。收件箱返回的字段名是它,
|
||||
// 而 SSE 事件里叫 in_reply_to —— 两个名字指同一件事。
|
||||
ParentMailID string `json:"parent_mail_id"`
|
||||
// from_session_id 用于档位继承:模型调 send_mail 时,Gateway 据此
|
||||
// 从来源会话继承权限档位(InheritedMode)。
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"mails"`
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit)
|
||||
@ -559,12 +583,10 @@ func (p *Plugin) catchUp(pending int) {
|
||||
// 必须在循环里逗封查而不是拉完一批再筛:InjectInputSync 一封要跑
|
||||
// 几十秒,那期间 SSE 完全可能已经投过后面那几封。
|
||||
p.sseMu.Lock()
|
||||
dup := p.deliveredMails[m.MailID]
|
||||
if !dup {
|
||||
p.deliveredMails[m.MailID] = true
|
||||
}
|
||||
// add 返回「本次是否新加入」,于是查重与登记在同一把锁里一步完成。
|
||||
fresh := p.deliveredMails.add(m.MailID)
|
||||
p.sseMu.Unlock()
|
||||
if dup {
|
||||
if !fresh {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -606,7 +628,9 @@ func (p *Plugin) catchUp(pending int) {
|
||||
replyInstruction(m.FromHuman, ""),
|
||||
)
|
||||
|
||||
p.currentSessionID = m.SessionID
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
|
||||
p.currentSessionID = ""
|
||||
if reply == "" {
|
||||
// B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。
|
||||
p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复")
|
||||
@ -614,7 +638,7 @@ func (p *Plugin) catchUp(pending int) {
|
||||
continue
|
||||
}
|
||||
// B-5.3:检查模型是否已经自己发过信
|
||||
rk := "homeagent:" + m.MailID
|
||||
rk := ClampRelayKey("homeagent:" + m.MailID)
|
||||
p.explicitSendsMu.Lock()
|
||||
_, sent := p.explicitSends[rk]
|
||||
p.explicitSendsMu.Unlock()
|
||||
@ -637,8 +661,16 @@ func (p *Plugin) catchUp(pending int) {
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额
|
||||
if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil {
|
||||
// 永久失败(4xx)重试一万次也是同一个结果 —— 标完成,否则
|
||||
// 每次重启都重跑一遍模型再碰同一堆墙(烧 token 且永不收敛)。
|
||||
// 暂时失败(5xx / 网络)不标,下次重启重试。
|
||||
if st := statusOf(err); IsPermanentFailure(st) {
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err)
|
||||
p.ledger.complete(m.MailID)
|
||||
continue
|
||||
}
|
||||
// 回信没发出去 —— 不标完成,下次重启重试。
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信失败(不标完成): %v", err)
|
||||
log.Printf("[homeagent-mail-bridge] 补投回信暂时失败(不标完成): %v", err)
|
||||
continue
|
||||
}
|
||||
p.ledger.complete(m.MailID)
|
||||
@ -794,12 +826,11 @@ func (p *Plugin) parseSSELine(line string) {
|
||||
// B-7.3:去重。SSE 重放时同一封邮件会再出现,没有这层
|
||||
// 每封邮件会被注入 agent 两遍(实测 21 次超时 → 21 次重放)。
|
||||
p.sseMu.Lock()
|
||||
if p.deliveredMails[evt.MailID] {
|
||||
p.sseMu.Unlock()
|
||||
fresh := p.deliveredMails.add(evt.MailID)
|
||||
p.sseMu.Unlock()
|
||||
if !fresh {
|
||||
return
|
||||
}
|
||||
p.deliveredMails[evt.MailID] = true
|
||||
p.sseMu.Unlock()
|
||||
|
||||
// 跨进程去重:上一个插件子进程可能已经把这封跑完了。
|
||||
// deliveredMails 只在本进程内有效,homed 重启会把它清空 ——
|
||||
@ -908,7 +939,7 @@ func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) {
|
||||
"请稍后重试,或通过其他方式联系。",
|
||||
subject, reason,
|
||||
)
|
||||
rk := "homeagent:failure:" + replyTo
|
||||
rk := ClampRelayKey("homeagent:failure:" + replyTo)
|
||||
if err := p.sendMailRelay(to, "Re: "+subject, body, replyTo, rk); err != nil {
|
||||
log.Printf("[homeagent-mail-bridge] 失败通知发送失败: %v", err)
|
||||
}
|
||||
@ -946,7 +977,10 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
)
|
||||
|
||||
// InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。
|
||||
// 工具 handler 没有独立的 session 上下文,因此在本轮处理期间暂存来源会话。
|
||||
p.currentSessionID = evt.SessionID
|
||||
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
|
||||
p.currentSessionID = ""
|
||||
|
||||
// B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话)
|
||||
if reply == "" {
|
||||
@ -961,7 +995,7 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
}
|
||||
|
||||
// B-5.3:检查模型是否已经自己发过信(通过 send_mail 或 output_send)
|
||||
rk := "homeagent:" + evt.MailID
|
||||
rk := ClampRelayKey("homeagent:" + evt.MailID)
|
||||
p.explicitSendsMu.Lock()
|
||||
_, sent := p.explicitSends[rk]
|
||||
if sent {
|
||||
@ -990,9 +1024,16 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
|
||||
// B-5.2:自动回信带 relay:"summary" + relay_key
|
||||
if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil {
|
||||
// 回信没发出去 —— **不标完成**,让下次重启能重试。
|
||||
// 永久失败(4xx)标完成:重试不会变好,不标的话每次重启
|
||||
// 都重跑一遍模型再碰同一堆墙。
|
||||
if st := statusOf(err); IsPermanentFailure(st) {
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err)
|
||||
p.ledger.complete(evt.MailID)
|
||||
return
|
||||
}
|
||||
// 暂时失败 → **不标完成**,让下次重启能重试。
|
||||
// 发件人至今一个字都没收到,这时标「已完成」就是静默丢件。
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信失败(不标完成,下次会重试): %v", err)
|
||||
log.Printf("[homeagent-mail-bridge] 自动回信暂时失败(不标完成,下次会重试): %v", err)
|
||||
} else {
|
||||
log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply))
|
||||
p.ledger.complete(evt.MailID)
|
||||
@ -1079,7 +1120,7 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro
|
||||
fn, _ := att["filename"].(string)
|
||||
sz, _ := att["size_bytes"].(float64)
|
||||
aid, _ := att["attachment_id"].(string)
|
||||
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", fn, sz/1024, aid)
|
||||
fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", fn, formatSize(int64(sz)), aid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1134,12 +1175,20 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
|
||||
"subject": subj,
|
||||
"body": body,
|
||||
}
|
||||
if p.currentSessionID != "" {
|
||||
payload["from_session_id"] = p.currentSessionID
|
||||
}
|
||||
if cc != "" {
|
||||
payload["cc"] = cc
|
||||
}
|
||||
if replyTo != "" {
|
||||
payload["reply_to"] = replyTo
|
||||
}
|
||||
// 附件必须由 send_mail 带上:上传只是把文件登记成「待挂载」,
|
||||
// 24 小时内没有任何邮件引用它就会被 GC 清掉。
|
||||
if ids := stringList(args["attachment_ids"]); len(ids) > 0 {
|
||||
payload["attachment_ids"] = ids
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := p.post("/mail/send", payload, &result); err != nil {
|
||||
@ -1161,7 +1210,7 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
|
||||
// C-14 附件上传 —— 真 multipart,不是桩。
|
||||
//
|
||||
// 读取本地文件 → 构造 multipart/form-data → POST /api/v1/attachments。
|
||||
// 返回 attachment_id,填入 send_mail 的 attachments 字段。
|
||||
// 返回 attachment_id,填入 send_mail 的 attachment_ids 字段。
|
||||
func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{}, error) {
|
||||
filePath, _ := args["file_path"].(string)
|
||||
if filePath == "" {
|
||||
@ -1203,17 +1252,33 @@ func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 服务端返回的是 {"attachment":{…}},字段**不在**顶层。
|
||||
//
|
||||
// 这里原先按平铺解,于是三个字段全是零值。那是最坏的一种失败:上传其实
|
||||
// 成功了(HTTP 200、文件已落盘、库里已登记),没有任何一层报错,但模型
|
||||
// 看到的是 `id= filename= size=0KB` —— 拿着空 id 它没法发出这个附件,
|
||||
// 而 24 小时后 GC 会把那个没人引用的文件清掉。
|
||||
var result struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
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 nil, err
|
||||
}
|
||||
a := result.Attachment
|
||||
// 解出空 id 说明响应结构又变了,必须当场报错。回一句「已上传」配一个空 id
|
||||
// 只会让模型接着去发信,然后收到一封没有附件的邮件 —— 那正是上面那个 bug
|
||||
// 之所以能存活的原因。
|
||||
if a.AttachmentID == "" {
|
||||
return nil, fmt.Errorf("上传响应里没有 attachment_id(服务端响应结构可能已变更),附件无法发出")
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("附件已上传:id=%s filename=%s size=%dKB\n在 send_mail 的 attachments 字段传 [{\"attachment_id\":\"%s\"}]",
|
||||
result.AttachmentID, result.Filename, result.SizeBytes/1024, result.AttachmentID)
|
||||
text := fmt.Sprintf("附件已上传:%s(%s)。attachment_id: %s\n"+
|
||||
"在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出:attachment_ids=[\"%s\"]",
|
||||
a.Filename, formatSize(a.SizeBytes), a.AttachmentID, a.AttachmentID)
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||||
}, nil
|
||||
@ -1260,7 +1325,7 @@ func (p *Plugin) handleDownloadAttachment(args map[string]interface{}) (interfac
|
||||
return nil, fmt.Errorf("写入文件失败: %v", err)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("附件已下载:%s(%dKB)", savePath, written/1024)
|
||||
text := fmt.Sprintf("附件已下载:%s(%s)", savePath, formatSize(written))
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||||
}, nil
|
||||
@ -1288,6 +1353,30 @@ func (p *Plugin) get(url string, out interface{}) error {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// httpError 带状态码的 HTTP 错误。
|
||||
//
|
||||
// 为什么要结构化:调用方需要区分「永久失败」与「暂时失败」
|
||||
// (见 IsPermanentFailure)。把状态码埋在 error 文本里,调用方只能
|
||||
// strings.Contains("HTTP 400") —— 那会在报文变化时静默失效。
|
||||
type httpError struct {
|
||||
Status int
|
||||
Path string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return fmt.Sprintf("POST %s HTTP %d: %s", e.Path, e.Status, e.Body)
|
||||
}
|
||||
|
||||
// statusOf 从 error 里取 HTTP 状态码;不是 httpError 时返回 0(按网络层错误处理)。
|
||||
func statusOf(err error) int {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
return he.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@ -1313,7 +1402,7 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s HTTP %d: %s", path, resp.StatusCode, string(body))
|
||||
return &httpError{Status: resp.StatusCode, Path: path, Body: string(body)}
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
|
||||
@ -69,8 +69,9 @@ func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error
|
||||
if len(data.Mail.Attachments) > 0 {
|
||||
fmt.Fprintf(&sb, "附件:\n")
|
||||
for _, a := range data.Mail.Attachments {
|
||||
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID)
|
||||
fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", a.Filename, formatSize(int64(a.SizeBytes)), a.AttachmentID)
|
||||
}
|
||||
sb.WriteString(" 用 download_attachment 取回(传 attachment_id 与 save_path)\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user