From 2093a8898bb4d27697188606b1561bf97e950885 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:07:34 +0800 Subject: [PATCH] =?UTF-8?q?loop:=20every=20request=20is=20built=20from=20t?= =?UTF-8?q?he=20log=20=E2=80=94=20boundary=20snapshot,=20header=20events,?= =?UTF-8?q?=20config-only=20waterfall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands. --- docs/cordis-catalog/events.md | 20 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 14 +- packages/core/agent-loop/README.md | 8 +- packages/core/agent-loop/src/loop.ts | 109 +++++-- packages/core/agent-loop/src/request-log.ts | 68 +++++ packages/core/agent-loop/tests/loop.spec.ts | 20 +- .../agent-loop/tests/request-cache.e2e.ts | 104 +++++++ .../core/agent-loop/tests/request-log.spec.ts | 85 ++++++ .../tests/request-reconstruction.spec.ts | 284 ++++++++++++++++++ .../agent-loop/tests/review-fixes.spec.ts | 5 +- packages/core/agent/src/types.ts | 26 +- packages/core/session/src/index.ts | 26 +- packages/core/session/src/request-header.ts | 23 +- packages/llm/llm/src/call-config.ts | 6 +- packages/llm/llm/tests/call-config.spec.ts | 12 + scripts/gen-cordis-catalog.ts | 1 + 17 files changed, 745 insertions(+), 68 deletions(-) create mode 100644 packages/core/agent-loop/src/request-log.ts create mode 100644 packages/core/agent-loop/tests/request-cache.e2e.ts create mode 100644 packages/core/agent-loop/tests/request-log.spec.ts create mode 100644 packages/core/agent-loop/tests/request-reconstruction.spec.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 91425e9e2b..c34143bada 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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:394`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -89,15 +89,15 @@ Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/t ### `agent/request` — waterfall -Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-step instead — by the time this fires, `options.messages` is already derived. +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. ```ts cordis-catalog -'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise +'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -133,7 +133,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:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -145,7 +145,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:382`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts) ## `fs/*` @@ -209,7 +209,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:38`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:39`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -221,7 +221,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -231,7 +231,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1c405975a6..0c3b8cb31a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:345`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:367`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index db3947b863..9df8a34ff8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,22 +9,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `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:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `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:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `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:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../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/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) | | `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) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:38`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index e169fce45a..ed651ca7f0 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -56,9 +56,11 @@ forever: drain steering assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt await serial agent/pre-step ⟵ surface mutation (compaction) outside the step - session('step/start') - request = waterfall agent/request - stream llm.stream(request) → session('assistant/chunk') + boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame, + session('step/start') strictly before step/start + config = waterfall agent/request ⟵ frozen seed; return a replacement to switch + session('request/header'[-delta]) ⟵ the header event this request owes the log + stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk') message = waterfall agent/step-result session('assistant/message') each tool-call: session('tool/call') diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 0b84bcb83f..40dd0fc380 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -8,10 +8,13 @@ */ import type { Context } from 'cordis' -import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' +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 { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' +import { createTransmissionLog, recordRequestHeader } from './request-log.ts' +import type { TransmissionLog } from './request-log.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -155,10 +158,13 @@ export interface LoopHandle { * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt * (persona section + {{variables}}) IS the full prompt * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step - * session('step/start') ⟵ durable step boundary (no agent/* mirror) - * req = {model, system, tools, messages: session.deriveMessages(), signal} - * req = waterfall agent/request ⟵ hooks/model-switch - * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) + * boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the + * session('step/start') same sync frame, strictly before step/start + * config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches + * session('request/header'|'request/header-delta') ⟵ the header event this request owes the + * log (initial/resume anchor, delta, fallback) + * req = freeze({header..., messages: boundary, sessionId, signal}) + * 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 * session('assistant/message' {content, usage?}) session records what actually ran @@ -181,6 +187,13 @@ export interface LoopHandle { * ``` */ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { + // Per-instance transmission bookkeeping: whether THIS loop instance has + // anchored the log's header fold yet (its first request logs a + // 'initial'/'resume' request/header snapshot). Everything else the request + // needs is read from the session log itself — the loop holds no + // conversation state (the reconstructability RFC). + const transmission = createTransmissionLog() + const { session } = agent while (!handle.isDisposed()) { @@ -236,7 +249,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // turn number is actually last in the log — a stale counter would collide. const turn = lastTurnNumber(session) + 1 try { - await runTurn(ctx, agent, handle, turn) + await runTurn(ctx, agent, handle, turn, transmission) } catch (error: unknown) { // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard // before turn/start) — no turn/start was appended, so no turn is open and @@ -271,7 +284,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } } -async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise { +async function runTurn( + ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, +): Promise { const { session } = agent // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end — @@ -474,6 +489,17 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, break } + // The reconstruction boundary (the reconstructability RFC): the request's + // messages are snapshotted HERE, in the same synchronous frame as the + // step/start append directly below — so the snapshot is exactly the + // derivation over the log prefix strictly before step/start's seq. + // Anything appended later — by a step/start session/event listener, an + // agent/request-window inject(), any concurrent task — lands after the + // boundary and joins the NEXT request. An external reconstructor + // recovers these exact messages by folding the surface over + // events[0..stepStartSeq). + const boundaryMessages = session.deriveMessages() + // Mark the step open BEFORE the append: Session.append pushes the event // to the log before notifying session/event listeners, so a THROWING // step/start listener leaves step/start in the log. Setting stepOpen first @@ -495,7 +521,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal) + stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -644,11 +670,12 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean { return messages.length > 0 } -/** One step: derive request from the (already pre-step-mutated) surface → - * stream model → record → execute tools. The caller assembles the system prompt - * and fires the `agent/pre-step` seam BEFORE opening the step, then passes the - * resulting `assembly`/`system` here, so the surface this step derives from - * already reflects any compaction. */ +/** 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. */ async function runStep( ctx: Context, agent: ReactLoopAgent, @@ -656,23 +683,57 @@ async function runStep( step: number, assembly: PromptAssembly, system: string, + boundaryMessages: Message[], + transmission: TransmissionLog, signal: AbortSignal, ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent - let request: GenerateOptions = { - model: options.model ?? '', - messages: session.deriveMessages(), - ...system ? { system } : {}, - ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, - sessionId: session.id, - signal, - } - request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request)) - if (!request.model) { + // Seed the call config: the first request of THIS loop instance seeds from + // current AgentOptions — explicit options always win over the logged + // baseline, which is what keeps fork model-overrides and resume-time + // reconfiguration correct. Later steps seed from the log's folded header, + // which by then is exactly what this instance last logged. + const seedConfig: LlmCallConfig = transmission.loggedHeader + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log + ? session.requestHeader()!.config + : { model: options.model ?? '' } + + // Shape the call config: listeners return a replacement to switch model or + // sampling (the seed is frozen — content shaping is not expressible here; + // model-visible content flows through the log channels). The header event + // below records whatever the request ACTUALLY uses, so a listener's switch + // is a logged, reconstructable fact, never silent drift. + const config = await ctx.waterfall('agent/request', agent, turn, step, deepFreeze({ ...seedConfig }), () => Promise.resolve(seedConfig)) + if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } + // The request header (the log's request/header* vocabulary): canonical form, + // recorded before dispatch so the log always explains the request. + const header = canonicalHeader({ + config, + ...system ? { system } : {}, + ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, + }) + 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. + const request: GenerateOptions = deepFreeze({ + model: header.config.model, + messages: boundaryMessages, + ...header.system !== undefined ? { system: header.system } : {}, + ...header.tools !== undefined ? { tools: header.tools } : {}, + ...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {}, + ...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {}, + ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, + sessionId: session.id, + signal, + }) + // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() const chunkSeqs: number[] = [] diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts new file mode 100644 index 0000000000..07c57b9ff9 --- /dev/null +++ b/packages/core/agent-loop/src/request-log.ts @@ -0,0 +1,68 @@ +/** + * Per-loop-instance transmission bookkeeping for the reconstructability + * contract: which header event to append before a request so the session log + * always explains the request (the reconstructability RFC). The loop is + * otherwise transmission-stateless — the comparison baseline is the log's own + * folded header (`Session.requestHeader()`), so resume and fork need no + * special path: a fresh loop instance simply logs a `'resume'` snapshot on + * its first request and deltas from there. + * + * @module dsh-agent-loop/request-log + */ + +import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session' +import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' + +/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */ +export interface TransmissionLog { + /** True once this loop instance appended its anchoring `request/header` snapshot. */ + loggedHeader: boolean +} + +/** Fresh bookkeeping for a newly-started loop instance. */ +export function createTransmissionLog(): TransmissionLog { + return { loggedHeader: false } +} + +/** + * Append whatever header event this request owes the log, so folding the log + * reproduces the header the request was built under. Exactly one of four + * things happens: + * + * 1. This loop instance has not logged a header yet → a full `request/header` + * snapshot anchors the fold: reason `'initial'` when the log has no header + * events at all (a new conversation), `'resume'` when it does (process + * restart, fork seed — the boundary itself is a recorded fact, so the + * snapshot is appended even when nothing changed). + * 2. The header equals the folded baseline → nothing; the log already + * explains this request. + * 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline + * reproduces the header exactly) → a `request/header-delta`. + * 4. It differs and the delta encoding cannot express the change (a pure tool + * reordering) → a full snapshot with reason `'fallback'`; deltas are an + * encoding optimization, never a correctness dependency. + * + * @param session - the session whose log explains the request. + * @param state - this loop instance's bookkeeping (mutated on first log). + * @param header - the canonical header the request will ACTUALLY use + * (post-`agent/request`). + */ +export function recordRequestHeader(session: Session, state: TransmissionLog, header: EpochHeader): void { + if (!state.loggedHeader) { + session.append('request/header', { header, reason: session.requestHeader() === undefined ? 'initial' : 'resume' }) + state.loggedHeader = true + return + } + // This instance logged a snapshot, so the fold is necessarily defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const baseline = session.requestHeader()! + if (headerEquals(baseline, header)) return + const delta = diffHeader(baseline, header) + /* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same three parts */ + if (delta === undefined) return + if (headerEquals(applyHeaderDelta(baseline, delta), header)) { + session.append('request/header-delta', delta) + } else { + session.append('request/header', { header, reason: 'fallback' }) + } +} diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 16a9b5e394..eebf56ee82 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -230,9 +230,8 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { - options.model = 'mock' - return next() + ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + return { ...config, model: 'mock' } }) const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) @@ -428,20 +427,27 @@ describe('agent loop', () => { expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) }) - it('agent/request waterfall can rewrite the request (model-switch pattern)', async () => { + it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.llm.registerAdapter(['other-model'], adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { - options.model = 'other-model' - return next() + ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + // The seed is frozen — config is not a mutable per-call knob; a switch + // is proposed by returning a replacement, and the loop logs it. + expect(Object.isFrozen(config)).toBe(true) + expect(() => { (config as { model: string }).model = 'other-model' }).toThrow(TypeError) + return { ...config, model: 'other-model' } }) send(agent, 'hi') await waitForIdle(ctx, agent) expect(adapter.requests[0]!.model).toBe('other-model') + // The header event records what the request ACTUALLY used — the switch is + // a reconstructable fact, not silent drift. + const headerEvent = agent.session.events.find(e => e.type === 'request/header') + expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) it('agent/pre-step fires once per step before the step is opened', async () => { diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts new file mode 100644 index 0000000000..5ccaa7e797 --- /dev/null +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' + +/** + * With-key proof that log-derived requests translate into REAL provider cache + * hits: a multi-step tool turn (plus a follow-up turn) against the live + * DeepSeek API must report `cacheReadTokens > 0` on every request after the + * first — the adapter maps the provider's `prompt_cache_hit_tokens`, and the + * per-step usage recorded on `assistant/message` events is the production + * observable for cache behavior (the reconstructability RFC's measurement + * layer: prefix stability is corollary #1). Mocks prove the requests are + * append-extensions; only the real API proves those bytes actually hit the + * provider cache. Key-gated — skips entirely without $DEEPSEEK_API_KEY. + */ + +// Long enough that the shared request prefix comfortably spans the provider's +// cache-block granularity (64 tokens) from the very first request. +const SYSTEM = 'You are a terse coding assistant used in an automated cache test. ' + + 'Always follow instructions literally and exactly. When the user asks you to look ' + + 'something up, call the lookup tool with the requested key and wait for its result ' + + 'before answering. Never invent a value the tool has not returned. After the tool ' + + 'returns, answer with a single short sentence that repeats the returned value ' + + 'verbatim. Do not add explanations, do not use markdown, do not ask follow-up ' + + 'questions. If the user asks anything else, answer in one short sentence.' + +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined +}) + +async function loopHarness(): Promise { + const created = new Context() + await created.plugin(LlmService) + await created.plugin(SessionStore) + await created.plugin(SystemPrompt, { persona: SYSTEM }) + await created.plugin(ToolRegistry) + await created.plugin(AgentRegistry) + await created.plugin(AgentLoop, { agents: [] }) + await created.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + created.tools.register(defineTool({ + name: 'lookup', + description: 'Look up the stored value for a key.', + parameters: { key: { type: 'string', description: 'The key to look up.' } }, + async execute(args) { + return [{ type: 'text', text: `value(${String(args.key)}) = azure-falcon-42` }] + }, + })) + return created +} + +function waitForIdle(context: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = context.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { + it('every request after the first hits the provider prefix cache', async () => { + ctx = await loopHarness() + const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { model: 'deepseek-v4-flash' }) + + // Turn 1: forces a tool call → at least two steps (two model requests). + agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) + await waitForIdle(ctx, agent) + // Turn 2: a follow-up over the same (longer) prefix. + agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) + await waitForIdle(ctx, agent) + + const usages = [...agent.session.events] + .filter(e => e.type === 'assistant/message') + .map(e => e.data.usage) + expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 + for (const usage of usages) expect(usage).toBeDefined() + + // The first request has nothing to hit; every later one shares its + // predecessor as a byte-identical prefix, so the provider must report + // cached prompt tokens (prompt_cache_hit_tokens → cacheReadTokens). + for (const usage of usages.slice(1)) { + expect(usage!.cacheReadTokens ?? 0).toBeGreaterThan(0) + } + + // World-verification of the conversation itself: the tool value made it + // through the loop into the final answer. + const finalText = agent.session.deriveMessages().at(-1)!.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + expect(finalText).toContain('azure-falcon-42') + }, 180_000) +}) diff --git a/packages/core/agent-loop/tests/request-log.spec.ts b/packages/core/agent-loop/tests/request-log.spec.ts new file mode 100644 index 0000000000..a6befde84e --- /dev/null +++ b/packages/core/agent-loop/tests/request-log.spec.ts @@ -0,0 +1,85 @@ +/** + * recordRequestHeader unit tests: exactly one of four things per request — + * an 'initial' snapshot (log has no header yet), a 'resume' snapshot (fresh + * loop instance over a log that has one), nothing (header unchanged), a + * round-tripping delta, or a 'fallback' snapshot when the delta encoding + * cannot express the change (pure tool reordering). + */ + +import { describe, expect, it } from 'vitest' +import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { createTransmissionLog, recordRequestHeader } from '../src/request-log.ts' + +function tool(name: string, description = 'd'): ToolSchema { + return { name, description, parameters: { type: 'object' } } +} + +function openSession(id: string): Session { + const session = new Session(SessionId(id)) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return session +} + +function headerEvents(session: Session): SessionEvent[] { + return session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta') +} + +describe('recordRequestHeader', () => { + it("anchors a new conversation with an 'initial' snapshot, then logs nothing while unchanged", () => { + const session = openSession('rl-initial') + const state = createTransmissionLog() + const header = canonicalHeader({ config: { model: 'm' }, system: 's', tools: [tool('t')] }) + + recordRequestHeader(session, state, header) + const [first] = headerEvents(session) + expect(first?.type === 'request/header' && first.data.reason).toBe('initial') + + recordRequestHeader(session, state, header) + expect(headerEvents(session)).toHaveLength(1) + }) + + it("anchors a fresh loop instance over an anchored log with a 'resume' snapshot, even unchanged", () => { + const session = openSession('rl-resume') + const header = canonicalHeader({ config: { model: 'm' }, system: 's' }) + recordRequestHeader(session, createTransmissionLog(), header) + + // A second instance (process restart / fork): the boundary itself is a + // recorded fact — snapshot appended even though the header is identical. + recordRequestHeader(session, createTransmissionLog(), header) + const events = headerEvents(session) + expect(events).toHaveLength(2) + expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('resume') + }) + + it('logs a round-tripping delta for a mid-run change, and the fold reproduces the header', () => { + const session = openSession('rl-delta') + const state = createTransmissionLog() + const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] }) + recordRequestHeader(session, state, first) + + const second = canonicalHeader({ config: { model: 'm' }, system: 'a\nc', tools: [tool('t'), tool('u')] }) + recordRequestHeader(session, state, second) + const events = headerEvents(session) + expect(events).toHaveLength(2) + expect(events[1]?.type).toBe('request/header-delta') + expect(session.requestHeader()).toEqual(second) + }) + + it("records a change the delta cannot express (pure reordering) as a 'fallback' snapshot", () => { + const session = openSession('rl-fallback') + const state = createTransmissionLog() + const first = canonicalHeader({ config: { model: 'm' }, tools: [tool('a'), tool('b')] }) + recordRequestHeader(session, state, first) + + const reordered = canonicalHeader({ config: { model: 'm' }, tools: [tool('b'), tool('a')] }) + recordRequestHeader(session, state, reordered) + const events = headerEvents(session) + expect(events).toHaveLength(2) + expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('fallback') + // The fold still lands on the exact header — deltas are an encoding + // optimization, never a correctness dependency. + expect(session.requestHeader()).toEqual(reordered) + }) +}) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts new file mode 100644 index 0000000000..788b451c2d --- /dev/null +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -0,0 +1,284 @@ +/** + * Loop-level reconstructability: every request the loop sends is a pure + * function of the session log — messages are the derivation at the step/start + * boundary, the header is the fold of request/header* events — and every + * request is an append-extension of its predecessor unless a logged event + * (compaction replace, header change) explains the difference. The requests + * recorded by the mock adapter are the observable; the offline-rebuild test + * at the bottom is the theorem stated end-to-end. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter, persona = 'stable base') { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function send(agent: ReactLoopAgent, text: string) { + agent.send([{ type: 'text', text }]) +} + +/** Assert `previous` is a strict value-prefix of `current`. */ +function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptions) { + expect(current.messages.length).toBeGreaterThan(previous.messages.length) + expect(current.messages.slice(0, previous.messages.length)).toEqual([...previous.messages]) + expect(current.system).toEqual(previous.system) + expect(current.tools).toEqual(previous.tools) +} + +function registerEcho(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'echo', + description: 'echo back', + parameters: { text: { type: 'string' } }, + async execute(args) { + return [{ type: 'text', text: `echo: ${String(args.text)}` }] + }, + })) +} + +describe('request stability across the loop', () => { + it('each step request within a turn append-extends the previous, frozen end to end', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'one' }, 'first'), + toolCallResponse('c2', 'echo', { text: 'two' }, 'second'), + textResponse('done'), + ]) + const ctx = await harness(adapter) + registerEcho(ctx) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(3) + expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + expectPrefixExtension(adapter.requests[1]!, adapter.requests[2]!) + for (const request of adapter.requests) { + expect(Object.isFrozen(request)).toBe(true) + expect(Object.isFrozen(request.messages)).toBe(true) + } + // One anchoring header snapshot; no further header events (nothing changed). + const headerEvents = agent.session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta') + expect(headerEvents).toHaveLength(1) + expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial') + }) + + it('a later turn append-extends the previous turn (one conversation, one log)', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(2) + expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + }) + + it('a compaction replace rewrites the resend, and the log explains it', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + + // A pre-step listener compacts turn 1's history before turn 2's step — + // the sanctioned surface rewrite, landing OUTSIDE the step. + const preStep = ctx.on('agent/pre-step', () => { + preStep() + const session = agent.session + const nodes = session.surface.nodes + session.append('context/message', { + content: [{ type: 'text', text: '[summary of turn 1]' }], + source: { kind: 'plugin', plugin: 'test-compact' }, + }, { + surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, + sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq], + }) + }) + + send(agent, 'second') + await waitForIdle(ctx, agent) + + const second = adapter.requests[1]! + // The rewritten history: summary replaces turn 1's user+assistant pair. + expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) + // No header event beyond the anchor: the replace is itself in the log. + expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1) + }) + + it('a real system-prompt change is a logged header delta; a stable prompt logs nothing', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + // Identical assembly re-rendered per step is NOT a change. + expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0) + + ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) + send(agent, 'third') + await waitForIdle(ctx, agent) + + const deltas = agent.session.events.filter(e => e.type === 'request/header-delta') + expect(deltas).toHaveLength(1) + expect(adapter.requests[2]!.system).toContain('new guidance') + // History is preserved across the change — only the header moved. + expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length) + }) + + it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let injected = false + ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { + if (!injected) { + injected = true + agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } }) + } + return next() + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + const first = adapter.requests[0]! + // The inject landed in the log after the boundary: not in THIS request… + expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) + expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + + send(agent, 'second') + await waitForIdle(ctx, agent) + // …but in the next one, at its logged position. + const second = adapter.requests[1]! + expect(second.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(true) + }) + + it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { + const adapter = new MockAdapter([textResponse('one')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('llm/stream', (options, next) => { + // The historical failure mode this design kills: a listener rewriting + // request content in place. The freeze turns it into a loud error. + options.messages.push({ role: 'user', content: [{ type: 'text', text: 'sneaky' }] }) + return next() + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(errors).toHaveLength(1) + expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i) + }) + + it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { + const adapter = new MockAdapter([textResponse('one')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('gen1'), { model: 'mock' }) + send(agent, 'first') + await waitForIdle(ctx, agent) + + // Second generation: a new agent whose session is seeded with the first + // one's full log (the resume/fork path). + const adapter2 = new MockAdapter([textResponse('two')]) + const ctx2 = await harness(adapter2) + const handle = ctx2.agents.create({ + agentId: AgentId('gen2'), + sessionId: SessionId('gen2-session'), + seed: [...agent.session.events], + agentOptions: { model: 'mock' }, + }) + const agent2 = handle.agent as ReactLoopAgent + send(agent2, 'second') + await waitForIdle(ctx2, agent2) + + const snapshots = agent2.session.events.filter(e => e.type === 'request/header') + expect(snapshots).toHaveLength(2) + expect(snapshots[1]?.type === 'request/header' && snapshots[1].data.reason).toBe('resume') + // Identical header across the restart: byte-identical continuation. + expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system) + expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!) + }) + + it('THEOREM: every request rebuilds byte-equal from the session log alone', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'echo', { text: 'one' }, 'calling'), + textResponse('done'), + textResponse('after change'), + ]) + const ctx = await harness(adapter) + registerEcho(ctx) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) + ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5 })) + send(agent, 'again') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(3) + const events = agent.session.events + const stepStarts = events.filter(e => e.type === 'step/start') + expect(stepStarts).toHaveLength(3) + + adapter.requests.forEach((request, index) => { + const stepStart = stepStarts[index]! + // Messages: the derivation over the log prefix strictly before this + // step's step/start — rebuilt here through a completely fresh Session. + const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq))) + expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages()) + + // Header: the fold of request/header* events up to this step's dispatch + // (its header event sits between step/start and the first chunk). + const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! + const header = foldRequestHeader(events.slice(0, firstChunk.seq))! + expect(request.model).toBe(header.config.model) + expect(request.system).toEqual(header.system) + expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? [])) + expect(request.temperature).toBe(header.config.temperature) + expect(request.maxTokens).toBe(header.config.maxTokens) + expect(request.stop).toEqual(header.config.stop) + }) + }) +}) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 21be9e8267..e487a2c428 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -399,9 +399,8 @@ describe('MEDIUM: misc registry and config fixes', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { - options.model = 'mock' - return next() + ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + return { ...config, model: 'mock' } }) send(agent, 'go') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1b4af61d9a..1fd6a57867 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -44,7 +44,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ @@ -345,18 +345,28 @@ declare module 'cordis' { */ 'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise /** - * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the - * model call (hooks, model switching, tool filtering, …). Call `next()` to - * delegate, or return without it to short-circuit. For surface mutation that - * must precede history derivation (compaction), use {@link agent/pre-step} - * instead — by the time this fires, `options.messages` is already derived. + * Waterfall: shape the step's call configuration — model switching, + * sampling overrides — by returning a replacement {@link 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 {@link agent/pre-step}. Call `next()` to + * delegate, or return an {@link LlmCallConfig} without it to + * short-circuit. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. - * @param options - the assembled request; listeners return a transformed copy. + * @param config - the config the loop would use (frozen); return a replacement to switch. * @mode waterfall */ - 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise + 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b446b80b8e..7fa85aa796 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -11,9 +11,10 @@ import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { isJsonValue } from './json.ts' import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' +import { foldRequestHeader } from './request-header.ts' export * from './types.ts' export { isJsonValue } from './json.ts' @@ -22,7 +23,7 @@ export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' -export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from './request-header.ts' +export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' declare module 'cordis' { interface Context { @@ -236,6 +237,27 @@ export class Session { return event } + /** Cached fold of the request-header events — see {@link requestHeader}. */ + private headerFold: EpochHeader | undefined + /** Log position (events consumed) the header fold has reached. */ + private headerFoldSeq = 0 + + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined { + if (this.headerFoldSeq < this.log.length) { + this.headerFold = foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold) + this.headerFoldSeq = this.log.length + } + return this.headerFold + } + /** The derived-message cache: frozen projections, extended per unseen node. */ private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index e8f47697c2..d83da16197 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -104,6 +104,23 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[ return [...kept, ...delta.added] } +/** + * Field-wise equality over canonical headers — the cheap comparison the + * 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. + * @param a - one canonical header. + * @param b - the other. + * @returns whether config, system, and tools (in order) all match. + */ +export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { + if (!callConfigEquals(a.config, b.config) || a.system !== b.system) 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)) +} + /** * Compute the `request/header-delta` payload between two canonical headers, * or undefined when they are equal. The caller MUST round-trip the result @@ -154,10 +171,12 @@ export function applyHeaderDelta( * the dev invariant both use it; the live session tracks the same fold * incrementally. * @param events - session events in log order (non-header events are skipped). + * @param from - a previously folded state to continue from (the live session's + * incremental cursor); omit to fold from nothing. * @returns the folded header, or undefined when no header event exists yet. */ -export function foldRequestHeader(events: readonly SessionEvent[]): EpochHeader | undefined { - let state: EpochHeader | undefined +export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined { + let state: EpochHeader | undefined = from for (const event of events) { if (event.type === 'request/header') { state = canonicalHeader(event.data.header) diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 0c0f705d2b..53cb157214 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -45,7 +45,10 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { * never rewrite it, so the wire bytes cannot silently desync from what the * session log reconstructs. Guards against cycles with a WeakSet: loop-built * requests hold `structuredClone`d JSON-validated session data, but the - * helper accepts arbitrarily constructed values. + * helper accepts arbitrarily constructed values. One exemption: an + * `AbortSignal` is never entered or frozen — it is the request's live + * cancellation channel, and freezing one breaks `AbortController.abort()` + * outright (Node stores the aborted flag as an own property of the signal). * @param value - the value to freeze in place. * @returns the same value, frozen. */ @@ -53,6 +56,7 @@ export function deepFreeze(value: T): T { const seen = new WeakSet() const walk = (node: unknown): void => { if (node === null || typeof node !== 'object') return + if (node instanceof AbortSignal) return if (seen.has(node)) return seen.add(node) Object.freeze(node) diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts index d5648a2262..65ff7d7d34 100644 --- a/packages/llm/llm/tests/call-config.spec.ts +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -33,6 +33,18 @@ describe('deepFreeze', () => { expect(() => { (value.a.b[1] as { c: string }).c = 'y' }).toThrow(TypeError) }) + it('never freezes an AbortSignal: the live cancellation channel keeps working', () => { + const controller = new AbortController() + const request = deepFreeze({ model: 'm', signal: controller.signal }) + expect(Object.isFrozen(request)).toBe(true) + expect(Object.isFrozen(controller.signal)).toBe(false) + let fired = false + controller.signal.addEventListener('abort', () => { fired = true }, { once: true }) + controller.abort('stop') + expect(fired).toBe(true) + expect(controller.signal.aborted).toBe(true) + }) + it('passes primitives through and terminates on cycles', () => { expect(deepFreeze(42)).toBe(42) expect(deepFreeze(null)).toBeNull() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index acb6579272..4a7a417e02 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -83,6 +83,7 @@ const LINK_MAP: Record = { Message: 'core.md', MessageSource: 'core.md', GenerateOptions: 'core.md', + LlmCallConfig: 'core.md', SessionEvent: 'core.md', StreamChunk: 'llm-streaming.md', TurnEndReason: 'session.md',