diff --git a/docs/architecture.md b/docs/architecture.md index 6d29fb10f5..d7bea1a674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,14 +1,14 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** is an SDK for building agent harnesses using the Cordis framework. The governing principle is simple: **everything is a plugin**. For example, the shipped agent loop is just one plugin in the default bundle, not a privileged kernel. +The **DeepSeek Harness SDK** is an SDK for building agent harnesses on the Cordis framework. The governing principle is simple: **everything is a plugin**. The shipped agent loop is one plugin in the default bundle, not a privileged kernel. -Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). If Cordis itself is new to you, start with the [Cordis primer](cordis-primer.md). +Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). New to Cordis? Start with the [Cordis primer](cordis-primer.md). ## System Shape A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services are the stable call surfaces (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners. -The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins from a Cordis perspective. +The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins. ### Default Service Spine @@ -40,7 +40,7 @@ Events are the harness extension API. Each service owns the vocabulary for the b ### Event Domains -Use the event domain to decide where new behavior belongs: +Pick the event domain for new behavior: - **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. - **Agent events** are live runtime surfaces. They carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. @@ -48,11 +48,11 @@ Use the event domain to decide where new behavior belongs: ### Interception Semantics -Waterfall events behave like around-middleware: a listener delegates by calling `next()` and vetoes or takes over by returning without it. The full rule lives in [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). +Waterfall events behave like around-middleware: a listener delegates by calling `next()`; returning without it vetoes or takes over. Full rule: [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). ## Default Loop Lifecycle -The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam that another plugin can program against. +The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam other plugins program against. A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension seams. @@ -71,6 +71,7 @@ forever: STEP loop: drain steering assemble system prompt and tool schemas + agent/session-prefix (first step) agent/pre-step 'step/start' snapshot the derived messages (the reconstruction boundary) @@ -90,7 +91,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` itself owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, from its `persona` config, shared by every agent in the context) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, its `persona` config, shared context-wide) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input. @@ -110,7 +111,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start`, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. @@ -124,7 +125,7 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families. +A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the package families. Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). @@ -144,6 +145,7 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | +| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 57561adb05..e78da1b7c4 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,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:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,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:271`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -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) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:476`](../../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), 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 -'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void +'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | 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:349`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:357`](../../packages/core/agent/src/types.ts) ### `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) -Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,11 +85,11 @@ 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:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble` — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. +Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,7 +97,23 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:385`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:394`](../../packages/core/agent/src/types.ts) + +### `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 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. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests. + +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. + +The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. + +```ts cordis-catalog +'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -109,7 +125,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:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -121,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -133,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) -Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -145,7 +161,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:408`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 71d3fe5321..b9d1d9e85f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -94,11 +94,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. ```ts cordis-catalog -abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, signal: AbortSignal, ): Promise +abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:64`](../../packages/compact/compact/src/index.ts) +Types: [Message](../core-data-structures/core.md) + +Source: [`packages/compact/compact/src/index.ts:65`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 78eb48d38e..2f402be57d 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(agent, 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, 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, the assembled `fullSystemPrompt`, 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, fullSystemPrompt, sessionPrefix, 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, 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, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), 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 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/core-data-structures/core.md b/docs/core-data-structures/core.md index 212570c358..1624d82d34 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -130,6 +130,12 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface GenerateOptions { model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a + * hand-built one-shot passes any list. + */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ system?: string @@ -190,7 +196,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -Requests are built by the loop, not shaped per call: the non-content half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt and the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset) — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. + +On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. @@ -323,7 +331,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`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. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`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. ## Interception decisions @@ -360,6 +368,8 @@ type ContinuationDecision = type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` +`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. + ## `ToolDefinition` The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6c4712114f..1b12c3fc48 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -74,16 +74,15 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Amendment to the folded {@link EpochHeader}: at least one of a - * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement - * {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the - * loop inside the step, before dispatch, when the header for this request - * differs from the fold of the log so far; the writer verifies - * `applyHeaderDelta(previous, delta)` reproduces the new header exactly and - * falls back to a `'fallback'` `request/header` snapshot when it cannot, so - * a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}. + * Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed + * tools delta, whole replacement config, or whole replacement session + * prefix (an EMPTY array encodes the transition to "none"). The + * writer verifies `applyHeaderDelta(previous, delta)` reproduces the new + * header exactly and falls back to a `'fallback'` `request/header` snapshot + * when it cannot, so a logged delta ALWAYS round-trips. NOT a + * {@link SurfaceEventType}. */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } ``` @@ -100,7 +99,7 @@ export interface TodoItem { ### The request header events: `request/header` and `request/header-delta` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message. +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message. ```ts type-equiv export interface EpochHeader { @@ -110,10 +109,18 @@ export interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] + /** + * The session prefix: request-only messages sent BEFORE the entire derived + * history (the `agent/session-prefix` waterfall's product, composed once + * per loop instance and reused for every request it sends). Not session + * history — `deriveMessages()` never returns it — so the header is its + * only durable record; absent when the instance composed none. + */ + messagePrefix?: Message[] } ``` -Canonical form: an empty system prompt and an empty tool list are ABSENT fields, matching how requests are built. The delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts). +Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are ABSENT fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); composed once per loop instance and anchored by that instance's snapshot, so the loop never produces a prefix delta in practice — the delta arm (whole-array replacement, an empty array encoding the transition back to absence) exists for codec totality. The other delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts). ## `SessionEvent` — one log entry diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c3f76629aa..4aba8c00dc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,17 +7,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../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:271`](../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:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:349`](../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:362`](../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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:385`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:304`](../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:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../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/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/error` | `emit` | [`packages/core/agent/src/types.ts:476`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `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: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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `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:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../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/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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../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/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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 58fd548665..16ef2fb583 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -35,7 +35,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) ### `compact/*` @@ -83,7 +83,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) ### `hook/*` @@ -119,7 +119,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `request/*` @@ -131,17 +131,17 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only -Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, or a whole replacement LlmCallConfig (four scalars — not worth diffing). Appended by the loop inside the step, before dispatch, when the header for this request differs from the fold of the log so far; the writer verifies `applyHeaderDelta(previous, delta)` reproduces the new header exactly and falls back to a `'fallback'` `request/header` snapshot when it cannot, so a logged delta ALWAYS round-trips. NOT a SurfaceEventType. +Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field — the loop never produces one in practice: the prefix is composed once per instance and anchored by that instance's snapshot, so this arm exists for codec totality). Appended by the loop inside the step, before dispatch, when the header for this request differs from the fold of the log so far; the writer verifies `applyHeaderDelta(previous, delta)` reproduces the new header exactly and falls back to a `'fallback'` `request/header` snapshot when it cannot, so a logged delta ALWAYS round-trips. NOT a SurfaceEventType. ```ts persistence-catalog -'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } +'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) ### `steering/*` @@ -155,7 +155,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) ### `step/*` @@ -167,7 +167,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -177,7 +177,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) ### `todo/*` @@ -193,7 +193,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) ### `tool/*` @@ -207,7 +207,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) #### `tool/result` — surface @@ -219,7 +219,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) ### `turn/*` @@ -233,7 +233,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -245,7 +245,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) ### `user/*` @@ -259,4 +259,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 47b1e92212..60fdb279ca 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -62,6 +62,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | | [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 | +| [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Background subagent tasks](implemented/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 601e64f70a..73967c14b6 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -20,13 +20,13 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -**The header.** The request's non-content half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas — is logged session state, in canonical form (empty system/tools ≡ 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). 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; content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log → build `GenerateOptions` from the snapshot + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's only in-process bookkeeping is one boolean: whether this instance has logged its anchoring snapshot. +**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. -**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the boundary derivation, rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself, and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. +**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted @@ -44,6 +44,7 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt — ## Consequences - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. +- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side. - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. - Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md new file mode 100644 index 0000000000..6f81d12407 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -0,0 +1,42 @@ +# RFC: The session prefix — request-only messages in front of the derived history + +Status: implemented + +## Problem + +A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role `` envelope, a multi-message primer) does not fit it, and providers weight conversation messages differently from system text. Durable history (`agent.inject()`, a `context/message` at session start) makes the opener permanent: every `deriveMessages()` consumer replays it, the compaction retention walk owns it, forks bake it in stale, and a resume cannot refresh it — a catalog captured at session birth outlives the world it described. + +The obvious third option — let a plugin edit the request's `messages` on the way out — is banned by [the reconstructable-requests RFC](../architecture/2026-07-05-reconstructable-requests.md): every loop-built request is a pure function of the session log, so whatever channel carries the opener must log exactly what it sends. What was missing was a request-only message channel with a durable record. + +## Decision + +`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). + +Three properties carry the design: + +- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. +- **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. +- **Composed before the pressure gate.** Composition precedes the instance's first `agent/pre-step`, and the seam hands the composed value through: `agent/pre-step` carries a `sessionPrefix` parameter and `CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. + +Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface. + +## Testing + +**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition, zero `request/header-delta`s), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session codec tests cover the `messagePrefix` fold/diff/apply arms (empty ≡ absent); dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on. + +## Alternatives considered + +- **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites silent drift — nothing anchors it to the log short of logging a header delta per step — and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation. +- **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with header deltas when it changes) while the opener wants instance-frozen semantics. +- **A durable history opener** (`inject()` at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes. +- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a header delta per change, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. +- **Compose lazily at the first request and let compaction read the folded header** (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step. +- **A dedicated session event carrying the prefix** — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total. + +## Consequences + +- `agent/pre-step` and `CompactService.compactIfNeeded` carry a `sessionPrefix` parameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance). +- A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable `context/message`. +- The dropped `after` slot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid. +- The `request/header-delta` `messagePrefix` arm (whole-array replacement, empty array encoding transition to absence) exists for codec totality; the loop never exercises it, because the cached prefix cannot change within an instance. +- An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index f4400911c6..a06e74b818 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -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: -- **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). +- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt. - **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. - **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. - **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 3916441402..c0d4ff483a 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -183,11 +183,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, fullSystemPrompt: string, signal: AbortSignal) => { + ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => { try { - const result = await this.compactIfNeeded(agent, fullSystemPrompt, signal) + const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) if (result) { - const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt) + const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix) ctx.logger.info( `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + @@ -359,11 +359,23 @@ export class BasicCompactService extends CompactService { // ---- Core API (implements the abstract contract) ---- /** - * The sole token-pressure gate: estimate the current surface-derived history, - * and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact + * The sole token-pressure gate: estimate the NEXT request's pressure — the + * session prefix + the surface-derived history + the system prompt + * ({@link estimatePressure}) — and if it exceeds the threshold + * (`contextWindow * thresholdRatio`), compact * the oldest surface nodes outside the `retainTokens` budget. The auto- * compaction listener delegates here rather than pre-checking, so this is the - * only place the decision lives. + * only place the decision lives. The prefix counts because every request + * carries it in front of the history (`EpochHeader.messagePrefix`) even + * though it is not derived history — omitting it would under-estimate by + * exactly the prefix and let a deployment at the window edge skip + * compaction, then ship an over-window request. The loop composes the + * prefix BEFORE the pre-step seam and hands it through, so the gate sees + * this instance's actual prefix (never a previous instance's logged one — + * a resumed/forked instance whose contributor grew is gated on the grown + * value from its very first step). Compaction itself can only + * shrink HISTORY: a prefix that alone approaches the window is a + * configuration error no compactor fixes. * * Retention is a UNIFORM tail→head walk over the whole surface — turn * boundaries play NO role. Walking node-by-node from the tail and summing @@ -387,13 +399,14 @@ export class BasicCompactService extends CompactService { override async compactIfNeeded( agent: Agent, fullSystemPrompt: string, + sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise { const session = agent.session const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio) let result: CompactionResult | null = null for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) { - const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix) if (totalTokens < threshold) return result const range = this._compactableRange(session) @@ -407,7 +420,7 @@ export class BasicCompactService extends CompactService { result = await this.compactRegion(session, range.start, range.end, agent, signal) } - const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt) + const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix) if (totalTokens < threshold) return result throw new Error( @@ -416,6 +429,20 @@ export class BasicCompactService extends CompactService { ) } + /** + * Estimated token pressure of the NEXT request: the session prefix + * (`EpochHeader.messagePrefix` — request-only messages the loop sends in + * front of the derived history, composed before the pre-step seam and + * handed to the gate), the derived history, and the system prompt. + * @param session - the session whose next request is being estimated. + * @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. + */ + estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number { + return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt) + } + override async compactRegion( session: Session, start: number, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 9cd61a95be..e3d80cd567 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -557,6 +557,24 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result!.shadowedSeqs.length).toBeGreaterThan(0) }) + 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 session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone + expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() + + // The loop composes the agent/session-prefix product before the pre-step + // seam and hands it to the gate; it rides every request, so pressure must + // include it — the same history now crosses the threshold. + const sessionPrefix: Message[] = [ + { 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) + expect(result).not.toBeNull() + // The prefix itself is NOT history: compaction shadowed surface nodes only. + expect(sessionPrefix).toHaveLength(2) + }) + 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`. @@ -982,8 +1000,9 @@ function compactIfNeeded( fullSystemPrompt: string, model: string, signal: AbortSignal, + sessionPrefix: readonly Message[] = [], ) { - return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, signal) + return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, sessionPrefix, signal) } function compactRegion( @@ -1151,7 +1170,7 @@ 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, fullSystemPrompt: string): Promise { - 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 () => { @@ -1253,7 +1272,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => const session = multiTurnSession(5, 1) 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(session.events.some(e => e.type === 'compact/summary')).toBe(true) @@ -1392,7 +1411,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, '', 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' }) @@ -1472,7 +1491,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, '', 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) @@ -1489,7 +1508,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, 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) }) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index e908412d16..e98f00899f 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(agent, 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, assembled `fullSystemPrompt`, and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | +| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, 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, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | | `compactRegion(session, start, end, agent, 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 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. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 931e8274cf..7d1138314c 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -22,6 +22,7 @@ */ import { Context, Service } from 'cordis' +import type { Message } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' @@ -69,16 +70,20 @@ export abstract class CompactService extends Service { /** * Check token pressure and compact if the conversation is too large. * - * Estimates the current surface-derived history size (including the system - * prompt), and if it exceeds the backend's threshold, compacts an older range + * Estimates the NEXT request's size — the session prefix, the + * surface-derived history, and the system prompt — and if it exceeds the + * backend's threshold, compacts an older range * via {@link compactRegion}, keeping recent context intact. Returns `null` * when no compaction is needed. * * Scope and guarantees a backend MUST honor: - * - **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. + * - **Compaction acts on surface-derived history only**, but the ESTIMATE + * counts everything the request carries: the loop composes the session + * prefix before the pre-step seam fires and hands it here, so the gate + * sees the prefix this instance will actually send (`EpochHeader.messagePrefix` + * — request-only, never derived history). Non-surface context injected + * downstream (into the request `messages` by a later listener) is out of + * this accounting by construction. * - **Head-anchored, best-effort.** Auto-compaction consolidates from the * surface HEAD up to a balanced tool-pairing cutoff, so a prior head * checkpoint is @@ -89,10 +94,14 @@ export abstract class CompactService extends Service { * - **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. + * 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 fullSystemPrompt - assembled system prompt, counted toward the estimate. + * @param sessionPrefix - the instance's composed session prefix, counted toward the estimate. * @param signal - cancellation signal. A backend summarizing via * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than @@ -102,6 +111,7 @@ export abstract class CompactService extends Service { abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, + sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 93c4e806ce..c4daa8cc5a 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CompactService } 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 type { CompactAgentContext } from '@deepseek-ai/dsh-compact' @@ -18,6 +19,7 @@ class StubCompactService extends CompactService { override async compactIfNeeded( _agent: CompactAgentContext, _fullSystemPrompt: string, + _sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise { this.lastSignal = signal @@ -78,7 +80,7 @@ describe('CompactService seam', () => { const ctx = new Context() const svc = new StubCompactService(ctx) 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 () => { @@ -107,7 +109,7 @@ describe('CompactService seam', () => { await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 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) }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 70b385e8bb..967180b2c9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -95,7 +95,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'compact', summary: 'Abstract compaction service.', methods: [ - 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, signal: AbortSignal, ): Promise', + 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise', 'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', ], }, @@ -231,7 +231,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/pre-step', mode: 'serial', - signature: '\'agent/pre-step\'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void', + signature: '\'agent/pre-step\'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void', summary: '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`.', }, { @@ -252,6 +252,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/request\'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', summary: 'Waterfall: shape the step\'s call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).', }, + { + name: 'agent/session-prefix', + mode: 'waterfall', + signature: '\'agent/session-prefix\'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', + summary: '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.', + }, { name: 'agent/session-start', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index bae2d9447a..f5d7358d7c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,12 +55,15 @@ forever: STEP loop: drain steering 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, session('step/start') strictly before step/start config = waterfall agent/request ⟵ frozen seed; return a replacement to switch session('request/header'[-delta]) ⟵ the header event this request owes the log - stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk') + stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk') message = waterfall agent/step-result session('assistant/message') each tool-call: session('tool/call') @@ -84,7 +87,7 @@ 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/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` +- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` - Compaction: `agent/pre-step` - Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute` - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index eb5b07ba23..33ddee8f64 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -157,13 +157,17 @@ export interface LoopHandle { * drain steering → session('steering/message') ⟵ catches late steering * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt * (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 * session('step/start') same sync frame, strictly before step/start * config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches * session('request/header'|'request/header-delta') ⟵ the header event this request owes the * log (initial/resume anchor, delta, fallback) - * req = freeze({header..., messages: boundary, sessionId, signal}) + * req = freeze({header..., messages: prefix+boundary, sessionId, signal}) * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req) * session('assistant/chunk') * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the @@ -472,6 +476,50 @@ async function runTurn( 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([]) + const composed = 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, and DISCARD the composition instead of caching it. An + // abort-aware listener may have returned a degraded fallback under + // the firing signal; committing it would ship a prefix no request + // ever used (and no header ever logged) on this instance's next real + // request. The next turn recomposes under a live signal — the cache + // only ever holds a fully composed prefix. The cache-hit path needs + // no such check: nothing awaits between the assembly check above and + // the pre-step seam. + if (handle.isCancelled() || handle.isDisposed()) { + handle.setAbort(undefined) + reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } + break + } + transmission.sessionPrefix = deepFreeze(structuredClone(composed)) + } + // 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 @@ -482,8 +530,10 @@ async function runTurn( // 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, fullSystemPrompt, abort.signal) + // pre-step plugin ends the turn, not the loop. The composed session + // 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. if (handle.isCancelled() || handle.isDisposed()) { @@ -674,11 +724,12 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean { } /** One step: build the request from the boundary snapshot + the step's - * header → log the header event the request owes → stream model → record → - * execute tools. The caller assembles the system prompt, fires the - * `agent/pre-step` seam, snapshots the derivation, and opens the step BEFORE - * calling this, so `boundaryMessages` is exactly the surface prefix at - * step/start and already reflects any compaction. */ + * header → compose the session prefix if this instance has none yet → log + * the header event the request owes → stream model → record → execute + * tools. The caller assembles the + * system prompt, fires the `agent/pre-step` seam, snapshots the derivation, + * and opens the step BEFORE calling this, so `boundaryMessages` is exactly + * the surface prefix at step/start and already reflects any compaction. */ async function runStep( ctx: Context, agent: ReactLoopAgent, @@ -718,22 +769,30 @@ async function runStep( throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } + // The session prefix was composed (once per instance) before this step's + // pre-step seam — the caller guarantees it, so the cache is always set here. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call + const sessionPrefix = transmission.sessionPrefix! + // 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 — + // including the session prefix, which no other event carries. const header = canonicalHeader({ config, ...system ? { system } : {}, ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, + ...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {}, }) recordRequestHeader(session, transmission, header) // Build and freeze: the request is a pure function of (boundary snapshot, // logged header) — llm/stream listeners and adapters read it, mutation // throws. sessionId + frozen is the loop-built marker the dev invariant - // keys on. + // keys on. Message order: header.messagePrefix, then the boundary + // snapshot — the reconstruction equation the invariant recomputes. const request: GenerateOptions = deepFreeze({ model: header.config.model, - messages: boundaryMessages, + messages: [...header.messagePrefix ?? [], ...boundaryMessages], ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {}, diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index 90f47068fa..d2763f5c2a 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -12,11 +12,20 @@ import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session' import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' +import type { Message } from '@deepseek-ai/dsh-llm' /** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */ export interface TransmissionLog { /** True once this loop instance appended its anchoring `request/header` snapshot. */ loggedHeader: boolean + /** + * The instance's composed session prefix (the `agent/session-prefix` + * waterfall's deep-frozen product), cached on the instance's first + * request-building step and reused verbatim for every request it sends — + * the structural guarantee that the prefix never changes mid-session. + * `undefined` until composed. + */ + sessionPrefix?: Message[] } /** @@ -61,7 +70,7 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he const baseline = session.requestHeader()! if (headerEquals(baseline, header)) return const delta = diffHeader(baseline, header) - /* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same three parts */ + /* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */ if (delta === undefined) return if (headerEquals(applyHeaderDelta(baseline, delta), header)) { session.append('request/header-delta', delta) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 0e77f0bcbf..63a75447ca 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -166,6 +166,103 @@ describe('Agent.cancel()', () => { 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 | 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('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + // The first composition is interrupted mid-waterfall and — like an + // abort-aware listener bailing on a firing signal — contributes nothing. + // Caching that degraded result would silently strip the prefix from every + // later request of this instance; the loop must discard it and recompose + // on the next send, and the SECOND composition's value must be what the + // wire and the header log carry. + const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] } + let compositions = 0 + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + compositions += 1 + if (compositions === 1) { + agent.cancel('mid-composition') + return next() + } + return [opener, ...await next()] + }) + + send(agent, 'dropped') + await waitForIdle(ctx, agent) + send(agent, 'real prompt') + await waitForIdle(ctx, agent) + + expect(compositions).toBe(2) + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]?.messages[0]).toEqual(opener) + const headerEvent = agent.session.events.find(e => e.type === 'request/header') + expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([opener]) + }) + 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) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index e76ac30fa9..bab2ae6ea1 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' @@ -310,6 +310,162 @@ describe('agent/session-start', () => { }) }) +describe('agent/session-prefix', () => { + it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'ping' }), + textResponse('done'), + textResponse('again'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } + let composed = 0 + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + composed += 1 + return [...await next(), reminder] + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + send(agent, 'next turn') + await waitForIdle(ctx, agent) + + // Three requests (two turns), ONE composition: the frozen product is + // reused verbatim, so the prefix cannot drift mid-session. + expect(adapter.requests).toHaveLength(3) + expect(composed).toBe(1) + for (const request of adapter.requests) { + expect(request.messages[0]).toEqual(reminder) + } + // The anchoring snapshot is the prefix's durable record — and the ONLY + // header event: reuse means no request/header-delta ever. + const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta') + expect(headerEvents).toHaveLength(1) + expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder]) + // Never session history: the derivation starts at the real user prompt. + 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 => { + 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 () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + // Both listeners use the canonical `[mine, ...await next()]` prepend: the + // waterfall unwinds innermost-first (the second listener's array is built + // first), so prepending puts the FIRST-registered contribution first. + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()] + }) + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()] + }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '') + expect(texts).toEqual(['first', 'second', 'hi']) + }) + + it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + // A listener that delegates without contributing — the canonical no-op. + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + const headerEvent = events(agent).find(e => e.type === 'request/header') + expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false) + expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) + }) + + it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let mutationError: unknown + ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { + try { + prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] }) + } catch (error: unknown) { + mutationError = error + } + return next() + }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(mutationError).toBeInstanceOf(TypeError) + expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) + }) + + it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'ping' }), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The listener mutates the object it contributed AFTER composition; the + // cached prefix is a deep-frozen clone, so step 2's request is unchanged. + held.content = [{ type: 'text', text: 'v2' }] + expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] }) + expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0) + }) +}) + + describe('agent/turn-continuation (ContinuationDecision)', () => { it('a continue decision with a reason records next-step steering in the same turn', async () => { const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 01dec7deaa..b1cb788585 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -46,8 +46,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/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/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/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. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 46bc52128a..2bde463be5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -17,7 +17,8 @@ * consumer that wants the live transcript subscribes here. * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ - * `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and + * `agent/request`/`agent/session-prefix`/`agent/step-result`/ + * `agent/turn-continuation` waterfalls and * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits * (`agent/status`, `agent/error`, `agent/created`/ * `agent/disposed`, `agent/queued`, `agent/session-start`) @@ -332,21 +333,28 @@ declare module 'cordis' { * 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 + * 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). * @param agent - the agent about to open the step. * @param turn - the already-open turn this step belongs to. * @param step - the number of the step about to start. * @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. * @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 + // TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic + // 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 // 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 + 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void /** * Waterfall: decide what happens to ONE drained queued message before it * becomes a `user/message` — allow (optionally rewriting the prompt bytes or @@ -367,8 +375,9 @@ declare module 'cordis' { * ALL a listener shapes here: every request is a pure function of the * session log (the reconstructability RFC), so model-visible content * flows through the log channels — `inject()`, steering, prompt-submit - * `additionalContext`, prompt sections via `system-prompt/assemble` — - * never through request mutation, and the loop records whatever config + * `additionalContext`, prompt sections via `system-prompt/assemble`, or + * the header-logged session prefix via {@link agent/session-prefix} + * — never through request mutation, and the loop records whatever config * the request actually uses as a `request/header*` event before dispatch. * The step's messages are already snapshotted when this fires (the * `step/start` boundary): an `inject()` from a listener here lands in the @@ -383,6 +392,53 @@ declare module 'cordis' { * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise + /** + * 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 {@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 + * 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. A + * composition interrupted by a cancel/dispose landing inside the + * waterfall is discarded — never cached, logged, or sent — and the next + * turn recomposes under a live signal, so an abort-aware listener's + * degraded fallback cannot leak into later requests. + * + * 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. + * + * The seed is a frozen empty list; a contributing listener returns a NEW + * array — never an in-place push. The canonical contribution is a + * PREPEND, `[mine, ...await next()]`: the waterfall unwinds + * innermost-first (the LAST-registered listener's `next()` resolves + * first), so prepending yields registration order on the wire, and every + * plugin using it composes deterministically. The append form + * `[...await next(), mine]` is legal but places a contribution AFTER + * every later-registered plugin's — reverse registration order when all + * contributors append. Call `next()` to + * delegate, or return a list without it to short-circuit. + * @param agent - the agent whose session prefix is being composed. + * @param prefix - the frozen empty seed; return an extended replacement to contribute. + * @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down. + * @mode waterfall + */ + 'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0ae5bbf39e..1cc393e172 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -51,7 +51,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Request-header reconstruction (`request-header.ts`) -The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools ≡ absent fields). +The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. ### Session event vocabulary (`types.ts`) diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index d83da16197..eeb2fe40ed 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -13,15 +13,23 @@ */ import { callConfigEquals } from '@deepseek-ai/dsh-llm' -import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts' +/** The `request/header-delta` payload shape: each present field amends the folded header. */ +type HeaderDelta = { + system?: SystemDelta + tools?: ToolsDelta + config?: LlmCallConfig + messagePrefix?: Message[] +} + /** - * Normalize a header to canonical form: an empty system prompt and an empty - * tool list become ABSENT fields, matching how requests are built (both - * request-build spreads skip empty values). Diff, fold, and comparison all - * operate on canonical headers, so "no system prompt" has exactly one - * representation. + * Normalize a header to canonical form: an empty system prompt, an empty + * tool list, and an empty session prefix become ABSENT fields, matching how + * requests are built (the request-build spreads skip empty values). Diff, + * fold, and comparison all operate on canonical headers, so "no system + * prompt" (and "no session prefix") has exactly one representation. * @param header - the header to normalize (not mutated). * @returns the canonical header. */ @@ -30,6 +38,7 @@ export function canonicalHeader(header: EpochHeader): EpochHeader { config: header.config, ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, + ...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {}, } } @@ -109,37 +118,46 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[ * writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal * the intended header) and the loop runs to skip logging an unchanged header. * Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is - * correctly unequal. + * correctly unequal; the session prefix compares as canonical JSON (both + * sides come from the same build path, so key order matches when the values + * do). * @param a - one canonical header. * @param b - the other. - * @returns whether config, system, and tools (in order) all match. + * @returns whether config, system, tools (in order), and the session prefix all match. */ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false + if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false const at = a.tools ?? [] const bt = b.tools ?? [] return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema)) } +/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */ +function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []) +} + /** * Compute the `request/header-delta` payload between two canonical headers, * or undefined when they are equal. The caller MUST round-trip the result * ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it — * the encoding cannot express every change (a pure tool reordering) — and * fall back to a full `request/header` snapshot when the check fails. + * The session prefix is replaced whole (small advisory content, not worth + * diffing); an empty replacement array encodes the transition to "none". * @param prev - the folded header the log currently implies. * @param next - the header the next request will actually use. * @returns the delta payload, or undefined when nothing changed. */ -export function diffHeader( - prev: EpochHeader, next: EpochHeader, -): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined { - const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {} +export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined { + const delta: HeaderDelta = {} if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system) const prevTools = prev.tools ?? [] const nextTools = next.tools ?? [] if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools) if (!callConfigEquals(prev.config, next.config)) delta.config = next.config + if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? [] return Object.keys(delta).length > 0 ? delta : undefined } @@ -151,15 +169,15 @@ export function diffHeader( * @param delta - the logged delta payload. * @returns the canonical header after the delta. */ -export function applyHeaderDelta( - prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }, -): EpochHeader { +export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader { const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools + const messagePrefix = delta.messagePrefix ?? prev.messagePrefix return canonicalHeader({ config: delta.config ?? prev.config, ...system !== undefined ? { system } : {}, ...tools !== undefined ? { tools } : {}, + ...messagePrefix !== undefined ? { messagePrefix } : {}, }) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index e2e0e1b216..ca6779e1dc 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -183,14 +183,15 @@ export interface TodoItem { } /** - * The request header: everything about an LLM request besides its message - * content — the call configuration plus the rendered system prompt and tool - * schemas. Logged session state (the reconstructability RFC): a + * The request header: everything about an LLM request besides its derived + * message history — the call configuration plus the rendered system prompt, + * tool schemas, and the session prefix. Logged session state (the + * reconstructability RFC): a * {@link SessionEventMap} `request/header` snapshot installs one, a * `request/header-delta` amends it, and folding those events over the log * (`foldRequestHeader`) reconstructs the header any request was built under. - * Canonical form: an empty system prompt and an empty tool list are ABSENT - * fields, matching how requests are built. + * Canonical form: an empty system prompt, an empty tool list, and an empty + * prefix are ABSENT fields, matching how requests are built. */ export interface EpochHeader { /** The conversation's call configuration (model + sampling scalars). */ @@ -199,6 +200,14 @@ export interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] + /** + * The session prefix: request-only messages sent BEFORE the entire derived + * history (the `agent/session-prefix` waterfall's product, composed once + * per loop instance and reused for every request it sends). Not session + * history — `deriveMessages()` never returns it — so the header is its + * only durable record; absent when the instance composed none. + */ + messagePrefix?: Message[] } /** @@ -356,15 +365,21 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** * Amendment to the folded {@link EpochHeader}: at least one of a - * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement - * {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the + * {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement + * {@link LlmCallConfig} (four scalars — not worth diffing), or a whole + * replacement session prefix (`messagePrefix` — small advisory content, + * replaced whole; an EMPTY array encodes the transition to "none", + * mirroring the canonical form's absent field — the loop never produces + * one in practice: the prefix is composed once per instance and anchored + * by that instance's snapshot, so this arm exists for codec totality). + * Appended by the * loop inside the step, before dispatch, when the header for this request * differs from the fold of the log so far; the writer verifies * `applyHeaderDelta(previous, delta)` reproduces the new header exactly and * falls back to a `'fallback'` `request/header` snapshot when it cannot, so * a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}. */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index c2368c46fe..8a5af819c3 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -8,9 +8,9 @@ */ import { describe, expect, it } from 'vitest' -import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' -import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' const CONFIG = { model: 'm' } @@ -18,6 +18,10 @@ function tool(name: string, description = 'd'): ToolSchema { return { name, description, parameters: { type: 'object' } } } +function msg(text: string): Message { + return { role: 'user', content: [{ type: 'text', text }] } +} + /** Round-trip helper: diff must reproduce `next` from `prev` exactly. */ function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType { const delta = diffHeader(prev, next) @@ -103,6 +107,48 @@ describe('diffHeader / applyHeaderDelta', () => { }) }) +describe('the session prefix (messagePrefix)', () => { + it('canonicalHeader normalizes an empty prefix to an absent field', () => { + expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] }) + expect(full.messagePrefix).toEqual([msg('p')]) + }) + + it('headerEquals treats absence and empty as one representation, content differences as unequal', () => { + expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true) + expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false) + expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false) + }) + + it('replaces a changed prefix whole and leaves untouched parts alone', () => { + const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] }) + const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] }) + const delta = roundTrip(prev, next) + expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] }) + }) + + it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => { + const none = canonicalHeader({ config: CONFIG }) + const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] }) + const gained = roundTrip(none, some) + expect(gained).toEqual({ messagePrefix: [msg('p')] }) + const lost = roundTrip(some, none) + expect(lost).toEqual({ messagePrefix: [] }) + }) + + it('folds prefix deltas over the log like any other header amendment', () => { + const session = new Session(SessionId('fold-prefix')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] }) + session.append('request/header', { header: first, reason: 'initial' }) + const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] }) + session.append('request/header-delta', diffHeader(first, second)!) + expect(foldRequestHeader(session.events)).toEqual(second) + session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!) + expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG }) + }) +}) + describe('foldRequestHeader', () => { function headerEvents(session: Session): readonly SessionEvent[] { return session.events diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 1644885a73..e3339869a5 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -172,6 +172,12 @@ export interface ToolSchema { /** A single model request, fully assembled. */ export interface GenerateOptions { model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a + * hand-built one-shot passes any list. + */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ system?: string diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 2cbe914b42..017dd504b3 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -13,8 +13,9 @@ * (deterministic — `seq = log.length`, part of the event-log contract). * * A separate, composable normalizer — {@link scrubRequestHeaders} — replaces - * the bulky request-header CONTENT (the composed system prompt and the tool - * schema list) with `{{system}}`/`{{tools}}` tokens. It is deliberately NOT + * the bulky request-header CONTENT (the composed system prompt, the tool + * schema list, and the session prefix) with + * `{{system}}`/`{{tools}}`/`{{messagePrefix}}` tokens. It is deliberately NOT * folded into {@link normalizeSessionLog}: each suite's one header-pinning * scenario compares that content verbatim, every other scenario composes the * scrub in (the `pinsHeader` flag on the scenario table, consumed by the suite @@ -30,6 +31,7 @@ const SESSION_ID = '{{sessionId}}' const CWD = '{{cwd}}' const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' +const MESSAGE_PREFIX = '{{messagePrefix}}' /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -135,15 +137,22 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri /** * Replace request-header CONTENT in a session JSONL with stable tokens, * keeping its structure: a `request/header` event's `data.header.system` → - * `{{system}}` and `data.header.tools` → `{{tools}}`; a + * `{{system}}`, `data.header.tools` → `{{tools}}`, and + * `data.header.messagePrefix` → one `{{messagePrefix}}` token per message + * (the session prefix is model-visible bulk — an AGENTS digest, a skills + * catalog — so its COUNT stays a structural fact while its text never lands + * in a fixture); a * `request/header-delta` event keeps every structural fact — the system * delta's `keepStart`/`keepEnd` line positions and inserted-line COUNT (one * `{{system}}` token per inserted line), the tools delta's - * added/removed/changed tool NAMES — and tokenizes only the bulk (prompt - * text; each added/changed schema's fields other than `name` → `{{tools}}`), + * added/removed/changed tool NAMES, the prefix replacement's message COUNT — + * and tokenizes only the bulk (prompt + * text; each added/changed schema's fields other than `name` → `{{tools}}`; + * each replacement prefix message → `{{messagePrefix}}`), * so two different deltas still compare different. - * Absent fields stay absent — WHETHER a header carried a system prompt or - * tools is behavior and stays visible; `config` and `reason` are small and + * Absent fields stay absent — WHETHER a header carried a system prompt, + * tools, or a prefix is behavior and stays visible; `config` and `reason` + * are small and * stable, so they stay verbatim (a model swap churns every fixture by design * — it invalidates the recorded responses; a prompt/schema edit churns none — * replay never reads this content, see dsh-llm-replay). @@ -166,9 +175,10 @@ export function scrubRequestHeaders(rawLog: string): string { if (record.type === 'request/header') { const header = data.header as Record | null | undefined if (header === null || typeof header !== 'object') return line - if (!('system' in header) && !('tools' in header)) return line + if (!('system' in header) && !('tools' in header) && !('messagePrefix' in header)) return line if ('system' in header) header.system = SYSTEM if ('tools' in header) header.tools = TOOLS + if (Array.isArray(header.messagePrefix)) header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX) return JSON.stringify(record) } if (record.type === 'request/header-delta') { @@ -183,6 +193,10 @@ export function scrubRequestHeaders(rawLog: string): string { if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true } if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true } } + if (Array.isArray(data.messagePrefix)) { + data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX) + touched = true + } return touched ? JSON.stringify(record) : line } return line diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index 8ebd1412b9..daa9f8342d 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -155,6 +155,38 @@ describe('scrubRequestHeaders', () => { expect(toolsOnly).not.toContain('{{system}}') }) + it('scrubs the header session prefix to one token per message, keeping the count', () => { + const ev = headerEvent({ + config: { model: 'm' }, + messagePrefix: [ + { role: 'user', content: [{ type: 'text', text: 'workspace AGENTS digest' }] }, + { role: 'user', content: [{ type: 'text', text: 'skills catalog' }] }, + ], + }) + const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`) + expect(out).toContain('"messagePrefix":["{{messagePrefix}}","{{messagePrefix}}"]') + expect(out).not.toContain('AGENTS digest') + expect(out).not.toContain('skills catalog') + // Absence stays absent — a prefix-less header gains no token… + expect(scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 's' })}\n`)).not.toContain('{{messagePrefix}}') + // …and a non-array shape passes through untouched. + const odd = JSON.stringify({ type: 'request/header', seq: 4, time: 9, data: { header: { config: { model: 'm' }, messagePrefix: 'weird' }, reason: 'initial' } }) + expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"') + }) + + it('scrubs a header-delta prefix replacement to one token per message', () => { + const delta = JSON.stringify({ + type: 'request/header-delta', seq: 8, time: 9, + data: { messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'leaked opener' }] }] }, + }) + const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`) + expect(out).toContain('"messagePrefix":["{{messagePrefix}}"]') + expect(out).not.toContain('leaked opener') + // The empty-array transition-to-absence stays a structural fact. + const toNone = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { messagePrefix: [] } }) + expect(scrubRequestHeaders(`${headerLine}\n${toNone}\n`)).toContain('"messagePrefix":[]') + }) + it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => { const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } }) const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } }) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f35fbafa1d..8147a6deb6 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -367,8 +367,11 @@ export function apply(ctx: Context, config: Config = {}): void { // hand-built one-shot (compaction summarize) is unfrozen and skipped — must // be EXACTLY what the session log reconstructs: // - // - messages: the derivation over the log prefix strictly before the - // in-flight step's `step/start` (the reconstruction boundary). Compared + // - messages: the folded header's session prefix (messagePrefix — the + // `agent/session-prefix` product, logged on the header because no + // session event carries it) followed by the + // derivation over the log prefix strictly before the in-flight step's + // `step/start` (the reconstruction boundary). The derivation is compared // against a FRESH Session built over that prefix — the same projection // code with zero shared state, so the live cache under test cannot vouch // for itself. Boundary-correct by construction: content appended after @@ -408,18 +411,22 @@ export function apply(ctx: Context, config: Config = {}): void { if (boundary === -1) { throw new InvariantError('a loop-built request with no step/start in its session log') } - const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) - // JSON equality is sound here: both sides are structuredClones produced by - // the same projection code path, so key insertion order matches when the - // values do. - if (JSON.stringify(options.messages) !== JSON.stringify(rebuilt.deriveMessages())) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) - } - const header = foldRequestHeader(events) if (header === undefined) { throw new InvariantError('a loop-built request with no request/header event in its session log') } + const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) + // The reconstruction equation: the folded header's session prefix, then + // the boundary derivation — the loop + // logs the header event BEFORE dispatch, so the fold already covers this + // request's prefix. JSON equality is sound here: both sides are + // structuredClones produced by the same projection/build code path, so key + // insertion order matches when the values do. + const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] + if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { + throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) + } + const headerMatches = options.model === header.config.model && options.system === header.system && options.temperature === header.config.temperature diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 489cfb9817..af31f01911 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -707,6 +707,21 @@ describe('request-reconstruction cross-check (llm/stream)', () => { expect(() => { dispatch(ctx, options) }).not.toThrow() }) + it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => { + const { ctx, session, boundary } = await requestSetup() + const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } + session.append('request/header-delta', { messagePrefix: [prefix] }) + // The prefixed request matches the fold… + const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id }) + expect(() => { dispatch(ctx, prefixed) }).not.toThrow() + // …a request that DROPPED the logged prefix diverges… + const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id }) + expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/) + // …and so does one that misplaced it (prefix sent after the history). + const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id }) + expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/) + }) + it('rejects a frozen request whose messages diverge from the boundary derivation', async () => { const { ctx, session, boundary } = await requestSetup() const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]