diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index f0aff7b..974f4d1 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "log" + "mime/multipart" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -17,9 +19,18 @@ import ( const ( defaultGateway = "http://127.0.0.1:8180" - heartbeatIntval = 25 * time.Second + heartbeatIntval = 30 * time.Second // 契约 B-2 要求 30 秒 sseRetry = 3 * time.Second - mailPollIntval = 15 * time.Second + + // B-5.3 explicitSends 记忆窗口:模型在一轮里发过的信, + // 在该窗口内不再自动 relay 同一封。超过窗口说明那轮已经结束。 + dedupWindow = 10 * time.Minute + + // B-1.6 补投上限(与 DSH/pi 保持一致) + catchupLimit = 5 + + // 连续 relay 跳数上限(与 Gateway 常量一致) + maxRelayHops = 5 ) // Plugin 实现 sdk.Plugin。 @@ -40,33 +51,89 @@ type Plugin struct { sdk *sdk.PluginSDK gwURL string key string + keyFile string // B-1.1 密钥文件路径 client *http.Client stopCh chan struct{} stopOnce sync.Once + + // W-4 SSE 重连 —— 断线期间的事件会丢,带上 Last-Event-ID 可以补回 + lastEventID string + sseMu sync.Mutex // 保护 lastEventID + + // B-5.3 explicitSends:模型在当前 turn 里通过 send_mail/output_send 发过的 + // 邮件 ID(relay_key 格式)。自动 relay 前查这个表,已有则让位。 + // + // 为什么不是按 session 隔离:TrueAgent 是单事件循环,所有邮件共享一个 turn。 + // 模型如果调了 send_mail 回给发件人,那就是它自己的回复,不该再 relay。 + explicitSends map[string]time.Time + explicitSendsMu sync.Mutex + + // B-2.2 模型目录缓存 + modelCatalog []string + + // B-1.6 补拉状态:首个成功心跳后只补一次 + catchupDone bool } +// ─── B-1.1 密钥解析与本地生成 ─── +// +// 契约要求:环境变量 → ~/.agentmail/agent.key → 本地生成一把。 +// 本地生成时打印到 stderr(进 journalctl),落盘到 key 文件(0600)。 +// 这样管理员拿到日志里的密钥全文去后台登记,下次重启就不再需要环境变量。 + +func resolveKey() (key, keyFile string) { + keyFile = os.Getenv("AGENTMAIL_CONFIG_DIR") + if keyFile == "" { + home, _ := os.UserHomeDir() + keyFile = filepath.Join(home, ".agentmail") + } + keyFile = filepath.Join(keyFile, "agent.key") + + // 1. 环境变量 + if k := strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_KEY")); k != "" { + return k, keyFile + } + + // 2. 本地文件 + if data, err := os.ReadFile(keyFile); err == nil { + k := strings.TrimSpace(string(data)) + if k != "" { + return k, keyFile + } + } + + // 3. 本地生成(ak_ 前缀 + 24 字节 hex,与 dsh 保持一致) + b := make([]byte, 24) + for i := range b { + b[i] = "0123456789abcdef"[time.Now().UnixNano()%16] + time.Sleep(1) + } + k := "ak_" + fmt.Sprintf("%x", b) + + dir := filepath.Dir(keyFile) + os.MkdirAll(dir, 0700) + os.WriteFile(keyFile, []byte(k), 0600) + + // 契约 9.8:密钥打印到 stderr 进 journalctl,不走平台 logger + fmt.Fprintf(os.Stderr, "[homeagent-mail-bridge] 本地生成密钥,请让管理员在 AgentMail 后台「Agent 密钥」中登记:\n%s\n文件:%s\n", k, keyFile) + return k, keyFile +} + +// ─── 工厂 ─── + func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { gw := "" - key := "" if v, ok := config["gateway_url"].(string); ok { gw = v } - if v, ok := config["gateway_key"].(string); ok { - key = v - } if gw == "" { gw = os.Getenv("AGENTMAIL_GATEWAY_URL") } if gw == "" { gw = defaultGateway } - if key == "" { - key = os.Getenv("AGENTMAIL_AGENT_KEY") - } - // AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。 - // 兜底那条让默认配置能直接跑通(homeagent-mail-bridge → homeagent), - // 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。 + // AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀 agentName := "" if v, ok := config["agent_name"].(string); ok { agentName = strings.TrimSpace(v) @@ -79,12 +146,14 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e } return &Plugin{ - name: name, - agentName: agentName, - gwURL: strings.TrimRight(gw, "/"), - key: key, - client: &http.Client{Timeout: 30 * time.Second}, - stopCh: make(chan struct{}), + name: name, + agentName: agentName, + gwURL: strings.TrimRight(gw, "/"), + key: "", // Start() 里解析 + keyFile: "", + client: &http.Client{Timeout: 60 * time.Second}, + stopCh: make(chan struct{}), + explicitSends: make(map[string]time.Time), }, nil } @@ -94,26 +163,29 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s s.SetAutoRestart(true) - // 注册读取工具 + // B-1.1 解析密钥(环境变量 → 本地文件 → 生成) + p.key, p.keyFile = resolveKey() + + // ─── 注册工具 ─── + s.RegisterTool("read_inbox", sdk.ToolDef{ Name: "read_inbox", Description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "status": map[string]interface{}{ - "type": "string", - "description": "过滤条件 unread|all,默认 unread", - }, - "limit": map[string]interface{}{ - "type": "number", - "description": "返回数量,默认 5", - }, + "status": map[string]interface{}{"type": "string", "description": "过滤条件 unread|all,默认 unread"}, + "limit": map[string]interface{}{"type": "number", "description": "返回数量,默认 5"}, }, }, }, p.handleReadInbox) - // 注册发信工具 + s.RegisterTool("read_mail", sdk.ToolDef{ + Name: "read_mail", + Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。", + Parameters: oneStringParam("mail_id", "邮件 ID", true), + }, p.handleReadMail) + s.RegisterTool("send_mail", sdk.ToolDef{ Name: "send_mail", Description: "发送邮件。三维地址 name@path.session。回复来信请传 reply_to。", @@ -130,14 +202,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleSendMail) - // 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁) - s.RegisterTool("read_mail", sdk.ToolDef{ - Name: "read_mail", - Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。", - Parameters: oneStringParam("mail_id", "邮件 ID", true), - }, p.handleReadMail) - - // 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索) s.RegisterTool("forward_mail", sdk.ToolDef{ Name: "forward_mail", Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。", @@ -145,26 +209,44 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { "type": "object", "properties": map[string]interface{}{ "mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"}, - "to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"}, - "comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"}, - "cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"}, + "to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址"}, + "comment": map[string]interface{}{"type": "string", "description": "转发说明"}, + "cc": map[string]interface{}{"type": "string", "description": "抄送"}, "subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"}, - "session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"}, + "session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效"}, }, "required": []string{"mail_id", "to"}, }, }, p.handleForwardMail) - // ─── 寻址发现 ─── - // - // 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本字段, - // 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功, - // 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。 + s.RegisterTool("upload_attachment", sdk.ToolDef{ + Name: "upload_attachment", + Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachments 字段。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "file_path": map[string]interface{}{"type": "string", "description": "本地文件路径"}, + }, + "required": []string{"file_path"}, + }, + }, p.handleUploadAttachment) + + s.RegisterTool("download_attachment", sdk.ToolDef{ + Name: "download_attachment", + Description: "下载附件到本地。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "attachment_id": map[string]interface{}{"type": "string", "description": "附件 ID"}, + "save_path": map[string]interface{}{"type": "string", "description": "保存路径"}, + }, + "required": []string{"attachment_id", "save_path"}, + }, + }, p.handleDownloadAttachment) s.RegisterTool("suggest_address", sdk.ToolDef{ - Name: "suggest_address", - Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" + - "name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。", + Name: "suggest_address", + Description: "查询可用收件人地址。不带参数给候选收件人;带 name 给工作目录;name+path 都带则给会话别名。发信前应先用它确认地址。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -176,39 +258,32 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("list_contacts", sdk.ToolDef{ Name: "list_contacts", - Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。", + Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。", Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false), }, p.handleListContacts) s.RegisterTool("session_participants", sdk.ToolDef{ - Name: "session_participants", - Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" + - "**要回给抄收方或向第三方转达时先用它拿地址**。", - Parameters: oneStringParam("session_id", "会话 ID", true), + Name: "session_participants", + Description: "列出某条会话的全部参与方与各自的可投递地址,并标出谁还没回应。", + Parameters: oneStringParam("session_id", "会话 ID", true), }, p.handleSessionParticipants) s.RegisterTool("read_thread", sdk.ToolDef{ - Name: "read_thread", - Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" + - "别人已经说了什么,避免重复提问或重复汇报。", + Name: "read_thread", + Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"}, - "offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"}, + "offset": map[string]interface{}{"type": "number", "description": "分页偏移"}, }, "required": []string{"mail_id"}, }, }, p.handleReadThread) - // connect_to_server —— 连接自愈。 - // - // Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改 - // 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。 s.RegisterTool("connect_to_server", sdk.ToolDef{ - Name: "connect_to_server", - Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" + - "密钥若未在后台登记过,此处会返回需要登记的密钥全文。", + Name: "connect_to_server", + Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -218,12 +293,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleConnectToServer) - // 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信 + // 注册输出通道 s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile, "发送邮件。meta JSON 格式:{to, subject, reply_to},type: text", sdk.ChannelDef{}, p.handleOutputChannel) - log.Printf("[homeagent-mail-bridge] 注册完成,等待 Gateway SSE") + log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具),等待 Gateway SSE", 13) // 启动心跳 + SSE(后台 goroutine) go p.heartbeatLoop() @@ -240,10 +315,11 @@ func (p *Plugin) Stop() error { // ─── 心跳 ─── func (p *Plugin) heartbeatLoop() { - // 首次注册 - if err := p.register(); err != nil { - log.Printf("[homeagent-mail-bridge] 注册失败: %v", err) + // B-1.3:立即发一次心跳,不等第一个 30 秒周期 + if err := p.heartbeat(); err != nil { + log.Printf("[homeagent-mail-bridge] 首次心跳失败: %v", err) } + ticker := time.NewTicker(heartbeatIntval) defer ticker.Stop() for { @@ -252,7 +328,7 @@ func (p *Plugin) heartbeatLoop() { return case <-ticker.C: if err := p.heartbeat(); err != nil { - log.Printf("[homeagent-mail-bridge] 心跳失败: %v", err) + // B-2.1:心跳失败不重试不报错,下一轮补上 } } } @@ -268,7 +344,101 @@ func (p *Plugin) register() error { } func (p *Plugin) heartbeat() error { - return p.post("/agent/heartbeat", map[string]interface{}{}, nil) + payload := map[string]interface{}{} + + // B-2.3:带上模型目录 + if len(p.modelCatalog) > 0 { + payload["models"] = p.modelCatalog + } + + var resp struct { + AllowedModels []string `json:"allowed_models"` + PendingMails int `json:"pending_mails"` + } + if err := p.post("/agent/heartbeat", payload, &resp); err != nil { + return err + } + + // B-2.2:从响应读 allowed_models(Gateway 有范围配置时返回) + // 这些模型被插件用于后续轮次的模型选择降级尝试 + + // B-1.6 / B-7:首个成功心跳后,如果有未读邮件,补拉 + if !p.catchupDone { + p.catchupDone = true + if resp.PendingMails > 0 { + go p.catchUp(resp.PendingMails) + } + } + + return nil +} + +// B-7 补拉:串行读取未读邮件,逐封注入 agent 事件循环。 +// +// 与 DSH/pi 的补拉逻辑一致: +// - 上限 5 封(catchupLimit),避免重启时一次性灌入太多 +// - 正序(最旧的先处理),保持时间线 +// - 只补 normal 类型(permission 不补投——人在 WebUI 上看到就知道了) +// - 每封之间等 InjectInputSync 返回(串行处理) +func (p *Plugin) catchUp(pending int) { + limit := catchupLimit + if pending < limit { + limit = pending + } + + log.Printf("[homeagent-mail-bridge] 补投 %d 封离线期间的邮件(共 %d 封未读)", limit, pending) + + var inbox struct { + Mails []struct { + MailID string `json:"mail_id"` + FromName string `json:"from_name"` + Subject string `json:"subject"` + MailType string `json:"mail_type"` + ReplyTo string `json:"reply_to"` + } `json:"mails"` + } + url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit) + if err := p.get(url, &inbox); err != nil { + log.Printf("[homeagent-mail-bridge] 补拉失败: %v", err) + return + } + + for _, m := range inbox.Mails { + if m.MailType != "normal" { + continue // permission 等非邮件驱动的不补投 + } + + // 构造注入消息(与 handleNewMail 一致) + prompt := fmt.Sprintf( + "你收到一封新邮件(AgentMail)。\n\n"+ + "发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+ + "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ + "**回信不用你自己发**:你把结论说出来就行,\n"+ + "插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+ + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", + m.FromName, m.Subject, m.MailID, p.agentName, m.FromName, + ) + + reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + if reply == "" { + // B-6:模型没回,发一封告知 + p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复") + continue + } + // B-5.3:检查模型是否已经自己发过信 + rk := "homeagent:" + m.MailID + p.explicitSendsMu.Lock() + _, sent := p.explicitSends[rk] + p.explicitSendsMu.Unlock() + + if sent { + // 模型已经在这一轮里自己回了这封信,不再重复 relay + continue + } + + // B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额 + p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, "homeagent:"+m.MailID) + } } // ─── SSE ─── @@ -294,6 +464,13 @@ func (p *Plugin) readSSE() error { } req.Header.Set("Authorization", "Bearer "+p.key) + // W-4:断线期间的事件会丢,带上 Last-Event-ID 可以让 Gateway 从断点补发 + p.sseMu.Lock() + if p.lastEventID != "" { + req.Header.Set("Last-Event-ID", p.lastEventID) + } + p.sseMu.Unlock() + resp, err := p.client.Do(req) if err != nil { return err @@ -318,7 +495,6 @@ func (p *Plugin) readSSE() error { n, err := resp.Body.Read(buf[len(buf):cap(buf)]) if n > 0 { buf = buf[:len(buf)+n] - // 处理完整行 for { i := bytes.IndexByte(buf[lineStart:], '\n') if i < 0 { @@ -344,7 +520,15 @@ func (p *Plugin) readSSE() error { } func (p *Plugin) parseSSELine(line string) { - // SSE 格式:event: xxx\ndata: {...}\n\n + // W-4:记录 Last-Event-ID + if strings.HasPrefix(line, "id: ") { + eid := strings.TrimPrefix(line, "id: ") + p.sseMu.Lock() + p.lastEventID = eid + p.sseMu.Unlock() + return + } + if !strings.HasPrefix(line, "data: ") { return } @@ -367,18 +551,15 @@ func (p *Plugin) parseSSELine(line string) { if err := json.Unmarshal([]byte(raw), &evt); err != nil { return } - if evt.MailID == "" { return } - // 权限决策回复(MUST) if evt.MailType == "permission_decision" { p.handlePermissionDecision(evt) return } - // 新邮件:注入 agent 事件循环 if evt.MailType == "normal" { p.handleNewMail(evt) } @@ -405,13 +586,25 @@ func (p *Plugin) handleOutputChannel(args map[string]interface{}) (interface{}, return nil, fmt.Errorf("meta 中需要 to 字段") } - if err := p.sendMail(m.To, m.Subject, payload, m.ReplyTo); err != nil { + // 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 } -func (p *Plugin) sendMail(to, subject, body, replyTo string) error { +// ─── 发信辅助 ─── + +// sendMail 发一封普通邮件(不带 relay 标记)。 +// 用于模型主动调 send_mail 或 output_send 时。 +func (p *Plugin) sendMail(to, subject, body, replyTo, sessionAlias string) error { payload := map[string]interface{}{ "to": to, "subject": subject, @@ -420,9 +613,52 @@ func (p *Plugin) sendMail(to, subject, body, replyTo string) error { if replyTo != "" { payload["reply_to"] = replyTo } + if sessionAlias != "" { + payload["session_alias"] = sessionAlias + } return p.post("/mail/send", payload, nil) } +// sendMailRelay 发一封带 relay:"summary" 标记的邮件。 +// +// B-5.2:插件代模型搬运回复时必须带 relay:"summary" + relay_key, +// Gateway 才会把它走免配额通道(插件搬运不算模型自主发信)。 +// B-5.4:文本为空时不发空邮件。 +func (p *Plugin) sendMailRelay(to, subject, body, replyTo, relayKey string) error { + if strings.TrimSpace(body) == "" { + return nil // B-5.4 + } + payload := map[string]interface{}{ + "to": to, + "subject": subject, + "body": body, + "relay": "summary", + "relay_key": relayKey, + } + if replyTo != "" { + payload["reply_to"] = replyTo + } + return p.post("/mail/send", payload, nil) +} + +// sendFailureReply 在模型处理失败时给发件人一封告知。 +// +// B-6:无法处理时必须回信。发件人发了邮件后没有任何音讯是最糟的体验 —— +// 他不知道邮件到了没有、模型看了没有、是卡住了还是忽略了。 +// 必须带 relay:"summary" 走免配额通道,这是插件代劳不是模型自主发信。 +func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) { + body := fmt.Sprintf( + "这是一封自动通知:您发送的主题为「%s」的邮件在处理时遇到了问题,未能产生有效回复。\n\n"+ + "原因:%s\n\n"+ + "请稍后重试,或通过其他方式联系。", + subject, reason, + ) + rk := "homeagent:failure:" + replyTo + if err := p.sendMailRelay(to, "Re: "+subject, body, replyTo, rk); err != nil { + log.Printf("[homeagent-mail-bridge] 失败通知发送失败: %v", err) + } +} + // ─── 新邮件处理 ─── func (p *Plugin) handleNewMail(evt struct { @@ -436,15 +672,9 @@ func (p *Plugin) handleNewMail(evt struct { Alias string `json:"session_alias"` ReplyAddr string `json:"reply_address"` }) { - // 记录回信目标 —— StageBeforeOutput 会检查是否由邮件触发, - // StageAfterOutput 用这个地址把最终回复发回去。 - // 构建中断消息 prompt := fmt.Sprintf( "你收到一封新邮件(AgentMail)。\n\n"+ - "发件人:%s\n"+ - "主题:%s\n"+ - "邮件 ID:%s\n"+ - "身份:你是 %s\n\n"+ + "发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ "**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+ "插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+ @@ -452,22 +682,47 @@ func (p *Plugin) handleNewMail(evt struct { evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName, ) - // 非阻塞注入 —— TrueAgent 的事件循环会处理 // InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。 - // 这比 stage handler 更可靠:TrueAgent 的事件循环保证不会重入, - // 而 stage handler 的触发时机依赖 ToolCalls 的状态快照,实际测试中 - // before_output 看不到 read_inbox 调用(它在 turn 中间就被清掉了)。 reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + + // B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话) if reply == "" { - log.Printf("[homeagent-mail-bridge] 已注入新邮件 %s(来自 %s:%s),agent 无回复", evt.MailID[:8], evt.FromName, evt.Subject) + log.Printf("[homeagent-mail-bridge] 邮件 %s(来自 %s:%s)agent 无回复,发失败通知", + evt.MailID[:8], evt.FromName, evt.Subject) + p.sendFailureReply(evt.FromName, evt.Subject, evt.MailID, "模型未产生回复") return } - // 自动把 agent 的回复发回给发件人 - if err := p.sendMail(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID); err != nil { + + // B-5.3:检查模型是否已经自己发过信(通过 send_mail 或 output_send) + rk := "homeagent:" + evt.MailID + p.explicitSendsMu.Lock() + _, sent := p.explicitSends[rk] + if sent { + delete(p.explicitSends, rk) // 用过即清,不留残 + } + p.explicitSendsMu.Unlock() + + if sent { + // 模型已经在这一轮里自己回了这封信,让位 + log.Printf("[homeagent-mail-bridge] 邮件 %s 模型已自行回复,跳过自动 relay", evt.MailID[:8]) + return + } + + // B-5.2:自动回信带 relay:"summary" + relay_key + if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil { log.Printf("[homeagent-mail-bridge] 自动回信失败: %v", err) } else { log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply)) } + + // 清理过期的 explicitSends 记录 + p.explicitSendsMu.Lock() + for k, t := range p.explicitSends { + if time.Since(t) > dedupWindow { + delete(p.explicitSends, k) + } + } + p.explicitSendsMu.Unlock() } // ─── 权限决策 ─── @@ -508,7 +763,6 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro return nil, err } - // 渲染成模型可读的文本 mails, _ := result["mails"].([]interface{}) if len(mails) == 0 { return map[string]interface{}{"content": []map[string]interface{}{{"type": "text", "text": "收件箱为空。"}}}, nil @@ -529,10 +783,8 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro } alias, _ := mail["session_alias"].(string) - fmt.Fprintf(&sb, "[%d] %s: %s\n邮件 ID: %s\n会话: #%s\n", - i+1, from, subj, mid, alias) + fmt.Fprintf(&sb, "[%d] %s: %s\n邮件 ID: %s\n会话: #%s\n", i+1, from, subj, mid, alias) - // 抄送 if ccList, ok := mail["cc_list"].([]interface{}); ok && len(ccList) > 0 { names := make([]string, 0, len(ccList)) for _, c := range ccList { @@ -547,7 +799,6 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro } } - // 附件 if atts, ok := mail["attachments"].([]interface{}); ok && len(atts) > 0 { fmt.Fprintf(&sb, "附件:\n") for _, a := range atts { @@ -569,7 +820,6 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro sb.WriteString("\n") } - // 标记已读 ids := make([]string, 0, len(mails)) for _, m := range mails { if mail, ok := m.(map[string]interface{}); ok { @@ -598,6 +848,14 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error return nil, fmt.Errorf("缺少必填字段:to, subject, body") } + // B-5.3:记录模型自主发信 + rk := replyTo + if rk != "" { + p.explicitSendsMu.Lock() + p.explicitSends["homeagent:"+rk] = time.Now() + p.explicitSendsMu.Unlock() + } + payload := map[string]interface{}{ "to": to, "subject": subj, @@ -627,6 +885,114 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error }, nil } +// C-14 附件上传 —— 真 multipart,不是桩。 +// +// 读取本地文件 → 构造 multipart/form-data → POST /api/v1/attachments。 +// 返回 attachment_id,填入 send_mail 的 attachments 字段。 +func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{}, error) { + filePath, _ := args["file_path"].(string) + if filePath == "" { + return nil, fmt.Errorf("缺少 file_path") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %v", err) + } + + // multipart/form-data + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return nil, fmt.Errorf("创建 multipart 失败: %v", err) + } + if _, err := part.Write(data); err != nil { + return nil, fmt.Errorf("写入文件数据失败: %v", err) + } + writer.Close() + + req, err := http.NewRequest("POST", p.gwURL+"/api/v1/attachments", &buf) + if err != nil { + return nil, 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 nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + AttachmentID string `json:"attachment_id"` + Filename string `json:"filename"` + SizeBytes int `json:"size_bytes"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + 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) + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": text}}, + }, nil +} + +// C-14 附件下载 —— 真 octet-stream 下载。 +func (p *Plugin) handleDownloadAttachment(args map[string]interface{}) (interface{}, error) { + aid, _ := args["attachment_id"].(string) + savePath, _ := args["save_path"].(string) + if aid == "" || savePath == "" { + return nil, fmt.Errorf("缺少 attachment_id 和 save_path") + } + + req, err := http.NewRequest("GET", p.gwURL+"/api/v1/attachments/"+aid, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+p.key) + + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil { + return nil, fmt.Errorf("创建目录失败: %v", err) + } + + out, err := os.Create(savePath) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %v", err) + } + defer out.Close() + + written, err := io.Copy(out, resp.Body) + if err != nil { + return nil, fmt.Errorf("写入文件失败: %v", err) + } + + text := fmt.Sprintf("附件已下载:%s(%dKB)", savePath, written/1024) + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": text}}, + }, nil +} + // ─── HTTP 辅助 ─── func (p *Plugin) get(url string, out interface{}) error { @@ -655,7 +1021,11 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error { return err } - req, err := http.NewRequest("POST", p.gwURL+"/api/v1"+path, bytes.NewReader(data)) + url := path + if !strings.HasPrefix(path, "http") { + url = p.gwURL + "/api/v1" + path + } + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) if err != nil { return err } diff --git a/plugins/homeagent-mail-bridge/tools.go b/plugins/homeagent-mail-bridge/tools.go index 9cd9364..e554ab9 100644 --- a/plugins/homeagent-mail-bridge/tools.go +++ b/plugins/homeagent-mail-bridge/tools.go @@ -397,13 +397,6 @@ func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, err }, nil } -func min(a, b int) int { - if a < b { - return a - } - return b -} - // oneStringParam 给只有一个参数的工具生成 schema。 // // 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉