## 设计规则: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 包全绿
276 lines
8.8 KiB
Go
276 lines
8.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- 转发 ----------
|
||
//
|
||
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
|
||
// 回复(reply_to)落回原会话,收件人是原发件人;
|
||
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
|
||
// 因此转发不复用 reply_to,而是走完整的三维寻址。
|
||
|
||
type forwardRequest struct {
|
||
// To 新收件人,完整三维地址
|
||
To string `json:"to"`
|
||
// CC 可选抄送
|
||
CC string `json:"cc"`
|
||
// Comment 转发者附加的说明,置于引用原文之前
|
||
Comment string `json:"comment"`
|
||
// Subject 可选;留空时自动加 "Fwd: " 前缀
|
||
Subject string `json:"subject"`
|
||
// SessionAlias 仅在目标地址以 .new 结尾时生效
|
||
SessionAlias string `json:"session_alias"`
|
||
}
|
||
|
||
// quoteBody 把原文渲染为 Markdown 引用块。
|
||
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
|
||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||
func quoteBody(m *models.Mail) string {
|
||
var b strings.Builder
|
||
b.WriteString("---\n\n")
|
||
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
|
||
if m.FromWorkspace != "" {
|
||
b.WriteString("@" + m.FromWorkspace)
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
|
||
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
|
||
if len(m.CCList) > 0 {
|
||
names := make([]string, 0, len(m.CCList))
|
||
for _, c := range m.CCList {
|
||
names = append(names, c.Raw)
|
||
}
|
||
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
|
||
}
|
||
b.WriteString(">\n")
|
||
for _, line := range strings.Split(m.Body, "\n") {
|
||
b.WriteString("> " + line + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
|
||
func forwardSubject(custom, original string) string {
|
||
if s := strings.TrimSpace(custom); s != "" {
|
||
return s
|
||
}
|
||
if strings.HasPrefix(original, "Fwd: ") {
|
||
return original
|
||
}
|
||
return "Fwd: " + original
|
||
}
|
||
|
||
// doForward 是 Agent 与人类两条转发路径的公共实现。
|
||
// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。
|
||
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
|
||
var req forwardRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.To) == "" {
|
||
Error(w, http.StatusBadRequest, "Missing to")
|
||
return
|
||
}
|
||
|
||
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
|
||
switch {
|
||
case errors.Is(err, repo.ErrMailNotFound):
|
||
Error(w, http.StatusNotFound, "待转发的邮件不存在")
|
||
return
|
||
case errors.Is(err, repo.ErrForwardNotAllowed):
|
||
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
|
||
return
|
||
case err != nil:
|
||
Error(w, http.StatusInternalServerError, "Failed to load mail")
|
||
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
|
||
}
|
||
|
||
user := middleware.GetUser(r)
|
||
if !isAgent && user != nil {
|
||
to = resolveHumanAlias(to, user.Username)
|
||
for i := range ccList {
|
||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||
}
|
||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||
Error(w, http.StatusForbidden, msg)
|
||
return
|
||
}
|
||
}
|
||
|
||
subject := forwardSubject(req.Subject, src.Subject)
|
||
|
||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
|
||
if err != nil {
|
||
writeErr(w, err, "Failed to resolve session")
|
||
return
|
||
}
|
||
|
||
if isAgent {
|
||
// 转发也是一次主动发信,扣【目标会话】的往返预算。
|
||
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。
|
||
budget, bErr := repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||
if errors.Is(bErr, repo.ErrSessionBudgetExhausted) {
|
||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||
"目标会话的往返预算已用尽(%d/%d)。请让人在对话页调高该会话的预算。",
|
||
budget.Used, budget.Max))
|
||
return
|
||
}
|
||
if bErr != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to check session budget")
|
||
return
|
||
}
|
||
repo.BumpSentCount(r.Context(), actor)
|
||
} else if user != nil {
|
||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||
}
|
||
|
||
body := quoteBody(src)
|
||
if c := strings.TrimSpace(req.Comment); c != "" {
|
||
body = c + "\n\n" + body
|
||
}
|
||
attachedCount := 0
|
||
|
||
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
|
||
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
|
||
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||
return
|
||
}
|
||
|
||
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
|
||
// 内容寻址下这只是新增元数据,不拷磁盘文件。
|
||
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
|
||
Error(w, http.StatusInternalServerError, "复制附件失败")
|
||
return
|
||
} else {
|
||
attachedCount = n
|
||
}
|
||
|
||
// 转发在数据上 parent 指向原邮件(用于回溯来源),但对**收件方**而言这是一封
|
||
// 全新的信:那封原邮件不是它写的,也不在它的线索里。
|
||
// 因此 in_reply_to 传空串 —— 提示词该说「有人转了一封信给你」而不是
|
||
// 「你上封信的回复到了」。
|
||
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject, "")
|
||
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"mail_id": newID.String(),
|
||
"session_id": sessionID.String(),
|
||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||
"forwarded_from": src.ID.String(),
|
||
"attachments": attachedCount,
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
|
||
func ForwardMail(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
mailID, ok := pathUUID(w, r, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
doForward(w, r, mailID, agentName, agentName, true)
|
||
}
|
||
|
||
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
|
||
func MeForwardMail(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
mailID, ok := pathUUID(w, r, "id")
|
||
if !ok {
|
||
return
|
||
}
|
||
doForward(w, r, mailID, user.Username, "", false)
|
||
}
|
||
|
||
// ---------- Agent 默认预算与统计(管理员) ----------
|
||
|
||
// GET /api/v1/admin/quotas
|
||
//
|
||
// 路径沿用 quotas(兼容已部署的前端),但语义已变:
|
||
// 返回的是【新任务默认预算 + 累计统计】,而不是会拦请求的终身额度。
|
||
// 真正的额度在每条会话上(GET /sessions/{id}/budget)。
|
||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||
stats, err := repo.ListAgentStats(r.Context())
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list agent stats")
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{"quotas": stats})
|
||
}
|
||
|
||
type setQuotaRequest struct {
|
||
// DefaultRounds 派给该 Agent 的新任务默认多少个来回(0 = 不限)
|
||
DefaultRounds *int `json:"default_rounds"`
|
||
// MaxRounds 是 DefaultRounds 的旧字段名,保留兼容:
|
||
// 已部署的前端与脚本不应该因为改名就难以察觉地失效。
|
||
MaxRounds *int `json:"max_rounds"`
|
||
}
|
||
|
||
// PUT /api/v1/admin/quotas/{name}
|
||
//
|
||
// 只能改【新任务默认预算】。不再接受 reset:
|
||
// 累计发信数是观测数据,不拦任何请求,归零它只会销毁历史。
|
||
// 要给某个卡住的任务加额度,去那条会话的对话页改预算。
|
||
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
|
||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||
if name == "" {
|
||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||
return
|
||
}
|
||
|
||
var req setQuotaRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
n := req.DefaultRounds
|
||
if n == nil {
|
||
n = req.MaxRounds // 兼容旧字段名
|
||
}
|
||
if n == nil {
|
||
Error(w, http.StatusBadRequest, "需要 default_rounds")
|
||
return
|
||
}
|
||
if *n < 0 {
|
||
Error(w, http.StatusBadRequest, "default_rounds 不能为负")
|
||
return
|
||
}
|
||
|
||
st, err := repo.SetDefaultRounds(r.Context(), name, *n)
|
||
if err != nil {
|
||
Error(w, http.StatusNotFound, err.Error())
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{"quota": st})
|
||
}
|