## 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 已纳入校验)
461 lines
13 KiB
Go
461 lines
13 KiB
Go
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, "会话: #%s(session_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: %s,Session: %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
|
||
}
|