From aa9afcefc7dc85f7dc53b7f79430952ab94a9527 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 25 Jun 2026 17:30:42 +0800 Subject: [PATCH 01/28] feat(compact-basic): baseline compaction backend (squashed from compact-basic) Collapses the per-round review churn of the prior compact-basic branch into a single clean baseline on top of compact-interface, so the upcoming retention refactor lands as fresh, well-scoped commits rather than stacking on a history of fixes that are being superseded. --- docs/architecture.md | 4 +- docs/cordis-catalog/events-and-services.md | 8 +- docs/core-data-structures/compaction.md | 15 +- docs/module-graph.md | 5 + packages/README.md | 6 +- packages/compact/README.md | 6 +- packages/compact/compact-basic/README.md | 45 + packages/compact/compact-basic/package.json | 39 + packages/compact/compact-basic/src/index.ts | 753 +++++++++ packages/compact/compact-basic/src/types.ts | 44 + .../compact-basic/tests/compact-basic.spec.ts | 1361 +++++++++++++++++ packages/compact/compact-basic/tsconfig.json | 16 + packages/compact/compact/README.md | 2 +- packages/compact/compact/src/index.ts | 18 +- packages/compact/compact/src/types.ts | 11 +- packages/core/session/src/index.ts | 1 + packages/core/session/src/step-boundary.ts | 97 ++ .../core/session/tests/step-boundary.spec.ts | 172 +++ packages/support/invariants/src/index.ts | 3 - .../invariants/tests/invariants.spec.ts | 26 +- pnpm-lock.yaml | 21 + tsconfig.build.json | 1 + tsconfig.json | 1 + 23 files changed, 2628 insertions(+), 27 deletions(-) create mode 100644 packages/compact/compact-basic/README.md create mode 100644 packages/compact/compact-basic/package.json create mode 100644 packages/compact/compact-basic/src/index.ts create mode 100644 packages/compact/compact-basic/src/types.ts create mode 100644 packages/compact/compact-basic/tests/compact-basic.spec.ts create mode 100644 packages/compact/compact-basic/tsconfig.json create mode 100644 packages/core/session/src/step-boundary.ts create mode 100644 packages/core/session/tests/step-boundary.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index c76d8f7ba4..bff7b03091 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -192,7 +192,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) wrapping `agent/request`: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -220,6 +220,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging. +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the `agent/request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 84ee2a893f..b8758bef00 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -207,7 +207,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:33`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:34`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -219,7 +219,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:39`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:40`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -229,7 +229,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:48`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:49`](../../packages/core/session/src/index.ts) ### `subagent/*` @@ -430,7 +430,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:321`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:322`](../../packages/core/session/src/index.ts) ### `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 9bdb987f81..ef22d79c94 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as `dsh-compact-basic`, deferred), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -11,7 +11,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed seq range, and the estimated token count | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, and the estimated token count | | `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. @@ -32,9 +32,16 @@ interface CompactionResult { endSeq: number /** The summary content blocks produced by the backend. */ summary: ContentBlock[] - /** The seq range that was shadowed [start, end] inclusive. */ + /** + * The surface-boundary pair that was shadowed: the seqs of the first + * (`start`) and last (`end`) surface nodes of the replaced range. A + * surface-POSITION span, not a numeric seq interval — after a prior replace + * lands a fresh high-seq summary node at an older range's position, `start` + * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the + * authoritative set of shadowed nodes, in surface order. + */ shadowedRange: { start: number; end: number } - /** The seq numbers of all shadowed surface nodes. */ + /** The seqs of all shadowed surface nodes, in surface order. */ shadowedSeqs: number[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number diff --git a/docs/module-graph.md b/docs/module-graph.md index 346ef157fe..137538a0f9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -23,6 +23,10 @@ graph TD llm-replay --> llm llm-replay --> session session-persistence --> session + compact-basic --> agent + compact-basic --> compact + compact-basic --> llm + compact-basic --> session invariants --> agent invariants --> llm invariants --> session @@ -106,6 +110,7 @@ graph TD | `compact` | `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | +| `compact-basic` | `agent`, `compact`, `llm`, `session` | | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | diff --git a/packages/README.md b/packages/README.md index 11cace9017..b828fd5407 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,7 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface | +| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | @@ -29,7 +29,8 @@ dsh-bash ← dsh-brand (abstract executor seam; b dsh-session ← dsh-llm, dsh-brand dsh-system-prompt ← dsh-llm dsh-agent ← dsh-llm, dsh-session, dsh-brand -dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred) +dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred) +dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend) dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) @@ -68,6 +69,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | +| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/compact/README.md b/packages/compact/README.md index 0d63b3b8cd..384fe98ffe 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -1,11 +1,11 @@ # compact/ — compaction capability family -A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. Only the interface tier exists today; the backend and consumer are deferred. All **product** packages. +A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages. | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` (deferred) | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | -The interface lives at `compact/compact/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md new file mode 100644 index 0000000000..8aa64a1111 --- /dev/null +++ b/packages/compact/compact-basic/README.md @@ -0,0 +1,45 @@ +# @deepseek-ai/dsh-compact-basic + +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization. + +This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design. + +## What it owns + +The abstract contract states only WHAT compaction does; this backend owns every HOW decision: + +- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). +- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner. +- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`). + +`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. + +## Config (`BasicCompactConfig`) + +| Key | Default | Meaning | +|---|---|---| +| `contextWindow` | `128000` | Context window size in tokens. | +| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | +| `retainTokens` | `20480` | Tokens of recent context to keep intact. | +| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | +| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | +| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. | + +## Usage + +```ts +import type { Context } from 'cordis' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' + +export const name = 'compact-basic' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 }) +} +``` + +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. diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json new file mode 100644 index 0000000000..c745fda233 --- /dev/null +++ b/packages/compact/compact-basic/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-compact-basic", + "description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts new file mode 100644 index 0000000000..28a0040848 --- /dev/null +++ b/packages/compact/compact-basic/src/index.ts @@ -0,0 +1,753 @@ +/** + * `BasicCompactService`: the first implementation of the + * `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy: + * + * - **Token estimation** — char/4 heuristic 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 step 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** — `ctx.llm.stream()` assembled via `BlockAssembler` + * (the single model-call surface; same path the loop uses) with a fixed + * condense-the-history system prompt. + * - **Surface mutation** — a single `user/message` replace node carries the + * summary; `compact/*` events are log-only lock + provenance records. + * - **Auto-compaction** — an `agent/request` waterfall listener delegates to + * {@link BasicCompactService.compactIfNeeded} before EVERY model call (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. + * + * @module @deepseek-ai/dsh-compact-basic + */ + +import { Context } from 'cordis' +import { CompactService } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' +import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { BasicCompactConfig, ResolvedConfig } from './types.ts' +import { resolveConfig } from './types.ts' + +export type { BasicCompactConfig, ResolvedConfig } from './types.ts' +export { DEFAULTS, resolveConfig } from './types.ts' + +/** Per-block structural overhead for JSON framing / type tag. */ +const BLOCK_OVERHEAD = 4 + +/** Heuristic token count for an image block (~85 tokens for low-res URL). */ +const IMAGE_TOKEN_COST = 85 + +/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */ +const ROLE_OVERHEAD = 4 + +/** Tags wrapping the structured summary inside the landed checkpoint node. */ +const SUMMARY_OPEN_TAG = '' +const SUMMARY_CLOSE_TAG = '' + +/** + * 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). + */ +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.', + '', + '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 ${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. + */ +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. + */ +function finishError(finish: FinishReason): Error | undefined { + switch (finish.kind) { + case 'error': { + const error = new Error(finish.message) as Error & { code?: string } + if (finish.code !== undefined) error.code = finish.code + return error + } + case 'aborted': { + const error = new Error('summarization stream aborted') as Error & { code?: string } + error.code = 'ABORTED' + return error + } + case 'max-tokens': { + const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string } + error.code = 'MAX_TOKENS' + return error + } + default: + return undefined + } +} + +/** + * Basic, dependency-light compaction backend. Defaults target a 128K context + * window, compacting at 80% utilization and retaining ~20K tokens of recent + * context. + */ +export class BasicCompactService extends CompactService { + /** + * `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis + * context proxy resolve it when this service loads as a sibling of LlmService: + * without the inject, `this.ctx.llm` cannot be resolved from this fiber and + * compaction throws at runtime (see postmortem 0001). + */ + static inject = ['llm'] + + /** Resolved configuration (defaults applied). */ + readonly config: ResolvedConfig + + constructor(ctx: Context, config: BasicCompactConfig = {}) { + super(ctx) + this.config = resolveConfig(config) + + if (this.config.auto) { + // Auto-compaction: delegate to compactIfNeeded before EVERY model call — + // every step, not just the first. 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; gating to step 1 would let a + // runaway turn overflow the window before the next turn's check. The + // listener stays agnostic — it owns NO threshold logic; compactIfNeeded is + // the single place that decides whether to compact, and its in-progress + // lock serializes concurrent attempts. + ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => { + const before = this.estimateTokens(request.messages, request.system) + try { + const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal) + if (result) { + // The surface has been mutated — re-derive messages for the call. + const rederived = agent.session.deriveMessages() + const afterTokens = this.estimateTokens(rederived, request.system) + + ctx.logger.info( + `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + + `~${result.shadowedTokenCount} tokens) ` + + `→ ${afterTokens} estimated tokens after compaction ` + + `(pressure was ~${before})`, + ) + + request.messages = rederived + } + } catch (error: unknown) { + // A failed compaction must not prevent the model call — proceed + // with the original messages. + const msg = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`) + } + + return next() + }) + } + } + + // ---- Token estimation (overridable hooks) ---- + + /** + * Estimate the token count of content blocks — char/4 with per-block + * overhead. Override in a subclass to plug in a real tokenizer. + */ + estimateContentTokens(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / 4) + + Math.ceil(block.arguments.length / 4) + + BLOCK_OVERHEAD + break + case 'tool-result': + tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD + break + case 'image': + tokens += IMAGE_TOKEN_COST + break + default: + // Unknown block types (merge-extensible ContentBlockMap): + // estimate conservatively via JSON stringify. + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4) + } + } + return tokens + } + + /** + * Estimate token count for a single session event. Returns 0 for non-message + * event types (boundaries, chunks, usage, errors, compact markers). + */ + estimateEventTokens(event: SessionEvent): number { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'context/message': + case 'steering/message': + case 'tool/result': + return this.estimateContentTokens(event.data.content) + default: + return 0 + } + } + + /** Estimate total tokens across a list of messages plus optional system prompt. */ + estimateTokens(messages: readonly Message[], systemPrompt?: string): number { + let total = 0 + for (const msg of messages) { + total += this.estimateContentTokens(msg.content) + total += ROLE_OVERHEAD + } + if (systemPrompt) total += Math.ceil(systemPrompt.length / 4) + return total + } + + /** + * Summarize conversation text into content blocks via `ctx.llm.stream()` + * assembled through a `BlockAssembler` (the single model-call surface). + * 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. + */ + async summarize(text: string, model: string, signal?: AbortSignal): Promise { + if (!model) throw new Error('no model available for summarization') + + const assembler = new BlockAssembler() + const options: GenerateOptions = { + model, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], + }], + system: SUMMARIZE_SYSTEM_PROMPT, + maxTokens: this.config.summarizationMaxTokens, + } + // exactOptionalPropertyTypes: only set `signal` when present — assigning + // `undefined` to an optional `signal?: AbortSignal` is a type error. + if (signal) options.signal = signal + for await (const chunk of this.ctx.llm.stream(options)) { + assembler.push(chunk) + } + + const error = finishError(assembler.finish) + if (error) throw error + + return assembler.message().content + } + + // ---- Core API (implements the abstract contract) ---- + + /** + * The sole token-pressure gate: estimate the current history, 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. + */ + override async compactIfNeeded( + session: Session, + systemPrompt?: string, + model?: string, + signal?: AbortSignal, + ): Promise { + const messages = session.deriveMessages() + const totalTokens = this.estimateTokens(messages, systemPrompt) + + const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) + if (totalTokens < threshold) return null + + // Walk surface nodes tail→head, accumulating token estimates. + const nodes = session.surface.nodes + if (nodes.length === 0) return null + + const retainBudget = this.config.retainTokens + // ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating + // user request and any mid-turn tool results are the exact input/observation + // the model is acting on right now, even if they exceed the soft retain + // budget. Compacting them would hand the model a lossy summary of its own + // current task. Only nodes in PRIOR (closed) turns are eligible to compact; + // `protectedIdx` is the first surface node of the open turn (or `nodes.length` + // when the open turn has no surface nodes yet, e.g. before step 1). + const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes) + if (protectedIdx === 0) return null + + let accumulated = 0 + let cutoffIdx = -1 + // Seed the accumulator with the protected suffix so the retain budget is + // measured against what actually stays, then look for a cutoff only among + // the older (compactable) nodes. + for (let i = nodes.length - 1; i >= protectedIdx; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = session.events[nodes[i]!.seq] + if (event) accumulated += this.estimateEventTokens(event) + } + + for (let i = protectedIdx - 1; i >= 0; i--) { + // nodes[i] bounded by i >= 0 and i < nodes.length — never undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[i]! + const event = session.events[node.seq] + /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ + if (!event) continue + accumulated += this.estimateEventTokens(event) + if (accumulated > retainBudget) { + cutoffIdx = i + break + } + } + + // If we walked the entire compactable range without exceeding the budget, + // everything outside the protected in-flight turn fits — no compaction + // needed. + if (cutoffIdx === -1) return null + + // Snap the cutoff to a step-aligned end so the compacted region never splits + // a step (which would orphan a tool-call or its tool/result). The token + // budget is a soft target. PREFER snapping FORWARD (compact slightly more + // recent context to reach a clean boundary), but never into the protected + // in-flight turn: if the forward snap would reach `protectedIdx`, fall back + // to snapping BACKWARD to the previous step-aligned end (compact slightly + // less), and decline only if no step-aligned end exists in the compactable + // range at all. + const events = session.events + cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx) + if (cutoffIdx === -1) return null + + // nodes is non-empty (checked above) and cutoffIdx is a valid index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstSeq = nodes[0]!.seq + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cutoffSeq = nodes[cutoffIdx]!.seq + const resolvedModel = model ?? '' + + return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal) + } + + override async compactRegion( + session: Session, + start: number, + end: number, + model: string, + signal?: AbortSignal, + ): Promise { + // 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. + const nodes = session.surface.nodes + const startIdx = nodes.findIndex(n => n.seq === start) + const endIdx = nodes.findIndex(n => n.seq === end) + if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`) + if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`) + if (startIdx > endIdx) { + throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) + } + + // The region must contain whole steps, 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 boundary is + // valid when it sits on a step edge or on a node that belongs to no step + // (pre-step user message, inter-step steering, injection context); an `end` + // inside an open (unclosed) tail step is also rejected — its tool-calls have + // no results yet. See dsh-session's step-boundary predicates. + const events = session.events + if (!isStepAlignedStart(events, start)) { + throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`) + } + if (!isStepAlignedEnd(events, end)) { + throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`) + } + + if (this._isCompactionInProgress(session)) { + 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 inside the + // `agent/request` waterfall, strictly between a turn's start and end. A + // manual call on a fully-closed session has no turn to enclose the events, + // so reject rather than emit an un-enclosed run. + const turn = this._openTurn(session) + if (turn === null) { + throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') + } + // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the + // shadowed range is positional, so this is the set the replace op covers. + const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq) + + // --- Acquire lock --- + const startEvent = session.append('compact/start', { turn }) + + try { + // --- Extract text and summarize --- + const text = this._extractText(session, shadowedSeqs) + const summaryModel = this.config.summarizationModel || model + const summary = await this.summarize(text, summaryModel, signal) + + // Estimate token count of the shadowed content for provenance. + let shadowedTokenCount = 0 + for (const seq of shadowedSeqs) { + // seq comes from a surface node — always a valid log index by construction. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) + } + + // --- Provenance record (log-only) --- + const summaryEvent = session.append('compact/summary', { + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + }) + + // --- 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. + session.append('user/message', { + content: this._frameSummary(summary), + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], + }) + + // --- Release lock (log-only) --- + // Appended LAST so the lock brackets the WHOLE operation: a crash between + // compact/start and here leaves a detectable orphaned lock (a compact/start + // with no matching compact/end) rather than a compact/end that falsely + // claims compaction finished before the surface replacement landed. + const endEvent = session.append('compact/end', { turn }) + + return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary, + shadowedRange: { start, end }, + shadowedSeqs, + shadowedTokenCount, + } + } catch (error: unknown) { + // Always release the lock — append compact/end with the error so a + // wedged lock is impossible. + const msg = error instanceof Error ? error.message : String(error) + session.append('compact/end', { turn, error: msg }) + throw error + } + } + + // ---- Internal helpers ---- + + /** + * The index of the first surface node that belongs to the currently-open turn + * — the boundary of the protected, never-compacted suffix. Returns + * `nodes.length` when the open turn has contributed no verbatim surface node + * yet (e.g. before step 1 appends anything), so the whole surface is + * compaction-eligible up to the tail. + * + * The in-flight turn's verbatim nodes (its request, mid-turn assistant + * messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL + * of the surface. A compaction replacement node, though also appended during + * the open turn (seq > `turn/start`), lands at the position of the older range + * it shadowed — earlier in the surface, NOT in the tail run — so it is itself + * compaction-eligible (a later cycle can merge it). The protected suffix is + * therefore the contiguous tail run of nodes whose seq exceeds the open turn's + * `turn/start`, found by walking from the tail. With no open turn (a closed + * session — only manual `compactRegion`, never the auto path), nothing is + * protected and this returns `nodes.length`. + */ + private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number { + const openTurn = this._openTurn(session) + if (openTurn === null) return nodes.length + // Find the open turn's turn/start seq (scanning back from the tail). + let turnStartSeq = -1 + for (let i = session.events.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = session.events[i]! + if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break } + } + /* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */ + if (turnStartSeq === -1) return nodes.length + // Walk from the tail while nodes belong to the open turn (seq > turn/start), + // taking only the CONTIGUOUS run — a compaction summary node appended this + // turn but sitting earlier in the surface stops the run and stays eligible. + let idx = nodes.length + while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion + return idx + } + + /** + * Snap a raw token-budget cutoff index to a step-aligned end among the nodes + * BELOW the protected suffix (`protectedIdx`, the first node of the in-flight + * turn). Returns the snapped index, or `-1` if no step-aligned end exists in + * the compactable range (e.g. it is empty, or its only content is an open tail + * step). + * + * Prefers snapping FORWARD to the next step-aligned end (compact slightly more + * recent context for a clean boundary); if the forward scan reaches + * `protectedIdx` without finding one, falls back to scanning BACKWARD from the + * raw cutoff (compact slightly less). The protected suffix is never returned — + * it stays verbatim so the model sees its current task, not a summary. + */ + private _snapCutoff( + events: readonly SessionEvent[], + nodes: readonly SurfaceNode[], + rawCutoffIdx: number, + protectedIdx: number, + ): number { + // Forward: the next step-aligned end strictly below the protected suffix. + for (let i = rawCutoffIdx; i < protectedIdx; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedEnd(events, nodes[i]!.seq)) return i + } + // Backward: the nearest step-aligned end at or below the raw cutoff. + for (let i = rawCutoffIdx - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedEnd(events, nodes[i]!.seq)) return i + } + return -1 + } + + /** + * Frame the raw summary blocks into the content that lands on the surface: + * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a + * fresh user request) followed by the summary wrapped in + * {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior + * checkpoint detectable in the transcript on the next compaction cycle, which + * triggers the merge rule in the summarization prompt. The raw, unframed + * `summary` is preserved separately on the `compact/summary` provenance event. + */ + private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { + return [ + { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` }, + ...summary, + { type: 'text', text: SUMMARY_CLOSE_TAG }, + ] + } + + /** + * 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. + */ + private _isCompactionInProgress(session: Session): boolean { + const events = session.events + for (let i = events.length - 1; i >= 0; i--) { + // Index bounded by i >= 0 and i < events.length — never undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = events[i]! + if (e.type === 'compact/start') return true + if (e.type === 'compact/end') break + // A turn/end bounds the scan: anything before it belongs to a prior + // (closed) turn and cannot be an in-progress compaction of THIS turn. + if (e.type === 'turn/end') break + } + return false + } + + /** + * The turn number of the currently OPEN turn — a `turn/start` not yet + * followed by its `turn/end` — or `null` if the session has no open turn. + * + * Compaction's events must be enclosed in a turn, so scanning back from the + * tail: a `turn/start` means that turn is open (return it); a `turn/end` means + * the most recent turn already closed (return null). The whole compaction + * sequence (compact/start … compact/end) is stamped with this turn. + */ + private _openTurn(session: Session): number | null { + for (let i = session.events.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e = session.events[i]! + if (e.type === 'turn/start') return e.data.turn + if (e.type === 'turn/end') return null + } + return null + } + + /** + * Extract plain-text conversation from a set of surface node seqs, for + * feeding into the summarization model. Walks events in log order so the + * summary captures chronological flow. + */ + private _extractText(session: Session, seqs: number[]): string { + const lines: string[] = [] + + // Walk seqs in the order given (surface order, as compactRegion slices the + // surface-node list) — NOT ascending log-seq order. After a replace the + // summary node carries a fresh high seq while sitting at the head of the + // surface before older retained lower-seq nodes, so a log-order scan would + // feed the transcript out of order and break the checkpoint-merge prompt. + for (const seq of seqs) { + const event = session.events[seq] + /* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */ + if (!event) continue + + switch (event.type) { + case 'user/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`User: ${text}`) + break + } + case 'assistant/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`Assistant: ${text}`) + break + } + case 'tool/result': { + const text = this._blocksToText(event.data.content) + const label = event.data.isError ? 'Tool error' : 'Tool result' + if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`) + break + } + case 'context/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`[Context: ${text}]`) + break + } + case 'steering/message': { + const text = this._blocksToText(event.data.content) + if (text) lines.push(`[Steering: ${text}]`) + break + } + // SessionEventMap is merge-extensible — unknown types are + // non-message events that carry no extractable text. + /* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */ + default: + break + } + } + + return lines.join('\n\n') + } + + /** + * Render content blocks to a single plain-text string for the summarization + * prompt. Text and reasoning contribute their text; every other block type + * contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, + * …) so the summarizer is told what non-text content existed in the region + * rather than silently losing it. Blocks join with newlines; empty-text + * blocks contribute nothing. + */ + private _blocksToText(blocks: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text) parts.push(block.text) + break + case 'reasoning': + if (block.text) parts.push(`[reasoning: ${block.text}]`) + break + case 'tool-call': + parts.push(`[tool-call: ${block.name}(${block.arguments})]`) + break + case 'tool-result': { + const inner = this._blocksToText(block.content) + parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') + break + } + case 'image': + parts.push('[image]') + break + // ContentBlockMap is merge-extensible — render an unknown block as a + // bare type-tagged placeholder so a plugin-added block type is still + // signalled to the summarizer rather than dropped. + default: + parts.push(`[${(block as ContentBlock).type}]`) + } + } + return parts.join('\n') + } +} + +export default BasicCompactService diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts new file mode 100644 index 0000000000..120fad0b19 --- /dev/null +++ b/packages/compact/compact-basic/src/types.ts @@ -0,0 +1,44 @@ +/** + * Configuration vocabulary for the basic compaction backend. + * + * Every tunable lives here, in the implementation — the abstract contract + * (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and + * retention policy are HOW decisions a different backend would make + * differently. + * + * @module @deepseek-ai/dsh-compact-basic/types + */ + +/** Backend configuration — all optional with sensible defaults. */ +export interface BasicCompactConfig { + /** Context window size in tokens (default 128000). */ + contextWindow?: number + /** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */ + thresholdRatio?: number + /** Number of tokens of recent context to retain during compaction (default 20480). */ + retainTokens?: number + /** Model to use for summarization (default '' — uses the agent's model). */ + summarizationModel?: string + /** Maximum tokens for the summarization response (default 2048). */ + summarizationMaxTokens?: number + /** Enable automatic compaction on the `agent/request` waterfall (default true). */ + auto?: boolean +} + +/** Resolved config with all defaults applied. */ +export type ResolvedConfig = Required + +/** Default configuration values. */ +export const DEFAULTS: ResolvedConfig = { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + summarizationMaxTokens: 2048, + auto: true, +} + +/** Apply defaults to a partial config. */ +export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { + return { ...DEFAULTS, ...config } +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts new file mode 100644 index 0000000000..3852b825f7 --- /dev/null +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -0,0 +1,1361 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' +import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** + * A BasicCompactService with summarize() stubbed (no real model call) and a + * predictable token estimate, for deterministic unit tests of the algorithm. + */ +class TestCompactService extends BasicCompactService { + /** Track calls to summarize for test assertions. */ + summarizeCalls: { text: string; model: string }[] = [] + /** The fixed summary to return. */ + mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }] + /** If set, summarize() throws this error. */ + summarizeError: Error | null = null + + override estimateContentTokens(blocks: readonly ContentBlock[]): number { + // 10 tokens per block — predictable for retention/threshold math. + return blocks.length * 10 + } + + override async summarize(text: string, model: string): Promise { + this.summarizeCalls.push({ text, model }) + if (this.summarizeError) throw this.summarizeError + return this.mockSummary + } +} + +/** Create a test service with a throwaway context (auto disabled — no model). */ +function createTestService(config: BasicCompactConfig = {}): TestCompactService { + return new TestCompactService(new Context(), { auto: false, ...config }) +} + +/** + * A test service where specific surface seqs (in `bigSeqs`) weigh 1000 tokens + * and every other message-producing event weighs 10 — for exercising the + * "newest node alone exceeds retainTokens" retention path. summarize() is + * stubbed (no model call). + */ +class TestCompactServiceVarTokens extends BasicCompactService { + bigSeqs = new Set() + constructor(config: BasicCompactConfig = {}) { + super(new Context(), { auto: false, ...config }) + } + + override estimateEventTokens(event: SessionEvent): number { + if (this.bigSeqs.has(event.seq)) return 1000 + return super.estimateEventTokens(event) + } + + override async summarize(): Promise { + return [{ type: 'text', text: 'summary' }] + } +} + +/** + * 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/request` waterfall between a turn's start and its end), 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). + */ +function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session { + const leaveOpen = opts.leaveOpen ?? true + const s = new Session(SessionId('test')) + for (let t = 1; t <= turns; t++) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: t, step: 1 }) + for (let m = 0; m < messagesPerTurn; m++) { + s.append('user/message', { + content: [{ type: 'text', text: `turn ${t} user message ${m + 1}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: t, step: 1, + content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}` }], + }, { surfaceOp: 'append' }) + } + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // Open one more turn so compaction's events are turn-enclosed, as they are + // when the loop runs the auto-compaction listener mid-turn. + if (leaveOpen) { + s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + } + return s +} + +/** Build a session with tool calls for richer extraction tests. */ +function sessionWithTools(): Session { + const s = new Session(SessionId('tools')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { + content: [{ type: 'text', text: 'read file x' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'text', text: 'Let me read that file.' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }, + ], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"cat x"}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('c1'), + content: [{ type: 'text', text: 'hello world' }], + isError: false, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'text', text: 'The file contains: hello world' }], + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Open a trailing turn so compaction's events are turn-enclosed (as they are + // when the loop runs the auto-compaction listener mid-turn). + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + return s +} + +/** + * Build a session of `turns` turns, each a SINGLE step containing an + * assistant/message that issues a tool-call plus its tool/result — the real + * multi-node-step shape (a step is two surface nodes: the assistant and the + * result). Each turn is preceded by a user/message. Used to exercise + * step-alignment: a region boundary must not fall between the assistant and its + * result. + */ +function toolTurnSession(turns: number): Session { + const s = new Session(SessionId('tools-multi')) + for (let t = 1; t <= turns; t++) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { + content: [{ type: 'text', text: `turn ${t} request` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('step/start', { turn: t, step: 1 }) + s.append('assistant/message', { + turn: t, step: 1, + content: [ + { type: 'text', text: `turn ${t} calling tool` }, + { type: 'tool-call', id: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }, + ], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: t, step: 1, callId: CallId(`c${t}`), name: 'bash', arguments: '{"command":"ls"}' }) + s.append('tool/result', { + turn: t, step: 1, callId: CallId(`c${t}`), + content: [{ type: 'text', text: `turn ${t} output` }], + isError: false, + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // Open a trailing turn so compaction's events are turn-enclosed. + s.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return s +} + +/** + * Assert the derived transcript has NO orphaned tool-result: every + * `tool-result` block's `toolCallId` must be matched by a preceding `tool-call` + * block in an earlier (assistant) message. A dangling tool-result is exactly + * what splitting a step at compaction produces, and every provider rejects it. + */ +function expectNoOrphanToolResults(messages: Message[]): void { + const seenCallIds = new Set() + for (const msg of messages) { + for (const block of msg.content) { + if (block.type === 'tool-call') seenCallIds.add(block.id) + if (block.type === 'tool-result') { + expect(seenCallIds.has(block.toolCallId), + `orphaned tool-result for callId ${block.toolCallId} (no preceding tool-call)`).toBe(true) + } + } + } +} + +describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { + it('compactIfNeeded snaps the cutoff forward past a mid-step boundary (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. With retainTokens=55 the tail→head walk overflows at + // asst2 (idx4), so the RAW cutoff falls BETWEEN asst2 and its result res2 + // (idx5) — splitting turn 2's step. The fix snaps the cutoff forward to res2 + // so the whole step is compacted and no dangling result survives. + const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) + const session = toolTurnSession(3) + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + // res2 (idx5) was pulled into the compacted region by the snap, not stranded. + expectNoOrphanToolResults(session.deriveMessages()) + // Turn 3's step is retained intact (summary + user3 + asst3 + res3 = 4 msgs). + expect(session.deriveMessages().length).toBe(4) + }) + + it('compactIfNeeded returns null when the only cutoff would enter an open tail step', async () => { + // A pre-step user/message then an OPEN step (assistant issued a tool-call, no + // tool/result / step/end yet — mid-flight). The token walk wants to compact + // into that open step, but its tool-call has no result yet; compacting it + // would defer the orphan. With no safe step-aligned cutoff, compactIfNeeded + // declines (returns null) rather than summarizing a pending tool-call away. + const s = new Session(SessionId('open-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + // no tool/result, no step/end — the step is open at the tail. + + const svc = createTestService({ contextWindow: 50, thresholdRatio: 0.5, retainTokens: 5 }) + const result = await svc.compactIfNeeded(s) + expect(result).toBeNull() + // The open step's assistant survived — its tool-call is intact for the result. + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('compactRegion rejects a start that is not a step boundary (splits a step)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes // [user, asst(tool-call), result] + const userSeq = nodes[0]!.seq + const resultSeq = nodes[2]!.seq + // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, + // so starting here would orphan that assistant's tool-call. end is fine (user). + await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) + .rejects.toThrow(/start seq .* is not on a step boundary/) + expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected + }) + + it('compactRegion rejects an end that is not a step boundary (splits a step)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes + const userSeq = nodes[0]!.seq + const asstSeq = nodes[1]!.seq + // end = the assistant/message: its tool/result follows IN THE SAME STEP, so + // ending here would strand that result. start is fine (the pre-step user). + await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) + .rejects.toThrow(/end seq .* is not on a step boundary/) + }) + + it('compactRegion rejects an end inside an open tail step', async () => { + const svc = createTestService() + const s = new Session(SessionId('open-tail')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes // [user, asst] + const userSeq = nodes[0]!.seq + const asstSeq = nodes[1]!.seq + await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) + .rejects.toThrow(/end seq .* is not on a step boundary/) + }) + + it('compactRegion accepts step-aligned boundaries (pre-step user → last result of a closed step)', async () => { + const svc = createTestService() + const session = toolTurnSession(2) + const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2] + const startSeq = nodes[0]!.seq // pre-step user1 (free boundary) + const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step + const result = await svc.compactRegion(session, startSeq, endSeq, 'm') + expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) + expectNoOrphanToolResults(session.deriveMessages()) + }) + + it('compactRegion accepts a single inter-step node (start === end on a pre-step user/message)', async () => { + const svc = createTestService() + const session = toolTurnSession(1) + const nodes = session.surface.nodes + const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways + const result = await svc.compactRegion(session, userSeq, userSeq, 'm') + expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) + }) + + it('compactRegion accepts an injection-turn context node (no step at all)', async () => { + const svc = createTestService() + const s = new Session(SessionId('inject')) + // An idle inject(): turn/start → context/message, NO step. A later turn is + // open so compaction's events are turn-enclosed. + s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) + s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const nodes = s.surface.nodes + const ctxSeq = nodes[0]!.seq + const result = await svc.compactRegion(s, ctxSeq, ctxSeq, 'm') + expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) + }) +}) + +describe('BasicCompactService.estimateEventTokens', () => { + it('returns 0 for non-message events (boundary, chunk, step/end, tool/call)', () => { + const svc = createTestService() + expect(svc.estimateEventTokens({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } })).toBe(0) + expect(svc.estimateEventTokens({ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'read', arguments: '{}' } })).toBe(0) + }) + + it('returns estimate for message-producing events', () => { + const svc = createTestService() + const userEvent: SessionEvent = { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } } + expect(svc.estimateEventTokens(userEvent)).toBe(10) + + const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } } + expect(svc.estimateEventTokens(asstEvent)).toBe(20) + + const toolEvent: SessionEvent = { type: 'tool/result', seq: 2, time: 3, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'output' }], isError: false } } + expect(svc.estimateEventTokens(toolEvent)).toBe(10) + }) +}) + +describe('BasicCompactService.estimateTokens', () => { + it('sums token estimates across messages', () => { + const svc = createTestService() + const messages: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }, { type: 'text', text: 'there' }] }, + ] + // 1 block * 10 + 4 (role) + 2 blocks * 10 + 4 (role) = 10 + 4 + 20 + 4 = 38 + expect(svc.estimateTokens(messages)).toBe(38) + }) + + it('includes system prompt in the estimate', () => { + const svc = createTestService() + const messages: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ] + const systemPrompt = 'You are a helpful assistant.' + // 1 block * 10 + 4 (role) + ceil(28/4) = 10 + 4 + 7 = 21 + expect(svc.estimateTokens(messages, systemPrompt)).toBe(21) + }) +}) + +describe('BasicCompactService.compactRegion', () => { + it('shadows surface nodes and inserts a summary via user/message', async () => { + const svc = createTestService() + const session = multiTurnSession(3, 1) // 3 turns, 2 surface nodes each = 6 nodes + + const nodes = session.surface.nodes + expect(nodes.length).toBe(6) + + const firstSeq = nodes[0]!.seq + const secondSeq = nodes[1]!.seq + const result = await svc.compactRegion(session, firstSeq, secondSeq, 'test-model') + + expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) + expect(result.shadowedRange.start).toBe(firstSeq) + expect(result.shadowedRange.end).toBe(secondSeq) + expect(result.summary).toEqual(svc.mockSummary) + + const events = session.events + const startEvent = events.findLast(e => e.type === 'compact/start') + const summaryEvent = events.findLast(e => e.type === 'compact/summary') + const endEvent = events.findLast(e => e.type === 'compact/end') + expect(startEvent).toBeDefined() + expect(summaryEvent).toBeDefined() + expect(endEvent).toBeDefined() + + // compact/* events are log-only — no surfaceOp (type system enforces this). + const startRaw = startEvent as unknown as { surfaceOp?: unknown } + expect(startRaw.surfaceOp).toBeUndefined() + + // The user/message carries the replace surfaceOp. + const userMsg = events.findLast(e => e.type === 'user/message')! + const surfaceUserMsg = userMsg as SurfaceEvent + expect(surfaceUserMsg.surfaceOp).toEqual({ op: 'replace', start: firstSeq, end: secondSeq }) + expect(surfaceUserMsg.sourceEventSeqs).toContain(startEvent!.seq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(summaryEvent!.seq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(firstSeq) + expect(surfaceUserMsg.sourceEventSeqs).toContain(secondSeq) + // compact/end is appended AFTER the replacement (the lock brackets the whole + // op), so the replacement cannot reference it — sourceEventSeqs may only + // reference earlier seqs. + expect(surfaceUserMsg.sourceEventSeqs).not.toContain(endEvent!.seq) + expect(endEvent!.seq).toBeGreaterThan(userMsg.seq) + + // Surface now has: summary user/message + retained 4 nodes = 5 nodes. + const newNodes = session.surface.nodes + expect(newNodes.length).toBe(5) + expect(newNodes[0]!.seq).toBe(userMsg.seq) + + // deriveMessages() produces the framed summary as a user-role message: + // a checkpoint preamble + tag-wrapped summary blocks. + const derived = session.deriveMessages() + expect(derived.length).toBe(5) + expect(derived[0]!.role).toBe('user') + const framed = derived[0]!.content + expect(framed[0]).toMatchObject({ type: 'text' }) + expect((framed[0] as { text: string }).text).toContain('') + expect(framed).toContainEqual(svc.mockSummary[0]) + expect((framed[framed.length - 1] as { text: string }).text).toBe('') + }) + + it('throws when start or end are not surface nodes', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + await expect(svc.compactRegion(session, 999, 1000, 'm')) + .rejects.toThrow(/start seq 999 not found in surface/) + }) + + it('throws when start is positioned after end on the surface', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + await expect(svc.compactRegion(session, nodes[1]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/is after end seq .* on the surface/) + }) + + it('throws when compaction is already in progress', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + session.append('compact/start', { turn: 2 }) + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/compaction already in progress/) + }) + + it('appends compact/end with error on summarize failure', async () => { + const svc = createTestService() + svc.summarizeError = new Error('model unavailable') + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow('model unavailable') + + const endEvent = session.events.findLast(e => e.type === 'compact/end') + expect(endEvent).toBeDefined() + // multiTurnSession(2,…) closes turns 1-2 and leaves turn 3 open; compaction + // stamps the open turn. + expect(endEvent!.data).toMatchObject({ turn: 3, error: 'model unavailable' }) + + // No replace-op user/message was appended (summarize failed). + const userMsgsAfter = session.events.filter(e => e.type === 'user/message') + const replaceMsgs = userMsgsAfter.filter((e) => { + const se = e as unknown as { surfaceOp?: unknown } + return se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string' + }) + expect(replaceMsgs.length).toBe(0) + }) + + it('extracts conversation text for summarization', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 2) + const nodes = session.surface.nodes + + await svc.compactRegion(session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + + expect(svc.summarizeCalls.length).toBe(1) + const { text, model } = svc.summarizeCalls[0]! + expect(model).toBe('m') + expect(text).toContain('User: turn 1 user message 1') + expect(text).toContain('Assistant: turn 1 assistant response 1') + }) + + it('frames the landed summary with a checkpoint preamble and tags, keeping raw provenance', async () => { + const svc = createTestService() + svc.mockSummary = [{ type: 'text', text: 'STRUCTURED SUMMARY' }] + const session = multiTurnSession(3, 1) + const nodes = session.surface.nodes + + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + + // Provenance (compact/summary) carries the RAW, unframed summary. + expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) + const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! + expect(summaryEvent.data).toMatchObject({ summary: [{ type: 'text', text: 'STRUCTURED SUMMARY' }] }) + + // The landed surface node is framed: preamble + tag-wrapped summary. + const landed = session.deriveMessages()[0]!.content + expect((landed[0] as { text: string }).text).toContain('checkpoint') + expect((landed[0] as { text: string }).text).toContain('') + expect(landed).toContainEqual({ type: 'text', text: 'STRUCTURED SUMMARY' }) + expect((landed[landed.length - 1] as { text: string }).text).toBe('') + }) + + it('extracts tool-call and tool-result context', async () => { + const svc = createTestService() + const session = sessionWithTools() + const nodes = session.surface.nodes + + const firstSeq = nodes[0]!.seq + const lastSeq = nodes[nodes.length - 1]!.seq + await svc.compactRegion(session, firstSeq, lastSeq, 'm') + + expect(svc.summarizeCalls.length).toBe(1) + const { text } = svc.summarizeCalls[0]! + expect(text).toContain('read file x') + expect(text).toContain('bash') + expect(text).toContain('Tool result') + }) +}) + +describe('BasicCompactService.compactIfNeeded', () => { + it('returns null when tokens are under threshold', async () => { + const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) + const session = multiTurnSession(1, 1) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('compacts when tokens exceed threshold', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + }) + + it('walks tail→head and retains nodes within token budget', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 15 }) + const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens + + const result = await svc.compactIfNeeded(session) + expect(result).not.toBeNull() + const nodes = session.surface.nodes + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq) + }) + + it('returns null when total tokens fit within budget', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 1000 }) + const session = multiTurnSession(2, 1) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('retains the in-flight turn verbatim even when its newest node exceeds retainTokens', async () => { + // The current turn's first step has CLOSED (so its last node is step-aligned + // and would otherwise be a valid compaction cutoff), and that node — a fresh + // tool result — is larger than the whole retain budget. It must NOT be + // compacted: it is the observation the model needs for the turn's next step. + // Only the older closed turns are eligible. + const svc = new TestCompactServiceVarTokens({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + const s = new Session(SessionId('big-tail')) + // Two closed turns (compactable older context). + for (const t of [1, 2]) { + s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: t, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: `turn ${t}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: t, step: 1, content: [{ type: 'text', text: `reply ${t}` }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: t, step: 1 }) + s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + } + // The in-flight turn 3: a user request, then a CLOSED step 1 whose tool + // result is HUGE (1000 tokens). The step is closed (step/end), so the result + // node is step-aligned — without the in-flight-turn protection the retention + // walk would pick it as the cutoff and compact it away. The turn itself is + // still open (no turn/end): the model is mid-turn, about to run step 2. + s.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'current request' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/start', { turn: 3, step: 1 }) + s.append('assistant/message', { turn: 3, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('huge'), name: 'bash', arguments: '{}' }] }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 3, step: 1, callId: CallId('huge'), name: 'bash', arguments: '{}' }) + const hugeSeq = s.append('tool/result', { + turn: 3, step: 1, callId: CallId('huge'), + content: [{ type: 'text', text: 'HUGE' }], isError: false, + }, { surfaceOp: 'append' }).seq + s.append('step/end', { turn: 3, step: 1 }) + svc.bigSeqs.add(hugeSeq) // make this node weigh 1000 tokens + + const result = await svc.compactIfNeeded(s) + expect(result).not.toBeNull() + // The in-flight turn's nodes — the request, the assistant, AND the huge + // result — are retained: none shadowed, all survive on the surface verbatim. + expect(result!.shadowedSeqs).not.toContain(hugeSeq) + const survivingSeqs = new Set(s.surface.nodes.map(n => n.seq)) + expect(survivingSeqs.has(hugeSeq)).toBe(true) + const requestSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'current request'))!.seq + expect(survivingSeqs.has(requestSeq)).toBe(true) + // The older closed turns WERE compacted. + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + }) + + it('returns null for an empty surface', async () => { + const svc = createTestService({ contextWindow: 10, thresholdRatio: 0.1 }) + const session = new Session(SessionId('empty')) + expect(await svc.compactIfNeeded(session)).toBeNull() + }) + + it('compacts again within the same open turn (the prior summary node is still eligible)', async () => { + // After the first compaction lands a replacement summary node, that node is + // appended DURING the open turn (seq > turn/start) but sits earlier in the + // surface (at the shadowed range's position), NOT in the verbatim tail run. + // It must stay compaction-eligible: a second step in the SAME turn, still + // over threshold, must be able to compact older context — protectedIdx must + // not collapse to 0 and silently disable per-step auto-compaction. + // retainTokens=25 leaves a couple of retained closed-turn nodes after the + // first compaction (so the surface is [summary, …retained], not [summary]). + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 25 }) + const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) + + const first = await svc.compactIfNeeded(s) + expect(first).not.toBeNull() + // The summary node now heads the surface; the open turn has no verbatim tail + // node yet, so the whole surface (incl. the summary) is eligible — the + // protected suffix is the contiguous tail run of open-turn nodes (none yet). + // The summary node's seq exceeds turn 5's turn/start, yet it sits at the + // head (not the tail), so it must NOT be counted as protected. + const summaryHeadSeq = s.surface.nodes[0]!.seq + const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq + expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) + + // Append a verbatim node in the open turn (a step's output), still over + // threshold, then compact again — the older summary + closed turns compact, + // the fresh nodes are retained. + s.append('step/start', { turn: 5, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'turn 5 work' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 5, step: 1 }) + + const second = await svc.compactIfNeeded(s) + expect(second).not.toBeNull() + expect(second!.shadowedSeqs.length).toBeGreaterThan(0) + // The fresh open-turn nodes were NOT compacted. + const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq + expect(second!.shadowedSeqs).not.toContain(turn5UserSeq) + }) +}) + +describe('BasicCompactService replay equivalence', () => { + it('produces identical deriveMessages() after seeding from compacted log', async () => { + const svc = createTestService() + const session = multiTurnSession(3, 1) + const nodes = session.surface.nodes + + await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const derived = session.deriveMessages() + + const replayed = new Session(SessionId('replay'), [...session.events]) + expect(replayed.deriveMessages()).toEqual(derived) + }) +}) + +describe('BasicCompactService blocking (compaction in progress)', () => { + it('detects in-progress compaction from unmatched compact/start', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + session.append('compact/start', { turn: 1 }) + const nodes = session.surface.nodes + // Whole step (user → assistant) is a step-aligned region, so the call reaches + // the in-progress check rather than being rejected for splitting a step. + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/compaction already in progress/) + }) + + it('allows compaction after compact/end is appended', async () => { + const svc = createTestService() + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 1 }) + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + expect(result).toBeDefined() + }) + + 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. + const svc = createTestService() + const s = new Session(SessionId('stale-lock')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'turn 1' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply 1' }] }, { surfaceOp: 'append' }) + s.append('compact/start', { turn: 1 }) // ← orphaned: no matching compact/end + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // repair closed the turn + // A new open turn. + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const nodes = s.surface.nodes + + // The stale start is before the turn/end, so it is NOT seen as in-progress. + const result = await svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm') + expect(result).toBeDefined() + }) +}) + +describe('BasicCompactService token estimation (char/4 heuristic)', () => { + it('estimates text blocks with char/4 + overhead', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 + const blocks: ContentBlock[] = [ + { type: 'text', text: 'this is a somewhat longer text block' }, + { type: 'text', text: 'short' }, + ] + expect(svc.estimateContentTokens(blocks)).toBe(19) + }) + + it('estimates reasoning blocks same as text', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 + expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) + }) + + it('estimates tool-call blocks from name + arguments', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 + expect(svc.estimateContentTokens([ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, + ])).toBe(9) + }) + + it('estimates tool-result blocks recursively', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 + expect(svc.estimateContentTokens([ + { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, + ])).toBe(10) + }) + + it('estimates image blocks at fixed 85 tokens', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85) + }) + + it('returns 0 for empty content blocks', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + expect(svc.estimateContentTokens([])).toBe(0) + }) +}) + +describe('BasicCompactService HMR safety', () => { + it('registers as ctx.compact', () => { + const ctx = new Context() + void new BasicCompactService(ctx, { auto: false }) + expect(ctx.compact).toBeDefined() + expect(ctx.compact).toBeInstanceOf(BasicCompactService) + }) +}) + +/** An adapter that emits a fixed summary text, for exercising the real summarize() path. */ +class ScriptedAdapter extends LlmAdapter { + lastOptions: GenerateOptions | null = null + constructor(private summaryText: string) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.lastOptions = options + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: this.summaryText } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Wire a real LlmService + scripted adapter into a context. */ +async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new ScriptedAdapter(summaryText) + ctx.llm.registerAdapter([model], adapter) + return { ctx, adapter } +} + +/** An adapter whose stream ends with a finish chunk of the given reason (no content). */ +class FinishOnlyAdapter extends LlmAdapter { + constructor(private reason: StreamChunk & { type: 'finish' }) { + super() + } + + async * stream(): AsyncIterable { + yield this.reason + } +} + +/** Wire a real LlmService + finish-only adapter into a context. */ +async function ctxWithFinish(reason: (StreamChunk & { type: 'finish' })['reason'], model = 'test-model'): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter([model], new FinishOnlyAdapter({ type: 'finish', reason })) + return ctx +} + +/** A minimal Agent stub carrying just session + options (enough for the listeners). */ +function stubAgent(session: Session, model?: string): Agent { + return { session, options: { model } } as unknown as Agent +} + +describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { + it('summarizes via the registered adapter and returns its content', async () => { + const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') + const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) + + const summary = await svc.summarize('User: hi\n\nAssistant: hello', 'test-model') + expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) + // The fixed system prompt and maxTokens flow through. + expect(adapter.lastOptions!.system).toContain('compaction engine') + expect(adapter.lastOptions!.system).toContain('## Next Step') + expect(adapter.lastOptions!.maxTokens).toBe(512) + expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) + }) + + it('throws when no model is provided', async () => { + const { ctx } = await ctxWithModel('x') + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', '')).rejects.toThrow(/no model available/) + }) + + it('rethrows when the stream ends with a finish-error chunk', async () => { + const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) + }) + + it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { + const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) + const svc = new BasicCompactService(ctx, { auto: false }) + const error = await svc.summarize('text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) + expect(error?.message).toBe('opaque failure') + expect(error?.code).toBeUndefined() + }) + + it('rethrows when the stream ends with a finish-aborted chunk', async () => { + const ctx = await ctxWithFinish({ kind: 'aborted' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) + }) + + it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { + const ctx = await ctxWithFinish({ kind: 'max-tokens' }) + const svc = new BasicCompactService(ctx, { auto: false }) + await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) + }) + + it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { + const ctx = await ctxWithFinish({ kind: 'max-tokens' }) + const svc = new BasicCompactService(ctx, { auto: false }) + const session = multiTurnSession(2, 1) + const before = [...session.surface.nodes] + const nodes = session.surface.nodes + + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) + .rejects.toMatchObject({ code: 'MAX_TOKENS' }) + + // No replacement landed — the surface is byte-identical, and the lock was + // released with the error (compact/end carries it). + expect(session.surface.nodes).toEqual(before) + const endEvent = session.events.findLast(e => e.type === 'compact/end')! + const endData = endEvent.data as { error?: string } + expect(endData.error).toContain('truncated') + }) + + it('compactRegion uses the real summarizer end-to-end', async () => { + const { ctx } = await ctxWithModel('CONDENSED') + const svc = new BasicCompactService(ctx, { auto: false }) + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + // The raw summary is wrapped in the checkpoint framing on the surface. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) + }) +}) + +describe('BasicCompactService auto-compaction (agent/request listener)', () => { + /** Fire the agent/request waterfall as the loop does. */ + function fireRequest(ctx: Context, agent: Agent, step: number, options: GenerateOptions): Promise { + return ctx.waterfall('agent/request', agent, 1, step, options, () => Promise.resolve(options)) + } + + it('compacts and rewrites request.messages when over threshold', async () => { + // Tiny window so the (large) session is over threshold; char/4 estimate. + const { ctx } = await ctxWithModel('SUMMARY') + const svc = new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + const session = multiTurnSession(5, 1) // 10 surface nodes + const agent = stubAgent(session, 'test-model') + + const messages = session.deriveMessages() + const before = messages.length + const options: GenerateOptions = { model: 'test-model', messages } + + const out = await fireRequest(ctx, agent, 1, options) + // The surface shrank — request.messages was re-derived to fewer entries. + expect(out.messages.length).toBeLessThan(before) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + // Re-derived first message is the framed summary checkpoint. + expect(out.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + expect(svc).toBeDefined() + }) + + it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + const session = multiTurnSession(3, 1) // over the 0.5 threshold + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + // A step-2 request (a tool-heavy turn's later step) must still compact — the + // surface accumulated assistant/message + tool/result nodes since step 1. + await fireRequest(ctx, agent, 2, options) + expect(session.events.some(e => e.type === 'compact/start')).toBe(true) + }) + + it('passes through unchanged when under threshold', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) + const session = multiTurnSession(1, 1) + const agent = stubAgent(session, 'test-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs } + + const out = await fireRequest(ctx, agent, 1, options) + expect(out.messages).toBe(msgs) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('proceeds with original history when compaction fails', async () => { + // No adapter registered for this model → summarize() rejects → caught, proceeds. + const ctx = new Context() + await ctx.plugin(LlmService) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'missing-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'missing-model', messages: msgs } + + const out = await fireRequest(ctx, agent, 1, options) + // Listener swallowed the failure and left messages intact. + expect(out.messages).toBe(msgs) + }) + + it('does not register the listener when auto is false', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + void new BasicCompactService(ctx, { auto: false, contextWindow: 10, thresholdRatio: 0.1, retainTokens: 1 }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + await fireRequest(ctx, agent, 1, options) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + }) +}) + +describe('BasicCompactService._extractText branches', () => { + it('renders reasoning, context, and steering messages', async () => { + const svc = createTestService() + const s = new Session(SessionId('rich')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('context/message', { + content: [{ type: 'text', text: 'project context here' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'reasoning', text: 'thinking hard' }, { type: 'text', text: 'answer' }], + }, { surfaceOp: 'append' }) + s.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'steer this way' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + + const { text } = svc.summarizeCalls[0]! + expect(text).toContain('[Context: project context here]') + expect(text).toContain('[reasoning: thinking hard]') + expect(text).toContain('[Steering: steer this way]') + }) + + it('labels tool errors distinctly from tool results', async () => { + const svc = createTestService() + const s = new Session(SessionId('toolerr')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c9'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('c9'), + content: [{ type: 'text', text: 'boom failure' }], + isError: true, + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') + }) +}) + +describe('BasicCompactService edge cases', () => { + it('renders bare and nested tool-result placeholders and unknown blocks', async () => { + const svc = createTestService() + const s = new Session(SessionId('toolresult')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + // assistant/message carrying a nested tool-result block and an unknown block. + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, + { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, + ], + }, { surfaceOp: 'append' }) + // tool/result whose content is itself only non-text → bare '[tool-result]'. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('b1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { + turn: 1, step: 1, callId: CallId('b1'), + content: [{ type: 'tool-result', toolCallId: CallId('inner'), content: [] }], + isError: false, + }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + const { text } = svc.summarizeCalls[0]! + expect(text).toContain('[tool-result: [image]]') // nested tool-result with content + expect(text).toContain('[custom-widget]') // unknown block placeholder + expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder + }) + + it('estimates unknown block types via JSON length (default branch)', () => { + const svc = new BasicCompactService(new Context(), { auto: false }) + // A block whose type is none of the known kinds — exercises the default arm. + const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock + expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) + }) + + it('compacts and re-derives without re-checking a post-compaction threshold', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + // Even with a window so tiny the post-compaction history still exceeds the + // threshold, the agnostic listener does NOT re-gate or warn — it compacts + // once (the single check lives in compactIfNeeded) and proceeds. + const warnings: string[] = [] + ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn + void new BasicCompactService(ctx, { contextWindow: 10, thresholdRatio: 0.1, retainTokens: 5 }) + const session = multiTurnSession(4, 1) + const agent = stubAgent(session, 'test-model') + const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } + + await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + // The surface was re-derived into the request; no cascade warning is emitted. + expect(options.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + expect(warnings.length).toBe(0) + }) + + it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { + const svc = createTestService() + // A session with surface nodes but NO open turn — compaction's compact/* and + // replacement events would be appended outside any turn, which the session-log + // contract forbids. + const s = new Session(SessionId('noturn')) + s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes + + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/no open turn/) + // The lock was never acquired — no compact/start landed. + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + + it('compactIfNeeded returns null for empty surface even when over threshold', async () => { + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1 }) + const session = new Session(SessionId('empty-but-pressured')) + // No surface nodes, but a large system prompt pushes the estimate over threshold. + const bigPrompt = 'x'.repeat(400) // ceil(400/4) = 100 tokens >> threshold 10 + expect(await svc.compactIfNeeded(session, bigPrompt)).toBeNull() + }) + + it('compactRegion throws when end is not a surface node (start valid)', async () => { + const svc = createTestService() + const session = multiTurnSession(1, 1) + const nodes = session.surface.nodes + await expect(svc.compactRegion(session, nodes[0]!.seq, 9999, 'm')) + .rejects.toThrow(/end seq 9999 not found in surface/) + }) + + it('compactRegion stringifies a non-Error thrown by summarize', async () => { + const svc = createTestService() + // Throw a non-Error value to exercise the String(error) branch in the catch. + svc.summarizeError = 'plain string failure' as unknown as Error + const session = multiTurnSession(1, 1) + const nodes = session.surface.nodes + + // Whole step (user → assistant): a step-aligned region that reaches summarize. + await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') + const endEvent = session.events.findLast(e => e.type === 'compact/end')! + expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) + }) + + it('auto-compaction listener stringifies a non-Error and proceeds', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + const warnings: string[] = [] + ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn + const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + svc.summarizeError = 'boom' as unknown as Error + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs } + + const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(out.messages).toBe(msgs) // proceeded with original history + expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true) + }) + + it('auto-compaction listener takes the result-null branch (nothing to compact)', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + // A large system prompt pushes the listener's estimate over threshold, but + // retainTokens is huge so compactIfNeeded walks everything and returns null. + const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 100000 }) + const session = multiTurnSession(2, 1) + const agent = stubAgent(session, 'test-model') + const bigSystem = 'x'.repeat(400) + const msgs = session.deriveMessages() + const options: GenerateOptions = { model: 'test-model', messages: msgs, system: bigSystem } + + const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + expect(out.messages).toBe(msgs) + expect(svc.summarizeCalls.length).toBe(0) + }) + + it('skips messages whose extracted text is empty across all kinds', async () => { + const svc = createTestService() + const s = new Session(SessionId('empties')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + // Empty-text text/reasoning blocks contribute nothing → message skipped. + s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) + // tool/result with empty content → empty extraction → skipped. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('z1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('z1'), content: [], isError: false }, { 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 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + // Every message extracted to empty text — the conversation is empty. + expect(svc.summarizeCalls[0]!.text).toBe('') + }) + + it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { + const svc = createTestService() + const s = new Session(SessionId('placeholders')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + // user/message with only an image block → '[image]' placeholder. + s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + // assistant/message with only an image block → '[image]' placeholder. + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'image', url: 'https://x/z.png' }] }, { surfaceOp: 'append' }) + // tool/result with an image block → '[image]' placeholder. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' }) + // context/message and steering/message with image content. + s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = s.surface.nodes + await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + const { text } = svc.summarizeCalls[0]! + // Every non-text block surfaces as a placeholder rather than being dropped. + expect(text).toContain('User: [image]') + expect(text).toContain('Assistant: [image]') + expect(text).toContain('Tool result (call e1): [image]') + expect(text).toContain('[Context: [image]]') + expect(text).toContain('[Steering: [image]]') + }) + +}) + +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. + const svc = createTestService({ auto: false }) + const session = multiTurnSession(4, 1) + + // First compaction: shadow the two oldest surface nodes. + const nodes0 = session.surface.nodes + const first = await svc.compactRegion(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 svc.compactRegion(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. + 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) + }) + + it('extracts the second-compaction transcript in surface order, not log-seq order', async () => { + 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. + const n0 = session.surface.nodes + await svc.compactRegion(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 svc.compactRegion(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(). + const { text } = svc.summarizeCalls[0]! + const checkpointIdx = text.indexOf('compacted-summary') + const olderIdx = text.indexOf('turn 2 user') + expect(checkpointIdx).toBeGreaterThanOrEqual(0) + expect(olderIdx).toBeGreaterThan(checkpointIdx) + }) +}) + +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. + expect(BasicCompactService.inject).toContain('llm') + }) + + it('resolves ctx.llm and summarizes when mounted as a sibling plugin of LlmService', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) + // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so + // the sibling-fiber ctx.llm resolution actually exercises the inject. + const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + + const svc = ctx.compact as BasicCompactService + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + + // HMR: disposing the fiber tears the service registration down. + await fiber.dispose() + expect(ctx.get('compact')).toBeUndefined() + }) +}) + +describe('BasicCompactService under the real invariants plugin', () => { + /** + * Drive compaction through a session whose `session/event` listeners include + * the real dev-mode invariants plugin (as a real app loads it via agent-core). + * The invariants throw on append, so a passing run proves the compaction + * sequence is contract-valid: every event is turn-enclosed, and the positional + * replace op is accepted even when the surface is no longer seq-ordered. + */ + async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants, {}) + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) + await ctx.plugin(BasicCompactService, { auto: false }) + const session = ctx.sessions.create() + return { ctx, session, svc: ctx.compact as BasicCompactService } + } + + /** Append one closed turn of [user, assistant] surface nodes via the store. */ + function closedTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant` }] }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + it('runs a turn-enclosed compaction whose positional replace the invariants accept', async () => { + const { session, svc } = await setup() + closedTurn(session, 1) + closedTurn(session, 2) + // Open turn 3, as the loop has when the auto-compaction listener fires. + session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const nodes = session.surface.nodes + // No invariant throws here: compact/* + the replacement are all in turn 3. + const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + expect(result.shadowedSeqs.length).toBe(2) + expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) + }) + + it('accepts a second compaction over the non-monotonic surface left by the first', async () => { + const { session, svc } = await setup() + closedTurn(session, 1) + closedTurn(session, 2) + closedTurn(session, 3) + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + + const n0 = session.surface.nodes + await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'test-model') + + // Surface head now carries a higher seq than the older retained nodes. A + // second compaction spanning [head … a later closed-step end] must pass the + // invariants' positional replace check even though startSeq > endSeq. + const n1 = session.surface.nodes + expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq) + const second = await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'test-model') + expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq]) + }) +}) + diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json new file mode 100644 index 0000000000..075c64cb61 --- /dev/null +++ b/packages/compact/compact-basic/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../../core/agent" }, + { "path": "../compact" } + ] +} diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 9ef5b73005..43737a4231 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -19,7 +19,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| | `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. | -| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. | +| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 9e58e5c905..9ff7898468 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -89,6 +89,16 @@ export abstract class CompactService extends Service { * summarizes their content and appends a replacement surface node. Used by the * (future) `/compact` tool and internally by {@link compactIfNeeded}. * + * The region MUST contain whole steps — `start` and `end` must each sit on a + * step boundary (the first / last surface node of a step) or on a node that + * belongs to no step (a pre-step user message, inter-step steering, or an + * injection context message). A boundary that falls INSIDE a step would split + * that 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. An `end` inside an open (unclosed) + * tail step is likewise invalid — its tool-calls have no results yet. + * `dsh-session` exports `isStepAlignedStart` / `isStepAlignedEnd` for this check. + * * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. * @param end - inclusive seq of the last surface node to compact. @@ -97,8 +107,12 @@ export abstract class CompactService extends Service { * `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, or if `start`/`end` are not - * valid surface nodes, or if `start > end`. + * @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 step-aligned (would split a step's tool-call/result + * pair). */ abstract compactRegion( session: Session, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 36dd5fb629..df001ff41a 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -48,9 +48,16 @@ export interface CompactionResult { endSeq: number /** The summary content blocks produced by the backend. */ summary: ContentBlock[] - /** The seq range that was shadowed [start, end] inclusive. */ + /** + * The surface-boundary pair that was shadowed: the seqs of the first + * (`start`) and last (`end`) surface nodes of the replaced range. A + * surface-POSITION span, not a numeric seq interval — after a prior replace + * lands a fresh high-seq summary node at an older range's position, `start` + * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the + * authoritative set of shadowed nodes, in surface order. + */ shadowedRange: { start: number; end: number } - /** The seq numbers of all shadowed surface nodes. */ + /** The seqs of all shadowed surface nodes, in surface order. */ shadowedSeqs: number[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2002c93051..0fb44f3299 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -19,6 +19,7 @@ export { isJsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/step-boundary.ts b/packages/core/session/src/step-boundary.ts new file mode 100644 index 0000000000..e8ed91d74c --- /dev/null +++ b/packages/core/session/src/step-boundary.ts @@ -0,0 +1,97 @@ +/** + * Step-boundary predicates over a session log: is a given surface node a SAFE + * place to start or end a region that will be collapsed (e.g. by compaction)? + * + * The invariant a consumer needs: a collapsed region must NOT partially overlap + * a step. A step's surface nodes form a contiguous run, and a region must + * contain either ALL of a step's nodes or NONE of them — otherwise it can split + * an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving + * the rehydrated transcript with a dangling tool-call or an orphaned tool-result + * (which every provider rejects). This is the compaction-time mirror of the + * crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load. + * + * Nodes that belong to NO step — a pre-step `user/message` (drained before the + * first `step/start`), inter-step `steering/message`, or an injection + * `context/message` (wrapped in a bare `turn/start → context/message → turn/end` + * with no step) — carry no tool pairing and are free boundaries on both sides. + * + * The scans classify each neighbor event into three buckets: a turn/step + * BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge + * is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*` + * records, and any future non-surface event). "Surface node" is decided by the + * shared {@link isSurfaceEvent} guard so the two notions can't drift. + * + * @module @deepseek-ai/dsh-session/step-boundary + */ + +import type { SessionEvent } from './types.ts' +import { isSurfaceEvent } from './surface.ts' + +/** Turn/step boundary marker types — the walls the scans stop on. */ +const BOUNDARY_TYPES = new Set(['turn/start', 'turn/end', 'step/start', 'step/end']) + +/** + * Whether the surface node at `seq` is a SAFE START for a collapsed region — + * i.e. it is the first surface node of its step, or it belongs to no step at + * all (a free inter-step / pre-step / injection node). + * + * Scans BACKWARD from `seq`, skipping noise, and stops at the first significant + * event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies + * before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in + * the same step, so starting here would orphan it), start-of-log ⇒ aligned. + * + * No open-step check is needed on the start side: an open (unclosed) step can + * only ever be the LAST turn's last step, never before a valid region start. + */ +export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[i]! + if (BOUNDARY_TYPES.has(event.type)) return true + if (isSurfaceEvent(event)) return false + } + return true +} + +/** + * Whether the surface node at `seq` is a SAFE END for a collapsed region — + * i.e. it is the last surface node of a CLOSED step, or it belongs to no step + * at all. + * + * Scans FORWARD from `seq`, skipping noise, and stops at the first significant + * event: a turn/step boundary marker ⇒ aligned (the step/turn closes after + * `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒ + * NOT aligned (a later surface node sits in the same step). Reaching + * end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open + * trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it + * would defer the orphan to when those results land later. {@link isInOpenStep} + * decides that via a backward scan. + */ +export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq + 1; i < events.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[i]! + if (BOUNDARY_TYPES.has(event.type)) return true + if (isSurfaceEvent(event)) return false + } + // End of log: aligned only if `seq` is not inside a still-open step. + return !isInOpenStep(events, seq) +} + +/** + * Whether `seq` sits inside an OPEN step — a `step/start` with no later + * `step/end`. Only meaningful at the tail (the EOL branch of + * {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary. + * The nearest one being `step/start` means a step opened before `seq` and never + * closed (no `step/end` lies after `seq`, or the forward scan would not have + * reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none) + * means `seq` is inter-step / pre-step. + */ +function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean { + for (let i = seq - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const type = events[i]!.type + if (BOUNDARY_TYPES.has(type)) return type === 'step/start' + } + return false +} diff --git a/packages/core/session/tests/step-boundary.spec.ts b/packages/core/session/tests/step-boundary.spec.ts new file mode 100644 index 0000000000..a24a6f7596 --- /dev/null +++ b/packages/core/session/tests/step-boundary.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' + +/** + * Unit coverage for the step-alignment predicates. They decide whether a + * surface node is a safe START / END for a collapsed region (compaction): a + * region must contain whole steps, never split an `assistant/message`'s + * tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step + * user message, inter-step steering, injection context) are free boundaries. + * + * Builders mirror the agent loop's real append order so the fixtures are + * representative: queued user messages land BEFORE `step/start`; within a step + * the order is `assistant/message` then `tool/result`(s); injection turns are a + * bare `turn/start → context/message → turn/end` with no step. + */ + +const SURFACE = { surfaceOp: 'append' as const } + +/** A closed turn with one closed step holding an assistant + its tool result. */ +function toolStepLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + { type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } }, + { type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE }, + { type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +describe('isStepAlignedStart', () => { + it('is true for a pre-step user/message (belongs to no step)', () => { + // seq 1 user/message sits before step/start at seq 2 → free boundary. + expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true) + }) + + it('is true for the first surface node of a step (the assistant/message)', () => { + // Backward from seq 3 the first significant event is step/start → aligned. + expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true) + }) + + it('is false for a tool/result whose assistant/message precedes it in the same step', () => { + // Backward from seq 5 the first significant event is the assistant/message + // surface node (seq 3) → starting here would orphan that assistant's call. + expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false) + }) + + it('is true at start-of-log (nothing precedes)', () => { + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedStart(log, 0)).toBe(true) + }) + + it('skips noise (assistant/chunk, compact/* records) when scanning back', () => { + // A compacted region landed compact/* log-only records between the prior + // step boundary and this surface node; they must be skipped, not treated as + // walls. Backward from seq 4 skips compact/end, compact/summary, compact/start + // and stops at step/start (seq 0) → aligned. + const log: SessionEvent[] = [ + { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, + { type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent, + { type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent, + { type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent, + { type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, + ] + expect(isStepAlignedStart(log, 4)).toBe(true) + }) +}) + +describe('isStepAlignedEnd', () => { + it('is true for the last surface node of a closed step (the tool/result)', () => { + // Forward from seq 5 the first significant event is step/end → aligned. + expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true) + }) + + it('is false for an assistant/message with a later tool/result in the same step', () => { + // Forward from seq 3 the first significant event is the tool/result surface + // node (seq 5) → ending here would strand that result. + expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false) + }) + + it('is true for a pre-step user/message (next significant event is step/start)', () => { + expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true) + }) + + it('is false at EOL when the node is inside an open (unclosed) step', () => { + // step/start then an assistant tool-call, but no step/end / tool/result yet + // (mid-flight). Ending the region on seq 3 would summarize away a tool-call + // whose result lands later → orphan. EOL + open step ⇒ not aligned. + const log: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 2)).toBe(false) + }) + + it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => { + // The open-step back-scan must skip non-boundary events (here an + // assistant/chunk) before it reaches step/start. Without the skip it would + // mis-read the chunk as the nearest "boundary" and never confirm the open step. + const log: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, + { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ] }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 3)).toBe(false) + }) + + it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => { + // A steering message appended after step/end, at the tail. Backward the + // nearest boundary is step/end → not in an open step → aligned. + const log: SessionEvent[] = [ + { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, + { type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } }, + { type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 3)).toBe(true) + }) + + it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => { + // A lone surface node, no turn/step markers at all → not in an open step. + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, + ] + expect(isStepAlignedEnd(log, 0)).toBe(true) + }) + + it('skips noise (assistant/chunk) when scanning forward', () => { + // assistant/chunk events precede the assistant/message in a real step; the + // forward scan from an inter-step node must skip them and stop on step/start. + const log: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, + ] + // Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot). + expect(isStepAlignedEnd(log, 0)).toBe(true) + }) +}) + +describe('step-alignment on an injection turn (no step)', () => { + // An idle inject() wraps a context/message in a bare turn/start → context/message + // → turn/end with NO step/start. The context node is a free boundary both ways. + const injectionLog = (): SessionEvent[] => [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, + { type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE }, + { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + + it('start: aligned (backward hits turn/start)', () => { + expect(isStepAlignedStart(injectionLog(), 1)).toBe(true) + }) + + it('end: aligned (forward hits turn/end)', () => { + expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true) + }) +}) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index b2372e28db..08fbf49b57 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -162,9 +162,6 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { trace.surface.push(event.seq) } else { const { start, end } = se.surfaceOp - if (start > end) { - throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`) - } const startIdx = trace.surface.indexOf(start) if (startIdx === -1) { throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 6ae2b0edf1..d7e95c1103 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -530,16 +530,17 @@ describe('surface invariants', () => { }).toThrow(/unknown seq 2/) }) - it('rejects replace op with start > end', async () => { + it('rejects a replace whose start is positioned after its end on the surface', async () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // start > end is invalid (reversed order). + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Reversed range: start seq 3 is at a later surface position than end seq 2. expect(() => { - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] }) - }).toThrow(/must be <= end/) + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) + }).toThrow(/is after end seq 2 .* on the surface/) }) it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { @@ -608,6 +609,23 @@ describe('surface invariants', () => { }).toThrow(/is after end seq 4 .* on the surface/) }) + it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 + session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 + // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the + // head seq (4) is numerically GREATER than the tail seq (3): the surface is + // not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is + // valid positionally and must be accepted even though start seq > end seq. + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 + expect(() => { + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5 + }).not.toThrow() + }) + it('rejects a replace that omits sourceEventSeqs entirely', async () => { const { ctx } = await setup() const session = ctx.sessions.create() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2afc331514..70a1a9973a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,27 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/compact/compact-basic: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.build.json b/tsconfig.build.json index 9d76a33385..19f9d65bb0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,6 +23,7 @@ { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/compact/compact-basic" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.json b/tsconfig.json index 81f52357d5..3da543318d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/compact/compact-basic" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, From cec32faa4ef11f0b74011f8d1b27d92719ca53ae Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 08:59:33 +0800 Subject: [PATCH 02/28] refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call. --- docs/architecture.md | 7 +- docs/cordis-catalog/events-and-services.md | 32 +- docs/core-data-structures/compaction.md | 6 +- docs/rfc/README.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 118 ++++++ .../2026-06-18-compaction-capability-seam.md | 59 --- examples/coding-agent/cordis.yml | 10 + examples/coding-agent/tests/compaction.e2e.ts | 95 +++++ examples/coding-agent/tests/harness.ts | 21 +- examples/coding-agent/tests/resume.e2e.ts | 4 +- packages/compact/README.md | 2 +- packages/compact/compact-basic/README.md | 10 +- packages/compact/compact-basic/src/index.ts | 228 ++++------- packages/compact/compact-basic/src/types.ts | 32 +- .../compact-basic/tests/compact-basic.spec.ts | 363 +++++++++--------- packages/compact/compact/README.md | 6 +- packages/compact/compact/src/index.ts | 37 +- packages/compact/compact/src/types.ts | 2 +- packages/core/agent-loop/src/loop.ts | 10 +- packages/core/agent-loop/tests/loop.spec.ts | 59 +++ packages/core/agent/src/types.ts | 26 +- packages/core/session/tests/surface.spec.ts | 19 +- 22 files changed, 724 insertions(+), 424 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md delete mode 100644 docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md create mode 100644 examples/coding-agent/tests/compaction.e2e.ts diff --git a/docs/architecture.md b/docs/architecture.md index bff7b03091..344ea08e26 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,8 +135,9 @@ forever: drain steering (late steering from previous step's listeners) session('step/start'); emit agent/step-start assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) before derive req = {model, system, tools, messages: session.deriveMessages(), signal} - req = waterfall agent/request ⟵ hooks, compaction, model switch + req = waterfall agent/request ⟵ hooks, model switch stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) session('assistant/chunk'); emit agent/stream-chunk if assembler.finish is error/aborted: throw ⟵ adapter's in-band error path → @@ -192,7 +193,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) wrapping `agent/request`: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the awaited `agent/pre-request` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call (every step — runaway-turn survival), manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -220,6 +221,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the `agent/request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the awaited `agent/pre-request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index b8758bef00..7514a5fe41 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -49,7 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) + +#### `agent/pre-request` — parallel + +Awaited surface-mutation checkpoint, fired BEFORE the step's message history is derived (and thus before agent/request). The loop awaits `ctx.parallel('agent/pre-request', …)` after assembling the system prompt but before `session.deriveMessages()`, then derives ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node), and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. + +Awaited (parallel), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before deriving. `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). + +```ts cordis-catalog +'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -65,7 +79,7 @@ Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/t #### `agent/request` — waterfall -Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, compaction, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. +Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-request instead — by the time this fires, `options.messages` is already derived. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise @@ -73,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -97,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -121,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -145,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -373,7 +387,7 @@ Implementations MUST honor: - **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. ```ts cordis-catalog -abstract compactIfNeeded( session: Session, systemPrompt?: string, model?: string, signal?: AbortSignal, ): Promise +abstract compactIfNeeded( session: Session, system: string, model: string, signal: AbortSignal, ): Promise abstract compactRegion( session: Session, start: number, end: number, model: string, signal?: AbortSignal, ): Promise ``` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index ef22d79c94..e637961784 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -50,4 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, systemPrompt?, model?, signal?)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. Both take an optional `signal: AbortSignal` that a backend summarizing via `ctx.llm.stream()` must forward into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. + +Auto-compaction runs on the awaited `agent/pre-request` loop seam (fired once per step, BEFORE the request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place, and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is step-alignment (a compacted region never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a731aa8ff9..07d3418bcb 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -44,7 +44,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | -| [Compaction as a capability seam (abstract contract + basic backend)](proposed/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | ### Simplification @@ -83,6 +82,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md new file mode 100644 index 0000000000..56139cc750 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -0,0 +1,118 @@ +# RFC: Compaction as a capability seam (abstract contract + basic backend) + +Status: implemented (2026-06-18; retention/seam reform 2026-06-26) + +## Context + +A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. + +The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. + +Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. + +## Decision + +### Compaction is a capability seam, split interface / implementation + +Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: + +1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. + +### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation + +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). + +This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. + +### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend + +An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. + +`compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). + +### Auto-compaction runs on `agent/pre-request`, a dedicated surface-mutation seam + +Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. + +The fix is a new awaited loop seam, **`agent/pre-request`** (`@mode parallel`), fired by the loop *after* system assembly and *before* `deriveMessages()`: + +``` +assembly = ctx.systemPrompt.assemble() +await ctx.parallel('agent/pre-request', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +messages = session.deriveMessages() ⟵ single derive, reflects the compaction +request = waterfall agent/request ⟵ pure request transform (hooks, model switch) +``` + +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-request` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. The seam is `parallel` (awaited fan-out, no veto), like `session/flush`: a listener mutates the surface as a side effect; there is nothing to transform or return. + +This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. + +### Retention is turn-agnostic; step-alignment is the only structural guard + +Auto-compaction fires before **every** model call (every step), not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-request`. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. + +So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands inside a step, it extends the retained side head-ward until the boundary is a step-aligned start. The single structural guard is therefore **step-alignment** — the compacted region always ends on a step boundary, so it never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). `compactRegion` enforces step-alignment strictly, throwing on a splitting boundary. + +A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. + +**Single-unit overflow is out of scope, by design.** 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 next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over. + +### Head-anchoring: one auto checkpoint, always at the head + +`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) + +### Single-pass convergence invariant + +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. + +### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary + +Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended: + +``` +compact/start → log-only. Acquires the lock. +[summarize older range via the backend] +compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +user/message → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). + deriveMessages() renders it as a user-role message. +compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). +``` + +`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. Reusing `user/message` is honest rather than a workaround: a summary genuinely *is* user-role context. + +### Checkpoint framing + incremental merge (backend-private) + +The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all. + +### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy + +The `compact/start … compact/end` bracket is justified, in order of what now does the work: + +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 the awaited `pre-request`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) + +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** — the surface replacement never landed, so the full, uncompacted history derives correctly. 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 can't wedge future compaction. Compaction simply re-attempts at the next `pre-request`. +- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. + +`compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. + +**Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. Teaching it would force every future `xxx/start … xxx/end` plugin pair to patch a core module — exactly the coupling the capability-seam architecture exists to avoid. Because the log-only orphan is inert, no special repair is needed: generic turn-repair plus the inertness of an un-landed surface mutation is sufficient. + +## Consequences + +- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. +- **New loop seam**: `agent/pre-request` (`@mode parallel`) declared in `dsh-agent` and emitted by `dsh-agent-loop` between system assembly and history derivation. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. +- **No changes** to `dsh-session` or `dsh-invariants`: the surface replace op, the surface-metadata runtime guard, and the turn-enclosure invariant all already exist and are reused. +- **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). + +## Testing + +- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. +- **Loop** (`dsh-agent-loop`): `agent/pre-request` fires once per step, before derive, awaited; a surface mutation in a `pre-request` listener is reflected in the single derived request. +- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. +- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. diff --git a/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md deleted file mode 100644 index 2d559fa65c..0000000000 --- a/docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: Compaction as a capability seam (abstract contract + basic backend) - -Status: proposed (2026-06-18) - -## Context - -A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. - -The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. - -Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. - -## Decision - -### Compaction is a capability seam, split interface / implementation - -Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: - -1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.generate()`, the surface replacement, the lock, and the `agent/request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). -3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. - -### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation - -The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). - -This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. We record the deviation here so a future reader doesn't mistake it for an accident or "fix" it by smuggling `Session` behind an opaque handle. - -### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend - -An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy (e.g. turn-count instead of token-budget) or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. - -### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary - -Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the summary `ContentBlock[]` and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance), never on the surface. The surface mutation sits **inside** the lock — `compact/end` is the last event appended: - -``` -compact/start → log-only. Acquires the lock. -[summarize older range via the backend] -compact/summary → log-only. Provenance: summary, range, shadowed seqs, token count. -user/message → surfaceOp { op:'replace', start, end }. THE surface mutation. - deriveMessages() renders it as a user-role message. -compact/end → log-only. Releases the lock. -``` - -Ordering the surface mutation **before** `compact/end` is deliberate: `session.append()` commits one event at a time, so there is no multi-event transaction to make the sequence atomic. Releasing the lock last converts the crash window from *silent corruption* (a `compact/end` that claims compaction finished while the surface was never shadowed) into a *detectable orphaned lock* (a `compact/start` with no matching `compact/end`), which a persistence backend already detects on reload. A `session/event` listener on `compact/end` likewise never sees the lock free before the replacement has landed. - -`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. An alternative — extending `SurfaceEventType` to admit a `compact/*` type — was rejected: the closed union is a deliberate safety boundary (only message-producing events reach the model), and a summary genuinely *is* user-role context, so reusing `user/message` is honest rather than a workaround. - -### Blocking via a log-recorded lock, not a mutex - -Compaction must be serialized: no second compaction starts before the first finishes, and no ordinary events interleave the slow summarization. Rather than an in-memory mutex (invisible to replay, lost on crash), the lock **is** the log: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. `compact/start` is appended first (fast, synchronous), the slow model call runs, then the `compact/summary` and `user/message` replacement land, and only then is `compact/end` appended — in a `catch` that records the error, so a failed summarization can never wedge the lock. Because the backend runs compaction synchronously inside the `agent/request` waterfall, the loop is single-threaded for that window; the lock additionally gives observability and lets a persistence backend detect an orphaned `compact/start` on reload. - -## Consequences - -- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the three root tsconfigs. The consumer tier is deferred. -- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **No changes** to `dsh-session`, `dsh-invariants`, or `dsh-agent-loop`: the surface replace op, the surface-metadata runtime guard, and the `agent/request` waterfall all already exist. Compaction is a pure plugin on documented seams. -- The capability-seams convention gains a second reference beyond bash, and a documented case where "interface depends only on cordis" relaxes to "depends only on interface/vocabulary packages the contract genuinely names." On acceptance, [AGENTS.md](../../../../AGENTS.md) § Conventions and [architecture.md](../../../architecture.md) § "Capability seams" should note this relaxation. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 0347115cd5..e3a3016dda 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -65,6 +65,16 @@ failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. +# Automatic context compaction: when the derived history approaches the model's +# context window, summarize an older range into a checkpoint so a long-running +# or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the +# agent-loop's `agent/pre-request` seam from the app above). +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + retainTokens: 20480 + # The subagent seam + BOTH in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh # child) and fork (a child seeded with the parent's completed-turn prefix) are diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts new file mode 100644 index 0000000000..fefcd579d0 --- /dev/null +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -0,0 +1,95 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' + +/** + * The compaction smoke test: a real model runs a multi-step bash task with a + * deliberately tiny context window, so the auto-compaction listener fires + * MID-SESSION and summarizes the older history into a checkpoint. This is the + * first end-to-end exercise of the compaction seam (it is wired nowhere else), + * and the runaway-survival regression net — it proves a session that grows past + * the window keeps running rather than overflowing. Key-gated. + * + * Verifies the WORLD, not the agent's self-report: a compact/start…end pair + * landed in the real session log, the surface actually shrank (a replace node + * exists and shadowed older nodes), and the agent still produced a final answer + * after compaction (so the summarized history did not break the conversation). + */ + +let workdir: string | undefined +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => { + it('summarizes older history into a checkpoint without breaking the task', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-')) + // A few files for the model to read, so multiple bash steps accumulate + // surface nodes (tool calls + results) and grow the history. + for (let i = 1; i <= 4; i++) { + await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) + } + + // Tiny window so a handful of steps crosses the threshold. The convergence + // invariant requires summarizationMaxTokens + retainTokens <= window * + // ratio = floor(8000 * 0.5) = 4000; 1500 + 2000 = 3500 <= 4000. + ctx = await codingHarness(workdir, { + compact: { + contextWindow: 8000, + thresholdRatio: 0.5, + retainTokens: 2000, + summarizationMaxTokens: 1500, + }, + }) + const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { + model: 'deepseek-v4-flash', + systemPrompt: SYSTEM_PROMPT, + }) + + agent.send([{ + type: 'text', + text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a time using cat ' + + '(a separate bash command for each). After reading all four, tell me how many ' + + 'files you read and the number mentioned in file1.txt.', + }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + + // A compaction ran: the start…end bracket landed in the real log. + const starts = events.filter(e => e.type === 'compact/start') + const ends = events.filter(e => e.type === 'compact/end') + expect(starts.length).toBeGreaterThan(0) + expect(ends.length).toBe(starts.length) // every start was released + + // It succeeded at least once: a compact/summary provenance event and a + // replace-op user/message (the surface mutation) both landed. + const summaries = events.filter(e => e.type === 'compact/summary') + expect(summaries.length).toBeGreaterThan(0) + const replaceNode = events.find((e) => { + const se = e as unknown as { type: string; surfaceOp?: unknown } + return se.type === 'user/message' && typeof se.surfaceOp === 'object' && se.surfaceOp !== null + }) + expect(replaceNode).toBeDefined() + + // The summary shadowed real older nodes (the surface shrank vs. the raw + // message-producing event count). + const summaryData = summaries[0]!.data as { shadowedSeqs: number[] } + expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0) + + // The conversation survived compaction: the agent produced a final answer + // that reflects the work (it read four files). + const answer = finalText(events).toLowerCase() + expect(answer.length).toBeGreaterThan(0) + expect(answer).toMatch(/\b(4|four)\b/) + }, 240_000) +}) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index fbe9b10db5..207652f69b 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -10,6 +10,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' +import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** * Shared harness for the coding-agent e2e suites: the full plugin stack @@ -21,7 +23,19 @@ export const SYSTEM_PROMPT = 'You are a coding agent. Your only tool is bash; ' + 'do file operations with cat/grep/heredocs, check [exit code: N] markers, ' + 'and report results briefly.' -export async function codingHarness(workdir: string, persistenceRoot?: string): Promise { +/** Options for {@link codingHarness}. */ +export interface CodingHarnessOptions { + /** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */ + persistenceRoot?: string + /** + * Load {@link BasicCompactService} with this config so the compaction e2e can + * trigger compaction at a small, controlled history size. Omitted ⇒ no + * compaction plugin (the default suites run without it). + */ + compact?: BasicCompactConfig +} + +export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -32,10 +46,13 @@ export async function codingHarness(workdir: string, persistenceRoot?: string): await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) + // Compaction is opt-in: only the compaction e2e loads it, with a lowered + // contextWindow/retainTokens so a short real session crosses the threshold. + if (options.compact !== undefined) await ctx.plugin(BasicCompactService, options.compact) // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the // other suites stay file-free. Loaded last so a resume's deferred // `ctx.inject(['sessionPersistence'])` resolves once this is present. - if (persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot }) + if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) return ctx } diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 450938fc6d..4be11ed3ea 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 1: a fresh agent on a KNOWN session id learns a secret, then we // dispose the whole context (simulating process exit) so only the JSONL // log on disk survives. - ctx = await codingHarness(process.cwd(), root) + ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) const first = ctx.agents.create({ agentId: AgentId('resume-1'), sessionId: SESSION_ID, @@ -52,7 +52,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 2: a brand-new context over the SAME root resumes the persisted // session. The loaded event log seeds the live session, so the model sees // run 1's exchange as conversation history. - ctx = await codingHarness(process.cwd(), root) + ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) const resumed = (await ctx.agents.resume({ agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, diff --git a/packages/compact/README.md b/packages/compact/README.md index 384fe98ffe..10eaf1617a 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -8,4 +8,4 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement | `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. 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). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 8aa64a1111..848c0cd6ae 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -2,18 +2,20 @@ The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization. -This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design. +This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. ## What it owns The abstract contract states only WHAT compaction does; this backend owns every HOW decision: - **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). -- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner. +- **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 **step-alignment**: the compacted region always ends on a step boundary, 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 step-alignment strictly, throwing on a boundary that would split a step. +- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. - **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`). +- **Auto-compaction** — an `agent/pre-request` listener delegates to `compactIfNeeded()` before every model call (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-request` is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface 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()`). +- **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/*`. `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. @@ -26,7 +28,7 @@ The abstract contract states only WHAT compaction does; this backend owns every | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | | `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | -| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. | +| `auto` | `true` | Register the `agent/pre-request` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 28a0040848..2ee0d82133 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -32,7 +32,7 @@ import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BasicCompactConfig, ResolvedConfig } from './types.ts' @@ -170,40 +170,40 @@ export class BasicCompactService extends CompactService { if (this.config.auto) { // Auto-compaction: delegate to compactIfNeeded before EVERY model call — - // every step, not just the first. 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; gating to step 1 would let a - // runaway turn overflow the window before the next turn's check. The - // listener stays agnostic — it owns NO threshold logic; compactIfNeeded is - // the single place that decides whether to compact, and its in-progress - // lock serializes concurrent attempts. - ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => { - const before = this.estimateTokens(request.messages, request.system) + // every step, not just the first. 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-request; 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-request` (a parallel surface-mutation checkpoint), + // 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. + ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { try { - const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal) + const result = await this.compactIfNeeded(agent.session, system, model, signal) if (result) { - // The surface has been mutated — re-derive messages for the call. - const rederived = agent.session.deriveMessages() - const afterTokens = this.estimateTokens(rederived, request.system) - + const after = this.estimateTokens(agent.session.deriveMessages(), system) ctx.logger.info( `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + `~${result.shadowedTokenCount} tokens) ` + - `→ ${afterTokens} estimated tokens after compaction ` + - `(pressure was ~${before})`, + `→ ${after} estimated tokens after compaction`, ) - - request.messages = rederived } } catch (error: unknown) { - // A failed compaction must not prevent the model call — proceed - // with the original messages. + // A failed compaction must not prevent the model call — the surface is + // untouched on failure, so the loop derives the full history and the + // call proceeds. const msg = error instanceof Error ? error.message : String(error) ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`) } - - return next() }) } } @@ -312,89 +312,89 @@ export class BasicCompactService extends CompactService { // ---- Core API (implements the abstract contract) ---- /** - * The sole token-pressure gate: estimate the current history, 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 sole token-pressure gate: estimate the current surface-derived history, + * 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. + * + * 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 step-aligned boundary: if the walk stopped INSIDE a step, + * it continues head-ward past that step's `step/start` 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 is already a + * clean boundary. This always rounds toward retaining MORE (retained ≥ + * `retainTokens`) and is step-aligned 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 step-aligned cutoff exists in the + * compactable range (its only content is an open tail step — retry once it + * closes). */ override async compactIfNeeded( session: Session, - systemPrompt?: string, - model?: string, - signal?: AbortSignal, + system: string, + model: string, + signal: AbortSignal, ): Promise { const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, systemPrompt) + const totalTokens = this.estimateTokens(messages, system) const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) if (totalTokens < threshold) return null - // Walk surface nodes tail→head, accumulating token estimates. const nodes = session.surface.nodes if (nodes.length === 0) return null + const events = session.events const retainBudget = this.config.retainTokens - // ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating - // user request and any mid-turn tool results are the exact input/observation - // the model is acting on right now, even if they exceed the soft retain - // budget. Compacting them would hand the model a lossy summary of its own - // current task. Only nodes in PRIOR (closed) turns are eligible to compact; - // `protectedIdx` is the first surface node of the open turn (or `nodes.length` - // when the open turn has no surface nodes yet, e.g. before step 1). - const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes) - if (protectedIdx === 0) return null + // Walk tail→head summing per-node token estimates. `keepFromIdx` is the + // index of the OLDEST node we retain verbatim; everything strictly older + // (`[0, keepFromIdx - 1]`) is the compactable range. let accumulated = 0 - let cutoffIdx = -1 - // Seed the accumulator with the protected suffix so the retain budget is - // measured against what actually stays, then look for a cutoff only among - // the older (compactable) nodes. - for (let i = nodes.length - 1; i >= protectedIdx; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = session.events[nodes[i]!.seq] - if (event) accumulated += this.estimateEventTokens(event) - } - - for (let i = protectedIdx - 1; i >= 0; i--) { - // nodes[i] bounded by i >= 0 and i < nodes.length — never undefined. + let keepFromIdx = nodes.length // nothing retained yet + for (let i = nodes.length - 1; i >= 0; i--) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const node = nodes[i]! - const event = session.events[node.seq] + const event = events[node.seq] /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ - if (!event) continue - accumulated += this.estimateEventTokens(event) - if (accumulated > retainBudget) { - cutoffIdx = i - break - } + if (event) accumulated += this.estimateEventTokens(event) + keepFromIdx = i + if (accumulated >= retainBudget) break } - // If we walked the entire compactable range without exceeding the budget, - // everything outside the protected in-flight turn fits — no compaction - // needed. - if (cutoffIdx === -1) return null + // The whole surface fits the retain budget — nothing to compact. + if (keepFromIdx === 0) return null - // Snap the cutoff to a step-aligned end so the compacted region never splits - // a step (which would orphan a tool-call or its tool/result). The token - // budget is a soft target. PREFER snapping FORWARD (compact slightly more - // recent context to reach a clean boundary), but never into the protected - // in-flight turn: if the forward snap would reach `protectedIdx`, fall back - // to snapping BACKWARD to the previous step-aligned end (compact slightly - // less), and decline only if no step-aligned end exists in the compactable - // range at all. - const events = session.events - cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx) - if (cutoffIdx === -1) return null + // Round the cutoff to a step boundary: if `keepFromIdx` sits INSIDE a step, + // extend the retained side head-ward until the boundary is a step-aligned + // start, so the compacted range ends on a clean step edge. A node that + // belongs to no step is already a valid start. Decline if no step-aligned + // start exists at or below `keepFromIdx` (the compactable range is only an + // un-splittable open tail step — retry once it closes). + while (keepFromIdx > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break + keepFromIdx -= 1 + } + if (keepFromIdx === 0) return null - // nodes is non-empty (checked above) and cutoffIdx is a valid index. + // The compacted range is [head … keepFromIdx - 1], anchored at the head. + // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END: + // the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary + // marker sits between them in the log), and that same boundary makes the node + // before it a step-aligned end — so no separate end check is needed. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstSeq = nodes[0]!.seq // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[cutoffIdx]!.seq - const resolvedModel = model ?? '' + const cutoffSeq = nodes[keepFromIdx - 1]!.seq - return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal) + return this.compactRegion(session, firstSeq, cutoffSeq, model, signal) } override async compactRegion( @@ -521,74 +521,6 @@ export class BasicCompactService extends CompactService { // ---- Internal helpers ---- /** - * The index of the first surface node that belongs to the currently-open turn - * — the boundary of the protected, never-compacted suffix. Returns - * `nodes.length` when the open turn has contributed no verbatim surface node - * yet (e.g. before step 1 appends anything), so the whole surface is - * compaction-eligible up to the tail. - * - * The in-flight turn's verbatim nodes (its request, mid-turn assistant - * messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL - * of the surface. A compaction replacement node, though also appended during - * the open turn (seq > `turn/start`), lands at the position of the older range - * it shadowed — earlier in the surface, NOT in the tail run — so it is itself - * compaction-eligible (a later cycle can merge it). The protected suffix is - * therefore the contiguous tail run of nodes whose seq exceeds the open turn's - * `turn/start`, found by walking from the tail. With no open turn (a closed - * session — only manual `compactRegion`, never the auto path), nothing is - * protected and this returns `nodes.length`. - */ - private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number { - const openTurn = this._openTurn(session) - if (openTurn === null) return nodes.length - // Find the open turn's turn/start seq (scanning back from the tail). - let turnStartSeq = -1 - for (let i = session.events.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const e = session.events[i]! - if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break } - } - /* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */ - if (turnStartSeq === -1) return nodes.length - // Walk from the tail while nodes belong to the open turn (seq > turn/start), - // taking only the CONTIGUOUS run — a compaction summary node appended this - // turn but sitting earlier in the surface stops the run and stays eligible. - let idx = nodes.length - while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion - return idx - } - - /** - * Snap a raw token-budget cutoff index to a step-aligned end among the nodes - * BELOW the protected suffix (`protectedIdx`, the first node of the in-flight - * turn). Returns the snapped index, or `-1` if no step-aligned end exists in - * the compactable range (e.g. it is empty, or its only content is an open tail - * step). - * - * Prefers snapping FORWARD to the next step-aligned end (compact slightly more - * recent context for a clean boundary); if the forward scan reaches - * `protectedIdx` without finding one, falls back to scanning BACKWARD from the - * raw cutoff (compact slightly less). The protected suffix is never returned — - * it stays verbatim so the model sees its current task, not a summary. - */ - private _snapCutoff( - events: readonly SessionEvent[], - nodes: readonly SurfaceNode[], - rawCutoffIdx: number, - protectedIdx: number, - ): number { - // Forward: the next step-aligned end strictly below the protected suffix. - for (let i = rawCutoffIdx; i < protectedIdx; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedEnd(events, nodes[i]!.seq)) return i - } - // Backward: the nearest step-aligned end at or below the raw cutoff. - for (let i = rawCutoffIdx - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedEnd(events, nodes[i]!.seq)) return i - } - return -1 - } /** * Frame the raw summary blocks into the content that lands on the surface: diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 120fad0b19..7273150d8e 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -38,7 +38,35 @@ export const DEFAULTS: ResolvedConfig = { auto: true, } -/** Apply defaults to a partial config. */ +/** + * Apply defaults to a partial config and enforce the single-pass convergence + * invariant. + * + * `summarizationMaxTokens + retainTokens` must not exceed the compaction + * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that + * after a compaction the derived history — the (bounded) summary plus the + * retained recent tail — is structurally BELOW the threshold, so the very next + * pre-request check passes and a second compaction cannot fire on the same + * content. Without it, a too-large summary budget or retain budget would leave + * the post-compaction history still over threshold, triggering compaction again + * and again. Pre-release we reject rather than clamp: a config that cannot + * guarantee convergence is a bug at the call site, not something to silently + * paper over. + * + * @throws if `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. + */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - return { ...DEFAULTS, ...config } + const resolved = { ...DEFAULTS, ...config } + const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) + const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens + if (postCompactionFloor > threshold) { + throw new Error( + `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` + + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} exceeds the compaction ` + + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` + + 'stay over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + + 'or raise contextWindow/thresholdRatio.', + ) + } + return resolved } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3852b825f7..255a59a5b7 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,6 +9,9 @@ import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import type { Agent } from '@deepseek-ai/dsh-agent' +/** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ +const SIGNAL = new AbortController().signal + /** * A BasicCompactService with summarize() stubbed (no real model call) and a * predictable token estimate, for deterministic unit tests of the algorithm. @@ -33,31 +36,14 @@ class TestCompactService extends BasicCompactService { } } -/** Create a test service with a throwaway context (auto disabled — no model). */ -function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, ...config }) -} - /** - * A test service where specific surface seqs (in `bigSeqs`) weigh 1000 tokens - * and every other message-producing event weighs 10 — for exercising the - * "newest node alone exceeds retainTokens" retention path. summarize() is - * stubbed (no model call). + * Create a test service with a throwaway context (auto disabled — no model). + * A small `summarizationMaxTokens` baseline keeps the convergence invariant + * (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`) + * satisfied for the tiny windows these tests use; a test may override it. */ -class TestCompactServiceVarTokens extends BasicCompactService { - bigSeqs = new Set() - constructor(config: BasicCompactConfig = {}) { - super(new Context(), { auto: false, ...config }) - } - - override estimateEventTokens(event: SessionEvent): number { - if (this.bigSeqs.has(event.seq)) return 1000 - return super.estimateEventTokens(event) - } - - override async summarize(): Promise { - return [{ type: 'text', text: 'summary' }] - } +function createTestService(config: BasicCompactConfig = {}): TestCompactService { + return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config }) } /** @@ -188,44 +174,48 @@ function expectNoOrphanToolResults(messages: Message[]): void { } describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { - it('compactIfNeeded snaps the cutoff forward past a mid-step boundary (no orphaned tool-result)', async () => { - // 3 turns, each one step = { assistant(tool-call) , tool/result }. Surface + 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. With retainTokens=55 the tail→head walk overflows at - // asst2 (idx4), so the RAW cutoff falls BETWEEN asst2 and its result res2 - // (idx5) — splitting turn 2's step. The fix snaps the cutoff forward to res2 - // so the whole step is compacted and no dangling result survives. + // 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. const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() - // res2 (idx5) was pulled into the compacted region by the snap, not stranded. + expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + // No dangling tool-result: every compacted/retained step stayed whole. expectNoOrphanToolResults(session.deriveMessages()) - // Turn 3's step is retained intact (summary + user3 + asst3 + res3 = 4 msgs). - expect(session.deriveMessages().length).toBe(4) + // The most-recent step's result is retained verbatim (still on the surface). + const lastResultSeq = session.events.findLast(e => e.type === 'tool/result')!.seq + expect(result!.shadowedSeqs).not.toContain(lastResultSeq) }) - it('compactIfNeeded returns null when the only cutoff would enter an open tail step', async () => { - // A pre-step user/message then an OPEN step (assistant issued a tool-call, no - // tool/result / step/end yet — mid-flight). The token walk wants to compact - // into that open step, but its tool-call has no result yet; compacting it - // would defer the orphan. With no safe step-aligned cutoff, compactIfNeeded - // declines (returns null) rather than summarizing a pending tool-call away. - const s = new Session(SessionId('open-step')) + 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. + const s = new Session(SessionId('one-step')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/start', { turn: 1, step: 1 }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) - // no tool/result, no step/end — the step is open at the tail. + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + // Turn stays open. - const svc = createTestService({ contextWindow: 50, thresholdRatio: 0.5, retainTokens: 5 }) - const result = await svc.compactIfNeeded(s) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(result).toBeNull() - // The open step's assistant survived — its tool-call is intact for the result. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -516,107 +506,103 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null when tokens are under threshold', async () => { const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) it('compacts when tokens exceed threshold', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) it('walks tail→head and retains nodes within token budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 15 }) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens - const result = await svc.compactIfNeeded(session) + const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) expect(result).not.toBeNull() const nodes = session.surface.nodes expect(result!.shadowedSeqs.length).toBeGreaterThan(0) expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq) }) - it('returns null when total tokens fit within budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 1000 }) + it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { + // threshold = floor(460*0.1) = 46. The 4 surface nodes weigh 10 each (raw 40 + // for the retention walk), but the derived estimate adds 4 role tokens per + // message → 56 ≥ 46, 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. The invariant holds: + // summarizationMaxTokens (1) + retainTokens (45) = 46 ≤ threshold 46. + const svc = createTestService({ contextWindow: 460, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) - it('retains the in-flight turn verbatim even when its newest node exceeds retainTokens', async () => { - // The current turn's first step has CLOSED (so its last node is step-aligned - // and would otherwise be a valid compaction cutoff), and that node — a fresh - // tool result — is larger than the whole retain budget. It must NOT be - // compacted: it is the observation the model needs for the turn's next step. - // Only the older closed turns are eligible. - const svc = new TestCompactServiceVarTokens({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) - const s = new Session(SessionId('big-tail')) - // Two closed turns (compactable older context). - for (const t of [1, 2]) { - s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('step/start', { turn: t, step: 1 }) - s.append('user/message', { content: [{ type: 'text', text: `turn ${t}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: t, step: 1, content: [{ type: 'text', text: `reply ${t}` }] }, { surfaceOp: 'append' }) - s.append('step/end', { turn: t, step: 1 }) - s.append('turn/end', { turn: t, reason: { kind: 'completed' } }) + 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. + const svc = createTestService({ contextWindow: 300, 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]. + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'do a big multi-step task' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + for (let step = 1; step <= 5; step++) { + s.append('step/start', { turn: 1, step }) + s.append('assistant/message', { + turn: 1, step, + content: [{ type: 'text', text: `step ${step}` }, { type: 'tool-call', id: CallId(`c${step}`), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step, callId: CallId(`c${step}`), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step, callId: CallId(`c${step}`), content: [{ type: 'text', text: `out ${step}` }], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step }) } - // The in-flight turn 3: a user request, then a CLOSED step 1 whose tool - // result is HUGE (1000 tokens). The step is closed (step/end), so the result - // node is step-aligned — without the in-flight-turn protection the retention - // walk would pick it as the cutoff and compact it away. The turn itself is - // still open (no turn/end): the model is mid-turn, about to run step 2. - s.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) - s.append('user/message', { content: [{ type: 'text', text: 'current request' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('step/start', { turn: 3, step: 1 }) - s.append('assistant/message', { turn: 3, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('huge'), name: 'bash', arguments: '{}' }] }, { surfaceOp: 'append' }) - s.append('tool/call', { turn: 3, step: 1, callId: CallId('huge'), name: 'bash', arguments: '{}' }) - const hugeSeq = s.append('tool/result', { - turn: 3, step: 1, callId: CallId('huge'), - content: [{ type: 'text', text: 'HUGE' }], isError: false, - }, { surfaceOp: 'append' }).seq - s.append('step/end', { turn: 3, step: 1 }) - svc.bigSeqs.add(hugeSeq) // make this node weigh 1000 tokens + // The turn stays OPEN (no turn/end) — the model is mid-turn, about to run + // step 6. Surface: user + 5×[asst, result] = 11 nodes. + const nodesBefore = s.surface.nodes.length + expect(nodesBefore).toBe(11) - const result = await svc.compactIfNeeded(s) + const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(result).not.toBeNull() - // The in-flight turn's nodes — the request, the assistant, AND the huge - // result — are retained: none shadowed, all survive on the surface verbatim. - expect(result!.shadowedSeqs).not.toContain(hugeSeq) - const survivingSeqs = new Set(s.surface.nodes.map(n => n.seq)) - expect(survivingSeqs.has(hugeSeq)).toBe(true) - const requestSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'current request'))!.seq - expect(survivingSeqs.has(requestSeq)).toBe(true) - // The older closed turns WERE compacted. + // Early steps of the SAME open turn were shadowed (impossible under layer 2). expect(result!.shadowedSeqs.length).toBeGreaterThan(0) + // The most-recent step's tool result is retained verbatim (still on surface). + const lastResultSeq = s.events.findLast(e => e.type === 'tool/result')!.seq + expect(result!.shadowedSeqs).not.toContain(lastResultSeq) + expect(s.surface.nodes.some(n => n.seq === lastResultSeq)).toBe(true) + // No orphaned tool-result survives (whole-step boundaries respected). + expectNoOrphanToolResults(s.deriveMessages()) }) it('returns null for an empty surface', async () => { - const svc = createTestService({ contextWindow: 10, thresholdRatio: 0.1 }) + const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = new Session(SessionId('empty')) - expect(await svc.compactIfNeeded(session)).toBeNull() + expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) - it('compacts again within the same open turn (the prior summary node is still eligible)', async () => { - // After the first compaction lands a replacement summary node, that node is - // appended DURING the open turn (seq > turn/start) but sits earlier in the - // surface (at the shadowed range's position), NOT in the verbatim tail run. - // It must stay compaction-eligible: a second step in the SAME turn, still - // over threshold, must be able to compact older context — protectedIdx must - // not collapse to 0 and silently disable per-step auto-compaction. - // retainTokens=25 leaves a couple of retained closed-turn nodes after the - // first compaction (so the surface is [summary, …retained], not [summary]). - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 25 }) + 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]). + const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) - const first = await svc.compactIfNeeded(s) + const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(first).not.toBeNull() - // The summary node now heads the surface; the open turn has no verbatim tail - // node yet, so the whole surface (incl. the summary) is eligible — the - // protected suffix is the contiguous tail run of open-turn nodes (none yet). - // The summary node's seq exceeds turn 5's turn/start, yet it sits at the - // head (not the tail), so it must NOT be counted as protected. + // The summary node now heads the surface with a fresh high seq. const summaryHeadSeq = s.surface.nodes[0]!.seq const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq) @@ -629,7 +615,7 @@ describe('BasicCompactService.compactIfNeeded', () => { s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) s.append('step/end', { turn: 5, step: 1 }) - const second = await svc.compactIfNeeded(s) + const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL) expect(second).not.toBeNull() expect(second!.shadowedSeqs.length).toBeGreaterThan(0) // The fresh open-turn nodes were NOT compacted. @@ -751,6 +737,27 @@ describe('BasicCompactService HMR safety', () => { }) }) +describe('BasicCompactService convergence invariant (config)', () => { + it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { + // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 > 500 → reject. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, + })).toThrow(/exceeds the compaction threshold/) + }) + + it('accepts the boundary case (sum equals the threshold)', () => { + // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 ≤ 500 → allowed. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, + })).not.toThrow() + }) + + it('the default config satisfies the invariant', () => { + // 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400. + expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() + }) +}) + /** An adapter that emits a fixed summary text, for exercising the real summarize() path. */ class ScriptedAdapter extends LlmAdapter { lastOptions: GenerateOptions | null = null @@ -876,81 +883,73 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { }) }) -describe('BasicCompactService auto-compaction (agent/request listener)', () => { - /** Fire the agent/request waterfall as the loop does. */ - function fireRequest(ctx: Context, agent: Agent, step: number, options: GenerateOptions): Promise { - return ctx.waterfall('agent/request', agent, 1, step, options, () => Promise.resolve(options)) +describe('BasicCompactService auto-compaction (agent/pre-request listener)', () => { + /** Fire the agent/pre-request parallel checkpoint as the loop does. */ + function firePreRequest(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { + return ctx.parallel('agent/pre-request', agent, 1, step, system, model, SIGNAL) } - it('compacts and rewrites request.messages when over threshold', async () => { - // Tiny window so the (large) session is over threshold; char/4 estimate. + it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - const svc = new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') + const before = session.surface.nodes.length - const messages = session.deriveMessages() - const before = messages.length - const options: GenerateOptions = { model: 'test-model', messages } + await firePreRequest(ctx, agent, 1, '', 'test-model') - const out = await fireRequest(ctx, agent, 1, options) - // The surface shrank — request.messages was re-derived to fewer entries. - expect(out.messages.length).toBeLessThan(before) + // The surface shrank in place, and a summary checkpoint landed. + expect(session.surface.nodes.length).toBeLessThan(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // Re-derived first message is the framed summary checkpoint. - expect(out.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - expect(svc).toBeDefined() + // The re-derived head message is the framed summary checkpoint. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) }) it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 }) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - // A step-2 request (a tool-heavy turn's later step) must still compact — the - // surface accumulated assistant/message + tool/result nodes since step 1. - await fireRequest(ctx, agent, 2, options) + // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — + // the surface accumulated assistant/message + tool/result nodes since step 1. + await firePreRequest(ctx, agent, 2, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) - it('passes through unchanged when under threshold', async () => { + it('does nothing when under threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs } - const out = await fireRequest(ctx, agent, 1, options) - expect(out.messages).toBe(msgs) + await firePreRequest(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) - it('proceeds with original history when compaction fails', async () => { - // No adapter registered for this model → summarize() rejects → caught, proceeds. + it('leaves the surface intact when compaction fails (summarize rejects)', async () => { + // No adapter registered for this model → summarize() rejects → caught, the + // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'missing-model', messages: msgs } + const before = session.surface.nodes.length - const out = await fireRequest(ctx, agent, 1, options) - // Listener swallowed the failure and left messages intact. - expect(out.messages).toBe(msgs) + await firePreRequest(ctx, agent, 1, '', 'missing-model') + // No summary landed; the surface is unchanged. + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + expect(session.surface.nodes.length).toBe(before) }) it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 10, thresholdRatio: 0.1, retainTokens: 1 }) + void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - await fireRequest(ctx, agent, 1, options) + await firePreRequest(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) }) @@ -1049,46 +1048,65 @@ describe('BasicCompactService edge cases', () => { expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) }) - it('compacts and re-derives without re-checking a post-compaction threshold', async () => { + it('compacts once without re-checking a post-compaction threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') // Even with a window so tiny the post-compaction history still exceeds the // threshold, the agnostic listener does NOT re-gate or warn — it compacts // once (the single check lives in compactIfNeeded) and proceeds. const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { contextWindow: 10, thresholdRatio: 0.1, retainTokens: 5 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 }) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() } - await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) - // The surface was re-derived into the request; no cascade warning is emitted. - expect(options.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + // The surface was mutated; the head message is the framed summary checkpoint. + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) + // No cascade warning is emitted. expect(warnings.length).toBe(0) }) it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { const svc = createTestService() - // A session with surface nodes but NO open turn — compaction's compact/* and - // replacement events would be appended outside any turn, which the session-log - // contract forbids. + // A session whose only turn has CLOSED — scanning back from the tail hits + // turn/end before any turn/start, so there is no open turn to enclose + // compaction's compact/* + replacement events, which the log contract forbids. const s = new Session(SessionId('noturn')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/no open turn/) // The lock was never acquired — no compact/start landed. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) + it('rejects compaction on a session with no turn boundaries at all', async () => { + const svc = createTestService() + // No turn events whatsoever — the open-turn scan falls through to the end + // of the log and finds none, so compaction is rejected (its events have no + // turn to enclose them). + const s = new Session(SessionId('turnless')) + s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const nodes = s.surface.nodes + + await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + .rejects.toThrow(/no open turn/) + expect(s.events.some(e => e.type === 'compact/start')).toBe(false) + }) + it('compactIfNeeded returns null for empty surface even when over threshold', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1 }) + const svc = createTestService({ contextWindow: 1000, thresholdRatio: 0.1, retainTokens: 5 }) const session = new Session(SessionId('empty-but-pressured')) // No surface nodes, but a large system prompt pushes the estimate over threshold. - const bigPrompt = 'x'.repeat(400) // ceil(400/4) = 100 tokens >> threshold 10 - expect(await svc.compactIfNeeded(session, bigPrompt)).toBeNull() + const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100 + expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull() }) it('compactRegion throws when end is not a surface node (start valid)', async () => { @@ -1116,15 +1134,16 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 }) + const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 }) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs } + const before = session.surface.nodes.length - const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) - expect(out.messages).toBe(msgs) // proceeded with original history + await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + // The failure was swallowed; the surface is untouched and a warning logged. + expect(session.surface.nodes.length).toBe(before) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true) }) @@ -1132,16 +1151,14 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. - const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 100000 }) + // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. + const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 }) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') - const bigSystem = 'x'.repeat(400) - const msgs = session.deriveMessages() - const options: GenerateOptions = { model: 'test-model', messages: msgs, system: bigSystem } + const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options)) + await ctx.parallel('agent/pre-request', agent, 1, 1, bigSystem, 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) - expect(out.messages).toBe(msgs) expect(svc.summarizeCalls.length).toBe(0) }) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 43737a4231..d75a5da774 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c | `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | -Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). +Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. 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). ## Service API (`ctx.compact`) @@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. | +| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | | `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | -Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. +`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. ## Surface contract diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 9ff7898468..5e63169fa1 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -16,7 +16,7 @@ * depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over * a `Session` and its output is 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/proposed/feature/2026-06-18-compaction-capability-seam.md). + * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). * * @module @deepseek-ai/dsh-compact */ @@ -62,14 +62,31 @@ export abstract class CompactService extends Service { /** * Check token pressure and compact if the conversation is too large. * - * Estimates the current history size (optionally including a system prompt), - * and if it exceeds the backend's threshold, compacts an older range via - * {@link compactRegion}, keeping recent context intact. + * Estimates the current surface-derived history size (including 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: + * - **Surface-derived history only.** The decision is made against the history + * derived from the session surface — the only thing compaction can act on. + * 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 step-aligned 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. * * @param session - the session whose surface may be compacted. - * @param systemPrompt - optional system prompt, counted toward the estimate. - * @param model - optional summarization model (falls back to backend config). - * @param signal - optional cancellation signal. A backend that summarizes via + * @param system - the assembled system prompt, counted toward the estimate. + * @param model - the summarization model (a backend may override via config). + * @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. @@ -77,9 +94,9 @@ export abstract class CompactService extends Service { */ abstract compactIfNeeded( session: Session, - systemPrompt?: string, - model?: string, - signal?: AbortSignal, + system: string, + model: string, + signal: AbortSignal, ): Promise /** diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index df001ff41a..ba886685d5 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -6,7 +6,7 @@ * 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/proposed/feature/2026-06-18-compaction-capability-seam.md)). + * [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, diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ceef1bca8e..4aeef18eec 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -149,8 +149,9 @@ export interface LoopHandle { * drain steering → session('steering/message') ⟵ catches late steering * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + * await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) BEFORE derive * req = {model, system, tools, messages: session.deriveMessages(), signal} - * req = waterfall agent/request ⟵ hooks/compaction/model-switch + * req = waterfall agent/request ⟵ hooks/model-switch * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) * session('assistant/chunk'); emit agent/stream-chunk * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the @@ -565,6 +566,13 @@ async function runStep( .filter(text => text.length > 0) .join('\n\n') + // Surface-mutation checkpoint BEFORE deriving history: compaction shadows an + // older range with a summary node here, and the single derive below reflects + // it. Awaited (no veto) — a listener mutates the surface as a side effect. + // `model` is resolved to '' when unset; a compaction listener that needs a + // model falls back to its own config. + await ctx.parallel('agent/pre-request', agent, turn, step, system, options.model ?? '', signal) + let request: GenerateOptions = { model: options.model ?? '', messages: session.deriveMessages(), diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d018eff7a2..f74e160936 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -320,6 +320,65 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) + it('agent/pre-request fires once per step before the request is derived', async () => { + // Two steps (a tool call, then a final text turn) → two model calls → two + // pre-request fires, each carrying the assembled system + model, BEFORE the + // request messages are derived (the request the adapter sees reflects any + // surface state at fire time). + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', {}, 'calling echo'), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: {}, + async execute() { return [{ type: 'text', text: 'echoed' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const fires: { turn: number; step: number; model: string }[] = [] + ctx.on('agent/pre-request', (subject, turn, step, _system, model) => { + if (subject === agent) fires.push({ turn, step, model }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // One fire per step, in order, each with the agent's model. + expect(fires).toEqual([ + { turn: 1, step: 1, model: 'mock' }, + { turn: 1, step: 2, model: 'mock' }, + ]) + }) + + it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => { + // pre-request fires BEFORE deriveMessages(), so a listener that appends a + // surface node there sees it land in the SAME step's request — proving the + // loop derives once, after the checkpoint, with no stale pre-derive. + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let injected = false + ctx.on('agent/pre-request', (subject, turn) => { + if (subject === agent && !injected) { + injected = true + subject.session.append('context/message', { + content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + void turn + } + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The adapter's request includes the node injected during pre-request. + const text = JSON.stringify(adapter.requests[0]!.messages) + expect(text).toContain('INJECTED-IN-PRE-REQUEST') + }) + it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index efe392155c..407201ea5d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -180,10 +180,32 @@ declare module 'cordis' { 'agent/step-end'(agent: Agent, turn: number, step: number): void // ---- interception seams (waterfall) ---- + /** + * Awaited surface-mutation checkpoint, fired BEFORE the step's message + * history is derived (and thus before {@link agent/request}). The loop + * awaits `ctx.parallel('agent/pre-request', …)` after assembling the system + * prompt but before `session.deriveMessages()`, then derives ONCE from + * whatever the surface now holds. This is where compaction belongs: it + * mutates the session surface in place (shadowing an older range with a + * summary node), and the single subsequent derive reflects the mutation — + * so there is no double-derive and no listener can see (or be expected to + * act on) an assembled `messages` array that does not exist yet. + * + * Awaited (parallel), not a waterfall: a listener mutates the surface as a + * side effect; there is nothing to transform or veto, but the loop must wait + * for the mutation to complete before deriving. `system`/`model` are the + * assembled values a listener needs to measure pressure (system counts + * toward the budget) and to summarize (the model). `signal` cancels any + * in-flight work a listener starts (e.g. a summarization model call). + * @mode parallel + */ + 'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the - * model call (hooks, compaction, model switching, tool filtering, …). Call - * `next()` to delegate, or return without it to short-circuit. + * model call (hooks, model switching, tool filtering, …). Call `next()` to + * delegate, or return without it to short-circuit. For surface mutation that + * must precede history derivation (compaction), use {@link agent/pre-request} + * instead — by the time this fires, `options.messages` is already derived. * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index a7f4c13dad..cd30142773 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, isSurfaceEvent } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -278,4 +278,21 @@ describe('Session.append surface opts', () => { // The string 'append' is a primitive — identity-preserving is fine. expect(event.surfaceOp).toBe('append') }) + + it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => { + // A raw event (not built via append, which mandates the marker) of a + // surface-eligible type but with no surfaceOp must NOT narrow to a + // SurfaceEvent — it would otherwise be silently dropped from the surface. + const noMarker: SessionEvent = { + type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, + } + expect(isSurfaceEvent(noMarker)).toBe(false) + // A non-surface type is rejected too (the type gate). + const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } + expect(isSurfaceEvent(boundary)).toBe(false) + // A properly-marked surface event narrows. + const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent + expect(isSurfaceEvent(marked)).toBe(true) + }) }) From f962fda8c1cda2186acc82dc917391e8fe74ac32 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Thu, 25 Jun 2026 22:42:48 -0700 Subject: [PATCH 03/28] docs: add Chinese terminology table --- docs/i18n/terminology.md | 110 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/i18n/terminology.md diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md new file mode 100644 index 0000000000..a0b145117a --- /dev/null +++ b/docs/i18n/terminology.md @@ -0,0 +1,110 @@ +# Terminology + +| English | 中文 | 备注 | +|---|---|---| +| ACP | ACP | | +| AI | AI | 首次出现可写:人工智能(AI) | +| API | API | | +| CLI | CLI | | +| Cordis | Cordis | | +| Function Calling | Function Calling | | +| HMR | HMR | | +| JSON Schema | JSON Schema | | +| JSONL | JSONL | | +| lint | lint | | +| loader | loader | | +| LLM | LLM | 首次出现可写:大语言模型(LLM) | +| MCP | MCP | | +| RAG | RAG | 首次出现可写:检索增强生成(RAG) | +| SDK | SDK | | +| SSE | SSE | | +| agent | agent | 首次出现可写:agent(智能体);不要译作:代理 | +| agent loop | agent loop | | +| fiber | fiber | | +| fixture | fixture | 首次出现可写:fixture(测试样例) | +| fork | fork | 首次出现可写:fork(派生) | +| harness | harness | 不要译作:测试框架、脚手架 | +| manifest | 清单 | 指文件名或字段名时保留 `manifest` | +| schema DSL | schema DSL | | +| schema | schema | API/类型名保留 `schema`;一般 prose 可译为“模式” | +| seam | seam | 首次出现可写:seam(扩展点);不要译作:接缝 | +| skill | skill | 首次出现可写:skill(技能) | +| spawn | spawn | 首次出现可写:spawn(新建) | +| steering | steering | 首次出现可写:steering(中途引导) | +| subagent | subagent | 首次出现可写:subagent(子 agent);不要译作:子代理 | +| transcript | 交互记录 | | +| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件);不要译作:瀑布流 | +| wire format | 协议格式 | | +| adapter contract | 适配器契约 | | +| adapter | 适配器 | | +| append-only | 仅追加 | | +| artifact | 产物 | | +| block | 块 | | +| background task | 后台任务 | | +| backend | 后端 | | +| capability | 能力 | | +| cancel | 取消 | | +| checkpoint | 检查点 | | +| chunk | 分片 | | +| compaction | 压缩 | | +| consumer | 消费方 | | +| content block | 内容块 | | +| config | 配置 | | +| context | 上下文 | | +| context compaction | 上下文压缩 | | +| coverage | 覆盖率 | | +| crash recovery | 崩溃恢复 | | +| dispose | 释放 | | +| durability | 持久性 | | +| event log | 事件日志 | | +| event | 事件 | | +| event stream | 事件流 | | +| executor | 执行器 | | +| extension | 扩展 | | +| finish reason | 结束原因 | | +| foreground run | 前台运行 | | +| hook | 钩子 | | +| implementation | 实现 | | +| inference | 推理 | | +| injection | 注入 | | +| interface | 接口 | | +| integration | 集成 | | +| memory | 记忆 | 指 agent memory;不要译作:内存 | +| message | 消息 | | +| model provider | 模型提供方 | | +| module | 模块 | | +| permission | 权限 | | +| persistence | 持久化 | | +| pipeline | 流水线 | | +| plugin | 模组 | 不要译作:插件 | +| prompt | 提示词 | | +| provider | 提供方 | | +| provider-neutral | 提供方无关 | | +| quality gate | 质量门禁 | | +| registry | 注册表 | | +| reasoning | 推理 | `reasoning_content` 译为“思考内容” | +| replay | 回放 | | +| resume | 恢复 | | +| runtime | 运行时 | | +| sandbox | 沙箱 | | +| service | 服务 | | +| session | 会话 | | +| session event | 会话事件 | | +| snapshot | 快照 | | +| spine | 主干 | | +| step | 步骤 | | +| stream | 流 | | +| streaming | 流式输出 | | +| system prompt | 系统提示词 | | +| taxonomy | 分类体系 | | +| token usage | token 用量 | | +| thinking | thinking | API 字段保留;模型模式译为“思考” | +| tool | 工具 | | +| tool call | 工具调用 | | +| tool result | 工具结果 | | +| tool schema | 工具 schema | | +| toolkit | 工具包 | | +| turn | 轮次 | | +| typecheck | 类型检查 | | +| vocabulary | 词汇 | | +| workflow | 工作流 | | From d6da8ca29aa027df8928f0cdb60e583899b17fb5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:01 +0800 Subject: [PATCH 04/28] fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic. --- docs/cordis-catalog/events-and-services.md | 26 +- packages/compact/compact-basic/package.json | 3 + packages/compact/compact-basic/src/index.ts | 127 +++---- .../compact-basic/tests/compact-basic.spec.ts | 86 +++-- .../tests/compact-loop-repro.spec.ts | 155 +++++++++ packages/compact/compact/src/index.ts | 25 +- packages/core/agent-loop/src/loop.ts | 82 +++-- packages/core/agent-loop/tests/cancel.spec.ts | 30 ++ packages/core/agent-loop/tests/loop.spec.ts | 73 +++- packages/core/agent/src/types.ts | 43 ++- packages/core/session/src/index.ts | 2 +- packages/core/session/src/step-boundary.ts | 97 ------ packages/core/session/src/tool-pairing.ts | 100 ++++++ .../core/session/tests/step-boundary.spec.ts | 172 ---------- .../core/session/tests/tool-pairing.spec.ts | 314 ++++++++++++++++++ pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 16 +- 17 files changed, 912 insertions(+), 448 deletions(-) create mode 100644 packages/compact/compact-basic/tests/compact-loop-repro.spec.ts delete mode 100644 packages/core/session/src/step-boundary.ts create mode 100644 packages/core/session/src/tool-pairing.ts delete mode 100644 packages/core/session/tests/step-boundary.spec.ts create mode 100644 packages/core/session/tests/tool-pairing.spec.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7514a5fe41..5ace95c782 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -49,21 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) -#### `agent/pre-request` — parallel +#### `agent/pre-step` — serial -Awaited surface-mutation checkpoint, fired BEFORE the step's message history is derived (and thus before agent/request). The loop awaits `ctx.parallel('agent/pre-request', …)` after assembling the system prompt but before `session.deriveMessages()`, then derives ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node), and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. +Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Awaited (parallel), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before deriving. `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). ```ts cordis-catalog -'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -79,7 +79,7 @@ Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/t #### `agent/request` — waterfall -Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-request instead — by the time this fires, `options.messages` is already derived. +Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-step instead — by the time this fires, `options.messages` is already derived. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index c745fda233..c019796e0d 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -30,10 +30,13 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 2ee0d82133..7648a245b0 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -5,18 +5,18 @@ * - **Token estimation** — char/4 heuristic 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 step 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). + * 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** — `ctx.llm.stream()` assembled via `BlockAssembler` * (the single model-call surface; same path the loop uses) with a fixed * condense-the-history system prompt. * - **Surface mutation** — a single `user/message` replace node carries the * summary; `compact/*` events are log-only lock + provenance records. - * - **Auto-compaction** — an `agent/request` waterfall listener delegates to - * {@link BasicCompactService.compactIfNeeded} before EVERY model call (every - * step, so a tool-heavy turn that grows the surface mid-turn still compacts); - * it owns the sole token-pressure check. + * - **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 @@ -33,7 +33,7 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' @@ -169,23 +169,26 @@ export class BasicCompactService extends CompactService { this.config = resolveConfig(config) if (this.config.auto) { - // Auto-compaction: delegate to compactIfNeeded before EVERY model call — - // every step, not just the first. 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-request; 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. + // 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-request` (a parallel surface-mutation checkpoint), - // 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. - ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { + // 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. + ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { try { const result = await this.compactIfNeeded(agent.session, system, model, signal) if (result) { @@ -321,17 +324,19 @@ export class BasicCompactService extends CompactService { * 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 step-aligned boundary: if the walk stopped INSIDE a step, - * it continues head-ward past that step's `step/start` 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 is already a - * clean boundary. This always rounds toward retaining MORE (retained ≥ - * `retainTokens`) and is step-aligned by construction — no separate snap pass. + * 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 step-aligned cutoff exists in the + * 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). */ @@ -371,24 +376,26 @@ 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 step boundary: if `keepFromIdx` sits INSIDE a step, - // extend the retained side head-ward until the boundary is a step-aligned - // start, so the compacted range ends on a clean step edge. A node that - // belongs to no step is already a valid start. Decline if no step-aligned - // start 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. 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). while (keepFromIdx > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break + if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break keepFromIdx -= 1 } if (keepFromIdx === 0) return null // The compacted range is [head … keepFromIdx - 1], anchored at the head. - // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END: - // the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary - // marker sits between them in the log), and that same boundary makes the node - // before it a step-aligned end — so no separate end check is needed. + // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a balanced END: + // the retained start `nodes[keepFromIdx]` opens on a balanced cut, and that + // same cut is the cut AFTER `nodes[keepFromIdx - 1]` — so no separate end + // check is needed. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstSeq = nodes[0]!.seq // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -420,19 +427,24 @@ 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 contain whole steps, 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 boundary is - // valid when it sits on a step edge or on a node that belongs to no step - // (pre-step user message, inter-step steering, injection context); an `end` - // inside an open (unclosed) tail step is also rejected — its tool-calls have - // no results yet. See dsh-session's step-boundary predicates. + // 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. const events = session.events - if (!isStepAlignedStart(events, start)) { - throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`) + 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)`) } - if (!isStepAlignedEnd(events, end)) { - throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`) + // The cut after `end` is named by `end`'s surface successor, or `null` when + // `end` is the tail. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const afterEnd: number | null = nodes[endIdx]!.next + if (!isToolPairingBalanced(nodes, events, afterEnd)) { + throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } if (this._isCompactionInProgress(session)) { @@ -441,10 +453,11 @@ export class BasicCompactService extends CompactService { // 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 inside the - // `agent/request` waterfall, strictly between a turn's start and end. A - // manual call on a fully-closed session has no turn to enclose the events, - // so reject rather than emit an un-enclosed run. + // 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. const turn = this._openTurn(session) if (turn === null) { throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 255a59a5b7..4c91f11173 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -49,9 +49,9 @@ function createTestService(config: BasicCompactConfig = {}): TestCompactService /** * 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/request` waterfall between a turn's start and its end), 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 + * `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). */ @@ -219,7 +219,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) - it('compactRegion rejects a start that is not a step boundary (splits a step)', async () => { + it('compactRegion rejects a start that splits a step (unbalanced boundary)', async () => { const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes // [user, asst(tool-call), result] @@ -228,11 +228,11 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, // so starting here would orphan that assistant's tool-call. end is fine (user). await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) - .rejects.toThrow(/start seq .* is not on a step boundary/) + .rejects.toThrow(/start seq .* is not a balanced boundary/) expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected }) - it('compactRegion rejects an end that is not a step boundary (splits a step)', async () => { + it('compactRegion rejects an end that splits a step (unbalanced boundary)', async () => { const svc = createTestService() const session = toolTurnSession(1) const nodes = session.surface.nodes @@ -241,7 +241,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // end = the assistant/message: its tool/result follows IN THE SAME STEP, so // ending here would strand that result. start is fine (the pre-step user). await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) - .rejects.toThrow(/end seq .* is not on a step boundary/) + .rejects.toThrow(/end seq .* is not a balanced boundary/) }) it('compactRegion rejects an end inside an open tail step', async () => { @@ -258,7 +258,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const userSeq = nodes[0]!.seq const asstSeq = nodes[1]!.seq await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) - .rejects.toThrow(/end seq .* is not on a step boundary/) + .rejects.toThrow(/end seq .* is not a balanced boundary/) }) it('compactRegion accepts step-aligned boundaries (pre-step user → last result of a closed step)', async () => { @@ -883,10 +883,10 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { }) }) -describe('BasicCompactService auto-compaction (agent/pre-request listener)', () => { - /** Fire the agent/pre-request parallel checkpoint as the loop does. */ - function firePreRequest(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { - return ctx.parallel('agent/pre-request', agent, 1, step, system, model, SIGNAL) +describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { + /** Fire the agent/pre-step serial checkpoint as the loop does. */ + function firePreStep(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { + return ctx.serial('agent/pre-step', agent, 1, step, system, model, SIGNAL) } it('compacts (mutating the surface) when over threshold', async () => { @@ -896,7 +896,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') // The surface shrank in place, and a summary checkpoint landed. expect(session.surface.nodes.length).toBeLessThan(before) @@ -913,7 +913,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — // the surface accumulated assistant/message + tool/result nodes since step 1. - await firePreRequest(ctx, agent, 2, '', 'test-model') + await firePreStep(ctx, agent, 2, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) @@ -923,7 +923,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -937,7 +937,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length - await firePreRequest(ctx, agent, 1, '', 'missing-model') + await firePreStep(ctx, agent, 1, '', 'missing-model') // No summary landed; the surface is unchanged. expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(session.surface.nodes.length).toBe(before) @@ -949,7 +949,7 @@ describe('BasicCompactService auto-compaction (agent/pre-request listener)', () const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - await firePreRequest(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '', 'test-model') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) }) @@ -992,6 +992,10 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c9'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) s.append('tool/call', { turn: 1, step: 1, callId: CallId('c9'), name: 'bash', arguments: '{}' }) s.append('tool/result', { turn: 1, step: 1, callId: CallId('c9'), @@ -1014,12 +1018,15 @@ describe('BasicCompactService edge cases', () => { const s = new Session(SessionId('toolresult')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) - // assistant/message carrying a nested tool-result block and an unknown block. + // assistant/message carrying a nested tool-result block, an unknown block, + // and the tool-call that the following tool/result answers (so the surface + // is tool-pairing balanced). s.append('assistant/message', { turn: 1, step: 1, content: [ { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, + { type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' }, ], }, { surfaceOp: 'append' }) // tool/result whose content is itself only non-text → bare '[tool-result]'. @@ -1059,7 +1066,7 @@ describe('BasicCompactService edge cases', () => { const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) @@ -1140,7 +1147,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) // The failure was swallowed; the surface is untouched and a warning logged. expect(session.surface.nodes.length).toBe(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) @@ -1157,7 +1164,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - await ctx.parallel('agent/pre-request', agent, 1, 1, bigSystem, 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, 'test-model', SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) expect(svc.summarizeCalls.length).toBe(0) }) @@ -1166,23 +1173,37 @@ 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 }) - // Empty-text text/reasoning blocks contribute nothing → message skipped. s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) - // tool/result with empty content → empty extraction → skipped. - s.append('tool/call', { turn: 1, step: 1, callId: CallId('z1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('z1'), content: [], isError: false }, { 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). + s.append('step/start', { turn: 1, step: 2 }) + s.append('assistant/message', { + turn: 1, step: 2, + content: [{ type: 'tool-call', id: CallId('z1'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + s.append('tool/call', { turn: 1, step: 2, callId: CallId('z1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 2, callId: CallId('z1'), content: [], isError: false }, { surfaceOp: 'append' }) + s.append('step/end', { turn: 1, step: 2 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - // Every message extracted to empty text — the conversation is empty. - expect(svc.summarizeCalls[0]!.text).toBe('') + // 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({})]') }) it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { @@ -1192,8 +1213,15 @@ describe('BasicCompactService edge cases', () => { s.append('step/start', { turn: 1, step: 1 }) // user/message with only an image block → '[image]' placeholder. s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // assistant/message with only an image block → '[image]' placeholder. - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'image', url: 'https://x/z.png' }] }, { surfaceOp: 'append' }) + // assistant/message with an image block AND the tool-call its tool/result + // answers (so the surface is tool-pairing balanced) → '[image]' placeholder. + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'image', url: 'https://x/z.png' }, + { type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' }, + ], + }, { surfaceOp: 'append' }) // tool/result with an image block → '[image]' placeholder. s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' }) s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts new file mode 100644 index 0000000000..e12861a43a --- /dev/null +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +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. + */ + +const TOKENS_PER_BLOCK = 10 + +class ReproCompactService extends BasicCompactService { + override estimateContentTokens(blocks: readonly ContentBlock[]): number { + return blocks.length * TOKENS_PER_BLOCK + } + + override async summarize(): Promise { + return [{ type: 'text', text: 'CHECKPOINT SUMMARY' }] + } +} + +/** Each call emits one tool-call until exhausted, then a final text answer. */ +class StepwiseToolAdapter extends LlmAdapter { + calls = 0 + constructor(private toolSteps: number) { + super() + } + + async * stream(_options: GenerateOptions): AsyncIterable { + const n = this.calls + this.calls += 1 + if (n < this.toolSteps) { + const id = CallId(`c${n}`) + const args = `{"i":${n}}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: `step ${n}` } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'work', arguments: args } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'all done' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants, {}) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) + ctx.tools.register(defineTool({ + name: 'work', + description: 'does work', + parameters: { i: { type: 'number' } }, + async execute() { + return [{ type: 'text', text: 'work result' }] + }, + })) + // Tiny window so a couple of tool steps cross the threshold and compaction + // fires within the runaway turn. Convergence invariant holds: + // summarizationMaxTokens(1) + retainTokens(20) = 21 <= floor(60*0.5) = 30. + const compact = new ReproCompactService(ctx, { + auto: true, + contextWindow: 60, + thresholdRatio: 0.5, + retainTokens: 20, + summarizationMaxTokens: 1, + }) + return { ctx, compact } +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { + it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { + const { ctx } = await harness(8) + try { + const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'do a long multi-step task' }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + // A compaction ran: at least one checkpoint landed on the surface. + const checkpoints = events.filter( + (e): e is SurfaceEvent => + e.type === 'user/message' + && typeof (e as SurfaceEvent).surfaceOp === 'object', + ) + 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. + const nodes = agent.session.surface.nodes + for (const cp of checkpoints) { + const node = nodes.find(n => n.seq === cp.seq) + if (!node) continue // shadowed by a later checkpoint — no longer an edge. + expect(isToolPairingBalanced(nodes, events, node.seq), + `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true) + expect(isToolPairingBalanced(nodes, events, node.next), + `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true) + } + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 5e63169fa1..c84c147ca7 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -73,7 +73,8 @@ export abstract class CompactService extends Service { * 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 step-aligned cutoff, so a prior head checkpoint is + * 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 @@ -106,15 +107,15 @@ export abstract class CompactService extends Service { * summarizes their content and appends a replacement surface node. Used by the * (future) `/compact` tool and internally by {@link compactIfNeeded}. * - * The region MUST contain whole steps — `start` and `end` must each sit on a - * step boundary (the first / last surface node of a step) or on a node that - * belongs to no step (a pre-step user message, inter-step steering, or an - * injection context message). A boundary that falls INSIDE a step would split - * that 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. An `end` inside an open (unclosed) - * tail step is likewise invalid — its tool-calls have no results yet. - * `dsh-session` exports `isStepAlignedStart` / `isStepAlignedEnd` for this check. + * 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 session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. @@ -128,8 +129,8 @@ export abstract class CompactService extends Service { * 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 step-aligned (would split a step's tool-call/result - * pair). + * either boundary is not a balanced tool-pairing cut (would split a step's + * tool-call/result pair). */ abstract compactRegion( session: Session, diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4aeef18eec..f6b286cb28 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -12,6 +12,7 @@ import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-ll import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' @@ -147,9 +148,9 @@ export interface LoopHandle { * drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble - * await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) BEFORE derive + * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step + * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) * req = {model, system, tools, messages: session.deriveMessages(), signal} * req = waterfall agent/request ⟵ hooks/model-switch * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) @@ -387,20 +388,54 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // (or turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) + // Assemble the system prompt for this step. Done HERE (before step/start) + // because the pre-step seam needs it: compaction measures token pressure + // against the system prompt (it counts toward the budget) and a listener + // also receives the model to summarize with. runStep reuses this same + // assembly for the request, so the prompt is assembled once per step. + const assembly = await ctx.systemPrompt.assemble() + const system = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] + .filter(text => text.length > 0) + .join('\n\n') + + // The step's AbortController exists BEFORE the pre-step seam so a cancel() + // during the seam aborts any in-flight work a listener started (e.g. a + // compaction summarization call). Cleared on every exit path below. + const abort = new AbortController() + handle.setAbort(abort) + + // Cancel landing before the seam: a synchronous `agent/turn-start` listener + // (or the previous step's continuation listeners) can have called + // `cancel()`. Drop the about-to-start step WITHOUT running the seam — no + // step is open yet, so end the turn `aborted` directly. + if (handle.isCancelled()) { + handle.setAbort(undefined) + reason = { kind: 'aborted', reason: handle.cancelReason() } + break + } + + // Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the + // step: after `turn/start` (and the prior step's close) but before + // `step/start`, so a compaction's log-only `compact/*` records and its + // replacement node land cleanly outside any step (honest structure that + // crash-safety relies on — a dangling `compact/start` sits before the + // synthetic `turn/end` repair appends). Serial (awaited, in order, no + // veto): each listener completes its surface mutation before the next, so + // concurrent listeners cannot interleave their `session.append`s. A + // throwing listener escapes to the outer catch, which closes the (not-yet- + // open) step as a no-op and ends the turn via failTurn — a broken + // pre-step plugin ends the turn, not the loop. + await ctx.serial('agent/pre-step', agent, turn, step, system, agent.options.model ?? '', abort.signal) + session.append('step/start', { turn, step }) stepOpen = true ctx.emit('agent/step-start', agent, turn, step) - const abort = new AbortController() - handle.setAbort(abort) - - // Cancel landing in the step-start window: a synchronous `agent/turn-start` - // or `agent/step-start` listener (both fire before this point) can have - // called `cancel()`, and `runStep` would otherwise run a full extra step - // with no AbortController having observed it. Check the marker AFTER - // setAbort (so the next-iteration drain sees a clean controller) and before - // `runStep`: drop the step, end the turn `aborted`. closeStep balances the - // already-appended step/start. + // Cancel landing in the seam / step-start window: a `cancel()` during the + // pre-step seam (it aborted `abort.signal` above) OR a synchronous + // `agent/step-start` listener that cancels. Check AFTER setAbort/step-start + // and before `runStep`: drop the step, end the turn `aborted`. closeStep + // balances the already-appended step/start. if (handle.isCancelled()) { handle.setAbort(undefined) reason = { kind: 'aborted', reason: handle.cancelReason() } @@ -410,7 +445,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, abort.signal) + stepOutcome = await runStep(ctx, agent, turn, step, assembly, system, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -550,29 +585,22 @@ function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boole return messages.length > 0 } -/** One step: assemble request → stream model → record → execute tools. */ +/** One step: derive request from the (already pre-step-mutated) surface → + * stream model → record → execute tools. The caller assembles the system prompt + * and fires the `agent/pre-step` seam BEFORE opening the step, then passes the + * resulting `assembly`/`system` here, so the surface this step derives from + * already reflects any compaction. */ async function runStep( ctx: Context, agent: ReactLoopAgent, turn: number, step: number, + assembly: PromptAssembly, + system: string, signal: AbortSignal, ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent - // --- Request assembly --- - const assembly = await ctx.systemPrompt.assemble() - const system = [renderPrompt(assembly), options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') - - // Surface-mutation checkpoint BEFORE deriving history: compaction shadows an - // older range with a summary node here, and the single derive below reflects - // it. Awaited (no veto) — a listener mutates the surface as a side effect. - // `model` is resolved to '' when unset; a compaction listener that needs a - // model falls back to its own config. - await ctx.parallel('agent/pre-request', agent, turn, step, system, options.model ?? '', signal) - let request: GenerateOptions = { model: options.model ?? '', messages: session.deriveMessages(), diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 9cdaa1973b..d6394acd54 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -194,6 +194,36 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }]) }) + it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => { + const adapter = new MockAdapter([textResponse('should not stream')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + // A step-start listener fires AFTER step/start is appended (and after the + // pre-step seam), so cancelling there lands in the SECOND cancel check (the + // one that must closeStep() to balance the already-open step) — distinct + // from a turn-start cancel, which is caught before the step opens. + let streamed = false + ctx.on('agent/stream-chunk', () => { streamed = true }) + const dispose = ctx.on('agent/step-start', (subject) => { + if (subject === agent) agent.cancel('from step-start') + }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + // No step streamed, the turn ended aborted with the caller's reason, and the + // log is balanced (the open step was closed by the cancel branch). + expect(streamed).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }]) + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + }) + it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { // A continuation-waterfall listener cancels DURING the continuation decision // (the finished step's AbortController is already cleared), and votes to diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f74e160936..2c6f9e06e8 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -320,11 +320,11 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) - it('agent/pre-request fires once per step before the request is derived', async () => { + it('agent/pre-step fires once per step before the step is opened', async () => { // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-request fires, each carrying the assembled system + model, BEFORE the - // request messages are derived (the request the adapter sees reflects any - // surface state at fire time). + // pre-step fires, each carrying the assembled system + model, BEFORE the + // step is opened and its request is derived (the request the adapter sees + // reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -337,7 +337,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const fires: { turn: number; step: number; model: string }[] = [] - ctx.on('agent/pre-request', (subject, turn, step, _system, model) => { + ctx.on('agent/pre-step', (subject, turn, step, _system, model) => { if (subject === agent) fires.push({ turn, step, model }) }) @@ -351,32 +351,77 @@ describe('agent loop', () => { ]) }) - it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => { - // pre-request fires BEFORE deriveMessages(), so a listener that appends a - // surface node there sees it land in the SAME step's request — proving the - // loop derives once, after the checkpoint, with no stale pre-derive. + it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => { + // A listener appending a surface node in pre-step lands it BEFORE step/start + // in the log — proving the seam fires outside the step. The node is still in + // the derived request for that step (derive happens after step/start). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let injected = false - ctx.on('agent/pre-request', (subject, turn) => { + ctx.on('agent/pre-step', (subject) => { if (subject === agent && !injected) { injected = true subject.session.append('context/message', { - content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }], + content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) - void turn } }) send(agent, 'go') await waitForIdle(ctx, agent) - // The adapter's request includes the node injected during pre-request. + // The adapter's request includes the node injected during pre-step (derive + // reflects it). const text = JSON.stringify(adapter.requests[0]!.messages) - expect(text).toContain('INJECTED-IN-PRE-REQUEST') + expect(text).toContain('INJECTED-IN-PRE-STEP') + + // And the injected event sits BEFORE the first step/start in the log — + // the seam fired outside the step. + const events = agent.session.events + const injectedSeq = events.find(e => e.type === 'context/message')!.seq + const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq + expect(injectedSeq).toBeLessThan(firstStepStartSeq) + }) + + it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => { + // The seam fires before step/start, so a throw escapes to runTurn's outer + // catch: the not-yet-open step closes as a no-op, the failure surfaces via + // agent/error, and the turn ends `error` (recorded on the durable turn/end). + // The loop survives and a follow-up prompt still runs. + const adapter = new MockAdapter([textResponse('second turn ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let throwOnce = true + ctx.on('agent/pre-step', () => { + if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } + }) + + const errors: Error[] = [] + ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + + send(agent, 'first') + await waitForIdle(ctx, agent) + // The first turn failed at step 1 (no model call happened), surfaced via + // agent/error, with the durable failure on turn/end.reason. + expect(errors).toHaveLength(1) + expect(errors[0]!.message).toContain('boom in pre-step') + expect(adapter.requests.length).toBe(0) + const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 }) + // The step opened-and-closed count stays balanced even though it never ran. + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + + // The loop survived: a second prompt runs a normal completed turn. + send(agent, 'second') + await waitForIdle(ctx, agent) + expect(adapter.requests.length).toBe(1) + const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) it('cancel() mid-stream ends the turn with reason aborted', async () => { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 407201ea5d..5bb603f1f6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -181,30 +181,37 @@ declare module 'cordis' { // ---- interception seams (waterfall) ---- /** - * Awaited surface-mutation checkpoint, fired BEFORE the step's message - * history is derived (and thus before {@link agent/request}). The loop - * awaits `ctx.parallel('agent/pre-request', …)` after assembling the system - * prompt but before `session.deriveMessages()`, then derives ONCE from - * whatever the surface now holds. This is where compaction belongs: it - * mutates the session surface in place (shadowing an older range with a - * summary node), and the single subsequent derive reflects the mutation — - * so there is no double-derive and no listener can see (or be expected to - * act on) an assembled `messages` array that does not exist yet. + * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER + * `turn/start` (and after the prior step closed) but BEFORE this step's + * `step/start` — so anything a listener appends lands OUTSIDE the step, + * between `turn/start`/`step/end` and the upcoming `step/start`. `step` is + * the number of the step about to start. The loop awaits + * `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then + * opens the step and derives the request history ONCE from whatever the + * surface now holds. This is where compaction belongs: it mutates the session + * surface in place (shadowing an older range with a summary node) with its + * log-only `compact/*` records cleanly outside any step, and the single + * subsequent derive reflects the mutation — so there is no double-derive and + * no listener can see (or be expected to act on) an assembled `messages` + * array that does not exist yet. * - * Awaited (parallel), not a waterfall: a listener mutates the surface as a - * side effect; there is nothing to transform or veto, but the loop must wait - * for the mutation to complete before deriving. `system`/`model` are the - * assembled values a listener needs to measure pressure (system counts - * toward the budget) and to summarize (the model). `signal` cancels any - * in-flight work a listener starts (e.g. a summarization model call). - * @mode parallel + * Serial (awaited, in registration order, no veto), not a waterfall: a + * listener mutates the surface as a side effect; there is nothing to + * transform or veto, but the loop must wait for the mutation to complete + * before opening the step and deriving, and serial isolates listeners from + * each other (one finishes its surface append before the next runs). + * `system`/`model` are the assembled values a listener needs to measure + * pressure (system counts toward the budget) and to summarize (the model). + * `signal` cancels any in-flight work a listener starts (e.g. a summarization + * model call). + * @mode serial */ - 'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void + 'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the * model call (hooks, model switching, tool filtering, …). Call `next()` to * delegate, or return without it to short-circuit. For surface mutation that - * must precede history derivation (compaction), use {@link agent/pre-request} + * must precede history derivation (compaction), use {@link agent/pre-step} * instead — by the time this fires, `options.messages` is already derived. * @mode waterfall */ diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0fb44f3299..ad6fd59dd3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -19,7 +19,7 @@ export { isJsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' -export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts' +export { isToolPairingBalanced } from './tool-pairing.ts' declare module 'cordis' { interface Context { diff --git a/packages/core/session/src/step-boundary.ts b/packages/core/session/src/step-boundary.ts deleted file mode 100644 index e8ed91d74c..0000000000 --- a/packages/core/session/src/step-boundary.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Step-boundary predicates over a session log: is a given surface node a SAFE - * place to start or end a region that will be collapsed (e.g. by compaction)? - * - * The invariant a consumer needs: a collapsed region must NOT partially overlap - * a step. A step's surface nodes form a contiguous run, and a region must - * contain either ALL of a step's nodes or NONE of them — otherwise it can split - * an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving - * the rehydrated transcript with a dangling tool-call or an orphaned tool-result - * (which every provider rejects). This is the compaction-time mirror of the - * crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load. - * - * Nodes that belong to NO step — a pre-step `user/message` (drained before the - * first `step/start`), inter-step `steering/message`, or an injection - * `context/message` (wrapped in a bare `turn/start → context/message → turn/end` - * with no step) — carry no tool pairing and are free boundaries on both sides. - * - * The scans classify each neighbor event into three buckets: a turn/step - * BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge - * is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*` - * records, and any future non-surface event). "Surface node" is decided by the - * shared {@link isSurfaceEvent} guard so the two notions can't drift. - * - * @module @deepseek-ai/dsh-session/step-boundary - */ - -import type { SessionEvent } from './types.ts' -import { isSurfaceEvent } from './surface.ts' - -/** Turn/step boundary marker types — the walls the scans stop on. */ -const BOUNDARY_TYPES = new Set(['turn/start', 'turn/end', 'step/start', 'step/end']) - -/** - * Whether the surface node at `seq` is a SAFE START for a collapsed region — - * i.e. it is the first surface node of its step, or it belongs to no step at - * all (a free inter-step / pre-step / injection node). - * - * Scans BACKWARD from `seq`, skipping noise, and stops at the first significant - * event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies - * before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in - * the same step, so starting here would orphan it), start-of-log ⇒ aligned. - * - * No open-step check is needed on the start side: an open (unclosed) step can - * only ever be the LAST turn's last step, never before a valid region start. - */ -export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = events[i]! - if (BOUNDARY_TYPES.has(event.type)) return true - if (isSurfaceEvent(event)) return false - } - return true -} - -/** - * Whether the surface node at `seq` is a SAFE END for a collapsed region — - * i.e. it is the last surface node of a CLOSED step, or it belongs to no step - * at all. - * - * Scans FORWARD from `seq`, skipping noise, and stops at the first significant - * event: a turn/step boundary marker ⇒ aligned (the step/turn closes after - * `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒ - * NOT aligned (a later surface node sits in the same step). Reaching - * end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open - * trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it - * would defer the orphan to when those results land later. {@link isInOpenStep} - * decides that via a backward scan. - */ -export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq + 1; i < events.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const event = events[i]! - if (BOUNDARY_TYPES.has(event.type)) return true - if (isSurfaceEvent(event)) return false - } - // End of log: aligned only if `seq` is not inside a still-open step. - return !isInOpenStep(events, seq) -} - -/** - * Whether `seq` sits inside an OPEN step — a `step/start` with no later - * `step/end`. Only meaningful at the tail (the EOL branch of - * {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary. - * The nearest one being `step/start` means a step opened before `seq` and never - * closed (no `step/end` lies after `seq`, or the forward scan would not have - * reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none) - * means `seq` is inter-step / pre-step. - */ -function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean { - for (let i = seq - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const type = events[i]!.type - if (BOUNDARY_TYPES.has(type)) return type === 'step/start' - } - return false -} diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts new file mode 100644 index 0000000000..638daaf654 --- /dev/null +++ b/packages/core/session/src/tool-pairing.ts @@ -0,0 +1,100 @@ +/** + * Tool-pairing balance over a session's SURFACE: is a given cut point in the + * surface a safe edge for a collapsed region (e.g. compaction)? + * + * The invariant a consumer needs: a collapsed region must never separate an + * `assistant/message`'s `tool-call` blocks from their answering `tool/result`s + * — that would leave the rehydrated transcript with a dangling tool-call or an + * orphaned tool-result, which every provider rejects. (This is the + * compaction-time mirror of the crash-recovery imbalance that + * {@link interruptedTurnClosers} repairs on load.) Steps were once used as a + * proxy for this bracketing, but a compaction REWRITES the surface — it lands a + * replacement node at a high log seq whose SURFACE position is the head — so a + * scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The + * pairing the invariant actually protects lives in the surface nodes' own + * content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels + * with the node through any reshaping, so alignment is decided over the surface + * directly. + * + * A **cut** is a gap between two adjacent surface nodes (named by the node it + * sits immediately before), or the after-tail gap (`null`). Walking the surface + * head→tail and assigning each node a delta — `+1` per `tool-call` block on an + * `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a + * cut is the number of still-unanswered tool calls before it. A cut is + * **balanced** when that depth is `0`. A region `[start..end]` is safe to + * collapse iff BOTH its edges are balanced cuts: the cut before `start` and the + * cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an + * inter-step `steering/message`, an injection `context/message`) carry no + * pairing, contribute `0`, and so are free boundaries — exactly as before, but + * now as a consequence of the balance rather than a special case. An open + * trailing step (an assistant whose `tool/result`s have not landed yet) keeps + * the depth positive through the tail, so no cut inside it is balanced — the + * old explicit open-step check falls out of the same counter. + * + * @module @deepseek-ai/dsh-session/tool-pairing + */ + +import type { SessionEvent } from './types.ts' +import type { SurfaceNode } from './surface.ts' + +/** + * The tool-pairing delta of a surface node: how it shifts the count of + * unanswered tool calls. An `assistant/message` opens one bracket per + * `tool-call` block; a `tool/result` closes one; every other surface node + * (`user/message`, `context/message`, `steering/message`, a usage-only + * `assistant/message` with no tool-call blocks) is pairing-neutral. + */ +function nodeDelta(event: SessionEvent): number { + switch (event.type) { + case 'assistant/message': + return event.data.content.filter(block => block.type === 'tool-call').length + case 'tool/result': + return -1 + // Non-pairing surface nodes and every non-surface event contribute nothing. + default: + return 0 + } +} + +/** + * Whether the surface prefix ending at the given cut has BALANCED tool-call / + * tool-result brackets — i.e. every `tool-call` block on the surface before the + * cut has its answering `tool/result` before the cut too, so the cut is a safe + * edge for a collapsed region (it cannot split an assistant↔result pair). + * + * `nodes` is the surface linked list in head→tail order (e.g. + * `session.surface.nodes`); `events` is the session log, used to look each + * node's event up by `seq`. `beforeSeq` names the cut by the surface node it + * sits immediately before; the after-tail cut (the whole surface) is `null`, + * as is any `beforeSeq` not present on the surface. + * + * A region `[start..end]` is collapsible iff both edges are balanced cuts: call + * `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and + * `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s + * surface successor (`SurfaceNode.next`), or `null` when `end` is the tail — + * for the cut after `end`. + * + * @throws if the surface prefix drives the unanswered-call depth negative — a + * `tool/result` with no preceding open `tool-call` on the surface. That is a + * corrupt surface (a structural invariant violation), surfaced loudly here + * rather than silently mis-classifying a boundary. + */ +export function isToolPairingBalanced( + nodes: readonly SurfaceNode[], + events: readonly SessionEvent[], + beforeSeq: number | null, +): boolean { + let depth = 0 + for (const node of nodes) { + if (node.seq === beforeSeq) return depth === 0 + // node.seq is a surface-node seq, always a valid log index by construction. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + depth += nodeDelta(events[node.seq]!) + if (depth < 0) { + throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`) + } + } + // Reached the after-tail cut (beforeSeq === null, or a seq not on the + // surface): the whole-surface prefix is balanced iff depth returned to 0. + return depth === 0 +} diff --git a/packages/core/session/tests/step-boundary.spec.ts b/packages/core/session/tests/step-boundary.spec.ts deleted file mode 100644 index a24a6f7596..0000000000 --- a/packages/core/session/tests/step-boundary.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' -import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts' -import type { SessionEvent } from '../src/index.ts' - -/** - * Unit coverage for the step-alignment predicates. They decide whether a - * surface node is a safe START / END for a collapsed region (compaction): a - * region must contain whole steps, never split an `assistant/message`'s - * tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step - * user message, inter-step steering, injection context) are free boundaries. - * - * Builders mirror the agent loop's real append order so the fixtures are - * representative: queued user messages land BEFORE `step/start`; within a step - * the order is `assistant/message` then `tool/result`(s); injection turns are a - * bare `turn/start → context/message → turn/end` with no step. - */ - -const SURFACE = { surfaceOp: 'append' as const } - -/** A closed turn with one closed step holding an assistant + its tool result. */ -function toolStepLog(): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ - { type: 'text', text: 'calling' }, - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - { type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } }, - { type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE }, - { type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, - ] -} - -describe('isStepAlignedStart', () => { - it('is true for a pre-step user/message (belongs to no step)', () => { - // seq 1 user/message sits before step/start at seq 2 → free boundary. - expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true) - }) - - it('is true for the first surface node of a step (the assistant/message)', () => { - // Backward from seq 3 the first significant event is step/start → aligned. - expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true) - }) - - it('is false for a tool/result whose assistant/message precedes it in the same step', () => { - // Backward from seq 5 the first significant event is the assistant/message - // surface node (seq 3) → starting here would orphan that assistant's call. - expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false) - }) - - it('is true at start-of-log (nothing precedes)', () => { - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedStart(log, 0)).toBe(true) - }) - - it('skips noise (assistant/chunk, compact/* records) when scanning back', () => { - // A compacted region landed compact/* log-only records between the prior - // step boundary and this surface node; they must be skipped, not treated as - // walls. Backward from seq 4 skips compact/end, compact/summary, compact/start - // and stops at step/start (seq 0) → aligned. - const log: SessionEvent[] = [ - { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, - { type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent, - { type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent, - { type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent, - { type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, - ] - expect(isStepAlignedStart(log, 4)).toBe(true) - }) -}) - -describe('isStepAlignedEnd', () => { - it('is true for the last surface node of a closed step (the tool/result)', () => { - // Forward from seq 5 the first significant event is step/end → aligned. - expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true) - }) - - it('is false for an assistant/message with a later tool/result in the same step', () => { - // Forward from seq 3 the first significant event is the tool/result surface - // node (seq 5) → ending here would strand that result. - expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false) - }) - - it('is true for a pre-step user/message (next significant event is step/start)', () => { - expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true) - }) - - it('is false at EOL when the node is inside an open (unclosed) step', () => { - // step/start then an assistant tool-call, but no step/end / tool/result yet - // (mid-flight). Ending the region on seq 3 would summarize away a tool-call - // whose result lands later → orphan. EOL + open step ⇒ not aligned. - const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 2)).toBe(false) - }) - - it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => { - // The open-step back-scan must skip non-boundary events (here an - // assistant/chunk) before it reaches step/start. Without the skip it would - // mis-read the chunk as the nearest "boundary" and never confirm the open step. - const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, - { type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, - ] }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 3)).toBe(false) - }) - - it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => { - // A steering message appended after step/end, at the tail. Backward the - // nearest boundary is step/end → not in an open step → aligned. - const log: SessionEvent[] = [ - { type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE }, - { type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } }, - { type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 3)).toBe(true) - }) - - it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => { - // A lone surface node, no turn/step markers at all → not in an open step. - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE }, - ] - expect(isStepAlignedEnd(log, 0)).toBe(true) - }) - - it('skips noise (assistant/chunk) when scanning forward', () => { - // assistant/chunk events precede the assistant/message in a real step; the - // forward scan from an inter-step node must skip them and stop on step/start. - const log: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } }, - ] - // Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot). - expect(isStepAlignedEnd(log, 0)).toBe(true) - }) -}) - -describe('step-alignment on an injection turn (no step)', () => { - // An idle inject() wraps a context/message in a bare turn/start → context/message - // → turn/end with NO step/start. The context node is a free boundary both ways. - const injectionLog = (): SessionEvent[] => [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, - { type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE }, - { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - - it('start: aligned (backward hits turn/start)', () => { - expect(isStepAlignedStart(injectionLog(), 1)).toBe(true) - }) - - it('end: aligned (forward hits turn/end)', () => { - expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true) - }) -}) diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts new file mode 100644 index 0000000000..307b0d8658 --- /dev/null +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' +import type { SessionEvent, SurfaceNode } from '../src/index.ts' + +/** + * Unit coverage for the tool-pairing balance check. It decides whether a CUT in + * the surface (a gap before a given surface node, or the after-tail gap) is a + * safe edge for a collapsed region (compaction): a region must never split an + * `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced + * when no unanswered tool-call sits before it on the surface. Nodes belonging to + * no step (pre-step user message, inter-step steering, injection context) are + * pairing-neutral, so their cuts are free boundaries. + * + * The fixtures are built through a real {@link Session} so the surface linked + * list is derived exactly as production does — including the non-monotonic + * surface a `replace` op leaves (a compaction checkpoint at a high log seq + * sitting at the surface head), which is the case the abandoned log-position + * scan mis-classified. + * + * Builders mirror the agent loop's real append order: queued user messages land + * BEFORE `step/start`; within a step the order is `assistant/message` then + * `tool/result`(s); injection turns are a bare `turn/start → context/message → + * turn/end` with no step. + */ + +const SURFACE = { surfaceOp: 'append' as const } + +/** Surface nodes + log for a session, the two args the balance check takes. */ +function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } { + return { nodes: session.surface.nodes, events: session.events } +} + +/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */ +function startBalanced(session: Session, seq: number): boolean { + const { nodes, events } = surfaceOf(session) + return isToolPairingBalanced(nodes, events, seq) +} + +/** The cut AFTER the surface node at `seq` is balanced (safe region end). */ +function endBalanced(session: Session, seq: number): boolean { + const { nodes, events } = surfaceOf(session) + const node = nodes.find(n => n.seq === seq) + if (!node) throw new Error(`seq ${seq} is not a surface node`) + return isToolPairingBalanced(nodes, events, node.next) +} + +/** Surface seq of the nth (0-based) event of a given type. */ +function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number { + return s.events.filter(e => e.type === type)[nth]!.seq +} + +/** A closed turn with one closed step holding an assistant + its tool result. */ +function toolStepSession(): Session { + const s = new Session(SessionId('tool-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, + ], + }, SURFACE) + s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s +} + +describe('isToolPairingBalanced — region START (cut before a node)', () => { + it('is true for a pre-step user/message (belongs to no step)', () => { + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) + + it('is true for the first surface node of a step (the assistant/message)', () => { + // The cut before the assistant is balanced — nothing unanswered precedes it. + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true) + }) + + it('is false for a tool/result whose assistant/message precedes it in the same step', () => { + // The cut before the tool/result has one unanswered tool-call (the + // assistant's) → starting the region here would orphan that call. + const s = toolStepSession() + expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false) + }) + + it('is true at the surface head (nothing precedes)', () => { + const s = new Session(SessionId('lone')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) + expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — region END (cut after a node)', () => { + it('is true for the last surface node of a closed step (the tool/result)', () => { + // After the tool/result the assistant's single call is answered → balanced. + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true) + }) + + it('is false for an assistant/message with a later tool/result in the same step', () => { + // After the assistant its tool-call is still unanswered → ending here strands + // the result. + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) + }) + + it('is true for a pre-step user/message', () => { + const s = toolStepSession() + expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) + + it('is false at the tail when the node is inside an open (unclosed) step', () => { + // step/start then an assistant tool-call, but no tool/result yet (mid-flight). + // The after-tail cut still has one unanswered call → not balanced. + const s = new Session(SessionId('open-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false) + }) + + it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => { + // A steering message appended after step/end, at the tail. The prior step's + // pair is balanced and steering is neutral → the after-tail cut is balanced. + const s = new Session(SessionId('trailing-steer')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE) + expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true) + }) + + it('is true at the tail when no step ever opened', () => { + const s = new Session(SessionId('no-step')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE) + expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => { + // An assistant message with two tool-calls needs BOTH results before the cut + // after it is balanced — depth +2, then -1, -1. + function twoCallStep(): Session { + const s = new Session(SessionId('two-call')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [ + { type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' }, + { type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' }, + ], + }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('is unbalanced after the first of two results (one call still open)', () => { + const s = twoCallStep() + expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false) + }) + + it('is balanced after the second result (both calls answered)', () => { + const s = twoCallStep() + expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — a mid-step injection context/message', () => { + // A background task-done inject() lands a context/message INSIDE an open step, + // between the assistant (with a tool-call) and its tool/result. It is + // pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is + // still open across it) — it is NOT a free boundary in this position. + function midStepInjection(): Session { + const s = new Session(SessionId('mid-inject')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('start cut before the mid-step context/message is unbalanced (call still open)', () => { + const s = midStepInjection() + expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false) + }) + + it('end cut after the mid-step context/message is unbalanced (call still open)', () => { + const s = midStepInjection() + expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false) + }) +}) + +describe('isToolPairingBalanced on an injection turn (no step)', () => { + // An idle inject() wraps a context/message in a bare turn/start → + // context/message → turn/end with NO step. The context node is a free boundary + // both ways (pairing-neutral, nothing open around it). + function injectionSession(): Session { + const s = new Session(SessionId('injection')) + s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } }) + s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return s + } + + it('start: balanced', () => { + const s = injectionSession() + expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true) + }) + + it('end: balanced', () => { + const s = injectionSession() + expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true) + }) +}) + +describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { + // The case the log-position scan got wrong. After a compaction, a replacement + // user/message lands at a HIGH log seq but sits at the SURFACE head, beside + // the still-open step whose events follow it in the log. It carries no + // tool-call/result pair (just summarized prose), so it must be a balanced cut + // on BOTH sides regardless of its log neighbours. + function checkpointHeadedSession(): Session { + const s = new Session(SessionId('checkpoint')) + // A closed turn with a tool step → surface [u1, asst(call), result]. + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE) + s.append('assistant/message', { + turn: 1, step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], + }, SURFACE) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE) + s.append('step/end', { turn: 1, step: 1 }) + s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // An OPEN turn whose step is in progress (loop fires compaction here). + s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 2, step: 1 }) + // Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one + // summary user/message — appended now, so it carries a high log seq. + const u1 = seqOf(s, 'user/message') + const result = s.events.find(e => e.type === 'tool/result')!.seq + s.append('user/message', { + content: [{ type: 'text', text: 'CHECKPOINT' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: u1, end: result } }) + // The step's own assistant/message lands AFTER the checkpoint in the log, + // still inside the open step. + s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE) + return s + } + + it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => { + const s = checkpointHeadedSession() + const nodes = s.surface.nodes + const checkpointSeq = nodes[0]!.seq + // The checkpoint heads the surface, yet a surface node (the open step's + // assistant) follows it in LOG order — the exact split between surface + // position and log position that the log-position scan tripped on. + const laterSurfaceInLog = s.events.find( + e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq), + ) + expect(laterSurfaceInLog).toBeDefined() + expect(nodes[0]!.seq).toBe(checkpointSeq) + }) + + it('start cut before the head checkpoint is balanced (it is the head)', () => { + const s = checkpointHeadedSession() + expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + }) + + it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => { + // This is the exact assertion the log-position scan failed: the forward log + // scan from the checkpoint reached the open step's assistant/message and + // wrongly reported mid-step. The surface balance sees a neutral node whose + // following cut closes no open call. + const s = checkpointHeadedSession() + expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true) + }) +}) + +describe('isToolPairingBalanced — corrupt surface guard', () => { + it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => { + // A surface that opens with a tool/result (no assistant call before it) is + // structurally corrupt — surfaced loudly rather than mis-classified. + const s = new Session(SessionId('corrupt')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE) + const { nodes, events } = surfaceOf(s) + expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a1a9973a..15b61280cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -147,6 +150,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7479f5306c..5bf7a344ca 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -23,8 +23,8 @@ * * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in * full from source: signature, the `@mode` badge, and the declaration's JSDoc. - * Every harness event MUST carry an `@mode emit|waterfall|parallel` tag — the - * generator hard-errors on a missing tag, and where the signature shape is + * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag + * — the generator hard-errors on a missing tag, and where the signature shape is * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author @@ -48,7 +48,7 @@ const OUT = 'docs/cordis-catalog/events-and-services.md' const FENCE = 'ts cordis-catalog' /** A dispatch mode, rendered as the badge after an event name. */ -type Mode = 'emit' | 'waterfall' | 'parallel' +type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' /** * Cross-link map: a type name that appears in a signature → the @@ -165,7 +165,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { para = [] } for (const line of inner) { - const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line) + const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) if (m) { mode = m[1] as Mode; continue } if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose if (line.trim() === '') { flushPara(); continue } @@ -223,11 +223,11 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) const src = pointer(rel, sf, member) if (!mode) { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`) + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } // Conclusive structural check: a trailing `next: () => …` parameter is a - // waterfall. (emit vs parallel is not structurally distinguishable, so - // it is trusted from the tag.) + // waterfall. (emit vs parallel vs serial is not structurally + // distinguishable, so it is trusted from the tag.) const last = member.parameters.at(-1) const hasNext = !!last && last.name.getText(sf) === 'next' if (hasNext && mode !== 'waterfall') { @@ -394,7 +394,7 @@ function render(events: EventEntry[], services: ServiceEntry[]): string { '', '## Events', '', - `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, + `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, '', ] const scopes = [...new Set(events.map(e => e.scope))].sort() From 6cac3e6476d8490d5e9842330b66048c72a2e552 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:20 +0800 Subject: [PATCH 05/28] fix(compact): reject threshold-equality config to keep compaction convergent (CBR-002) Codex round 1 CBR-002: `resolveConfig` rejected only `summarizationMaxTokens + retainTokens > threshold` (allowing equality), but `compactIfNeeded` declines only when the estimate is `< threshold`. At exact equality the post-compaction history sits at the threshold and re-triggers on the very next check. Make the bound strict (`>=` rejects), so post-compaction history is guaranteed strictly below the threshold. Updated the boundary test (the sum-equals-threshold case is now rejected, not accepted) and added an "accepts just below the threshold" case; nudged one unrelated config that incidentally sat at the equality boundary. --- packages/compact/compact-basic/src/types.ts | 28 ++++++++++--------- .../compact-basic/tests/compact-basic.spec.ts | 24 ++++++++++------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 7273150d8e..b7261eb093 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -21,7 +21,7 @@ export interface BasicCompactConfig { summarizationModel?: string /** Maximum tokens for the summarization response (default 2048). */ summarizationMaxTokens?: number - /** Enable automatic compaction on the `agent/request` waterfall (default true). */ + /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } @@ -42,29 +42,31 @@ export const DEFAULTS: ResolvedConfig = { * Apply defaults to a partial config and enforce the single-pass convergence * invariant. * - * `summarizationMaxTokens + retainTokens` must not exceed the compaction + * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that * after a compaction the derived history — the (bounded) summary plus the - * retained recent tail — is structurally BELOW the threshold, so the very next - * pre-request check passes and a second compaction cannot fire on the same - * content. Without it, a too-large summary budget or retain budget would leave - * the post-compaction history still over threshold, triggering compaction again - * and again. Pre-release we reject rather than clamp: a config that cannot - * guarantee convergence is a bug at the call site, not something to silently - * paper over. + * retained recent tail — is structurally below the threshold, so the very next + * pre-step check passes and a second compaction cannot fire on the same + * content. The bound is strict (`>=` rejects) because `compactIfNeeded` declines + * only when the estimate is `< threshold`: a post-compaction history sitting + * EXACTLY at the threshold would re-trigger on the next check. Without the + * invariant, a too-large summary or retain budget would leave the + * post-compaction history at/over threshold, triggering compaction again and + * again. Pre-release we reject rather than clamp: a config that cannot guarantee + * convergence is a bug at the call site, not something to silently paper over. * - * @throws if `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. + * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { const resolved = { ...DEFAULTS, ...config } const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens - if (postCompactionFloor > threshold) { + if (postCompactionFloor >= threshold) { throw new Error( `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` - + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} exceeds the compaction ` + + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} is not below the compaction ` + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` - + 'stay over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + + 'stay at/over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' + 'or raise contextWindow/thresholdRatio.', ) } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 4c91f11173..3a6d9a0360 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -530,13 +530,13 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(460*0.1) = 46. The 4 surface nodes weigh 10 each (raw 40 + // threshold = floor(470*0.1) = 47. The 4 surface nodes weigh 10 each (raw 40 // for the retention walk), but the derived estimate adds 4 role tokens per - // message → 56 ≥ 46, so the threshold check passes and the walk runs. The + // message → 56 ≥ 47, 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. The invariant holds: - // summarizationMaxTokens (1) + retainTokens (45) = 46 ≤ threshold 46. - const svc = createTestService({ contextWindow: 460, thresholdRatio: 0.1, retainTokens: 45 }) + // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. + const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() }) @@ -739,16 +739,24 @@ describe('BasicCompactService HMR safety', () => { describe('BasicCompactService convergence invariant (config)', () => { it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 > 500 → reject. + // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 is not below 500 → reject. expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, - })).toThrow(/exceeds the compaction threshold/) + })).toThrow(/not below the compaction threshold/) }) - it('accepts the boundary case (sum equals the threshold)', () => { - // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 ≤ 500 → allowed. + it('rejects the boundary case (sum equals the threshold — would re-trigger)', () => { + // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 is NOT below 500, so + // post-compaction history would sit exactly at threshold and re-compact. expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, + })).toThrow(/not below the compaction threshold/) + }) + + it('accepts the case just below the threshold', () => { + // threshold = floor(1000 * 0.5) = 500; 99 + 400 = 499 < 500 → allowed. + expect(() => new BasicCompactService(new Context(), { + auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 99, })).not.toThrow() }) From b13586ff8e0b31c45b414321fb8a6f9a7174d548 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 13:51:45 +0800 Subject: [PATCH 06/28] docs(compact): align seam docs with the pre-step seam and record session/invariants changes (CBR-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 1 CBR-003: several docs still described compaction as an `agent/request` waterfall concern, and the implemented compaction RFC claimed "No changes to dsh-session or dsh-invariants" while the diff changed both. - Package READMEs / JSDoc (agent, agent-loop, system-prompt, compact, compact-basic): compaction now lives on the serial `agent/pre-step` seam (fired after turn/start, before step/start); the structural guard is tool-pairing balance (`isToolPairingBalanced`), not step-alignment; the convergence bound is strict (`>=` rejects). - architecture.md / core-data-structures/compaction.md: same seam + predicate + dispatch-mode updates; regenerated cordis catalog. - Implemented compaction RFC, updated in place to describe shipped reality: the seam is `agent/pre-step` (@mode serial) fired before step/start; alignment is surface tool-pairing balance; the convergence invariant rejects `>=`; and the "no dsh-session/dsh-invariants changes" claim is corrected — dsh-session gains the tool-pairing predicate and dsh-invariants drops its `start <= end` replace assertion (a positional replace makes start > end normal). --- docs/architecture.md | 8 ++--- docs/core-data-structures/compaction.md | 4 +-- .../2026-06-18-compaction-capability-seam.md | 32 ++++++++++--------- packages/compact/compact-basic/README.md | 8 ++--- packages/compact/compact/README.md | 2 +- packages/core/agent-loop/README.md | 6 ++-- packages/core/agent/README.md | 9 +++--- packages/core/system-prompt/README.md | 2 +- 8 files changed, 38 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 344ea08e26..f5e5a52dce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,9 +133,9 @@ forever: drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start STEP loop: drain steering (late steering from previous step's listeners) - session('step/start'); emit agent/step-start assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble - await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) before derive + await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step + session('step/start'); emit agent/step-start req = {model, system, tools, messages: session.deriveMessages(), signal} req = waterfall agent/request ⟵ hooks, model switch stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) @@ -193,7 +193,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the awaited `agent/pre-request` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call (every step — runaway-turn survival), manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | +| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -221,6 +221,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. -- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the awaited `agent/pre-request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). +- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the serial `agent/pre-step` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). - **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index e637961784..d8a05dc8cf 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the awaited `agent/pre-request` loop seam (fired once per step, BEFORE the request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place, and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is step-alignment (a compacted region never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 56139cc750..f1ca9dad2f 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -32,28 +32,29 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text `compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). -### Auto-compaction runs on `agent/pre-request`, a dedicated surface-mutation seam +### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam -Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. +Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. -The fix is a new awaited loop seam, **`agent/pre-request`** (`@mode parallel`), fired by the loop *after* system assembly and *before* `deriveMessages()`: +The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): ``` assembly = ctx.systemPrompt.assemble() -await ctx.parallel('agent/pre-request', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +await ctx.serial('agent/pre-step', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +session('step/start') ⟵ the step opens AFTER the seam messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-request` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. The seam is `parallel` (awaited fan-out, no veto), like `session/flush`: a listener mutates the surface as a side effect; there is nothing to transform or return. +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order, no veto), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. -### Retention is turn-agnostic; step-alignment is the only structural guard +### Retention is turn-agnostic; tool-pairing balance is the only structural guard -Auto-compaction fires before **every** model call (every step), not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-request`. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. +Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. -So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands inside a step, it extends the retained side head-ward until the boundary is a step-aligned start. The single structural guard is therefore **step-alignment** — the compacted region always ends on a step boundary, so it never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). `compactRegion` enforces step-alignment strictly, throwing on a splitting boundary. +So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained node is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over the surface linked list, **not** the log's `step/*` markers: a compaction lands a replacement node at a high log seq whose surface position is the head, so a log-position scan mis-reads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -65,7 +66,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Single-pass convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -91,11 +92,11 @@ The landed `user/message` is not the raw summary: the backend wraps it in a chec The `compact/start … compact/end` bracket is justified, in order of what now does the work: 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 the awaited `pre-request`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across the awaited `pre-step`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) 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** — the surface replacement never landed, so the full, uncompacted history derives correctly. 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 can't wedge future compaction. Compaction simply re-attempts at the next `pre-request`. +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. 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 can't wedge future compaction. Compaction simply re-attempts at the next `pre-step`. - **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. `compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. @@ -105,14 +106,15 @@ Two failure paths, both documented: ## Consequences - **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. -- **New loop seam**: `agent/pre-request` (`@mode parallel`) declared in `dsh-agent` and emitted by `dsh-agent-loop` between system assembly and history derivation. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. -- **No changes** to `dsh-session` or `dsh-invariants`: the surface replace op, the surface-metadata runtime guard, and the turn-enclosure invariant all already exist and are reused. +- **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. +- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). ## Testing - **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. -- **Loop** (`dsh-agent-loop`): `agent/pre-request` fires once per step, before derive, awaited; a surface mutation in a `pre-request` listener is reflected in the single derived request. +- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request. - **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. - **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 848c0cd6ae..cc171f62b4 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,12 +9,12 @@ This is the implementation tier of the compaction capability — see the [interf The abstract contract states only WHAT compaction does; this backend owns every HOW decision: - **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). -- **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 **step-alignment**: the compacted region always ends on a step boundary, 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 step-alignment strictly, throwing on a boundary that would split a step. -- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. +- **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. +- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. - **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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-request` listener delegates to `compactIfNeeded()` before every model call (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-request` is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface 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()`). +- **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, no-veto) 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()`). - **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/*`. `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. @@ -28,7 +28,7 @@ The abstract contract states only WHAT compaction does; this backend owns every | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | | `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | -| `auto` | `true` | Register the `agent/pre-request` auto-compaction listener. Set `false` for manual-only. | +| `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index d75a5da774..8a95277c17 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,7 +18,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | +| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | | `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 4892b357bf..11873a56e4 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,6 +52,8 @@ forever: STEP loop: drain steering assembly = systemPrompt.assemble() + await serial agent/pre-step ⟵ surface mutation (compaction) outside the step + session('step/start') request = waterfall agent/request stream llm.stream(request) → session('assistant/chunk') message = waterfall agent/step-result @@ -73,8 +75,8 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears ### What is NOT here Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: -- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` -- Compaction: `agent/request` +- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` +- Compaction: `agent/pre-step` - Sandbox, permission, plan mode: `tools/execute` - Sub-agents: TODO seam on `AgentLoop.create()` - Persistence: `session/event` + `session/flush` diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index d0ec0ee614..537211889e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -37,11 +37,12 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) - `agent/step-start`, `agent/step-end` -#### Interception seams (waterfall) +#### Interception seams -- `agent/request` — mutate `GenerateOptions` before the model call (hooks, compaction, model switching, tool filtering) -- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records) -- `agent/turn-continuation` — override the continue/stop decision (force-continue /loop, force-stop budget guard) +- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step. +- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering) +- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records) +- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard) #### Streaming + tool (emit) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 6f14e40f88..1c18bdf1d6 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -34,4 +34,4 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` via decla ### What is NOT here - Any hardcoded prompt text — every section comes from plugins. -- Prompt compaction (belongs on the `agent/request` seam in `dsh-agent`). +- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). From 6de5e3a20a58fdd41475f197be6865ece6355ee7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 14:02:02 +0800 Subject: [PATCH 07/28] docs(compact): fix stale agent/pre-request comment in coding-agent cordis.yml (CBR-004) codex review round 2 (non-blocking) CBR-004: the example's compaction wiring comment still named the old `agent/pre-request` seam. Renamed to `agent/pre-step` to match the shipped seam. --- examples/coding-agent/cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index e3a3016dda..d239db3a7e 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -68,7 +68,7 @@ # Automatic context compaction: when the derived history approaches the model's # context window, summarize an older range into a checkpoint so a long-running # or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the -# agent-loop's `agent/pre-request` seam from the app above). +# agent-loop's `agent/pre-step` seam from the app above). - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: From f4ace256485baf5622a76ef06df9ed69267563be Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 14:40:20 +0800 Subject: [PATCH 08/28] fix(compact): correct _extractText surface-order JSDoc; add disposal to the HMR-safety suite (CBR-005, CBR-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual review round, two non-blocking findings: - CBR-005: _extractText's JSDoc claimed it "walks events in log order", but it walks the seqs in surface order (the inline comment already said so) — the exact distinction CBR-001 paid for, since after a replace a high-seq checkpoint heads the surface before lower-seq retained nodes. Corrected the JSDoc to match. - CBR-006: the "HMR safety" suite only asserted registration; the actual dispose-and-confirm-cleanup test lived under "llm inject", so a reader searching by name could miss it. Added a disposal test to the HMR-safety suite (mount via the real plugin fiber with LlmService present so inject resolves, dispose, assert ctx.get('compact') is undefined) and reframed the llm-inject test's trailing teardown to point at it. --- packages/compact/compact-basic/src/index.ts | 7 +++++-- .../compact-basic/tests/compact-basic.spec.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 7648a245b0..479553a17c 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -601,8 +601,11 @@ export class BasicCompactService extends CompactService { /** * Extract plain-text conversation from a set of surface node seqs, for - * feeding into the summarization model. Walks events in log order so the - * summary captures chronological flow. + * feeding into the summarization model. Walks the seqs in the order given + * (surface order, as `compactRegion` slices the surface-node list) so the + * summary follows the conversation as the model sees it — which, after a + * `replace`, is NOT ascending log-seq order (a high-seq summary node heads the + * surface before older retained lower-seq nodes). */ private _extractText(session: Session, seqs: number[]): string { const lines: string[] = [] diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3a6d9a0360..971aadb9dd 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -735,6 +735,21 @@ describe('BasicCompactService HMR safety', () => { expect(ctx.compact).toBeDefined() expect(ctx.compact).toBeInstanceOf(BasicCompactService) }) + + 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.) + const ctx = new Context() + await ctx.plugin(LlmService) + const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) + + await fiber.dispose() + expect(ctx.get('compact')).toBeUndefined() + }) }) describe('BasicCompactService convergence invariant (config)', () => { @@ -1343,7 +1358,8 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) - // HMR: disposing the fiber tears the service registration down. + // Tear the fiber down so this test owns no leaked registration; the + // dedicated cleanup assertion lives in the "HMR safety" suite. await fiber.dispose() expect(ctx.get('compact')).toBeUndefined() }) From 05c1bb628c14116b4decb47f562441b78d279f17 Mon Sep 17 00:00:00 2001 From: Ni Shentu <87308515+NI0317@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:18:15 +0800 Subject: [PATCH 09/28] Add GitHub Actions workflow to mirror to GitLab --- .github/workflows/mirror-to-gitlab.yml | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/mirror-to-gitlab.yml diff --git a/.github/workflows/mirror-to-gitlab.yml b/.github/workflows/mirror-to-gitlab.yml new file mode 100644 index 0000000000..671915ba49 --- /dev/null +++ b/.github/workflows/mirror-to-gitlab.yml @@ -0,0 +1,33 @@ +name: Mirror to GitLab + +on: + push: + branches: ['**'] + tags: ['**'] + delete: + workflow_dispatch: + +concurrency: + group: mirror-to-gitlab + cancel-in-progress: false + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GITLAB_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.com >> ~/.ssh/known_hosts + + - name: Push to GitLab + run: | + git remote add gitlab "${{ secrets.GITLAB_REPO_URL }}" + git push --mirror gitlab From c76b7042b6086e90c2d50ced83bf6a1fd7898257 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 26 Jun 2026 16:42:51 +0800 Subject: [PATCH 10/28] fix(compact): size the compaction e2e to actually cross threshold; sync stale docs (CBR-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compaction e2e never exercised compaction: its window/fixture combo (contextWindow 8000, thresholdRatio 0.5 → threshold 4000; four small files) peaked at ~1389 estimated tokens, so compactIfNeeded declined every pre-step and compact/start never landed. Shrink the window (contextWindow 2400 → threshold 1200; retainTokens 500 + summarizationMaxTokens 300 = 800 < 1200, convergence holds) and grow the fixture to six files so a couple of bash steps reliably cross the threshold. Verified compaction fires and the suite passes across repeated real-API runs. Sync docs left stale by the landed compaction work: list compaction.e2e.ts and keyless-smoke.e2e.ts in the coding-agent README (and fix the wrong "Both self-skip" count), add compaction to the examples with-key inventory, and replace the hypothetical compaction/marker / "future plugin" naming in the session README, session types JSDoc, and the core-data-structures catalog with the real compact/start, compact/summary, compact/end events. --- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session.md | 2 +- examples/AGENTS.md | 2 +- examples/coding-agent/README.md | 3 +- examples/coding-agent/tests/compaction.e2e.ts | 30 ++++++++++--------- packages/core/session/README.md | 6 ++-- packages/core/session/src/types.ts | 5 ++-- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6d20900c93..ee4f06e14a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -205,7 +205,7 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8f8ef4a800..890a3a0dee 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -55,7 +55,7 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 67ea68ae6f..2c2b2a44f1 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -20,7 +20,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P | Example | Keyless smoke | With-key smoke | |---|---|---| | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | -| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume}.e2e.ts` — real model + real bash, world-verified | +| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction}.e2e.ts` — real model + real bash, world-verified | | `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless; `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote | See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 7585129382..76fbb7a237 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -48,5 +48,6 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends and lo - `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. - `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. - `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. +- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. -Both self-skip without `DEEPSEEK_API_KEY`. +All four self-skip without `DEEPSEEK_API_KEY`. The keyless boot smoke is `tests/keyless-smoke.e2e.ts` (boots the full real tree with a dummy key and no prompt, so no model call), which runs in the default e2e gate. diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index fefcd579d0..2c9814c79d 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -33,21 +33,23 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => { it('summarizes older history into a checkpoint without breaking the task', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-')) - // A few files for the model to read, so multiple bash steps accumulate - // surface nodes (tool calls + results) and grow the history. - for (let i = 1; i <= 4; i++) { + // A handful of files for the model to read, so multiple bash steps + // accumulate surface nodes (tool calls + results) and grow the history past + // the (deliberately tiny) window. + for (let i = 1; i <= 6; i++) { await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) } - // Tiny window so a handful of steps crosses the threshold. The convergence - // invariant requires summarizationMaxTokens + retainTokens <= window * - // ratio = floor(8000 * 0.5) = 4000; 1500 + 2000 = 3500 <= 4000. + // Tiny window so a couple of steps crosses the threshold. The convergence + // invariant requires summarizationMaxTokens + retainTokens to be strictly + // BELOW the threshold = floor(contextWindow * thresholdRatio) = + // floor(2400 * 0.5) = 1200; 300 + 500 = 800 < 1200. ctx = await codingHarness(workdir, { compact: { - contextWindow: 8000, + contextWindow: 2400, thresholdRatio: 0.5, - retainTokens: 2000, - summarizationMaxTokens: 1500, + retainTokens: 500, + summarizationMaxTokens: 300, }, }) const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { @@ -57,9 +59,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa agent.send([{ type: 'text', - text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a time using cat ' - + '(a separate bash command for each). After reading all four, tell me how many ' - + 'files you read and the number mentioned in file1.txt.', + text: 'Read file1.txt, file2.txt, file3.txt, file4.txt, file5.txt, and file6.txt one at a ' + + 'time using cat (a separate bash command for each). After reading all six, tell me how ' + + 'many files you read and the number mentioned in file1.txt.', }]) await waitForIdle(ctx, agent) @@ -87,9 +89,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0) // The conversation survived compaction: the agent produced a final answer - // that reflects the work (it read four files). + // that reflects the work (it read six files). const answer = finalText(events).toLowerCase() expect(answer.length).toBeGreaterThan(0) - expect(answer).toMatch(/\b(4|four)\b/) + expect(answer).toMatch(/\b(6|six)\b/) }, 240_000) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 2a90d0f792..cde29d091a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -51,13 +51,13 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. -Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc. +Merge-extensible via `SessionEventMap` — the compaction plugin (`dsh-compact-basic`) adds `compact/start`, `compact/summary`, `compact/end`, etc. Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). Every `SessionEvent` carries two optional top-level fields (structural metadata): -- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker). +- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node). - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). ### Metadata types (`types.ts`) @@ -68,7 +68,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. -- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes. +- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 41f878892f..2a32c7b3f0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -156,7 +156,8 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap] * same events; trace/telemetry = subscribe to the log. * * Merge-extensible: plugins declare extra event types via declaration merging - * (e.g. a compaction plugin adds `'compaction/marker'`). + * (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`, + * `'compact/end'`). * * Durability contract (what a persistence backend relies on): the durable log * persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay @@ -279,7 +280,7 @@ export type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction marker). + * or the surface nodes shadowed by a compaction replace node). */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ From 8d1f86fcca71aa2a89d33536eb93adffaf6cc6b6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:53:23 +0800 Subject: [PATCH 11/28] empty From f05717e1aca336011b2b3baee7cf760dfa87863f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:47:29 -0700 Subject: [PATCH 12/28] docs: address terminology review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarity-first revisions from review: - keep English for ambiguous terms (Cordis, fork, harness, schema, spawn, manifest, transcript, compaction, dispose) - first-use glosses for CLI, Function Calling, HMR, fiber - inference/reasoning carry the English in parens to disambiguate - memory distinguishes 记忆 (agent memory) vs 内存 (resource usage) - plugin -> 插件; add a separate mod -> 模组 row - 中英搭配 note on translated terms (wire format, adapter contract) --- docs/i18n/terminology.md | 53 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index a0b145117a..6031f3f70a 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -1,14 +1,16 @@ # Terminology +本表约定本仓库的中英术语统一译法。 + | English | 中文 | 备注 | |---|---|---| | ACP | ACP | | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | -| CLI | CLI | | -| Cordis | Cordis | | -| Function Calling | Function Calling | | -| HMR | HMR | | +| CLI | CLI | 首次出现可写:命令行界面(CLI) | +| Cordis | Cordis | 保留英文 | +| Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) | +| HMR | HMR | 首次出现可写:热模块替换(HMR) | | JSON Schema | JSON Schema | | | JSONL | JSONL | | | lint | lint | | @@ -18,24 +20,24 @@ | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | | -| agent | agent | 首次出现可写:agent(智能体);不要译作:代理 | +| agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | -| fiber | fiber | | -| fixture | fixture | 首次出现可写:fixture(测试样例) | -| fork | fork | 首次出现可写:fork(派生) | -| harness | harness | 不要译作:测试框架、脚手架 | -| manifest | 清单 | 指文件名或字段名时保留 `manifest` | +| fiber | fiber | 首次出现可写:fiber(插件运行时) | +| fixture | fixture | 首次出现可写:fixture(测试夹具);指测试前置数据或环境 | +| fork | fork | 保留英文 | +| harness | harness | 保留英文 | +| manifest | manifest | 首次出现可写:manifest(描述模块或工具元数据的文件) | | schema DSL | schema DSL | | -| schema | schema | API/类型名保留 `schema`;一般 prose 可译为“模式” | -| seam | seam | 首次出现可写:seam(扩展点);不要译作:接缝 | +| schema | schema | 保留英文 | +| seam | seam | 首次出现可写:seam(扩展点) | | skill | skill | 首次出现可写:skill(技能) | -| spawn | spawn | 首次出现可写:spawn(新建) | +| spawn | spawn | 保留英文 | | steering | steering | 首次出现可写:steering(中途引导) | -| subagent | subagent | 首次出现可写:subagent(子 agent);不要译作:子代理 | -| transcript | 交互记录 | | -| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件);不要译作:瀑布流 | -| wire format | 协议格式 | | -| adapter contract | 适配器契约 | | +| subagent | subagent | 首次出现可写:subagent(子 agent) | +| transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | +| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) | +| wire format | 协议格式 | 首次出现可写:协议格式(wire format) | +| adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) | | adapter | 适配器 | | | append-only | 仅追加 | | | artifact | 产物 | | @@ -46,15 +48,15 @@ | cancel | 取消 | | | checkpoint | 检查点 | | | chunk | 分片 | | -| compaction | 压缩 | | +| compaction | compaction | 首次出现可写:compaction(上下文压缩);正文优先保留英文 | | consumer | 消费方 | | | content block | 内容块 | | | config | 配置 | | | context | 上下文 | | -| context compaction | 上下文压缩 | | +| context compaction | 上下文压缩 | 首次出现可写:上下文压缩(context compaction) | | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | -| dispose | 释放 | | +| dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | | durability | 持久性 | | | event log | 事件日志 | | | event | 事件 | | @@ -65,24 +67,25 @@ | foreground run | 前台运行 | | | hook | 钩子 | | | implementation | 实现 | | -| inference | 推理 | | +| inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 | | injection | 注入 | | | interface | 接口 | | | integration | 集成 | | -| memory | 记忆 | 指 agent memory;不要译作:内存 | +| memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | +| mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | -| plugin | 模组 | 不要译作:插件 | +| plugin | 插件 | mod 对应“模组” | | prompt | 提示词 | | | provider | 提供方 | | | provider-neutral | 提供方无关 | | | quality gate | 质量门禁 | | | registry | 注册表 | | -| reasoning | 推理 | `reasoning_content` 译为“思考内容” | +| reasoning | 推理(reasoning) | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” | | replay | 回放 | | | resume | 恢复 | | | runtime | 运行时 | | From 1f35a4446d28dbd67030ade4bd26d65443956a47 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 15:59:52 +0800 Subject: [PATCH 13/28] fix(compact): address PR 110 review findings Honor cancellation and disposal around async pre-step setup before the loop can open a step or call the model. Route compaction summarization through agent/request so router agents can select the model, and remove the stale model argument from agent/pre-step. Document serial events and the approximate convergence bound, regenerate the Cordis catalog, and add regression coverage for router compaction, HMR cleanup, and assembly/pre-step interruption. --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 24 +- docs/core-data-structures/compaction.md | 4 +- docs/core-data-structures/core.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 8 +- .../2026-06-20-generated-cordis-catalog.md | 2 +- examples/coding-agent/tests/compaction.e2e.ts | 7 +- packages/compact/compact-basic/README.md | 4 +- packages/compact/compact-basic/src/index.ts | 60 ++-- packages/compact/compact-basic/src/types.ts | 23 +- .../compact-basic/tests/compact-basic.spec.ts | 192 ++++++++----- packages/compact/compact/README.md | 6 +- packages/compact/compact/src/index.ts | 28 +- .../compact/compact/tests/compact.spec.ts | 27 +- packages/core/agent-loop/src/loop.ts | 58 ++-- packages/core/agent-loop/tests/loop.spec.ts | 18 +- .../agent-loop/tests/review-fixes.spec.ts | 270 ++++++++++++++++++ packages/core/agent/src/types.ts | 9 +- 18 files changed, 551 insertions(+), 193 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e08dd02e5f..f02940b5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,7 +236,7 @@ In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/c Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order with no veto (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 5ace95c782..3823cf8144 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -49,21 +49,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `system`/`model` are the assembled values a listener needs to measure pressure (system counts toward the budget) and to summarize (the model). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). ```ts cordis-catalog -'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void +'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -387,11 +387,11 @@ Implementations MUST honor: - **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. ```ts cordis-catalog -abstract compactIfNeeded( session: Session, system: string, model: string, signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, model: string, signal?: AbortSignal, ): Promise +abstract compactIfNeeded( agent: CompactAgentContext, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal, ): Promise +abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, turn: number, step: number, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:57`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index d8a05dc8cf..a1ca8978a6 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(session, system, model, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, model, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, turn, step, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the single-pass convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the approximate convergence invariant, and the crash/recoverable failure taxonomy. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ee4f06e14a..0b9c8452e3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -306,7 +306,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). ## `ToolDefinition` diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index f1ca9dad2f..ba6f6c6ae6 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. -`compactIfNeeded(session, system, model, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies all four — the assembled system prompt (counted toward the estimate), the model (summarization fallback), and the turn's abort signal — so optionality would only invite a hidden default at the seam. `compactRegion(session, start, end, model, signal?)` keeps an optional signal (a manual caller may omit it). +`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies the agent, lifecycle context, assembled system prompt (counted toward the estimate), and the turn's abort signal, so optionality would only invite a hidden default at the seam. The session being compacted comes from the agent context. `compactRegion(session, start, end, agent, turn, step, signal?)` keeps an optional signal (a manual caller may omit it). Passing lifecycle context rather than a concrete model keeps router agents honest: the backend's summarization request can run through `agent/request`, where model-routing plugins already choose the actual model. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam @@ -40,7 +40,7 @@ The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired b ``` assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, model, signal) ⟵ compaction mutates the surface here +await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here session('step/start') ⟵ the step opens AFTER the seam messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) @@ -64,9 +64,9 @@ A runaway turn thus compacts exactly like any other history: its early *closed* `compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) -### Single-pass convergence invariant +### Approximate convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history — the bounded summary plus the retained recent tail — is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction, with no thrash throttle needed. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks convergence. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot guarantee convergence is a bug at the call site, not something to silently clamp. +`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant bounds the two variable parts of post-compaction history — the bounded summary plus the retained recent tail — but it is intentionally approximate: checkpoint framing, per-message role overhead, system-prompt size, and the char/4 estimator's error can still leave a narrow accepted config near the threshold. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks the structural budget. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot satisfy the structural bound is a bug at the call site, not something to silently clamp. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index 2801ae209c..ae07f4b14c 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -20,7 +20,7 @@ Pure generation is correct here because the codebase is disciplined enough that Specific choices: -- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). +- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel|serial` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit/parallel/serial distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`, as does the ordered `agent/pre-step` checkpoint), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). - **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. - **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. - **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 2c9814c79d..17186055b1 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -43,14 +43,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Tiny window so a couple of steps crosses the threshold. The convergence // invariant requires summarizationMaxTokens + retainTokens to be strictly // BELOW the threshold = floor(contextWindow * thresholdRatio) = - // floor(2400 * 0.5) = 1200; 300 + 500 = 800 < 1200. + // floor(2400 * 0.5) = 1200; 600 + 500 = 1100 < 1200. The summary cap + // stays high enough for the live model to emit the required checkpoint + // sections; a truncated checkpoint fails closed and leaves no summary. ctx = await codingHarness(workdir, { compact: { contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, - summarizationMaxTokens: 300, + summarizationMaxTokens: 600, }, + persistenceRoot: './.sessions', }) const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash', diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index cc171f62b4..2b13666033 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization. +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline. This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -11,7 +11,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). - **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. - **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. -- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) 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 compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. +- **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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, no-veto) 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()`). diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 479553a17c..e474be7cd9 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,7 +10,7 @@ * compaction declines and retries once it closes). * - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler` * (the single model-call surface; same path the loop uses) with a fixed - * condense-the-history system prompt. + * condense-the-history system prompt routed through `agent/request`. * - **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 @@ -153,12 +153,6 @@ function finishError(finish: FinishReason): Error | undefined { * context. */ export class BasicCompactService extends CompactService { - /** - * `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis - * context proxy resolve it when this service loads as a sibling of LlmService: - * without the inject, `this.ctx.llm` cannot be resolved from this fiber and - * compaction throws at runtime (see postmortem 0001). - */ static inject = ['llm'] /** Resolved configuration (defaults applied). */ @@ -188,11 +182,11 @@ export class BasicCompactService extends CompactService { // 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. - ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => { + ctx.on('agent/pre-step', async (agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal) => { try { - const result = await this.compactIfNeeded(agent.session, system, model, signal) + const result = await this.compactIfNeeded(agent, turn, step, fullSystemPrompt, signal) if (result) { - const after = this.estimateTokens(agent.session.deriveMessages(), system) + const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt) ctx.logger.info( `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + @@ -274,8 +268,9 @@ export class BasicCompactService extends CompactService { } /** - * Summarize conversation text into content blocks via `ctx.llm.stream()` - * assembled through a `BlockAssembler` (the single model-call surface). + * Summarize conversation text into content blocks via `agent/request` plus + * `ctx.llm.stream()` assembled through a `BlockAssembler` (the single + * model-call surface). * Override in a subclass for a template or remote summarizer. * * Honors the adapter failure contract: an adapter may report a model failure @@ -286,12 +281,10 @@ export class BasicCompactService extends CompactService { * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears * down the in-flight summarization rather than orphaning the model call. */ - async summarize(text: string, model: string, signal?: AbortSignal): Promise { - if (!model) throw new Error('no model available for summarization') - + async summarize(text: string, agent: Agent, turn: number, step: number, signal?: AbortSignal): Promise { const assembler = new BlockAssembler() const options: GenerateOptions = { - model, + model: this.config.summarizationModel || agent.options.model || '', messages: [{ role: 'user', content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], @@ -302,7 +295,11 @@ export class BasicCompactService extends CompactService { // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. if (signal) options.signal = signal - for await (const chunk of this.ctx.llm.stream(options)) { + const request = await this.ctx.waterfall('agent/request', agent, turn, step, options, () => Promise.resolve(options)) + if (!request.model) { + throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel, AgentOptions.model, or supply one via the agent/request waterfall') + } + for await (const chunk of this.ctx.llm.stream(request)) { assembler.push(chunk) } @@ -341,13 +338,15 @@ export class BasicCompactService extends CompactService { * closes). */ override async compactIfNeeded( - session: Session, - system: string, - model: string, + agent: Agent, + turn: number, + step: number, + fullSystemPrompt: string, signal: AbortSignal, ): Promise { + const session = agent.session const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, system) + const totalTokens = this.estimateTokens(messages, fullSystemPrompt) const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) if (totalTokens < threshold) return null @@ -401,14 +400,16 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const cutoffSeq = nodes[keepFromIdx - 1]!.seq - return this.compactRegion(session, firstSeq, cutoffSeq, model, signal) + return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal) } override async compactRegion( session: Session, start: number, end: number, - model: string, + agent: Agent, + turn: number, + step: number, signal?: AbortSignal, ): Promise { // Resolve the range by surface POSITION, not numeric seq interval. A prior @@ -458,8 +459,8 @@ export class BasicCompactService extends CompactService { // 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. - const turn = this._openTurn(session) - if (turn === null) { + const openTurn = this._openTurn(session) + if (openTurn === null) { throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn') } // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the @@ -467,13 +468,12 @@ export class BasicCompactService extends CompactService { const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq) // --- Acquire lock --- - const startEvent = session.append('compact/start', { turn }) + const startEvent = session.append('compact/start', { turn: openTurn }) try { // --- Extract text and summarize --- const text = this._extractText(session, shadowedSeqs) - const summaryModel = this.config.summarizationModel || model - const summary = await this.summarize(text, summaryModel, signal) + const summary = await this.summarize(text, agent, turn, step, signal) // Estimate token count of the shadowed content for provenance. let shadowedTokenCount = 0 @@ -511,7 +511,7 @@ export class BasicCompactService extends CompactService { // compact/start and here leaves a detectable orphaned lock (a compact/start // with no matching compact/end) rather than a compact/end that falsely // claims compaction finished before the surface replacement landed. - const endEvent = session.append('compact/end', { turn }) + const endEvent = session.append('compact/end', { turn: openTurn }) return { startSeq: startEvent.seq, @@ -526,7 +526,7 @@ export class BasicCompactService extends CompactService { // Always release the lock — append compact/end with the error so a // wedged lock is impossible. const msg = error instanceof Error ? error.message : String(error) - session.append('compact/end', { turn, error: msg }) + session.append('compact/end', { turn: openTurn, error: msg }) throw error } } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index b7261eb093..13365b7ed1 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -39,21 +39,20 @@ export const DEFAULTS: ResolvedConfig = { } /** - * Apply defaults to a partial config and enforce the single-pass convergence + * Apply defaults to a partial config and enforce the approximate convergence * invariant. * * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction - * threshold (`contextWindow * thresholdRatio`). The invariant guarantees that - * after a compaction the derived history — the (bounded) summary plus the - * retained recent tail — is structurally below the threshold, so the very next - * pre-step check passes and a second compaction cannot fire on the same - * content. The bound is strict (`>=` rejects) because `compactIfNeeded` declines - * only when the estimate is `< threshold`: a post-compaction history sitting - * EXACTLY at the threshold would re-trigger on the next check. Without the - * invariant, a too-large summary or retain budget would leave the - * post-compaction history at/over threshold, triggering compaction again and - * again. Pre-release we reject rather than clamp: a config that cannot guarantee - * convergence is a bug at the call site, not something to silently paper over. + * threshold (`contextWindow * thresholdRatio`). The invariant bounds the two + * variable pieces of post-compaction history — the summary and the retained + * recent tail — but it is intentionally approximate: checkpoint framing, + * per-message role overhead, system-prompt size, and the char/4 estimator's + * error can still leave a narrow accepted config near the threshold. The bound + * is strict (`>=` rejects) because `compactIfNeeded` declines only when the + * estimate is `< threshold`: a post-compaction history sitting EXACTLY at the + * threshold would re-trigger on the next check. Pre-release we reject rather + * than clamp: a config that cannot satisfy even this structural bound is a bug + * at the call site, not something to silently paper over. * * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 971aadb9dd..fbbb8258c5 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -29,7 +29,8 @@ class TestCompactService extends BasicCompactService { return blocks.length * 10 } - override async summarize(text: string, model: string): Promise { + override async summarize(text: string, agent: Agent): Promise { + const model = this.config.summarizationModel || agent.options.model || '' this.summarizeCalls.push({ text, model }) if (this.summarizeError) throw this.summarizeError return this.mockSummary @@ -184,7 +185,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) // No dangling tool-result: every compacted/retained step stayed whole. @@ -214,7 +215,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // Turn stays open. const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) - const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(result).toBeNull() expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -227,7 +228,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const resultSeq = nodes[2]!.seq // start = the tool/result: its issuing assistant precedes it IN THE SAME STEP, // so starting here would orphan that assistant's tool-call. end is fine (user). - await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm')) + await expect(compactRegion(svc, session, resultSeq, resultSeq, 'm')) .rejects.toThrow(/start seq .* is not a balanced boundary/) expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected }) @@ -240,7 +241,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const asstSeq = nodes[1]!.seq // end = the assistant/message: its tool/result follows IN THE SAME STEP, so // ending here would strand that result. start is fine (the pre-step user). - await expect(svc.compactRegion(session, userSeq, asstSeq, 'm')) + await expect(compactRegion(svc, session, userSeq, asstSeq, 'm')) .rejects.toThrow(/end seq .* is not a balanced boundary/) }) @@ -257,7 +258,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const nodes = s.surface.nodes // [user, asst] const userSeq = nodes[0]!.seq const asstSeq = nodes[1]!.seq - await expect(svc.compactRegion(s, userSeq, asstSeq, 'm')) + await expect(compactRegion(svc, s, userSeq, asstSeq, 'm')) .rejects.toThrow(/end seq .* is not a balanced boundary/) }) @@ -267,7 +268,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2] const startSeq = nodes[0]!.seq // pre-step user1 (free boundary) const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step - const result = await svc.compactRegion(session, startSeq, endSeq, 'm') + const result = await compactRegion(svc, session, startSeq, endSeq, 'm') expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq }) expectNoOrphanToolResults(session.deriveMessages()) }) @@ -277,7 +278,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai const session = toolTurnSession(1) const nodes = session.surface.nodes const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways - const result = await svc.compactRegion(session, userSeq, userSeq, 'm') + const result = await compactRegion(svc, session, userSeq, userSeq, 'm') expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq }) }) @@ -292,7 +293,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes const ctxSeq = nodes[0]!.seq - const result = await svc.compactRegion(s, ctxSeq, ctxSeq, 'm') + const result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm') expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq }) }) }) @@ -352,7 +353,7 @@ describe('BasicCompactService.compactRegion', () => { const firstSeq = nodes[0]!.seq const secondSeq = nodes[1]!.seq - const result = await svc.compactRegion(session, firstSeq, secondSeq, 'test-model') + const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model') expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) expect(result.shadowedRange.start).toBe(firstSeq) @@ -405,7 +406,7 @@ describe('BasicCompactService.compactRegion', () => { it('throws when start or end are not surface nodes', async () => { const svc = createTestService() const session = multiTurnSession(1, 1) - await expect(svc.compactRegion(session, 999, 1000, 'm')) + await expect(compactRegion(svc, session, 999, 1000, 'm')) .rejects.toThrow(/start seq 999 not found in surface/) }) @@ -413,7 +414,7 @@ describe('BasicCompactService.compactRegion', () => { const svc = createTestService() const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[1]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[1]!.seq, nodes[0]!.seq, 'm')) .rejects.toThrow(/is after end seq .* on the surface/) }) @@ -422,7 +423,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes session.append('compact/start', { turn: 2 }) - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -432,7 +433,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow('model unavailable') const endEvent = session.events.findLast(e => e.type === 'compact/end') @@ -455,7 +456,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(1, 2) const nodes = session.surface.nodes - await svc.compactRegion(session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') expect(svc.summarizeCalls.length).toBe(1) const { text, model } = svc.summarizeCalls[0]! @@ -470,7 +471,7 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') // Provenance (compact/summary) carries the RAW, unframed summary. expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) @@ -492,7 +493,7 @@ describe('BasicCompactService.compactRegion', () => { const firstSeq = nodes[0]!.seq const lastSeq = nodes[nodes.length - 1]!.seq - await svc.compactRegion(session, firstSeq, lastSeq, 'm') + await compactRegion(svc, session, firstSeq, lastSeq, 'm') expect(svc.summarizeCalls.length).toBe(1) const { text } = svc.summarizeCalls[0]! @@ -506,14 +507,14 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null when tokens are under threshold', async () => { const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 }) const session = multiTurnSession(1, 1) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts when tokens exceed threshold', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60 - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) @@ -522,7 +523,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens - const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) expect(result).not.toBeNull() const nodes = session.surface.nodes expect(result!.shadowedSeqs.length).toBeGreaterThan(0) @@ -538,7 +539,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => { @@ -572,7 +573,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const nodesBefore = s.surface.nodes.length expect(nodesBefore).toBe(11) - const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(result).not.toBeNull() // Early steps of the SAME open turn were shadowed (impossible under layer 2). expect(result!.shadowedSeqs.length).toBeGreaterThan(0) @@ -587,7 +588,7 @@ describe('BasicCompactService.compactIfNeeded', () => { it('returns null for an empty surface', async () => { const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = new Session(SessionId('empty')) - expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => { @@ -600,7 +601,7 @@ describe('BasicCompactService.compactIfNeeded', () => { const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet) - const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(first).not.toBeNull() // The summary node now heads the surface with a fresh high seq. const summaryHeadSeq = s.surface.nodes[0]!.seq @@ -615,7 +616,7 @@ describe('BasicCompactService.compactIfNeeded', () => { s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' }) s.append('step/end', { turn: 5, step: 1 }) - const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL) + const second = await compactIfNeeded(svc, s, '', 'm', SIGNAL) expect(second).not.toBeNull() expect(second!.shadowedSeqs.length).toBeGreaterThan(0) // The fresh open-turn nodes were NOT compacted. @@ -630,7 +631,7 @@ describe('BasicCompactService replay equivalence', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') const derived = session.deriveMessages() const replayed = new Session(SessionId('replay'), [...session.events]) @@ -646,7 +647,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes // Whole step (user → assistant) is a step-aligned region, so the call reaches // the in-progress check rather than being rejected for splitting a step. - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/compaction already in progress/) }) @@ -656,7 +657,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = session.surface.nodes session.append('compact/start', { turn: 1 }) session.append('compact/end', { turn: 1 }) - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm') expect(result).toBeDefined() }) @@ -679,7 +680,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { const nodes = s.surface.nodes // The stale start is before the turn/end, so it is NOT seen as in-progress. - const result = await svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm') + const result = await compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm') expect(result).toBeDefined() }) }) @@ -829,12 +830,37 @@ function stubAgent(session: Session, model?: string): Agent { return { session, options: { model } } as unknown as Agent } +function compactIfNeeded( + svc: BasicCompactService, + session: Session, + fullSystemPrompt: string, + model: string, + signal: AbortSignal, +) { + return svc.compactIfNeeded(stubAgent(session, model), 1, 1, fullSystemPrompt, signal) +} + +function compactRegion( + svc: BasicCompactService, + session: Session, + start: number, + end: number, + model: string, + signal?: AbortSignal, +) { + return svc.compactRegion(session, start, end, stubAgent(session, model), 1, 1, signal) +} + +function summarize(svc: BasicCompactService, text: string, model: string) { + return svc.summarize(text, stubAgent(new Session(SessionId('summary')), model), 1, 1) +} + describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) - const summary = await svc.summarize('User: hi\n\nAssistant: hello', 'test-model') + const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) // The fixed system prompt and maxTokens flow through. expect(adapter.lastOptions!.system).toContain('compaction engine') @@ -846,19 +872,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', '')).rejects.toThrow(/no model available/) + await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) }) it('rethrows when the stream ends with a finish-error chunk', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) }) it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) const svc = new BasicCompactService(ctx, { auto: false }) - const error = await svc.summarize('text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) + const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) expect(error?.message).toBe('opaque failure') expect(error?.code).toBeUndefined() }) @@ -866,13 +892,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('rethrows when the stream ends with a finish-aborted chunk', async () => { const ctx = await ctxWithFinish({ kind: 'aborted' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) }) it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) + await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) }) it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { @@ -882,7 +908,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const before = [...session.surface.nodes] const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')) .rejects.toMatchObject({ code: 'MAX_TOKENS' }) // No replacement landed — the surface is byte-identical, and the lock was @@ -899,7 +925,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) @@ -908,8 +934,8 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { /** Fire the agent/pre-step serial checkpoint as the loop does. */ - function firePreStep(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise { - return ctx.serial('agent/pre-step', agent, 1, step, system, model, SIGNAL) + function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise { + return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL) } it('compacts (mutating the surface) when over threshold', async () => { @@ -919,7 +945,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') // The surface shrank in place, and a summary checkpoint landed. expect(session.surface.nodes.length).toBeLessThan(before) @@ -936,7 +962,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // A step-2 checkpoint (a tool-heavy turn's later step) must still compact — // the surface accumulated assistant/message + tool/result nodes since step 1. - await firePreStep(ctx, agent, 2, '', 'test-model') + await firePreStep(ctx, agent, 2, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(true) }) @@ -946,7 +972,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -960,7 +986,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length - await firePreStep(ctx, agent, 1, '', 'missing-model') + await firePreStep(ctx, agent, 1, '') // No summary landed; the surface is unchanged. expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) expect(session.surface.nodes.length).toBe(before) @@ -972,9 +998,44 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') - await firePreStep(ctx, agent, 1, '', 'test-model') + await firePreStep(ctx, agent, 1, '') expect(session.events.some(e => e.type === 'compact/start')).toBe(false) }) + + it('routes summarization through agent/request so router agents can choose the model', async () => { + const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model') + ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { + options.model = 'routed-model' + return next() + }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + const session = multiTurnSession(5, 1) + const agent = stubAgent(session) + + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) + + expect(adapter.lastOptions?.model).toBe('routed-model') + expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) + expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'ROUTED SUMMARY' }) + }) + + it('removes the auto pre-step listener when the plugin fiber is disposed', async () => { + const { ctx } = await ctxWithModel('SUMMARY') + const fiber = await ctx.plugin(BasicCompactService, { + contextWindow: 200, + thresholdRatio: 0.5, + retainTokens: 20, + summarizationMaxTokens: 50, + }) + const session = multiTurnSession(5, 1) + const agent = stubAgent(session, 'test-model') + + await fiber.dispose() + await firePreStep(ctx, agent, 1, '') + + expect(session.events.some(e => e.type === 'compact/start')).toBe(false) + expect(ctx.get('compact')).toBeUndefined() + }) }) describe('BasicCompactService._extractText branches', () => { @@ -1001,7 +1062,7 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! expect(text).toContain('[Context: project context here]') @@ -1030,7 +1091,7 @@ describe('BasicCompactService._extractText branches', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure') }) }) @@ -1064,7 +1125,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! expect(text).toContain('[tool-result: [image]]') // nested tool-result with content expect(text).toContain('[custom-widget]') // unknown block placeholder @@ -1089,7 +1150,7 @@ describe('BasicCompactService edge cases', () => { const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') - await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) @@ -1111,7 +1172,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm')) .rejects.toThrow(/no open turn/) // The lock was never acquired — no compact/start landed. expect(s.events.some(e => e.type === 'compact/start')).toBe(false) @@ -1126,7 +1187,7 @@ describe('BasicCompactService edge cases', () => { s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const nodes = s.surface.nodes - await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm')) + await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[0]!.seq, 'm')) .rejects.toThrow(/no open turn/) expect(s.events.some(e => e.type === 'compact/start')).toBe(false) }) @@ -1136,14 +1197,14 @@ describe('BasicCompactService edge cases', () => { const session = new Session(SessionId('empty-but-pressured')) // No surface nodes, but a large system prompt pushes the estimate over threshold. const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100 - expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull() + expect(await compactIfNeeded(svc, session, bigPrompt, 'm', SIGNAL)).toBeNull() }) it('compactRegion throws when end is not a surface node (start valid)', async () => { const svc = createTestService() const session = multiTurnSession(1, 1) const nodes = session.surface.nodes - await expect(svc.compactRegion(session, nodes[0]!.seq, 9999, 'm')) + await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm')) .rejects.toThrow(/end seq 9999 not found in surface/) }) @@ -1155,7 +1216,7 @@ describe('BasicCompactService edge cases', () => { const nodes = session.surface.nodes // Whole step (user → assistant): a step-aligned region that reaches summarize. - await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure') const endEvent = session.events.findLast(e => e.type === 'compact/end')! expect(endEvent.data).toMatchObject({ error: 'plain string failure' }) }) @@ -1170,7 +1231,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length - await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL) // The failure was swallowed; the surface is untouched and a warning logged. expect(session.surface.nodes.length).toBe(before) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) @@ -1187,7 +1248,7 @@ describe('BasicCompactService edge cases', () => { const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 - await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, 'test-model', SIGNAL) + await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL) expect(session.events.some(e => e.type === 'compact/start')).toBe(false) expect(svc.summarizeCalls.length).toBe(0) }) @@ -1221,7 +1282,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + 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 @@ -1256,7 +1317,7 @@ describe('BasicCompactService edge cases', () => { s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const nodes = s.surface.nodes - await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') + await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! // Every non-text block surfaces as a placeholder rather than being dropped. expect(text).toContain('User: [image]') @@ -1280,7 +1341,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction: shadow the two oldest surface nodes. const nodes0 = session.surface.nodes - const first = await svc.compactRegion(session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') + 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 @@ -1298,7 +1359,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const startSeq = nodes1[0]!.seq const endSeq = nodes1[2]!.seq expect(startSeq).toBeGreaterThan(endSeq) - const second = await svc.compactRegion(session, startSeq, endSeq, 'm') + 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. @@ -1316,14 +1377,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction shadows the oldest two surface nodes, landing a high-seq // summary node at the head. const n0 = session.surface.nodes - await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'm') + 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 svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'm') + 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(). @@ -1355,7 +1416,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // Tear the fiber down so this test owns no leaked registration; the @@ -1403,7 +1464,7 @@ describe('BasicCompactService under the real invariants plugin', () => { const nodes = session.surface.nodes // No invariant throws here: compact/* + the replacement are all in turn 3. - const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model') expect(result.shadowedSeqs.length).toBe(2) expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq) }) @@ -1416,15 +1477,14 @@ describe('BasicCompactService under the real invariants plugin', () => { session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) const n0 = session.surface.nodes - await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'test-model') + await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model') // Surface head now carries a higher seq than the older retained nodes. A // second compaction spanning [head … a later closed-step end] must pass the // invariants' positional replace check even though startSeq > endSeq. const n1 = session.surface.nodes expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq) - const second = await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'test-model') + const second = await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'test-model') expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq]) }) }) - diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 8a95277c17..3cc3ba0333 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| -| `compactIfNeeded(session, system, model, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. | -| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`; router-aware summarizers can use the agent lifecycle context to route their own model call through `agent/request`. | +| `compactRegion(session, start, end, agent, turn, step, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | -`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it. +`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. ## Surface contract diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index c84c147ca7..3001783d7d 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -27,6 +27,12 @@ import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' +/** Minimal agent context compaction needs without depending on the agent package. */ +export interface CompactAgentContext { + session: Session + options: { model?: string } +} + declare module 'cordis' { interface Context { compact: CompactService @@ -84,9 +90,10 @@ export abstract class CompactService extends Service { * exceeds the budget, compaction cannot help and the call may go out * over-budget. Bounding an individual unit's size is a separate concern. * - * @param session - the session whose surface may be compacted. - * @param system - the assembled system prompt, counted toward the estimate. - * @param model - the summarization model (a backend may override via config). + * @param agent - agent context owning the session surface and model options. + * @param turn - turn number of the pre-step checkpoint. + * @param step - step number about to start. + * @param fullSystemPrompt - assembled system prompt, 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 @@ -94,9 +101,10 @@ export abstract class CompactService extends Service { * @returns the compaction result, or `null` if no compaction was needed. */ abstract compactIfNeeded( - session: Session, - system: string, - model: string, + agent: CompactAgentContext, + turn: number, + step: number, + fullSystemPrompt: string, signal: AbortSignal, ): Promise @@ -120,7 +128,9 @@ export abstract class CompactService extends Service { * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. * @param end - inclusive seq of the last surface node to compact. - * @param model - summarization model. + * @param agent - agent context used by router-aware summarizers. + * @param turn - lifecycle turn forwarded to request-routing seams. + * @param step - lifecycle step forwarded to request-routing seams. * @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 @@ -136,7 +146,9 @@ export abstract class CompactService extends Service { session: Session, start: number, end: number, - model: string, + agent: CompactAgentContext, + turn: number, + step: number, signal?: AbortSignal, ): Promise } diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index b3ad9d1501..5b9e033fcc 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { CompactAgentContext } from '@deepseek-ai/dsh-compact' /** * A trivial concrete CompactService implementing the abstract contract. The @@ -15,10 +16,11 @@ class StubCompactService extends CompactService { lastSignal: AbortSignal | undefined override async compactIfNeeded( - _session: Session, - _systemPrompt?: string, - _model?: string, - signal?: AbortSignal, + _agent: CompactAgentContext, + _turn: number, + _step: number, + _fullSystemPrompt: string, + signal: AbortSignal, ): Promise { this.lastSignal = signal return null @@ -28,7 +30,9 @@ class StubCompactService extends CompactService { session: Session, start: number, end: number, - _model: string, + _agent: CompactAgentContext, + _turn: number, + _step: number, signal?: AbortSignal, ): Promise { this.lastSignal = signal @@ -54,6 +58,10 @@ class StubCompactService extends CompactService { } describe('CompactService seam', () => { + function stubAgent(session: Session, model?: string): CompactAgentContext { + return { session, options: model === undefined ? {} : { model } } + } + it('registers as ctx.compact', () => { const ctx = new Context() void new StubCompactService(ctx) @@ -72,7 +80,8 @@ describe('CompactService seam', () => { it('exposes the abstract contract methods', async () => { const ctx = new Context() const svc = new StubCompactService(ctx) - expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull() + const session = new Session(SessionId('s')) + expect(await svc.compactIfNeeded(stubAgent(session), 1, 1, '', new AbortController().signal)).toBeNull() }) it('compact/* events merge into SessionEventMap and are log-only', async () => { @@ -80,7 +89,7 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - const result = await svc.compactRegion(session, 0, 0, 'm') + const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1) const startEvent = session.events.find(e => e.type === 'compact/start') expect(startEvent).toBeDefined() @@ -98,10 +107,10 @@ describe('CompactService seam', () => { const session = new Session(SessionId('s')) const controller = new AbortController() - await svc.compactRegion(session, 0, 0, 'm', controller.signal) + await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1, controller.signal) expect(svc.lastSignal).toBe(controller.signal) - await svc.compactIfNeeded(session, undefined, undefined, controller.signal) + await svc.compactIfNeeded(stubAgent(session), 1, 1, '', controller.signal) expect(svc.lastSignal).toBe(controller.signal) }) }) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f6b286cb28..2982a60fc8 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -388,29 +388,34 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // (or turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) - // Assemble the system prompt for this step. Done HERE (before step/start) - // because the pre-step seam needs it: compaction measures token pressure - // against the system prompt (it counts toward the budget) and a listener - // also receives the model to summarize with. runStep reuses this same - // assembly for the request, so the prompt is assembled once per step. - const assembly = await ctx.systemPrompt.assemble() - const system = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') - - // The step's AbortController exists BEFORE the pre-step seam so a cancel() - // during the seam aborts any in-flight work a listener started (e.g. a - // compaction summarization call). Cleared on every exit path below. + // The step's AbortController exists BEFORE any async pre-step work so a + // dispose() or cancel() — in a synchronous turn-start listener or an + // async listener whose effect fires before we block — always has an armed + // abort to cancel against. isDisposed below covers disposal, which does + // NOT set the cancel marker. Cleared on every exit path below. const abort = new AbortController() handle.setAbort(abort) - // Cancel landing before the seam: a synchronous `agent/turn-start` listener - // (or the previous step's continuation listeners) can have called - // `cancel()`. Drop the about-to-start step WITHOUT running the seam — no - // step is open yet, so end the turn `aborted` directly. - if (handle.isCancelled()) { + // Assemble the system prompt for this step. Done HERE (before step/start) + // because the pre-step seam needs it: compaction measures token pressure + // against the system prompt (it counts toward the budget). runStep reuses + // this same assembly for the request, so the prompt is assembled once per + // step. + const assembly = await ctx.systemPrompt.assemble() + const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] + .filter(text => text.length > 0) + .join('\n\n') + + // Interruption landing after assembly: dispose() or cancel() in a + // turn-start listener (or a listener whose promise resolved before the + // await above) arms either handle.isDisposed() or handle.isCancelled(). + // The Abort was created first, so any concurrent abort also lands on it. + // Drop the about-to-start step WITHOUT running the seam — no step is open + // yet, so end the turn accordingly (disposed wins for an unambiguous + // reason). + if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) - reason = { kind: 'aborted', reason: handle.cancelReason() } + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } @@ -425,7 +430,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // throwing listener escapes to the outer catch, which closes the (not-yet- // open) step as a no-op and ends the turn via failTurn — a broken // pre-step plugin ends the turn, not the loop. - await ctx.serial('agent/pre-step', agent, turn, step, system, agent.options.model ?? '', abort.signal) + await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal) session.append('step/start', { turn, step }) stepOpen = true @@ -433,19 +438,20 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // Cancel landing in the seam / step-start window: a `cancel()` during the // pre-step seam (it aborted `abort.signal` above) OR a synchronous - // `agent/step-start` listener that cancels. Check AFTER setAbort/step-start - // and before `runStep`: drop the step, end the turn `aborted`. closeStep - // balances the already-appended step/start. - if (handle.isCancelled()) { + // `agent/step-start` listener that cancels. And disposal, which the earlier + // assembly check may have missed if it only checked isCancelled. Check + // AFTER step/start append + emit and before `runStep`: drop the step, end + // the turn accordingly. closeStep balances the already-appended step/start. + if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) - reason = { kind: 'aborted', reason: handle.cancelReason() } + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } closeStep() break } let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, assembly, system, abort.signal) + stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2c6f9e06e8..aa565f15b5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -322,9 +322,9 @@ describe('agent loop', () => { it('agent/pre-step fires once per step before the step is opened', async () => { // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-step fires, each carrying the assembled system + model, BEFORE the - // step is opened and its request is derived (the request the adapter sees - // reflects any surface state at fire time). + // pre-step fires, each carrying the assembled full system prompt, BEFORE + // the step is opened and its request is derived (the request the adapter + // sees reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -336,18 +336,18 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const fires: { turn: number; step: number; model: string }[] = [] - ctx.on('agent/pre-step', (subject, turn, step, _system, model) => { - if (subject === agent) fires.push({ turn, step, model }) + const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] + ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { + if (subject === agent) fires.push({ turn, step, fullSystemPrompt }) }) send(agent, 'go') await waitForIdle(ctx, agent) - // One fire per step, in order, each with the agent's model. + // One fire per step, in order, each with the assembled system prompt. expect(fires).toEqual([ - { turn: 1, step: 1, model: 'mock' }, - { turn: 1, step: 2, model: 'mock' }, + { turn: 1, step: 1, fullSystemPrompt: '' }, + { turn: 1, step: 2, fullSystemPrompt: '' }, ]) }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index a092bd8419..5b02ca60c1 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1047,3 +1047,273 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected') }) }) + + + +describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { + it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { + // Block `system-prompt/assemble` on a promise. Start disposal (which + // calls stop() synchronously, setting status=disposed), then release the + // block. The loop must check isDisposed() after assembly and end the turn + // `disposed` — no LLM call. Don't await fiber.dispose() before releasing + // the blocker: the dispose chain awaits agent.done, which hangs until the + // loop unblocks. + const adapter = new MockAdapter(['hang']) + let releaseAssemble!: () => void + const blocked = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + // Blocking listener on the parent context (survives fiber disposal). + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + await blocked + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + // Give the loop time to enter the step and reach assemble(). + await new Promise(r => setTimeout(r, 50)) + + // Start disposal — stop() sets status=disposed synchronously, then the + // disposer's await agent.done hangs because the loop is blocked in the + // waterfall. Do NOT await yet; release the blocker first. + const disposalDone = fiber.dispose() + + // Now release the blocked waterfall — the loop unblocks, checks + // isDisposed(), and exits, which resolves agent.done and disposalDone. + releaseAssemble() + await disposalDone + await agent.done + unlisten() + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + // No step was opened, no LLM call was made. + expect(e.some(x => x.type === 'step/start')).toBe(false) + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + // agent/turn-end may not fire when disposal happens during assembly: the + // fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s + // emit, and the LIFO chain disposes effects in reverse registration order. + // The turn/end durable record is the one that matters. + }) + + it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { + const adapter = new MockAdapter([textResponse('should not appear')]) + let releaseAssemble!: () => void + const blocker = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + await blocker + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + agent.cancel('user cancelled during assembly') + + releaseAssemble() + await waitForIdle(ctx, agent) + await fiber.dispose() + await agent.done + unlisten() + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ + kind: 'aborted', + reason: 'user cancelled during assembly', + }) + expect(e.some(x => x.type === 'step/start')).toBe(false) + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + expect(e.some(x => x.type === 'assistant/message')).toBe(false) + expect(adapter.requests).toHaveLength(0) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }]) + }) + + it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { + // Block the `agent/pre-step` serial seam on a promise we control, then + // dispose the agent's fiber. When the block releases, the loop must see + // isDisposed() at the post-seam check and end the turn disposed. + const adapter = new MockAdapter(['hang']) + let releasePreStep!: () => void + const blocker = new Promise(r => void (releasePreStep = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('agent/pre-step', async () => { + await blocker + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + + // Start disposal, then release the block, then await disposal. + const disposalDone = fiber.dispose() + releasePreStep() + await disposalDone + await agent.done + + // After the pre-step seam finishes, the post-seam cancel/dispose check + // catches disposal. The step was never opened, no LLM call was made. + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + // Disposal wins the post-seam check — reason is `disposed`. + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + // agent/turn-end may not fire when disposal happens during pre-step: the + // fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end + // is the authoritative record. + }) + + it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { + // Block `agent/pre-step`, then cancel() the agent. When the block releases, + // the post-seam check catches cancellation and ends the turn aborted. + const adapter = new MockAdapter(['hang']) + let releasePreStep!: () => void + const blocker = new Promise(r => void (releasePreStep = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('agent/pre-step', async () => { + await blocker + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel('user cancelled') + + releasePreStep() + await waitForIdle(ctx, agent) + await fiber.dispose() + await agent.done + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + const turnEnd = e.findLast(x => x.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' }) + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }]) + }) + + it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => { + // The key assertion from the original bug report: after disposal, no + // assistant/chunk or assistant/message appears — the turn ends disposed + // before any model interaction. + const adapter = new MockAdapter([textResponse('should not appear')]) + let releaseAssemble!: () => void + const blocker = new Promise(r => void (releaseAssemble = r)) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(Invariants, { freeze: false }) + ctx.llm.registerAdapter(['mock'], adapter) + + ctx.on('system-prompt/assemble', async function (_assembly, next) { + await blocker + return next() + }) + + let agent!: ReactLoopAgent + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' }) + }, { inject: ['agentLoop'] })) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 50)) + + const disposalDone = fiber.dispose() + releaseAssemble() + await disposalDone + await agent.done + + const e = [...agent.session.events] + expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) + expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) + // The critical assertions: after disposal, the turn has no assistant + // artifacts — the turn ended disposed before the model was invoked. + expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) + expect(e.some(x => x.type === 'assistant/message')).toBe(false) + expect(adapter.requests).toHaveLength(0) + // The durable turn/end reason is the authoritative record; agent/turn-end + // may not fire when disposal interleaves with closeTurn(true)'s emit. + }) +}) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5bb603f1f6..83b3dfa4e7 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -200,13 +200,12 @@ declare module 'cordis' { * transform or veto, but the loop must wait for the mutation to complete * before opening the step and deriving, and serial isolates listeners from * each other (one finishes its surface append before the next runs). - * `system`/`model` are the assembled values a listener needs to measure - * pressure (system counts toward the budget) and to summarize (the model). - * `signal` cancels any in-flight work a listener starts (e.g. a summarization - * model call). + * `fullSystemPrompt` is the assembled prompt a listener needs to measure + * pressure (the system prompt counts toward the budget). `signal` cancels any + * in-flight work a listener starts (e.g. a summarization model call). * @mode serial */ - 'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise | void + 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the * model call (hooks, model switching, tool filtering, …). Call `next()` to From 1808570933d866ec5c7ceff83181e0456f3ddaf6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 16:56:44 +0800 Subject: [PATCH 14/28] fix(compact): harden summarization convergence Use maxTokens as the provider generation cap and remove the confusing stored-summary max config. Strip reasoning blocks before storing compaction summaries, reject non-shrinking summaries, and retry bounded re-compaction when the surface remains over threshold. Add config validation for numeric and type-shaped knobs plus unit and real-API e2e coverage for reasoning-capable summarization. --- docs/core-data-structures/compaction.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- examples/coding-agent/tests/compaction.e2e.ts | 13 +- packages/compact/compact-basic/README.md | 7 +- packages/compact/compact-basic/src/index.ts | 148 +++++++----- packages/compact/compact-basic/src/types.ts | 70 +++--- .../compact-basic/tests/compact-basic.spec.ts | 220 +++++++++++++----- .../tests/compact-loop-repro.spec.ts | 6 +- 8 files changed, 319 insertions(+), 149 deletions(-) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index a1ca8978a6..05d1ce6c2e 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -52,4 +52,4 @@ interface CompactionResult { `CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, turn, step, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the approximate convergence invariant, and the crash/recoverable failure taxonomy. +Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index ba6f6c6ae6..29371a3abb 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -66,7 +66,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Approximate convergence invariant -`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant bounds the two variable parts of post-compaction history — the bounded summary plus the retained recent tail — but it is intentionally approximate: checkpoint framing, per-message role overhead, system-prompt size, and the char/4 estimator's error can still leave a narrow accepted config near the threshold. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks the structural budget. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot satisfy the structural bound is a bug at the call site, not something to silently clamp. +`resolveConfig` validates numeric knobs but does NOT reject based on a pretend summary-length invariant. Convergence is dynamic: provider output caps can be spent on hidden or surfaced reasoning tokens, and the model may emit a summary of unpredictable size. `maxTokens` is only the provider-side generation cap for the summarization call; reasoning blocks are stripped before the checkpoint is stored. If a compacted surface is still over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times, but each committed summary must be smaller than the content it shadows. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 17186055b1..a300aac69a 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -40,18 +40,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) } - // Tiny window so a couple of steps crosses the threshold. The convergence - // invariant requires summarizationMaxTokens + retainTokens to be strictly - // BELOW the threshold = floor(contextWindow * thresholdRatio) = - // floor(2400 * 0.5) = 1200; 600 + 500 = 1100 < 1200. The summary cap - // stays high enough for the live model to emit the required checkpoint - // sections; a truncated checkpoint fails closed and leaves no summary. + // Tiny window so a couple of steps crosses the threshold. The generation + // cap is deliberately larger than the final checkpoint because + // reasoning-capable APIs count reasoning tokens against the provider output + // budget even though those blocks are stripped before the checkpoint is + // stored. ctx = await codingHarness(workdir, { compact: { contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, - summarizationMaxTokens: 600, + maxTokens: 2048, }, persistenceRoot: './.sessions', }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 2b13666033..5d4f2c755e 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -10,8 +10,8 @@ The abstract contract states only WHAT compaction does; this backend owns every - **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). - **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. -- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger. -- **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. +- **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; reasoning blocks from reasoning-capable APIs are stripped before the checkpoint is stored. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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, no-veto) 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()`). @@ -27,7 +27,8 @@ The abstract contract states only WHAT compaction does; this backend owns every | `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | | `retainTokens` | `20480` | Tokens of recent context to keep intact. | | `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | -| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. | +| `maxTokens` | `8192` | Provider generation cap for the summarization call; may include reasoning tokens. | +| `compactionRetries` | `1` | Extra compaction attempts after the first if the compacted surface remains over threshold. | | `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | ## Usage diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index e474be7cd9..7d84b52511 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -290,7 +290,7 @@ export class BasicCompactService extends CompactService { content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], }], system: SUMMARIZE_SYSTEM_PROMPT, - maxTokens: this.config.summarizationMaxTokens, + maxTokens: this.config.maxTokens, } // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. @@ -306,7 +306,12 @@ export class BasicCompactService extends CompactService { const error = finishError(assembler.finish) if (error) throw error - return assembler.message().content + const summary = this._stripReasoning(assembler.message().content) + if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) { + throw new Error('summarization produced no non-reasoning summary content') + } + + return summary } // ---- Core API (implements the abstract contract) ---- @@ -345,62 +350,28 @@ export class BasicCompactService extends CompactService { signal: AbortSignal, ): Promise { const session = agent.session - const messages = session.deriveMessages() - const totalTokens = this.estimateTokens(messages, fullSystemPrompt) - const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) - if (totalTokens < threshold) return null + let result: CompactionResult | null = null + for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) { + const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + if (totalTokens < threshold) return result - const nodes = session.surface.nodes - if (nodes.length === 0) return null + const range = this._compactableRange(session) + if (range === null) { + if (result === null) return null + break + } - const events = session.events - const retainBudget = this.config.retainTokens - - // Walk tail→head summing per-node token estimates. `keepFromIdx` is the - // index of the OLDEST node we retain verbatim; everything strictly older - // (`[0, keepFromIdx - 1]`) is the compactable range. - let accumulated = 0 - let keepFromIdx = nodes.length // nothing retained yet - for (let i = nodes.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const node = nodes[i]! - const event = events[node.seq] - /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ - if (event) accumulated += this.estimateEventTokens(event) - keepFromIdx = i - if (accumulated >= retainBudget) break + result = await this.compactRegion(session, range.start, range.end, agent, turn, step, signal) } - // The whole surface fits the retain budget — nothing to compact. - if (keepFromIdx === 0) return null + const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + if (totalTokens < threshold) return result - // 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). - while (keepFromIdx > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break - keepFromIdx -= 1 - } - if (keepFromIdx === 0) return null - - // The compacted range is [head … keepFromIdx - 1], anchored at the head. - // The cutoff node `nodes[keepFromIdx - 1]` is necessarily a balanced END: - // the retained start `nodes[keepFromIdx]` opens on a balanced cut, and that - // same cut is the cut AFTER `nodes[keepFromIdx - 1]` — so no separate end - // check is needed. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstSeq = nodes[0]!.seq - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cutoffSeq = nodes[keepFromIdx - 1]!.seq - - return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal) + throw new Error( + `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` + + `(${totalTokens} estimated tokens >= threshold ${threshold})`, + ) } override async compactRegion( @@ -482,7 +453,12 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) } - + const summaryTokenCount = this.estimateContentTokens(summary) + if (summaryTokenCount >= shadowedTokenCount) { + throw new Error( + `summary is not smaller than the shadowed content (${summaryTokenCount} estimated tokens >= ${shadowedTokenCount})`, + ) + } // --- Provenance record (log-only) --- const summaryEvent = session.append('compact/summary', { summary, @@ -580,6 +556,72 @@ export class BasicCompactService extends CompactService { return false } + /** Resolve the next head-anchored compactable surface range, or `null`. */ + private _compactableRange(session: Session): { start: number; end: number } | null { + const nodes = session.surface.nodes + if (nodes.length === 0) return null + + const events = session.events + const retainBudget = this.config.retainTokens + + // Walk tail→head summing per-node token estimates. `keepFromIdx` is the + // index of the OLDEST node we retain verbatim; everything strictly older + // (`[0, keepFromIdx - 1]`) is the compactable range. + let accumulated = 0 + let keepFromIdx = nodes.length // nothing retained yet + for (let i = nodes.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[i]! + const event = events[node.seq] + /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */ + if (event) accumulated += this.estimateEventTokens(event) + keepFromIdx = i + if (accumulated >= retainBudget) break + } + + // 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). + while (keepFromIdx > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break + keepFromIdx -= 1 + } + if (keepFromIdx === 0) return null + + // The compacted range is [head … keepFromIdx - 1], anchored at the head. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstSeq = nodes[0]!.seq + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cutoffSeq = nodes[keepFromIdx - 1]!.seq + return { start: firstSeq, end: cutoffSeq } + } + + /** Remove reasoning blocks from model-produced summary content before storing it. */ + private _stripReasoning(blocks: readonly ContentBlock[]): ContentBlock[] { + const stripped: ContentBlock[] = [] + for (const block of blocks) { + switch (block.type) { + case 'reasoning': + break + case 'tool-result': + stripped.push({ ...block, content: this._stripReasoning(block.content) }) + break + default: + stripped.push(block) + } + } + return stripped + } + /** * The turn number of the currently OPEN turn — a `turn/start` not yet * followed by its `turn/end` — or `null` if the session has no open turn. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 13365b7ed1..8c4753c84f 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -19,8 +19,10 @@ export interface BasicCompactConfig { retainTokens?: number /** Model to use for summarization (default '' — uses the agent's model). */ summarizationModel?: string - /** Maximum tokens for the summarization response (default 2048). */ - summarizationMaxTokens?: number + /** Provider generation cap for the summarization call (default 8192). */ + maxTokens?: number + /** Extra compaction attempts when the first compacted surface is still over threshold (default 1). */ + compactionRetries?: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } @@ -34,40 +36,52 @@ export const DEFAULTS: ResolvedConfig = { thresholdRatio: 0.8, retainTokens: 20480, summarizationModel: '', - summarizationMaxTokens: 2048, + maxTokens: 8192, + compactionRetries: 1, auto: true, } /** - * Apply defaults to a partial config and enforce the approximate convergence - * invariant. + * Apply defaults to a partial config and reject nonsensical numeric knobs. * - * `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction - * threshold (`contextWindow * thresholdRatio`). The invariant bounds the two - * variable pieces of post-compaction history — the summary and the retained - * recent tail — but it is intentionally approximate: checkpoint framing, - * per-message role overhead, system-prompt size, and the char/4 estimator's - * error can still leave a narrow accepted config near the threshold. The bound - * is strict (`>=` rejects) because `compactIfNeeded` declines only when the - * estimate is `< threshold`: a post-compaction history sitting EXACTLY at the - * threshold would re-trigger on the next check. Pre-release we reject rather - * than clamp: a config that cannot satisfy even this structural bound is a bug - * at the call site, not something to silently paper over. - * - * @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. + * 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. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { const resolved = { ...DEFAULTS, ...config } - const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio) - const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens - if (postCompactionFloor >= threshold) { - throw new Error( - `BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + ` - + `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} is not below the compaction ` - + `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would ` - + 'stay at/over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens ' - + 'or raise contextWindow/thresholdRatio.', - ) + + assertPositiveInteger('contextWindow', resolved.contextWindow) + assertRatio('thresholdRatio', resolved.thresholdRatio) + assertNonNegativeInteger('retainTokens', resolved.retainTokens) + assertPositiveInteger('maxTokens', resolved.maxTokens) + assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + if (typeof resolved.summarizationModel !== 'string') { + throw new Error('BasicCompactConfig: summarizationModel must be a string.') + } + if (typeof resolved.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean.') } return resolved } + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`) + } +} + +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`) + } +} + +function assertRatio(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`) + } +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index fbbb8258c5..73fc6246e6 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -17,14 +17,18 @@ const SIGNAL = new AbortController().signal * predictable token estimate, for deterministic unit tests of the algorithm. */ class TestCompactService extends BasicCompactService { + private readonly summaryOutputs = new WeakSet() /** Track calls to summarize for test assertions. */ summarizeCalls: { text: string; model: string }[] = [] /** The fixed summary to return. */ mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }] + /** Per-call summaries; when set, each summarize() call shifts one value. */ + mockSummaryQueue: ContentBlock[][] = [] /** If set, summarize() throws this error. */ summarizeError: Error | null = null override estimateContentTokens(blocks: readonly ContentBlock[]): number { + if (this.summaryOutputs.has(blocks)) return blocks.length * 2 // 10 tokens per block — predictable for retention/threshold math. return blocks.length * 10 } @@ -33,18 +37,15 @@ class TestCompactService extends BasicCompactService { const model = this.config.summarizationModel || agent.options.model || '' this.summarizeCalls.push({ text, model }) if (this.summarizeError) throw this.summarizeError - return this.mockSummary + const summary = this.mockSummaryQueue.shift() ?? this.mockSummary + this.summaryOutputs.add(summary) + return summary } } -/** - * Create a test service with a throwaway context (auto disabled — no model). - * A small `summarizationMaxTokens` baseline keeps the convergence invariant - * (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`) - * satisfied for the tiny windows these tests use; a test may override it. - */ +/** Create a test service with a throwaway context (auto disabled — no model). */ function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config }) + return new TestCompactService(new Context(), { auto: false, ...config }) } /** @@ -182,7 +183,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai // 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. - const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 }) + const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) @@ -520,7 +521,7 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('walks tail→head and retains nodes within token budget', async () => { - const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 }) + const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) @@ -531,13 +532,12 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(470*0.1) = 47. The 4 surface nodes weigh 10 each (raw 40 + // 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 ≥ 47, so the threshold check passes and the walk runs. The + // 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. The invariant holds: - // summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47. - const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 }) + // so keepFromIdx reaches 0 and compaction declines. + const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) @@ -553,7 +553,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // 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. - const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) + 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]. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -598,7 +598,7 @@ describe('BasicCompactService.compactIfNeeded', () => { // never stranded. retainTokens=25 leaves a couple of retained nodes after // the first compaction (so the surface is [summary, …retained], not just // [summary]). - const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 }) + 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) const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL) @@ -623,6 +623,45 @@ describe('BasicCompactService.compactIfNeeded', () => { const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq expect(second!.shadowedSeqs).not.toContain(turn5UserSeq) }) + + it('re-compacts smaller summaries until the post-compaction surface drops below threshold', async () => { + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.5, + retainTokens: 10, + compactionRetries: 2, + }) + svc.mockSummaryQueue = [ + Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), + [{ type: 'text', text: 'second' }], + ] + const session = multiTurnSession(4, 1) + + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) + + expect(result).not.toBeNull() + expect(svc.summarizeCalls).toHaveLength(2) + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(2) + expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(50) + }) + + it('throws after the configured re-compaction attempts still leave the surface above threshold', async () => { + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.5, + retainTokens: 10, + compactionRetries: 1, + }) + svc.mockSummaryQueue = [ + Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), + Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })), + ] + const session = multiTurnSession(4, 1) + + await expect(compactIfNeeded(svc, session, '', 'm', SIGNAL)) + .rejects.toThrow(/still above threshold after 2 compaction attempts/) + expect(svc.summarizeCalls).toHaveLength(2) + }) }) describe('BasicCompactService replay equivalence', () => { @@ -753,31 +792,31 @@ describe('BasicCompactService HMR safety', () => { }) }) -describe('BasicCompactService convergence invariant (config)', () => { - it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 is not below 500 → reject. - expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200, - })).toThrow(/not below the compaction threshold/) +describe('BasicCompactService config validation', () => { + it('rejects invalid numeric config values', () => { + expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 0 })).toThrow(/contextWindow .* positive integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 0 })).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 1.1 })).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), { auto: false, retainTokens: -1 })).toThrow(/retainTokens .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, maxTokens: 0 })).toThrow(/maxTokens .* positive integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, compactionRetries: -1 })) + .toThrow(/compactionRetries .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), { auto: false, summarizationModel: 1 } as unknown as BasicCompactConfig)) + .toThrow(/summarizationModel must be a string/) + expect(() => new BasicCompactService(new Context(), { auto: 'no' } as unknown as BasicCompactConfig)) + .toThrow(/auto must be a boolean/) }) - it('rejects the boundary case (sum equals the threshold — would re-trigger)', () => { - // threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 is NOT below 500, so - // post-compaction history would sit exactly at threshold and re-compact. + it('accepts a large retain budget because convergence is enforced dynamically', () => { expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100, - })).toThrow(/not below the compaction threshold/) - }) - - it('accepts the case just below the threshold', () => { - // threshold = floor(1000 * 0.5) = 500; 99 + 400 = 499 < 500 → allowed. - expect(() => new BasicCompactService(new Context(), { - auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 99, + auto: false, + contextWindow: 1000, + thresholdRatio: 0.5, + retainTokens: 900, })).not.toThrow() }) - it('the default config satisfies the invariant', () => { - // 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400. + it('the default config is valid', () => { expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() }) }) @@ -797,6 +836,41 @@ class ScriptedAdapter extends LlmAdapter { } } +/** An adapter that emits arbitrary content blocks, preserving reasoning/text shape. */ +class BlocksAdapter extends LlmAdapter { + lastOptions: GenerateOptions | null = null + constructor(private blocks: readonly ContentBlock[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.lastOptions = options + for (const [index, block] of this.blocks.entries()) { + yield { type: 'block-start', index, blockType: block.type } + switch (block.type) { + case 'text': + yield { type: 'text-delta', index, text: block.text } + break + case 'reasoning': + yield { type: 'reasoning-delta', index, text: block.text } + break + default: + yield { type: 'block-end', index, block } + } + } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Wire a real LlmService + arbitrary-block adapter into a context. */ +async function ctxWithBlocks(blocks: readonly ContentBlock[], model = 'test-model'): Promise<{ ctx: Context; adapter: BlocksAdapter }> { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new BlocksAdapter(blocks) + ctx.llm.registerAdapter([model], adapter) + return { ctx, adapter } +} + /** Wire a real LlmService + scripted adapter into a context. */ async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> { const ctx = new Context() @@ -858,7 +932,7 @@ function summarize(svc: BasicCompactService, text: string, model: string) { describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 }) + const svc = new BasicCompactService(ctx, { auto: false, maxTokens: 512 }) const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) @@ -869,6 +943,37 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) }) + it('uses maxTokens as the summarization provider cap', async () => { + const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') + const svc = new BasicCompactService(ctx, { + auto: false, + maxTokens: 50, + }) + + await summarize(svc, 'User: hi', 'test-model') + + expect(adapter.lastOptions!.maxTokens).toBe(50) + }) + + it('strips reasoning blocks from the stored summary', async () => { + const { ctx } = await ctxWithBlocks([ + { type: 'reasoning', text: 'private chain of thought' }, + { type: 'text', text: 'PUBLIC SUMMARY' }, + ]) + const svc = new BasicCompactService(ctx, { auto: false }) + + const summary = await summarize(svc, 'User: hi', 'test-model') + + expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) + }) + + it('throws when stripping reasoning leaves no summary text', async () => { + const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) + const svc = new BasicCompactService(ctx, { auto: false }) + + await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no non-reasoning summary content/) + }) + it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') const svc = new BasicCompactService(ctx, { auto: false }) @@ -930,6 +1035,17 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) }) + + it('rejects a summary that is not smaller than the shadowed content', async () => { + const svc = createTestService({ auto: false }) + const session = multiTurnSession(2, 1) + const nodes = session.surface.nodes + svc.mockSummary = Array.from({ length: 20 }, (_, index) => ({ type: 'text', text: `large ${index}` })) + + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/summary is not smaller than the shadowed content/) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + }) }) describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { @@ -940,7 +1056,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length @@ -956,7 +1072,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 }) + void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') @@ -981,7 +1097,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 }) + void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length @@ -994,7 +1110,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 }) + void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1008,7 +1124,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => options.model = 'routed-model' return next() }) - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 }) + void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) const session = multiTurnSession(5, 1) const agent = stubAgent(session) @@ -1025,7 +1141,6 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, - summarizationMaxTokens: 50, }) const session = multiTurnSession(5, 1) const agent = stubAgent(session, 'test-model') @@ -1139,14 +1254,16 @@ describe('BasicCompactService edge cases', () => { expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) }) - it('compacts once without re-checking a post-compaction threshold', async () => { + it('auto-compaction reports bounded retry exhaustion after committing a smaller summary', async () => { const { ctx } = await ctxWithModel('SUMMARY') - // Even with a window so tiny the post-compaction history still exceeds the - // threshold, the agnostic listener does NOT re-gate or warn — it compacts - // once (the single check lives in compactIfNeeded) and proceeds. const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 }) + void new BasicCompactService(ctx, { + contextWindow: 300, + thresholdRatio: 0.1, + retainTokens: 5, + compactionRetries: 0, + }) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') @@ -1154,8 +1271,7 @@ describe('BasicCompactService edge cases', () => { expect(session.events.some(e => e.type === 'compact/summary')).toBe(true) // The surface was mutated; the head message is the framed summary checkpoint. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) - // No cascade warning is emitted. - expect(warnings.length).toBe(0) + expect(warnings.some(w => w.includes('still above threshold after 1 compaction attempts'))).toBe(true) }) it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => { @@ -1225,7 +1341,7 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 }) + const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1243,7 +1359,7 @@ describe('BasicCompactService edge cases', () => { // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. - const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 }) + const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 }) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e12861a43a..dddb636b21 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -91,14 +91,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr }, })) // Tiny window so a couple of tool steps cross the threshold and compaction - // fires within the runaway turn. Convergence invariant holds: - // summarizationMaxTokens(1) + retainTokens(20) = 21 <= floor(60*0.5) = 30. + // fires within the runaway turn. const compact = new ReproCompactService(ctx, { auto: true, - contextWindow: 60, + contextWindow: 64, thresholdRatio: 0.5, retainTokens: 20, - summarizationMaxTokens: 1, }) return { ctx, compact } } From 4bda95b5897c471dc6befd48e664bcbed298e8f0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 18:04:11 +0800 Subject: [PATCH 15/28] ci: remove gitlab mirror workflow --- .github/workflows/mirror-to-gitlab.yml | 33 -------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/mirror-to-gitlab.yml diff --git a/.github/workflows/mirror-to-gitlab.yml b/.github/workflows/mirror-to-gitlab.yml deleted file mode 100644 index 671915ba49..0000000000 --- a/.github/workflows/mirror-to-gitlab.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Mirror to GitLab - -on: - push: - branches: ['**'] - tags: ['**'] - delete: - workflow_dispatch: - -concurrency: - group: mirror-to-gitlab - cancel-in-progress: false - -jobs: - mirror: - runs-on: ubuntu-latest - steps: - - name: Checkout full history - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.GITLAB_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.com >> ~/.ssh/known_hosts - - - name: Push to GitLab - run: | - git remote add gitlab "${{ secrets.GITLAB_REPO_URL }}" - git push --mirror gitlab From 170643ec9b9daea0311f1a2c3c20a16c46f65f72 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 29 Jun 2026 18:04:59 +0800 Subject: [PATCH 16/28] test(compact-basic): restore coverage gate --- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 34 +++++++------- .../compact-basic/tests/compact-basic.spec.ts | 47 +++++++++++++++++-- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 5d4f2c755e..6e8eb0765d 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -11,7 +11,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). - **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; reasoning blocks from reasoning-capable APIs are stripped before the checkpoint is stored. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. +- **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `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 (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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, no-veto) 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()`). diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 7d84b52511..9d0849b19a 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -306,9 +306,9 @@ export class BasicCompactService extends CompactService { const error = finishError(assembler.finish) if (error) throw error - const summary = this._stripReasoning(assembler.message().content) + const summary = this._textOnly(assembler.message().content) if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) { - throw new Error('summarization produced no non-reasoning summary content') + throw new Error('summarization produced no text summary content') } return summary @@ -358,7 +358,9 @@ export class BasicCompactService extends CompactService { const range = this._compactableRange(session) if (range === null) { + /* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */ if (result === null) return null + /* v8 ignore next -- paired with the ignored defensive branch above. */ break } @@ -605,21 +607,19 @@ export class BasicCompactService extends CompactService { return { start: firstSeq, end: cutoffSeq } } - /** Remove reasoning blocks from model-produced summary content before storing it. */ - private _stripReasoning(blocks: readonly ContentBlock[]): ContentBlock[] { - const stripped: ContentBlock[] = [] - for (const block of blocks) { - switch (block.type) { - case 'reasoning': - break - case 'tool-result': - stripped.push({ ...block, content: this._stripReasoning(block.content) }) - break - default: - stripped.push(block) - } - } - return stripped + /** + * 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. + */ + private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] { + return blocks.filter((block): block is Extract => block.type === 'text') } /** diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 73fc6246e6..906841c39f 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -520,6 +520,24 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) + it('returns the first compaction result when a zero-retry pass converges after the loop', async () => { + // With compactionRetries=0 there is no next-loop threshold check after the + // first mutation, so the success path is the post-loop `return result`. + const svc = createTestService({ + contextWindow: 100, + thresholdRatio: 0.7, + retainTokens: 10, + compactionRetries: 0, + }) + const session = multiTurnSession(3, 1) // 6 derived messages = 84 estimated tokens. + + const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL) + + expect(result).not.toBeNull() + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) + expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(70) + }) + it('walks tail→head and retains nodes within token budget', async () => { const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 }) const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens @@ -955,10 +973,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.maxTokens).toBe(50) }) - it('strips reasoning blocks from the stored summary', async () => { + it('keeps only text blocks in the stored summary (drops reasoning and tool-call)', async () => { const { ctx } = await ctxWithBlocks([ { type: 'reasoning', text: 'private chain of thought' }, { type: 'text', text: 'PUBLIC SUMMARY' }, + // A model reply can carry a tool-call; it must not survive into the + // synthesized user/message summary as an orphaned call. + { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, ]) const svc = new BasicCompactService(ctx, { auto: false }) @@ -967,11 +988,11 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) }) - it('throws when stripping reasoning leaves no summary text', async () => { + it('throws when no text block remains after filtering', async () => { const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) const svc = new BasicCompactService(ctx, { auto: false }) - await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no non-reasoning summary content/) + await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) }) it('throws when no model is provided', async () => { @@ -1070,6 +1091,26 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' }) }) + it('logs compaction details when auto-compaction returns a converged result', async () => { + const ctx = new Context() + const infos: string[] = [] + ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info + void new TestCompactService(ctx, { + contextWindow: 100, + thresholdRatio: 0.7, + retainTokens: 10, + compactionRetries: 0, + }) + const session = multiTurnSession(3, 1) + const agent = stubAgent(session, 'test-model') + + await firePreStep(ctx, agent, 1, '') + + expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) + expect(infos.some(msg => msg.includes('compaction: shadowed'))).toBe(true) + expect(infos.some(msg => msg.includes('estimated tokens after compaction'))).toBe(true) + }) + it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) From 6ae1e229fddd7b528dbf679b092313a58ae35eb9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 09:40:51 +0800 Subject: [PATCH 17/28] docs(cordis): clarify serial bail semantics --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 20 +++++++++---------- .../2026-06-18-compaction-capability-seam.md | 2 +- packages/compact/compact-basic/README.md | 2 +- packages/core/agent/src/types.ts | 17 ++++++++-------- scripts/gen-cordis-catalog.ts | 4 ++-- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47893c81de..d4f04b1eed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/c Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order with no veto (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a2bb8a4229..6a43db0b0e 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto). +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). ### `agent/*` @@ -49,13 +49,13 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. -Serial (awaited, in registration order, no veto), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform or veto, but the loop must wait for the mutation to complete before opening the step and deriving, and serial isolates listeners from each other (one finishes its surface append before the next runs). `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). ```ts cordis-catalog 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void @@ -63,7 +63,7 @@ Serial (awaited, in registration order, no veto), not a waterfall: a listener mu Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -511,7 +511,7 @@ The framework surface every plugin inherits, beyond the harness vocabulary above ### Inherited `ctx` members - `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) -- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-non-nullish / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) - `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts)) - `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts)) - `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts)) diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 29371a3abb..9e08df2fbd 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -46,7 +46,7 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order, no veto), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. +This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface. This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 6e8eb0765d..5b2e177997 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -14,7 +14,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **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 runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `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 (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) 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 `` 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, no-veto) 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()`). +- **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/*`. `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. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 83b3dfa4e7..0edc787f9c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -195,14 +195,15 @@ declare module 'cordis' { * no listener can see (or be expected to act on) an assembled `messages` * array that does not exist yet. * - * Serial (awaited, in registration order, no veto), not a waterfall: a - * listener mutates the surface as a side effect; there is nothing to - * transform or veto, but the loop must wait for the mutation to complete - * before opening the step and deriving, and serial isolates listeners from - * each other (one finishes its surface append before the next runs). - * `fullSystemPrompt` is the assembled prompt a listener needs to measure - * pressure (the system prompt counts toward the budget). `signal` cancels any - * in-flight work a listener starts (e.g. a summarization model call). + * Serial (awaited in registration order), not a waterfall: a listener + * mutates the surface as a side effect; there is nothing to transform, but + * the loop must wait for the mutation to complete before opening the step + * and deriving. Cordis `serial` bails early if a listener returns a bail + * value; this event is typed and documented as `void`, so listeners must not + * return a semantic veto value. `fullSystemPrompt` is the assembled prompt a + * listener needs to measure pressure (the system prompt counts toward the + * budget). `signal` cancels any in-flight work a listener starts (e.g. a + * summarization model call). * @mode serial */ 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ea1c4b6661..de477d8ba3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -331,7 +331,7 @@ const INHERITED_EVENTS: InheritedEntry[] = [ const INHERITED_SERVICES: InheritedEntry[] = [ { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' }, - { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, @@ -394,7 +394,7 @@ function render(events: EventEntry[], services: ServiceEntry[]): string { '', '## Events', '', - 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto), **serial** (awaited, in registration order, no veto).', + 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', '', ] const scopes = [...new Set(events.map(e => e.scope))].sort() From b0eae94fc8062ab66d4776f32e484c2f7eaa1029 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 10:56:34 +0800 Subject: [PATCH 18/28] fix pre-step cancellation and compaction convergence --- packages/compact/compact-basic/src/index.ts | 9 ++-- .../compact-basic/tests/compact-basic.spec.ts | 45 +++++++++++++++++-- packages/core/agent-loop/src/loop.ts | 20 ++++++--- .../agent-loop/tests/review-fixes.spec.ts | 2 + packages/core/agent/src/types.ts | 2 +- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 9d0849b19a..4c7bce00bc 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -455,10 +455,11 @@ export class BasicCompactService extends CompactService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion shadowedTokenCount += this.estimateEventTokens(session.events[seq]!) } - const summaryTokenCount = this.estimateContentTokens(summary) - if (summaryTokenCount >= shadowedTokenCount) { + const framedSummary = this._frameSummary(summary) + const framedSummaryTokenCount = this.estimateContentTokens(framedSummary) + if (framedSummaryTokenCount >= shadowedTokenCount) { throw new Error( - `summary is not smaller than the shadowed content (${summaryTokenCount} estimated tokens >= ${shadowedTokenCount})`, + `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`, ) } // --- Provenance record (log-only) --- @@ -477,7 +478,7 @@ export class BasicCompactService extends CompactService { // The landed content is FRAMED (checkpoint preamble + tag-wrapped summary); // the compact/summary provenance event above holds the raw model output. session.append('user/message', { - content: this._frameSummary(summary), + content: framedSummary, source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start, end }, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 906841c39f..1134862793 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -12,12 +12,17 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ const SIGNAL = new AbortController().signal +/** Long enough that the real checkpoint preamble is smaller than two fixture messages. */ +const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20) + /** * A BasicCompactService with summarize() stubbed (no real model call) and a * predictable token estimate, for deterministic unit tests of the algorithm. */ class TestCompactService extends BasicCompactService { private readonly summaryOutputs = new WeakSet() + /** Boundary/unit tests use tiny fixtures; keep framing from dominating them unless a test opts out. */ + estimateFramedSummariesCheaply = true /** Track calls to summarize for test assertions. */ summarizeCalls: { text: string; model: string }[] = [] /** The fixed summary to return. */ @@ -29,6 +34,7 @@ class TestCompactService extends BasicCompactService { override estimateContentTokens(blocks: readonly ContentBlock[]): number { if (this.summaryOutputs.has(blocks)) return blocks.length * 2 + if (this.estimateFramedSummariesCheaply && isFramedCheckpoint(blocks)) return blocks.length * 2 // 10 tokens per block — predictable for retention/threshold math. return blocks.length * 10 } @@ -43,6 +49,15 @@ class TestCompactService extends BasicCompactService { } } +function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { + const first = blocks[0] + const last = blocks[blocks.length - 1] + return first?.type === 'text' + && first.text.includes('') + && last?.type === 'text' + && last.text === '' +} + /** Create a test service with a throwaway context (auto disabled — no model). */ function createTestService(config: BasicCompactConfig = {}): TestCompactService { return new TestCompactService(new Context(), { auto: false, ...config }) @@ -65,12 +80,12 @@ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { le s.append('step/start', { turn: t, step: 1 }) for (let m = 0; m < messagesPerTurn; m++) { s.append('user/message', { - content: [{ type: 'text', text: `turn ${t} user message ${m + 1}` }], + content: [{ type: 'text', text: `turn ${t} user message ${m + 1}.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: t, step: 1, - content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}` }], + content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}.${LONG_FIXTURE_TEXT}` }], }, { surfaceOp: 'append' }) } s.append('step/end', { turn: t, step: 1 }) @@ -649,6 +664,7 @@ describe('BasicCompactService.compactIfNeeded', () => { retainTokens: 10, compactionRetries: 2, }) + svc.estimateFramedSummariesCheaply = false svc.mockSummaryQueue = [ Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), [{ type: 'text', text: 'second' }], @@ -670,6 +686,7 @@ describe('BasicCompactService.compactIfNeeded', () => { retainTokens: 10, compactionRetries: 1, }) + svc.estimateFramedSummariesCheaply = false svc.mockSummaryQueue = [ Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })), Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })), @@ -1067,6 +1084,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { .rejects.toThrow(/summary is not smaller than the shadowed content/) expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) }) + + it('rejects when the framed checkpoint is not smaller than the shadowed content', async () => { + const svc = createTestService({ auto: false }) + svc.estimateFramedSummariesCheaply = false + const session = new Session(SessionId('framed-nonshrinking')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', { content: [{ type: 'text', text: 'tiny user' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'tiny assistant' }] }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const before = [...session.surface.nodes] + const nodes = session.surface.nodes + + await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')) + .rejects.toThrow(/summary is not smaller than the shadowed content/) + expect(session.events.some(e => e.type === 'compact/summary')).toBe(false) + expect(session.surface.nodes).toEqual(before) + }) }) describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => { @@ -1606,8 +1643,8 @@ describe('BasicCompactService under the real invariants plugin', () => { function closedTurn(session: Session, turn: number): void { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn, step: 1 }) - session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant` }] }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant.${LONG_FIXTURE_TEXT}` }] }, { surfaceOp: 'append' }) session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 2982a60fc8..200115af9a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -432,16 +432,24 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // pre-step plugin ends the turn, not the loop. await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal) + // Interruption landing during the pre-step seam: do not open an empty + // step. `agent/step-start` listeners get their own check below because + // they necessarily run after step/start is appended/emitted. + if (handle.isCancelled() || handle.isDisposed()) { + handle.setAbort(undefined) + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } + break + } + session.append('step/start', { turn, step }) stepOpen = true ctx.emit('agent/step-start', agent, turn, step) - // Cancel landing in the seam / step-start window: a `cancel()` during the - // pre-step seam (it aborted `abort.signal` above) OR a synchronous - // `agent/step-start` listener that cancels. And disposal, which the earlier - // assembly check may have missed if it only checked isCancelled. Check - // AFTER step/start append + emit and before `runStep`: drop the step, end - // the turn accordingly. closeStep balances the already-appended step/start. + // Cancel landing in the step-start window: a synchronous + // `agent/step-start` listener can cancel after the step is already open. + // Check AFTER step/start append + emit and before `runStep`: drop the + // step, end the turn accordingly. closeStep balances the already-appended + // step/start. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5b02ca60c1..346d70e2f7 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1216,6 +1216,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { const turnEnd = e.findLast(x => x.type === 'turn/end') // Disposal wins the post-seam check — reason is `disposed`. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) // agent/turn-end may not fire when disposal happens during pre-step: the // fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end @@ -1265,6 +1266,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' }) + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }]) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0edc787f9c..fc412f0e0e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -179,7 +179,7 @@ declare module 'cordis' { */ 'agent/step-end'(agent: Agent, turn: number, step: number): void - // ---- interception seams (waterfall) ---- + // ---- step/request extension seams (serial + waterfall) ---- /** * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER * `turn/start` (and after the prior step closed) but BEFORE this step's From ed5d8550ae278fb91f6baf5fe5af8e23e1935d5c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 11:33:40 +0800 Subject: [PATCH 19/28] test(agent-loop): cover step-start disposal --- packages/core/agent-loop/tests/cancel.spec.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index d6394acd54..32c46f72f0 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -224,6 +224,43 @@ describe('Agent.cancel()', () => { expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) + it('disposal from a synchronous agent/step-start listener closes the open step as disposed', async () => { + const adapter = new MockAdapter([textResponse('should not stream')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + + const handle = ctx.agents.create({ + agentId: AgentId('a-dispose-step-start'), + sessionId: SessionId('dispose-step-start-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + + let disposalDone: Promise | undefined + let streamed = false + ctx.on('agent/stream-chunk', () => { streamed = true }) + ctx.on('agent/step-start', (subject) => { + if (subject === agent) disposalDone = handle.dispose() + }) + + send(agent, 'go') + await disposalDone + await agent.done + + expect(streamed).toBe(false) + expect(adapter.requests).toHaveLength(0) + const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + const types = agent.session.events.map(e => e.type) + expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) + }) + it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { // A continuation-waterfall listener cancels DURING the continuation decision // (the finished step's AbortController is already cleared), and votes to From 1a5302dbcfa2a9bd61184d1a3a6c4d41c92de726 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 12:07:20 +0800 Subject: [PATCH 20/28] fix(compact): stamp summarization session ids --- packages/compact/compact-basic/src/index.ts | 3 +-- packages/compact/compact-basic/tests/compact-basic.spec.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 4c7bce00bc..f7fdadb3fa 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -291,6 +291,7 @@ export class BasicCompactService extends CompactService { }], system: SUMMARIZE_SYSTEM_PROMPT, maxTokens: this.config.maxTokens, + sessionId: agent.session.id, } // exactOptionalPropertyTypes: only set `signal` when present — assigning // `undefined` to an optional `signal?: AbortSignal` is a type error. @@ -512,8 +513,6 @@ export class BasicCompactService extends CompactService { // ---- Internal helpers ---- - /** - /** * Frame the raw summary blocks into the content that lands on the surface: * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1134862793..10aae6d8be 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -975,6 +975,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { expect(adapter.lastOptions!.system).toContain('compaction engine') expect(adapter.lastOptions!.system).toContain('## Next Step') expect(adapter.lastOptions!.maxTokens).toBe(512) + expect(adapter.lastOptions!.sessionId).toBe(SessionId('summary')) expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' }) }) From 5252477bc9130e6ee7b446f212035d064d801729 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:10:49 +0800 Subject: [PATCH 21/28] fix(compact): make config knobs explicit and flag two review smells Address @tianyicui's minor-revision review on PR #110: - Make every BasicCompactConfig knob required except `auto` (defaults true): there is no data yet to justify default thresholds/budgets, so a consumer states each value explicitly. Drop the DEFAULTS export and the constructor's `= {}` default; example cordis.yml, the compaction e2e, the README, and every test construction site now pass a complete config (tests route through a `cfg()` helper). - Add a TODO on estimateContentTokens: char/4 is coarse; replace with a real tokenizer or post-response usage feedback in a follow-up. - Add a TODO on the agent/pre-step `fullSystemPrompt` param flagging it as a smell on a generic per-step seam (compaction is its sole consumer); a `//` line comment so it stays out of the generated catalog. --- docs/cordis-catalog/events-and-services.md | 14 +- examples/coding-agent/cordis.yml | 4 + examples/coding-agent/tests/compaction.e2e.ts | 2 + packages/compact/compact-basic/README.md | 27 ++-- packages/compact/compact-basic/src/index.ts | 9 +- packages/compact/compact-basic/src/types.ts | 48 +++---- .../compact-basic/tests/compact-basic.spec.ts | 124 +++++++++++------- .../tests/compact-loop-repro.spec.ts | 3 + packages/core/agent/src/types.ts | 5 + 9 files changed, 139 insertions(+), 97 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 6a43db0b0e..d6a34d48ed 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 82253a0aef..e5bbea2811 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -81,7 +81,11 @@ name: '@deepseek-ai/dsh-compact-basic' config: contextWindow: 128000 + thresholdRatio: 0.8 retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 # The subagent seam + BOTH in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index a300aac69a..39483dc848 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -50,7 +50,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa contextWindow: 2400, thresholdRatio: 0.5, retainTokens: 500, + summarizationModel: '', maxTokens: 2048, + compactionRetries: 1, }, persistenceRoot: './.sessions', }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 5b2e177997..c195ae42fb 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -21,15 +21,17 @@ The abstract contract states only WHAT compaction does; this backend owns every ## Config (`BasicCompactConfig`) -| Key | Default | Meaning | +Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`. + +| Key | Required | Meaning | |---|---|---| -| `contextWindow` | `128000` | Context window size in tokens. | -| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. | -| `retainTokens` | `20480` | Tokens of recent context to keep intact. | -| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). | -| `maxTokens` | `8192` | Provider generation cap for the summarization call; may include reasoning tokens. | -| `compactionRetries` | `1` | Extra compaction attempts after the first if the compacted surface remains over threshold. | -| `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | +| `contextWindow` | yes | Context window size in tokens. | +| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. | +| `retainTokens` | yes | Tokens of recent context to keep intact. | +| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). | +| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. | +| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. | +| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | ## Usage @@ -41,7 +43,14 @@ export const name = 'compact-basic' export const inject = ['llm'] export function apply(ctx: Context): void { - ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 }) + ctx.plugin(BasicCompactService, { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } ``` diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f7fdadb3fa..f53dace461 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -39,7 +39,7 @@ import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' export type { BasicCompactConfig, ResolvedConfig } from './types.ts' -export { DEFAULTS, resolveConfig } from './types.ts' +export { resolveConfig } from './types.ts' /** Per-block structural overhead for JSON framing / type tag. */ const BLOCK_OVERHEAD = 4 @@ -155,10 +155,10 @@ function finishError(finish: FinishReason): Error | undefined { export class BasicCompactService extends CompactService { static inject = ['llm'] - /** Resolved configuration (defaults applied). */ + /** Resolved configuration (`auto` defaulted). */ readonly config: ResolvedConfig - constructor(ctx: Context, config: BasicCompactConfig = {}) { + constructor(ctx: Context, config: BasicCompactConfig) { super(ctx) this.config = resolveConfig(config) @@ -207,6 +207,9 @@ export class BasicCompactService extends CompactService { // ---- Token estimation (overridable hooks) ---- + // TODO: char/4 is a coarse heuristic. Replace with an exact count — a real + // tokenizer, or the provider's post-response `usage` (input tokens) fed back + // as a correction — so threshold decisions match the model's actual budget. /** * Estimate the token count of content blocks — char/4 with per-block * overhead. Override in a subclass to plug in a real tokenizer. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 8c4753c84f..98195d8883 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -9,40 +9,34 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Backend configuration — all optional with sensible defaults. */ +/** + * Backend configuration. Every knob is REQUIRED except `auto`: there is no + * concrete data yet to justify default thresholds/budgets, so a consumer must + * state each value explicitly rather than inherit a guessed default. `auto` + * alone defaults to `true` (auto-compaction is the intended posture). + */ export interface BasicCompactConfig { - /** Context window size in tokens (default 128000). */ - contextWindow?: number - /** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */ - thresholdRatio?: number - /** Number of tokens of recent context to retain during compaction (default 20480). */ - retainTokens?: number - /** Model to use for summarization (default '' — uses the agent's model). */ - summarizationModel?: string - /** Provider generation cap for the summarization call (default 8192). */ - maxTokens?: number - /** Extra compaction attempts when the first compacted surface is still over threshold (default 1). */ - compactionRetries?: number + /** Context window size in tokens. */ + contextWindow: number + /** Compact when estimated token usage exceeds this fraction of context window. */ + thresholdRatio: number + /** Number of tokens of recent context to retain during compaction. */ + retainTokens: number + /** Model to use for summarization (`''` — uses the agent's model). */ + summarizationModel: string + /** Provider generation cap for the summarization call. */ + maxTokens: number + /** Extra compaction attempts when the first compacted surface is still over threshold. */ + compactionRetries: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean } -/** Resolved config with all defaults applied. */ +/** Resolved config with `auto` defaulted. */ export type ResolvedConfig = Required -/** Default configuration values. */ -export const DEFAULTS: ResolvedConfig = { - contextWindow: 128000, - thresholdRatio: 0.8, - retainTokens: 20480, - summarizationModel: '', - maxTokens: 8192, - compactionRetries: 1, - auto: true, -} - /** - * Apply defaults to a partial config and reject nonsensical numeric knobs. + * Default `auto` 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 @@ -52,7 +46,7 @@ export const DEFAULTS: ResolvedConfig = { * throwing if the surface still exceeds the threshold. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - const resolved = { ...DEFAULTS, ...config } + const resolved: ResolvedConfig = { auto: true, ...config } assertPositiveInteger('contextWindow', resolved.contextWindow) assertRatio('thresholdRatio', resolved.thresholdRatio) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 10aae6d8be..1929e656be 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -12,6 +12,25 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** A never-aborted signal for the required `compactIfNeeded`/listener arg. */ const SIGNAL = new AbortController().signal +/** + * Baseline config with every required knob set. `BasicCompactConfig` has no + * defaults for the numeric/model knobs (only `auto` defaults), so each test + * builds a complete config via `cfg()` and overrides only the knob under test. + */ +const TEST_CONFIG: BasicCompactConfig = { + contextWindow: 128000, + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, +} + +/** A complete config with `overrides` applied over the baseline. */ +function cfg(overrides: Partial = {}): BasicCompactConfig { + return { ...TEST_CONFIG, ...overrides } +} + /** Long enough that the real checkpoint preamble is smaller than two fixture messages. */ const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20) @@ -59,8 +78,8 @@ function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { } /** Create a test service with a throwaway context (auto disabled — no model). */ -function createTestService(config: BasicCompactConfig = {}): TestCompactService { - return new TestCompactService(new Context(), { auto: false, ...config }) +function createTestService(overrides: Partial = {}): TestCompactService { + return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) } /** @@ -761,7 +780,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => { describe('BasicCompactService token estimation (char/4 heuristic)', () => { it('estimates text blocks with char/4 + overhead', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 const blocks: ContentBlock[] = [ { type: 'text', text: 'this is a somewhat longer text block' }, @@ -771,13 +790,13 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates reasoning blocks same as text', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) }) it('estimates tool-call blocks from name + arguments', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 expect(svc.estimateContentTokens([ { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, @@ -785,7 +804,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates tool-result blocks recursively', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 expect(svc.estimateContentTokens([ { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, @@ -793,12 +812,12 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { }) it('estimates image blocks at fixed 85 tokens', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85) }) it('returns 0 for empty content blocks', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([])).toBe(0) }) }) @@ -806,7 +825,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { describe('BasicCompactService HMR safety', () => { it('registers as ctx.compact', () => { const ctx = new Context() - void new BasicCompactService(ctx, { auto: false }) + void new BasicCompactService(ctx, cfg({ auto: false })) expect(ctx.compact).toBeDefined() expect(ctx.compact).toBeInstanceOf(BasicCompactService) }) @@ -819,7 +838,7 @@ describe('BasicCompactService HMR safety', () => { // under the "llm inject (real plugin-load path)" suite.) const ctx = new Context() await ctx.plugin(LlmService) - const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await fiber.dispose() @@ -829,30 +848,33 @@ describe('BasicCompactService HMR safety', () => { describe('BasicCompactService config validation', () => { it('rejects invalid numeric config values', () => { - expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 0 })).toThrow(/contextWindow .* positive integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 0 })).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 1.1 })).toThrow(/thresholdRatio .* \(0, 1\]/) - expect(() => new BasicCompactService(new Context(), { auto: false, retainTokens: -1 })).toThrow(/retainTokens .* non-negative integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, maxTokens: 0 })).toThrow(/maxTokens .* positive integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, compactionRetries: -1 })) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 0 }))) + .toThrow(/contextWindow .* positive integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 0 }))).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 1.1 }))).toThrow(/thresholdRatio .* \(0, 1\]/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, retainTokens: -1 }))) + .toThrow(/retainTokens .* non-negative integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, maxTokens: 0 }))).toThrow(/maxTokens .* positive integer/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, compactionRetries: -1 }))) .toThrow(/compactionRetries .* non-negative integer/) - expect(() => new BasicCompactService(new Context(), { auto: false, summarizationModel: 1 } as unknown as BasicCompactConfig)) - .toThrow(/summarizationModel must be a string/) - expect(() => new BasicCompactService(new Context(), { auto: 'no' } as unknown as BasicCompactConfig)) + expect(() => new BasicCompactService( + new Context(), cfg({ auto: false, summarizationModel: 1 } as unknown as Partial), + )).toThrow(/summarizationModel must be a string/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial))) .toThrow(/auto must be a boolean/) }) it('accepts a large retain budget because convergence is enforced dynamically', () => { - expect(() => new BasicCompactService(new Context(), { + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 900, - })).not.toThrow() + }))).not.toThrow() }) it('the default config is valid', () => { - expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow() + expect(() => new BasicCompactService(new Context(), cfg({ auto: false }))).not.toThrow() }) }) @@ -967,7 +989,7 @@ function summarize(svc: BasicCompactService, text: string, model: string) { describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { auto: false, maxTokens: 512 }) + const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) @@ -981,10 +1003,10 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('uses maxTokens as the summarization provider cap', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, { + const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 50, - }) + })) await summarize(svc, 'User: hi', 'test-model') @@ -999,7 +1021,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { // synthesized user/message summary as an orphaned call. { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, ]) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const summary = await summarize(svc, 'User: hi', 'test-model') @@ -1008,26 +1030,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('throws when no text block remains after filtering', async () => { const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) }) it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) }) it('rethrows when the stream ends with a finish-error chunk', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) }) it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) expect(error?.message).toBe('opaque failure') expect(error?.code).toBeUndefined() @@ -1035,19 +1057,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('rethrows when the stream ends with a finish-aborted chunk', async () => { const ctx = await ctxWithFinish({ kind: 'aborted' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) }) it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) }) it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const session = multiTurnSession(2, 1) const before = [...session.surface.nodes] const nodes = session.surface.nodes @@ -1065,7 +1087,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('compactRegion uses the real summarizer end-to-end', async () => { const { ctx } = await ctxWithModel('CONDENSED') - const svc = new BasicCompactService(ctx, { auto: false }) + const svc = new BasicCompactService(ctx, cfg({ auto: false })) const session = multiTurnSession(2, 1) const nodes = session.surface.nodes @@ -1115,7 +1137,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts (mutating the surface) when over threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) const session = multiTurnSession(5, 1) // 10 surface nodes const agent = stubAgent(session, 'test-model') const before = session.surface.nodes.length @@ -1133,12 +1155,12 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const ctx = new Context() const infos: string[] = [] ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info - void new TestCompactService(ctx, { + void new TestCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.7, retainTokens: 10, compactionRetries: 0, - }) + })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1151,7 +1173,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })) const session = multiTurnSession(3, 1) // over the 0.5 threshold const agent = stubAgent(session, 'test-model') @@ -1163,7 +1185,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does nothing when under threshold', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 128000, thresholdRatio: 0.8 })) const session = multiTurnSession(1, 1) const agent = stubAgent(session, 'test-model') @@ -1176,7 +1198,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => // surface is untouched (the loop derives the full history). const ctx = new Context() await ctx.plugin(LlmService) - void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'missing-model') const before = session.surface.nodes.length @@ -1189,7 +1211,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('does not register the listener when auto is false', async () => { const { ctx } = await ctxWithModel('SUMMARY') - void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }) + void new BasicCompactService(ctx, cfg({ auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })) const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1203,7 +1225,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => options.model = 'routed-model' return next() }) - void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }) + void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })) const session = multiTurnSession(5, 1) const agent = stubAgent(session) @@ -1216,11 +1238,11 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('removes the auto pre-step listener when the plugin fiber is disposed', async () => { const { ctx } = await ctxWithModel('SUMMARY') - const fiber = await ctx.plugin(BasicCompactService, { + const fiber = await ctx.plugin(BasicCompactService, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, - }) + })) const session = multiTurnSession(5, 1) const agent = stubAgent(session, 'test-model') @@ -1327,7 +1349,7 @@ describe('BasicCompactService edge cases', () => { }) it('estimates unknown block types via JSON length (default branch)', () => { - const svc = new BasicCompactService(new Context(), { auto: false }) + const svc = new BasicCompactService(new Context(), cfg({ auto: false })) // A block whose type is none of the known kinds — exercises the default arm. const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) @@ -1337,12 +1359,12 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - void new BasicCompactService(ctx, { + void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, compactionRetries: 0, - }) + })) const session = multiTurnSession(4, 1) const agent = stubAgent(session, 'test-model') @@ -1420,7 +1442,7 @@ describe('BasicCompactService edge cases', () => { const { ctx } = await ctxWithModel('SUMMARY') const warnings: string[] = [] ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn - const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }) + const svc = new TestCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })) svc.summarizeError = 'boom' as unknown as Error const session = multiTurnSession(3, 1) const agent = stubAgent(session, 'test-model') @@ -1438,7 +1460,7 @@ describe('BasicCompactService edge cases', () => { // A large system prompt pushes the listener's estimate over threshold, but // retainTokens is huge so compactIfNeeded walks everything and returns null. // threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200. - const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 }) + const svc = new TestCompactService(ctx, cfg({ contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 })) const session = multiTurnSession(2, 1) const agent = stubAgent(session, 'test-model') const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200 @@ -1606,7 +1628,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so // the sibling-fiber ctx.llm resolution actually exercises the inject. - const fiber = await ctx.plugin(BasicCompactService, { auto: false }) + const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false })) const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) @@ -1635,7 +1657,7 @@ describe('BasicCompactService under the real invariants plugin', () => { await ctx.plugin(Invariants, {}) await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) - await ctx.plugin(BasicCompactService, { auto: false }) + await ctx.plugin(BasicCompactService, cfg({ auto: false })) const session = ctx.sessions.create() return { ctx, session, svc: ctx.compact as BasicCompactService } } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dddb636b21..319e30a73c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -97,6 +97,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr contextWindow: 64, thresholdRatio: 0.5, retainTokens: 20, + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, }) return { ctx, compact } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fc412f0e0e..407cce5250 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -206,6 +206,11 @@ declare module 'cordis' { * summarization model call). * @mode serial */ + // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction + // is its only consumer, so a wide event carries a string just one listener + // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy + // prompt provider, or move token-pressure measurement behind a + // compaction-specific seam instead of the shared pre-step checkpoint. 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the From 57900f9cbd7d04ed4dcfd1cd00902bdf0509fb68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:08:45 +0800 Subject: [PATCH 22/28] docs(i18n): address round-2 terminology review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ACP/SSE: show full English name in parens on first occurrence - fixture: drop 测试夹具 gloss, keep descriptive note - manifest: keep English, drop 首次出现可写 clause --- docs/i18n/terminology.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 6031f3f70a..f9baa40adc 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -4,7 +4,7 @@ | English | 中文 | 备注 | |---|---|---| -| ACP | ACP | | +| ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | | CLI | CLI | 首次出现可写:命令行界面(CLI) | @@ -19,14 +19,14 @@ | MCP | MCP | | | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | -| SSE | SSE | | +| SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | | fiber | fiber | 首次出现可写:fiber(插件运行时) | -| fixture | fixture | 首次出现可写:fixture(测试夹具);指测试前置数据或环境 | +| fixture | fixture | 指测试前置数据或环境 | | fork | fork | 保留英文 | | harness | harness | 保留英文 | -| manifest | manifest | 首次出现可写:manifest(描述模块或工具元数据的文件) | +| manifest | manifest | 描述模块或工具元数据的文件 | | schema DSL | schema DSL | | | schema | schema | 保留英文 | | seam | seam | 首次出现可写:seam(扩展点) | From 58b86839afe184ab22b4abc98ddd55428b653944 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:29:33 +0800 Subject: [PATCH 23/28] docs(compaction): flag missing snapshot coverage with a FIXME The compaction e2e is the only coverage of runaway compaction; there is no keyless full-transcript snapshot. Record why in a FIXME on the e2e module doc: dsh-llm-replay rebuilds one model call per (turn, step) from assistant/chunk events, but summarize() assembles its stream locally and appends none, so the interleaved summarization call is unreplayable until the replay harness can serve it. --- examples/coding-agent/tests/compaction.e2e.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 39483dc848..2b8f278be3 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -18,6 +18,13 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. * landed in the real session log, the surface actually shrank (a replace node * exists and shadowed older nodes), and the agent still produced a final answer * after compaction (so the summarized history did not break the conversation). + * + * FIXME(compaction-snapshot): this key-gated e2e is the ONLY coverage of runaway + * compaction — there is no keyless full-transcript snapshot of it. dsh-llm-replay + * reconstructs one model call per (turn, step) from `assistant/chunk` events, but + * `summarize()` assembles its stream into a local BlockAssembler and appends no + * `assistant/chunk`, so the interleaved summarization call is unreplayable. A + * snapshot needs replay-harness work to serve that call; deferred as a follow-up. */ let workdir: string | undefined From 140f818a4245f53670bf3cc5275a0ccfc0031c87 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:26:45 +0800 Subject: [PATCH 24/28] refactor(events): remove the turn boundary mirror events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged. --- docs/architecture.md | 8 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cordis-catalog/events-and-services.md | 46 +--- docs/rfc/README.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-06-30-event-domain-semantics.md | 16 +- ...-20-remove-agent-boundary-mirror-events.md | 37 ++++ ...-20-remove-agent-boundary-mirror-events.md | 31 --- packages/core/agent-loop/src/loop.ts | 73 +++---- packages/core/agent-loop/tests/cancel.spec.ts | 27 ++- .../agent-loop/tests/coverage-edges.spec.ts | 71 +----- packages/core/agent-loop/tests/loop.spec.ts | 33 ++- .../agent-loop/tests/review-fixes.spec.ts | 205 +++++------------- packages/core/agent/README.md | 6 +- packages/core/agent/src/types.ts | 38 +--- packages/support/ui-stdio/README.md | 7 +- packages/support/ui-stdio/src/index.ts | 33 ++- .../support/ui-stdio/tests/ui-stdio.spec.ts | 52 ++++- packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 32 +-- 20 files changed, 282 insertions(+), 441 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md diff --git a/docs/architecture.md b/docs/architecture.md index da6641440d..8f83625ab0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,7 +159,7 @@ forever: steering pending forces cont = true (from continuation listeners OR from step/end session-event listeners — the /goal pattern; hasSteering override) if !cont: break - session('turn/end'); emit agent/turn-end + session('turn/end') ⟵ durable turn boundary (no agent/* mirror) await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure reported via agent/error, not fatal) leftover steering re-enqueued as queued messages ⟵ steering is never stranded @@ -170,7 +170,7 @@ Error containment: a throwing `agent/turn-continuation` listener or a broken ste Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. -A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. +A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) is reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. **Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). @@ -196,8 +196,8 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl |---|---| | Hook system (user + project level) | listeners on `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`; a hooks plugin bridges config files to shell commands | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | -| `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | -| Dynamic workflow | orchestrator plugin on `agent/turn-end` (or the `step/end` session event) driving `send`/`steer` (+ sub-agents later) | +| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| Dynamic workflow | orchestrator plugin on the `turn/end` (or `step/end`) session event driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e6c0378361..48b3874bd4 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (settle from the durable `turn/end` session event — the boundary is a session event, not an `agent/*` mirror — with `agent/status` as the fallback if a peer listener starved yours), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c26b453a4f..e9a4447aec 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:164`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:170`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:263`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -75,7 +75,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -99,7 +99,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -123,7 +123,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -135,7 +135,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -147,31 +147,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) - -#### `agent/turn-end` — emit - -A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, failed, disposed, or crash-interrupted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the reason union is merge-extensible, so a plugin can add further variants. - -```ts cordis-catalog -'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void -``` - -Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) - -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) - -#### `agent/turn-start` — emit - -A turn began. `turn` is the 1-based turn number within the session. - -```ts cordis-catalog -'agent/turn-start'(agent: Agent, turn: number): void -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 30dbb7702c..64868d475e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -50,7 +50,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | ### Architecture @@ -97,6 +96,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 42e231430b..63ebc1c875 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload. -The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush` (which runs as the post-`turn/end` durability checkpoint) or a throwing `agent/turn-end` listener (after `closeTurn` already appended `turn/end`) — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So those post-turn failures are reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. +The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index c33ff3d5b3..5ca6f874b5 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -22,18 +22,14 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab - **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the turn boundaries) that notify with the `Agent` in hand. - **`tools/*` — the tool registry + execution seam.** -**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A datum that is BOTH — a turn or step boundary — lives in the session log, and is mirrored as an `agent/*` emit ONLY where a live consumer provably needs the `Agent` handle at that instant. +**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins (prune case-by-case):** - -- `agent/turn-start` — **KEPT.** The stdio UI (`dsh-ui-stdio`) labels turn output by `agent.id`, which the `turn/start` session event does not carry. A genuine live-object need. -- `agent/turn-end` — **KEPT.** The stdio UI listens to print the next-prompt glyph. (Note: the ACP bridge does NOT settle on this event — it settles from `session/event` `turn/end` plus `agent/status`; the surviving justification is the stdio UI alone.) -- `agent/step-start`, `agent/step-end` — **REMOVED.** No production consumer needs the live `Agent` at a step boundary; a consumer that wants per-step boundaries reads the durable `step/start`/`step/end` session events. Removing the two emits also simplifies the loop's `closeStep` (one append, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences -- The loop no longer emits `agent/step-start`/`agent/step-end`; `closeStep` appends `step/end` only, and a throwing `step/end` session-event listener is the surviving step-boundary-listener failure path (contained by `closeStep` → `failTurn`, the turn closes balanced). -- Tests that observed step boundaries via the removed emits now observe the durable `step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved together. +- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless). +- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. - The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. -- This is a partial, conservative realization of the broader [proposed simplification "Stop mirroring durable boundaries as agent events"](../../proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md): that RFC proposes removing ALL boundary mirrors (including the turn boundaries and `agent/steering`) and migrating the stdio UI's turn rendering onto `session/event`. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration. -- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the two events. +- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (a live control signal, not a boundary mirror) is retained; see that RFC's scope section. +- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the mirror events. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md new file mode 100644 index 0000000000..d4cf8bbfe9 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -0,0 +1,37 @@ +# RFC: Stop mirroring durable boundaries as agent events + +Status: implemented (accepted 2026-07-01) + + + +## Problem + +The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. + +This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. + +## Decision + +Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. + +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. + +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. + +## Scope: what is and isn't removed + +Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. + +RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: + +- `agent/steering` — a live control signal, not a boundary. (The original proposal bundled it into the removal; validating against the code, it is not a duplicate of a durable boundary, so removing it here would have been scope creep. Its fate is a separate future decision.) +- `agent/stream-chunk` — the live token stream. `assistant/chunk` persistence remains load-bearing, so the chunk stream could later be evaluated as a mirror, but that is a separate decision. +- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. + +## What we give up + +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md deleted file mode 100644 index 4b1cd75a56..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ /dev/null @@ -1,31 +0,0 @@ -# RFC: Stop mirroring durable boundaries as agent events - -Status: proposed - -## Problem - -The loop records the canonical transcript in `SessionEvent` and also emits a parallel set of live `agent/*` mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, `agent/stream-chunk`, and `agent/steering`. The mirrors make consumers choose between two sources of truth. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI is the only production consumer that still renders turn boundaries and the token stream from the mirror events; it already renders tool calls and results from `session/event`. - -This duplication is not free. Every lifecycle change has to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also make failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. - -## Proposal - -Make `session/event` the live transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. Keep agent lifecycle/control events that are not transcript data: `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued`. `agent/queued` is an inbox acknowledgement rather than a transcript mirror: it fires before any durable event exists, and cancelled queued work may never enter the log. - -Remove the duplicate durable-boundary mirrors from the agent event taxonomy. If a UI wants an agent handle from a session event, it can keep a small map from session id to agent built from `agent/created`/`agent/disposed`, or the registry can offer an explicit lookup. The canonical record remains the event-sourced session log. - -## Acceptance criteria - -- ACP and stdio render transcript content from `session/event`. -- `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, and `agent/steering` are removed or reduced to private implementation details. -- `agent/queued` is either retained and documented as live-only inbox/control state, or deleted in a separate proposal that names the queue-acknowledgement capability loss. -- Tests assert the persisted event stream, not a second mirror stream, for turn and step ordering. -- Documentation presents `SessionEvent` as both the durable source and the live transcript feed. - -## What we give up - -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: transcript consumers should not depend on a second event feed that can drift from the durable log. - -## Related - -Because high-fidelity `assistant/chunk` persistence remains load-bearing, `agent/stream-chunk` can be evaluated as another mirror of durable session data rather than as the only token stream. If a future proposal moves chunks out of the canonical log, `agent/stream-chunk` would need a fresh decision as a deliberately live-only UI signal. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f71d6167b8..eba75f9615 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -145,7 +145,7 @@ export interface LoopHandle { * forever: * wait for queued messages (idle) * TURN (error-contained — a throwing plugin ends the turn, never the loop): - * drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start + * drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror) * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble @@ -165,7 +165,7 @@ export interface LoopHandle { * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) * if !cont && steering arrived from step/end session-event/continuation listeners: cont = true * if !cont: break - * session('turn/end'); emit agent/turn-end + * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) * await ctx.parallel('session/flush', session) ⟵ durability checkpoint * re-enqueue leftover steering as queued ⟵ steering is never stranded * idle (emit agent/status) unless more queued @@ -277,7 +277,6 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let reason: TurnEndReason = { kind: 'completed' } let step = 0 - let turnEnded = false let stepOpen = false let errorReported = false @@ -320,47 +319,38 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // Set the error reason ONLY while the turn is still open — closeTurn appends - // turn/end with it. If the turn has already ended (the only way here: a - // throwing agent/turn-end listener after closeTurn(true) already appended - // turn/end), the reason can no longer affect the durable log, so log the late - // throw directly instead — otherwise the listener exception would vanish. - if (!turnEnded) { - reason = { kind: 'error', step, ...errorData(err) } - } else { - ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`) - } + // The turn is always still open here: the only failure that can reach + // failTurn once turn/end is appended would be a throwing turn-boundary + // listener, and turn boundaries are durable session events with no agent/* + // mirror to throw. A throwing `turn/end` session-event listener is already + // contained inside closeTurn (append pushes before notifying, so the + // boundary is durable). So set the error reason for closeTurn to append. + reason = { kind: 'error', step, ...errorData(err) } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // contained: the error is already captured (on `reason`, or via the logger - // above); a throwing agent/error listener must not prevent the turn from - // closing. + // contained: the error is already captured on `reason`; a throwing + // agent/error listener must not prevent the turn from closing. } } - // Close the turn exactly once (idempotent via turnEnded). `emit` is false on - // the error path (the failure was already surfaced via agent/error) and true - // on the normal/inline-error path. A throwing agent/turn-end listener on the - // normal path escapes to the outer catch, which surfaces it via failTurn — - // turn/end is already appended, so balance holds either way. - const closeTurn = (emit: boolean): void => { - if (turnEnded) return - turnEnded = true + // Close the turn. Called exactly once per turn — the normal loop exit and the + // outer catch are mutually exclusive paths, and this never throws (the append + // is contained below), so there is no re-entry to guard against (unlike + // closeStep, which the cancel branches and the outer catch can both reach). + // Turn boundaries are durable session events only — there is no agent/* turn + // emit to mirror them (see the agent event-domain rule). + const closeTurn = (): void => { // Session.append pushes turn/end BEFORE notifying session/event listeners, // so a throwing listener leaves turn/end in the log (the turn is balanced) - // but would otherwise escape — from the outer catch's closeTurn(false) it - // would propagate to the runLoop backstop, and from the normal-path - // closeTurn(true) it would skip the agent/turn-end emit. Contain it: the - // boundary is durable either way, and finalization must not abort on a bad - // listener. (On the normal path the outer catch also re-runs closeTurn, - // which is an idempotent no-op once turnEnded is set.) + // but would otherwise escape — from the outer catch it would propagate to + // the runLoop backstop. Contain it: the boundary is durable either way, and + // finalization must not abort on a bad listener. try { session.append('turn/end', { turn, reason }) } catch (error: unknown) { ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`) } - if (emit) ctx.emit('agent/turn-end', agent, turn, reason) } try { @@ -375,13 +365,12 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, for (const message of queued) { session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' }) } - ctx.emit('agent/turn-start', agent, turn) while (true) { step += 1 - // Steering from the previous round's continuation listeners (or - // turn-start listeners on the first step) joins before the request. + // Steering from the previous round's continuation listeners joins before + // the request. drainSteering(ctx, agent, turn) // The step's AbortController exists BEFORE any async pre-step work so a @@ -530,8 +519,8 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } } - // Normal / inline-error loop exit: close the turn and notify. - closeTurn(true) + // Normal / inline-error loop exit: close the turn. + closeTurn() } catch (error: unknown) { // Decide whether this turn was ever opened from the LOG, not a flag. // Session.append pushes the event BEFORE notifying session/event listeners, @@ -550,18 +539,16 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, closeStep() // Choose the close reason. Disposal wins only if no error was already // reported: a turn disposed mid-step sets reason=disposed in the step-error - // branch (without reporting an error), and if closeTurn(true)'s turn-end - // emit then throws, we land here and must PRESERVE disposed rather than - // overwrite it with the listener's throw. Otherwise a boundary-emit throw - // on a live agent is a real failure → failTurn. (errorReported is mutated - // only inside the failTurn closure, which the analyzer can't follow, hence - // the inline lint-disable.) + // branch (without reporting an error), so preserve disposed rather than + // overwrite it. Otherwise a mid-step throw on a live agent is a real + // failure → failTurn. (errorReported is mutated only inside the failTurn + // closure, which the analyzer can't follow, hence the inline lint-disable.) if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition reason = { kind: 'disposed' } } else { failTurn(toError(error)) } - closeTurn(false) + closeTurn() } // Durability checkpoint: persistence plugins drain write-behind buffers. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 7950cf80a8..df94c6c503 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -117,7 +117,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -134,7 +134,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -166,22 +166,23 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) - it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => { + it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A turn-start listener fires BEFORE any AbortController is installed for the - // step. Cancelling there must still drop the step (the turn-scoped marker, - // not the step AbortController, is what catches this) — no model step runs. + // A turn/start listener fires right after turn/start is appended, BEFORE any + // AbortController is installed for the step. Cancelling there must still drop + // the step (the turn-scoped marker, not the step AbortController, is what + // catches this) — no model step runs. let streamed = false ctx.on('agent/stream-chunk', () => { streamed = true }) - const dispose = ctx.on('agent/turn-start', (subject) => { - if (subject === agent) agent.cancel('from turn-start') + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start') }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -210,7 +211,7 @@ describe('Agent.cancel()', () => { }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -271,9 +272,11 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('session/event', (_session, event) => { if (event.type === 'step/start') steps += 1 }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/start') steps += 1 + if (event.type === 'turn/end') reasons.push(event.data.reason) + }) let continued = false ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 3eefbf6986..7a044bfb57 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) { } describe('turn boundary listener throws (handled in-turn, loop survives)', () => { - it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => { - // The agent/turn-start emit happens AFTER turn/start is appended to the log, - // so a throwing listener is handled inside runTurn (the turn is balanced and - // closed via failTurn → agent/error), NOT rethrown to the runLoop backstop. - // The second turn should proceed normally and consume the first script entry. - const adapter = new MockAdapter([textResponse('turn 2')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - - let threwOnce = false - ctx.on('agent/turn-start', () => { - if (!threwOnce) { - threwOnce = true - throw new Error('broken turn-start listener') - } - }) - - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - - send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['broken turn-start listener']) - // The turn is balanced: its turn/start was logged, so a turn/end was owed - // and appended (decided from the log, not a flag). - expect(agent.session.events.at(-1)?.type).toBe('turn/end') - - // loop survives: second turn works fine and makes the model call - send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) - expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true) - }) - - it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => { - const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - - let threwOnce = false - ctx.on('agent/turn-end', () => { - if (!threwOnce) { - threwOnce = true - throw new Error('broken turn-end listener') - } - }) - - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - - send(agent, 'first') - await waitForIdle(ctx, agent) - // The turn-end throw happens after the model call is complete, so turn 1's - // request is consumed. turn/end is already in the log (append pushes before - // notifying), so the turn is balanced; the error is surfaced via agent/error. - expect(errors.map(e => e.message)).toEqual(['broken turn-end listener']) - - // loop survives: second turn works fine - send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) - }) - it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => { // A non-serializable message source makes the turn/start append throw BEFORE // the event is pushed (Session.append validates before push), so turn/start @@ -192,14 +129,14 @@ describe('tool JSON parse', () => { }) describe('toError normalization', () => { - it('normalizes non-Error throws from turn-start listeners via toError', async () => { + it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/turn-start', () => { - if (!threwOnce) { + ctx.on('session/event', (_session, event) => { + if (event.type === 'turn/start' && !threwOnce) { threwOnce = true throw 'naked string error' // non-Error throw, normalized via toError } @@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 311bc88fa3..f3a6da38b4 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -46,21 +46,20 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Turn boundaries are live agent/* emits; step boundaries are durable - // session events only (no agent/* mirror). Interleave both feeds in fire - // order to assert the full boundary nesting. + // All boundaries — turn and step — are durable session events on the + // session/event feed (no agent/* mirror). Record them in fire order to + // assert the full boundary nesting. const order: string[] = [] - for (const name of ['agent/turn-start', 'agent/turn-end'] as const) { - ctx.on(name, () => void order.push(name)) - } ctx.on('session/event', (_session, event) => { - if (event.type === 'step/start' || event.type === 'step/end') order.push(event.type) + if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') { + order.push(event.type) + } }) send(agent, 'hi') await waitForIdle(ctx, agent) - expect(order).toEqual(['agent/turn-start', 'step/start', 'step/end', 'agent/turn-end']) + expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) const types = agent.session.events.map(e => e.type) // turn/start opens the turn, THEN the queued user message is recorded inside @@ -436,7 +435,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // wait until the stream is hanging, then cancel @@ -456,7 +455,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -490,7 +489,7 @@ describe('agent loop', () => { }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -512,7 +511,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -545,7 +544,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -587,7 +586,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -606,7 +605,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -683,7 +682,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) // queue two messages while idle — first starts turn 1 immediately; // queue the second during turn 1 via a stream-chunk hook @@ -730,7 +729,7 @@ describe('agent loop', () => { const errors: Error[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'hi') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 092e63a4e5..51fdd2e146 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -132,7 +132,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -217,20 +217,21 @@ describe('HIGH: steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') }) - it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => { + it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let steeredOnce = false - ctx.on('agent/turn-end', () => { - if (steeredOnce) return - steeredOnce = true - agent.steer([{ type: 'text', text: 'too late for this turn' }]) - }) - const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + let steeredOnce = false + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && !steeredOnce) { + steeredOnce = true + agent.steer([{ type: 'text', text: 'too late for this turn' }]) + } + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -332,7 +333,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/status', (_agent, status) => void statuses.push(status)) - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -461,7 +462,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.effect(() => forked.start()) const turns: number[] = [] - ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.send([{ type: 'text', text: 'continue' }]) await new Promise((resolve) => { ctx2.on('agent/status', (subject, status) => { @@ -505,7 +506,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -530,7 +531,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -548,7 +549,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -619,28 +620,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar } } - it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => { - const adapter = new MockAdapter([textResponse('never reached')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // turn opened and closed; no step ran; exactly one error turn-end + emitted. - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 0, stepEnd: 0, errors: 1 }) - expect(errors.map(e => e.message)).toEqual(['boom turn-start']) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', step: 0, message: 'boom turn-start' }) - // model was never called (we threw before the step's request). - expect(adapter.requests).toHaveLength(0) - }) - it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) @@ -720,7 +699,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -737,46 +716,46 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) }) - it('preserves reason disposed when the turn-end emit throws during disposal (outer-catch disposed branch)', async () => { - // Dispose mid-step → the step-error branch sets reason=disposed (no error - // reported). closeTurn(true) then emits agent/turn-end, whose listener - // throws → control reaches the outer catch with isDisposed() && !errorReported, - // which must PRESERVE disposed rather than overwrite it with the listener's - // throw. This is the only path that exercises that catch sub-branch. - const adapter = new MockAdapter(['hang']) + it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { + // Reach the OUTER catch while disposed: an `agent/pre-step` listener requests + // disposal AND throws. The throw escapes the pre-step `await` (line ~419) to + // the loop's outer catch — BEFORE the post-pre-step disposal check at ~422 + // gets to run — so the catch sees `isDisposed() && !errorReported` and must + // PRESERVE reason=disposed rather than overwrite it with the listener's throw + // (disposal is not a failure). This is the surviving path to that sub-branch + // now that there is no turn-boundary emit to throw from. + const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - // The FIRST agent/turn-end emit throws (the disposal-driven turn end). let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end during disposal') } }) - // Collect agent/error emissions to prove none is surfaced through that - // channel either (the listener throw must be fully contained). + ctx.on('agent/pre-step', () => { + if (threw) return + threw = true + // Request disposal, then throw in the same synchronous tick: status flips + // to 'disposed' (the disposer aborts the step controller) and the throw + // drives control into the outer catch with isDisposed() already true. + void fiber.dispose() + throw new Error('boom pre-step during disposal') + }) const errorEmits: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() // dispose during the hanging step await agent.done - // The throwing turn-end listener actually fired — proving the outer-catch - // path was exercised, not skipped. - expect(threw).toBe(true) - const e = [...agent.session.events] - // Exactly one turn/start and one turn/end (balanced); the turn/end carries - // the disposed reason, NOT an error reason from the throwing listener. + // Balanced: one turn/start, one turn/end carrying disposed (NOT error). expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - // The throwing turn-end listener is contained: the turn/end carries the - // disposed reason (not an error) and no agent/error is emitted (disposal is - // not a failure; the throw is swallowed). expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) + // No step opened (the throw was before step/start) and disposal is not a + // failure, so no agent/error for the contained throw. + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -822,43 +801,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(adapter.requests).toHaveLength(1) }) - it('a throwing turn-end listener on a SUCCESSFUL turn leaves no event after turn/end (loadable log)', async () => { - // Regression: a normal turn completes, closeTurn(true) appends turn/end and - // emits agent/turn-end whose listener throws. The error must NOT be appended - // as a session event after turn/end — that would sit past the commit - // boundary and be dropped as a crash tail on resume (the turn-enclosure RFC). It is - // surfaced via agent/error instead, and the log's last event is turn/end. - const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - expect(c.turnEnd).toBe(1) - expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end) - expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary - expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error - // The late throw is also logged directly: failTurn's turn-already-ended - // branch warns so a throwing turn-end listener after turn/end never vanishes. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed')) - // The whole log is loadable (nothing dropped): a fresh replay sees the turn. - const replay = new Session(SessionId('replay'), [...agent.session.events]) - expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant']) - - // loop survives. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(2) - }) - it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => { // closeStep() must surface a throwing step/end listener via failTurn so the // turn ends with reason error, not a silent "completed" with the throw @@ -902,39 +844,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c2.stepStart).toBe(c2.stepEnd) }) - it('a step error followed by a throwing turn-end listener logs the error exactly once (no double-report)', async () => { - // The step fails (finish-error) → failTurn records ONE error and sets the - // error reason. closeTurn(true) then appends turn/end and emits - // agent/turn-end, whose listener throws → the outer catch calls failTurn - // again, but its errorReported guard makes it a no-op. Trap #1: exactly one - // error, the turn stays balanced. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] - const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // exactly one error turn-end + one agent/error emit, despite two failTurn calls. - expect(c.errors).toBe(1) - expect(errors.map(e => e.message)).toEqual(['provider down']) - expect(c.turnStart).toBe(1) - expect(c.turnEnd).toBe(1) // single turn/end, balanced - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider down' }) - - // loop survives the compound failure. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(2) - }) - it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => { // A finish-error stream opens a step then fails it, driving finalization // through closeStep() with the step open. closeStep appends step/end; a @@ -974,11 +883,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => { // closeTurn appends turn/end; Session.append pushes it BEFORE notifying // session/event listeners, so a throwing listener leaves turn/end in the log - // (the turn is balanced) but must not escape — from the normal-path - // closeTurn(true) it would otherwise propagate; the append is contained so - // the turn/end emit + loop continue. (A throwing agent/turn-end LISTENER is - // a separate, already-tested path; here the session/event append notify is - // what throws.) + // (the turn is balanced) but must not escape — from the normal-path closeTurn + // it would otherwise propagate; the append is contained so the loop continues. + // Turn boundaries are durable session events only (no agent/* mirror), so this + // session/event append-notify throw is the sole turn-end-listener failure path. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) @@ -1117,7 +1025,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // Give the loop time to enter the step and reach assemble(). @@ -1143,10 +1051,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { // No step was opened, no LLM call was made. expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during assembly: the - // fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s - // emit, and the LIFO chain disposes effects in reverse registration order. - // The turn/end durable record is the one that matters. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror), so this asserts on the log. }) it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { @@ -1175,7 +1081,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1230,7 +1136,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1251,9 +1157,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during pre-step: the - // fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end - // is the authoritative record. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror). }) it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { @@ -1283,7 +1188,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -1348,7 +1253,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - // The durable turn/end reason is the authoritative record; agent/turn-end - // may not fire when disposal interleaves with closeTurn(true)'s emit. + // The durable turn/end reason is the authoritative turn-boundary record + // (turn boundaries have no agent/* mirror). }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 594ae06ef0..a27d7f8d57 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -32,11 +32,9 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/status` — idle / running / disposed transition - `agent/queued` — message entered inbox (source-resolved, steering flag) -#### Turn boundaries (emit) +#### Boundaries are durable session events, not `agent/*` emits -- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) - -Step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs per-step boundaries reads the durable `step/start`/`step/end` session events (the session log is the live boundary feed). The turn boundaries stay as `agent/*` emits because the stdio UI needs the `Agent` handle (`agent.id`) at the boundary, which the session event does not carry. See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md). +Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md). #### Interception seams diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 82682ef856..0dcc1d0501 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -26,13 +26,12 @@ * * **The rule:** a durable, replayable fact is a SessionEvent; a live * interception or a transient/live-object signal is an `agent`/`tools` Cordis - * event. A datum that is BOTH (a turn/step boundary) lives in the session log, - * and is mirrored as an `agent/*` emit ONLY where a live consumer provably - * needs the `Agent` handle at that instant. Turn boundaries are so mirrored - * (the stdio UI labels output by `agent.id`); step boundaries are NOT (no live - * consumer needs them — read `step/start`/`step/end` from the session log). + * event. A turn/step boundary is a durable fact: it lives in the session log + * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` + * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary + * keeps a session-id→agent map from `agent/created`/`agent/disposed`. * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` - * and the related `docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. + * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. * * @module @deepseek-ai/dsh-agent/types */ @@ -47,7 +46,7 @@ export type AgentId = Branded<'AgentId'> export function AgentId(id: string): AgentId { return id as AgentId } -import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' /** * Options an agent is created with. @@ -183,26 +182,11 @@ declare module 'cordis' { */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void - // ---- turn boundaries (emit) — the live boundary surface ---- - // Step boundaries are NOT mirrored here: a consumer that needs per-step - // boundaries reads the durable `step/start`/`step/end` session events (the - // session log is the live transcript feed). The TURN boundaries stay as - // agent/* emits because the only live consumer (the stdio UI) needs the - // `Agent` handle at the boundary to label output, which the session event - // does not carry. See the module doc's three-domain rule. - /** - * A turn began. `turn` is the 1-based turn number within the session. - * @mode emit - */ - 'agent/turn-start'(agent: Agent, turn: number): void - /** - * A turn ended. `reason` distinguishes a clean stop from a truncated, - * aborted, failed, disposed, or crash-interrupted one (`completed` | - * `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the - * reason union is merge-extensible, so a plugin can add further variants. - * @mode emit - */ - 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void + // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer + // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ + // `step/end` session events off the `session/event` feed (the session log is + // the live transcript feed). See the module doc's three-domain rule and the + // "remove agent boundary mirror events" RFC. // ---- step/request extension seams (serial + waterfall) ---- /** diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index b65fd4d8e1..25f53833b7 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -1,6 +1,8 @@ # @deepseek-ai/dsh-ui-stdio -A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the `agent/*` event taxonomy plus the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. +A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/stream-chunk`, `agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. + +This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages. This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`. @@ -23,8 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on. -- `agent/turn-start` / `agent/turn-end` — a `[ turn N]` header and a trailing `> ` prompt. -- `session/event` — `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`. +- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. ## The I/O seam diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index edfa82285e..5f3b2bdc1c 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -76,6 +76,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime + // Render label lookup: the `turn/start` session event carries only the turn + // number, so to print the short agent id (`[main turn 1]`) we map the + // session's id to its agent's id. The session id is not reliably the agent id + // (a session can be created with an explicit/client-supplied id), so build the + // map from `agent/created` rather than parsing the id string. + const labelBySession = new Map() + ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) + ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + let inReasoning = false ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => { if (chunk.type === 'reasoning-delta') { @@ -90,18 +99,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } }) - ctx.on('agent/turn-start', (agent, turn) => { - output.write(`\n[${agent.id} turn ${turn}] `) - }) - - ctx.on('agent/turn-end', () => { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write('\n> ') - }) - - ctx.on('session/event', (_session, event) => { - if (event.type === 'tool/call') { + // Transcript rendering off the durable `session/event` feed — turn/step + // boundaries, tool activity, and todos all come from the one canonical stream + // (no agent/* boundary mirrors). + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') { + const label = labelBySession.get(session.header.id) ?? session.header.id + output.write(`\n[${label} turn ${event.data.turn}] `) + } else if (event.type === 'turn/end') { + if (inReasoning) output.write('\x1B[0m') + inReasoning = false + output.write('\n> ') + } else if (event.type === 'tool/call') { const { name: toolName, arguments: args } = event.data if (inReasoning) output.write('\x1B[0m') inReasoning = false diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts index 7bd1fd4868..0c58211d82 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/support/ui-stdio/tests/ui-stdio.spec.ts @@ -56,11 +56,19 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, + // A minimal session stub: the UI reads only `session.header.id` (to map the + // session back to its agent id for the turn-boundary label). + session: { header: { id: `${id}-session` } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } +/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ +function makeSession(agentId: string): Session { + return { header: { id: `${agentId}-session` } } as Session +} + const CONFIG: Config = { welcome: 'hi there', agent: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { @@ -116,23 +124,54 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe(before) }) - it('renders turn-start and turn-end markers', async () => { + it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/turn-start', agent, 3) + // agent/created populates the session-id → agent-id label map. + ctx.emit('agent/created', agent) + const session = makeSession('main') + ctx.emit('session/event', session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, + } as SessionEvent) expect(out.text()).toContain('[main turn 3] ') - ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' }) + ctx.emit('session/event', session, { + type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, + } as SessionEvent) expect(out.text()).toContain('\n> ') }) - it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => { + it('falls back to the session id as the label when no agent is mapped', async () => { + const { ctx, out } = await setup() + // No agent/created emitted, so the label map is empty — the header id shows. + ctx.emit('session/event', makeSession('orphan'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[orphan-session turn 1] ') + }) + + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' }) - ctx.emit('agent/turn-end', agent, 1, { kind: 'completed' }) + ctx.emit('session/event', makeSession('main'), { + type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, + } as SessionEvent) expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) + it('drops the label mapping on agent/disposed', async () => { + const { ctx, out } = await setup() + const agent = makeAgent('main') + ctx.emit('agent/created', agent) + ctx.emit('agent/disposed', agent) + // After disposal the map no longer resolves the agent id — fall back to the + // session header id. + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main-session turn 1] ') + }) + it('renders tool/call and tool/result session events', async () => { const { ctx, out } = await setup() const session = {} as Session @@ -196,7 +235,8 @@ describe('createStdioChat rendering', () => { const { ctx, out } = await setup() const before = out.text() ctx.emit('session/event', {} as Session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } }, + type: 'user/message', seq: 1, time: 0, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, } as SessionEvent) expect(out.text()).toBe(before) }) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ad50383542..3f3fe967d0 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -57,7 +57,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal ## Settle-exactly-once -A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. +A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. ## Disposal & disconnect diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 754b1750be..3fab77a150 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -199,13 +199,14 @@ interface SessionRecord { } /** - * Drive the in-flight prompt's settle from the harness event stream. A turn - * can end three ways the bridge must all handle (AGENTS.md "honor cross-seam - * contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end` - * session event WITHOUT the agent event (a boundary emit threw inside the loop, - * which still appends `turn/end`); or the agent erroring/settling to idle. The - * first of these to fire settles the prompt; `settle` is then cleared so the - * others are no-ops (settle-exactly-once). + * Drive the in-flight prompt's settle from the harness event stream. The bridge + * settles off the durable log: the `turn/end` session event on the + * `session/event` feed for the prompt's own turn, with the agent + * erroring/settling to idle as a fallback (AGENTS.md "honor cross-seam contracts + * on BOTH sides") for the case where a throwing peer `session/event` listener + * starved the bridge's listener before it saw the boundary. The first of these + * to fire settles the prompt; `settle` is then cleared so the others are no-ops + * (settle-exactly-once). */ export function apply(ctx: Context, config: AcpConfig): void { // TODO(double-default): these literals duplicate the Config schema defaults @@ -318,15 +319,14 @@ export function apply(ctx: Context, config: AcpConfig): void { // the canonical log: every assistant/chunk and tool/call/result is logged, so // translating from the log makes live streaming and `session/load` replay // share the identical path (streamSessionEventUpdate). Both the owning-turn - // capture and the settle key off the log's own `turn/start`/`turn/end` — NOT - // the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER - // listener (cordis `emit` stops at the first throw) or a boundary-emit failure - // can skip. `closeTurn` appends `turn/end` to the log unconditionally, and - // `turn/start` is appended before any step runs, so within this one listener - // we always see the prompt's turn-start (tag `inflight.turn`) then its - // turn-end (settle). A `turn/end` settles the prompt ONLY when it is the - // prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous, - // already-cancelled turn whose end arrives late is ignored (see + // capture and the settle key off the log's own `turn/start`/`turn/end` — the + // durable boundary events (there is no agent/* turn mirror). `closeTurn` + // appends `turn/end` to the log unconditionally, and `turn/start` is appended + // before any step runs, so within this one listener we always see the + // prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A + // `turn/end` settles the prompt ONLY when it is the prompt's OWN turn + // (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn + // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux // strictly by session id: a `session/event` is routed to its own record, so From 9e575a2a2cc016c30aee0a9225c858269ea9e31a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:47:37 +0800 Subject: [PATCH 25/28] docs(events): fix stale turn-mirror references caught in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the turn-mirror removal found current-state docs/comments that still claimed the removed `agent/turn-start`/`agent/turn-end` events exist: - docs/architecture.md: the loop diagram's turn-start line still said "emit agent/turn-start" (the turn-end line was already fixed). - event-domain-semantics RFC: the `agent/*` domain description listed "the turn boundaries" among the transient emits. - docs/core-data-structures/core.md: the agent/* taxonomy blurb listed "turn/step boundaries" as agent events. - the proposed ACP RFC: the settle-signal rows named agent/turn-start / agent/turn-end; retargeted to the durable `turn/end` session event + the session/event owning-turn correlation. - loop.ts outer-catch comment: said "closeTurn/failTurn are idempotent" — after the emit-param removal closeTurn is called exactly once (mutually exclusive normal/catch paths), so corrected to state that and to scope idempotency to closeStep (which is still guarded by stepOpen). Regenerated the cordis catalog. No behavior change. --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 2 +- .../2026-06-30-event-domain-semantics.md | 2 +- .../feature/2026-06-14-acp-agent-client-protocol.md | 4 ++-- packages/core/agent-loop/src/loop.ts | 13 ++++++++----- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8f83625ab0..4e856305ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,7 @@ forever: wait for queued messages (idle) emit agent/status(running) TURN (error-contained — a throwing plugin ends the turn, never the loop): - drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start + drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror) STEP loop: drain steering (late steering from previous step's listeners) assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 34cf410944..7466b063eb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -306,7 +306,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle emits, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## `ToolDefinition` diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 5ca6f874b5..7ae254f8dc 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -19,7 +19,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab **Three domains, one job each, with a single boundary rule.** - **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path. -- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the turn boundaries) that notify with the `Agent` in hand. +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`. - **`tools/*` — the tool registry + execution seam.** **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 675e295c2c..0aa5af4dc4 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -27,7 +27,7 @@ The mapping between ACP and existing harness seams — each row names the seam a | `session/new {cwd, mcpServers, additionalDirectories}` → `{sessionId}` | the `dsh-agent` create factory (see Dependency note + Plan) | the seam must accept `{ sessionId, meta }` so the ACP-generated `sessionId` becomes the live/persisted session id and the validated `cwd` is attached as the `SessionHeader` (today `AgentLoop.create(id)` hardcodes `${id}-session` and takes no metadata); reject a 2nd session (single-session MVP, see [ACP multi-session](2026-06-14-acp-multi-session.md)); `cwd` validated (require absolute) — any absolute cwd is honored: it becomes the session's `SessionHeader.cwd` and the default bash workdir (per-session cwd, see § Deferred → RESOLVED), so the server need not launch in the workspace; non-empty `mcpServers` and `additionalDirectories` are rejected for the MVP because silently ignoring requested servers/roots would desync the client's tool and filesystem-scope UI | | `session/load {sessionId, cwd, mcpServers, additionalDirectories}` | the `dsh-agent` resume factory ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) + Dependency note) | load `{ meta, events }`, seed the session, re-derive history via `deriveMessages()`, replay prior turns to the client as `session/update` per the ACP load contract; `mcpServers` and `additionalDirectories` rejected as in `session/new` | | `session/prompt {prompt}` | `agent.send()` (idle) | text blocks → `TextBlock`; reject image/audio per advertised capabilities; one in-flight prompt per session | -| resolve `session/prompt` → `{stopReason}` | `agent/turn-end` (extended, see Plan) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics | +| resolve `session/prompt` → `{stopReason}` | the `turn/end` `session/event` (its `reason`) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics | | `session/update: agent_message_chunk` | `agent/stream-chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text | | `session/update: agent_thought_chunk` | `agent/stream-chunk` `reasoning-delta` | | | `session/update: tool_call` (pending→in_progress) | `session/event` `tool/call` | demux via a Session→sessionId map; `kind` inferred from the tool name | @@ -46,7 +46,7 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom 1. Package scaffold `packages/ui/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps. 3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/core/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. -4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install listeners before `send()`; gate on an observed `agent/turn-start` (confirms work was accepted) then resolve on the next `agent/turn-end`; reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. +4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install the `session/event` listener before `send()`; capture the prompt's owning turn from its `turn/start` record, then resolve on that turn's `turn/end` (with `agent/status` idle/disposed as a fallback); reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. 6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. 7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: [property-based testing](../../implemented/testing/2026-06-11-property-based-testing.md)) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a `tool_call_update` before its `tool_call`; exactly one `session/prompt` resolution per prompt; monotonic, well-formed ordering; `stopReason` in the legal set); codec unit tests over an in-memory `Duplex` pair (drive `AgentSideConnection` without a subprocess; assert exact frames for `initialize`, `session/new`, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all `ctx.on` listeners gone, any in-flight `request_permission` settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification `send()` rejects but the turn survives; `finish{kind:'error'|'aborted'}`; a `tools/execute` throw with no `tool/result`; a second `session/new` rejected; a `session/prompt` while one is in flight; an empty prompt rejected without hanging; a `session/load` re-derives identical history and replays it); and an e2e (`*.e2e.ts`, self-skips without `DEEPSEEK_API_KEY`) that boots `examples/acp-agent`, connects a `ClientSideConnection`, sends a real prompt, owns and disposes the harness in `afterEach`, and verifies the world (files on disk), not the agent's self-report. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index eba75f9615..3921122559 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -529,11 +529,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // Gating on a "turn started" boolean would skip turn/end and leave a // permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We // check the log for THIS turn's turn/start: present means a turn/end is owed - // (or was already appended — closeTurn/failTurn are idempotent, so running - // them again is a safe no-op that still preserves the disposed/error reason - // chosen below). Absent means the turn/start append threw BEFORE its push (a - // non-serializable trigger — impossible for our fixed trigger); nothing was - // opened, so rethrow to the runLoop backstop. + // and the normal-exit `closeTurn()` did NOT run (we are here because a throw + // preceded it — the two `closeTurn()` sites are on mutually exclusive paths), + // so this catch appends turn/end with the disposed/error reason chosen below. + // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run + // already in a step branch, so running it again is a safe no-op. Absent + // turn/start means the append threw BEFORE its push (a non-serializable + // trigger — impossible for our fixed trigger); nothing was opened, so rethrow + // to the runLoop backstop. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() From bb9ae2ba9924d8896b151c858a27f80f4cc28107 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:08:24 +0800 Subject: [PATCH 26/28] =?UTF-8?q?docs(bash):=20reframe=20stdin/env=20?= =?UTF-8?q?=E2=80=94=20the=20scrub=20is=20the=20security=20control,=20not?= =?UTF-8?q?=20a=20trust=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the "trusted-plugin surface" framing overstated the security story. A model driving the `bash` tool already has equivalent power to set env vars and feed stdin through ordinary shell syntax (`FOO=bar cmd`, heredocs), so the `env`/`stdin` seam fields grant it no new capability — and they cannot exfiltrate the harness's ambient credentials, because the credential SCRUB in dsh-bash-local (which strips *KEY*/*SECRET*/*TOKEN* from process.env before the child sees it) is the actual control, and it works regardless of these fields (tool-call args are static JSON, never shell-evaluated). So drop the "dangerous / trusted-plugin boundary" language across the RFC, the three bash-package READMEs, the bash/src/types.ts JSDoc, and docs/bash.md (both the type-equiv blocks — kept 1:1 with source — and the prose). The reality that remains: the `bash` tool doesn't EXPOSE env/stdin as parameters because they'd be redundant with shell syntax; the fields exist for in-process plugins (the hooks bridges) to pass a JSON payload + CLAUDE_* vars cleanly. The guard test is kept but reframed: it catches a future `...args` spread that would silently forward model input into the post-scrub env merge, NOT a trust wall. No code or behavior change. --- docs/core-data-structures/bash.md | 24 ++++++++-------- ...0-bash-stdin-env-trusted-plugin-surface.md | 16 +++++------ packages/bash/bash-local/README.md | 2 +- packages/bash/bash/README.md | 2 +- packages/bash/bash/src/types.ts | 22 +++++++-------- packages/bash/tool-bash/README.md | 4 +-- packages/bash/tool-bash/tests/tools.spec.ts | 28 +++++++++++-------- 7 files changed, 52 insertions(+), 46 deletions(-) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 2e0c8de3a4..273ba5ebe8 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -19,19 +19,20 @@ interface BashExecRequest { signal?: AbortSignal | undefined /** * Bytes to write to the command's stdin, then close it. Absent leaves stdin - * closed/empty (the default for model-driven tool calls). A TRUSTED-PLUGIN - * surface: the model-facing bash tool does NOT thread model-supplied input - * here — it is set by in-process plugins (e.g. the hooks bridges, which write - * a hook command's JSON payload to its stdin). + * closed/empty (the default for model-driven tool calls). Set by in-process + * plugins (e.g. the hooks bridges, which write a hook command's JSON payload + * to its stdin); the model-facing bash tool does not expose it as a parameter + * (a model that needs stdin uses shell syntax like a heredoc or a pipe). */ stdin?: string | undefined /** * Extra environment entries for the command, merged AFTER the * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller takes responsibility). - * Like {@link stdin}, a TRUSTED-PLUGIN surface: the model-facing bash tool - * never forwards model-supplied env; in-process plugins (the hooks bridges) - * set hook env vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …) here. + * when its name matches the scrub pattern — the caller named a value it holds, + * not the harness's ambient secret). Set by in-process plugins (the hooks + * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing + * bash tool does not expose it as a parameter (a model that needs an env var + * uses shell syntax like `FOO=bar cmd`). */ env?: Record | undefined /** @@ -58,8 +59,7 @@ interface BashExecSpec { * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec * (unlike `owner`): it has no config default, so a missing one means "no * stdin" — the safe, ordinary case — not a silent footgun, so it stays a - * plain optional rather than required-but-nullable. A TRUSTED-PLUGIN surface - * (see the request field). + * plain optional rather than required-but-nullable (see the request field). */ stdin?: string | undefined /** @@ -67,7 +67,7 @@ interface BashExecSpec { * {@link BashExecRequest.env} and merged by the implementation AFTER its * credential scrub (an explicit entry wins even when its name matches the * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no - * config default, absent means "no extra env". A TRUSTED-PLUGIN surface. + * config default, absent means "no extra env". */ env?: Record | undefined /** @@ -84,7 +84,7 @@ interface BashExecSpec { The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. -`stdin` and `env` are a **trusted-plugin surface**: an in-process plugin (the hooks bridges, native plugins) sets them to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool deliberately NEVER forwards model input into either field — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — so a model cannot smuggle an env var or stdin payload past the credential scrub (a guard test asserts this). `env` is merged AFTER the scrub so a trusted caller can set even a credential-shaped var; the scrub's job is to stop the harness's OWN ambient credentials leaking into model-driven commands, not to constrain a trusted plugin. +`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 224ff6c24a..aafa24cb1d 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -1,4 +1,4 @@ -# RFC: stdin + extra env on the bash seam — a trusted-plugin surface +# RFC: stdin + extra env on the bash seam Status: implemented (accepted 2026-06-30) @@ -6,9 +6,9 @@ Status: implemented (accepted 2026-06-30) ## Context -The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. +The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs. -The friction is that those two inputs are **dangerous in exactly the way the seam was built to prevent**. [dsh-bash-local](../../../../packages/bash/bash-local)'s `childEnv()` deliberately scrubs `*KEY*`/`*SECRET*`/`*TOKEN*` from the child environment so the harness's own `DEEPSEEK_API_KEY` cannot leak into model-driven command output (see [AGENTS.md](../../../../AGENTS.md) § Defensive patterns, "Never hand untrusted/model output the ambient environment or predictable paths"). An arbitrary-env / arbitrary-stdin capability is the opposite of that guarantee. So the question this RFC answers is not "can we add stdin/env" — it is "who is allowed to use them, and how is that boundary enforced". +**These fields are NOT a new security boundary.** It is tempting to frame arbitrary-stdin / arbitrary-env as "dangerous, so gate who may use them" — but that framing is wrong, because a model driving the `bash` tool **already** has equivalent power through ordinary shell syntax: `FOO=bar cmd` sets an env var, a heredoc or `printf … | cmd` feeds arbitrary stdin. Adding `env`/`stdin` as seam fields grants the model no capability it lacks. In particular they cannot exfiltrate the harness's ambient credentials: the real control for that is the **credential scrub** in [dsh-bash-local](../../../../packages/bash/bash-local)'s `childEnv()`, which strips `*KEY*`/`*SECRET*`/`*TOKEN*` from `process.env` before the child sees it (see [AGENTS.md](../../../../AGENTS.md) § Defensive patterns, "Never hand untrusted/model output the ambient environment or predictable paths"). The scrub works regardless of these fields — a model cannot read a value that is not in the environment, and tool-call arguments are static JSON, never shell-evaluated, so a model cannot write `env: {LEAK: $DEEPSEEK_API_KEY}` and have it expand. So the security question is already answered by the scrub; this RFC is only about giving trusted in-process callers a clean way to pass a JSON payload + `CLAUDE_*` vars without routing them through model-visible shell text. ## Decision @@ -16,18 +16,18 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq Three deliberate choices: -1. **`stdin`/`env` are a TRUSTED-PLUGIN surface, enforced at the consumer, not the seam.** The seam itself imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it). The enforcement lives in the model-facing consumer [dsh-tool-bash](../../../../packages/bash/tool-bash): its `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` **only**, and never reads model arguments into `stdin`/`env`. A model that smuggles `env`/`stdin` keys into the tool-call arguments gets them ignored. A regression guard (`tool-bash` "trusted-plugin boundary" tests) drives the real tool with adversarial args and asserts the recorded request carries neither field — and is proven to go red if the consumer ever forwards them. Only in-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly can set them. +1. **The model-facing `bash` tool simply does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only; a model that includes `env`/`stdin` keys in its tool-call arguments simply has them ignored. A regression guard (`tool-bash` "does not forward env/stdin" tests) drives the real tool with those extra args and asserts the recorded request carries neither field — its purpose is to catch a future refactor that blindly spreads `...args` into the request and silently starts forwarding model input into the post-scrub `env` merge, NOT to defend a trust boundary. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it). -2. **`env` merges AFTER the credential scrub, so a trusted caller's explicit entry always wins** — even a credential-shaped name. This is correct precisely because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into *model-driven* commands. A trusted plugin that explicitly sets a var has taken responsibility for it; the scrub is not a constraint on trusted callers. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. -`dsh-bash-local` now ALWAYS spawns stdin as a `'pipe'` and closes it immediately — with the supplied bytes when a trusted plugin set `stdin`, empty otherwise. A closed empty pipe gives a reading child EOF exactly as the previous `'ignore'` (`/dev/null`) did, so the no-stdin path is behavior-equivalent; keeping the `stdio` tuple a literal `['pipe','pipe','pipe']` also preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. A child that exits without reading makes the stdin write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. +`dsh-bash-local` now ALWAYS spawns stdin as a `'pipe'` and closes it immediately — with the supplied bytes when a caller set `stdin`, empty otherwise. A closed empty pipe gives a reading child EOF exactly as the previous `'ignore'` (`/dev/null`) did, so the no-stdin path is behavior-equivalent; keeping the `stdio` tuple a literal `['pipe','pipe','pipe']` also preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. A child that exits without reading makes the stdin write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. ## Scope: configurable scrub pattern is NOT included -An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a trusted plugin full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a feature with no consumer, against [AGENTS.md](../../../../AGENTS.md) § "Don't add features beyond what the task requires". If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then. +An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a feature with no consumer, against [AGENTS.md](../../../../AGENTS.md) § "Don't add features beyond what the task requires". If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then. ## Consequences -A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged: the consumer's request-building is the single boundary, guarded by a test that fails if it regresses. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs; the trusted-plugin rule mirrors the existing scrub/predictable-path discipline in [AGENTS.md](../../../../AGENTS.md) § Defensive patterns. +A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged (the credential scrub, not these fields, is what bounds it), and the `bash` tool's request-building stays the single place that decides which fields a model call carries — guarded by a test that fails if a refactor starts forwarding model input. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 2ae905b628..7cb6f809c5 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -21,7 +21,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's **trusted-plugin** `env` is merged LAST (after the scrub), so an in-process plugin's explicit entry wins even on a credential-shaped name — the scrub guards the harness's *ambient* credentials from *model-driven* commands, not a trusted caller. The spec's `stdin` (also trusted-plugin) is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin` is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. ## Sandboxing diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index cec9e5834a..39318ae371 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -30,4 +30,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal `BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. -`stdin` and `env` are a **trusted-plugin surface**: an in-process plugin (the hooks bridges, native plugins) sets them to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool deliberately never forwards model input into either — so a model cannot smuggle an env var or stdin payload past the implementation's credential scrub. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default, not a security footgun. See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index f5be9f11fe..9acd5c7cb7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -47,19 +47,20 @@ export interface BashExecRequest { signal?: AbortSignal | undefined /** * Bytes to write to the command's stdin, then close it. Absent leaves stdin - * closed/empty (the default for model-driven tool calls). A TRUSTED-PLUGIN - * surface: the model-facing bash tool does NOT thread model-supplied input - * here — it is set by in-process plugins (e.g. the hooks bridges, which write - * a hook command's JSON payload to its stdin). + * closed/empty (the default for model-driven tool calls). Set by in-process + * plugins (e.g. the hooks bridges, which write a hook command's JSON payload + * to its stdin); the model-facing bash tool does not expose it as a parameter + * (a model that needs stdin uses shell syntax like a heredoc or a pipe). */ stdin?: string | undefined /** * Extra environment entries for the command, merged AFTER the * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller takes responsibility). - * Like {@link stdin}, a TRUSTED-PLUGIN surface: the model-facing bash tool - * never forwards model-supplied env; in-process plugins (the hooks bridges) - * set hook env vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …) here. + * when its name matches the scrub pattern — the caller named a value it holds, + * not the harness's ambient secret). Set by in-process plugins (the hooks + * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing + * bash tool does not expose it as a parameter (a model that needs an env var + * uses shell syntax like `FOO=bar cmd`). */ env?: Record | undefined /** @@ -92,8 +93,7 @@ export interface BashExecSpec { * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec * (unlike `owner`): it has no config default, so a missing one means "no * stdin" — the safe, ordinary case — not a silent footgun, so it stays a - * plain optional rather than required-but-nullable. A TRUSTED-PLUGIN surface - * (see the request field). + * plain optional rather than required-but-nullable (see the request field). */ stdin?: string | undefined /** @@ -101,7 +101,7 @@ export interface BashExecSpec { * {@link BashExecRequest.env} and merged by the implementation AFTER its * credential scrub (an explicit entry wins even when its name matches the * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no - * config default, absent means "no extra env". A TRUSTED-PLUGIN surface. + * config default, absent means "no extra env". */ env?: Record | undefined /** diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index f7a15894f9..b81e49d027 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -40,9 +40,9 @@ These tools own how their calls render in a UI (an editor's tool-call card) via When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. -## Trusted-plugin boundary: env / stdin are never model-driven +## The tool builds its request from named args only -The `BashExecRequest` seam carries optional `stdin` and `env` (a **trusted-plugin surface** used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env). This tool deliberately **never** threads model input into either: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored — it cannot smuggle an environment variable or stdin payload past `dsh-bash-local`'s credential scrub. A regression guard (the "trusted-plugin boundary" tests) drives the real tool with adversarial args and asserts the resulting request carries neither field. See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). ## Permissions diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 0845163193..79e8ea183d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -864,14 +864,18 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { }) }) -describe('trusted-plugin boundary: the model-facing bash tool never sets env/stdin', () => { +describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => { /** * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a - * test can assert what the model-facing tool DID and DID NOT forward. `stdin` - * and `env` are a TRUSTED-PLUGIN surface (in-process plugins only); the `bash` - * tool must never thread model-supplied input into them, even when the model - * smuggles extra keys into the tool arguments. Foreground `run()` returns a - * canned result; `start()` is unused here. + * test can assert what the model-facing tool DID and DID NOT forward. The `bash` + * tool does not expose `stdin`/`env` as parameters (bash syntax already gives a + * model that power), so it must build its request from named args only and + * never spread unknown tool-call keys into it. This guard's job is to catch a + * future refactor that blindly forwards `...args` — which would silently thread + * model input into the post-scrub `env` merge — NOT to defend a trust boundary + * (the credential scrub in dsh-bash-local is the security control; see the + * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is + * unused here. */ class RecordingBashExecutor extends BashExecutor { readonly requests: BashExecRequest[] = [] @@ -911,12 +915,14 @@ describe('trusted-plugin boundary: the model-facing bash tool never sets env/std return { ctx, bash: ctx.bash as RecordingBashExecutor } } - it('does not forward env/stdin even when the model smuggles them as extra arguments', async () => { + it('does not forward env/stdin even when the model includes them as extra arguments', async () => { const { ctx, bash } = await setupRecording() - // Adversarial args: the model includes `env` and `stdin` keys (and a - // credential-shaped value) hoping they reach the executor. The bash tool's - // schema ignores unknown keys, and execute() builds the request from only - // command/workdir/timeoutMs/signal — so the recorded request carries NEITHER. + // Extra args: the model includes `env` and `stdin` keys hoping they reach the + // executor. The bash tool's schema ignores unknown keys, and execute() builds + // the request from only command/workdir/timeoutMs/signal — so the recorded + // request carries NEITHER. (Not a security wall — the model could set an env + // var or feed stdin via shell syntax anyway; this just keeps the request + // shape honest so a future `...args` spread can't silently forward input.) await ctx.tools.execute({ callId: CallId('boundary-1'), name: 'bash', From 5533bb783a87d5430253ea76667b119d13075a36 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:30:40 +0800 Subject: [PATCH 27/28] docs(bash): purge remaining trusted-plugin wording caught in review Codex review of the reframe found stale "trusted-plugin surface/boundary" wording still in review-relevant spots the first pass missed: - docs/rfc/README.md index title for the RFC. - packages/bash/bash-local/src/run.ts (childEnv JSDoc + SpawnSpec stdin/env JSDoc + the spawn stdin comment) and src/index.ts (resolve carry-through comment); run.ts also pointed at a tool-bash README section name that no longer exists. - the two bash-local test descriptors (run.spec.ts / executor.spec.ts). - the tool-bash guard test's `boundary-*` call ids and one "boundary assertion" comment (renamed to `no-forward-*`). All reworded to the scrub-is-the-control framing (or neutral wording). The RFC FILENAME keeps `-trusted-plugin-surface` as a stable id (many links point at it; the index title and content are corrected). No code or behavior change. --- docs/rfc/README.md | 2 +- packages/bash/bash-local/src/index.ts | 4 +-- packages/bash/bash-local/src/run.ts | 28 ++++++++++--------- .../bash/bash-local/tests/executor.spec.ts | 2 +- packages/bash/bash-local/tests/run.spec.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 6 ++-- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 5c47c98599..f40d47a3e9 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -121,7 +121,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 | -| [stdin + extra env on the bash seam — a trusted-plugin surface](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 | +| [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 | ### Process diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 53c369a24c..b1ea729fb4 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -116,8 +116,8 @@ export class LocalBashExecutor extends BashExecutor { workdir: request.workdir ?? this.config.cwd ?? process.cwd(), timeoutMs, ...request.signal ? { signal: request.signal } : {}, - // Carry the trusted-plugin stdin/env through verbatim — optional, no - // config default (absent means none). env merges AFTER the scrub in run.ts. + // Carry stdin/env through verbatim — optional, no config default (absent + // means none). env merges AFTER the scrub in run.ts. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, // Carry the owner through verbatim (required-but-nullable on the spec): diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 5b67bbdc1b..de3d880c7c 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -48,12 +48,13 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i * * Layering matters: the scrub drops `process.env` credentials, then * `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is - * merged LAST so a TRUSTED-PLUGIN entry wins even when its name matches the - * scrub pattern (the scrub guards against leaking the HARNESS's ambient - * credentials into model-driven commands; an in-process plugin that explicitly - * sets a var has taken responsibility for it). `extra` is NEVER model-supplied - * — `dsh-tool-bash` does not forward model input here (see its README, § - * "Trusted-plugin boundary"). + * merged LAST so an explicit caller entry wins even when its name matches the + * scrub pattern (the scrub is the control that stops the HARNESS's ambient + * credentials leaking into a spawned command; a caller that explicitly sets a + * var named a value it already holds, not that ambient secret). `extra` is set + * by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash` + * builds its request from named fields only and does not forward model input + * here (see its README, § "The tool builds its request from named args only"). */ export function childEnv(extra?: Record): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} @@ -75,14 +76,15 @@ export interface SpawnSpec { signal?: AbortSignal | undefined /** * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. A TRUSTED-PLUGIN surface (see {@link SpawnSpec}'s - * consumer `dsh-bash`); never carries model input. + * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); + * the model-facing `dsh-tool-bash` tool does not thread model input here. */ stdin?: string | undefined /** * Extra environment entries, merged onto the scrubbed env AFTER the * credential scrub and the model-friendly overrides (so an explicit entry - * wins). A TRUSTED-PLUGIN surface; never carries model input. + * wins). Set by in-process plugins; the model-facing tool does not forward + * model input here. */ env?: Record | undefined } @@ -297,10 +299,10 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } // stdin is ALWAYS a pipe (kept literal so the typed spawn overload guarantees - // non-null stdout/stderr) and is closed immediately: with bytes when a - // trusted plugin supplied stdin, empty otherwise. A closed empty pipe gives a - // reading child EOF exactly as `/dev/null` would, so the no-stdin path (every - // model-driven call) is unchanged. + // non-null stdout/stderr) and is closed immediately: with bytes when a caller + // supplied stdin, empty otherwise. A closed empty pipe gives a reading child + // EOF exactly as `/dev/null` would, so the no-stdin path (every model-driven + // call) is unchanged. const child = spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env: childEnv(spec.env), diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 03f602a2f1..cf6d1c267e 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -110,7 +110,7 @@ describe('LocalBashExecutor.run', () => { it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => { const { bash } = await setup() const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } }) - // resolve() keeps the trusted-plugin fields verbatim (optional, no default). + // resolve() keeps the stdin/env fields verbatim (optional, no default). expect(spec.stdin).toBe('piped\n') expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' }) const result = await bash.run(spec) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 3859f2ac39..395f442dad 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -158,7 +158,7 @@ describe('runBash', () => { }) }) -describe('stdin and extra env (trusted-plugin surface)', () => { +describe('stdin and extra env (set by in-process plugins)', () => { it('writes stdin to the command and closes it', async () => { const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done expect(result.exitCode).toBe(0) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 79e8ea183d..8fd131d251 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -924,7 +924,7 @@ describe('the model-facing bash tool builds its request from named args only (no // var or feed stdin via shell syntax anyway; this just keeps the request // shape honest so a future `...args` spread can't silently forward input.) await ctx.tools.execute({ - callId: CallId('boundary-1'), + callId: CallId('no-forward-1'), name: 'bash', arguments: { command: 'echo hi', @@ -943,9 +943,9 @@ describe('the model-facing bash tool builds its request from named args only (no it('a background bash call likewise carries no env/stdin', async () => { const { ctx, bash } = await setupRecording() // start() throws in this recorder, but resolve() runs first and records the - // request — which is all this boundary assertion needs. + // request — which is all this no-forward assertion needs. await ctx.tools.execute({ - callId: CallId('boundary-2'), + callId: CallId('no-forward-2'), name: 'bash', arguments: { command: 'sleep 1', From d3be934a173a0d58295c8be48904dafba85added Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:13:16 +0800 Subject: [PATCH 28/28] docs(events): fix stale turn-mirror / tools-execute prose caught in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the interception-seams merge flagged current-state prose still describing removed/renamed surfaces: - packages/core/agent/src/types.ts module doc: the agent/* "transient emits" list still ended with "the turn boundaries" — corrected to state turn/step boundaries are durable session/event records, not agent/* emits, and to list the actual interception seams (prompt-submit/pre-step/request/step-result/ turn-continuation) + agent/session-start. - interception-seams RFC: "agent/turn-end fires and the ACP bridge settles" → the durable turn/end is appended and ACP settles off it (no turn mirror). - two proposed RFCs (acp-agent-client-protocol, optional-code-mode) named the pre-split `tools/execute` waterfall → the `tools/pre-execute`/`tools/post-execute` pair. Regenerated the cordis catalog (module-doc change). No code/behavior change. --- docs/cordis-catalog/events-and-services.md | 26 +++++++++---------- .../feature/2026-06-30-interception-seams.md | 2 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- .../feature/2026-06-15-optional-code-mode.md | 2 +- packages/core/agent/src/types.ts | 14 +++++----- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 62174667b1..dc4b91f6b3 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:354`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) #### `agent/prompt-submit` — waterfall @@ -75,7 +75,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:320`](../../packages/core/agent/src/types.ts) #### `agent/session-start` — emit @@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:240`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -135,7 +135,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -147,7 +147,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:334`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index c79f237343..7f2d23ab3e 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -26,7 +26,7 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi ### Three load-bearing loop decisions -1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) `agent/turn-end` fires and the ACP bridge settles normally (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. +1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 0aa5af4dc4..1e31a3fdb8 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -15,7 +15,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch ## Proposal -A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall. +A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/pre-execute`/`tools/post-execute` waterfalls. It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. diff --git a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md index eb41750d1d..f5da38c14c 100644 --- a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md @@ -55,7 +55,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi **3c. The single tool — `run_code`.** Registered normally in `ctx.tools` with one parameter `{ code: string (required) }`. Because it is an ordinary tool, the unchanged loop dispatches it through the normal path — this is the crux of "zero loop changes." Its `execute(args, exec)`: -1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: , name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/execute` waterfall, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones. +1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: , name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/pre-execute`/`tools/post-execute` waterfalls, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones. 2. Calls `ctx.codeRuntime.run({ code: args.code, sdk: bindings, signal: exec.signal })`. 3. Surfaces the outcome. A *successful* run returns `[{ type: 'text', text: }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise` and `ToolRegistry.execute()` hardcodes `isError: false` on any successful return — `isError: true` arises only from the registry's catch path. So on an error result the tool **throws a `CodeRunError extends HarnessError`** (`HarnessError` is exported from `dsh-llm`; the registry catch turns any throw into `isError: true` with the message as text, and a `HarnessError` additionally carries structured `{ name, code }`). An alternative — registering `run_code` handling as a `tools/execute` listener that returns a full `ToolExecutionResult` and can set `isError` directly — is noted; the throw is simpler and preferred. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e7511935a9..b92bc08d39 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -16,12 +16,14 @@ * durability checkpoint. Answers "what happened, durably/replayably." A * consumer that wants the live transcript subscribes here. * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the - * live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, - * `agent/step-result`, `agent/turn-continuation`) that mutate/veto, and - * TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, - * `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the - * turn boundaries) that notify with the `Agent` in hand. Answers "right now, - * with the agent object — intercept or observe." + * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ + * `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and + * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits + * (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/ + * `agent/disposed`, `agent/queued`, `agent/steering`, `agent/session-start`) + * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — + * they are durable `session/event` records. Answers "right now, with the agent + * object — intercept or observe." * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. * * **The rule:** a durable, replayable fact is a SessionEvent; a live