diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index d49aedb545..d7f0fc779c 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 9bbc177f456e006179c466f8c245e4599db3dd5a -2026-07-15-replay-token-meter-service.zh.md: 4437626c8651a80537d45197a93733271a592173 +2026-07-15-replay-token-meter-service.md: 2841c56eab362d7162954266c14b5eda21c7c1a4 +2026-07-15-replay-token-meter-service.zh.md: ab7bc01ce39aa3fa7a7299d97aed3b52f4f8b74e diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 9bbc177f45..2841c56eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -32,7 +32,7 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas `dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. Configuration, the region transaction, and summarization stay in separate modules; the service registers automatic listeners itself, while `summarize()` remains its sole subclass hook. The singleton meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. -Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. +Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization, then compares the detached surface-node vectors. An intervening surface mutation prevents replacement; `logRevision` may advance for unrelated log-only facts without invalidating an unchanged selected span. Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4437626c86..ab7bc01ce3 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -32,7 +32,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket `dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。配置、区域事务与摘要器分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类 hook。单例计量器一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 -自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 +自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务会在追加持久 `compact/start` 锁后执行计量,在异步摘要完成后再次计量,随后比较分离的表层节点向量。期间发生的表层变更会阻止替换;`logRevision` 可以因无关的纯日志事实而推进,而不会使未变的选定范围失效。 压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 99313866ed..92f5774388 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -93,6 +93,8 @@ The `compact/start … compact/end` bracket is justified, in order of what now d 1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. 2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across either awaited automatic seam, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +The lock excludes another compaction, not unrelated log-only facts. The basic backend snapshots the token meter's surface nodes after `compact/start` and compares them again after asynchronous summarization; any surface mutation rejects before replacement, while a title or other log-only append leaves the selected span valid. + Two failure paths, both documented: - **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — no summary replacement lands. The derived surface remains the durable surface present at `compact/start`: full history when pruning made no replacement, or the already-pruned history when it did. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index bb6fc53670..32b0b3a218 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-log-backed-session-titles.md: d322b58c32d44c07d8d18618546225e7001ab025 -2026-07-21-log-backed-session-titles.zh.md: cb70533d52310574c56c7b0177b9b1ba34d7176a +2026-07-21-log-backed-session-titles.md: cd0d2a4bab9b6504c65e942c0e03bce79488364e +2026-07-21-log-backed-session-titles.zh.md: b90ac6c59677e6542733210b91de38ef1169c760 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index d322b58c32..cd0d2a4bab 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -24,7 +24,7 @@ The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugi Only text blocks from human-source `user/message` events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq. -Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event and zero-step turn. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. +Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event and zero-step turn. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement. The first-message provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; `refresh()` is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index cb70533d52..b90ac6c596 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -24,7 +24,7 @@ Status: implemented 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件和一个零步骤轮次。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。 +仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件和一个零步骤轮次。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index d789bf2328..532cc4bbfe 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -14,7 +14,7 @@ This backend owns the compaction policy: - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. +- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index ac1ee260b1..ad40265cc4 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-compact-basic/region */ +import { isDeepStrictEqual } from 'node:util' import { renderTranscript, toolPairingBalancedAfter, @@ -113,8 +114,8 @@ export async function compactSurfaceRegion( const shadowedSeqs = nodes.slice(startIdx, endIdx + 1) const startEvent = session.append('compact/start', { turn: tail.turn }) try { - // Capture after the lock event so any later durable append, including a - // log-only one, invalidates the async selection before replacement. + // Capture after the lock event so a later surface mutation invalidates the + // async selection before replacement. Unrelated log-only facts may append. const lockedMeasurement = dependencies.meter.measure(session) const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1) if (selected.length !== shadowedSeqs.length @@ -126,8 +127,8 @@ export async function compactSurfaceRegion( const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal) const currentMeasurement = dependencies.meter.measure(session) - if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) { - throw new Error('compaction: session log changed during summarization') + if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) { + throw new Error('compaction: session surface changed during summarization') } const framedSummary = frameSummary(summary) const framedSummaryTokenCount = dependencies.meter.estimateMessage({ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index d0340e8b0d..090bb561e6 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -683,13 +683,13 @@ describe('compaction region transaction', () => { .toMatchObject({ error: 'plain failure' }) }) - it('rejects concurrent durable appends before committing the replacement', async () => { + it('tolerates concurrent log-only appends while the selected surface is stable', async () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, - reason: 'initial', + reason: 'change', }) } const nodes = session.surface.nodes @@ -698,7 +698,26 @@ describe('compaction region transaction', () => { nodes[0]!, nodes[2]!, agent(session, MODEL), - )).rejects.toThrow(/session log changed/) + )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) + }) + + it('rejects concurrent surface appends before committing the replacement', async () => { + const compact = service() + const session = conversation(2) + compact.mutateDuringSummary = () => { + session.append('context/message', { + content: [{ type: 'text', text: 'concurrent surface mutation' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + } + const nodes = session.surface.nodes + + await expect(compact.compactRegion( + nodes[0]!, + nodes[2]!, + agent(session, MODEL), + )).rejects.toThrow(/session surface changed/) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 6e33b6e570..6c1291518f 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -47,7 +47,7 @@ The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is ## Blocking -Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock. +Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock. ## Events