From ea4c10d7530ce85fec1de3ed5114c3f8581e2962 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 8 Jul 2026 15:44:30 +0800 Subject: [PATCH] refactor(agent): replace the per-step advice seam with agent/session-prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review discussion converged on the industry shape (Claude Code caches user context per conversation; Codex separates initial context from diffs; Kimi appends at continuation boundaries to protect prompt caching): stable openers belong in a compose-once prefix, mid-session changes belong in append-only history — not in a per-request slot. agent/session-prefix fires ONCE per loop instance, lazily on its first request-building step: the composed Message[] is deep-frozen, cached on the transmission bookkeeping, recorded as EpochHeader.messagePrefix on the anchoring 'initial'/'resume' snapshot, and reused verbatim for every request the instance sends — prefix stability is structural, not a producer discipline, and a resume recomposes with attributable drift. The request is messagePrefix + boundary snapshot. The per-step RequestAdvice/RequestAdviceContext surface and the messageSuffix header field are dropped: the tail slot had no consumer, and every current update pattern (new AGENTS.md discovered, memory update, skills change) routes through the existing append-only history channels — inject(), tools/post-execute additionalContext, prompt-submit additionalContext — each paid once and prefix-cached thereafter. The messagePrefix delta arm stays for codec totality; the loop never produces one in practice. --- docs/architecture.md | 10 +- docs/cordis-catalog/events.md | 38 ++--- docs/core-data-structures/core.md | 40 +---- docs/core-data-structures/session.md | 21 ++- docs/event-producer-consumer.md | 24 +-- docs/persistence-catalog.md | 32 ++-- .../2026-07-05-reconstructable-requests.md | 8 +- packages/core/agent-loop/README.md | 8 +- packages/core/agent-loop/src/loop.ts | 66 +++++---- packages/core/agent-loop/src/request-log.ts | 11 +- .../agent-loop/tests/interception.spec.ts | 137 +++++++----------- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 119 ++++----------- packages/core/session/README.md | 2 +- packages/core/session/src/request-header.ts | 28 ++-- packages/core/session/src/types.ts | 28 ++-- .../core/session/tests/request-header.spec.ts | 29 ++-- packages/llm/llm/src/types.ts | 4 +- packages/support/invariants/src/index.ts | 14 +- .../invariants/tests/invariants.spec.ts | 17 +-- scripts/type-equiv.manifest.json | 2 - 21 files changed, 260 insertions(+), 380 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1ba6eb1ef9..0358c73d52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,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. @@ -46,7 +46,7 @@ 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. Full rule: [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 @@ -72,7 +72,7 @@ forever: agent/pre-step 'step/start' snapshot the derived messages (the reconstruction boundary) - agent/request (config only) -> agent/request-advice -> log request/header -> llm/stream (frozen) + agent/request (config only) -> agent/session-prefix (first request) -> log request/header -> llm/stream (frozen) 'assistant/chunk' agent/step-result 'assistant/message' @@ -108,7 +108,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` framed by the header's request-only `messagePrefix`/`messageSuffix`, 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. @@ -141,7 +141,7 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Add command execution | implement and register a `ctx.bash` backend | | 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 per-request context that must not become history | contribute request-only messages on `agent/request-advice`; logged on the request header | +| 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 7ee3c0e2f5..20cd367f32 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:313`](../../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:320`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:512`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:455`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:350`](../../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:411`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:363`](../../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:338`](../../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`, or header-logged request-only messages via agent/request-advice — 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,23 +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:435`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) -### `agent/request-advice` — waterfall +### `agent/session-prefix` — waterfall -Waterfall: weave request-ONLY advice around the derived history — a RequestAdvice whose `before` messages sit in front of the ENTIRE boundary snapshot in `GenerateOptions.messages` and whose `after` messages follow its last message. Fires once per step, inside the open step, after the agent/request config waterfall and before the loop logs the request header. This is the seam for per-request advisory context the model must see NOW but that must NOT become durable history (a skills catalog, an environment reminder): contributions are recorded on the request's `request/header*` event (`EpochHeader.messagePrefix` / `messageSuffix`) — never as session messages — so `Session.deriveMessages()` stays untouched and the request remains reconstructable from the log. +Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily on its first request-building step; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). -The seed is frozen and empty; a contributing listener returns a NEW RequestAdvice extending `await next()` (spread its arrays — never mutate them), so contributions compose across plugins in registration order. The boundary snapshot is already taken when this fires: a `session.append`/`inject()` from a listener here lands in the log but joins the NEXT request — contribute through the returned value, not the session. Call `next()` to delegate, or return a RequestAdvice without it to short-circuit. +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. -Pick the channel by change frequency (the cost model): a contribution rides the request's uncached tail, re-tokenized at full price on EVERY request it appears in — cheap only while small. Session-FROZEN content belongs in `before`, where it extends the cacheable prefix at zero marginal cost (but changing it mid-session invalidates the provider cache for the entire history after it). A LOW-FREQUENCY change notice belongs in durable history via `agent.inject()` — appended once, prefix-cached thereafter. Reserve `after` for small, frequently refreshed state snapshots, where a durable chain of stale copies would bloat the log and mislead the model. +The seed is a frozen empty list; a contributing listener returns a NEW array extending `await next()` (`[...prefix, mine]` — never an in-place push), so contributions compose across plugins in registration order and compose deterministically for a fixed plugin set. Call `next()` to delegate, or return a list without it to short-circuit. ```ts cordis-catalog -'agent/request-advice'(agent: Agent, turn: number, step: number, advice: RequestAdvice, context: RequestAdviceContext, next: () => Promise): Promise +'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise ``` -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:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,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:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,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:329`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:487`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:500`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts) ## `fs/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c5249d4859..13692dad9e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -130,8 +130,8 @@ interface GenerateOptions { /** * 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 + `messageSuffix` - * (dsh-agent-loop); a hand-built one-shot passes any list. + * `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). */ @@ -193,9 +193,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-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 any request-only advice messages — 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/request-advice` waterfall weaves request-only advice around the derived history (recorded as the header's `messagePrefix`/`messageSuffix`) — 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 request-only `before` advice) → 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 — → `messageSuffix` (the request-only `after` advice, the last thing the model reads). The advice arrays never enter the derived history; their durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. +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. @@ -328,7 +328,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/request-advice`/`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 @@ -365,35 +365,7 @@ type ContinuationDecision = type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/request-advice` returns a `RequestAdvice` — the request-only advice woven around the derived history for ONE request (advice in both senses: advisory content for the model, attached before/after the join point like AOP advice, never modifying the history itself). Concretely, per request: `before` messages sit in front of the ENTIRE derived history, directly after the system slot — the conventional home for session-stable openers like an AGENTS.md digest or a skills catalog, re-contributed identically every step so the provider prefix cache holds; `after` messages follow the history's last message, closing the request. Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself; the loop records the non-empty arrays as the header's `messagePrefix`/`messageSuffix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and `deriveMessages()` never returns them: - -```ts type-equiv -interface RequestAdvice { - /** Before-advice: messages placed ahead of the entire derived history. */ - before: Message[] - /** After-advice: messages placed after the derived history's last message. */ - after: Message[] -} -``` - -Listeners read the already-fixed request facts from a `RequestAdviceContext` (decide what to contribute from these; never mutate them): - -```ts type-equiv -interface RequestAdviceContext { - /** The rendered system prompt this request will carry. */ - system: string - /** The prompt assembly the system prompt was rendered from (sections + tools). */ - assembly: PromptAssembly - /** - * The boundary snapshot: the derived history this request will carry between - * `before` and `after`. A frozen snapshot — treat it as read-only; content - * for the NEXT request flows through the log channels. - */ - boundaryMessages: readonly Message[] - /** Aborts in-flight listener work when the step is torn down. */ - signal: AbortSignal -} -``` +`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` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index e1f29882e5..1b12c3fc48 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -75,14 +75,14 @@ interface SessionEventMap { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** * Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed - * tools delta, whole replacement config, or whole replacement request-only - * message arrays (an EMPTY array encodes the transition to "none"). The + * 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; messagePrefix?: Message[]; messageSuffix?: Message[] } + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } ``` @@ -99,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 + request-only messages) — 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,18 +110,17 @@ export interface EpochHeader { /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] /** - * Request-only messages sent BEFORE the derived history (the - * `agent/request-advice` waterfall's `before` contributions). Not session - * history — `deriveMessages()` never returns them — so the header is their - * only durable record; absent when the request carried none. + * 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[] - /** Request-only messages sent AFTER the derived history; absent when none. */ - messageSuffix?: Message[] } ``` -Canonical form: an empty system prompt, an empty tool list, and empty request-only message arrays are ABSENT fields, matching how requests are built. `messagePrefix`/`messageSuffix` are the durable record of the `agent/request-advice` waterfall's contributions (the request is `messagePrefix + derived history + messageSuffix`); their deltas replace the array whole, an empty array encoding the transition back to absence. 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). +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 02bad3d6c8..834dfc6d69 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,18 +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:313`](../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:320`](../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:512`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:398`](../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:411`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:338`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/request-advice` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:353`](../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:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:487`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:500`](../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:455`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:420`](../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), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../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 5c0ddd2ca1..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:315`](../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:322`](../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:313`](../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:307`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `request/*` @@ -131,14 +131,14 @@ 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:367`](../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, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement request-only message array (`messagePrefix`/`messageSuffix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field). 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; messagePrefix?: Message[]; messageSuffix?: Message[] } +'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) @@ -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:340`](../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:294`](../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:292`](../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:354`](../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:328`](../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:338`](../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:290`](../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:284`](../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:296`](../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/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index e2bb16438a..20bd1e3005 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-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and any request-only messages (`messagePrefix`/`messageSuffix`, below) — is logged session state, in canonical form (empty system/tools/message arrays ≡ 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`/`messageSuffix`: replaced whole, an empty array encoding the transition to absence). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds. +**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds. -**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the `agent/request-advice` waterfall — request-ONLY `before`/`after` messages framing the boundary snapshot (a frozen empty seed, contributions returned as an extension of `next()`; the per-request advisory channel: content the model must see now that must NOT become history — a skills catalog, an environment reminder) — → the header event the request owes the log, carrying those contributions as `messagePrefix`/`messageSuffix` (no session event carries them, so the header is their only durable record) → build `GenerateOptions` from `messagePrefix + snapshot + messageSuffix` + 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) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → on the instance's FIRST request only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed. **The 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 folded header's `messagePrefix`, then the boundary derivation, then its `messageSuffix` — 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/request-advice` seam's contributions enter only because the header event records them 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. +**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,7 +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 cost decision, and the seam does not hide it: an `agent/request-advice` contribution rides the request's uncached tail and is re-tokenized at full price on every request it appears in (a `before` contribution instead extends the cacheable prefix at zero marginal cost while stable, but a mid-session change invalidates the provider cache for the entire history after it), whereas an `inject()`ed `context/message` is paid once and prefix-cached thereafter at the price of accumulating durably in history and the log. Route session-frozen content to `before`, low-frequency change notices to `inject()`, and reserve `after` for small, frequently refreshed state snapshots where a durable chain of stale copies would bloat the log and mislead the model. +- 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/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ebdcc7d91a..d4c1c50466 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -59,10 +59,10 @@ forever: 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 - reqMsgs = waterfall agent/request-advice ⟵ request-only before/after messages; recorded - on the header, never session history + prefix ??= waterfall agent/session-prefix ⟵ once per instance (first request): frozen + session prefix; on the header, never history session('request/header'[-delta]) ⟵ the header event this request owes the log - stream llm.stream(freeze({header..., messages: before+boundary+after})) → 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') @@ -86,7 +86,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/request-advice`, `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 9170de4836..753da50db7 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContinuationDecision, HookContext, PromptDecision, RequestAdvice } from '@deepseek-ai/dsh-agent' +import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -161,11 +161,12 @@ export interface LoopHandle { * 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 - * advice = waterfall agent/request-advice ⟵ request-only before/after advice; logged on - * the header, never session history + * prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first request): + * frozen session prefix; logged on the header, + * never session history * session('request/header'|'request/header-delta') ⟵ the header event this request owes the * log (initial/resume anchor, delta, fallback) - * req = freeze({header..., messages: before+boundary+after, 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 @@ -676,8 +677,9 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean { } /** One step: build the request from the boundary snapshot + the step's - * header → collect request-only messages → log the header event the request - * owes → stream model → record → execute tools. The caller assembles the + * 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. */ @@ -720,47 +722,47 @@ async function runStep( throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } - // Collect the request-ONLY advice: `before` messages go in front of the - // entire boundary snapshot, `after` messages follow its last message. Advice - // is not session history — the header event below is its only durable - // record (EpochHeader.messagePrefix/messageSuffix), which keeps the request - // a pure function of the log. The frozen empty seed serves both the - // listener chain and the no-listener fallback: a contribution is a RETURNED - // extension of `await next()`, never an in-place push. The context gets a - // frozen COPY of the boundary (the request is built from the internal - // snapshot), so a listener cannot smuggle unlogged content into the request - // by mutating what it was shown. Fired AFTER the boundary snapshot, so a - // listener's session append lands past the boundary and joins the NEXT - // request — the same window rule as the `agent/request` waterfall. - const emptyRequestAdvice: RequestAdvice = deepFreeze({ before: [], after: [] }) - const requestAdviceBoundary = deepFreeze([...boundaryMessages]) - const requestAdvice = await ctx.waterfall( - 'agent/request-advice', agent, turn, step, emptyRequestAdvice, - { system, assembly, boundaryMessages: requestAdviceBoundary, signal }, - () => Promise.resolve(emptyRequestAdvice), - ) + // Compose the session prefix ONCE per loop instance, lazily on its first + // request-building step: request-only messages placed in front of the + // ENTIRE derived history on every request this instance sends. 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 + // below is its only durable record (EpochHeader.messagePrefix), which + // keeps the request a pure function of the log. The frozen empty seed + // serves both the listener chain and the no-listener fallback: a + // contribution is a RETURNED extension of `await next()`, never an + // in-place push. + if (transmission.sessionPrefix === undefined) { + const emptyPrefix: Message[] = deepFreeze([]) + transmission.sessionPrefix = deepFreeze(structuredClone(await ctx.waterfall( + 'agent/session-prefix', agent, emptyPrefix, signal, + () => Promise.resolve(emptyPrefix), + ))) + } + const sessionPrefix = transmission.sessionPrefix // The request header (the log's request/header* vocabulary): canonical form, // recorded before dispatch so the log always explains the request — - // including the request-only advice, which no other event carries. + // including the session prefix, which no other event carries. const header = canonicalHeader({ config, ...system ? { system } : {}, ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, - ...requestAdvice.before.length > 0 ? { messagePrefix: requestAdvice.before } : {}, - ...requestAdvice.after.length > 0 ? { messageSuffix: requestAdvice.after } : {}, + ...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. Message order: header.messagePrefix, then the boundary snapshot, - // then header.messageSuffix — the reconstruction equation the invariant - // recomputes. + // 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: [...header.messagePrefix ?? [], ...boundaryMessages, ...header.messageSuffix ?? []], + 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/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index f980b2337c..c3f301afc7 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { foldRequestHeader, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +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' import AgentRegistry, { AgentId, type ContinuationDecision, type PromptDecision, - type RequestAdvice, type SessionStartSource, } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -311,58 +310,58 @@ describe('agent/session-start', () => { }) }) -describe('agent/request-advice (RequestAdvice)', () => { - it('frames the derived history: before precedes it, after follows it, and the header records both', async () => { - const adapter = new MockAdapter([textResponse('ok')]) +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' }] } - const trailer: Message = { role: 'user', content: [{ type: 'text', text: 'trailing note' }] } - ctx.on('agent/request-advice', async (_agent, _turn, _step, _messages, _context, next): Promise => { - const result = await next() - return { before: [...result.before, reminder], after: [...result.after, trailer] } + let composed = 0 + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + composed += 1 + return [...await next(), reminder] }) - send(agent, 'hi') + send(agent, 'go') + await waitForIdle(ctx, agent) + send(agent, 'next turn') await waitForIdle(ctx, agent) - // The request carries before + derived history + after, in that order… - const request = adapter.requests[0]! - expect(request.messages).toEqual([ - reminder, - { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - trailer, - ]) - // …the header event is their durable record… - const headerEvent = events(agent).find(e => e.type === 'request/header') - expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([reminder]) - expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messageSuffix).toEqual([trailer]) - // …and they never become session history. - expect(agent.session.deriveMessages()).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, - ]) + // 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('contributions compose across listeners and see the read-only request facts', async () => { + it('contributions compose across listeners in registration order', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const seen: { system: string; boundaryRoles: string[]; sectionCount: number }[] = [] - ctx.on('agent/request-advice', async (_agent, _turn, _step, _messages, context, next): Promise => { - const result = await next() - seen.push({ - system: context.system, - boundaryRoles: context.boundaryMessages.map(m => m.role), - sectionCount: context.assembly.sections.length, - }) - return { before: [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...result.before], after: result.after } + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()] }) - ctx.on('agent/request-advice', async (_agent, _turn, _step, _messages, _context, next): Promise => { - const result = await next() - return { before: [...result.before, { role: 'user', content: [{ type: 'text', text: 'second' }] }], after: result.after } + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { + return [...await next(), { role: 'user', content: [{ type: 'text', text: 'second' }] }] }) send(agent, 'hi') @@ -372,27 +371,21 @@ describe('agent/request-advice (RequestAdvice)', () => { // out (waterfall), so its prepend lands first. const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '') expect(texts).toEqual(['first', 'second', 'hi']) - // The context carried the request facts: the rendered system prompt, the - // boundary snapshot (exactly the drained user prompt), and the assembly. - expect(seen).toHaveLength(1) - expect(seen[0]!.boundaryRoles).toEqual(['user']) - expect(typeof seen[0]!.system).toBe('string') }) - it('with no contributions the header omits both fields and the request is the bare derivation', async () => { + 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/request-advice', async (_agent, _turn, _step, _messages, _context, next) => next()) + 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(headerEvent?.type === 'request/header' && 'messageSuffix' in headerEvent.data.header).toBe(false) expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) }) @@ -402,9 +395,9 @@ describe('agent/request-advice (RequestAdvice)', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let mutationError: unknown - ctx.on('agent/request-advice', async (_agent, _turn, _step, messages, _context, next): Promise => { + ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { try { - messages.before.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] }) + prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] }) } catch (error: unknown) { mutationError = error } @@ -418,30 +411,7 @@ describe('agent/request-advice (RequestAdvice)', () => { expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) }) - it('the read-only boundary context rejects in-place mutation before the request is built', 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/request-advice', async (_agent, _turn, _step, _messages, context, next): Promise => { - try { - const mutableBoundary = context.boundaryMessages as Message[] - mutableBoundary.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('a per-step contribution change is logged as a header delta, so every request stays reconstructable', async () => { + 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'), @@ -453,26 +423,21 @@ describe('agent/request-advice (RequestAdvice)', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let step = 0 - ctx.on('agent/request-advice', async (_agent, _turn, _step, _messages, _context, next): Promise => { - const result = await next() - step += 1 - return { before: [...result.before, { role: 'user', content: [{ type: 'text', text: `reminder v${step}` }] }], after: result.after } - }) + 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) - expect(adapter.requests[0]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'reminder v1' }] }) - expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] }) - // Step 2's changed prefix rides a request/header-delta whose fold matches - // what the second request actually sent. - const delta = events(agent).find(e => e.type === 'request/header-delta') - expect(delta?.type === 'request/header-delta' && delta.data.messagePrefix).toEqual([{ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] }]) - expect(foldRequestHeader(agent.session.events)?.messagePrefix).toEqual([{ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] }]) + // 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 0500223369..8a811048c6 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -45,7 +45,7 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne - `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/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event -- `agent/request-advice` — contribute request-ONLY messages around the derived history: a frozen empty `RequestAdvice` seed in, an extension of `await next()` out (`before` messages precede the boundary snapshot in the request, `after` messages follow it). For per-request advisory context the model must see now but that must not become durable history; the loop records the contributions on the request's `request/header*` event (`EpochHeader.messagePrefix`/`messageSuffix`), so `deriveMessages()` stays untouched and the request stays reconstructable. Cost model: contributions ride the request's uncached tail and are re-paid at full price on every request they appear in — put session-frozen content in `before` (cacheable prefix; a mid-session change busts the cache for everything after it), route low-frequency change notices through `agent.inject()` instead (paid once, prefix-cached thereafter), and reserve `after` for small, frequently refreshed state snapshots +- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily on its first request; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter - `agent/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 c35fc0d84e..27af421b17 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -17,7 +17,7 @@ * 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/request-advice`/`agent/step-result`/ + * `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`/ @@ -46,7 +46,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -155,54 +155,6 @@ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } -/** - * The request-only ADVICE an `agent/request-advice` waterfall listener weaves - * around the derived history of ONE LLM request — advice in both senses: - * advisory content for the model, attached before/after the join point like - * AOP advice, never modifying the history itself. In - * `GenerateOptions.messages` the `before` messages sit in front of the ENTIRE - * derived history (directly after the provider's system slot) and the `after` - * messages follow its last message (the newest user prompt on a turn's first - * step, the previous step's tool results afterwards). Advice is NOT session - * state — nothing here enters the session log as durable history, - * `Session.deriveMessages()` never returns it, and the next step recomputes - * it from scratch. The loop records the non-empty arrays on the request's - * `request/header*` event (`EpochHeader.messagePrefix` / `messageSuffix`), so - * the request stays reconstructable from the log (the reconstructability - * RFC). For content that must become durable conversation history, use the - * log channels instead: `agent.inject()`, steering, or prompt-submit - * `additionalContext`. - */ -export interface RequestAdvice { - /** Before-advice: messages placed ahead of the entire derived history. */ - before: Message[] - /** After-advice: messages placed after the derived history's last message. */ - after: Message[] -} - -/** - * Read-only facts about the request an `agent/request-advice` listener is - * contributing to. Everything here is already fixed when the seam fires: the - * step is open, the boundary snapshot is taken, and the system prompt is - * assembled — a listener uses these to DECIDE what to contribute (e.g. render - * a workspace-dependent reminder, or skip one already present in history), - * never to mutate them. - */ -export interface RequestAdviceContext { - /** The rendered system prompt this request will carry. */ - system: string - /** The prompt assembly the system prompt was rendered from (sections + tools). */ - assembly: PromptAssembly - /** - * The boundary snapshot: the derived history this request will carry between - * `before` and `after`. A frozen snapshot — treat it as read-only; content - * for the NEXT request flows through the log channels. - */ - boundaryMessages: readonly Message[] - /** Aborts in-flight listener work when the step is torn down. */ - signal: AbortSignal -} - /** * Why an agent's session lifecycle began, carried by `agent/session-start`. A * bridge keys its SessionStart hook's matcher on this (Claude Code's @@ -417,7 +369,7 @@ declare module 'cordis' { * 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 - * header-logged request-only messages via {@link agent/request-advice} + * 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 @@ -434,47 +386,38 @@ declare module 'cordis' { */ 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** - * Waterfall: weave request-ONLY advice around the derived history — a - * {@link RequestAdvice} whose `before` messages sit in front of the - * ENTIRE boundary snapshot in `GenerateOptions.messages` and whose - * `after` messages follow its last message. Fires once per step, inside - * the open step, after the - * {@link agent/request} config waterfall and before the loop logs the - * request header. This is the seam for per-request advisory context the - * model must see NOW but that must NOT become durable history (a skills - * catalog, an environment reminder): contributions are recorded on the - * request's `request/header*` event (`EpochHeader.messagePrefix` / - * `messageSuffix`) — never as session messages — so - * `Session.deriveMessages()` stays untouched and the request remains - * reconstructable from the log. + * Waterfall: compose the SESSION PREFIX — request-only messages placed in + * front of the ENTIRE derived history (directly after the provider's + * system slot) on every request this loop instance sends. Fired ONCE per + * loop instance, lazily on its first request-building step; the composed + * result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the + * instance's anchoring `'initial'`/`'resume'` header snapshot, and reused + * verbatim for every subsequent request — never recomputed mid-session, + * so the provider prefix cache holds by construction (a process restart + * or `ctx.agents.resume()` is a new instance: it recomposes, and any + * drift lands attributably on the `'resume'` snapshot). * - * The seed is frozen and empty; a contributing listener returns a NEW - * {@link RequestAdvice} extending `await next()` (spread its arrays — - * never mutate them), so contributions compose across plugins in - * registration order. The boundary snapshot is already taken when this - * fires: a `session.append`/`inject()` from a listener here lands in the - * log but joins the NEXT request — contribute through the returned value, - * not the session. Call `next()` to delegate, or return a - * {@link RequestAdvice} without it to short-circuit. + * 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. * - * Pick the channel by change frequency (the cost model): a contribution - * rides the request's uncached tail, re-tokenized at full price on EVERY - * request it appears in — cheap only while small. Session-FROZEN content - * belongs in `before`, where it extends the cacheable prefix at zero - * marginal cost (but changing it mid-session invalidates the provider - * cache for the entire history after it). A LOW-FREQUENCY change notice - * belongs in durable history via `agent.inject()` — appended once, - * prefix-cached thereafter. Reserve `after` for small, frequently - * refreshed state snapshots, where a durable chain of stale copies would - * bloat the log and mislead the model. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param advice - the frozen empty seed; return an extended replacement to contribute. - * @param context - read-only request facts ({@link RequestAdviceContext}). + * The seed is a frozen empty list; a contributing listener returns a NEW + * array extending `await next()` (`[...prefix, mine]` — never an in-place + * push), so contributions compose across plugins in registration order + * and compose deterministically for a fixed plugin set. 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/request-advice'(agent: Agent, turn: number, step: number, advice: RequestAdvice, context: RequestAdviceContext, next: () => Promise): Promise + '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 f032874cbe..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 / whole request-only message arrays) 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/messageSuffix ≡ absent fields; a delta's EMPTY message array encodes the transition back to absence). `EpochHeader.messagePrefix`/`messageSuffix` are the durable record of the `agent/request-advice` waterfall's request-only contributions — the request is `messagePrefix + derived history + messageSuffix`, and `deriveMessages()` never returns them. +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 a891237425..eeb2fe40ed 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -22,16 +22,14 @@ type HeaderDelta = { tools?: ToolsDelta config?: LlmCallConfig messagePrefix?: Message[] - messageSuffix?: Message[] } /** * Normalize a header to canonical form: an empty system prompt, an empty - * tool list, and empty request-only message arrays 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 request-only messages") has exactly one - * representation. + * 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. */ @@ -41,7 +39,6 @@ export function canonicalHeader(header: EpochHeader): EpochHeader { ...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 } : {}, - ...header.messageSuffix !== undefined && header.messageSuffix.length > 0 ? { messageSuffix: header.messageSuffix } : {}, } } @@ -121,22 +118,22 @@ 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; request-only message arrays compare as canonical JSON - * (both sides come from the same build path, so key order matches when the - * values do). + * 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, tools (in order), and request-only messages 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) || !sameMessages(a.messageSuffix, b.messageSuffix)) 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 request-only message arrays; absence equals the empty array. */ +/** 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 ?? []) } @@ -147,7 +144,7 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | * ({@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. - * Request-only messages are replaced whole (small advisory content, not worth + * 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. @@ -161,7 +158,6 @@ export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | 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 ?? [] - if (!sameMessages(prev.messageSuffix, next.messageSuffix)) delta.messageSuffix = next.messageSuffix ?? [] return Object.keys(delta).length > 0 ? delta : undefined } @@ -177,13 +173,11 @@ export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHe 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 - const messageSuffix = delta.messageSuffix ?? prev.messageSuffix return canonicalHeader({ config: delta.config ?? prev.config, ...system !== undefined ? { system } : {}, ...tools !== undefined ? { tools } : {}, ...messagePrefix !== undefined ? { messagePrefix } : {}, - ...messageSuffix !== undefined ? { messageSuffix } : {}, }) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 75fc84d9a0..ca6779e1dc 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -185,14 +185,13 @@ export interface TodoItem { /** * The request header: everything about an LLM request besides its derived * message history — the call configuration plus the rendered system prompt, - * tool schemas, and any request-only messages. Logged session state (the + * 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, an empty tool list, and empty - * request-only message arrays 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). */ @@ -202,14 +201,13 @@ export interface EpochHeader { /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] /** - * Request-only messages sent BEFORE the derived history (the - * `agent/request-advice` waterfall's `before` contributions). Not session - * history — `deriveMessages()` never returns them — so the header is their - * only durable record; absent when the request carried none. + * 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[] - /** Request-only messages sent AFTER the derived history; absent when none. */ - messageSuffix?: Message[] } /** @@ -369,9 +367,11 @@ export interface SessionEventMap { * Amendment to the folded {@link EpochHeader}: at least one of a * {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement * {@link LlmCallConfig} (four scalars — not worth diffing), or a whole - * replacement request-only message array (`messagePrefix`/`messageSuffix` — - * small advisory content, replaced whole; an EMPTY array encodes the - * transition to "none", mirroring the canonical form's absent field). + * 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 @@ -379,7 +379,7 @@ export interface SessionEventMap { * 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; messagePrefix?: Message[]; messageSuffix?: Message[] } + '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 9db46598ac..8a5af819c3 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -107,38 +107,37 @@ describe('diffHeader / applyHeaderDelta', () => { }) }) -describe('request-only messages (messagePrefix / messageSuffix)', () => { - it('canonicalHeader normalizes empty arrays to absent fields', () => { - expect(canonicalHeader({ config: CONFIG, messagePrefix: [], messageSuffix: [] })).toEqual({ config: CONFIG }) - const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] }) +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')]) - expect(full.messageSuffix).toEqual([msg('s')]) }) 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, messageSuffix: [msg('a')] }, { config: CONFIG })).toBe(false) + expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false) }) - it('replaces a changed prefix whole and leaves an untouched suffix alone', () => { - const prev = canonicalHeader({ config: CONFIG, messagePrefix: [msg('old')], messageSuffix: [msg('keep')] }) - const next = canonicalHeader({ config: CONFIG, messagePrefix: [msg('new'), msg('more')], messageSuffix: [msg('keep')] }) + 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 framing gained from a bare header and lost back to one (empty array encodes absence)', () => { + 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')], messageSuffix: [msg('s')] }) + const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] }) const gained = roundTrip(none, some) - expect(gained).toEqual({ messagePrefix: [msg('p')], messageSuffix: [msg('s')] }) + expect(gained).toEqual({ messagePrefix: [msg('p')] }) const lost = roundTrip(some, none) - expect(lost).toEqual({ messagePrefix: [], messageSuffix: [] }) + expect(lost).toEqual({ messagePrefix: [] }) }) - it('folds framing deltas over the log like any other header amendment', () => { - const session = new Session(SessionId('fold-framing')) + 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' }) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 48125fff0d..e3339869a5 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -175,8 +175,8 @@ export interface GenerateOptions { /** * 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 + `messageSuffix` - * (dsh-agent-loop); a hand-built one-shot passes any list. + * `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). */ diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 216049da09..8147a6deb6 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -367,9 +367,9 @@ 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 folded header's request-only messages (messagePrefix / - // messageSuffix — the `agent/request-advice` contributions, logged on - // the header because no session event carries them) framing the + // - 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 @@ -416,13 +416,13 @@ export function apply(ctx: Context, config: Config = {}): void { 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 request-only messages - // frame the boundary derivation (prefix + derived + suffix) — the loop + // 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 contributions. JSON equality is sound here: both sides are + // 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(), ...header.messageSuffix ?? []] + 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)`) } diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index cccd6a54d4..af31f01911 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -707,19 +707,18 @@ describe('request-reconstruction cross-check (llm/stream)', () => { expect(() => { dispatch(ctx, options) }).not.toThrow() }) - it('expects the folded header\'s request-only messages to frame the derivation (prefix + derived + suffix)', async () => { + 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' }] } - const suffix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'trailing note' }] } - session.append('request/header-delta', { messagePrefix: [prefix], messageSuffix: [suffix] }) - // The framed request matches the fold… - const framed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary, suffix]), sessionId: session.id }) - expect(() => { dispatch(ctx, framed) }).not.toThrow() - // …a request that DROPPED the logged framing diverges… + 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 (suffix sent as a prefix). - const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([suffix, prefix, ...boundary]), sessionId: session.id }) + // …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/) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index dfa3be16c4..b66882d8c9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -15,8 +15,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "RequestAdvice", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "RequestAdviceContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },