## 设计规则:Agent 之间不自动转发
自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。
规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投
`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。
## Gateway 侧:`in_reply_to` + `from_human`
- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
→ `models.Mail.FromHuman`,让补拉路径也有这个信号
## 提示词分流
三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」
## homeagent 特殊处理
Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。
## `mailEvent` 命名类型
homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。
## 测试
- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
305 lines
9.0 KiB
Go
305 lines
9.0 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ----------
|
||
|
||
type meSendMailRequest struct {
|
||
To string `json:"to"` // name@path.session
|
||
CC string `json:"cc"` // 多个 name@path.session
|
||
Subject string `json:"subject"`
|
||
Body string `json:"body"`
|
||
ReplyTo string `json:"reply_to"`
|
||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
|
||
SessionAlias string `json:"session_alias"`
|
||
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
|
||
AttachmentIDs []string `json:"attachment_ids"`
|
||
// MaxRounds 是本次任务的往返预算(0/省略 = 不限)。
|
||
//
|
||
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
|
||
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
|
||
// PUT /sessions/{id}/budget(对话页里可随时改)。
|
||
MaxRounds *int `json:"max_rounds"`
|
||
}
|
||
|
||
// POST /api/v1/me/mail/send
|
||
func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
var req meSendMailRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||
return
|
||
}
|
||
|
||
to, err := models.ParseAddress(req.To)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||
return
|
||
}
|
||
ccList, err := models.ParseAddressList(req.CC)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||
return
|
||
}
|
||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
// human@ 是兼容别名,人类发信时解析为自己
|
||
to = resolveHumanAlias(to, user.Username)
|
||
for i := range ccList {
|
||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||
}
|
||
|
||
// 权限边界:校验可调用的 Agent 与可访问的目录
|
||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||
Error(w, http.StatusForbidden, msg)
|
||
return
|
||
}
|
||
|
||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
|
||
if err != nil {
|
||
writeErr(w, err, "Failed to resolve session")
|
||
return
|
||
}
|
||
// 人类发起的会话归属于该用户
|
||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||
|
||
// 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段,
|
||
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
|
||
//
|
||
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
|
||
// 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||
if parentMailID == nil {
|
||
rounds := 0
|
||
if req.MaxRounds != nil {
|
||
if *req.MaxRounds < 0 {
|
||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||
return
|
||
}
|
||
rounds = *req.MaxRounds
|
||
} else {
|
||
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
|
||
}
|
||
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to set session budget")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
|
||
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
|
||
_, body := extractRenameProposal(req.Body)
|
||
|
||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||
return
|
||
}
|
||
|
||
if !attachAll(w, r, mailID, attachIDs, user.Username) {
|
||
return
|
||
}
|
||
|
||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject, parentIDString(parentMailID))
|
||
|
||
resp := map[string]any{
|
||
"mail_id": mailID.String(),
|
||
"session_id": sessionID.String(),
|
||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||
}
|
||
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
|
||
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
|
||
resp["budget_max"] = b.Max
|
||
resp["budget_used"] = b.Used
|
||
resp["budget_remaining"] = b.Remaining
|
||
}
|
||
JSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// GET /api/v1/me/mail/inbox
|
||
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
status := r.URL.Query().Get("status")
|
||
if status == "" {
|
||
status = "all"
|
||
}
|
||
limit := 50
|
||
if l := r.URL.Query().Get("limit"); l != "" {
|
||
if n, err := parseInt(l); err == nil && n > 0 {
|
||
limit = n
|
||
}
|
||
}
|
||
|
||
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||
return
|
||
}
|
||
// 列表页要显示附件图标与下载入口
|
||
ptrs := make([]*models.Mail, len(mails))
|
||
for i := range mails {
|
||
ptrs[i] = &mails[i]
|
||
}
|
||
fillAttachments(r, ptrs...)
|
||
total, _ := repo.CountUnread(r.Context(), user.Username)
|
||
|
||
JSON(w, http.StatusOK, map[string]interface{}{
|
||
"mails": emptySlice(mails),
|
||
"total": total,
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/me/mail/sent
|
||
func MeGetSent(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
limit := 50
|
||
if l := r.URL.Query().Get("limit"); l != "" {
|
||
if n, err := parseInt(l); err == nil && n > 0 {
|
||
limit = n
|
||
}
|
||
}
|
||
|
||
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
|
||
if err == nil {
|
||
ptrs := make([]*models.Mail, len(mails))
|
||
for i := range mails {
|
||
ptrs[i] = &mails[i]
|
||
}
|
||
fillAttachments(r, ptrs...)
|
||
}
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list sent")
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]interface{}{
|
||
"mails": emptySlice(mails),
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/me/sessions
|
||
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
scope := user.Username
|
||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||
scope = ""
|
||
}
|
||
|
||
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list sessions")
|
||
return
|
||
}
|
||
|
||
type SessionOut struct {
|
||
SessionID uuid.UUID `json:"session_id"`
|
||
SessionAlias *string `json:"session_alias"`
|
||
FromAgent string `json:"from_agent"`
|
||
Subject string `json:"subject"`
|
||
Status string `json:"status"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
MailCount int `json:"mail_count"`
|
||
UnreadCount int `json:"unread_count"`
|
||
// 往返预算随列表一并返回:预算是【任务】的属性,
|
||
// 工作列表上就应当看得见哪些任务快跑满了,
|
||
// 而不是点进去一个一个查。
|
||
MaxRounds int `json:"max_rounds"`
|
||
UsedRounds int `json:"used_rounds"`
|
||
}
|
||
|
||
result := make([]SessionOut, 0, len(sessions))
|
||
for _, s := range sessions {
|
||
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
|
||
result = append(result, SessionOut{
|
||
SessionID: s.ID,
|
||
SessionAlias: s.Alias,
|
||
FromAgent: s.FromAgent,
|
||
Subject: s.Subject,
|
||
Status: s.Status,
|
||
CreatedAt: s.CreatedAt,
|
||
UpdatedAt: s.UpdatedAt,
|
||
MailCount: s.MailCount,
|
||
UnreadCount: unread,
|
||
MaxRounds: s.MaxRounds,
|
||
UsedRounds: s.UsedRounds,
|
||
})
|
||
}
|
||
|
||
JSON(w, http.StatusOK, map[string]interface{}{
|
||
"sessions": result,
|
||
})
|
||
}
|
||
|
||
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
|
||
func resolveHumanAlias(a models.Address, username string) models.Address {
|
||
if a.Name != "human" {
|
||
return a
|
||
}
|
||
a.Name = username
|
||
a.Raw = username + "@" + a.Path
|
||
if a.Session != "" {
|
||
a.Raw += "." + a.Session
|
||
}
|
||
return a
|
||
}
|
||
|
||
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
|
||
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
|
||
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
|
||
if user.IsAdmin() {
|
||
return ""
|
||
}
|
||
for _, a := range addrs {
|
||
if a.Name == "" || a.Name == user.Username {
|
||
continue
|
||
}
|
||
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
|
||
if err != nil {
|
||
return "无法校验收件人权限"
|
||
}
|
||
if !isHuman && !user.CanUseAgent(a.Name) {
|
||
return "无权调用 Agent: " + a.Name
|
||
}
|
||
if !user.CanUsePath(a.Path) {
|
||
return "无权访问目录: " + a.Path
|
||
}
|
||
}
|
||
return ""
|
||
}
|