fix(agent-loop): compose the session prefix before pre-step; hand it to the pressure gate
ds-review-bot critical (follow-up): on the first step of a resumed or seeded/forked instance, auto-compaction ran before runStep composed this instance's prefix, so the gate read the PREVIOUS instance's logged prefix from the header fold — a contributor that grew across resume/fork (skills added, AGENTS.md grown: exactly the environment-dependent case) could under-gate and ship an over-window first request. The loop now composes agent/session-prefix before the instance's first agent/pre-step (still once per instance; runStep just reads the cache), and agent/pre-step carries the composed prefix to its listeners. CompactService.compactIfNeeded gains the sessionPrefix parameter; BasicCompactService.estimatePressure gates on the handed value — the header-fold read is gone, so the estimate is exact at every step including a resumed/forked instance's first. Composition moving before the boundary snapshot also means a composing listener's session append now joins the CURRENT request (documented on the seam). New coverage: composition precedes pre-step and the seam receives the composed prefix; cancel and disposal landing inside the composition window drop the step cleanly; the compact gate test hands the prefix directly.
This commit is contained in:
@@ -70,10 +70,11 @@ forever:
|
|||||||
STEP loop:
|
STEP loop:
|
||||||
drain steering
|
drain steering
|
||||||
assemble system prompt and tool schemas
|
assemble system prompt and tool schemas
|
||||||
|
agent/session-prefix (first step)
|
||||||
agent/pre-step
|
agent/pre-step
|
||||||
'step/start'
|
'step/start'
|
||||||
snapshot the derived messages (the reconstruction boundary)
|
snapshot the derived messages (the reconstruction boundary)
|
||||||
agent/request (config only) -> agent/session-prefix (first request) -> log request/header -> llm/stream (frozen)
|
agent/request (config only) -> log request/header -> llm/stream (frozen)
|
||||||
'assistant/chunk'
|
'assistant/chunk'
|
||||||
agent/step-result
|
agent/step-result
|
||||||
'assistant/message'
|
'assistant/message'
|
||||||
|
|||||||
@@ -47,21 +47,21 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:460`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/pre-step` — serial
|
### `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.
|
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), 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).
|
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), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call).
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:357`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/prompt-submit` — waterfall
|
### `agent/prompt-submit` — waterfall
|
||||||
|
|
||||||
@@ -73,7 +73,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)
|
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:363`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/queued` — emit
|
### `agent/queued` — emit
|
||||||
|
|
||||||
@@ -97,11 +97,11 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:394`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/session-prefix` — waterfall
|
### `agent/session-prefix` — waterfall
|
||||||
|
|
||||||
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily on its first request-building step; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot).
|
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history.
|
||||||
|
|
||||||
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:437`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/session-start` — emit
|
### `agent/session-start` — emit
|
||||||
|
|
||||||
@@ -149,7 +149,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)
|
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:435`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/turn-continuation` — waterfall
|
### `agent/turn-continuation` — waterfall
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:460`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
## `fs/*`
|
## `fs/*`
|
||||||
|
|
||||||
|
|||||||
@@ -98,11 +98,13 @@ 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.
|
- **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
|
```ts cordis-catalog
|
||||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, signal: AbortSignal, ): Promise<CompactionResult | null>
|
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts)
|
Types: [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
|
Source: [`packages/compact/compact/src/index.ts:64`](../../packages/compact/compact/src/index.ts)
|
||||||
|
|
||||||
## `ctx.fs` — `FileSystem` (abstract seam)
|
## `ctx.fs` — `FileSystem` (abstract seam)
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:460`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:437`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:460`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
|||||||
|
|
||||||
**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds.
|
**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds.
|
||||||
|
|
||||||
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → on the instance's FIRST request only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed.
|
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed.
|
||||||
|
|
||||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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:
|
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||||
|
|
||||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the logged session prefix (`EpochHeader.messagePrefix` from the header fold — the `agent/session-prefix` product rides every request in front of the history, so omitting it would under-estimate pressure by exactly the prefix) + the derived history + the system prompt.
|
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt.
|
||||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
- **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.
|
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||||
|
|||||||
@@ -183,11 +183,11 @@ export class BasicCompactService extends CompactService {
|
|||||||
// log-only `compact/*` records and the replacement node cleanly outside a
|
// log-only `compact/*` records and the replacement node cleanly outside a
|
||||||
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
|
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
|
||||||
// closes — never a half-open step.
|
// closes — never a half-open step.
|
||||||
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, signal: AbortSignal) => {
|
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
|
||||||
try {
|
try {
|
||||||
const result = await this.compactIfNeeded(agent, fullSystemPrompt, signal)
|
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||||
if (result) {
|
if (result) {
|
||||||
const after = this.estimatePressure(agent.session, fullSystemPrompt)
|
const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
||||||
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
||||||
@@ -360,7 +360,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The sole token-pressure gate: estimate the NEXT request's pressure — the
|
* The sole token-pressure gate: estimate the NEXT request's pressure — the
|
||||||
* logged session prefix + the surface-derived history + the system prompt
|
* session prefix + the surface-derived history + the system prompt
|
||||||
* ({@link estimatePressure}) — and if it exceeds the threshold
|
* ({@link estimatePressure}) — and if it exceeds the threshold
|
||||||
* (`contextWindow * thresholdRatio`), compact
|
* (`contextWindow * thresholdRatio`), compact
|
||||||
* the oldest surface nodes outside the `retainTokens` budget. The auto-
|
* the oldest surface nodes outside the `retainTokens` budget. The auto-
|
||||||
@@ -369,7 +369,11 @@ export class BasicCompactService extends CompactService {
|
|||||||
* carries it in front of the history (`EpochHeader.messagePrefix`) even
|
* carries it in front of the history (`EpochHeader.messagePrefix`) even
|
||||||
* though it is not derived history — omitting it would under-estimate by
|
* though it is not derived history — omitting it would under-estimate by
|
||||||
* exactly the prefix and let a deployment at the window edge skip
|
* exactly the prefix and let a deployment at the window edge skip
|
||||||
* compaction, then ship an over-window request. Compaction itself can only
|
* compaction, then ship an over-window request. The loop composes the
|
||||||
|
* prefix BEFORE the pre-step seam and hands it through, so the gate sees
|
||||||
|
* this instance's actual prefix (never a previous instance's logged one —
|
||||||
|
* a resumed/forked instance whose contributor grew is gated on the grown
|
||||||
|
* value from its very first step). Compaction itself can only
|
||||||
* shrink HISTORY: a prefix that alone approaches the window is a
|
* shrink HISTORY: a prefix that alone approaches the window is a
|
||||||
* configuration error no compactor fixes.
|
* configuration error no compactor fixes.
|
||||||
*
|
*
|
||||||
@@ -395,13 +399,14 @@ export class BasicCompactService extends CompactService {
|
|||||||
override async compactIfNeeded(
|
override async compactIfNeeded(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
fullSystemPrompt: string,
|
fullSystemPrompt: string,
|
||||||
|
sessionPrefix: readonly Message[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CompactionResult | null> {
|
): Promise<CompactionResult | null> {
|
||||||
const session = agent.session
|
const session = agent.session
|
||||||
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
||||||
let result: CompactionResult | null = null
|
let result: CompactionResult | null = null
|
||||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
|
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
|
||||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt)
|
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||||
if (totalTokens < threshold) return result
|
if (totalTokens < threshold) return result
|
||||||
|
|
||||||
const range = this._compactableRange(session)
|
const range = this._compactableRange(session)
|
||||||
@@ -415,7 +420,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
result = await this.compactRegion(session, range.start, range.end, agent, signal)
|
result = await this.compactRegion(session, range.start, range.end, agent, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt)
|
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||||
if (totalTokens < threshold) return result
|
if (totalTokens < threshold) return result
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -425,19 +430,16 @@ export class BasicCompactService extends CompactService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimated token pressure of the NEXT request: the logged session prefix
|
* Estimated token pressure of the NEXT request: the session prefix
|
||||||
* (`EpochHeader.messagePrefix` from the header fold — request-only messages
|
* (`EpochHeader.messagePrefix` — request-only messages the loop sends in
|
||||||
* the loop sends in front of the derived history), the derived history, and
|
* front of the derived history, composed before the pre-step seam and
|
||||||
* the system prompt. The fold is exact from the loop instance's second
|
* handed to the gate), the derived history, and the system prompt.
|
||||||
* request on (and from a resumed instance's first — the previous instance
|
|
||||||
* logged its prefix); it is absent only before a fresh session's first
|
|
||||||
* request, where the history is a single prompt and compaction is moot.
|
|
||||||
* @param session - the session whose next request is being estimated.
|
* @param session - the session whose next request is being estimated.
|
||||||
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
|
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
|
||||||
|
* @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
|
||||||
* @returns the estimated token total the next request will carry.
|
* @returns the estimated token total the next request will carry.
|
||||||
*/
|
*/
|
||||||
estimatePressure(session: Session, fullSystemPrompt: string): number {
|
estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
|
||||||
const sessionPrefix = session.requestHeader()?.messagePrefix ?? []
|
|
||||||
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
|
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,27 +557,22 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
|||||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts the logged session prefix toward pressure (every request carries it in front of the history)', async () => {
|
it('counts the session prefix toward pressure (every request carries it in front of the history)', async () => {
|
||||||
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 10 })
|
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 10 })
|
||||||
const session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone
|
const session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone
|
||||||
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
|
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
|
||||||
|
|
||||||
// The loop records the composed agent/session-prefix product on the
|
// The loop composes the agent/session-prefix product before the pre-step
|
||||||
// request header; it rides every request, so pressure must include it.
|
// seam and hands it to the gate; it rides every request, so pressure must
|
||||||
session.append('request/header', {
|
// include it — the same history now crosses the threshold.
|
||||||
header: {
|
const sessionPrefix: Message[] = [
|
||||||
config: { model: 'm' },
|
{ role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] },
|
||||||
messagePrefix: [
|
{ role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] },
|
||||||
{ role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] },
|
]
|
||||||
{ role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] },
|
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL, sessionPrefix)
|
||||||
],
|
|
||||||
},
|
|
||||||
reason: 'initial',
|
|
||||||
})
|
|
||||||
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
|
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
// The prefix itself is NOT history: compaction shadowed surface nodes only.
|
// The prefix itself is NOT history: compaction shadowed surface nodes only.
|
||||||
expect(session.requestHeader()?.messagePrefix).toHaveLength(2)
|
expect(sessionPrefix).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns the first compaction result when a zero-retry pass converges after the loop', async () => {
|
it('returns the first compaction result when a zero-retry pass converges after the loop', async () => {
|
||||||
@@ -1005,8 +1000,9 @@ function compactIfNeeded(
|
|||||||
fullSystemPrompt: string,
|
fullSystemPrompt: string,
|
||||||
model: string,
|
model: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
sessionPrefix: readonly Message[] = [],
|
||||||
) {
|
) {
|
||||||
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, signal)
|
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, sessionPrefix, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactRegion(
|
function compactRegion(
|
||||||
@@ -1174,7 +1170,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
|
|||||||
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
|
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
|
||||||
/** Fire the agent/pre-step serial checkpoint as the loop does. */
|
/** Fire the agent/pre-step serial checkpoint as the loop does. */
|
||||||
function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise<unknown> {
|
function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise<unknown> {
|
||||||
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL)
|
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, [], SIGNAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
it('compacts (mutating the surface) when over threshold', async () => {
|
it('compacts (mutating the surface) when over threshold', async () => {
|
||||||
@@ -1276,7 +1272,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
|
|||||||
const session = multiTurnSession(5, 1)
|
const session = multiTurnSession(5, 1)
|
||||||
const agent = stubAgent(session, 'agent-model')
|
const agent = stubAgent(session, 'agent-model')
|
||||||
|
|
||||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||||
|
|
||||||
expect(adapter.lastOptions?.model).toBe('routed-model')
|
expect(adapter.lastOptions?.model).toBe('routed-model')
|
||||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||||
@@ -1415,7 +1411,7 @@ describe('BasicCompactService edge cases', () => {
|
|||||||
const session = multiTurnSession(4, 1)
|
const session = multiTurnSession(4, 1)
|
||||||
const agent = stubAgent(session, 'test-model')
|
const agent = stubAgent(session, 'test-model')
|
||||||
|
|
||||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||||
// The surface was mutated; the head message is the framed summary checkpoint.
|
// The surface was mutated; the head message is the framed summary checkpoint.
|
||||||
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||||
@@ -1495,7 +1491,7 @@ describe('BasicCompactService edge cases', () => {
|
|||||||
const agent = stubAgent(session, 'test-model')
|
const agent = stubAgent(session, 'test-model')
|
||||||
const before = session.surface.nodes.length
|
const before = session.surface.nodes.length
|
||||||
|
|
||||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||||
// The failure was swallowed; the surface is untouched and a warning logged.
|
// The failure was swallowed; the surface is untouched and a warning logged.
|
||||||
expect(session.surface.nodes.length).toBe(before)
|
expect(session.surface.nodes.length).toBe(before)
|
||||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
|
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
|
||||||
@@ -1512,7 +1508,7 @@ describe('BasicCompactService edge cases', () => {
|
|||||||
const agent = stubAgent(session, 'test-model')
|
const agent = stubAgent(session, 'test-model')
|
||||||
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
|
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
|
||||||
|
|
||||||
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL)
|
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, [], SIGNAL)
|
||||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||||
expect(svc.summarizeCalls.length).toBe(0)
|
expect(svc.summarizeCalls.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
|
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
import type { Session } from '@deepseek-ai/dsh-session'
|
||||||
import type { CompactionResult } from './types.ts'
|
import type { CompactionResult } from './types.ts'
|
||||||
|
|
||||||
@@ -68,16 +69,20 @@ export abstract class CompactService extends Service {
|
|||||||
/**
|
/**
|
||||||
* Check token pressure and compact if the conversation is too large.
|
* Check token pressure and compact if the conversation is too large.
|
||||||
*
|
*
|
||||||
* Estimates the current surface-derived history size (including the system
|
* Estimates the NEXT request's size — the session prefix, the
|
||||||
* prompt), and if it exceeds the backend's threshold, compacts an older range
|
* surface-derived history, and the system prompt — and if it exceeds the
|
||||||
|
* backend's threshold, compacts an older range
|
||||||
* via {@link compactRegion}, keeping recent context intact. Returns `null`
|
* via {@link compactRegion}, keeping recent context intact. Returns `null`
|
||||||
* when no compaction is needed.
|
* when no compaction is needed.
|
||||||
*
|
*
|
||||||
* Scope and guarantees a backend MUST honor:
|
* Scope and guarantees a backend MUST honor:
|
||||||
* - **Surface-derived history only.** The decision is made against the history
|
* - **Compaction acts on surface-derived history only**, but the ESTIMATE
|
||||||
* derived from the session surface — the only thing compaction can act on.
|
* counts everything the request carries: the loop composes the session
|
||||||
* Non-surface context injected downstream (into the request `messages` by a
|
* prefix before the pre-step seam fires and hands it here, so the gate
|
||||||
* later listener) is out of this accounting by construction.
|
* sees the prefix this instance will actually send (`EpochHeader.messagePrefix`
|
||||||
|
* — request-only, never derived history). Non-surface context injected
|
||||||
|
* downstream (into the request `messages` by a later listener) is out of
|
||||||
|
* this accounting by construction.
|
||||||
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
|
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
|
||||||
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
|
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
|
||||||
* checkpoint is
|
* checkpoint is
|
||||||
@@ -88,10 +93,14 @@ export abstract class CompactService extends Service {
|
|||||||
* - **Single-unit overflow is out of scope.** If a single retained unit (one
|
* - **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
|
* 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
|
* exceeds the budget, compaction cannot help and the call may go out
|
||||||
* over-budget. Bounding an individual unit's size is a separate concern.
|
* over-budget. Bounding an individual unit's size is a separate concern —
|
||||||
|
* as is a session prefix that alone approaches the window (a
|
||||||
|
* configuration error no compactor fixes: compaction cannot shrink the
|
||||||
|
* prefix).
|
||||||
*
|
*
|
||||||
* @param agent - agent context owning the session surface and model options.
|
* @param agent - agent context owning the session surface and model options.
|
||||||
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
|
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
|
||||||
|
* @param sessionPrefix - the instance's composed session prefix, counted toward the estimate.
|
||||||
* @param signal - cancellation signal. A backend summarizing via
|
* @param signal - cancellation signal. A backend summarizing via
|
||||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||||
* so an abort/dispose tears down the in-flight summarization rather than
|
* so an abort/dispose tears down the in-flight summarization rather than
|
||||||
@@ -101,6 +110,7 @@ export abstract class CompactService extends Service {
|
|||||||
abstract compactIfNeeded(
|
abstract compactIfNeeded(
|
||||||
agent: CompactAgentContext,
|
agent: CompactAgentContext,
|
||||||
fullSystemPrompt: string,
|
fullSystemPrompt: string,
|
||||||
|
sessionPrefix: readonly Message[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CompactionResult | null>
|
): Promise<CompactionResult | null>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||||
|
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ class StubCompactService extends CompactService {
|
|||||||
override async compactIfNeeded(
|
override async compactIfNeeded(
|
||||||
_agent: CompactAgentContext,
|
_agent: CompactAgentContext,
|
||||||
_fullSystemPrompt: string,
|
_fullSystemPrompt: string,
|
||||||
|
_sessionPrefix: readonly Message[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CompactionResult | null> {
|
): Promise<CompactionResult | null> {
|
||||||
this.lastSignal = signal
|
this.lastSignal = signal
|
||||||
@@ -78,7 +80,7 @@ describe('CompactService seam', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const svc = new StubCompactService(ctx)
|
const svc = new StubCompactService(ctx)
|
||||||
const session = new Session(SessionId('s'))
|
const session = new Session(SessionId('s'))
|
||||||
expect(await svc.compactIfNeeded(stubAgent(session), '', new AbortController().signal)).toBeNull()
|
expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||||
@@ -107,7 +109,7 @@ describe('CompactService seam', () => {
|
|||||||
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
|
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
|
||||||
expect(svc.lastSignal).toBe(controller.signal)
|
expect(svc.lastSignal).toBe(controller.signal)
|
||||||
|
|
||||||
await svc.compactIfNeeded(stubAgent(session), '', controller.signal)
|
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
|
||||||
expect(svc.lastSignal).toBe(controller.signal)
|
expect(svc.lastSignal).toBe(controller.signal)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ forever:
|
|||||||
STEP loop:
|
STEP loop:
|
||||||
drain steering
|
drain steering
|
||||||
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
||||||
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
|
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
|
||||||
|
session prefix; on the header, never history
|
||||||
|
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
|
||||||
|
pressure gates see the prefix the request carries
|
||||||
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
|
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
|
||||||
session('step/start') strictly before step/start
|
session('step/start') strictly before step/start
|
||||||
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
|
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
|
||||||
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first request): frozen
|
|
||||||
session prefix; on the header, never history
|
|
||||||
session('request/header'[-delta]) ⟵ the header event this request owes the log
|
session('request/header'[-delta]) ⟵ the header event this request owes the log
|
||||||
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
|
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
|
||||||
message = waterfall agent/step-result
|
message = waterfall agent/step-result
|
||||||
|
|||||||
@@ -157,13 +157,14 @@ export interface LoopHandle {
|
|||||||
* drain steering → session('steering/message') ⟵ catches late steering
|
* drain steering → session('steering/message') ⟵ catches late steering
|
||||||
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
||||||
* (persona section + {{variables}}) IS the full prompt
|
* (persona section + {{variables}}) IS the full prompt
|
||||||
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
|
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
|
||||||
|
* session prefix; logged on the header, never
|
||||||
|
* session history
|
||||||
|
* await ctx.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
|
||||||
|
* pressure gates see the prefix the request carries
|
||||||
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
||||||
* session('step/start') same sync frame, strictly before step/start
|
* session('step/start') same sync frame, strictly before step/start
|
||||||
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
|
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
|
||||||
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first request):
|
|
||||||
* frozen session prefix; logged on the header,
|
|
||||||
* never session history
|
|
||||||
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
|
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
|
||||||
* log (initial/resume anchor, delta, fallback)
|
* log (initial/resume anchor, delta, fallback)
|
||||||
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
|
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
|
||||||
@@ -475,6 +476,41 @@ async function runTurn(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compose the session prefix ONCE per loop instance, lazily before the
|
||||||
|
// instance's first pre-step: request-only messages placed in front of
|
||||||
|
// the ENTIRE derived history on every request this instance sends. It
|
||||||
|
// MUST precede the pre-step seam so compaction gates on THIS instance's
|
||||||
|
// prefix — reading a previous instance's logged prefix would let a
|
||||||
|
// resumed/forked instance whose contributor grew skip compaction and
|
||||||
|
// ship an over-window first request. The result is deep-cloned
|
||||||
|
// (decoupled from listener-held references), deep-frozen, and cached on
|
||||||
|
// the transmission bookkeeping, so reuse is structural — the prefix
|
||||||
|
// cannot change mid-session and the provider prefix cache holds by
|
||||||
|
// construction (resume = a new instance = a recompose, anchored by its
|
||||||
|
// 'resume' snapshot). The prefix is not session history — the header
|
||||||
|
// event in runStep is its only durable record
|
||||||
|
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
|
||||||
|
// listener chain and the no-listener fallback: a contribution is a
|
||||||
|
// RETURNED extension of `await next()`, never an in-place push. This
|
||||||
|
// runs OUTSIDE the step, before the boundary snapshot: a composing
|
||||||
|
// listener's session append lands before the boundary and joins the
|
||||||
|
// CURRENT request.
|
||||||
|
if (transmission.sessionPrefix === undefined) {
|
||||||
|
const emptyPrefix: Message[] = deepFreeze([])
|
||||||
|
transmission.sessionPrefix = deepFreeze(structuredClone(await ctx.waterfall(
|
||||||
|
'agent/session-prefix', agent, emptyPrefix, abort.signal,
|
||||||
|
() => Promise.resolve(emptyPrefix),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interruption landing during prefix composition: mirror the assembly
|
||||||
|
// window above — drop the about-to-start step without running the seam.
|
||||||
|
if (handle.isCancelled() || handle.isDisposed()) {
|
||||||
|
handle.setAbort(undefined)
|
||||||
|
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
|
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
|
||||||
// step: after `turn/start` (and the prior step's close) but before
|
// step: after `turn/start` (and the prior step's close) but before
|
||||||
// `step/start`, so a compaction's log-only `compact/*` records and its
|
// `step/start`, so a compaction's log-only `compact/*` records and its
|
||||||
@@ -485,8 +521,10 @@ async function runTurn(
|
|||||||
// concurrent listeners cannot interleave their `session.append`s. A
|
// concurrent listeners cannot interleave their `session.append`s. A
|
||||||
// throwing listener escapes to the outer catch, which closes the (not-yet-
|
// 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
|
// open) step as a no-op and ends the turn via failTurn — a broken
|
||||||
// pre-step plugin ends the turn, not the loop.
|
// pre-step plugin ends the turn, not the loop. The composed session
|
||||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
|
// prefix rides along so token-pressure listeners count everything the
|
||||||
|
// request will actually carry.
|
||||||
|
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||||
|
|
||||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||||
if (handle.isCancelled() || handle.isDisposed()) {
|
if (handle.isCancelled() || handle.isDisposed()) {
|
||||||
@@ -722,27 +760,10 @@ async function runStep(
|
|||||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose the session prefix ONCE per loop instance, lazily on its first
|
// The session prefix was composed (once per instance) before this step's
|
||||||
// request-building step: request-only messages placed in front of the
|
// pre-step seam — the caller guarantees it, so the cache is always set here.
|
||||||
// ENTIRE derived history on every request this instance sends. The result
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||||
// is deep-cloned (decoupled from listener-held references), deep-frozen,
|
const sessionPrefix = transmission.sessionPrefix!
|
||||||
// and cached on the transmission bookkeeping, so reuse is structural — the
|
|
||||||
// prefix cannot change mid-session and the provider prefix cache holds by
|
|
||||||
// construction (resume = a new instance = a recompose, anchored by its
|
|
||||||
// 'resume' snapshot). The prefix is not session history — the header event
|
|
||||||
// below is its only durable record (EpochHeader.messagePrefix), which
|
|
||||||
// keeps the request a pure function of the log. The frozen empty seed
|
|
||||||
// serves both the listener chain and the no-listener fallback: a
|
|
||||||
// contribution is a RETURNED extension of `await next()`, never an
|
|
||||||
// in-place push.
|
|
||||||
if (transmission.sessionPrefix === undefined) {
|
|
||||||
const emptyPrefix: Message[] = deepFreeze([])
|
|
||||||
transmission.sessionPrefix = deepFreeze(structuredClone(await ctx.waterfall(
|
|
||||||
'agent/session-prefix', agent, emptyPrefix, signal,
|
|
||||||
() => Promise.resolve(emptyPrefix),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
const sessionPrefix = transmission.sessionPrefix
|
|
||||||
|
|
||||||
// The request header (the log's request/header* vocabulary): canonical form,
|
// The request header (the log's request/header* vocabulary): canonical form,
|
||||||
// recorded before dispatch so the log always explains the request —
|
// recorded before dispatch so the log always explains the request —
|
||||||
|
|||||||
@@ -166,6 +166,69 @@ describe('Agent.cancel()', () => {
|
|||||||
expect(reasons.length).toBe(2)
|
expect(reasons.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
|
||||||
|
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||||
|
const ctx = await harness(adapter)
|
||||||
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
|
// Prefix composition runs before the pre-step seam on the instance's first
|
||||||
|
// step; a cancel landing inside it must drop the about-to-start step
|
||||||
|
// without running the seam or the model.
|
||||||
|
let streamed = false
|
||||||
|
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||||
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||||
|
agent.cancel('from prefix composition')
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reasons: TurnEndReason[] = []
|
||||||
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||||
|
|
||||||
|
send(agent, 'go')
|
||||||
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
expect(streamed).toBe(false)
|
||||||
|
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', 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-prefix'),
|
||||||
|
sessionId: SessionId('dispose-prefix-session'),
|
||||||
|
agentOptions: { model: 'mock' },
|
||||||
|
})
|
||||||
|
const agent = handle.agent as ReactLoopAgent
|
||||||
|
|
||||||
|
let disposalDone: Promise<void> | undefined
|
||||||
|
let streamed = false
|
||||||
|
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||||
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||||
|
disposalDone = handle.dispose()
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
send(agent, 'go')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
await disposalDone
|
||||||
|
await agent.done
|
||||||
|
|
||||||
|
// No step opened, no model call ran, and the turn closed disposed.
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
|
||||||
it('cancel from a synchronous turn/start session-event 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 adapter = new MockAdapter([textResponse('should not stream')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
|
|||||||
@@ -352,6 +352,33 @@ describe('agent/session-prefix', () => {
|
|||||||
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
|
||||||
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
|
const ctx = await harness(adapter)
|
||||||
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
|
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
|
||||||
|
const order: string[] = []
|
||||||
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||||
|
order.push('compose')
|
||||||
|
return [reminder, ...await next()]
|
||||||
|
})
|
||||||
|
const seen: (readonly Message[])[] = []
|
||||||
|
ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
|
||||||
|
order.push('pre-step')
|
||||||
|
seen.push(sessionPrefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
send(agent, 'hi')
|
||||||
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
// Composition precedes the pre-step seam, and the seam receives THIS
|
||||||
|
// instance's composed prefix — a token-pressure gate (compaction) counts
|
||||||
|
// what the request will actually carry, never a stale logged prefix.
|
||||||
|
expect(order).toEqual(['compose', 'pre-step'])
|
||||||
|
expect(seen[0]).toEqual([reminder])
|
||||||
|
})
|
||||||
|
|
||||||
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
|||||||
|
|
||||||
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
||||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||||
- `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/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; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
|
||||||
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
||||||
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily on its first request; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
- `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 via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||||
|
|
||||||
|
|||||||
@@ -333,21 +333,28 @@ declare module 'cordis' {
|
|||||||
* value; this event is typed and documented as `void`, so listeners must not
|
* value; this event is typed and documented as `void`, so listeners must not
|
||||||
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
||||||
* listener needs to measure pressure (the system prompt counts toward the
|
* listener needs to measure pressure (the system prompt counts toward the
|
||||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
* budget), and `sessionPrefix` is the instance's composed
|
||||||
|
* {@link agent/session-prefix} product for the same reason — every request
|
||||||
|
* carries it in front of the derived history, and it is composed BEFORE
|
||||||
|
* this seam fires precisely so a pressure gate counts the prefix the
|
||||||
|
* request will actually send (never a stale logged one). `signal` cancels
|
||||||
|
* any in-flight work a listener starts (e.g. a
|
||||||
* summarization model call).
|
* summarization model call).
|
||||||
* @param agent - the agent about to open the step.
|
* @param agent - the agent about to open the step.
|
||||||
* @param turn - the already-open turn this step belongs to.
|
* @param turn - the already-open turn this step belongs to.
|
||||||
* @param step - the number of the step about to start.
|
* @param step - the number of the step about to start.
|
||||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||||
|
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
|
||||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||||
* @mode serial
|
* @mode serial
|
||||||
*/
|
*/
|
||||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
|
||||||
// is its only consumer, so a wide event carries a string just one listener
|
// per-step seam — compaction
|
||||||
|
// is their only consumer, so a wide event carries payloads just one listener
|
||||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||||
// prompt provider, or move token-pressure measurement behind a
|
// prompt provider, or move token-pressure measurement behind a
|
||||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
// 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> | void
|
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||||
/**
|
/**
|
||||||
* Waterfall: decide what happens to ONE drained queued message before it
|
* Waterfall: decide what happens to ONE drained queued message before it
|
||||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||||
@@ -389,13 +396,18 @@ declare module 'cordis' {
|
|||||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||||
* front of the ENTIRE derived history (directly after the provider's
|
* front of the ENTIRE derived history (directly after the provider's
|
||||||
* system slot) on every request this loop instance sends. Fired ONCE per
|
* system slot) on every request this loop instance sends. Fired ONCE per
|
||||||
* loop instance, lazily on its first request-building step; the composed
|
* loop instance, lazily before its first step's {@link agent/pre-step}
|
||||||
|
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
|
||||||
|
* the prefix this instance will actually send, never a previous
|
||||||
|
* instance's logged one. The composed
|
||||||
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
||||||
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
||||||
* verbatim for every subsequent request — never recomputed mid-session,
|
* verbatim for every subsequent request — never recomputed mid-session,
|
||||||
* so the provider prefix cache holds by construction (a process restart
|
* so the provider prefix cache holds by construction (a process restart
|
||||||
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
||||||
* drift lands attributably on the `'resume'` snapshot).
|
* drift lands attributably on the `'resume'` snapshot). Composition runs
|
||||||
|
* outside the step, before the boundary snapshot: a composing listener's
|
||||||
|
* session append joins the CURRENT request's derived history.
|
||||||
*
|
*
|
||||||
* This is the home for session-stable openers the model must always see
|
* This is the home for session-stable openers the model must always see
|
||||||
* but that must NOT become durable history — a skills catalog, an
|
* but that must NOT become durable history — a skills catalog, an
|
||||||
|
|||||||
Reference in New Issue
Block a user