Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-14 18:58:29 +08:00
507 changed files with 3451 additions and 9268 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 model comes from `summarizationModel` falling back to the agent's own, and 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 model and cap 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.
The protected `estimateContentTokens()` and `summarize()` methods are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the private retention/pressure accounting and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.

View File

@@ -48,13 +48,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

@@ -15,24 +15,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 index = nodes.indexOf(cp.seq)

View File

@@ -1,24 +1,9 @@
/**
* The compaction service seam (`ctx.compact`): an abstract service defining
* WHAT compaction does — decide when to compact, summarize a range of
* conversation history into a single surface node — without saying HOW.
*
* Implementations subclass {@link CompactService}, implement
* {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
* and load as a plugin — registering as `ctx.compact` (one implementation per
* context). A tokenizer-, template-, or model-backed implementation can live
* as a sibling package; callers stay on the same `ctx.compact` seam without
* touching consumers.
*
* The split follows the capability-seams RFC — interface (this) /
* implementation (`dsh-compact-basic`) / consumer (a `/compact` tool, deferred) — modeled
* on the bash trio. Unlike `dsh-bash`, this interface necessarily
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
* an agent-owned `Session` and the durable summary event uses the
* `ContentBlock` vocabulary. That deviation
* from the "interface depends only on cordis" guidance is intentional and
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
*
* Compaction service seam (`ctx.compact`): implementations decide when to
* compact and replace a history range with one summary node by subclassing
* {@link CompactService}. This interface necessarily depends on session and LLM
* vocabulary; the rationale is in the
* [compaction RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
* @module @deepseek-ai/dsh-compact
*/
@@ -43,25 +28,10 @@ declare module 'cordis' {
}
/**
* Abstract compaction service. Subclass implement the two abstract methods,
* and load the subclass as a plugin — it registers as `ctx.compact` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Both core methods are abstract: the contract states WHAT compaction does,
* while the entire strategy — token estimation, retention policy, event
* sequencing, summarization — is a HOW decision owned by the implementation.
*
* Implementations MUST honor:
* - **Surface contract**: a successful compaction shadows the compacted surface
* nodes with a SINGLE replacement node carrying the summary. Because
* `SurfaceEventType` is a closed union, that node is a `user/message` with
* `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
* log-only (lock + provenance).
* - **Blocking**: no compaction begins while another is in progress for the
* same session. The recommended mechanism is the log-recorded lock — append
* `compact/start` before the slow work and `compact/end` after (even on
* failure) — so the lock is visible to replay and crash recovery.
* Abstract compaction service. Implementations own token estimation, retention,
* and summarization, but a successful run must replace the selected surface span
* with one summary node and prevent concurrent compaction of the same session.
* Load one implementation per context as `ctx.compact`.
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -70,43 +40,17 @@ export abstract class CompactService extends Service {
/**
* Check token pressure and compact if the conversation is too large.
*
* Estimates the NEXT request's size — the session prefix, the
* surface-derived history, and the system prompt — and if it exceeds the
* backend's threshold, compacts an older range
* via {@link compactRegion}, keeping recent context intact. Returns `null`
* when no compaction is needed.
*
* Scope and guarantees a backend MUST honor:
* - **Compaction acts on surface-derived history only**, but the ESTIMATE
* counts everything the request carries: the loop composes the session
* prefix before the pre-step seam fires and hands it here, so the gate
* sees the prefix this instance will actually send (`EpochHeader.messagePrefix`
* — request-only, never derived history). Non-surface context injected
* downstream (into the request `messages` by a later listener) is out of
* this accounting by construction.
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
* checkpoint is
* re-summarized into one fresh checkpoint (the surface holds at most one
* auto-generated checkpoint, always at the head). It is best-effort over
* CLOSED steps: when the only compactable content left is an un-splittable
* open tail step, it declines (`null`) and retries once that step closes.
* - **Single-unit overflow is out of scope.** If a single retained unit (one
* closed step, or a large free node such as a 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 —
* as is a session prefix that alone approaches the window (a
* configuration error no compactor fixes: compaction cannot shrink the
* prefix).
* Estimate the next request, including its session prefix, derived history,
* and system prompt. Above threshold, compact a head-anchored range ending at
* a balanced tool boundary and reconsolidate any prior automatic checkpoint.
* Return `null` when no compaction is needed or an open tail leaves no safe
* cutoff. A single oversized retained unit or prefix cannot be repaired here.
*
* @param agent - agent context owning the session surface and model options.
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
* @param sessionPrefix - the instance's composed session prefix, counted toward the estimate.
* @param signal - cancellation signal. A backend summarizing via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @param sessionPrefix - the instance's composed session prefix, counted toward the
* estimate.
* @param signal - cancellation signal; model-backed implementations must forward it.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
@@ -118,37 +62,18 @@ export abstract class CompactService extends Service {
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq
* order; replacements can make visible seqs non-monotonic. Both edges must be
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges.
*
* `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
* summarizes their content and appends a replacement surface node. Used by the
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
*
* The region MUST NOT split a step's `assistant/message` tool-calls from their
* `tool/result`s, leaving the rehydrated transcript with a dangling tool-call
* or an orphaned tool-result that every provider rejects. A region is safe iff
* both its edges are balanced cuts on the surface: the cut before `start` and
* the cut after `end` each have no unanswered tool-call before them. A node
* that belongs to no step (a pre-step user message, inter-step steering, or an
* injection context message) is a balanced (free) boundary; an `end` inside an
* open (unclosed) tail step is invalid — its tool-calls have no results yet.
* `dsh-session` exports `isToolPairingBalanced` for this check.
*
* @param start - inclusive seq of the first surface node to compact.
* @param end - inclusive seq of the last surface node to compact.
* @param agent - agent context whose session is mutated and whose routing
* options are used by summarizers.
* @param signal - optional cancellation signal. A backend that summarizes via
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @throws if compaction is already in progress, if `start`/`end` are not
* valid surface nodes, if `start` is positioned after `end` on the surface
* (the range is a surface-POSITION span, not a numeric seq interval — a
* prior replace can leave the surface non-monotonic in seq order), or if
* either boundary is not a balanced tool-pairing cut (would split a step's
* tool-call/result pair).
* @returns the replaced range and its token accounting. The durable
* `compact/summary` event owns the summary and bookkeeping-event identity.
* @param start - first surface seq, inclusive.
* @param end - last surface seq, inclusive.
* @param agent - context whose session is mutated and whose routing options guide summarization.
* @param signal - optional cancellation; model-backed implementations must forward it.
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
* @returns the replaced range and token accounting; the durable event owns the summary.
*/
abstract compactRegion(
start: number,

View File

@@ -1,16 +1,6 @@
/**
* Plain-text transcript rendering over session events: the shared projection
* used wherever a compaction-class consumer needs "what a model once saw" as
* readable text — a summarizer's input, or a recall tool's output.
*
* Extracted from the basic backend's private helpers so the summarize path and
* the recall read path render one span identically (two renderers would drift,
* and a recall reader would then see a different transcript than the one the
* summary was written from). Both functions are pure over their arguments: no
* session access beyond the provided events, no clock, no randomness — a
* rendered span is a pure function of the log, so replay reproduces it
* byte-identically.
*
* Pure shared transcript projection for summarization and recall, so both
* render the same log span byte-for-byte under replay.
* @module @deepseek-ai/dsh-compact/render
*/
@@ -18,14 +8,9 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Render content blocks to a single plain-text string. Text and reasoning
* contribute their text (reasoning wrapped as `[reasoning: …]`); every other
* block type contributes a type-tagged placeholder (`[tool-call: name(args)]`,
* `[tool-result: …]`, …) so the reader is told what non-text content existed
* rather than silently losing it. A `tool-result` block recurses into its
* nested content (`[tool-result: <inner rendering>]`), falling back to a bare
* `[tool-result]` when the nested content renders to nothing. Blocks join
* with newlines; empty-text blocks contribute nothing.
* Render text directly, reasoning as a tagged span, and every other block as a
* type-tagged placeholder. Tool results recurse into nested content; empty
* blocks contribute nothing and rendered blocks join with newlines.
*
* @param blocks - the content blocks to render.
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
@@ -59,17 +44,9 @@ export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
}
/**
* Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:`
* transcript. Walks `seqs` in the order given — callers pass surface order
* (e.g. a `compactRegion` slice of the surface-node list), which after a
* `replace` is NOT ascending log-seq order (a high-seq summary node can sit at
* the head of the surface before older retained lower-seq nodes); a log-order
* scan would render the transcript out of order.
*
* Only the five surface (message-producing) event types render; a seq naming
* any other event type contributes nothing. `SessionEventMap` is
* merge-extensible, so unknown types are simply non-message events with no
* renderable text.
* Render message-producing events as a role-labeled transcript. `seqs` are
* walked in caller-supplied surface order, which may differ from numeric log
* order after replacement; non-surface and unknown merged events are skipped.
*
* @param events - the session log the seqs index into (`session.events`).
* @param seqs - the surface-node seqs to render, in surface order.

View File

@@ -1,17 +1,9 @@
/**
* Compaction vocabulary: the result type and the `compact/*` session events.
*
* Extends {@link SessionEventMap} with `compact/*` event types via declaration
* merging. {@link SurfaceEventType} is deliberately NOT extended — `compact/*`
* events are log-only markers (lock + provenance); only the five
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
* performed by a separate `user/message` event carrying the summary (see the
* [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
*
* Configuration lives in the backend, not here: the contract states WHAT
* compaction produces, while every tunable (context window, thresholds,
* retention budget) is a HOW decision owned by the implementation.
*
* Those declaration-merged events are log-only lock/provenance markers, not
* surface events; a separate replacement `user/message` carries the summary.
* Backend packages own configuration and retention policy; see
* `docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md`.
* @module @deepseek-ai/dsh-compact/types
*/