Files
MailUI4Agents/plugins/homeagent-mail-bridge/tools.go
JianFeeeee f321380fa3 fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
2026-09-03 15:01:52 +08:00

461 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"strings"
)
// ─── 工具定义(与 opencode/dsh/pi 同源逻辑Go 版本)───
//
// 所有工具都是对 Gateway REST API 的薄封装HTTP → 渲染 → 模型可读文本。
// 与 JS 插件的区别仅在 HTTP 辅助函数p.get / p.post行为完全一致。
func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
var data struct {
Mail struct {
FromName string `json:"from_name"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
Subject string `json:"subject"`
Body string `json:"body"`
CCList []struct {
Name string `json:"name"`
Path string `json:"path"`
Raw string `json:"raw"`
} `json:"cc_list"`
Attachments []struct {
Filename string `json:"filename"`
SizeBytes int `json:"size_bytes"`
AttachmentID string `json:"attachment_id"`
} `json:"attachments"`
} `json:"mail"`
SessionAlias string `json:"session_alias"`
ReplyAddress string `json:"reply_address"`
SelfAddress string `json:"self_address"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
Address string `json:"address"`
IsSelf bool `json:"is_self"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil {
return nil, err
}
var sb strings.Builder
fmt.Fprintf(&sb, "发件人: %s\n", data.Mail.FromName)
if data.Mail.ToWorkspace != "" {
fmt.Fprintf(&sb, "收件人: %s@%s\n", data.Mail.ToName, data.Mail.ToWorkspace)
} else {
fmt.Fprintf(&sb, "收件人: %s\n", data.Mail.ToName)
}
fmt.Fprintf(&sb, "主题: %s\n", data.Mail.Subject)
fmt.Fprintf(&sb, "会话: #%ssession_id: %s\n", data.SessionAlias, data.Mail.FromName)
if len(data.Mail.CCList) > 0 {
names := make([]string, 0, len(data.Mail.CCList))
for _, c := range data.Mail.CCList {
names = append(names, c.Raw)
}
fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、"))
}
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, "\n%s\n", data.Mail.Body)
if len(data.Participants) > 0 {
fmt.Fprintf(&sb, "\n可投递地址:\n")
for _, pt := range data.Participants {
if pt.Address != "" && !pt.IsSelf {
fmt.Fprintf(&sb, " - %s (%s)\n", pt.Address, strings.Join(pt.Roles, "/"))
}
}
}
if data.ReplyAddress != "" {
fmt.Fprintf(&sb, "回信给发件人用 %s或传 reply_to=%s\n", data.ReplyAddress, data.Mail.FromName)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
to, _ := args["to"].(string)
comment, _ := args["comment"].(string)
cc, _ := args["cc"].(string)
subj, _ := args["subject"].(string)
sa, _ := args["session_alias"].(string)
if mid == "" || to == "" {
return nil, fmt.Errorf("缺少 mail_id 和 to")
}
payload := map[string]interface{}{
"to": to,
"comment": comment,
"cc": cc,
"subject": subj,
"session_alias": sa,
}
var result map[string]interface{}
if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil {
return nil, err
}
text := fmt.Sprintf("已转发。新 Mail ID: %sSession: %s", result["mail_id"], result["session_id"])
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": text}},
}, nil
}
func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
path, _ := args["path"].(string)
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
qs := ""
if name != "" {
qs += "name=" + name
}
if path != "" {
if qs != "" {
qs += "&"
}
qs += "path=" + path
}
var data struct {
Kind string `json:"kind"`
Suggestions []string `json:"suggestions"`
Addresses []string `json:"addresses"`
Candidates []struct {
Alias string `json:"alias"`
Title string `json:"title"`
Unread int `json:"unread"`
Source string `json:"source"`
} `json:"candidates"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil {
return nil, err
}
var sb strings.Builder
switch data.Kind {
case "name":
sb.WriteString(fmt.Sprintf("可投递的收件人(%d 个):\n", len(data.Suggestions)))
for _, n := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", n)
}
sb.WriteString("\n下一步用 suggest_address 带上 name 查它可用的工作目录path 位)。")
case "path":
if len(data.Suggestions) == 0 {
fmt.Fprintf(&sb, "%s 没有记录在案的工作目录。\npath 位可以留空。", name)
} else {
fmt.Fprintf(&sb, "%s 用过的工作目录(按最近使用排序):\n", name)
for _, p := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", p)
}
}
default:
// session
existing := 0
for _, a := range data.Suggestions {
if a != "new" {
existing++
}
}
if existing == 0 {
fmt.Fprintf(&sb, "%s@%s 下还没有可续谈的会话。", name, path)
} else {
fmt.Fprintf(&sb, "%s@%s 下可续谈的会话:\n", name, path)
for i, alias := range data.Suggestions {
if alias == "new" {
continue
}
addr := ""
if i < len(data.Addresses) {
addr = data.Addresses[i]
}
fmt.Fprintf(&sb, "- %s\n", addr)
}
}
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, error) {
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
var data struct {
Contacts []struct {
Address string `json:"address"`
Subject string `json:"subject"`
Unread int `json:"unread_count"`
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
Alias string `json:"session_alias"`
} `json:"contacts"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil {
return nil, err
}
if len(data.Contacts) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "还没有任何往来会话。"}},
}, nil
}
// 按未读优先排序
for i := 0; i < len(data.Contacts)-1; i++ {
for j := i + 1; j < len(data.Contacts); j++ {
if data.Contacts[j].Unread > data.Contacts[i].Unread {
data.Contacts[i], data.Contacts[j] = data.Contacts[j], data.Contacts[i]
}
}
}
var sb strings.Builder
n := limit
if n > len(data.Contacts) {
n = len(data.Contacts)
}
fmt.Fprintf(&sb, "往来会话(共 %d 条):\n", len(data.Contacts))
for _, c := range data.Contacts[:n] {
bits := []string{}
if c.Unread > 0 {
bits = append(bits, fmt.Sprintf("%d 封未读", c.Unread))
}
if c.Subject != "" {
bits = append(bits, c.Subject)
}
if c.MaxRounds > 0 {
left := c.MaxRounds - c.UsedRounds
if left < 0 {
left = 0
}
bits = append(bits, fmt.Sprintf("剩 %d/%d 个来回", left, c.MaxRounds))
}
extra := ""
if len(bits) > 0 {
extra = fmt.Sprintf(" %s", strings.Join(bits, ""))
}
fmt.Fprintf(&sb, "- %s%s\n", c.Address, extra)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interface{}, error) {
sid, _ := args["session_id"].(string)
if sid == "" {
return nil, fmt.Errorf("缺少 session_id")
}
var data struct {
SessionAlias string `json:"session_alias"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
IsSelf bool `json:"is_self"`
MailCount int `json:"mail_count"`
Address string `json:"address"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil {
return nil, err
}
if len(data.Participants) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "该会话还没有参与方。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "会话 #%s 的参与方:\n", data.SessionAlias)
for _, pt := range data.Participants {
tags := []string{}
if pt.IsSelf {
tags = append(tags, "就是你")
}
if len(pt.Roles) > 0 {
tags = append(tags, strings.Join(pt.Roles, "/"))
}
if pt.MailCount == 0 && !pt.IsSelf {
tags = append(tags, "尚未回应")
}
extra := ""
if len(tags) > 0 {
extra = fmt.Sprintf(" [%s]", strings.Join(tags, ""))
}
fmt.Fprintf(&sb, "- %s %s%s\n", pt.Name, pt.Address, extra)
}
sb.WriteString("\n要联系其中某一方把它的地址原样填进 send_mail 的 to。")
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
offset := ""
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = fmt.Sprintf("?offset=%d", int(v))
}
var data struct {
Total int `json:"total"`
Hidden int `json:"hidden"`
HasMore bool `json:"has_more"`
NextOff int `json:"next_offset"`
AnchorID string `json:"anchor_mail_id"`
Nodes []struct {
MailID string `json:"mail_id"`
FromName string `json:"from_name"`
ToName string `json:"to_name"`
Subject string `json:"subject"`
Depth int `json:"depth"`
Detached bool `json:"detached"`
ParentHid bool `json:"parent_hidden"`
} `json:"nodes"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil {
return nil, err
}
if len(data.Nodes) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "这条线索上没有可见的邮件。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "线索共 %d 封", data.Total)
if data.Hidden > 0 {
fmt.Fprintf(&sb, "(另有 %d 封无权查看)", data.Hidden)
}
sb.WriteString("\n")
for _, n := range data.Nodes {
indent := ""
if n.Depth > 0 {
indent = strings.Repeat(" ", min(n.Depth, 8))
}
marks := []string{}
if n.MailID == data.AnchorID {
marks = append(marks, "当前这封")
}
if n.Detached {
if n.ParentHid {
marks = append(marks, "父邮件无权查看")
} else {
marks = append(marks, "父邮件尚未加载")
}
}
extra := ""
if len(marks) > 0 {
extra = fmt.Sprintf(" (%s)", strings.Join(marks, ""))
}
fmt.Fprintf(&sb, "%s- %s → %s: %s [%s]%s\n",
indent, n.FromName, n.ToName, n.Subject, n.MailID, extra)
}
if data.HasMore {
fmt.Fprintf(&sb, "\n还有更多用 offset=%d 继续取。\n", data.NextOff)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// oneStringParam 给只有一个参数的工具生成 schema。
//
// 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉
// `"type": "object"` 或把 required 写成字符串而不是数组 —— 那类错误不会
// 在编译期暴露,而是让模型收到一个它无法调用的工具。
func oneStringParam(name, desc string, required bool) map[string]interface{} {
schema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
name: map[string]interface{}{"type": "string", "description": desc},
},
}
if required {
schema["required"] = []string{name}
}
return schema
}
// handleConnectToServer 重新登记密钥并注册。
//
// 成功后把新坐标写回 p当场生效 —— 不用等重启。这是这个工具存在的全部意义:
// 若还要重启才生效,人直接改环境变量就行了,不需要给模型一个工具。
func (p *Plugin) handleConnectToServer(args map[string]interface{}) (interface{}, error) {
url := p.gwURL
if v, ok := args["gateway_url"].(string); ok && strings.TrimSpace(v) != "" {
url = strings.TrimRight(strings.TrimSpace(v), "/")
}
key := p.key
if v, ok := args["key_token"].(string); ok && strings.TrimSpace(v) != "" {
key = strings.TrimSpace(v)
}
if key == "" {
return nil, fmt.Errorf("没有可用的密钥:请传 key_token或在 AGENTMAIL_AGENT_KEY 环境变量里配置")
}
// 用候选坐标试注册,成功了才写回 —— 失败时不该把原本能用的配置改坏
probe := &Plugin{agentName: p.agentName, gwURL: url, key: key, client: p.client}
if err := probe.register(); err != nil {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": strings.Join([]string{
fmt.Sprintf("连接失败:%v", err),
"",
"若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:",
key,
}, "\n")}},
}, nil
}
p.gwURL = url
p.key = key
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": fmt.Sprintf("已连接 %s注册为 %s。", url, p.agentName)}},
}, nil
}