diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 5c00b1c..59bb6c1 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) fail=0 for peer in "${PEERS[@]}"; do - for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do + for f in relay-dedup relay-policy inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 @@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do done # 测试同样要同源:共用模块的行为约定写在测试里, # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 - for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do + for f in relay-policy inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index 93c1e3b..c804c6c 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -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(), diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 84fc0cf..6eb003d 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -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() +} diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go index ab7700b..1b731ec 100644 --- a/gateway/internal/handler/me.go +++ b/gateway/internal/handler/me.go @@ -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(), diff --git a/gateway/internal/models/models.go b/gateway/internal/models/models.go index a30fdf7..5da6aa8 100644 --- a/gateway/internal/models/models.go +++ b/gateway/internal/models/models.go @@ -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 向人类发起的权限请求 diff --git a/gateway/internal/notify/mail.go b/gateway/internal/notify/mail.go index 065eca1..2dde1f8 100644 --- a/gateway/internal/notify/mail.go +++ b/gateway/internal/notify/mail.go @@ -50,6 +50,14 @@ type Mail struct { // 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字, // 让模型把结果回报到同一条线索上。 ReplyToName string + // ParentMailID 非空表示这封是**回信**(回的那封的 mail_id)。 + // + // 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言 + // 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。 + // + // 后果在生产上兜现过:pi 转发给 dsh,dsh 回确认,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 diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 3afa22b..ba0c435 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -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 { diff --git a/plugins/dsh-mail-bridge/lib/relay-policy.d.ts b/plugins/dsh-mail-bridge/lib/relay-policy.d.ts new file mode 100644 index 0000000..dd7dd16 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/relay-policy.d.ts @@ -0,0 +1,24 @@ +export function addrName(addr: string): string; + +export interface AutoRelayCtx { + fromHuman: boolean; + replyTo?: string; +} + +export function autoRelayDecision(ctx: AutoRelayCtx): { relay: boolean; reason: string }; + +export interface ReplyInstructionCtx { + fromHuman: boolean; + replyAddress?: string; +} + +export function replyInstruction(ctx: ReplyInstructionCtx): string[]; + +export interface InboundHeadlineCtx { + inReplyTo?: string; + fromHuman: boolean; + catchup?: boolean; + reused?: boolean; +} + +export function inboundHeadline(ctx: InboundHeadlineCtx): string; diff --git a/plugins/dsh-mail-bridge/lib/relay-policy.js b/plugins/dsh-mail-bridge/lib/relay-policy.js new file mode 100644 index 0000000..ae4f48e --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/relay-policy.js @@ -0,0 +1,120 @@ +// 自动转发的**适用范围**,以及据此该给模型说什么话。 +// +// 单独一个文件而不是放在入口里导出:**opencode 会把插件入口模块的每一个导出 +// 都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),多导出一个函数 +// 就会让整个插件加载失败。因此入口只 `export default`,判断逻辑一律搁在这里。 +// +// # 为什么 Agent → Agent 不自动转发 +// +// 自动转发存在的理由是「人不该等模型记得调 send_mail」:人发一封信出去, +// 模型把活干完、话说完,插件替它把结论搬进邮件。收件方是人时这是纯收益。 +// +// 收件方是**另一个 Agent** 时这个理由不成立,而且有害:对方的插件同样会自动 +// 回一封,于是两个模型都以为「我只要把话说完就行」,实际上在持续互相唤醒。 +// 生产实测过一条完整的客套链(pi 转发给 dsh,dsh 回确认,pi 又确认那个确认, +// 一直到第 6 封撞上连续 relay 跳数上限才停): +// +// pi→dsh parent=24be32e5 转发 +// dsh→pi parent=bf79f8fe 已收到转发 +// pi→dsh parent=2700bd0a 收到你的确认 +// dsh→pi parent=34127884 确认闭环 +// pi→dsh parent=34058c13 … +// dsh→pi parent=9590bf16 ← 被 hop 上限拦下 +// +// 每一封都不是错的,每一封都没有新信息。跳数上限是最后一道闸,不是设计意图。 +// +// 因此规则是:**Agent 之间通信必须由模型主动调 send_mail。** +// 插件不再代它开口 —— 该说话的时候它会说,没什么要说的时候就该安静。 +// +// 副作用是好的:模型必须自己决定「这值得回一封信吗」,而那正是它该做的判断。 + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 这一轮的结论该不该由插件自动转发出去。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman 来信方是人类用户(SSE 的 `from_human`) + * @param {string} [ctx.replyTo] 自动转发本来要发给谁 + * @returns {{relay: boolean, reason: string}} + * reason 供日志用 —— 「本轮没有回信」必须能在日志里查到原因, + * 否则它与「模型没说话」「转发失败」三种情形长得一样。 + */ +export function autoRelayDecision(ctx) { + const { fromHuman, replyTo } = ctx || {}; + if (!replyTo) { + return { relay: false, reason: "不知道回给谁" }; + } + if (!fromHuman) { + return { + relay: false, + reason: `来信方 ${addrName(replyTo)} 是 Agent,按约定不自动转发(Agent 间通信须由模型主动 send_mail)`, + }; + } + return { relay: true, reason: "" }; +} + +/** + * 提示词里关于「回信怎么发」的那句话。 + * + * 必须与 `autoRelayDecision` 一致 —— 这是同一件事的两个出口,分开写必然分叉。 + * 而分叉的代价是模型被骗:它以为插件会替它回信,于是把话说完就停手, + * 而实际上那封信永远不会发出去,发件方一直等着。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman + * @param {string} [ctx.replyAddress] 服务端算好的回信地址 + * @returns {string[]} 若干行,直接拼进提示词 + */ +export function replyInstruction(ctx) { + const { fromHuman, replyAddress } = ctx || {}; + if (fromHuman) { + return [ + "**回信不用你自己发**:把这一轮做完、把结论说出来就行,", + "插件会在这一轮结束时把你最后那段话作为回信发回去(不消耗你的发信配额)。", + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", + ]; + } + return [ + "**这封信来自另一个 Agent,插件不会替你回信。**", + "需要回复时你必须自己调用 send_mail" + + (replyAddress ? `(回信地址:${replyAddress})` : "") + ";", + "把话说完并不会让对方收到任何东西。", + "也请先判断这封信是否真的需要回复 —— 单纯的「收到」「确认」会让两个 Agent", + "无休止地互相客套,那对谁都没有价值。有实质结论或有事要问时才回。", + ]; +} + +/** + * 描述「进来的这封是什么」。 + * + * 在此之前提示词一律说「你收到一封新邮件」,于是模型分不清三种处境: + * 有人派了新活、我上封信的回复到了、离线期间积压的补投。 + * 第二种被当成第一种时,模型会把一句「已收到」当成待办再处理一遍。 + * + * @param {object} ctx + * @param {string} [ctx.inReplyTo] 非空 = 这是对本方某封信的回复(SSE 的 `in_reply_to`) + * @param {boolean} ctx.fromHuman + * @param {boolean} [ctx.catchup] 离线期间积压后补投的 + * @param {boolean} [ctx.reused] 投进一条已存在的会话(续谈) + * @returns {string} 提示词第一行 + */ +export function inboundHeadline(ctx) { + const { inReplyTo, fromHuman, catchup, reused } = ctx || {}; + const who = fromHuman ? "" : "(对方是一个 Agent)"; + if (inReplyTo) { + // 「回复到了」与「有人派活」是两种处境。说清楚它,模型才不会把 + // 一句确认当成新任务 —— 那正是互相客套的起点。 + return `你上一封信的**回复**到了${who}。这不是新任务。`; + } + if (catchup) { + return `你收到一封新邮件${who}。说明:这是插件离线期间积压的邮件,现在补投给你。`; + } + if (reused) { + return `本会话收到一封新邮件${who}。`; + } + return `你收到一封新邮件${who}。`; +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 9135dd3..8a96366 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -22,6 +22,11 @@ import { shouldSkipAutoRelay, } from '../lib/relay-dedup.js'; import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; +import { + autoRelayDecision, + replyInstruction, + inboundHeadline, +} from '../lib/relay-policy.js'; import { userMessage, replySubject, @@ -145,7 +150,13 @@ class GatewayClient { const sessionMap = new Map(); const reverseMap = new Map(); const mailDrivenSessions = new Set(); -const mailContexts = new Map(); +// 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号: +// 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js), +// 后者决定提示词说「新任务」还是「你上封信的回复到了」。 +const mailContexts = new Map(); const relayedSummaries = new Map(); // 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 @@ -582,16 +593,18 @@ export function apply(ctx: any, config: PluginConfig): void { if (kind === 'permission') { return `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。`; } + const fromHuman = data.from_human === true; return [ - `本会话收到一封新邮件(AgentMail)。`, + inboundHeadline({ inReplyTo: data.in_reply_to, fromHuman, reused: true }), ``, `发件人:${data.from_name || 'unknown'}`, `主题:${data.subject || '(无主题)'}`, `邮件 ID:${data.mail_id || 'unknown'}`, + ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), + ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, - `回信不用你自己发:把这一轮做完、把结论说出来就行,`, - `插件会在轮次结束时把你最后那段话发回给 ${data.from_name || '发件人'}(不消耗配额)。`, + ...replyInstruction({ fromHuman, replyAddress: data.reply_address }), ].join('\n'); } @@ -610,6 +623,8 @@ export function apply(ctx: any, config: PluginConfig): void { replyTo: data.from_name || '', subject: data.subject || '', mailID: data.mail_id || '', + fromHuman: data.from_human === true, + inReplyTo: data.in_reply_to || '', }); } @@ -682,19 +697,30 @@ export function apply(ctx: any, config: PluginConfig): void { replyTo: data.from_name || '', subject: data.subject || '', mailID: data.mail_id || '', + fromHuman: data.from_human === true, + inReplyTo: data.in_reply_to || '', }); } const promptText = kind === 'permission' ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。` : [ - `本会话收到一封新邮件(AgentMail 续谈)。`, + inboundHeadline({ + inReplyTo: data.in_reply_to, + fromHuman: data.from_human === true, + reused: true, + }), ``, `发件人:${data.from_name || 'unknown'}`, `主题:${data.subject || '(无主题)'}`, `邮件 ID:${data.mail_id || 'unknown'}`, + ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), + ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, - `回信不用你自己发:把这一轮做完、把结论说出来就行。`, + ...replyInstruction({ + fromHuman: data.from_human === true, + replyAddress: data.reply_address, + }), ].join('\n'); live.followup(userMessage(promptText)); // 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封, @@ -725,18 +751,26 @@ export function apply(ctx: any, config: PluginConfig): void { const promptText = kind === 'permission' ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。` : [ - `你收到一封新邮件(AgentMail)。`, + inboundHeadline({ + inReplyTo: data.in_reply_to, + fromHuman: data.from_human === true, + catchup: data.catchup, + reused: false, + }), ``, `发件人:${data.from_name || 'unknown'}`, `主题:${data.subject || '(无主题)'}`, `邮件 ID:${data.mail_id || 'unknown'}`, + ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), `身份:你是 ${AGENT_NAME}`, + ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, ``, - `**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`, - `插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || '发件人'}(不消耗你的发信配额)。`, - `只有在需要主动联系其他人、或要带附件时才调用 send_mail。`, + ...replyInstruction({ + fromHuman: data.from_human === true, + replyAddress: data.reply_address, + }), ].join('\n'); // 按管理员划定的范围逐个尝试建 agent,全部失败才回一封说明原因的邮件。 @@ -778,6 +812,8 @@ export function apply(ctx: any, config: PluginConfig): void { replyTo: data.from_name || '', subject: data.subject || '', mailID: data.mail_id || '', + fromHuman: data.from_human === true, + inReplyTo: data.in_reply_to || '', }); } @@ -1326,7 +1362,24 @@ export function apply(ctx: any, config: PluginConfig): void { const mailSessionID = reverseMap.get(String(agent.id)); if (!mailSessionID) return; const mctx = mailContexts.get(mailSessionID); - if (!mctx?.replyTo) return; + + // **只给人类来信自动转发**(见 lib/relay-policy.js)。 + // + // 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为「我只要 + // 把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮直到 + // 撞上连续 relay 跳数上限。日志走 console.error:ctx.logger 不进 journalctl, + // 而「本轮为何没有回信」必须能查到。 + const policy = autoRelayDecision({ + fromHuman: mctx?.fromHuman === true, + replyTo: mctx?.replyTo, + }); + if (!policy.relay) { + console.error(`[dsh-mail-bridge] 本轮不自动转发:${policy.reason}`); + return; + } + // policy.relay 为真已经蕴含 mctx?.replyTo 非空(autoRelayDecision 的第一道判定), + // 但 TS 推不出那层关系 —— 显式窄一下,而不是给后面每处加非空断言。 + if (!mctx) return; // 取最后一条 assistant 消息的文本 const events = agent.session?.events ?? []; diff --git a/plugins/dsh-mail-bridge/test/relay-policy.test.mjs b/plugins/dsh-mail-bridge/test/relay-policy.test.mjs new file mode 100644 index 0000000..20f0d4b --- /dev/null +++ b/plugins/dsh-mail-bridge/test/relay-policy.test.mjs @@ -0,0 +1,131 @@ +/** + * 自动转发适用范围的判定(lib/relay-policy.js)。 + * + * 三个函数是同一件事的三个出口,必须一起看: + * - autoRelayDecision 插件该不该替模型把结论发出去 + * - replyInstruction 提示词里怎么跟模型说这件事 + * - inboundHeadline 进来的这封是新活、是回复、还是补投 + * + * 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完 + * 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addrName, + autoRelayDecision, + replyInstruction, + inboundHeadline, +} from '../lib/relay-policy.js'; + +// ─── addrName ─── + +test('addrName 取三维地址的名字段', () => { + assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi'); + assert.equal(addrName('jianf'), 'jianf'); + assert.equal(addrName(' dsh@/x '), 'dsh'); + assert.equal(addrName(''), ''); + assert.equal(addrName(undefined), ''); +}); + +// ─── autoRelayDecision ─── + +test('人类来信 → 自动转发', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }); + assert.equal(d.relay, true); +}); + +test('Agent 来信 → 不自动转发', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' }); + assert.equal(d.relay, false, + 'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒'); + assert.match(d.reason, /dsh/, '日志要说清是谁'); + assert.match(d.reason, /Agent/); +}); + +test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: '' }); + assert.equal(d.relay, false); + assert.match(d.reason, /不知道回给谁/, + '「本轮没有回信」有三种原因,日志里必须能分辨'); +}); + +test('replyTo 带三维地址时也能认出 Agent 名', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' }); + assert.equal(d.relay, false); + assert.match(d.reason, /opencode/); +}); + +test('缺省参数不抛错(畸形事件不该弄死投递)', () => { + assert.equal(autoRelayDecision().relay, false); + assert.equal(autoRelayDecision({}).relay, false); +}); + +// ─── replyInstruction 与 autoRelayDecision 的一致性 ─── + +test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => { + const lines = replyInstruction({ fromHuman: true }); + const text = lines.join('\n'); + assert.match(text, /回信不用你自己发/); + assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true, + '承诺了就必须真的做'); +}); + +test('Agent 来信的提示词必须明说「插件不会替你回信」', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /不会替你回信/); + assert.match(text, /send_mail/, '必须给出唯一可行的做法'); + assert.doesNotMatch(text, /回信不用你自己发/, + '这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手'); +}); + +test('Agent 来信的提示词要劝阻纯客套', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /收到|确认/, '要点名那种没有信息量的回复'); + assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻'); +}); + +test('Agent 来信时把回信地址带进提示词(有就带)', () => { + const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n'); + assert.match(withAddr, /dsh@\/x\.别名/, + '要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话'); + const without = replyInstruction({ fromHuman: false }).join('\n'); + assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号'); +}); + +// ─── inboundHeadline ─── + +test('回复到了 → 明说「这不是新任务」', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false }); + assert.match(h, /回复/); + assert.match(h, /不是新任务/, + '把回复当新任务处理正是互相客套的起点'); +}); + +test('回复的标题优先于续谈/补投标记', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true }); + assert.match(h, /回复/, 'in_reply_to 是最强信号'); +}); + +test('Agent 来信在标题里就标出来', () => { + assert.match(inboundHeadline({ fromHuman: false }), /Agent/); + assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/, + '人类来信不该带这个括号 —— 那是噪音'); +}); + +test('补投要说明,否则模型按「刚到的」语气回', () => { + const h = inboundHeadline({ fromHuman: true, catchup: true }); + assert.match(h, /积压|补投/); +}); + +test('续谈与新会话的措辞不同', () => { + assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/); + assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/); +}); + +test('缺省参数不抛错', () => { + assert.equal(typeof inboundHeadline(), 'string'); + assert.equal(typeof inboundHeadline({}), 'string'); +}); diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 199ad51..c7e10ee 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -530,6 +530,12 @@ func (p *Plugin) catchUp(pending int) { Subject string `json:"subject"` MailType string `json:"mail_type"` ReplyTo string `json:"reply_to"` + // 补拉路径也必须知道发件方是人还是 Agent:Agent 之间不自动回信。 + // 缺了它补投的邮件会被保守当成 Agent 来信,于是人发的那封失去自动回复。 + FromHuman bool `json:"from_human"` + // parent_mail_id 非空 = 这封是回信。收件箱返回的字段名是它, + // 而 SSE 事件里叫 in_reply_to —— 两个名字指同一件事。 + ParentMailID string `json:"parent_mail_id"` } `json:"mails"` } url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit) @@ -586,14 +592,18 @@ func (p *Plugin) catchUp(pending int) { if resumed { prefix = resumeNote(m.MailID) } + replyLine := "" + if m.ParentMailID != "" { + replyLine = fmt.Sprintf("回的是你那封:%s\n", m.ParentMailID) + } prompt := prefix + fmt.Sprintf( - "你收到一封新邮件(AgentMail)。\n\n"+ - "发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+ + "%s\n\n"+ + "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ - "**回信不用你自己发**:你把结论说出来就行,\n"+ - "插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+ - "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", - m.FromName, m.Subject, m.MailID, p.agentName, m.FromName, + "%s", + inboundHeadline(m.ParentMailID, m.FromHuman, true), + m.FromName, m.Subject, m.MailID, replyLine, p.agentName, + replyInstruction(m.FromHuman, ""), ) reply := p.sdk.InjectInputSync(p.name, p.name, prompt) @@ -615,6 +625,16 @@ func (p *Plugin) catchUp(pending int) { continue } + // **只给人类来信自动转发**(见 relay_policy.go)。 + // 与 SSE 路径同一取舍 —— 两条路径行为不一致的话,同一封邮件“走 SSE 还是 + // 走补投”就能決定发件方有没有回信,而那取决于重启时机。 + if d := autoRelayDecision(m.FromHuman, m.FromName); !d.relay { + log.Printf("[homeagent-mail-bridge] 补投 %s 不自动转发:%s", + shortID(m.MailID), d.reason) + p.ledger.complete(m.MailID) + continue + } + // B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额 if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil { // 回信没发出去 —— 不标完成,下次重启重试。 @@ -627,6 +647,30 @@ func (p *Plugin) catchUp(pending int) { // ─── SSE ─── +// mailEvent 是 SSE 事件里与邮件有关的字段。 +// +// 提成命名类型而不是三处各写一遍匿名 struct:加字段时必须三处同时改, +// 而漏改一处的表现是「类型不匹配」编译错误里塞满两串几乎相同的字段列表 —— +// 本轮加 from_human / in_reply_to 时就踩了一次。 +type mailEvent struct { + MailID string `json:"mail_id"` + SessionID string `json:"session_id"` + FromName string `json:"from_name"` + Subject string `json:"subject"` + MailType string `json:"mail_type"` + Role string `json:"role"` + Workspace string `json:"to_workspace"` + Alias string `json:"session_alias"` + ReplyAddr string `json:"reply_address"` + // FromHuman 决定要不要自动回信:Agent 之间不自动回(见 relay_policy.go)。 + FromHuman bool `json:"from_human"` + // InReplyTo 非空 = 这封是对本方某封信的回复,不是新派的活。 + InReplyTo string `json:"in_reply_to"` + // Decision / DecidedBy 只在 permission_decision 事件上有值。 + Decision string `json:"decision"` + DecidedBy string `json:"decided_by"` +} + func (p *Plugin) sseLoop() { for { select { @@ -733,17 +777,7 @@ func (p *Plugin) parseSSELine(line string) { return } - var evt struct { - MailID string `json:"mail_id"` - SessionID string `json:"session_id"` - FromName string `json:"from_name"` - Subject string `json:"subject"` - MailType string `json:"mail_type"` - Role string `json:"role"` - Workspace string `json:"to_workspace"` - Alias string `json:"session_alias"` - ReplyAddr string `json:"reply_address"` - } + var evt mailEvent if err := json.Unmarshal([]byte(raw), &evt); err != nil { return } @@ -882,17 +916,7 @@ func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) { // ─── 新邮件处理 ─── -func (p *Plugin) handleNewMail(evt struct { - MailID string `json:"mail_id"` - SessionID string `json:"session_id"` - FromName string `json:"from_name"` - Subject string `json:"subject"` - MailType string `json:"mail_type"` - Role string `json:"role"` - Workspace string `json:"to_workspace"` - Alias string `json:"session_alias"` - ReplyAddr string `json:"reply_address"` -}, resumed bool) { +func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { // resumed = 上一个进程注入过这封但那一轮被中断了。 // // 必须把这件事告诉模型:不说的话它在上下文里看到两段几乎相同的指令, @@ -903,14 +927,22 @@ func (p *Plugin) handleNewMail(evt struct { log.Printf("[homeagent-mail-bridge] 邮件 %s 重投(上一轮被中断)", shortID(evt.MailID)) } + replyLine := "" + if evt.InReplyTo != "" { + replyLine = fmt.Sprintf("回的是你那封:%s\n", evt.InReplyTo) + } + addrLine := "" + if evt.ReplyAddr != "" { + addrLine = fmt.Sprintf("回信地址:%s\n", evt.ReplyAddr) + } prompt := prefix + fmt.Sprintf( - "你收到一封新邮件(AgentMail)。\n\n"+ - "发件人:%s\n主题:%s\n邮件 ID:%s\n身份:你是 %s\n\n"+ + "%s\n\n"+ + "发件人:%s\n主题:%s\n邮件 ID:%s\n%s身份:你是 %s\n%s\n"+ "请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+ - "**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+ - "插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+ - "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", - evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName, + "%s", + inboundHeadline(evt.InReplyTo, evt.FromHuman, false), + evt.FromName, evt.Subject, evt.MailID, replyLine, p.agentName, addrLine, + replyInstruction(evt.FromHuman, evt.ReplyAddr), ) // InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。 @@ -944,6 +976,18 @@ func (p *Plugin) handleNewMail(evt struct { return } + // **只给人类来信自动转发**(见 relay_policy.go)。 + // + // 对方是 Agent 时它那边的插件也会自动回一封,两个模型都以为「我只要把话 + // 说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮。 + // 此时这一封算处理完:模型已经看过并做完了活,没有回信是设计意图而不是失败。 + if d := autoRelayDecision(evt.FromHuman, evt.FromName); !d.relay { + log.Printf("[homeagent-mail-bridge] 邮件 %s 本轮不自动转发:%s", + shortID(evt.MailID), d.reason) + p.ledger.complete(evt.MailID) + return + } + // B-5.2:自动回信带 relay:"summary" + relay_key if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil { // 回信没发出去 —— **不标完成**,让下次重启能重试。 @@ -966,17 +1010,7 @@ func (p *Plugin) handleNewMail(evt struct { // ─── 权限决策 ─── -func (p *Plugin) handlePermissionDecision(evt struct { - MailID string `json:"mail_id"` - SessionID string `json:"session_id"` - FromName string `json:"from_name"` - Subject string `json:"subject"` - MailType string `json:"mail_type"` - Role string `json:"role"` - Workspace string `json:"to_workspace"` - Alias string `json:"session_alias"` - ReplyAddr string `json:"reply_address"` -}) { +func (p *Plugin) handlePermissionDecision(evt mailEvent) { prompt := fmt.Sprintf( "你之前发起的权限请求已有结论:%s(决策人:%s)。请据此继续。", evt.Subject, evt.FromName, diff --git a/plugins/homeagent-mail-bridge/relay_policy.go b/plugins/homeagent-mail-bridge/relay_policy.go new file mode 100644 index 0000000..39f96a4 --- /dev/null +++ b/plugins/homeagent-mail-bridge/relay_policy.go @@ -0,0 +1,96 @@ +package main + +// 自动转发的适用范围,以及据此该给模型说什么话。 +// +// 这是三个 Node 插件里 `lib/relay-policy.js` 的 Go 对应物。**不能共用那个文件** +// (homeagent 是 Go 子进程插件),但语义必须一致 —— 因此这里把那边的注释与判据 +// 原样搬过来,`relay_policy_test.go` 逐条钉住。 +// +// # 为什么 Agent → Agent 不自动转发 +// +// 自动转发存在的理由是「人不该等模型记得调 send_mail」:人发一封信出去, +// 模型把活干完、话说完,插件替它把结论搬进邮件。收件方是人时这是纯收益。 +// +// 收件方是**另一个 Agent** 时这个理由不成立,而且有害:对方的插件同样会自动 +// 回一封,于是两个模型都以为「我只要把话说完就行」,实际上在持续互相唤醒。 +// 生产实测过一条完整的客套链(pi 转发给 dsh,dsh 回确认,pi 又确认那个确认, +// 一直到第 6 封撞上连续 relay 跳数上限才停): +// +// pi→dsh parent=24be32e5 转发 +// dsh→pi parent=bf79f8fe 已收到转发 +// pi→dsh parent=2700bd0a 收到你的确认 +// dsh→pi parent=34127884 确认闭环 +// pi→dsh parent=34058c13 … +// dsh→pi parent=9590bf16 ← 被 hop 上限拦下 +// +// 每一封都不是错的,每一封都没有新信息。跳数上限是最后一道闸,不是设计意图。 +// +// 因此规则是:**Agent 之间通信必须由模型主动调 send_mail。** + +import "fmt" + +// relayDecision 是「这一轮的结论该不该由插件自动转发出去」的判定结果。 +type relayDecision struct { + relay bool + // reason 供日志用 —— 「本轮没有回信」必须能在日志里查到原因, + // 否则它与「模型没说话」「转发失败」三种情形长得一样。 + reason string +} + +// autoRelayDecision 判定要不要自动转发。 +// +// fromHuman 来自 SSE 的 `from_human`;replyTo 是本来要回给谁。 +func autoRelayDecision(fromHuman bool, replyTo string) relayDecision { + if replyTo == "" { + return relayDecision{false, "不知道回给谁"} + } + if !fromHuman { + return relayDecision{false, fmt.Sprintf( + "来信方 %s 是 Agent,按约定不自动转发(Agent 间通信须由模型主动 send_mail)", + replyTo)} + } + return relayDecision{true, ""} +} + +// replyInstruction 是提示词里关于「回信怎么发」的那几句话。 +// +// 必须与 autoRelayDecision 一致 —— 这是同一件事的两个出口,分开写必然分叉。 +// 而分叉的代价是模型被骗:它以为插件会替它回信,于是把话说完就停手, +// 而实际上那封信永远不会发出去,发件方一直等着。 +func replyInstruction(fromHuman bool, replyAddress string) string { + if fromHuman { + return "**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n" + + "插件会在这一轮结束时自动把你最后那段话作为回信发回去(不消耗你的发信配额)。\n" + + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。" + } + addr := "" + if replyAddress != "" { + addr = fmt.Sprintf("(回信地址:%s)", replyAddress) + } + return "**这封信来自另一个 Agent,插件不会替你回信。**\n" + + fmt.Sprintf("需要回复时你必须自己调用 send_mail%s;\n", addr) + + "把话说完并不会让对方收到任何东西。\n" + + "也请先判断这封信是否真的需要回复 —— 单纯的「收到」「确认」会让两个 Agent\n" + + "无休止地互相客套,那对谁都没有价值。有实质结论或有事要问时才回。" +} + +// inboundHeadline 描述「进来的这封是什么」。 +// +// 在此之前提示词一律说「你收到一封新邮件」,于是模型分不清三种处境: +// 有人派了新活、我上封信的回复到了、离线期间积压的补投。 +// 第二种被当成第一种时,模型会把一句「已收到」当成待办再处理一遍。 +func inboundHeadline(inReplyTo string, fromHuman, catchup bool) string { + who := "" + if !fromHuman { + who = "(对方是一个 Agent)" + } + if inReplyTo != "" { + // 「回复到了」与「有人派活」是两种处境。说清楚它,模型才不会把 + // 一句确认当成新任务 —— 那正是互相客套的起点。 + return fmt.Sprintf("你上一封信的**回复**到了%s。这不是新任务。", who) + } + if catchup { + return fmt.Sprintf("你收到一封新邮件%s。说明:这是插件离线期间积压的邮件,现在补投给你。", who) + } + return fmt.Sprintf("你收到一封新邮件%s。", who) +} diff --git a/plugins/homeagent-mail-bridge/relay_policy_test.go b/plugins/homeagent-mail-bridge/relay_policy_test.go new file mode 100644 index 0000000..c8da6ce --- /dev/null +++ b/plugins/homeagent-mail-bridge/relay_policy_test.go @@ -0,0 +1,122 @@ +package main + +// relay_policy.go 的单元测试。 +// +// 这三个函数是 Node 侧 lib/relay-policy.js 的 Go 对应物,**语义必须一致** —— +// 一个平台自动回信、另一个不回,同一条 Agent 链的行为就取决于对方是谁, +// 那是无法排查的。所以这里的判据与那边的 relay-policy.test.mjs 逐条对齐。 + +import ( + "strings" + "testing" +) + +func TestAutoRelay_HumanSenderRelays(t *testing.T) { + d := autoRelayDecision(true, "jianf") + if !d.relay { + t.Fatalf("人类来信必须自动转发,got reason=%q", d.reason) + } +} + +func TestAutoRelay_AgentSenderDoesNot(t *testing.T) { + d := autoRelayDecision(false, "dsh") + if d.relay { + t.Fatal("Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒") + } + if !strings.Contains(d.reason, "dsh") { + t.Errorf("日志要说清是谁,got %q", d.reason) + } + if !strings.Contains(d.reason, "Agent") { + t.Errorf("理由要点明这是 Agent 来信,got %q", d.reason) + } +} + +// 「本轮没有回信」有三种原因,日志里必须能分辨。 +func TestAutoRelay_NoRecipientHasDistinctReason(t *testing.T) { + d := autoRelayDecision(true, "") + if d.relay { + t.Fatal("不知道回给谁时不该发") + } + if !strings.Contains(d.reason, "不知道回给谁") { + t.Errorf("理由必须与「对方是 Agent」区分得开,got %q", d.reason) + } +} + +func TestReplyInstruction_HumanPromisesAutoRelay(t *testing.T) { + s := replyInstruction(true, "") + if !strings.Contains(s, "回信不用你自己发") { + t.Errorf("人类来信要告诉模型插件会代劳,got %q", s) + } + // 承诺了就必须真的做 + if !autoRelayDecision(true, "jianf").relay { + t.Error("提示词与决策不一致 —— 承诺了自动转发却不转") + } +} + +func TestReplyInstruction_AgentSaysPluginWontReply(t *testing.T) { + s := replyInstruction(false, "") + if strings.Contains(s, "回信不用你自己发") { + t.Error("这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手") + } + if !strings.Contains(s, "不会替你回信") { + t.Errorf("必须明说插件不代劳,got %q", s) + } + if !strings.Contains(s, "send_mail") { + t.Errorf("必须给出唯一可行的做法,got %q", s) + } +} + +func TestReplyInstruction_AgentDiscouragesEmptyCourtesy(t *testing.T) { + s := replyInstruction(false, "") + if !strings.Contains(s, "收到") || !strings.Contains(s, "确认") { + t.Errorf("要点名那种没有信息量的回复,got %q", s) + } + if !strings.Contains(s, "互相客套") { + t.Errorf("要说清后果,否则模型不知道为什么被劝阻,got %q", s) + } +} + +func TestReplyInstruction_CarriesReplyAddress(t *testing.T) { + with := replyInstruction(false, "dsh@/x.别名") + if !strings.Contains(with, "dsh@/x.别名") { + t.Error("要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话") + } + without := replyInstruction(false, "") + if strings.Contains(without, "(回信地址:)") { + t.Error("没有地址时不该留一个空括号") + } +} + +func TestInboundHeadline_ReplyIsNotNewWork(t *testing.T) { + h := inboundHeadline("m-1", false, false) + if !strings.Contains(h, "回复") { + t.Errorf("要说清这是回复,got %q", h) + } + if !strings.Contains(h, "不是新任务") { + t.Errorf("把回复当新任务处理正是互相客套的起点,got %q", h) + } +} + +// in_reply_to 是最强信号:它同时出现时压过补投标记。 +func TestInboundHeadline_ReplyBeatsCatchup(t *testing.T) { + h := inboundHeadline("m-1", true, true) + if !strings.Contains(h, "回复") { + t.Errorf("in_reply_to 优先,got %q", h) + } +} + +func TestInboundHeadline_MarksAgentSender(t *testing.T) { + if !strings.Contains(inboundHeadline("", false, false), "Agent") { + t.Error("Agent 来信要在标题里标出来") + } + if strings.Contains(inboundHeadline("", true, false), "Agent") { + t.Error("人类来信不该带这个括号 —— 那是噪音") + } +} + +func TestInboundHeadline_CatchupExplained(t *testing.T) { + h := inboundHeadline("", true, true) + if !strings.Contains(h, "积压") { + t.Errorf("补投要说明,否则模型按「刚到的」语气回,got %q", h) + } +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index d5f0dc6..2c2135d 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -34,6 +34,7 @@ import { shouldSkipAutoRelay, } from "./lib/relay-dedup.js"; import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js"; +import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js"; import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js"; // opencode 原生支持三态权限,免批由它自己记(response:"always"), // 所以这里只借用决策文本的判定,不需要 createGrantStore。 @@ -706,6 +707,23 @@ async function relaySummary(client, directory, sessionID) { if (!mailSessionID) return null; // 不是邮件驱动的会话,不碰 if (!mailDrivenSessions.has(sessionID)) return null; + // 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。 + const ctx = mailContexts.get(mailSessionID); + + // **只给人类来信自动转发**(见 lib/relay-policy.js)。 + // + // 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为「我只要 + // 把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh 客套 6 轮直到 + // 撞上连续 relay 跳数上限。放在取消息列表**之前**判:少一次网关往返。 + const policy = autoRelayDecision({ + fromHuman: ctx?.fromHuman === true, + replyTo: ctx?.replyTo, + }); + if (!policy.relay) { + console.error(`[mail-bridge] 本轮不自动转发:${policy.reason}`); + return null; + } + // 取最后一条 assistant 文本消息 const listed = await client.session.messages({ path: { id: sessionID }, @@ -731,10 +749,6 @@ async function relaySummary(client, directory, sessionID) { // 本地去重(服务端另有 relay_key 幂等兜底,这里只是少打一次网关) if (relayedSummaries.get(sessionID) === last.id) return null; - // 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。 - const ctx = mailContexts.get(mailSessionID); - if (!ctx?.replyTo) return null; - // 模型这一轮已经亲手回过这条线索 → 不再自动转发。 // // 否则收件箱里会出现两封说同一件事的邮件(生产实测:311 字节与 342 字节各一封, @@ -831,31 +845,44 @@ async function deliverMail(client, directory, data, kind) { // 不清的话,上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。 explicitSends.delete(sessionID); - // 记住这轮该回给谁:idle 时 relaySummary 靠它决定收件人与 reply_to。 + // 回信上下文:idle 时 relaySummary 靠它决定收件人与 reply_to。 // 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。 + // + // 多记两个服务端信号: + // fromHuman —— 只有人类来信才自动转发(见 lib/relay-policy.js) + // inReplyTo —— 这封是不是对本方某封信的回复 + // 不存进上下文的话,relaySummary 那一刻拿不到它们(那个函数只有 sessionID)。 if (kind === "mail" && data.session_id) { mailContexts.set(data.session_id, { replyTo: data.from_name || "", subject: data.subject || "", mailID: data.mail_id || "", + fromHuman: data.from_human === true, + inReplyTo: data.in_reply_to || "", }); } + const fromHuman = data.from_human === true; const text = kind === "permission" ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。` : [ - reused ? `本会话收到一封新邮件(AgentMail 续谈)。` : `你收到一封新邮件(AgentMail)。`, + inboundHeadline({ + inReplyTo: data.in_reply_to, + fromHuman, + catchup: data.catchup, + reused, + }), ``, `发件人:${data.from_name || "unknown"}`, `主题:${data.subject || "(无主题)"}`, `邮件 ID:${data.mail_id || "unknown"}`, + ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), `身份:你是 ${AGENT_NAME}`, + ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, ``, - `**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`, - `插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || "发件人"}(不消耗你的发信配额)。`, - `只有在需要主动联系其他人、或要带附件时才调用 send_mail。`, + ...replyInstruction({ fromHuman, replyAddress: data.reply_address }), ].join("\n"); // 按管理员划定的范围逐个尝试,全部失败才回一封说明失败原因的邮件。 diff --git a/plugins/opencode-mail-bridge/lib/relay-policy.js b/plugins/opencode-mail-bridge/lib/relay-policy.js new file mode 100644 index 0000000..ae4f48e --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/relay-policy.js @@ -0,0 +1,120 @@ +// 自动转发的**适用范围**,以及据此该给模型说什么话。 +// +// 单独一个文件而不是放在入口里导出:**opencode 会把插件入口模块的每一个导出 +// 都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),多导出一个函数 +// 就会让整个插件加载失败。因此入口只 `export default`,判断逻辑一律搁在这里。 +// +// # 为什么 Agent → Agent 不自动转发 +// +// 自动转发存在的理由是「人不该等模型记得调 send_mail」:人发一封信出去, +// 模型把活干完、话说完,插件替它把结论搬进邮件。收件方是人时这是纯收益。 +// +// 收件方是**另一个 Agent** 时这个理由不成立,而且有害:对方的插件同样会自动 +// 回一封,于是两个模型都以为「我只要把话说完就行」,实际上在持续互相唤醒。 +// 生产实测过一条完整的客套链(pi 转发给 dsh,dsh 回确认,pi 又确认那个确认, +// 一直到第 6 封撞上连续 relay 跳数上限才停): +// +// pi→dsh parent=24be32e5 转发 +// dsh→pi parent=bf79f8fe 已收到转发 +// pi→dsh parent=2700bd0a 收到你的确认 +// dsh→pi parent=34127884 确认闭环 +// pi→dsh parent=34058c13 … +// dsh→pi parent=9590bf16 ← 被 hop 上限拦下 +// +// 每一封都不是错的,每一封都没有新信息。跳数上限是最后一道闸,不是设计意图。 +// +// 因此规则是:**Agent 之间通信必须由模型主动调 send_mail。** +// 插件不再代它开口 —— 该说话的时候它会说,没什么要说的时候就该安静。 +// +// 副作用是好的:模型必须自己决定「这值得回一封信吗」,而那正是它该做的判断。 + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 这一轮的结论该不该由插件自动转发出去。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman 来信方是人类用户(SSE 的 `from_human`) + * @param {string} [ctx.replyTo] 自动转发本来要发给谁 + * @returns {{relay: boolean, reason: string}} + * reason 供日志用 —— 「本轮没有回信」必须能在日志里查到原因, + * 否则它与「模型没说话」「转发失败」三种情形长得一样。 + */ +export function autoRelayDecision(ctx) { + const { fromHuman, replyTo } = ctx || {}; + if (!replyTo) { + return { relay: false, reason: "不知道回给谁" }; + } + if (!fromHuman) { + return { + relay: false, + reason: `来信方 ${addrName(replyTo)} 是 Agent,按约定不自动转发(Agent 间通信须由模型主动 send_mail)`, + }; + } + return { relay: true, reason: "" }; +} + +/** + * 提示词里关于「回信怎么发」的那句话。 + * + * 必须与 `autoRelayDecision` 一致 —— 这是同一件事的两个出口,分开写必然分叉。 + * 而分叉的代价是模型被骗:它以为插件会替它回信,于是把话说完就停手, + * 而实际上那封信永远不会发出去,发件方一直等着。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman + * @param {string} [ctx.replyAddress] 服务端算好的回信地址 + * @returns {string[]} 若干行,直接拼进提示词 + */ +export function replyInstruction(ctx) { + const { fromHuman, replyAddress } = ctx || {}; + if (fromHuman) { + return [ + "**回信不用你自己发**:把这一轮做完、把结论说出来就行,", + "插件会在这一轮结束时把你最后那段话作为回信发回去(不消耗你的发信配额)。", + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", + ]; + } + return [ + "**这封信来自另一个 Agent,插件不会替你回信。**", + "需要回复时你必须自己调用 send_mail" + + (replyAddress ? `(回信地址:${replyAddress})` : "") + ";", + "把话说完并不会让对方收到任何东西。", + "也请先判断这封信是否真的需要回复 —— 单纯的「收到」「确认」会让两个 Agent", + "无休止地互相客套,那对谁都没有价值。有实质结论或有事要问时才回。", + ]; +} + +/** + * 描述「进来的这封是什么」。 + * + * 在此之前提示词一律说「你收到一封新邮件」,于是模型分不清三种处境: + * 有人派了新活、我上封信的回复到了、离线期间积压的补投。 + * 第二种被当成第一种时,模型会把一句「已收到」当成待办再处理一遍。 + * + * @param {object} ctx + * @param {string} [ctx.inReplyTo] 非空 = 这是对本方某封信的回复(SSE 的 `in_reply_to`) + * @param {boolean} ctx.fromHuman + * @param {boolean} [ctx.catchup] 离线期间积压后补投的 + * @param {boolean} [ctx.reused] 投进一条已存在的会话(续谈) + * @returns {string} 提示词第一行 + */ +export function inboundHeadline(ctx) { + const { inReplyTo, fromHuman, catchup, reused } = ctx || {}; + const who = fromHuman ? "" : "(对方是一个 Agent)"; + if (inReplyTo) { + // 「回复到了」与「有人派活」是两种处境。说清楚它,模型才不会把 + // 一句确认当成新任务 —— 那正是互相客套的起点。 + return `你上一封信的**回复**到了${who}。这不是新任务。`; + } + if (catchup) { + return `你收到一封新邮件${who}。说明:这是插件离线期间积压的邮件,现在补投给你。`; + } + if (reused) { + return `本会话收到一封新邮件${who}。`; + } + return `你收到一封新邮件${who}。`; +} diff --git a/plugins/opencode-mail-bridge/test/relay-policy.test.mjs b/plugins/opencode-mail-bridge/test/relay-policy.test.mjs new file mode 100644 index 0000000..20f0d4b --- /dev/null +++ b/plugins/opencode-mail-bridge/test/relay-policy.test.mjs @@ -0,0 +1,131 @@ +/** + * 自动转发适用范围的判定(lib/relay-policy.js)。 + * + * 三个函数是同一件事的三个出口,必须一起看: + * - autoRelayDecision 插件该不该替模型把结论发出去 + * - replyInstruction 提示词里怎么跟模型说这件事 + * - inboundHeadline 进来的这封是新活、是回复、还是补投 + * + * 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完 + * 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addrName, + autoRelayDecision, + replyInstruction, + inboundHeadline, +} from '../lib/relay-policy.js'; + +// ─── addrName ─── + +test('addrName 取三维地址的名字段', () => { + assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi'); + assert.equal(addrName('jianf'), 'jianf'); + assert.equal(addrName(' dsh@/x '), 'dsh'); + assert.equal(addrName(''), ''); + assert.equal(addrName(undefined), ''); +}); + +// ─── autoRelayDecision ─── + +test('人类来信 → 自动转发', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }); + assert.equal(d.relay, true); +}); + +test('Agent 来信 → 不自动转发', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' }); + assert.equal(d.relay, false, + 'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒'); + assert.match(d.reason, /dsh/, '日志要说清是谁'); + assert.match(d.reason, /Agent/); +}); + +test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: '' }); + assert.equal(d.relay, false); + assert.match(d.reason, /不知道回给谁/, + '「本轮没有回信」有三种原因,日志里必须能分辨'); +}); + +test('replyTo 带三维地址时也能认出 Agent 名', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' }); + assert.equal(d.relay, false); + assert.match(d.reason, /opencode/); +}); + +test('缺省参数不抛错(畸形事件不该弄死投递)', () => { + assert.equal(autoRelayDecision().relay, false); + assert.equal(autoRelayDecision({}).relay, false); +}); + +// ─── replyInstruction 与 autoRelayDecision 的一致性 ─── + +test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => { + const lines = replyInstruction({ fromHuman: true }); + const text = lines.join('\n'); + assert.match(text, /回信不用你自己发/); + assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true, + '承诺了就必须真的做'); +}); + +test('Agent 来信的提示词必须明说「插件不会替你回信」', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /不会替你回信/); + assert.match(text, /send_mail/, '必须给出唯一可行的做法'); + assert.doesNotMatch(text, /回信不用你自己发/, + '这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手'); +}); + +test('Agent 来信的提示词要劝阻纯客套', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /收到|确认/, '要点名那种没有信息量的回复'); + assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻'); +}); + +test('Agent 来信时把回信地址带进提示词(有就带)', () => { + const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n'); + assert.match(withAddr, /dsh@\/x\.别名/, + '要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话'); + const without = replyInstruction({ fromHuman: false }).join('\n'); + assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号'); +}); + +// ─── inboundHeadline ─── + +test('回复到了 → 明说「这不是新任务」', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false }); + assert.match(h, /回复/); + assert.match(h, /不是新任务/, + '把回复当新任务处理正是互相客套的起点'); +}); + +test('回复的标题优先于续谈/补投标记', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true }); + assert.match(h, /回复/, 'in_reply_to 是最强信号'); +}); + +test('Agent 来信在标题里就标出来', () => { + assert.match(inboundHeadline({ fromHuman: false }), /Agent/); + assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/, + '人类来信不该带这个括号 —— 那是噪音'); +}); + +test('补投要说明,否则模型按「刚到的」语气回', () => { + const h = inboundHeadline({ fromHuman: true, catchup: true }); + assert.match(h, /积压|补投/); +}); + +test('续谈与新会话的措辞不同', () => { + assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/); + assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/); +}); + +test('缺省参数不抛错', () => { + assert.equal(typeof inboundHeadline(), 'string'); + assert.equal(typeof inboundHeadline({}), 'string'); +}); diff --git a/plugins/pi-mail-bridge/lib/relay-policy.js b/plugins/pi-mail-bridge/lib/relay-policy.js new file mode 100644 index 0000000..ae4f48e --- /dev/null +++ b/plugins/pi-mail-bridge/lib/relay-policy.js @@ -0,0 +1,120 @@ +// 自动转发的**适用范围**,以及据此该给模型说什么话。 +// +// 单独一个文件而不是放在入口里导出:**opencode 会把插件入口模块的每一个导出 +// 都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),多导出一个函数 +// 就会让整个插件加载失败。因此入口只 `export default`,判断逻辑一律搁在这里。 +// +// # 为什么 Agent → Agent 不自动转发 +// +// 自动转发存在的理由是「人不该等模型记得调 send_mail」:人发一封信出去, +// 模型把活干完、话说完,插件替它把结论搬进邮件。收件方是人时这是纯收益。 +// +// 收件方是**另一个 Agent** 时这个理由不成立,而且有害:对方的插件同样会自动 +// 回一封,于是两个模型都以为「我只要把话说完就行」,实际上在持续互相唤醒。 +// 生产实测过一条完整的客套链(pi 转发给 dsh,dsh 回确认,pi 又确认那个确认, +// 一直到第 6 封撞上连续 relay 跳数上限才停): +// +// pi→dsh parent=24be32e5 转发 +// dsh→pi parent=bf79f8fe 已收到转发 +// pi→dsh parent=2700bd0a 收到你的确认 +// dsh→pi parent=34127884 确认闭环 +// pi→dsh parent=34058c13 … +// dsh→pi parent=9590bf16 ← 被 hop 上限拦下 +// +// 每一封都不是错的,每一封都没有新信息。跳数上限是最后一道闸,不是设计意图。 +// +// 因此规则是:**Agent 之间通信必须由模型主动调 send_mail。** +// 插件不再代它开口 —— 该说话的时候它会说,没什么要说的时候就该安静。 +// +// 副作用是好的:模型必须自己决定「这值得回一封信吗」,而那正是它该做的判断。 + +/** 取三维地址的名字段:admin@root.alias -> admin */ +export function addrName(addr) { + return String(addr || "").split("@")[0].trim(); +} + +/** + * 这一轮的结论该不该由插件自动转发出去。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman 来信方是人类用户(SSE 的 `from_human`) + * @param {string} [ctx.replyTo] 自动转发本来要发给谁 + * @returns {{relay: boolean, reason: string}} + * reason 供日志用 —— 「本轮没有回信」必须能在日志里查到原因, + * 否则它与「模型没说话」「转发失败」三种情形长得一样。 + */ +export function autoRelayDecision(ctx) { + const { fromHuman, replyTo } = ctx || {}; + if (!replyTo) { + return { relay: false, reason: "不知道回给谁" }; + } + if (!fromHuman) { + return { + relay: false, + reason: `来信方 ${addrName(replyTo)} 是 Agent,按约定不自动转发(Agent 间通信须由模型主动 send_mail)`, + }; + } + return { relay: true, reason: "" }; +} + +/** + * 提示词里关于「回信怎么发」的那句话。 + * + * 必须与 `autoRelayDecision` 一致 —— 这是同一件事的两个出口,分开写必然分叉。 + * 而分叉的代价是模型被骗:它以为插件会替它回信,于是把话说完就停手, + * 而实际上那封信永远不会发出去,发件方一直等着。 + * + * @param {object} ctx + * @param {boolean} ctx.fromHuman + * @param {string} [ctx.replyAddress] 服务端算好的回信地址 + * @returns {string[]} 若干行,直接拼进提示词 + */ +export function replyInstruction(ctx) { + const { fromHuman, replyAddress } = ctx || {}; + if (fromHuman) { + return [ + "**回信不用你自己发**:把这一轮做完、把结论说出来就行,", + "插件会在这一轮结束时把你最后那段话作为回信发回去(不消耗你的发信配额)。", + "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", + ]; + } + return [ + "**这封信来自另一个 Agent,插件不会替你回信。**", + "需要回复时你必须自己调用 send_mail" + + (replyAddress ? `(回信地址:${replyAddress})` : "") + ";", + "把话说完并不会让对方收到任何东西。", + "也请先判断这封信是否真的需要回复 —— 单纯的「收到」「确认」会让两个 Agent", + "无休止地互相客套,那对谁都没有价值。有实质结论或有事要问时才回。", + ]; +} + +/** + * 描述「进来的这封是什么」。 + * + * 在此之前提示词一律说「你收到一封新邮件」,于是模型分不清三种处境: + * 有人派了新活、我上封信的回复到了、离线期间积压的补投。 + * 第二种被当成第一种时,模型会把一句「已收到」当成待办再处理一遍。 + * + * @param {object} ctx + * @param {string} [ctx.inReplyTo] 非空 = 这是对本方某封信的回复(SSE 的 `in_reply_to`) + * @param {boolean} ctx.fromHuman + * @param {boolean} [ctx.catchup] 离线期间积压后补投的 + * @param {boolean} [ctx.reused] 投进一条已存在的会话(续谈) + * @returns {string} 提示词第一行 + */ +export function inboundHeadline(ctx) { + const { inReplyTo, fromHuman, catchup, reused } = ctx || {}; + const who = fromHuman ? "" : "(对方是一个 Agent)"; + if (inReplyTo) { + // 「回复到了」与「有人派活」是两种处境。说清楚它,模型才不会把 + // 一句确认当成新任务 —— 那正是互相客套的起点。 + return `你上一封信的**回复**到了${who}。这不是新任务。`; + } + if (catchup) { + return `你收到一封新邮件${who}。说明:这是插件离线期间积压的邮件,现在补投给你。`; + } + if (reused) { + return `本会话收到一封新邮件${who}。`; + } + return `你收到一封新邮件${who}。`; +} diff --git a/plugins/pi-mail-bridge/src/turn.mjs b/plugins/pi-mail-bridge/src/turn.mjs index 7fa660a..5787fc9 100644 --- a/plugins/pi-mail-bridge/src/turn.mjs +++ b/plugins/pi-mail-bridge/src/turn.mjs @@ -8,6 +8,8 @@ * 校验),这里的东西是 pi 专属的(消息形状、stopReason 语义),不参与那个约束。 */ +import { replyInstruction, inboundHeadline } from '../lib/relay-policy.js'; + /** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */ export function stripRe(subject) { return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, ''); @@ -127,33 +129,41 @@ export function buildMailPrompt({ agentName, data, kind, reused }) { ].join('\n'); } - const head = reused - ? '本会话收到一封新邮件(AgentMail 续谈)。' - : '你收到一封新邮件(AgentMail)。'; + // 发件方是人还是 Agent,以及这封是不是回信 —— 两个信号都来自服务端。 + // 旧版一律说「你收到一封新邮件」+「回信不用你自己发」,于是 Agent 之间 + // 两边都以为插件会代它开口,把对方的一句「已收到」当成待办再处理一遍。 + // + // `from_human` 缺失时保守当作「不是人」:宁可让模型多调一次 send_mail, + // 也不能对它承诺一个不会发生的自动回信 —— 后者让发件方白等。 + const fromHuman = data?.from_human === true; const lines = [ - head, + inboundHeadline({ + inReplyTo: data?.in_reply_to, + fromHuman, + catchup: data?.catchup, + reused, + }), '', `发件人:${data?.from_name || 'unknown'}`, `主题:${data?.subject || '(无主题)'}`, `邮件 ID:${data?.mail_id || 'unknown'}`, ]; + if (data?.in_reply_to) { + lines.push(`回的是你那封:${data.in_reply_to}`); + } if (!reused) lines.push(`身份:你是 ${agentName}`); // 服务端算好的回信地址(`new_mail` 的 reply_address)。带上它是因为模型 // **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。 // 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话, // 原来的线索里再无下文。 if (data?.reply_address) { - lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`); - } - if (data?.catchup) { - // 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回 - lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。'); + lines.push(`回信地址:${data.reply_address}`); } lines.push( '', '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', '然后处理其中的请求。', - '回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。', + ...replyInstruction({ fromHuman, replyAddress: data?.reply_address }), ); return lines.join('\n'); } diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index f067484..2528ff4 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -51,6 +51,7 @@ import { planNamingSync, planWriteBack } from './naming.mjs'; import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; import { modelAttemptOrder, renderFailureReport } from '../lib/model-scope.js'; import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js'; +import { autoRelayDecision } from '../lib/relay-policy.js'; import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js'; import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js'; @@ -324,10 +325,23 @@ async function relaySummary(session, sessionManager, adopted) { .catch((e) => log(`命名同步失败: ${describeError(e)}`)); } + // **只给人类来信自动转发**(详见 lib/relay-policy.js)。 + // + // 对方是 Agent 时它那边的插件也会自动回一封,于是两个模型都以为 + // 「我只要把话说完就行」,实际在持续互相唤醒 —— 生产实测 pi 与 dsh + // 客套 6 轮直到撞上 hop 上限。这一步在取文本之前判:早退比白做一轮清楚。 + const policy = autoRelayDecision({ + fromHuman: job.data?.from_human === true, + replyTo: mailContext.replyTo, + }); + if (!policy.relay) { + log(`本轮不自动转发:${policy.reason}`); + return; + } + // 只取 type==='text' 的块(B-5.1 / N-6):thinking 是思考过程,不是结论。 const text = lastAssistantText(session.messages); if (!text) return; // 空文本不发空邮件(B-5.4) - if (!mailContext.replyTo) return; // 不知道回给谁 // 幂等键用 pi 会话 id + 会话树叶子 id:两者都落盘,重放也是同一个键。 const relayKey = relayKeyFor(piSessionId, sessionManager.getLeafId?.()); diff --git a/plugins/pi-mail-bridge/test/relay-policy.test.mjs b/plugins/pi-mail-bridge/test/relay-policy.test.mjs new file mode 100644 index 0000000..20f0d4b --- /dev/null +++ b/plugins/pi-mail-bridge/test/relay-policy.test.mjs @@ -0,0 +1,131 @@ +/** + * 自动转发适用范围的判定(lib/relay-policy.js)。 + * + * 三个函数是同一件事的三个出口,必须一起看: + * - autoRelayDecision 插件该不该替模型把结论发出去 + * - replyInstruction 提示词里怎么跟模型说这件事 + * - inboundHeadline 进来的这封是新活、是回复、还是补投 + * + * 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完 + * 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addrName, + autoRelayDecision, + replyInstruction, + inboundHeadline, +} from '../lib/relay-policy.js'; + +// ─── addrName ─── + +test('addrName 取三维地址的名字段', () => { + assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi'); + assert.equal(addrName('jianf'), 'jianf'); + assert.equal(addrName(' dsh@/x '), 'dsh'); + assert.equal(addrName(''), ''); + assert.equal(addrName(undefined), ''); +}); + +// ─── autoRelayDecision ─── + +test('人类来信 → 自动转发', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }); + assert.equal(d.relay, true); +}); + +test('Agent 来信 → 不自动转发', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' }); + assert.equal(d.relay, false, + 'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒'); + assert.match(d.reason, /dsh/, '日志要说清是谁'); + assert.match(d.reason, /Agent/); +}); + +test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => { + const d = autoRelayDecision({ fromHuman: true, replyTo: '' }); + assert.equal(d.relay, false); + assert.match(d.reason, /不知道回给谁/, + '「本轮没有回信」有三种原因,日志里必须能分辨'); +}); + +test('replyTo 带三维地址时也能认出 Agent 名', () => { + const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' }); + assert.equal(d.relay, false); + assert.match(d.reason, /opencode/); +}); + +test('缺省参数不抛错(畸形事件不该弄死投递)', () => { + assert.equal(autoRelayDecision().relay, false); + assert.equal(autoRelayDecision({}).relay, false); +}); + +// ─── replyInstruction 与 autoRelayDecision 的一致性 ─── + +test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => { + const lines = replyInstruction({ fromHuman: true }); + const text = lines.join('\n'); + assert.match(text, /回信不用你自己发/); + assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true, + '承诺了就必须真的做'); +}); + +test('Agent 来信的提示词必须明说「插件不会替你回信」', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /不会替你回信/); + assert.match(text, /send_mail/, '必须给出唯一可行的做法'); + assert.doesNotMatch(text, /回信不用你自己发/, + '这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手'); +}); + +test('Agent 来信的提示词要劝阻纯客套', () => { + const text = replyInstruction({ fromHuman: false }).join('\n'); + assert.match(text, /收到|确认/, '要点名那种没有信息量的回复'); + assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻'); +}); + +test('Agent 来信时把回信地址带进提示词(有就带)', () => { + const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n'); + assert.match(withAddr, /dsh@\/x\.别名/, + '要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话'); + const without = replyInstruction({ fromHuman: false }).join('\n'); + assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号'); +}); + +// ─── inboundHeadline ─── + +test('回复到了 → 明说「这不是新任务」', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false }); + assert.match(h, /回复/); + assert.match(h, /不是新任务/, + '把回复当新任务处理正是互相客套的起点'); +}); + +test('回复的标题优先于续谈/补投标记', () => { + const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true }); + assert.match(h, /回复/, 'in_reply_to 是最强信号'); +}); + +test('Agent 来信在标题里就标出来', () => { + assert.match(inboundHeadline({ fromHuman: false }), /Agent/); + assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/, + '人类来信不该带这个括号 —— 那是噪音'); +}); + +test('补投要说明,否则模型按「刚到的」语气回', () => { + const h = inboundHeadline({ fromHuman: true, catchup: true }); + assert.match(h, /积压|补投/); +}); + +test('续谈与新会话的措辞不同', () => { + assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/); + assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/); +}); + +test('缺省参数不抛错', () => { + assert.equal(typeof inboundHeadline(), 'string'); + assert.equal(typeof inboundHeadline({}), 'string'); +}); diff --git a/plugins/pi-mail-bridge/test/turn.test.mjs b/plugins/pi-mail-bridge/test/turn.test.mjs index ff2bae4..e4b7c94 100644 --- a/plugins/pi-mail-bridge/test/turn.test.mjs +++ b/plugins/pi-mail-bridge/test/turn.test.mjs @@ -177,15 +177,51 @@ const mailData = { from_name: 'admin', subject: '排查连接泄漏', to_workspace: '/home/program/agentmail', + // 人类来信。**这一项不能省**:缺失时保守当作 Agent 来信,而两者的 + // 提示词完全不同(人类才有自动转发)。 + from_human: true, }; -test('不变量:提示词写明回信由桥自动发', () => { +test('不变量:人类来信的提示词写明回信由桥自动发', () => { // 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 —— // 同一件事两封邮件(生产里真实发生过)。 const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); assert.match(p, /回信不用你自己发/); }); +test('不变量:Agent 来信的提示词必须改口(插件不代它回信)', () => { + // Agent 之间两边都自动回信 = 无休止互相唤醒(实测 pi 与 dsh 客套 6 轮)。 + const p = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, from_name: 'dsh', from_human: false }, + kind: 'mail', + reused: false, + }); + assert.doesNotMatch(p, /回信不用你自己发/, '那句话在这里是假的'); + assert.match(p, /不会替你回信/); + assert.match(p, /send_mail/); +}); + +test('不变量:from_human 缺失时按 Agent 处理(不能承诺做不到的事)', () => { + const { from_human, ...noFlag } = mailData; + const p = buildMailPrompt({ agentName: 'pi', data: noFlag, kind: 'mail', reused: false }); + assert.doesNotMatch(p, /回信不用你自己发/, + '宁可让它多调一次 send_mail,也不能让发件方白等一个不会发生的自动回信'); +}); + +test('不变量:回信到达时明说「不是新任务」', () => { + // 把回复当新任务处理正是互相客套的起点。 + const p = buildMailPrompt({ + agentName: 'pi', + data: { ...mailData, from_human: false, in_reply_to: 'm-0' }, + kind: 'mail', + reused: true, + }); + assert.match(p, /回复/); + assert.match(p, /不是新任务/); + assert.match(p, /m-0/, '要说出回的是哪封'); +}); + test('不变量:提示词带 mail_id 与 read_inbox 指引', () => { // 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封 const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); @@ -198,7 +234,7 @@ test('首封带身份,续谈不重复带', () => { const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true }); assert.match(first, /你是 pi/); assert.doesNotMatch(again, /你是 pi/); - assert.match(again, /续谈/); + assert.match(again, /本会话/, '续谈用「本会话」而不是「你收到」'); }); test('补投的邮件在提示词里说明来源', () => {