fix(4 bridges): 投递提示词改指向 read_mail,不再引 read_inbox

投递时 `markDelivered` 就把信标成已读(dsh `src/index.ts:505` 定义,
`:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
2026-09-26 为治"重启重投→回声"定的性)。而提示词却要求"先调
read_inbox 读正文",read_inbox 默认 `status=unread` ⇒ **看不到刚投递的
那封信**。

危险的不是"白费一次调用",是模型看到空之后以为"没有新邮件"就结束
回合 —— 那会**静默丢掉一个真实请求**。mail_id 就在同一段提示词里。

## 改了什么

投递提示词(8 处)与 read_inbox 工具描述(4 处):

    请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文……
    这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;
    read_inbox 只用来看本会话的其它未读。

工具描述那句「收到新邮件通知后应立即调用此工具」是**模型看到的第一句话**,
只改提示词不改它,模型照样走偏,所以一并改。另给 read_mail 的描述补
一句「刚投递到本会话的那封邮件就读这个」。

落点(dsh 桥是**三处**,不是一处 —— adoptPrompt / resume 复用 / 新开会话
三条建会话路径各带一份文案):

- dsh `index.ts:1047`、`:1163`、`:1219` + 工具描述 `:1428`/`:1680`
- opencode `index.js:1016` + `:261`/`:473`
- pi `turn.mjs:170` + `tools.mjs:197`/`:430`
- zcode `prompt.mjs:152` + `lib/tools.mjs:123`

## 为什么指向 read_mail 是安全的

`workspace` 必需只加在**两个**端点上:GET /mail/inbox
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)与全部标已读(`:806`)。`read_mail` 走
`GET /agent/mail/{id}` → `AgentGetMail`(`agent_discovery.go:306`),
该路径**没有 workspace 参数**,鉴权走 `canReadSession()` 按会话归属判定。

桥侧也印证:`read_mail` 走 `withScope()`(只拼 session_id),
`read_inbox` 的 scope 单独拼 `&workspace=`。**两条路分开,400 搬不过去。**

## 不动默认行为

`read_inbox` 的默认 `status=unread` 保持不变(`lib/inbox-format.js:157`
刻意如此:默认 all 会让模型每轮重读旧邮件),`idsToMarkRead` 在 `all`
下不标已读与"投递即标已读"配套。改默认值等于把 2026-09-26 定的性放松
回去,不是另一种修法。

## homeagent 故意不动

`plugin.go:673,1005,250` 三处留着。改 Go 源码需重出 `plugin.bin`,产物在
**跨机器**的 `/home/newqqagent/plugins/`,你我都碰不到 —— 源码改了线上
没生效,仓库与线上分叉比不改更难排查。待跨机重出。

## 测试

新增 test/inbound-prompt-reads-mail.test.mjs(5 项),钉住:旧文案不存在、
新文案三处齐全、工具描述改过、read_mail 描述指过、附件指引保留。同时
断言 src 与 **dist**(dist 是 dsh 真正加载的那份,忘了 build 就是
"源码对、线上旧代码")。

四桥测试:dsh 419 / opencode 344 / pi 517 / zcode 394,全绿。
(改前 407/344/517/394,无回归。)

## dist 变更(不在 diff 里,.gitignore:20 忽略 plugins/*/dist/)

重出后 `请先调用 read_inbox` 由 **3 处 → 0 处**,新文案 3 处;
旧工具描述 1 处 → 0 处。已用 `npx tsc -p tsconfig.json` 重出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:29:26 +08:00
parent 9985c41b13
commit c851bef5cb
7 changed files with 118 additions and 12 deletions

View File

@ -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' },

View File

@ -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 取回'), '附件取回指引丢了');
});

View File

@ -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 }),
``,

View File

@ -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',

View File

@ -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');

View File

@ -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',

View File

@ -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)