Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -6,16 +6,15 @@ This is the implementation tier of the compaction capability — see the [interf
## What it owns
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
This backend owns the compaction policy:
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt.
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the target comes from the explicit `summarizationProvider`+`summarizationModel` pair, otherwise the latest logged request pair, otherwise the agent pair. Per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
- **Surface mutation** — `compactRegion()` appends the `compact/start``compact/summary``compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`.
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **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 `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, provider, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.
@@ -58,3 +57,75 @@ export function apply(ctx: Context): void {
```
Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
## Model Experience
### Conversation history
**What the model sees**: Before a step whose estimated envelope and history exceed the threshold, the conversation model receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. This one checkpoint replaces the selected older range and is followed by the retained recent units.
**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
#### Conversation checkpoint preamble
```markdown
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.
```
### Auxiliary summarizer user message
**What the model sees**: The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored.
**Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once.
### Auxiliary summarizer system prompt
**What the model sees**: The summarization model receives the checkpoint-writing instruction below.
**Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt.
#### Auxiliary summarizer system prompt
```markdown
You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.
Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.
## Primary Request and Intent
- [the user's original and evolving goals; quote verbatim where the exact wording matters]
## Key Technical Concepts
- [technologies, frameworks, patterns, and conventions in play]
## Files and Code
- [exact path: why it matters, key changes or snippets]
## Errors and Fixes
- [error: how it was resolved, plus any related user feedback]
## Pending Tasks
- [explicitly requested work not yet completed]
## Current Work
- [precisely what was in progress at this checkpoint]
## Next Step
- [the single next action, directly in line with the most recent request, or "(none)"]
## Critical Context
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
Rules:
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
- Capture user feedback and explicit instructions faithfully, especially corrections.
- Do NOT mention this summarization process or that the context was compacted.
- If the transcript already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.
```
## Known Limitations and Deferred Work
- **Token estimation is the chars/`charsPerToken` heuristic** — a marked TODO schedules replacing it with an exact count (a real tokenizer, or provider `usage` fed back) so thresholds track the model's actual budget.
- **`estimatePressure()` does not count the request's `tools` field** — pressure is underestimated by the size of the serialized tool schemas the request also carries.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).

View File

@@ -1,31 +1,8 @@
/**
* `BasicCompactService`: the first implementation of the
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
*
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
* with per-block structural overhead.
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
* to a token budget, compact everything older. The cutoff is snapped forward
* to the next balanced tool-pairing boundary so a compacted region never
* splits a step's tool-call/result pair (an open tail step is never crossed —
* compaction declines and retries once it closes).
* - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled
* via `BlockAssembler` with a fixed condense-the-history system prompt;
* NOT a loop step, so `agent/request` never fires — interception happens
* at `llm/stream` like any other direct call.
* - **Surface mutation** — a single `user/message` replace node carries the
* summary; `compact/*` events are log-only lock + provenance records.
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
* sole token-pressure check.
*
* A different backend (real tokenizer, template summarizer, turn-count
* retention) either subclasses this and overrides the {@link
* BasicCompactService.estimateContentTokens} / {@link
* BasicCompactService.summarize} hooks, or implements the abstract
* {@link CompactService} from scratch.
*
* Basic compaction backend. It estimates request pressure, retains a recent
* tool-balanced surface tail, summarizes the older head through a one-shot model
* call, and replaces that head with one checkpoint. Auto-compaction runs before
* every step so a growing turn can compact its earlier closed steps.
* @module @deepseek-ai/dsh-compact-basic
*/
@@ -54,15 +31,8 @@ const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/**
* The summarization system prompt: instructs the model to condense the
* conversation into a fixed, fully-populated structure rather than freeform
* bullets. The fixed structure guarantees coverage of the things a resuming
* model needs (original intent, pending work, the next step, critical context)
* and is stable across compaction cycles, so a prior checkpoint can be merged
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
* transcript already contains a prior checkpoint, the model consolidates rather
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
* extra log/event machinery — the tag travels on the summary surface node).
* Fixed summary structure for resumable checkpoints. A tagged prior checkpoint
* is merged with newer history instead of copied forward verbatim.
*/
const SUMMARIZE_SYSTEM_PROMPT = [
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
@@ -100,29 +70,13 @@ const SUMMARIZE_SYSTEM_PROMPT = [
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
].join('\n')
/**
* Framing prepended to the landed summary so a resuming model reads it as a
* checkpoint rather than a fresh user request, and continues the task from it.
* It summarizes an earlier span of the conversation; the messages that follow
* are the continuation. Because region compaction can be invoked manually, a
* surface may hold several checkpoints, so the framing does NOT claim that
* everything after it is recent or verbatim — only that the captured context
* should be built on, not restated.
*/
/** Framing that makes a landed summary established context rather than a new request. */
const CHECKPOINT_PREAMBLE =
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
/**
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
*
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
* (discard) the real history it summarizes. Raising here keeps the original
* surface intact (the caller appends `compact/end` with the error and the auto
* path proceeds with full history). `stop`/future kinds are accepted.
* Map a terminal summary failure to an error. A max-token finish is rejected
* because committing an incomplete checkpoint would shadow the full history.
*/
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
@@ -164,25 +118,8 @@ export class BasicCompactService extends CompactService {
this.config = resolveConfig(config)
if (this.config.auto) {
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
// an assistant/message and a tool/result per step, so the surface (and the
// derived token count) grows WITHIN a turn. The only moment to rescue a
// turn that alone approaches the window is the next step's pre-step
// checkpoint; gating to a turn's first step would let a runaway turn
// overflow before the next turn's check. The listener owns NO threshold
// logic — compactIfNeeded is the single place that decides whether to
// compact, and its in-progress lock serializes concurrent attempts.
//
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
// mutates the session surface, and the loop derives the request `messages`
// AFTER this fires — so a single derive already reflects the compaction,
// with no double-derive and no need to rewrite an already-assembled
// `messages` array. Firing pre-step (outside any open step) keeps the
// log-only `compact/*` records and the replacement node cleanly outside a
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
// closes — never a half-open step.
// Check before every step so a single growing turn can compact earlier closed steps.
// This serial pre-step seam mutates the surface outside the pending step.
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
try {
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
@@ -289,27 +226,10 @@ export class BasicCompactService extends CompactService {
}
/**
* Summarize conversation text into content blocks via `ctx.llm.stream()`
* assembled through a `BlockAssembler`. A direct one-shot model call, NOT a
* loop step: it does not run the `agent/request` waterfall (that seam shapes
* the loop's conversation requests); per-call
* interception happens at `llm/stream` like any other direct call. The model
* target comes from the explicit summarization provider/model pair, falling
* back to the last logged request target and then the agent's creation options.
* Override in a subclass for a template or remote summarizer.
*
* Honors the adapter failure contract: an adapter may report a model failure
* by throwing from `stream()` (propagated here) OR by ending the stream with
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
* provider error never yields an empty summary.
*
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
* down the in-flight summarization rather than orphaning the model call.
*
* Returns the summary blocks TOGETHER with the call envelope it actually
* used (`provider`, `model`, `maxTokens`) — the caller logs the envelope on the
* `compact/summary` provenance event, so an overriding subclass (template
* or remote summarizer) reports its own envelope honestly.
* Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent
* step or `agent/request` dispatch. Failure finishes and truncated summaries
* reject; the signal is forwarded, only text reaches the checkpoint, and the
* returned envelope identifies the provider/model actually used.
*
* @param text - plain-text rendering of the conversation region to condense.
* @param agent - supplies the request-header/creation fallback target and the
@@ -363,42 +283,10 @@ export class BasicCompactService extends CompactService {
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the NEXT request's pressure — the
* session prefix + the surface-derived history + the system prompt
* ({@link estimatePressure}) — and if it exceeds the threshold
* (`contextWindow * thresholdRatio`), compact
* the oldest surface nodes outside the `retainTokens` budget. The auto-
* compaction listener delegates here rather than pre-checking, so this is the
* only place the decision lives. The prefix counts because every request
* carries it in front of the history (`EpochHeader.messagePrefix`) even
* though it is not derived history — omitting it would under-estimate by
* exactly the prefix and let a deployment at the window edge skip
* compaction, then ship an over-window request. The loop composes the
* prefix BEFORE the pre-step seam and hands it through, so the gate sees
* this instance's actual prefix (never a previous instance's logged one —
* a resumed/forked instance whose contributor grew is gated on the grown
* value from its very first step). Compaction itself can only
* shrink HISTORY: a prefix that alone approaches the window is a
* configuration error no compactor fixes.
*
* Retention is a UNIFORM tail→head walk over the whole surface — turn
* boundaries play NO role. Walking node-by-node from the tail and summing
* token estimates, once the retained total reaches `retainTokens` the cutoff
* is rounded to a balanced tool-pairing boundary: if the cut before the
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
* it is mid-step), the walk continues head-ward until the cut is balanced so
* the whole step is retained (never splitting a step's tool-calls from their
* results); if it stopped on a free node (a node belonging to no step), that
* cut is already balanced. This always rounds toward retaining MORE (retained
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
* pass.
*
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
* auto-compaction re-consolidates any prior head checkpoint into one fresh
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
* surface fits the retain budget, or when no balanced cutoff exists in the
* compactable range (its only content is an open tail step — retry once it
* closes).
* The sole pressure gate: count the next request's prefix, derived history,
* and system prompt. Above threshold, retain a recent tool-balanced tail and
* compact the head, reconsolidating any prior automatic checkpoint. Returns
* `null` when no safe or necessary range exists.
*/
override async compactIfNeeded(
agent: Agent,
@@ -454,13 +342,7 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve the range by surface POSITION, not numeric seq interval. A prior
// replace lands a fresh high-seq summary node AT the shadowed range's
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
// Resolve by surface position: a newer replacement seq may occupy an older slot.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
@@ -470,14 +352,7 @@ export class BasicCompactService extends CompactService {
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
}
// The region must never split a step's assistant-message tool-calls from
// their tool/results (which would orphan one side and produce a transcript
// every provider rejects). A region is safe iff BOTH its edges are balanced
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
// to no step (pre-step user message, inter-step steering, injection context)
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
// leaves the cut after it unbalanced (the open tool-call has no result yet),
// so it is rejected. See dsh-session's tool-pairing balance check.
// Both range edges must preserve assistant tool-call/result pairing.
const events = session.events
if (!isToolPairingBalanced(nodes, events, start)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
@@ -494,13 +369,8 @@ export class BasicCompactService extends CompactService {
throw new Error('compaction already in progress')
}
// Compaction's events (compact/* and the replacement user/message) must be
// turn-enclosed: the session-log contract rejects any plugin event appended
// outside an open turn. Auto-compaction satisfies this — it runs on the
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
// strictly inside the open turn (but outside any step). A manual call on a
// fully-closed session has no turn to enclose the events, so reject rather
// than emit an un-enclosed run.
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
// the session-log contract rejects any plugin event appended outside an open turn.
const openTurn = this._openTurn(session)
if (openTurn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
@@ -542,13 +412,8 @@ export class BasicCompactService extends CompactService {
...maxTokens !== undefined ? { maxTokens } : {},
})
// --- Surface replacement ---
// The user/message directly shadows all compacted surface nodes with a
// single replace op. It is the ONLY surface event in the compaction
// sequence — compact/start, compact/summary, and compact/end are log-only
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
// the compact/summary provenance event above holds the raw model output.
// --- Surface replacement --- The user/message directly shadows all compacted surface
// nodes with a single replace op.
session.append('user/message', {
content: framedSummary,
source: { kind: 'plugin', plugin: 'compact' },
@@ -602,17 +467,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Whether a compaction is currently in progress for `session` — an unmatched
* `compact/start` (no later `compact/end`) WITHIN the current turn.
*
* The scan is scoped to the current turn: walking back from the tail it stops
* at the first `turn/end` (the boundary closing the prior turn). A
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
* persistence repair then closes with a synthetic `turn/end`; scoping here so
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
* compaction's `compact/start` is always in the still-open current turn,
* before any `turn/end`, so it is still detected.
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
* (no later `compact/end`) WITHIN the current turn.
*/
private _isCompactionInProgress(session: Session): boolean {
const events = session.events
@@ -655,14 +511,10 @@ export class BasicCompactService extends CompactService {
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
// Round the cutoff to a tool-pairing boundary: if the cut before
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
// it — i.e. it is mid-step), extend the retained side head-ward until the
// cut is balanced, so the compacted range ends without splitting an
// assistant↔result pair. A node that belongs to no step is already a
// balanced (free) boundary. Decline if no balanced cut exists at or below
// `keepFromIdx` (the compactable range is only an un-splittable open tail
// step — retry once it closes).
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
// retained side head-ward until the cut is balanced, so the compacted range ends without
// splitting an assistant↔result pair.
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
@@ -678,17 +530,7 @@ export class BasicCompactService extends CompactService {
return { start: firstSeq, end: cutoffSeq }
}
/**
* Keep ONLY text blocks from the model-produced summary before storing it.
*
* The summary lands on the surface as a synthesized `user/message` (see
* {@link _frameSummary}), so the only block type that is both useful and safe
* there is `text`. A model assistant message can otherwise carry `reasoning`
* (private chain-of-thought, must not leak into the durable checkpoint) and
* `tool-call` blocks — and a surviving `tool-call` in a user message would be
* an orphaned call with no matching `tool-result`, exactly the tool-pairing
* breakage compaction works to avoid. Filtering to text drops both.
*/
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
}

View File

@@ -50,13 +50,6 @@ export type ResolvedConfig = Required<BasicCompactConfig>
/**
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
*
* Convergence is not a static config invariant: provider generation caps can be
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
* of unpredictable size. The backend instead enforces convergence dynamically:
* each committed summary must be smaller than the content it shadows, and
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
* throwing if the surface still exceeds the threshold.
*
* @param config - the raw, unresolved backend config.
* @returns the validated config with `auto` and `charsPerToken` defaulted.
*/

View File

@@ -87,15 +87,7 @@ function createTestService(overrides: Partial<BasicCompactConfig> = {}): TestCom
return new TestCompactService(new Context(), cfg({ auto: false, ...overrides }))
}
/**
* Build a multi-turn session with surface markers (simulating real agent-loop
* output). Compaction always runs inside an OPEN turn (the loop fires the
* `agent/pre-step` seam after a turn's start and before a step's start), so by
* default the session is left with a trailing open turn: turns `1..turns`
* close, then one more `turn/start` opens with no matching `turn/end`. Pass
* `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual
* compaction is rejected when no turn is open).
*/
/** Build closed turns plus an open compaction turn unless `leaveOpen` is false. */
function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session {
const leaveOpen = opts.leaveOpen ?? true
const s = new Session(SessionId('test'))
@@ -216,12 +208,8 @@ function expectNoOrphanToolResults(messages: Message[]): void {
describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => {
it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => {
// 3 turns, each one step = { assistant(tool-call), tool/result }. Surface
// (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 —
// 10/20/10 tokens. The tail→head walk retains by whole units; the compacted
// region always ends on a step boundary, so no step's tool-call is split
// from its result. retainTokens=55 keeps the recent tail; the older steps
// compact intact.
// Retain the recent tail while the older assistant/result pairs compact as
// whole units; no boundary may orphan a result.
const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 })
const session = toolTurnSession(3)
@@ -236,12 +224,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
})
it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => {
// The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over
// threshold (by the derived role overhead), the tail→head walk stops with the
// retained boundary at the tool/result — which is NOT a step-aligned start (its
// issuing assistant precedes it in the same step). Rounding head-ward to find a
// clean boundary reaches index 0, so there is no step-aligned cutoff in the
// compactable range: compactIfNeeded declines rather than splitting the step.
// The only candidate cut is inside one assistant/result pair; with no safe
// compactable prefix, decline rather than split it.
const s = new Session(SessionId('one-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
@@ -610,27 +594,16 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
// threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40
// for the retention walk), but the derived estimate adds 4 role tokens per
// message → 56 ≥ 48, so the threshold check passes and the walk runs. The
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
// so keepFromIdx reaches 0 and compaction declines.
// Role overhead pushes the request above its 48-token threshold, but the
// raw four-node retention walk remains below retainTokens=45, so all fit.
const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 })
const session = multiTurnSession(2, 1)
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
// The REGRESSION that motivated dropping turn-protection. A single in-flight
// (open) turn has grown past the threshold on its own: several CLOSED steps,
// each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so
// the turn's OWN early closed steps are eligible — they compact while the
// recent tail stays verbatim, and the harness survives.
//
// On the OLD layer-2 code this test FAILS: the entire open turn was retained
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
// returned null and shadowedSeqs would be empty — the runaway turn could
// never compact and the next model call would overflow the window.
// Completed early steps of the open turn remain eligible; protecting the
// whole turn would make a runaway turn impossible to compact.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = new Session(SessionId('runaway'))
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
@@ -670,12 +643,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
// After the first compaction lands a replacement summary node at the head,
// a second compaction (still over threshold) re-consolidates it with newer
// context — head-anchoring means the prior checkpoint is always re-included,
// never stranded. retainTokens=25 leaves a couple of retained nodes after
// the first compaction (so the surface is [summary, …retained], not just
// [summary]).
// Head-anchored recompaction must include the previous summary and retained context.
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
@@ -781,10 +749,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
})
it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => {
// A crash mid-compaction left a compact/start with no compact/end; the turn
// it lived in was later closed (persistence repair appends turn/end). A
// whole-log scan would treat that stale start as an active lock forever. The
// scan is scoped to the current turn, so a NEW turn compacts normally.
// An orphaned start in a closed repaired turn is stale; only the current
// turn participates in the in-progress lock.
const svc = createTestService()
const s = new Session(SessionId('stale-lock'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -865,11 +831,8 @@ describe('BasicCompactService HMR safety', () => {
})
it('disposing the plugin fiber unregisters ctx.compact', async () => {
// Mount through the real plugin fiber (the Loader path), then dispose it and
// confirm the service registration is torn down. LlmService is mounted first
// so the service's `inject: ['llm']` resolves and the fiber activates. (The
// sibling-fiber ctx.llm resolution this same setup also exercises is covered
// under the "llm inject (real plugin-load path)" suite.)
// Mount through the real plugin fiber (the Loader path), then dispose it and confirm the
// service registration is torn down.
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
@@ -1264,11 +1227,8 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => {
const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model')
// The summarize call is a direct one-shot model call, not a loop step: it
// does not run agent/request (that seam shapes the loop's conversation
// requests). llm/stream is its interception surface, and a hand-built
// request is not frozen, so mutate-then-next provider/model routing works —
// the adapter resolves AFTER the waterfall, so the rewrite picks it.
// One-shot summaries bypass agent/request but remain mutable at llm/stream;
// adapter selection happens after the waterfall rewrite.
ctx.on('llm/stream', (options, next) => {
options.provider = 'routed-model'
options.model = 'routed-model'
@@ -1523,19 +1483,13 @@ describe('BasicCompactService edge cases', () => {
const svc = createTestService()
const s = new Session(SessionId('empties'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// Step 1: an empty-text user, an empty-reasoning assistant with NO tool-call
// (balanced: nothing to answer), and empty context/steering — all extract to
// nothing and are skipped.
s.append('step/start', { turn: 1, step: 1 })
s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' })
s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('step/end', { turn: 1, step: 1 })
// Step 2: a tool exchange whose tool/result has empty content → empty
// extraction → skipped. The assistant carries the matching tool-call so the
// surface stays tool-pairing balanced; its text extracts to the tool-call
// placeholder (the one surviving line).
// Keep the log pairing-valid while the empty result covers the final message kind.
s.append('step/start', { turn: 1, step: 2 })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
turn: 1, step: 2,
@@ -1549,10 +1503,6 @@ describe('BasicCompactService edge cases', () => {
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
// Every empty-content message (user text, empty reasoning, empty-content
// tool/result, empty context, empty steering) extracted to nothing and was
// skipped — the only surviving line is the assistant's tool-call (which a
// balanced surface requires to answer the tool/result).
expect(svc.summarizeCalls[0]!.text).toBe('Assistant: [tool-call: bash({})]')
})
@@ -1600,40 +1550,26 @@ describe('BasicCompactService edge cases', () => {
describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => {
it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => {
// A replace inserts the new summary node (a high seq) AT the shadowed
// range's surface position, so the surface becomes
// [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a
// range whose start node has a HIGHER seq than its end node must still
// succeed — the range is positional, not a numeric seq interval.
// Replacement makes surface seqs non-monotonic. The next region is a
// positional span even when startSeq > endSeq.
const svc = createTestService({ auto: false })
const session = multiTurnSession(4, 1)
// First compaction: shadow the two oldest surface nodes.
// A replacement puts its high-seq summary at the surface head.
const nodes0 = session.surface.nodes
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
// head is the user/message replace node, appended after the compact/summary
// provenance event, so its seq is at least first.summarySeq.)
const nodes1 = session.surface.nodes
expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq)
expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq)
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
// seq (the head summary node) is GREATER than the end seq (an older retained
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
// The end must land on a step boundary (turn-2's assistant message closes
// its step).
const startSeq = nodes1[0]!.seq
const endSeq = nodes1[2]!.seq
expect(startSeq).toBeGreaterThan(endSeq)
const second = await compactRegion(svc, session, startSeq, endSeq, 'm')
// Exactly the three nodes at surface positions [0..2] are shadowed, in
// surface order — the positional slice, regardless of their seq values.
// Selection follows surface positions, not sequence-number order.
expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq])
// The surface still derives cleanly: a new head replace node + the rest.
const finalNodes = session.surface.nodes
expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq)
expect(session.deriveMessages().length).toBe(finalNodes.length)
@@ -1643,20 +1579,15 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
const svc = createTestService({ auto: false })
const session = multiTurnSession(3, 1)
// First compaction shadows the oldest two surface nodes, landing a high-seq
// summary node at the head.
// Put a high-seq summary at the head; log order would place retained older nodes first.
const n0 = session.surface.nodes
await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm')
// Second compaction spans [head summary … turn-2's step end]. The head's seq
// is higher than the older retained nodes' seqs, so a log-seq-order walk
// would emit the older messages BEFORE the checkpoint.
const n1 = session.surface.nodes
svc.summarizeCalls = []
await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm')
// The extracted transcript follows surface order: the checkpoint (head)
// first, then the older retained messages — matching deriveMessages().
// Extraction must match surface and `deriveMessages()` order.
const { text } = svc.summarizeCalls[0]!
const checkpointIdx = text.indexOf('compacted-summary')
const olderIdx = text.indexOf('turn 2 user')
@@ -1667,10 +1598,8 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
describe('BasicCompactService llm inject (real plugin-load path)', () => {
it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => {
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a
// sibling LlmService when this service is mounted as its own plugin fiber.
// Asserting the declaration (and exercising the real mount below) guards the
// resolution that root-ctx unit tests cannot, since they share one fiber.
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a sibling
// LlmService when this service is mounted as its own plugin fiber.
expect(BasicCompactService.inject).toContain('llm')
})

View File

@@ -14,24 +14,10 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
* free surface boundary (it carries no tool-call/result pair), so it must be a
* valid region edge on BOTH sides. A surface-anchored balance check sees that;
* the abandoned log-position scan did not.
*
* The loop fires the compaction seam mid-flight, so the landed checkpoint
* `user/message{replace}` sits at a HIGH log seq positioned beside the current
* step even though its SURFACE position is the head. A log-position forward scan
* from the checkpoint reaches the step's own later `assistant/message` and
* wrongly reports the checkpoint as mid-step — refusing it as a region end. A
* SECOND compaction that re-summarizes just that head checkpoint (region end ==
* checkpoint) therefore throws and is swallowed, so the surface never
* re-consolidates.
*
* This drives a real auto-compaction through the agent-loop and asserts the
* landed checkpoint balances on both sides AND that re-compacting it (end ==
* checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
* is decided from surface tool-pairing balance.
* CBR-001 regression through the real loop. A replacement checkpoint has a high
* log seq at the surface head and carries no tool pair, so both adjacent cuts
* must be safe and re-compacting that checkpoint alone must succeed. This pins
* surface-position semantics rather than raw-log scanning.
*/
const TOKENS_PER_BLOCK = 10
@@ -133,14 +119,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
)
expect(checkpoints.length).toBeGreaterThan(0)
// The loop fired compaction mid-flight, so each landed checkpoint sits at a
// high log seq beside the step it landed in, even though its SURFACE
// position is the head of the range it shadowed. A checkpoint carries no
// tool-call/result pair (only summarized prose), so every checkpoint still
// on the surface must be a balanced cut on BOTH sides — the cut before it
// (region START) and the cut after it (region END). The abandoned
// log-position scan reported the END as mis-aligned because the forward log
// scan reached the neighbouring step's assistant/message.
// High log position does not make a text-only checkpoint mid-step; both
// its start and end cuts are balanced in surface order.
const nodes = agent.session.surface.nodes
for (const cp of checkpoints) {
const node = nodes.find(n => n.seq === cp.seq)