diff --git a/docs/architecture.md b/docs/architecture.md index 371b5df579..18138c41cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,14 +1,14 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** is an SDK for building agent harnesses using the Cordis framework. The governing principle is simple: **everything is a plugin**. For example, the shipped agent loop is just one plugin in the default bundle, not a privileged kernel. +The **DeepSeek Harness SDK** is an SDK for building agent harnesses on the Cordis framework. The governing principle is simple: **everything is a plugin**. The shipped agent loop is one plugin in the default bundle, not a privileged kernel. -Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). If Cordis itself is new to you, start with the [Cordis primer](cordis-primer.md). +Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). New to Cordis? Start with the [Cordis primer](cordis-primer.md). ## System Shape A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services are the stable call surfaces (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners. -The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins from a Cordis perspective. +The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins. ### Default Service Spine @@ -46,11 +46,11 @@ Use the event domain to decide where new behavior belongs: ### Interception Semantics -Waterfall events behave like around-middleware: a listener delegates by calling `next()` and vetoes or takes over by returning without it. The full rule lives in [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). +Waterfall events behave like around-middleware: a listener delegates by calling `next()` and vetoes or takes over by returning without it. Full rule: [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). ## Default Loop Lifecycle -The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam that another plugin can program against. +The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam other plugins program against. A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension seams. @@ -72,7 +72,7 @@ forever: agent/pre-step 'step/start' snapshot the derived messages (the reconstruction boundary) - agent/request (config only) -> log request/header -> llm/stream (frozen) + agent/request (config only) -> agent/request-messages -> log request/header -> llm/stream (frozen) 'assistant/chunk' agent/step-result 'assistant/message' @@ -88,7 +88,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` itself owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, from its `persona` config, shared by every agent in the context) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, its `persona` config, shared context-wide) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input. @@ -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`, 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` 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)). 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. @@ -122,7 +122,7 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families. +A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the package families. Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). @@ -141,6 +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-messages`; 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 753cf8f4d0..d62e1ef042 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:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../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:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:299`](../../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:404`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:490`](../../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:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:377`](../../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:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:390`](../../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:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble` — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. +Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or header-logged request-only messages via agent/request-messages — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. ```ts cordis-catalog 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,7 +97,23 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) + +### `agent/request-messages` — waterfall + +Waterfall: contribute request-ONLY messages around the derived history — a RequestMessages whose `before` messages precede the boundary snapshot in `GenerateOptions.messages` and whose `after` messages follow it. 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. + +The seed is frozen and empty; a contributing listener returns a NEW RequestMessages 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 RequestMessages without it to short-circuit. + +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. + +```ts cordis-catalog +'agent/request-messages'(agent: Agent, turn: number, step: number, messages: RequestMessages, context: RequestMessagesContext, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:455`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -109,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -121,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -133,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:465`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -145,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:478`](../../packages/core/agent/src/types.ts) ## `fs/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8cd9f1c30b..62dfb41c64 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -127,6 +127,12 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface GenerateOptions { model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history + `messageSuffix` + * (dsh-agent-loop); a hand-built one-shot passes any list. + */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ system?: string @@ -187,7 +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-content half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt and assembled tool schemas — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, assembled tool schemas, and any request-only 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-messages` waterfall contributes request-only messages framing 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. + +On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (request-only `before` contributions) → 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` (request-only `after` contributions, the last thing the model reads). The framing 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. 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. @@ -320,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/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/request-messages`/`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 @@ -357,6 +365,36 @@ type ContinuationDecision = type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` +`agent/request-messages` returns a `RequestMessages` — request-only `before`/`after` messages framing the derived history for ONE 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 RequestMessages { + /** Messages placed before the derived history in the request. */ + before: Message[] + /** Messages placed after the derived history in the request. */ + after: Message[] +} +``` + +Listeners read the already-fixed request facts from a `RequestMessagesContext` (decide what to contribute from these; never mutate them): + +```ts type-equiv +interface RequestMessagesContext { + /** 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 +} +``` + ## `ToolDefinition` The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6c4712114f..ddfa77a9ff 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -74,16 +74,15 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Amendment to the folded {@link EpochHeader}: at least one of a - * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement - * {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the - * loop inside the step, before dispatch, when the header for this request - * differs from the fold of the log so far; the writer verifies - * `applyHeaderDelta(previous, delta)` reproduces the new header exactly and - * falls back to a `'fallback'` `request/header` snapshot when it cannot, so - * a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}. + * Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed + * tools delta, whole replacement config, or whole replacement request-only + * message arrays (an EMPTY array encodes the transition to "none"). The + * writer verifies `applyHeaderDelta(previous, delta)` reproduces the new + * header exactly and falls back to a `'fallback'` `request/header` snapshot + * when it cannot, so a logged delta ALWAYS round-trips. NOT a + * {@link SurfaceEventType}. */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[]; messageSuffix?: Message[] } } ``` @@ -100,7 +99,7 @@ export interface TodoItem { ### The request header events: `request/header` and `request/header-delta` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message. +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + 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. ```ts type-equiv export interface EpochHeader { @@ -110,10 +109,19 @@ export interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] + /** + * Request-only messages sent BEFORE the derived history (the + * `agent/request-messages` 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. + */ + messagePrefix?: Message[] + /** Request-only messages sent AFTER the derived history; absent when none. */ + messageSuffix?: Message[] } ``` -Canonical form: an empty system prompt and an empty tool list are ABSENT fields, matching how requests are built. The delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts). +Canonical form: an empty system prompt, an empty tool list, and empty request-only message arrays are ABSENT fields, matching how requests are built. `messagePrefix`/`messageSuffix` are the durable record of the `agent/request-messages` 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). ## `SessionEvent` — one log entry diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24a2793826..44bbe79144 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,17 +7,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../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:255`](../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:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../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:346`](../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:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../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:264`](../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:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:392`](../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:292`](../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:299`](../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:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:377`](../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:390`](../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:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/request-messages` | `waterfall` | [`packages/core/agent/src/types.ts:455`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:332`](../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:308`](../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:465`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:478`](../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 c29cbc0d99..b3618d93f8 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:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:309`](../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:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:316`](../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:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:307`](../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:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) ### `request/*` @@ -131,17 +131,17 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only -Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, or a whole replacement LlmCallConfig (four scalars — not worth diffing). Appended by the loop inside the step, before dispatch, when the header for this request differs from the fold of the log so far; the writer verifies `applyHeaderDelta(previous, delta)` reproduces the new header exactly and falls back to a `'fallback'` `request/header` snapshot when it cannot, so a logged delta ALWAYS round-trips. NOT a SurfaceEventType. +Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement 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. ```ts persistence-catalog -'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } +'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[]; messageSuffix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `steering/*` @@ -155,7 +155,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:334`](../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:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../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:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../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:337`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:348`](../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:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:322`](../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:321`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:332`](../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:273`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../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:267`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../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:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../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 601e64f70a..e17679f913 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -20,13 +20,13 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -**The header.** The request's non-content half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas — is logged session state, in canonical form (empty system/tools ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds. +**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and 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 loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log → build `GenerateOptions` from the snapshot + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's only in-process bookkeeping is one boolean: whether this instance has logged its anchoring snapshot. +**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `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-messages` 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 reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward. -**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the boundary derivation, rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself, and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. +**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix`, 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-messages` 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. ### The MiniCode shape: adopted, with the provenance arrow inverted @@ -44,6 +44,7 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt — ## Consequences - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. +- Choosing between the advisory channels is a change-frequency cost decision, and the seam does not hide it: an `agent/request-messages` 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. - 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 ed651ca7f0..2afa08850a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -59,8 +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-messages ⟵ request-only before/after messages; recorded + on the header, never session history session('request/header'[-delta]) ⟵ the header event this request owes the log - stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk') + stream llm.stream(freeze({header..., messages: before+boundary+after})) → session('assistant/chunk') message = waterfall agent/step-result session('assistant/message') each tool-call: session('tool/call') @@ -84,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/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` +- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/request-messages`, `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 9484f0ad5f..600bb211a1 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 } from '@deepseek-ai/dsh-agent' +import type { ContinuationDecision, HookContext, PromptDecision, RequestMessages } 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,9 +161,11 @@ 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 + * reqMsgs = waterfall agent/request-messages ⟵ request-only before/after messages; 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: boundary, sessionId, signal}) + * req = freeze({header..., messages: before+boundary+after, 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 @@ -671,11 +673,11 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean { } /** One step: build the request from the boundary snapshot + the step's - * header → log the header event the request owes → stream model → record → - * execute tools. The caller assembles the system prompt, fires the - * `agent/pre-step` seam, snapshots the derivation, and opens the step BEFORE - * calling this, so `boundaryMessages` is exactly the surface prefix at - * step/start and already reflects any compaction. */ + * header → collect request-only messages → log the header event the request + * owes → stream model → record → execute tools. The caller assembles the + * system prompt, fires the `agent/pre-step` seam, snapshots the derivation, + * and opens the step BEFORE calling this, so `boundaryMessages` is exactly + * the surface prefix at step/start and already reflects any compaction. */ async function runStep( ctx: Context, agent: ReactLoopAgent, @@ -715,22 +717,43 @@ async function runStep( throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } + // Collect request-ONLY messages: `before` contributions precede the boundary + // snapshot in the request, `after` contributions follow it. They are not + // session history — the header event below is their 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. 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 emptyRequestMessages: RequestMessages = deepFreeze({ before: [], after: [] }) + const requestMessages = await ctx.waterfall( + 'agent/request-messages', agent, turn, step, emptyRequestMessages, + { system, assembly, boundaryMessages, signal }, + () => Promise.resolve(emptyRequestMessages), + ) + // The request header (the log's request/header* vocabulary): canonical form, - // recorded before dispatch so the log always explains the request. + // recorded before dispatch so the log always explains the request — + // including the request-only messages, which no other event carries. const header = canonicalHeader({ config, ...system ? { system } : {}, ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, + ...requestMessages.before.length > 0 ? { messagePrefix: requestMessages.before } : {}, + ...requestMessages.after.length > 0 ? { messageSuffix: requestMessages.after } : {}, }) recordRequestHeader(session, transmission, header) // Build and freeze: the request is a pure function of (boundary snapshot, // logged header) — llm/stream listeners and adapters read it, mutation // throws. sessionId + frozen is the loop-built marker the dev invariant - // keys on. + // keys on. Message order: header.messagePrefix, then the boundary snapshot, + // then header.messageSuffix — the reconstruction equation the invariant + // recomputes. const request: GenerateOptions = deepFreeze({ model: header.config.model, - messages: boundaryMessages, + messages: [...header.messagePrefix ?? [], ...boundaryMessages, ...header.messageSuffix ?? []], ...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/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index e76ac30fa9..36b9100b85 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' +import SessionStore, { foldRequestHeader, 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 RequestMessages, type SessionStartSource, } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -310,6 +311,145 @@ describe('agent/session-start', () => { }) }) +describe('agent/request-messages (RequestMessages)', () => { + it('frames the derived history: before precedes it, after follows it, and the header records both', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } + const trailer: Message = { role: 'user', content: [{ type: 'text', text: 'trailing note' }] } + ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, _context, next): Promise => { + const result = await next() + return { before: [...result.before, reminder], after: [...result.after, trailer] } + }) + + send(agent, 'hi') + 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' }] }, + ]) + }) + + it('contributions compose across listeners and see the read-only request facts', 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-messages', 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/request-messages', 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 } + }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + // Registration order composes: the first listener runs last on the way + // 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 () => { + 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-messages', async (_agent, _turn, _step, _messages, _context, 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' }] }]) + }) + + it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let mutationError: unknown + ctx.on('agent/request-messages', async (_agent, _turn, _step, messages, _context, next): Promise => { + try { + messages.before.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 () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'ping' }), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let step = 0 + ctx.on('agent/request-messages', 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 } + }) + + 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' }] }]) + }) +}) + 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 9e15356ed0..64a5516761 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -45,6 +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-messages` — contribute request-ONLY messages around the derived history: a frozen empty `RequestMessages` 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/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 1fd6a57867..9d4fa4f10c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -17,7 +17,8 @@ * consumer that wants the live transcript subscribes here. * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ - * `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and + * `agent/request`/`agent/request-messages`/`agent/step-result`/ + * `agent/turn-continuation` waterfalls and * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits * (`agent/status`, `agent/error`, `agent/created`/ * `agent/disposed`, `agent/queued`, `agent/session-start`) @@ -45,7 +46,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-system-prompt' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -138,6 +139,49 @@ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } +/** + * Request-ONLY messages an `agent/request-messages` waterfall listener + * contributes around the derived history of ONE LLM request: `before` messages + * precede the derived history in `GenerateOptions.messages`, `after` messages + * follow it. They are NOT session events — nothing here enters the session log + * as durable history, `Session.deriveMessages()` never returns them, and the + * next step recomputes them 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 RequestMessages { + /** Messages placed before the derived history in the request. */ + before: Message[] + /** Messages placed after the derived history in the request. */ + after: Message[] +} + +/** + * Read-only facts about the request an `agent/request-messages` 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 RequestMessagesContext { + /** 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 @@ -351,8 +395,9 @@ declare module 'cordis' { * ALL a listener shapes here: every request is a pure function of the * session log (the reconstructability RFC), so model-visible content * flows through the log channels — `inject()`, steering, prompt-submit - * `additionalContext`, prompt sections via `system-prompt/assemble` — - * never through request mutation, and the loop records whatever config + * `additionalContext`, prompt sections via `system-prompt/assemble`, or + * header-logged request-only messages via {@link agent/request-messages} + * — 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 @@ -367,6 +412,47 @@ declare module 'cordis' { * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise + /** + * Waterfall: contribute request-ONLY messages around the derived history — + * a {@link RequestMessages} whose `before` messages precede the boundary + * snapshot in `GenerateOptions.messages` and whose `after` messages follow + * it. 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. + * + * The seed is frozen and empty; a contributing listener returns a NEW + * {@link RequestMessages} 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 RequestMessages} without it to short-circuit. + * + * 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 messages - the frozen empty seed; return an extended replacement to contribute. + * @param context - read-only request facts ({@link RequestMessagesContext}). + * @mode waterfall + */ + 'agent/request-messages'(agent: Agent, turn: number, step: number, messages: RequestMessages, context: RequestMessagesContext, next: () => Promise): Promise /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0ae5bbf39e..7fa16d0f3d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -51,7 +51,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Request-header reconstruction (`request-header.ts`) -The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools ≡ absent fields). +The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole 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-messages` waterfall's request-only contributions — the request is `messagePrefix + derived history + messageSuffix`, and `deriveMessages()` never returns them. ### Session event vocabulary (`types.ts`) diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index d83da16197..a891237425 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -13,14 +13,24 @@ */ import { callConfigEquals } from '@deepseek-ai/dsh-llm' -import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts' +/** The `request/header-delta` payload shape: each present field amends the folded header. */ +type HeaderDelta = { + system?: SystemDelta + tools?: ToolsDelta + config?: LlmCallConfig + messagePrefix?: Message[] + messageSuffix?: Message[] +} + /** - * Normalize a header to canonical form: an empty system prompt and an empty - * tool list become ABSENT fields, matching how requests are built (both - * request-build spreads skip empty values). Diff, fold, and comparison all - * operate on canonical headers, so "no system prompt" has exactly one + * 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. * @param header - the header to normalize (not mutated). * @returns the canonical header. @@ -30,6 +40,8 @@ export function canonicalHeader(header: EpochHeader): EpochHeader { config: header.config, ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, + ...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {}, + ...header.messageSuffix !== undefined && header.messageSuffix.length > 0 ? { messageSuffix: header.messageSuffix } : {}, } } @@ -109,37 +121,47 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[ * writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal * the intended header) and the loop runs to skip logging an unchanged header. * Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is - * correctly unequal. + * correctly unequal; request-only message arrays compare as canonical JSON + * (both sides come from the same build path, so key order matches when the + * values do). * @param a - one canonical header. * @param b - the other. - * @returns whether config, system, and tools (in order) all match. + * @returns whether config, system, tools (in order), and request-only messages 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 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. */ +function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []) +} + /** * Compute the `request/header-delta` payload between two canonical headers, * or undefined when they are equal. The caller MUST round-trip the result * ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it — * the encoding cannot express every change (a pure tool reordering) — and * fall back to a full `request/header` snapshot when the check fails. + * Request-only messages are replaced whole (small advisory content, not worth + * diffing); an empty replacement array encodes the transition to "none". * @param prev - the folded header the log currently implies. * @param next - the header the next request will actually use. * @returns the delta payload, or undefined when nothing changed. */ -export function diffHeader( - prev: EpochHeader, next: EpochHeader, -): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined { - const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {} +export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined { + const delta: HeaderDelta = {} if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system) const prevTools = prev.tools ?? [] const nextTools = next.tools ?? [] if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools) if (!callConfigEquals(prev.config, next.config)) delta.config = next.config + if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? [] + if (!sameMessages(prev.messageSuffix, next.messageSuffix)) delta.messageSuffix = next.messageSuffix ?? [] return Object.keys(delta).length > 0 ? delta : undefined } @@ -151,15 +173,17 @@ export function diffHeader( * @param delta - the logged delta payload. * @returns the canonical header after the delta. */ -export function applyHeaderDelta( - prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }, -): EpochHeader { +export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader { const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools + const messagePrefix = delta.messagePrefix ?? prev.messagePrefix + 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 ec227e9884..32a10c7bca 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -177,14 +177,16 @@ export interface TodoItem { } /** - * The request header: everything about an LLM request besides its message - * content — the call configuration plus the rendered system prompt and tool - * schemas. Logged session state (the reconstructability RFC): a + * The request header: everything about an LLM request besides its derived + * message history — the call configuration plus the rendered system prompt, + * tool schemas, and any request-only messages. Logged session state (the + * reconstructability RFC): a * {@link SessionEventMap} `request/header` snapshot installs one, a * `request/header-delta` amends it, and folding those events over the log * (`foldRequestHeader`) reconstructs the header any request was built under. - * Canonical form: an empty system prompt and an empty tool list are ABSENT - * fields, matching how requests are built. + * Canonical form: an empty system prompt, an empty tool list, and empty + * request-only message arrays are ABSENT fields, matching how requests are + * built. */ export interface EpochHeader { /** The conversation's call configuration (model + sampling scalars). */ @@ -193,6 +195,15 @@ export interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] + /** + * Request-only messages sent BEFORE the derived history (the + * `agent/request-messages` 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. + */ + messagePrefix?: Message[] + /** Request-only messages sent AFTER the derived history; absent when none. */ + messageSuffix?: Message[] } /** @@ -350,15 +361,19 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** * Amendment to the folded {@link EpochHeader}: at least one of a - * {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement - * {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the + * {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement + * {@link LlmCallConfig} (four scalars — not worth diffing), or a whole + * replacement 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 {@link SurfaceEventType}. */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[]; messageSuffix?: Message[] } } export type SessionEventType = keyof SessionEventMap diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index c2368c46fe..9db46598ac 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -8,9 +8,9 @@ */ import { describe, expect, it } from 'vitest' -import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' -import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' const CONFIG = { model: 'm' } @@ -18,6 +18,10 @@ function tool(name: string, description = 'd'): ToolSchema { return { name, description, parameters: { type: 'object' } } } +function msg(text: string): Message { + return { role: 'user', content: [{ type: 'text', text }] } +} + /** Round-trip helper: diff must reproduce `next` from `prev` exactly. */ function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType { const delta = diffHeader(prev, next) @@ -103,6 +107,49 @@ 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')] }) + 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) + }) + + 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')] }) + 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)', () => { + const none = canonicalHeader({ config: CONFIG }) + const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] }) + const gained = roundTrip(none, some) + expect(gained).toEqual({ messagePrefix: [msg('p')], messageSuffix: [msg('s')] }) + const lost = roundTrip(some, none) + expect(lost).toEqual({ messagePrefix: [], messageSuffix: [] }) + }) + + it('folds framing deltas over the log like any other header amendment', () => { + const session = new Session(SessionId('fold-framing')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] }) + session.append('request/header', { header: first, reason: 'initial' }) + const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] }) + session.append('request/header-delta', diffHeader(first, second)!) + expect(foldRequestHeader(session.events)).toEqual(second) + session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!) + expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG }) + }) +}) + describe('foldRequestHeader', () => { function headerEvents(session: Session): readonly SessionEvent[] { return session.events diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 7163ddc1d9..f6212e7870 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -168,6 +168,12 @@ export interface ToolSchema { /** A single model request, fully assembled. */ export interface GenerateOptions { model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history + `messageSuffix` + * (dsh-agent-loop); a hand-built one-shot passes any list. + */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ system?: string diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f35fbafa1d..024838ea4c 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -367,8 +367,11 @@ export function apply(ctx: Context, config: Config = {}): void { // hand-built one-shot (compaction summarize) is unfrozen and skipped — must // be EXACTLY what the session log reconstructs: // - // - messages: the derivation over the log prefix strictly before the - // in-flight step's `step/start` (the reconstruction boundary). Compared + // - messages: the folded header's request-only messages (messagePrefix / + // messageSuffix — the `agent/request-messages` contributions, logged on + // the header because no session event carries them) framing the + // derivation over the log prefix strictly before the in-flight step's + // `step/start` (the reconstruction boundary). The derivation is compared // against a FRESH Session built over that prefix — the same projection // code with zero shared state, so the live cache under test cannot vouch // for itself. Boundary-correct by construction: content appended after @@ -408,18 +411,22 @@ export function apply(ctx: Context, config: Config = {}): void { if (boundary === -1) { throw new InvariantError('a loop-built request with no step/start in its session log') } - const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) - // JSON equality is sound here: both sides are structuredClones produced by - // the same projection code path, so key insertion order matches when the - // values do. - if (JSON.stringify(options.messages) !== JSON.stringify(rebuilt.deriveMessages())) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) - } - const header = foldRequestHeader(events) if (header === undefined) { throw new InvariantError('a loop-built request with no request/header event in its session log') } + const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) + // The reconstruction equation: the folded header's request-only messages + // frame the boundary derivation (prefix + derived + suffix) — 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 + // 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 ?? []] + if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { + throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) + } + const headerMatches = options.model === header.config.model && options.system === header.system && options.temperature === header.config.temperature diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 489cfb9817..cccd6a54d4 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -707,6 +707,22 @@ 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 () => { + 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… + 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 }) + expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/) + }) + it('rejects a frozen request whose messages diverge from the boundary derivation', async () => { const { ctx, session, boundary } = await requestSetup() const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b5c648527e..7291bee4ca 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -15,6 +15,8 @@ { "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": "RequestMessages", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "RequestMessagesContext", "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" },