diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 619e404..800bf71 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -1044,7 +1044,9 @@ export function apply(ctx: any, config: PluginConfig): void { ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, - `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, + `请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文,然后处理其中的请求。` + + `这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;` + + `read_inbox 只用来看本会话的其它未读。`, ...replyInstruction({ fromHuman, replyAddress: data.reply_address }), ].join('\n'); } @@ -1160,7 +1162,9 @@ export function apply(ctx: any, config: PluginConfig): void { ...(data.in_reply_to ? [`回的是你那封:${data.in_reply_to}`] : []), ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, - `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, + `请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文,然后处理其中的请求。` + + `这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;` + + `read_inbox 只用来看本会话的其它未读。`, ...replyInstruction({ fromHuman: data.from_human === true, replyAddress: data.reply_address, @@ -1209,7 +1213,10 @@ export function apply(ctx: any, config: PluginConfig): void { `身份:你是 ${AGENT_NAME}`, ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, - `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, + `请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文(附带附件清单,` + + `有附件可用 download_attachment 取回),然后处理其中的请求。` + + `这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;` + + `read_inbox 只用来看本会话的其它未读。`, ``, ...replyInstruction({ fromHuman: data.from_human === true, @@ -1436,7 +1443,10 @@ export function apply(ctx: any, config: PluginConfig): void { // read_inbox ctx.tools.register(defineTool({ name: 'read_inbox', - description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', + description: '查阅收件箱里的其它未读邮件。' + + '注意:刚投递到本会话的那封已在投递时标为已读,不在这里 —— ' + + '读那封用 read_mail(mail_id),mail_id 就在投递提示词里。' + + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', parameters: { status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, limit: { type: 'number', description: '返回数量,默认 5' }, @@ -1667,6 +1677,7 @@ export function apply(ctx: any, config: PluginConfig): void { name: 'read_mail', description: '读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' + + '刚投递到本会话的那封邮件就读这个(mail_id 在投递提示词的「邮件 ID」里)。' + '收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。', parameters: { mail_id: { type: 'string', required: true, description: '邮件 ID' }, diff --git a/plugins/dsh-mail-bridge/test/inbound-prompt-reads-mail.test.mjs b/plugins/dsh-mail-bridge/test/inbound-prompt-reads-mail.test.mjs new file mode 100644 index 0000000..a311a73 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/inbound-prompt-reads-mail.test.mjs @@ -0,0 +1,86 @@ +/** + * 投递提示词不能把模型引向 `read_inbox`(dsh)。 + * + * # 为什么不只是"措辞过时" + * + * 投递时 `markDelivered` 就把信标成已读(`src/index.ts:505` 的定义, + * `:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 —— + * 2026-09-26 为治"重启重投→回声"定的性)。于是: + * + * 提示词说「调 read_inbox 读正文」 ⇒ read_inbox 默认 status=unread + * ⇒ 看不到刚投递的那封 ⇒ 返回空。 + * + * **危险的形状**不是"白费一次调用",是模型看到空之后以为"没有新邮件" + * 就结束回合 —— 那样会**静默丢掉一个真实请求**。mail_id 就在同一段 + * 提示词里,`read_mail` 能拿到正文与附件清单。 + * + * # 钉住三件事 + * + * 1. 投递提示词指向 read_mail,且带得出 mail_id(提示词里有「邮件 ID」行)。 + * 2. read_inbox 的工具描述不再说"收到新邮件通知后应立即调用此工具" + * —— 那句是模型看到的第一句话,只改提示词不改它,模型照样走偏。 + * 3. 三条建会话路径(adoptPrompt / resume 复用 / 新开会话)**各带一份** + * 文案。只改其中一处 = 另外两条路径上的会话照样走偏。dsh 桥曾 + * 长期是 3 处而外部只数出 1 处。 + * + * 反向对照:必须同时断言「旧文案不存在」与「新文案存在」, + * 否则把提示词整段删空也能过。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); +// dist 是**真正被 dsh 加载**的那份(package.json main: dist/index.js)。 +// 改了 src 忘了 build 就是"源码对、线上旧代码"。 +const dist = readFileSync(join(HERE, '..', 'dist', 'index.js'), 'utf8'); + +test('★ 投递提示词不再让模型先调 read_inbox', () => { + assert.ok( + !src.includes('请先调用 read_inbox'), + 'src 里还有旧文案:投递时信已标为已读,read_inbox 默认只看未读,读不到', + ); + assert.ok(!dist.includes('请先调用 read_inbox'), 'dist 里还有 —— 忘了 npm run build?'); +}); + +test('★ 新文案指向 read_mail 并说明为什么不用 read_inbox', () => { + const hits = src.match(/请用 read_mail\(mail_id 用上面「邮件 ID」那处\)/g) || []; + assert.equal(hits.length, 3, `三条建会话路径各带一份文案,应为 3 处,实际 ${hits.length}`); + assert.ok( + src.includes('这封信在投递时已标为已读'), + '必须说明原因,否则模型可能"顺手"再去调一次 read_inbox', + ); + assert.ok(src.includes('read_inbox 只用来看本会话的其它未读'), '要写清 read_inbox 仍有什么用途'); + assert.equal( + (dist.match(/请用 read_mail\(mail_id 用上面「邮件 ID」那处\)/g) || []).length, + 3, + 'dist 里应是 3 处', + ); +}); + +test('★ read_inbox 的工具描述不再声称"收到新邮件通知后应立即调用"', () => { + assert.ok( + !src.includes('收到新邮件通知后应立即调用'), + '那句与「投递即标已读」直接矛盾,且它是模型看到的第一句话', + ); + assert.ok(!dist.includes('收到新邮件通知后应立即调用'), 'dist 里还有 —— 忘了 npm run build?'); + assert.ok( + src.includes('刚投递到本会话的那封已在投递时标为已读'), + 'read_inbox 描述要说明它为什么没有刚投递的那封', + ); +}); + +test('★ read_mail 的描述指明「刚投递的那封读这个」', () => { + assert.ok( + src.includes('刚投递到本会话的那封邮件就读这个'), + '两个工具描述都在往 read_inbox 上引时,光改提示词不够', + ); +}); + +test('★ 附件提示保留(新开会话那份原文有 download_attachment 指引)', () => { + // 新开会话分支原本是唯一带附件说明的;改写时不能顺手丢掉。 + assert.ok(src.includes('有附件可用 download_attachment 取回'), '附件取回指引丢了'); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 8c9dddd..17fc0cc 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -258,7 +258,7 @@ const forwardMailTool = { }; const readInboxTool = { - description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。", + description: "查阅收件箱里的其它未读邮件。刚投递到本会话的那封已在投递时标为已读,不在这里 —— 读那封用 read_mail(mail_id),mail_id 就在投递提示词里。", args: { filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"), limit: z.number().optional().describe("返回数量,默认 5"), @@ -471,6 +471,7 @@ const readThreadTool = { const readMailTool = { description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。" + + "刚投递到本会话的那封邮件就读这个(mail_id 在投递提示词的「邮件 ID」里)。" + "收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。", args: { mail_id: z.string().describe("邮件 ID"), @@ -1013,7 +1014,10 @@ async function deliverMail(client, directory, data, kind) { `身份:你是 ${AGENT_NAME}`, ...(data.reply_address ? [`回信地址:${data.reply_address}`] : []), ``, - `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, + `请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文(附带附件清单,` + + `有附件可用 download_attachment 取回),然后处理其中的请求。` + + `这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;` + + `read_inbox 只用来看本会话的其它未读。`, ``, ...replyInstruction({ fromHuman, replyAddress: data.reply_address }), ``, diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 0ce68e4..dbe8a73 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -194,7 +194,8 @@ export function createMailTools({ client, log, agentName = '', onReconnect, name: 'read_inbox', label: 'ReadInbox', description: - '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' + + '查阅收件箱里的其它未读邮件。' + + '刚投递到本会话的那封已在投递时标为已读,不在这里 —— 读那封用 read_mail(mail_id),mail_id 就在投递提示词里。' + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', parameters: { type: 'object', @@ -427,6 +428,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect, label: 'ReadMail', description: '读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' + + '刚投递到本会话的那封邮件就读这个(mail_id 在投递提示词的「邮件 ID」里)。' + '收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。', parameters: { type: 'object', diff --git a/plugins/pi-mail-bridge/src/turn.mjs b/plugins/pi-mail-bridge/src/turn.mjs index a253770..eedb4e3 100644 --- a/plugins/pi-mail-bridge/src/turn.mjs +++ b/plugins/pi-mail-bridge/src/turn.mjs @@ -167,8 +167,9 @@ export function buildMailPrompt({ agentName, data, kind, reused }) { } lines.push( '', - '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', - '然后处理其中的请求。', + '请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文(附带附件清单,有附件可用 download_attachment 取回),', + '然后处理其中的请求。这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;', + 'read_inbox 只用来看本会话的其它未读。', ...replyInstruction({ fromHuman, replyAddress: data?.reply_address }), ); return lines.join('\n'); diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs index fe4e893..dfe5bb2 100644 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -120,7 +120,8 @@ export function buildTools({ client, agentName }) { name: 'read_inbox', annotations: READ_ONLY, description: - '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' + + '查阅收件箱里的其它未读邮件。' + + '刚投递到本会话的那封已在投递时标为已读,不在这里 —— 读那封用 read_mail(mail_id),mail_id 就在投递提示词里。' + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', inputSchema: { type: 'object', diff --git a/plugins/zcode-mail-bridge/src/prompt.mjs b/plugins/zcode-mail-bridge/src/prompt.mjs index 8e30f73..a0339ad 100644 --- a/plugins/zcode-mail-bridge/src/prompt.mjs +++ b/plugins/zcode-mail-bridge/src/prompt.mjs @@ -149,8 +149,9 @@ export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false lines.push( '', - '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', - '然后处理其中的请求。', + '请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文(附带附件清单,有附件可用 download_attachment 取回),', + '然后处理其中的请求。这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;', + 'read_inbox 只用来看本会话的其它未读。', ...replyInstruction({ fromHuman, replyAddress: data?.reply_address }), '', capabilityNote(data?.permission_mode)