/** * 会话重试 (Session retry) —— 宿主侧。 * * 客户端那半个插件在 `conversation.chat.assistant-actions` 动作条上放一个「重新 * 回答」按钮,但它自己答不出一个问题:这条助手消息对应的是哪一句用户提问?本包在 * 共享的 `/api` 通道上注册一个只读接口,把整条会话的答案一次答完: * * POST /api/session-retry/context { sessionId } * → { ok, sessionId, latestTurn, anchors: [{ messageId, turn, text, omittedParts, promptSeq, anchorSeq }] } * * 一次答完整条会话而不是逐条问:动作条是**每个已结束的轮次各一条**(ui-chat 的 * turn-tail 节点在 `turn/end` 时发布,`closing = finalized.findLast(hasText)`,见 * ui-chat lib/client.js 的 tailData/TurnTailNodeView),一场会话有几十条动作条同时 * 挂在页面上。逐条询问就是几十趟日志回放,所以这里一遍扫过日志、把每个轮次的 * 「动作条所挂的那条助手消息」和「该轮唯一的人工提问」配对返回,客户端拿本地 * messageId 去表里查。 * * 判定规则与 ui-chat 的轮次尾部严格对齐: * - 一条动作条挂在该轮**最后一个产出文本的步骤**里**最后一条** assistant/message 上 * (hasText = 该步骤末条消息的 content 里有非空文本块); * - 该轮的用户提问 = 该轮 `turn/start` 之后、那条消息之前的人工消息 * (source.kind === 'user')。注入的上下文(time-context、skill-catalog、插件 * 通知)和压缩检查点都不是用户提问;替换操作(surfaceOp 为对象,压缩复写旧消息) * 也不是追加,不参与判定。 * * 「重新回答」不只是再问一遍,还要让**这一轮的旧回答退出模型上下文**。这一步用会话自己的 * 可见面(surface)替换通道完成:客户端先 POST 本包的写路由,宿主据此追加若干条「空 system * 节点」替换事件,把该轮提问起的可见面节点影掉,客户端随后照常提交提问。 * * POST /api/session-retry/regenerate { sessionId, messageId } * → { ok, turn, promptSeq, runs, shadowed, deferred: true } * * 影掉的是**可见面**,不是日志:日志、界面上的历史消息、会话标题与统计都原样保留,只有送给 * 模型的消息列表少掉这一段(dsh-session 的 surface 折叠 + `deriveMessages()` 决定模型能看到 * 什么)。载体必须是空 system/message —— 应用自己的规则是「空 system/developer 节点保留可见 * 面位置但不产生线缆消息」(dsh-session `lib/types/surface.js` 的 deriveEventMessage),而空 * user/message 会作为一条空 user 消息真的发出去。 * * **影子节点不在点按钮的当场写,而是等到下一次请求的步骤里补写**。原因不是风格,是日志格式 * 的硬约束:V4 校验要求 `system/message` 必须匹配**当前打开的** turn/step * (`dsh-session-format-v3-to-v4` 的 STEP_EVENT_TYPES + requireStep)。点按钮时会话是空闲的 * —— 上一轮已经 turn/end、下一轮还没 turn/start,没有任何打开的步骤;此刻带上一轮的 * turn/step 追加空 system 节点,会把日志写成应用自己读不回来的样子(下次打开这条会话就是 * `stored log is corrupt: system/message does not match an open turn and step`)。 * 所以 `/regenerate` 只**登记**这份影子计划,客户端提交的提问真的打开一个步骤时,在 * `agent/request` 里用**当时打开的 turn/step** 补写:位置合法,而且落在 `deriveMessages()` * 之前,用户这一次请求就已经看不见旧回答了。 * * 影子范围按节点的**内容区间终点**判定。可见面节点分两类:追加型(自己就是新内容)与替换型 * (继承被替换段的位置,例如压缩摘要)。终点早于本轮提问的节点属于「提问之前的记忆」,保留; * 终点不早于提问的节点都是这一轮的产物,影掉。摘要节点会插在可见面早期位置,所以要影的节点 * 未必连续:按可见面下标切成连续段,一段一次 append(每段留一个空节点占位,不占线缆)。 * * 会话不能截断,也无法删除已写入的日志,所以这是「让模型看不见」而不是「从会话里删掉」; * 与「分支」按钮的差别是:分支另开会话,重新回答留在本会话。 * * @module @local/dsh-session-retry */ import { randomUUID } from 'node:crypto' /** Cordis 插件名。 */ export const name = 'dsh-session-retry' /** * `/api` 通道归 connection 服务所有:信任检查与浏览器会话鉴权由该通道在进入 * 这里的处理器之前完成。sessionQuery 不注入 —— 缺失时按不可用降级回答。 * * `agents` 是为了 `agent/request` 钩子:影子节点要在真正打开的请求步骤里补写 * (见模块头)。 */ export const inject = ['connection', 'agents'] /** 客户端取上下文的只读路由。 */ const ROUTE = '/api/session-retry/context' /** 客户端触发「重新回答」的写路由:登记这一轮及其后的影子计划。 */ const REGEN_ROUTE = '/api/session-retry/regenerate' /** 同一条会话的上下文缓存时长:回放整条日志不便宜,连续渲染不重复付账。 */ const CACHE_TTL_MS = 1500 /** 缓存条目上限,避免长时间运行后留住多条会话的判定结果。 */ const CACHE_LIMIT = 8 /** 影子计划的有效期:客户端 POST 成功后立刻提交提问,超过这个时长还没等到请求就作废。 */ const PENDING_TTL_MS = 5 * 60 * 1000 /** sessionId → { at, covered }。 */ const cache = new Map() /** * 已登记、等待下一个请求步骤补写的影子计划:sessionId → { at, covered, messageId }。 * * `covered` 是**登记那一刻**要影掉的可见面节点 seq 列表。补写时按这份名单在当时的可见面里 * 重新切段,而不是按提问号重算 —— 用户提交的那句提问是登记之后才追加的节点,按提问号重算 * 会把它自己也影掉。 */ const pending = new Map() /** * 统一的 JSON 应答(与 /api/usage 的既有形状一致)。 * * @param {any} payload * @param {number} [status] * @returns {Response} */ function json(payload, status = 200) { return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) } /** * 把一个内容块数组拆成文本与非文本两部分。 * * 非文本部分(图片、文件等)计数而不复制:重试只重发文字,调用方据此提示用户 * 哪些内容没有被带上。 * * @param {any} content * @returns {{ text: string, others: number }} */ function splitContent(content) { const parts = [] let others = 0 if (Array.isArray(content)) { for (const block of content) { if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { parts.push(block.text) } else { others += 1 } } } return { text: parts.join('\n').trim(), others } } /** * 表层追加事件的判定。 * * 替换操作(`{ op, startSeq, endSeq }`,如压缩检查点复写旧的用户消息)不是追加, * 不参与判定;缺标记的旧日志按追加处理。 * * @param {any} event * @returns {boolean} */ function isAppended(event) { return event.surfaceOp === undefined || event.surfaceOp === 'append' } /** * 一遍扫过一条会话的原始事件日志,列出每一轮可以重发的提问。 * * 收齐三样东西:每轮 `turn/start` 的 seq、每个 (turn, step) 的末条 assistant/message * (含末条是否产出文本)、以及人工 user/message。随后按轮次顺序取「最后一个产出文本 * 的步骤」作为该轮的动作条锚点,再取该轮起点与锚点之间的人工提问。 * * 一轮里可能不止一句提问:用户在模型回答途中发话(steering / 排队)时,那句话会被 * 接进同一个轮次。这里把该轮全部人工提问按顺序合并成一段重发 —— 只挑其中一句会 * 丢掉另一句,而合并既不丢内容也仍然只有一次提交。 * * 该轮没有产出文本的步骤时不给锚点(ui-chat 对这类轮次根本不渲染动作条,所以这不是 * 缺失,而是与界面一致)。 * * @param {any[]} events - sessionQuery.readSession() 返回的事件数组。 * @returns {{ latestTurn: number, anchors: Array<{ messageId: string, turn: number, text: string, promptCount: number, omittedParts: number, promptSeq: number, anchorSeq: number }> }} */ export function retryTargets(events) { /** turn → turn/start 的 seq。 */ const turnStarts = new Map() /** `turn/step` → { turn, step, seq, messageId, hasText }。 */ const steps = new Map() /** 步骤按出现顺序。 */ const stepOrder = [] /** 人工提问,按出现顺序。 */ const prompts = [] let latestTurn = 0 for (const event of events ?? []) { if (event === null || typeof event !== 'object') continue const data = event.data if (data === null || typeof data !== 'object') continue if (!isAppended(event)) continue if (event.type === 'turn/start') { if (typeof data.turn !== 'number') continue if (!turnStarts.has(data.turn)) turnStarts.set(data.turn, event.seq) latestTurn = Math.max(latestTurn, data.turn) continue } if (event.type === 'turn/end') { if (typeof data.turn === 'number') latestTurn = Math.max(latestTurn, data.turn) continue } if (event.type === 'user/message') { const source = data.source if (source === null || typeof source !== 'object' || source.kind !== 'user') continue const parts = splitContent(data.content) if (parts.text === '' && parts.others === 0) continue prompts.push({ seq: event.seq, text: parts.text, others: parts.others }) continue } if (event.type !== 'assistant/message') continue const message = data.message if (message === null || typeof message !== 'object' || typeof message.id !== 'string') continue if (typeof data.turn !== 'number' || typeof data.step !== 'number') continue const key = `${data.turn}/${data.step}` let step = steps.get(key) if (step === undefined) { step = { turn: data.turn, step: data.step, seq: event.seq, messageId: message.id, hasText: false } steps.set(key, step) stepOrder.push(step) } // 同一 (turn, step) 内可能有多条 assistant/message(续写/分片):末条才是该步骤的回答。 step.seq = event.seq step.messageId = message.id step.hasText = splitContent(message.content).text !== '' latestTurn = Math.max(latestTurn, data.turn) } const anchors = [] for (const [turn, startSeq] of [...turnStarts].sort((left, right) => left[0] - right[0])) { let closing = null for (const step of stepOrder) { if (step.turn === turn && step.hasText) closing = step } if (closing === null) continue const inTurn = prompts.filter((prompt) => prompt.seq > startSeq && prompt.seq <= closing.seq) if (inTurn.length === 0) continue // 该轮的全部提问合并重发:顺序保持,中间空一行分开,非文字内容只计数不复制。 const text = inTurn .map((prompt) => prompt.text) .filter((part) => part !== '') .join('\n\n') if (text === '') continue let omittedParts = 0 for (const prompt of inTurn) omittedParts += prompt.others anchors.push({ messageId: closing.messageId, turn, text, promptCount: inTurn.length, omittedParts, promptSeq: inTurn[0].seq, anchorSeq: closing.seq, }) } return { latestTurn, anchors } } /** * 算出「把这一轮及其后的内容从可见面上影掉」需要哪些替换段。 * * 每个可见面节点取一个「内容区间终点」:追加型节点自己就是新内容,终点是它自己的 seq; * 替换型节点继承被替换段的位置(压缩摘要就是这样占住早期位置的),终点是该段末端的 seq。 * 终点早于本轮提问的节点是提问之前的记忆(例如提问前产生的摘要),保留;终点不早于提问的 * 节点都属于这一轮的产物,影掉。要影的节点在可见面里未必连续,所以按可见面下标切成连续段, * 调用方一段一次 append —— 替换操作一次只能覆盖一段连续区间。 * * 纯函数,不碰会话:真实日志与合成事件都能直接喂进来。 * * @param {number[]} nodes - 活的可见面节点 seq 列表(`session.surface.nodes`)。 * @param {any[]} events - 同一会话的原始事件(`session.snapshotEvents()`)。 * @param {number} questionSeq - 该轮第一句人工提问的 seq。 * @returns {{ runs: Array<{ start: number, end: number, seqs: number[] }>, shadowed: number, kept: number }} */ export function shadowPlan(nodes, events, questionSeq) { const bySeq = new Map() for (const event of events ?? []) { if (event !== null && typeof event === 'object' && typeof event.seq === 'number') bySeq.set(event.seq, event) } const list = Array.isArray(nodes) ? nodes : [] const runs = [] list.forEach((seq, index) => { const op = bySeq.get(seq)?.surfaceOp const end = op === undefined || op === 'append' ? seq : op.endSeq if (typeof end !== 'number' || end < questionSeq) return const last = runs[runs.length - 1] if (last !== undefined && last.end === index - 1) { last.end = index last.seqs.push(seq) } else { runs.push({ start: index, end: index, seqs: [seq] }) } }) const shadowed = runs.reduce((sum, run) => sum + run.seqs.length, 0) return { runs, shadowed, kept: list.length - shadowed } } /** * 按**当时的可见面**把已登记的影子节点重新切成连续段。 * * 登记与补写之间隔着用户提交提问这一次追加,可见面可能已经变了:不在可见面上的节点(已经被 * 别的替换影掉、或已经不在可见面里)直接丢掉,剩下的按可见面下标重新切段。第一个节点(系统 * 提示)永不影掉 —— 它必须留在可见面首位。 * * 纯函数,不碰会话:真实会话与合成数组都能直接喂进来。 * * @param {number[]} nodes - 补写那一刻的可见面节点 seq 列表(`session.surface.nodes`)。 * @param {number[]} covered - 登记时记下的、要影掉的节点 seq 列表。 * @returns {Array<{ start: number, end: number, seqs: number[] }>} 连续段,一段一次 append。 */ export function pendingRuns(nodes, covered) { const wanted = new Set(Array.isArray(covered) ? covered : []) const runs = [] const list = Array.isArray(nodes) ? nodes : [] list.forEach((seq, index) => { if (index === 0 || !wanted.has(seq)) return const last = runs[runs.length - 1] if (last !== undefined && last.end === index - 1) { last.end = index last.seqs.push(seq) } else { runs.push({ start: index, end: index, seqs: [seq] }) } }) return runs } /** * 在一个真正打开的请求步骤里补写已登记的影子节点。 * * 只有拿到打开的 turn/step 才动手(`agent/request` 钩子给的坐标就是会话此刻打开的步骤), * 因此写下去的 `system/message` 一定匹配打开着的步骤,日志不会被写坏。写之前先按当时的 * 可见面重算段落;一段一次 append,写完即作废计划。任何一段写失败都只记一条警告并停止: * 旧回答留在上下文里总好过把日志写成读不回来的样子。 * * @param {any} session - 活会话(需要 surface.nodes 与 append)。 * @param {number} turn - 此刻打开的轮次。 * @param {number} step - 此刻打开的步骤。 * @param {any} [logger] - 可选的 ctx.logger。 * @returns {{ runs: number, shadowed: number, eventSeqs: number[] } | null} 未补写时返回 null。 */ export function applyPendingShadow(session, turn, step, logger) { const id = typeof session?.id === 'string' && session.id !== '' ? session.id : undefined const plan = id === undefined ? undefined : pending.get(id) if (plan === undefined) return null if (Date.now() - plan.at > PENDING_TTL_MS) { pending.delete(id) return null } if (!Number.isInteger(turn) || !Number.isInteger(step)) return null const runs = pendingRuns(session.surface?.nodes, plan.covered) pending.delete(id) const eventSeqs = [] let shadowed = 0 for (const run of runs) { try { const event = session.append( 'system/message', { turn, step, message: { role: 'system', source: { kind: 'system-prompt' }, content: [], id: randomUUID() } }, { surfaceOp: { op: 'replace', startSeq: run.seqs[0], endSeq: run.seqs[run.seqs.length - 1] }, sourceEventSeqs: run.seqs, }, ) eventSeqs.push(event?.seq) shadowed += run.seqs.length } catch (error) { logger?.warn?.( `会话重试:影子节点补写失败,旧回答继续留在上下文里(计划已作废):${String(error?.message ?? error)}`, ) break } } return { runs: runs.length, shadowed, eventSeqs } } /** * 记住一条判定结果,并保持在条数上限内。 * * @param {string} key * @param {any} value */ function remember(key, value) { cache.delete(key) cache.set(key, { at: Date.now(), value }) while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value) } /** * `POST /api/session-retry/context` 的处理器。 * * @param {any} ctx * @param {Request} request * @returns {Promise} */ async function handleContext(ctx, request) { let body try { body = await request.json() } catch { body = null } const fields = body !== null && typeof body === 'object' ? body : {} const sessionId = typeof fields.sessionId === 'string' ? fields.sessionId : '' if (sessionId === '') return json({ ok: false, code: 'BAD_REQUEST', message: 'sessionId 不可为空' }) const cached = cache.get(sessionId) if (cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS) { return json({ ok: true, sessionId, cached: true, ...cached.value }) } const query = ctx.get('sessionQuery') if (query === undefined) { return json({ ok: false, code: 'UNAVAILABLE', message: '本组合未挂载 sessionQuery' }) } let observation try { observation = await query.readSession(sessionId) } catch (error) { return json({ ok: false, code: 'READ_FAILED', message: String(error?.message ?? error) }) } const value = retryTargets(Array.isArray(observation?.events) ? observation.events : []) remember(sessionId, value) return json({ ok: true, sessionId, cached: false, ...value }) } /** * `POST /api/session-retry/regenerate` 的处理器。 * * 只认**活会话**:会话必须挂在应用里(`sessions.get(id)`),且当前不在回答中 —— 边答边改 * 可见面属于压缩那种受控场景,这里不做。判定用活会话自己的事件(`snapshotEvents()`),与 * `/context` 读同一份日志、同一套 retryTargets 规则,所以客户端手上的 messageId 一定对得上。 * * 这里只**登记**影子计划,不写日志:点按钮时会话空闲,没有任何打开的步骤,此刻写 * `system/message` 会把日志写坏(见模块头)。登记前仍然把影子段算完并校验一遍,宁可整件事 * 失败,也不要留下半影的上下文;补写时机与写失败的处置见 {@link applyPendingShadow}。 * * @param {any} ctx * @param {Request} request * @returns {Promise} */ async function handleRegenerate(ctx, request) { let body try { body = await request.json() } catch { body = null } const fields = body !== null && typeof body === 'object' ? body : {} const sessionId = typeof fields.sessionId === 'string' ? fields.sessionId : '' const messageId = typeof fields.messageId === 'string' ? fields.messageId : '' if (sessionId === '' || messageId === '') { return json({ ok: false, code: 'BAD_REQUEST', message: 'sessionId 与 messageId 都不可为空' }) } const store = ctx.get('sessions') if (store === undefined) { return json({ ok: false, code: 'UNAVAILABLE', message: '本组合未挂载 sessions 服务' }) } const session = store.get(sessionId) if (session === undefined) { return json({ ok: false, code: 'NOT_LIVE', message: '这条会话当前不在内存里,只有应用里打开着的会话能重新回答' }) } let running = false try { running = session.getSnapshot()?.running === true } catch { running = false } if (running) { return json({ ok: false, code: 'RUNNING', message: '这条会话正在回答,先停止再重新回答' }) } const events = session.snapshotEvents() const anchor = retryTargets(events).anchors.find((item) => item.messageId === messageId) if (anchor === undefined) { return json({ ok: false, code: 'NO_ANCHOR', message: '这条助手消息对应的轮次已经不在可重新回答的范围里' }) } const nodes = [...session.surface.nodes] const plan = shadowPlan(nodes, events, anchor.promptSeq) if (plan.runs.some((run) => run.start === 0)) { return json({ ok: false, code: 'HEAD_NODE', message: '影子范围碰到了可见面第一个节点(系统提示),已放弃' }) } const covered = [...new Set(plan.runs.flatMap((run) => run.seqs))].sort((left, right) => left - right) if (covered.length > 0) { // 按会话自己的 id 登记:补写时是会话对象自己来找计划,两边必须是同一个 key。 const key = typeof session.id === 'string' && session.id !== '' ? session.id : sessionId // 同一条会话连着点两次:并集处理,早登记的那一份影子意图不丢。 const earlier = pending.get(key) const merged = earlier === undefined ? covered : [...new Set([...earlier.covered, ...covered])].sort((left, right) => left - right) pending.set(key, { at: Date.now(), covered: merged, messageId }) } // 可见面马上要变(提交提问会追加新节点),别让 /context 再拿旧缓存。 cache.delete(sessionId) return json({ ok: true, sessionId, turn: anchor.turn, promptSeq: anchor.promptSeq, runs: plan.runs.length, shadowed: plan.shadowed, kept: plan.kept, eventSeqs: [], deferred: covered.length > 0, }) } /** * 在共享的 `/api` 通道上注册本包的路由,并在每个请求真正打开步骤时补写已登记的影子节点。 * * 补写钩子必须挂在 `agent/request`:`agent/pre-step` 时步骤还没开,钩子在 * `buildRequest()`(它调用 `deriveMessages()`)之前跑完,所以用户这一次请求就已经看不到 * 被影掉的旧回答。 * * @param {any} ctx */ export function apply(ctx) { const routes = [ { path: ROUTE, label: 'context route', fetch: (request) => handleContext(ctx, request) }, { path: REGEN_ROUTE, label: 'regenerate route', fetch: (request) => handleRegenerate(ctx, request) }, ] for (const route of routes) { ctx.effect( () => ctx.connection.fetch.register({ path: route.path, methods: ['POST'], requestBody: 'buffered', fetch: route.fetch, }), `dsh-session-retry: ${route.label}`, ) } ctx.effect( () => ctx.on( 'agent/request', ({ agent, turn, step }, next) => { const session = agent?.session if (session !== undefined) { try { applyPendingShadow(session, turn, step, ctx.logger) } catch (error) { // 补写失败不能影响请求本身:旧回答留在上下文里,用户下次再点一次即可。 ctx.logger?.warn?.(`会话重试:影子计划补写异常:${String(error?.message ?? error)}`) } } return next() }, { global: true }, ), 'dsh-session-retry: shadow plan applier', ) }