fix(dsh)★★: idle 时取不到 assistant 文本 —— 重试而非当场判「空回复」
## 用户报「dsh 插件还是没有正确回复邮件」
第一版修复(519bdf7)改对了 `lastAssistantText` 本身,但生产仍复现。取证后定位
到**第二层原因**:不是取错事件,而是**取的时候那条还没到**。
## 证据(生产 22:56 那次,磁盘 session.v4.jsonl.zstd 解压)
那一轮的事件是:
seq=50 step/start
seq=51 assistant/message 块=['text'] ← 模型确实写了完整文本
seq=52 step/end seq=53 turn/end
按修复后的逻辑(从后往前找第一条带 text 的 assistant)**必然会取到 seq=51**,
可它仍被判成空回复。
⇒ 唯一解释:`agent/status: idle` 触发的那一刻,`agent.session.events` 里
**还没有 seq=51** —— 宿主在最后一条 assistant 消息落入内存**之前**就发了 idle。
旁证:投影缓存 `/root/.dsh/storages/session_projcache/sessions/<id>.json`
只有 `{identity, rows}`,**没有 events** ⇒ 它不是从投影读的,确属内存对象。
## 修法:idle 时取不到就短暂重试
重试 3 次 × 250ms(合计 750ms)。远小于一轮模型思考的量级,不拖慢链路;
而 seq=51 落盘只需几毫秒,三次足够覆盖。
顺带把诊断日志改成打出**重试后**的 events 长度与末尾类型(附「第 N 次重试才拿到」)
——下一次复现就能直接验证这个推断是否成立,而不是再靠推测。
## 一条被推翻的推断(记下来)
我一度判定「根因是 resume 路径」,理由是新会话那次通过、resume 那次失败。
**这是错的**:那两次的差异其实在更早一步 ——
`shouldSkipAutoRelay`(模型已主动 send_mail 就早退)。C-1 新会话与 C-2 resume
都因为 dsh 主动回信而早退,压根没走到取文本那段,所以两者都「正常」。
⇒ resume 与本问题**无关**,此前把差异归因于它是观察不足。
## 一条设定更正(用户指出)
我曾说「Agent 间通信不自动转发」是不可协商的约定,并据此说「pi 发信会早退,
拿不到诊断日志」。用户更正:agent→agent 只是**消耗额度**,而额度会随时间恢复
——那是成本权衡,不是禁止。该约定来自 784192d(2026-09-04),四个桥共用同一份
relay-policy.js,它**没有随额度恢复机制一起更新**。
⚠ 该约定是否要改属成本策略,未擅自改动。但它确实挡在诊断路径前面,是本轮
多花一轮的直接原因。
## 验证状态(诚实标注)
- tsc 零错 · dsh 435 格全绿 · 部署成功(current → 20261004-073447)
- **最终行为验证未完成**:需要一次**真人发信**(`from_human=true`)才能走到
取文本那段。我的所有身份都是 Agent,会被「不自动转发」早退。
⇒ 诊断日志已就位,待真人复现时可直接读出 events 的真实内容。
This commit is contained in:
@ -1917,8 +1917,50 @@ export function apply(ctx: any, config: PluginConfig): void {
|
|||||||
if (!mctx) return;
|
if (!mctx) return;
|
||||||
|
|
||||||
// 取最后一条 assistant 消息的文本
|
// 取最后一条 assistant 消息的文本
|
||||||
const events = agent.session?.events ?? [];
|
//
|
||||||
const lastText = lastAssistantText(events);
|
// ★★ 2026-10-04 实测定位(用户报「dsh 还是不回信」,生产复现 22:56):
|
||||||
|
//
|
||||||
|
// 那次磁盘上的最后一轮事件是
|
||||||
|
// seq=50 step/start
|
||||||
|
// seq=51 assistant/message 块=['text'] ← 模型确实写了完整文本
|
||||||
|
// seq=52 step/end seq=53 turn/end
|
||||||
|
// 按 lastAssistantText 的逻辑(从后往前找第一条带 text 的 assistant)
|
||||||
|
// **必然会取到 seq=51**,可它仍被判成空回复。
|
||||||
|
//
|
||||||
|
// ⇒ 唯一解释:`agent/status: idle` 触发的那一刻,
|
||||||
|
// `agent.session.events` 里**还没有** seq=51 —— 也就是宿主在
|
||||||
|
// 最后一条 assistant 消息落入内存**之前**就发了 idle。
|
||||||
|
// 投影缓存里也没有 events(只有 identity/rows),证实它不是从投影读的。
|
||||||
|
//
|
||||||
|
// ⇒ 修法:**idle 时取不到文本就稍等再取**,而不是当场判失败。
|
||||||
|
// events 是会补上的(磁盘已有 ⇒ 内存最终也会有),重试即可命中。
|
||||||
|
// 下面的诊断日志会打出重试前后的 events 长度,验证这个推断。
|
||||||
|
// events 这个绑定后面还要用(relay_key 与 modelTitle),故保留。
|
||||||
|
const events = (): any[] => agent.session?.events ?? [];
|
||||||
|
const readAssistantText = (): string => lastAssistantText(events());
|
||||||
|
let lastText = readAssistantText();
|
||||||
|
let retryNote = '';
|
||||||
|
if (!lastText) {
|
||||||
|
// 重试三次、每次 250ms(合计 750ms):远小于一轮模型思考的量级,
|
||||||
|
// 不会拖慢链路;而 seq=51 落盘只需几毫秒,三次足够覆盖。
|
||||||
|
for (let attempt = 1; attempt <= 3 && !lastText; attempt++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
lastText = readAssistantText();
|
||||||
|
if (lastText) retryNote = ` (第 ${attempt} 次重试才拿到文本)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const evs = agent.session?.events ?? [];
|
||||||
|
const tail = evs.slice(-4).map((e: any) => {
|
||||||
|
const c = (e as any)?.data?.message?.content;
|
||||||
|
const kinds = Array.isArray(c) ? c.map((b: any) => b?.type).join('+') : typeof c;
|
||||||
|
return `${(e as any)?.type ?? '?'}(${kinds})`;
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`[dsh-mail-bridge] [diag] events=${evs.length} 末尾=[${tail.join(' ')}] ` +
|
||||||
|
`hasText=${lastText.length > 0}${retryNote}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!lastText) {
|
if (!lastText) {
|
||||||
// 模型 idle 但没有任何 assistant 文本:空回复/被中断在文本前。
|
// 模型 idle 但没有任何 assistant 文本:空回复/被中断在文本前。
|
||||||
// 此前静默返回 —— 发件人等不到任何回复(缺的是 B-6 的「模型全部失败」
|
// 此前静默返回 —— 发件人等不到任何回复(缺的是 B-6 的「模型全部失败」
|
||||||
@ -1961,7 +2003,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
|||||||
reply_to: mctx.mailID || '',
|
reply_to: mctx.mailID || '',
|
||||||
// relay + relay_key:走免配额通道(harness 的搬运不该收费)
|
// relay + relay_key:走免配额通道(harness 的搬运不该收费)
|
||||||
relay: 'summary',
|
relay: 'summary',
|
||||||
relay_key: clampRelayKey(`${agent.id}:${events.length}`),
|
relay_key: clampRelayKey(`${agent.id}:${events().length}`),
|
||||||
});
|
});
|
||||||
relayedSummaries.set(String(agent.id), lastText);
|
relayedSummaries.set(String(agent.id), lastText);
|
||||||
explicitSends.delete(String(agent.id));
|
explicitSends.delete(String(agent.id));
|
||||||
@ -1976,7 +2018,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
|||||||
// DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉
|
// DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉
|
||||||
// `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。
|
// `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。
|
||||||
// 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。
|
// 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。
|
||||||
const title = modelTitle(events);
|
const title = modelTitle(events());
|
||||||
if (title && syncedTitles.get(String(agent.id)) !== title) {
|
if (title && syncedTitles.get(String(agent.id)) !== title) {
|
||||||
syncedTitles.set(String(agent.id), title);
|
syncedTitles.set(String(agent.id), title);
|
||||||
const alias = slugFromTitle(title);
|
const alias = slugFromTitle(title);
|
||||||
|
|||||||
Reference in New Issue
Block a user