fix(homeagent): 删除 plugin.go 里与 tools.go 重复的 6 个方法
plugin.go 与 tools.go 各自声明了同名工具处理器。逐个 diff 后保留 tools.go 的版本 —— 它严格更丰富:read_thread 多 ParentHid → parent_hidden 并区分「父邮件无权查看」与「父邮件尚未加载」,suggest_address 多解析 candidates,connect_to_server 多一条注释。 plugin.go 删除 handleConnectToServer / handleForwardMail / handleSuggestAddress / handleListContacts / handleSessionParticipants / handleReadThread + minInt。 tools.go 删除本地 min():go.mod 是 go 1.25.0,内置 min 可用, 本地定义只是遮蔽。 部署验证:11 项工具注册(日志里硬编码的「13 个工具」是错的数字)。
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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 字面量很容易漏掉
|
||||
|
||||
Reference in New Issue
Block a user