Agent→Agent 不自动转发 + 提示词区分新活/回复/补投

## 设计规则: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 包全绿
This commit is contained in:
2026-09-04 23:52:52 +08:00
parent 375578cf9b
commit 784192d8c4
22 changed files with 1315 additions and 92 deletions

View File

@ -170,7 +170,11 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
attachedCount = n
}
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject)
// 转发在数据上 parent 指向原邮件(用于回溯来源),但对**收件方**而言这是一封
// 全新的信:那封原邮件不是它写的,也不在它的线索里。
// 因此 in_reply_to 传空串 —— 提示词该说「有人转了一封信给你」而不是
// 「你上封信的回复到了」。
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject, "")
JSON(w, http.StatusOK, map[string]any{
"mail_id": newID.String(),

View File

@ -370,7 +370,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject)
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject, parentIDString(parentMailID))
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
resp := map[string]any{
@ -401,14 +401,17 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
//
// 实现只有一份,在 internal/notify 里 —— 此前 handler 与 scheduler 各写一份,
// 加字段时漏改一处直接造成生产事故(详见那个包的注释)。
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
//
// parentMailID 为空字串表示这不是回信。
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject, parentMailID string) {
notify.Recipients(ctx, notify.Mail{
SessionID: sessionID,
MailID: mailID,
From: from,
To: to,
CC: cc,
Subject: subject,
SessionID: sessionID,
MailID: mailID,
From: from,
To: to,
CC: cc,
Subject: subject,
ParentMailID: parentMailID,
})
}
@ -595,3 +598,13 @@ func MarkInboxRead(w http.ResponseWriter, r *http.Request) {
"requested": len(ids),
})
}
// parentIDString 把可空的父邮件 id 转成字符串nil → 空串)。
//
// 空串在 SSE payload 里的语义是「这不是回信」—— 插件据此选提示词。
func parentIDString(id *uuid.UUID) string {
if id == nil {
return ""
}
return id.String()
}

View File

@ -121,7 +121,7 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject)
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject, parentIDString(parentMailID))
resp := map[string]any{
"mail_id": mailID.String(),

View File

@ -163,6 +163,16 @@ type Mail struct {
// 「谁在哪一封里提了什么」应当留痕。
RenameAlias string `json:"rename_alias,omitempty"`
RenameReason string `json:"rename_reason,omitempty"`
// FromHuman 表示发件方是人类用户而不是 Agent。
//
// 插件靠它判「要不要自动转发本轮结论」Agent 之间不自动回,
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
//
// **补拉路径必须有它**SSE 事件里叫 `from_human`,而插件重启后走
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
FromHuman bool `json:"from_human"`
}
// PermissionRequest 是 Agent 向人类发起的权限请求

View File

@ -50,6 +50,14 @@ type Mail struct {
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
// 让模型把结果回报到同一条线索上。
ReplyToName string
// ParentMailID 非空表示这封是**回信**(回的那封的 mail_id
//
// 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言
// 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。
//
// 后果在生产上兜现过pi 转发给 dshdsh 回确认pi 把那封确认当成新任务
// 又回一封,两边互相客套 6 轮直到撞上 hop 上限。
ParentMailID string
}
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
@ -105,6 +113,14 @@ func Recipients(ctx context.Context, m Mail) {
return platformID
}
// 发件方是人还是 Agent。
//
// 插件靠它选提示词里那句关键的话:**「插件会自动把你本轮结论发回去」只对
// 人类发件方成立**。发给另一个 Agent 时,那边的插件也会自动回一封,
// 于是两个模型都以为「我只要把话说完就行」,实际上彼此持续唤醒 ——
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
payload := func(role, workspace, forName string) map[string]interface{} {
p := map[string]interface{}{
"mail_id": m.MailID.String(),
@ -134,6 +150,16 @@ func Recipients(ctx context.Context, m Mail) {
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
// 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。
"platform_session_id": platformFor(forName),
// in_reply_to 非空 = 这封是对收件方某封信的**回复**,不是新派的活。
//
// 插件据此换一套提示词:把它当新任务会让模型又“处理”一遍并再回一封,
// 于是两个 Agent 互相客套直到撞上 hop 上限(生产实测 6 轮)。
"in_reply_to": m.ParentMailID,
// from_human 区分「人在找你」与「另一个 Agent 在找你」。
//
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
"from_human": fromHuman,
}
if m.Origin != "" {
p["origin"] = m.Origin

View File

@ -392,7 +392,8 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, s.workspace
m.status, m.created_at, s.session_alias, s.workspace,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
@ -421,7 +422,7 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman); err != nil {
return nil, err
}
if len(ccJSON) > 0 {