/** * pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。 * * 单独一个文件而不是塞进 index.mjs:这几件事每一件都对应过一次真实的错误行为, * 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。 * * 与 lib/ 的区别:lib/ 下的文件三个平台**逐字节相同**(deploy/check-shared-libs.sh * 校验),这里的东西是 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, ''); } /** 自动转发时的回信主题。 */ export function replySubject(subject) { const base = stripRe(subject).trim(); return base ? `Re: ${base}` : '本轮工作总结'; } /** * 取最后一条 assistant 消息里的纯文本。 * * pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。 * * **只取 `type === 'text'`**(B-5.1):thinking 块是思考过程,转进邮件对收件人 * 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。 * * 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」: * 一轮的收尾常常是纯工具调用消息(content 里只有 toolCall), * 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。 * * @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages * @returns {string} 纯文本,找不到时为空串 */ export function lastAssistantText(messages) { const list = Array.isArray(messages) ? messages : []; for (let i = list.length - 1; i >= 0; i--) { const m = list[i]; if (m?.role !== 'assistant') continue; const blocks = Array.isArray(m.content) ? m.content : []; const text = blocks .filter((b) => b?.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join('\n') .trim(); if (text) return text; } return ''; } /** * 判定这一轮到底跑起来了没有(C-4 / D-3)。 * * 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。 * pi 侧有三条互不重叠的失败信号,必须全查: * * 1. `prompt()` 直接 reject。凭证缺失就是这条:实测无 API key 的 provider * 抛 `No API key found for amazon-bedrock.`,一个事件都不发。 * 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。 * 模型请求发出去了但上游报错走这条。 * 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出, * 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。 * * `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断 * (Esc / dispose),不是模型故障,因此不该触发换模型重试。 * * @param {{error?: any, messages?: any[]}} input * @returns {{ok: boolean, error: string, aborted: boolean}} */ export function classifyTurnOutcome({ error, messages } = {}) { if (error) { return { ok: false, error: describeError(error), aborted: false }; } const list = Array.isArray(messages) ? messages : []; let lastAssistant = null; for (let i = list.length - 1; i >= 0; i--) { if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; } } if (!lastAssistant) { return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false }; } const stop = lastAssistant.stopReason; if (stop === 'error') { return { ok: false, error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因', aborted: false, }; } if (stop === 'aborted') { return { ok: false, error: '本轮被中断(aborted)', aborted: true }; } // 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**, // 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。 // 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。 return { ok: true, error: '', aborted: false }; } /** 把各种形态的错误拼成一行可读文本。 */ export function describeError(err) { if (!err) return ''; if (typeof err === 'string') return err.split('\n')[0].trim(); const parts = [err.code, err.message ?? String(err)].filter(Boolean); return parts.join(': ').split('\n')[0].trim() || '未知错误'; } /** * 投递一封邮件时给模型的提示词。 * * 三条硬要求(B-3.4 / B-3.5): * - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail, * 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。 * - 带上 mail_id,让模型能自己定位这一封。 * - 让它先调 read_inbox:事件里只有主题,正文和附件清单都在收件箱里。 * * @param {{agentName: string, data: any, kind: string, reused: boolean}} input * @returns {string} */ export function buildMailPrompt({ agentName, data, kind, reused }) { if (kind === 'permission') { return [ `你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` + `(决策人:${data?.decided_by || '用户'})。`, `请据此继续后续工作。`, ].join('\n'); } // 发件方是人还是 Agent,以及这封是不是回信 —— 两个信号都来自服务端。 // 旧版一律说「你收到一封新邮件」+「回信不用你自己发」,于是 Agent 之间 // 两边都以为插件会代它开口,把对方的一句「已收到」当成待办再处理一遍。 // // `from_human` 缺失时保守当作「不是人」:宁可让模型多调一次 send_mail, // 也不能对它承诺一个不会发生的自动回信 —— 后者让发件方白等。 const fromHuman = data?.from_human === true; const lines = [ 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}`); } lines.push( '', '请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),', '然后处理其中的请求。', ...replyInstruction({ fromHuman, replyAddress: data?.reply_address }), ); return lines.join('\n'); } /** * 自动转发的幂等键(W-6 / B-5.2)。 * * 用 pi 侧的会话 id + 会话树叶子条目 id:两者都由 pi 生成且落盘, * 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 —— * 压缩(compaction)会改变条数,于是同一轮结论换了个键,被当成新消息再转一次。 * * @param {string} piSessionId * @param {string} leafId * @returns {string} */ export function relayKeyFor(piSessionId, leafId) { return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`; } /** * pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。 * * 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的 * `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。 * * @param {string} cwd * @returns {string} */ export function sessionDirLabel(cwd) { return `--${String(cwd ?? '').replace(/\//g, '-')}--`; }