UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

758 lines (712 loc) 64.1 kB
/** * # session/agent — the Agent seam (the frozen-frame → Actions boundary of Simulate) * * Simulate is a **wake-driven, agent-in-loop** run: between wakes standing Plans fire * autonomously (fire-then-inform); at a Wake the runtime hands the agent a **frozen, * date-blind {@link ActingFrame}** and consumes the {@link AgentTurn} it returns. This module * defines the ONE narrow boundary the runtime invokes the author through — `open(briefing) / * decide(frame) / close(final)` — and the four adapters that satisfy it. The driver that walks * a bus, freezes the vantage, and serializes each Action onto the graded bus * (`runSimulateSession`) is the companion module `./simulate.ts` (next phase); it drives the * SAME {@link import("./sim.ts").SessionCore} the stepped day already drives, so the two share * one event loop and one determinism hash. * * ## Where the determinism boundary sits (the whole point) * The live loop is *deliberately non-deterministic* — judgment is the variable Simulate * measures. Everything ABOVE the boundary (Rendering the Frame, the model call, sampling) sits * OFF the runtime path. The boundary is the **returned {@link AgentTurn}**: exactly one value * crosses, and below it everything is a pure function of Bus bytes (ADR-0011). The driver stamps * every Action at the **injected wake ts** (no wall clock, no unseeded RNG on the record path), * so `project(gradedBus)` re-derives a byte-identical Blotter, and a captured run re-runs as a * Backtest. * * ## Four adapters, one seam, one driver * - {@link liveAgent} — the model; the sole non-deterministic point, above the line. * Declared-only here — its concrete body (provider SDK, credential * injection, Rendering, token/reasoning capture) is the Agent * Harness epic's, and imports no part of the deterministic core. * - {@link recordedAgent} — replays a prior run's captured turns keyed by stable wake identity * ({@link wakeKeyOf}): byte-identity replay, no model call, no Rendering. * - {@link fixedPlanAgent} — arm one document at open, pass forever = a Backtest expressed * through the seam (the case where discretionary and static coincide). * - `fileHandshakeAgent` — the file-handshake protocol (frames/turns as JSON files), now ONE * adapter rather than the boundary. Promoted to a versioned, * identity-stamped seam in `./harness/file-handshake.ts` (kestrel-m9i.7). * * ## Non-negotiables the seam encodes (enforced by the driver next phase) * - **Fail closed.** A parse escape on an authored `supersede` document, or an unparseable whole * turn, → a {@link StandDownAction} turn (de-arm clean, inventory rides its TP/EXIT, logged * reason); the agent can never crash the Session. A single unresolvable/past * {@link ScheduleWakeAction} is the *proportionate* refuse-that-action-and-continue (the book * stays armed on the staleness backstop) — NOT a book-wide stand-down. An empty `actions[]` is a * legitimate pass (standing Plans keep managing). A missing {@link AgentConfig} refuses to grade, * never a silent default. * - **Bounded risk / never naked.** {@link PlaceOrderAction} / {@link CancelOrderAction} route the * SAME Gate as a fired Plan (SELL floored at intrinsic, budget clamp, never naked); price is a * Kestrel expression the engine resolves, never a bare mid; `standDown` never liquidates. * - **Round-trip.** Two substrates, kept apart: the authored `supersede` document round-trips * through Kestrel `parse`/`print` (the `tests/golden/` contract), while the non-surface actions * (`standDown`/`scheduleWake`/`placeOrder`/`cancelOrder`) serialize through the SAME sorted-key / * drop-undefined canonicalizer the Blotter and receipts already use — never Kestrel `print()`. * * This module is an **extraction** from `./day.ts`, not a greenfield: the frozen-at-wake * checkpoint (`events[idx].ts < w.ts`), the date-blind author projection (`projectAuthorFrame`), * and supersede+arm already exist there. Simulate's one behavioral change is that the wake set * stops being a precomputed static list and becomes agent-driven via {@link ScheduleWakeAction}, * still floored by the staleness backstop and coalesced/downgraded past the attention budget. */ import type { WakeDeltaInput, BriefingInput } from "../frame/types.ts"; import type { ViewSelection } from "../frame/pane-catalog.ts"; import type { SeatViewsPolicy } from "../frame/seat.ts"; import type { CorpusTier, Face, TokenAccounting } from "../bus/types.ts"; import { parse } from "../lang/index.ts"; import { standDownTurn as mkStandDownTurn } from "../engine/disarm.ts"; // ───────────────────────────────────────────────────────────────────────────── // The comparison axis — AgentConfig (the seam's runtime view of the config descriptor) // ───────────────────────────────────────────────────────────────────────────── /** * The agent **config** — the comparison axis of Simulate (same input bus, many configs), * stamped onto the graded bus META as a judge self-description (alongside `fill_model`), so a * cell coordinate is a pure read of the record. * * This IS the a57.14 experimental envelope's **input half** — a single definition, never a second one * (the agent-harness ADR (d)). {@link import("./config.ts").canonicalizeConfig} folds it to the canonical descriptor * (`ModelIdentity` ⊕ `SamplingConfig` ⊕ `RenderingIdentity` ⊕ `AuthorPolicy` → `ConfigId = * sha256(canonical)`, the grid column key), and {@link import("./config.ts").envelopeForConfig} maps it to * the {@link import("../bus/types.ts").ExperimentalEnvelope} the Simulate driver stamps — the same value in * two shapes, never two truths (ADR-0011). * * The first six fields are the mechanical core every run carries (a NO-LLM Backtest carries only these — * {@link BACKTEST_CONFIG}). The envelope-identity fields below are OPTIONAL: a config that declares any of * them is an authoring run whose envelope is stamped; a Backtest declares none, so no envelope is stamped and * the Blotter stays `certified` (fail-closed by omission — never a fabricated identity, a57.14). */ export interface AgentConfig { /** The pinned model identity (family/snapshot). Credentials are NEVER part of the config. */ readonly model: string; /** The Rendering tokenizer identity — the tokenizer token costs are measured under. */ readonly tokenizer: string; /** The Rendering format (the Frame is rendered to tokens INSIDE the live adapter). */ readonly format: "ascii" | "unicode" | "md" | "json" | "html"; readonly temperature: number; readonly thinkingLevel: "none" | "low" | "medium" | "high" | "max"; /** The leaderboard / `BY` key (names are data, ADR-0006). */ readonly label: string; /** SamplingConfig — the **fill-realization seed** for the sampled (seeded-Bernoulli) fill channel * (ADR-0016 / kestrel-9gu.8.1). Present ⇒ the SimFillEngine draws causal fills off a `runSeed`-keyed * mulberry32 substream (`hash(runSeed, model.version, order.ref, episodeIndex)`); **absent ⇒ the sampled * channel is OFF** (fail-closed — the run grades floor+expected only, no realized draws). It folds into * the {@link import("./config.ts").deriveConfigId ConfigId} (a sampling knob, like `temperature`) but is * NOT an envelope-identity field, so two runs differing only in `fillSeed` are REPLICATES under one * {@link import("./run-identity.ts").CellKey CellKey} (same tape × fill_model × envelope) yet each yields a * distinct {@link import("./run-identity.ts").SimRunId SimRunId} — the ensemble (`baseSeed + i`). */ readonly fillSeed?: number; // ── The a57.14 envelope input half (ModelIdentity ⊕ RenderingIdentity ⊕ AuthorPolicy ⊕ injected // accounting). OPTIONAL — a NO-LLM Backtest carries none ⇒ no envelope ⇒ stays certified. ─────── /** ModelIdentity — the model provider/family (a generic vendor label; NEVER a credential). */ readonly provider?: string; /** ModelIdentity — the model version/snapshot token. */ readonly version?: string; /** ModelIdentity — the model's training cutoff (the basis for model-relative holdback gating). */ readonly trainingCutoff?: string; /** RenderingIdentity — how the Frame was rendered to tokens (distinct from `format`, which rides the ConfigId). */ readonly renderingIdentity?: string; /** AuthorPolicy — the sha256 HASH of the author prompt/profile (a HASH, NEVER the prompt content). */ readonly promptHash?: string; /** AuthorPolicy — the sha256 HASH of the author's BRIEF (ADR-0026 — the soft, directional English * persona/goal). A HASH, sibling to {@link promptHash}, NEVER the brief text: it binds "this * performance came from this thesis" into the run/grade provenance (folds into the ConfigId / * grid column, so a rebrief mints a new column). The Brief itself is rendered into the frame from * the cell/kernel and can NEVER enter admission — only the mandate/envelope do (the hard guard). */ readonly briefHash?: string; /** AuthorPolicy — the tool policy the author ran under (e.g. `no-tools`). */ readonly toolPolicy?: string; /** AuthorPolicy — what woke the author (e.g. `structural-cadence`). */ readonly wakeSource?: string; /** AuthorPolicy — the corpus tier the author draws on — the CLOSED {@link CorpusTier} vocabulary * (`public`|`semi-private`|`private`, ADR-0018 three tiers), the practice-vs-holdback wall (kestrel-m9i.26). * Closed at capture (mirror {@link face}); the projector re-checks the injected bytes fail-closed. */ readonly corpusTier?: CorpusTier; /** The interface {@link Face} the author reaches the model through (`http`|`sdk`|`cli`|`mcp`) — the * m9i.7 tournament axis (m9i.2 owner tweak). Declared by the CALLER (who knows the transport); a * harness never fabricates it — an undeclared face fails the envelope closed to PROVISIONAL. */ readonly face?: Face; /** The adapter/harness identity that drives the author (m9i.2 owner tweak — mandatory-for-certified * BEFORE the m9i.7 harness tournament). `liveAgent` stamps its own identity when the caller leaves it * unset (the harness KNOWS itself — never a fabricated third-party identity). */ readonly adapter?: string; /** The adapter/harness version/revision token (paired with `adapter`). */ readonly adapterVersion?: string; /** The EXTERNALLY-DECLARED brain identity the shared `adapter` (e.g. `file-handshake`) drives — the * m9i.7 harness-tournament pivot: `claude-code/2.x` | `codex/x` | `opencode/x` | `first-party-liveagent` * (kestrel-m9i.7 stage-0). CFG-only, NOT an a57.14 envelope field (the certified envelope stays exactly * 14 fields), yet it folds into {@link import("./config.ts").deriveConfigId ConfigId} — which the grid * CellKey keys on — so two runs differing only in `harness` are DISTINCT grid columns. Declared by the * CALLER (the harness names ITSELF); a shared adapter never fabricates it. */ readonly harness?: string; /** The `harness` version/revision token (paired with `harness`) — folds into the ConfigId alongside it. */ readonly harnessVersion?: string; /** The prompt-cache / conversation-reuse policy the live harness ran under (kestrel-rul; System Profile * v1.1 `cache policy` slot). A **ConfigId axis only** (like `format`/`temperature`), NOT an envelope-identity * field: caching is transport-side and OFF the graded path, so two runs differing only in `cachePolicy` * grade byte-identically yet land in DISTINCT grid columns — exactly the A/B m9i.5 sweeps (append-only KV * reuse vs stateless redraw, cache on/off). `liveAgent` stamps the policy it actually ran under when the * caller pins none (the harness knows its own transport). See {@link CachePolicy}. */ readonly cachePolicy?: CachePolicy; /** The provider prompt-cache TTL the `conversation-cached` policy requests on its breakpoints (kestrel-wa0j.1). * ABSENT ⇒ the provider default (Anthropic 5-minute ephemeral / Bedrock default cachePoint) — the request * bytes and ConfigId are byte-identical to before this axis existed. `"1h"` opts a live-paced session (wakes * > 5 min apart) into the 1-hour TTL, so the stable prefix survives between wakes instead of expiring and * re-writing at the cache-write premium every wake. Like {@link cachePolicy}, a **ConfigId axis only** — * never an envelope field — so a changed TTL mints a distinct grid column. Fail-closed: a `cacheTtl` on a * config whose policy requests no cache breakpoints (anything but `conversation-cached`), or on a provider * lane with no explicit breakpoint primitive, throws rather than riding silently inert. */ readonly cacheTtl?: CacheTtl; /** The INJECTED token accounting (off the deterministic record path — the owner-away default). */ readonly tokenAccounting?: TokenAccounting; /** The **clock-honest session-semantics switch** (ADR-0040 / kestrel-w7la.1, clock-honest wakes §4): * `true` ⇒ deliberation consumes tape time — a Seat's turn is measured at the driver's injectable * `now` seam, buffered by {@link latencyBufferMs}, recorded on the Bus as a `deliberation` record, * and its control lands at `returnTs = vantageTs + measured + buffer`. Absent ⇒ latency-blind, * byte-identical driver behavior to today. A ConfigId axis (it folds into `deriveConfigId` * automatically), never an envelope-identity field — a clocked run can NEVER share a grid column * with an unclocked one. When the ADR-0032 cascade lands this stays session-scoped (all seats or * none); {@link latencyBufferMs} becomes per-seat. */ readonly clockHonest?: true; /** The configured network/infra latency allowance added to EVERY measured Seat turn under * {@link clockHonest} (clock-honest wakes §4). Integer milliseconds. **No default (decided)**: a * clock-honest session with this absent (or non-integer, or negative) is REFUSED loudly at open — * "never a silent default", and any designed default would be a judge-designed weighting (ADR-0030 * spirit: the clock is measured, not designed). `0` is legal and visible: it declares a colocated * seat in the column identity. Governed seasons pin the production value platform-side * (PLAT-ADR-0033). A ConfigId axis like {@link clockHonest}. */ readonly latencyBufferMs?: number; /** The **founder-View opt-in** (ADR-0041 §2 / A1, kestrel-wa0j.26). `"founder"` ⇒ the driver passes * the acting tier's pod SEAT into the render's `resolveView` (strategist at OPEN, watcher at wakes), * so a seat with a founder seed reads its GRADED founder View when it authored none — a distinct * experimental subject. Founder Views are SEEDS, never blessed defaults, so this is OFF by default: * absent (or `"phase-default"`) ⇒ no seat is consulted ⇒ the frame-kind default panes, byte-identical * to before this axis existed. A ConfigId axis (it folds into {@link import("./config.ts").deriveConfigId * deriveConfigId} automatically, like {@link clockHonest}/`cachePolicy`), NEVER an a57.14 envelope * field — so a founder-View run mints a DISTINCT grid column while the certified envelope stays exactly * 14 fields. Absent ⇒ every existing ConfigId is byte-identical (drop-undefined canonicalizer). */ readonly seatViews?: SeatViewsPolicy; } /** * How the live harness reuses a session's conversation across wakes — the prompt-cache economics lever * (kestrel-rul, the CONTEXT "Streaming Rendering (cache-monotone)" doctrine). A pure {@link AgentConfig} * axis: it selects transport behaviour ABOVE the determinism line and never changes the returned * {@link AgentTurn}, so a recorded run replays byte-identically regardless of the policy it ran under. * * - `stateless-redraw` — v0: each wake re-renders its frame as ONE fresh user message with no prior turns * in context and no cache markers (the model has no memory of earlier wakes). The redraw arm for m9i.5. * - `conversation` — the session is a GROWING message list (byte-stable system + briefing + each wake's * appended delta frame + the model's prior replies), so the model sees the whole session, but no provider * prompt-cache control is requested. * - `conversation-cached` — `conversation` PLUS a provider prompt-cache breakpoint on the byte-stable * prefix (Anthropic `cache_control` / Bedrock cache-point via the AI SDK), so the stable prefix is billed * as a cache-read (~0.1×) instead of full-price on every wake. The default the live harness runs under. */ export type CachePolicy = "stateless-redraw" | "conversation" | "conversation-cached"; /** * The provider prompt-cache TTL requested on every breakpoint the `conversation-cached` policy marks * (kestrel-wa0j.1). The values are the two TTLs the Anthropic-family cache actually serves (`cache_control * ttl` on the direct API, `cachePoint.ttl` on Bedrock — verified against the installed AI-SDK providers). * The economics: a 5-minute entry costs 1.25× to write; a 1-hour entry costs 2× to write but survives a * live-paced session's inter-wake gaps — under the default 5m TTL, wakes > 5 min apart MISS the expired * cache and re-write the whole prefix at 1.25× every wake, making `conversation-cached` the most expensive * policy exactly where it should be the cheapest. Absent ({@link AgentConfig.cacheTtl} undefined) ⇒ the * provider default rides the wire — byte-identical to before this axis existed. */ export type CacheTtl = "5m" | "1h"; /** * The ONE shared refusal text for a configured {@link CacheTtl} that cannot reach the wire * (kestrel-wa0j.1). THREE independent guards refuse for three different reasons — the live-agent * policy gate (a {@link CachePolicy} that marks no breakpoints), the AI-SDK breakpoint builder (a * provider lane with no breakpoint primitive), and the codex-cli lane (no cache-control flag at * all) — but the doctrine is one: a ConfigId column must never claim a cache TTL the provider * never saw (a configured knob silently inert is exactly what fail-closed forbids). Each guard * supplies its own `blocker` (why THIS layer cannot carry the TTL) and, optionally, a `remedy`. */ export function cacheTtlNeverReachesWire(ttl: CacheTtl, blocker: string, remedy?: string): string { return ( `cacheTtl "${ttl}" is configured but ${blocker} — refusing to run with a cache TTL that ` + `never reaches the wire (fail-closed, kestrel-wa0j.1).${remedy === undefined ? "" : ` ${remedy}`}` ); } /** * Does `provider` expose an explicit per-request prompt-cache breakpoint primitive a {@link CacheTtl} * can ride (kestrel-wa0j.19 §4)? TRUE only for the Anthropic-family lanes — `anthropic` (`cacheControl`) * and `bedrock` (`cachePoint`) — the two the AI-SDK wire guard ({@link import("./harness/ai-sdk-client.ts").cacheBreakpoint}) * can attach a `ttl` to. Every other lane (Gemini/OpenAI-compatible/gateway/local/codex-cli/…) caches * IMPLICITLY with no breakpoint to carry a TTL, so a configured `cacheTtl` there would never reach the * wire. PROVIDER-FREE (a pure string check, imports no provider SDK), so the deterministic core — and * {@link import("./harness/live-agent.ts").liveAgent}'s CONSTRUCTION gate — can refuse a misconfigured * `cacheTtl × provider` column BEFORE a session of provider-error passes burns, while the wire guard * stays as defense in depth. A blank/unknown provider is NOT positively refused here (the caller may * pin none — a mock/test); the wire guard is the backstop for what reaches an actual provider. */ export function providerSupportsCacheTtl(provider: string): boolean { return provider === "anthropic" || provider === "bedrock"; } // ───────────────────────────────────────────────────────────────────────────── // The acting-kernel strip layered onto the delta Frame (all date-blind) // // These lead-block fields extend the existing delta-frame input with the wake's own oversight // context. Simulate CONSUMES the acting kernel; it does not fork it. When the richer Frame-of- // Fields kernel lands, each scalar becomes an attributed, watermarked Field — this seam is the // interim, date-blind shape the driver populates today. // ───────────────────────────────────────────────────────────────────────────── /** How urgent this wake is (authored PRIORITY for author wakes; a platform default otherwise). */ export type WakeSeverity = "routine" | "elevated" | "urgent"; /** WHICH frontier raised a wake — the driver's own provenance for a delivery, and the stable half of the * {@link WakeKey} replay identity. Lives on the seam (not in the driver) because it crosses to the agent * on every {@link ActingFrame}: `agent` (the author's own {@link ScheduleWakeAction}), `seed` (an authored * fixture vantage), `own-fill` (the seeded run's fire-then-inform management wake, ADR-0016), `structural` * (the cadence), `staleness` (the platform backstop), `latency-fold` (the clock-honest catch-up delivery — * ONE vantage at the Seat's return time carrying every wake that fell inside its open deliberation window, * ADR-0040 / clock-honest wakes §2.3; CONTEXT "Latency-fold"). */ export type WakeSource = "agent" | "seed" | "own-fill" | "structural" | "staleness" | "latency-fold"; /** Per-signal data health at the wake vantage — date-blind (a clock, never a date). `stale` / * `unavailable` name the instruments/capabilities the runtime could not resolve, so the agent * always sees what is UNKNOWN rather than a guessed default (fail-closed). */ export interface DataHealth { /** The `HHMM` ET clock this health snapshot was taken at (dateless). */ readonly asOfClockET: string; /** Instruments whose canonical series is stale this vantage. */ readonly stale: readonly string[]; /** Capabilities currently UNKNOWN (a de-armed pane / series), each with its reason upstream. */ readonly unavailable: readonly string[]; } /** The sim-time horizon around the wake — NOT a think budget (phase 1 is frozen-at-wake, so * fast and slow models decide on byte-identical Frames). Any horizon may be `null` ⇒ `—`. */ export interface WakeDeadline { readonly minutesToClose: number | null; readonly minutesToNextWake: number | null; } /** * The acting Frame handed to the agent at a Wake — **identical bytes for every Agent** (frozen- * at-wake isolates judgment). A superset of the existing {@link WakeDeltaInput} (tape/levels/ * kernel since the last vantage) plus the acting-kernel strip. All fields are date-blind: relative * time and the `HHMM` ET clock only (the contamination fence). */ export interface ActingFrame extends WakeDeltaInput { /** The 0-based GLOBAL wake ordinal — this delivery's position in the session's wake sequence (distinct * from the inherited `wakeIndex` display field). A display/log coordinate, NOT the replay key: it is * unstable under vantage insertion (see {@link wakeKeyOf}). */ readonly wakeOrdinal: number; /** WHICH frontier raised this delivery — the stable half of the {@link WakeKey} replay identity * (kestrel-5zl.19). */ readonly wakeSource: WakeSource; /** The 0-based MONOTONE PER-SOURCE ordinal — this wake's position among the wakes THIS source RAISED, * minted at the raise (kestrel-5zl.20). Unlike {@link wakeOrdinal} it does not move when a *different* * source inserts a vantage, NOR when an earlier delivery is folded (spacing/budget/own-fill/probe). */ readonly wakeSourceOrdinal: number; /** The relative-day coordinate `d{k}` (kestrel-5zl.16.4): this session's ordinal in its Instance, * pinning no calendar date. Present ONLY for a multi-session run; absent (and unserialized) for a * standalone session — so existing single-session acting-frame goldens are byte-identical. Paired with * the per-session `sinceOpenMs`/`clockET` (both reset at each session's open), it is how the agent tells * the days of a horizon apart without any calendar identity reaching the frame. */ readonly sessionOrdinal?: number; readonly severity: WakeSeverity; readonly deadline: WakeDeadline; readonly dataHealth: DataHealth; /** The View delivered WITH this wake (kestrel-wa0j.4): the scheduled wake's stored View * ({@link ScheduleWakeAction.view}), resolved fail-closed at schedule time — the wake decides * *when*, the View decides *what* (CONTEXT "Wake delivery"). Absent ⇒ the frame-kind default * WAKE panes, byte-identical to a view-less delivery. The kernel stays OUTSIDE this selection, * and the View budget guard applies at materialization exactly as on OPEN. */ readonly view?: ViewSelection; /** Attention accounting — the squelch stays visible, never silently dropped. */ readonly attention: { /** Wakes remaining under the attention budget; `null` ⇒ unbounded/unknown. */ readonly wakesRemaining: number | null; /** Same-key wakes folded into this delivery (coalesced past the budget). */ readonly coalesced: number; }; } // ───────────────────────────────────────────────────────────────────────────── // The Action union — what an agent may DO at a wake // ───────────────────────────────────────────────────────────────────────────── /** A dateless sim-time instant the agent schedules its next Wake for. An unresolvable/past value * is refused-and-logged (the book rides the staleness backstop), never a book-wide stand-down. */ export type WakeAt = | { readonly kind: "inMinutes"; readonly minutes: number } | { readonly kind: "atClockET"; readonly clockET: string }; /** A single-leg order intent. `price` is a **Kestrel expression** the engine resolves and gates * exactly like a fired Plan's price (never a bare mid; SELL floored at intrinsic). * * `strike`+`right` follow the ADR-0017 leg convention: BOTH present ⇒ an OPTION order (option-chain * fill path); BOTH absent ⇒ an EQUITY/SPOT order (spot-fill path). Exactly one present is a defect the * parser fails closed on — never a silent equity fallback. `qty` is contracts for an option, shares for * equity. */ export interface OrderIntent { readonly side: "buy" | "sell"; readonly instrument: string; /** Present TOGETHER with `right` for an option; BOTH absent for an equity/spot order (ADR-0017). */ readonly strike?: number; readonly right?: "C" | "P"; readonly qty: number; /** A Kestrel price expression (e.g. `@fair`, `cap fair,0.80`) — resolved + gated, never a raw mid. */ readonly price: string; } /** Author or revise the standing document. The first turn's supersede is the initial arm. A parse * escape on `document` → a {@link StandDownAction} turn (fail-closed). Only the embedded `document` * is a Kestrel surface statement (`print(parse(document))` round-trips). */ export interface SupersedeAction { readonly kind: "supersede"; /** Kestrel source (a View/Wake/Plan document — decision, record, and executable in one object). */ readonly document: string; readonly note?: string; } /** Self-defined cadence atop the platform staleness backstop + optional structural cadence. */ export interface ScheduleWakeAction { readonly kind: "scheduleWake"; readonly at: WakeAt; readonly reason: string; /** An optional View selection to deliver at the scheduled wake. */ readonly view?: string; } /** Place a single-leg order — routed through the SAME Gate as a fired Plan (never naked). */ export interface PlaceOrderAction { readonly kind: "placeOrder"; readonly order: OrderIntent; readonly note?: string; } /** Cancel a resting order by `ref` (from the Frame kernel's resting list). An unknown ref is * refused-and-logged (fail-closed), not a stand-down. */ export interface CancelOrderAction { readonly kind: "cancelOrder"; readonly ref: string; readonly note?: string; } /** De-arm clean — inventory rides its TP/EXIT. The fail-closed default (never liquidates). */ export interface StandDownAction { readonly kind: "standDown"; readonly reason: string; } /** FLATTEN a held position (bd if0 / 75n): the first-class "get me out now". For every managing plan * holding `instrument`, the engine cancels its resting orders (neutralizing the rolling TP re-post that * defeats a bare `cancelOrder`) AND crosses a COVERED close through the SAME admission Gate a fired Plan * uses — so never-naked still holds (a flatten of a HELD long is a covered sell; SELL floored at * intrinsic) and it can NEVER create a naked position. After a flatten the position is flat and its R is * freed (bd 1a8). A flatten of a flat book is a clean no-op. Unlike `standDown` (de-arm, inventory RIDES), * `flatten` actually LIQUIDATES the named position. */ export interface FlattenAction { readonly kind: "flatten"; readonly instrument: string; readonly note?: string; } /** What the agent may DO at a wake. Each serializes to a CONTROL/ORDER/JOURNAL bus event; the * order actions route via the Gate and land as ORDER events (bounded-risk enforced there). */ export type Action = | SupersedeAction | ScheduleWakeAction | PlaceOrderAction | CancelOrderAction | FlattenAction | StandDownAction; /** One turn = everything decided at one wake. `journal`, when present, becomes a **pre-hoc** * JOURNAL `author` event (provably pre-hoc by `seq` — written BEFORE arming). An empty `actions[]` * is a legitimate pass (standing Plans keep managing). */ export interface AgentTurn { readonly actions: readonly Action[]; readonly journal?: string; } // ───────────────────────────────────────────────────────────────────────────── // The bounded authoring loop (ADR-0029) — the requestView outcome + the in-window re-entry // ───────────────────────────────────────────────────────────────────────────── /** * The **view-request outcome** (ADR-0029 §1, Option C) — a DISTINCT top-level authoring reply, NOT * an {@link Action}: it is the only agent output that lives ABOVE the determinism line and NEVER * becomes a Bus event (folding it into the {@link Action} union would break "every Action is a Bus * event"). Its `view` payload is a Kestrel VIEW document (validated to a SINGLE `View` statement, so * `print(parse(view))` round-trips and the panes come from the one catalog), and `reason` is the * agent's stated why — the emergence-log signal (§5). Honored ONLY under a viewshop-enabled config; * under the baseline an emitted `requestView` is an unknown/illegal outcome (fail-closed). */ export interface RequestView { /** A Kestrel VIEW document — exactly one `VIEW` statement selecting panes from the one catalog. */ readonly view: string; /** Why this lens, now (against the Brief) — logged as evidence, never a graded input. */ readonly reason: string; } /** * The classified result of ONE authoring-window model call (ADR-0029 §1/§2). The bounded OPEN loop * branches on the three variants; two of them are NON-terminal (they consume the shared dual budget * and the driver re-asks): * - `turn` — a valid TERMINAL {@link AgentTurn} (`supersede`/`standDown`/empty pass); * - `requestView` — a well-formed, non-terminal lens request; the driver materializes it at the SAME * frozen cutoff and re-asks (the shopping half of the loop); * - `invalid` — a repairable defect; the driver surfaces `reason` (the repair-guiding error) and * re-asks the model to fix and resubmit (the repair half — ADR-0029's approved * sibling, unified here). `reason` becomes the terminal fail-closed pass ONLY once * the budget is spent. */ export type AuthoringReply = | { readonly kind: "turn"; readonly turn: AgentTurn } | { readonly kind: "requestView"; readonly request: RequestView } | { readonly kind: "invalid"; readonly reason: string }; /** How the driver drives one in-window authoring re-entry ({@link Agent.authoringOpen}). Exactly one * of these framings applies per call: materialize the briefing under `view` (a lens), OR — when * `repairError` is set — re-ask the model to fix its last invalid author (a repair). The very first * call carries neither (the incumbent default View, the baseline ask). */ export interface AuthoringOpenOptions { /** Materialize the OPEN briefing under this lens (a requested View); absent ⇒ the incumbent default * View. The SAME frozen briefing snapshot / cutoff — a lens, never a look-ahead (ADR-0029 §3). */ readonly view?: ViewSelection; /** A repair re-ask: surface this repair-guiding error to the model (do NOT re-render a frame) and * ask it to fix and resubmit. Mutually exclusive with a fresh `view` materialization in practice. */ readonly repairError?: string; } /** One in-window authoring call's result: the classified {@link AuthoringReply} plus the model spend. * `usage` (input + output + thinking, incl. cache splits) is summed by the driver into the * `authoringTokenBudget` AND folds into cumulative session spend — so a model that needs many * iterations scores lower on the `ev_per_ktoken` attention axis (ADR-0029 §7). `latencyMs` is audit * evidence only, off the graded path. */ export interface AuthoringStep { readonly reply: AuthoringReply; readonly usage: LlmUsage; readonly latencyMs?: number; } // ───────────────────────────────────────────────────────────────────────────── // The WakeHandler seam // ───────────────────────────────────────────────────────────────────────────── /** * THE WAKEHANDLER SEAM (CONTEXT.md "WakeHandler"). The adapter boundary between the ONE session * driver ({@link import("./simulate.ts").runSimulateSession}) and its author: at each wake the driver * hands the adapter the delivered Frame ({@link BriefingInput} at OPEN, {@link ActingFrame} at every * wake) and the adapter returns the author's response — new/superseding statements (an * {@link AgentTurn}), or nothing (an empty pass). Stateful across ONE Session (the live adapter owns * its conversation / KV cache; delta Frames arrive append-only, ADR-0008) but it never touches the * clock, the bus, or the market. * * **The adapter is the ONLY place wall time may exist** (an external author deliberating); **the * driver owns tape-time accounting** (the Deliberation record, the Latency-fold — ADR-0040) * UNIFORMLY, ABOVE this seam. That accounting must NOT live per-adapter: it is minted once at the * driver's single `now` seam ({@link import("./simulate.ts").runSimulateSession}'s `clockedTurn`), * so day-compat and in-process and (later) the platform DO adapter can never drift a second clock. * A response that tries to carry driver-owned tape-time back through this seam is REFUSED fail-closed * ({@link assertHandlerResponseTimeless}) — the response channel is authored statements only. * * **The wake boundary is CHECKPOINT-CAPABLE.** Driver state is serializable while a response is * pending, so the platform `SimulationSessionDO` can become a fourth WakeHandler adapter in a later, * separate slice (kestrel-3wik notes this; it is deliberately NOT implemented here). The * checkpointable driver state at the seam is exactly: the pending wake frontier + its cursor * (`frontier`, `idx`, `prevVantageTs`, `prevWakeSeq`), the delivery accounting (`delivered`, * `coalesced`, `sourceOrdinals`, `pendingCatchUp`, `fillWatermark`, `deliveredWakeTs`), the armed * standing state (`armed`, `authoredWakesPerDay`), and the {@link SessionCore} — every one a pure * value/offset off the injected `firstTs`/`session_date` (no wall clock, no RNG), so a resumed * driver re-projects byte-identically. * * The four adapters that satisfy this seam today — {@link liveAgent} (in-process), {@link recordedAgent} * (replay), {@link fixedPlanAgent} (autonomous no-op / backtest), and the day file-handshake adapter — * are the three WakeHandler modes of the decided design (arch-review B5): the autonomous no-op, the * file-handshake external author, and the in-process Agent, over the ONE shared driver. * * `decide` is `async` because the live loop is deliberately non-deterministic; the recorded and * fixed adapters resolve synchronously. */ export interface WakeHandler { readonly config: AgentConfig; /** Open the Session on the OPEN keyframe: arm the initial document, or stand down. */ open(briefing: BriefingInput): Promise<AgentTurn> | AgentTurn; /** Decide at one Wake on the frozen {@link ActingFrame}. */ decide(frame: ActingFrame): Promise<AgentTurn> | AgentTurn; /** Optional end-of-horizon debrief at settle — a post-hoc JOURNAL `debrief`. (Exercising a * close *decision* turn that routes end-of-horizon actions through the Gate is a next-phase * refinement; this seam keeps close to the debrief note for now.) */ close?(final: ActingFrame): Promise<string | void> | string | void; /** * The bounded OPEN authoring loop's in-window re-entry (ADR-0029 §2/§6). Present ONLY on a * **viewshop-enabled** adapter — its presence IS the config gate: when the driver sees it, it runs * the bounded loop through this instead of {@link open}; when it is absent (baseline, recorded, * fixed-plan) the driver runs the single-shot {@link open} and the view-request move does not exist. * * Each call is ONE model call at the SAME frozen vantage: it materializes the briefing under * `opts.view` (or the incumbent default View), or — when `opts.repairError` is set — re-asks the * model to fix its last invalid author, and returns the classified {@link AuthoringStep}. The adapter * reuses its growing conversation across calls, so the model shops/repairs WITH memory. Only the * driver-selected TERMINAL turn ever enters {@link CapturedTurns}, so {@link recordedAgent} (which * never implements this) replays byte-identically with zero new graded-path machinery (§5). */ authoringOpen?(briefing: BriefingInput, opts: AuthoringOpenOptions): Promise<AuthoringStep>; /** * The REPLAY-mode deliberation-cost source (ADR-0040 / clock-honest wakes §7). Present ONLY on a * replay adapter ({@link recordedAgent}); its very presence tells the driver this run's cost source * is the capture, so the driver NEVER touches its injectable clock (replay never re-times — the * poisoned-clock fixture proves it). Three answers, each pinned: * - a number — the recorded `measuredMs` of the Seat-answered turn at `key` (buffered + recorded * exactly like a live measurement); * - `null` — the capture holds NO turn at `key`: the replay machinery SYNTHESIZED one (a * divergence stand-down / inserted-vantage pass). Engine-minted, not Seat deliberation — it * costs 0, takes no buffer, and records nothing (charging tape for a turn no Seat took would * fabricate cost); * - `undefined` — the capture holds a turn at `key` but no cost: a COSTLESS (latency-blind / * legacy) capture. Under `clockHonest: true` the driver refuses loudly at open — a costless * capture replays only latency-blind (never a silent downgrade). */ recordedCostMs?(key: WakeKey): number | null | undefined; } /** * The historical name for the {@link WakeHandler} seam — retained as an alias so every existing * adapter (`recordedAgent`/`fixedPlanAgent`/`liveAgent`/the day file agent) and their consumers keep * their `Agent` type annotations while the canonical CONTEXT.md vocabulary (`WakeHandler`) lands * (kestrel-3wik). A follow-up bead does the flat call-site rename; nothing behavioural rides on the * name, so the alias keeps this slice byte-identical. */ export type Agent = WakeHandler; /** * The tape-time fields the DRIVER alone stamps on the Bus (ADR-0040 / clock-honest wakes §§1, 7) — * `measured_ms`/`buffer_ms`/`wake_seq` on the `deliberation` record, the derived `returnTs`, the * `CapturedTurn.measuredMs` the driver captures. A {@link WakeHandler} response ({@link AgentTurn}) * carries the author's STATEMENTS only; it must NEVER carry any of these back through the seam — that * would be an adapter OWNING tape-time accounting, the exact per-adapter clock drift the one-driver * seam exists to kill. Both the snake_case Bus field and its camelCase driver-local sibling are * fenced, so neither spelling can leak. */ const RESERVED_TAPE_TIME_FIELDS: readonly string[] = [ "measuredMs", "measured_ms", "bufferMs", "buffer_ms", "costMs", "cost_ms", "returnTs", "return_ts", "wakeSeq", "wake_seq", "deliberation", ]; /** * Fail-closed fence over a {@link WakeHandler} response at the driver's consult seam (kestrel-3wik) — * the belt that mechanically enforces "the driver owns tape-time accounting UNIFORMLY, above the * seam; it must not live per-adapter" (CONTEXT.md "WakeHandler"). Mirrors {@link assertFrameDateBlind} * (the date-blind fence at the acting-Frame boundary): a pure inspection that is a NO-OP on a * conformant turn (`{ actions, journal? }`) and THROWS on any response smuggling a driver-owned * tape-time field back through the seam. Wired through the REAL driver at every consult site (OPEN + * every wake, latency-blind AND clocked), so the guard is proven WIRED by a fixture that goes red * without it — never a green unit test of an inert function (AGENTS.md non-negotiable). */ export function assertHandlerResponseTimeless(turn: AgentTurn): AgentTurn { for (const field of RESERVED_TAPE_TIME_FIELDS) { if (Object.prototype.hasOwnProperty.call(turn, field)) { throw new Error( `WakeHandler response carries driver-owned tape-time field ${JSON.stringify(field)} — the adapter is the only place wall time may EXIST, but tape-time accounting (the deliberation record, the latency-fold, ADR-0040) is the DRIVER's, minted once above the seam; a response owning it would drift a second per-adapter clock (refused fail-closed, CONTEXT.md "WakeHandler")`, ); } } return turn; } /** * The **stable wake identity** one captured turn is keyed by (kestrel-5zl.19) — `<source>#<k>#<trigger>`: * the wake's {@link WakeSource}, its MONOTONE PER-SOURCE ordinal, and its trigger (the semantic * `wakeReason`). Never the global wake ordinal: the ordinal is a function of the WHOLE frontier, so any * source that inserts a vantage the recording never held (the seeded run's `own-fill` management wake, * ADR-0016 / kestrel-9gu.8.3) shifts every later ordinal and silently re-joins the recorded stream one * wake off. A per-source ordinal is NARROWER: another source raising a vantage does not, by itself, move * it. Carrying the trigger too makes a genuine divergence MISS (fail closed) rather than misattribute a * decision the agent made at some other vantage. * * `k` is minted where the wake is RAISED (kestrel-5zl.20) — on the `FrontierWake` at `pushFrontier` in * `./simulate.ts` — and carried through to the {@link ActingFrame}, so it counts RAISES of a source, NOT * deliveries. This closes the residual 5zl.19 only narrowed: delivery is a decision of the WHOLE frontier * (an inserted `own-fill` delivery advances `prevVantageTs` and the global delivered-budget counter, so it * can FOLD — by `minWakeSpacingMin` / `effectiveMaxWakes` — a wake the recording DID deliver), but the * folded wake already consumed its `k` at the raise, so the next same-source wake keeps its OWN raise * ordinal instead of inheriting the folded wake's. The key therefore no longer depends on whether a * delivery survived folding: a survivor never collides with a folded wake's recorded key, and the silent * misjoin that class produced is eliminated (not merely narrowed). */ export type WakeKey = string; /** * One captured turn PLUS its deliberation cost (ADR-0040 / kestrel-w7la.1, clock-honest wakes §7): * ADR-0012 §4's "one value crosses the determinism line: the {@link AgentTurn}" becomes "the * `AgentTurn` plus its deliberation cost". `measuredMs` is minted ONCE, live-side, at the driver's * injectable `now` seam — integer ms of the Seat's WHOLE awaited turn (bounded authoring loop and * repair retries included); a synchronous adapter legitimately measures 0. * * `measuredMs` is ABSENT exactly when the run never measured — a latency-blind session, or a legacy / * externally-captured (run-dir) recording. Such a **costless** capture replays ONLY under * latency-blind semantics: replaying it with `clockHonest: true` is REFUSED loudly at open (a silent * cost-0 stand-in would let a latency-blind record claim a clock-honest result — the exact silent * downgrade §7 forbids). Absent-vs-0 is load-bearing: `0` is a measured colocated turn; absent is * "never measured". */ export interface CapturedTurn { readonly turn: AgentTurn; /** Measured wall ms of the Seat's whole turn at this vantage (integer ≥ 0). Absent ⇒ a costless * (latency-blind / legacy) capture — see above. */ readonly measuredMs?: number; } /** * Captured turns keyed by stable {@link WakeKey} — the deterministic replay input. NOT stored as Kestrel * `print()` text: only an embedded `supersede` document is a Kestrel surface statement, so the * replay substrate serializes each Action's control/order envelope through the SAME sorted-key / * drop-undefined canonicalizer the Blotter and receipts use, while the embedded document keeps its * Kestrel `parse`/`print` round-trip. Replaying these re-emits identical Bus events at identical * wake ts (byte-identity, not re-computation). Each entry carries the turn AND — on a clocked run — * its recorded deliberation cost ({@link CapturedTurn}), so a clock-honest replay re-projects from * recorded time and never re-times (ADR-0040 §7). */ export type CapturedTurns = ReadonlyMap<WakeKey, CapturedTurn>; /** The {@link CapturedTurns} replay key for the OPEN turn (`agent.open`) — the one delivery that is not a * wake, so it carries no {@link WakeSource} and can never collide with a {@link wakeKeyOf} key. */ export const OPEN_WAKE_KEY: WakeKey = "open"; /** The OPEN keyframe's wake ordinal (`n = -1`, the stepped runner's convention) — a LOG/trace coordinate * (the authoring trace's `ordinal`), no longer a {@link CapturedTurns} key. Replay keys on * {@link OPEN_WAKE_KEY} / {@link wakeKeyOf}. */ export const OPEN_ORDINAL = -1; /** Derive a delivery's stable {@link WakeKey} — the ONE join both the Simulate driver (capture) and * {@link recordedAgent} (replay) key on, so a recorded turn and its wake can never drift apart. */ export function wakeKeyOf(frame: ActingFrame): WakeKey { return `${frame.wakeSource}#${frame.wakeSourceOrdinal}#${frame.wakeReason ?? ""}`; } /** The {@link AgentConfig} a NO-LLM deterministic adapter (recorded / fixed-plan) advertises: a Backtest, * not a model call. Names are data (ADR-0006) — `label` is the leaderboard key. Credentials are NEVER * config; a fixed-plan run pins no tokenizer/format because it renders nothing (frozen-at-wake). */ export const BACKTEST_CONFIG: AgentConfig = { model: "fixed-plan", tokenizer: "none", format: "json", temperature: 0, thinkingLevel: "none", label: "fixed-plan", }; /** * The ENGINE-INSERTED vantage classes (kestrel-5zl.19) — the ONLY {@link WakeSource}s {@link recordedAgent} * may answer leniently (a logged empty pass) when the recording holds no wake from them at all. An * ALLOWLIST, deliberately, not "any source the recording lacks": leniency must be argued per source, so a * WakeSource added later inherits the fail-closed branch by default rather than the lenient one. * * `own-fill` is here because it is a pure function of the FILL MODEL, not of the market or the agent: a * seeded engine raises an own-fill management wake off its own simulated fill, so an unseeded recording * cannot hold one and its absence is evidence about the ENGINE, never about the agent or the tape. * * Every other source is excluded on its own argument, not by omission: * - `agent` — the agent's own scheduleWake. Its absence means the recorded agent scheduled differently: * a real divergence. * - `seed` / `structural` — the driver's configured cadence, fixed by config + tape, identical across a * faithful replay. An absence means the CONFIG diverged, which is exactly what must fail * closed; it is NOT the own-fill argument (nothing about the fill model inserts one). * - `staleness` — the backstop, the platform's fail-closed safety floor ("there is no Action that removes * this — the agent can never disable safety", `./simulate.ts`). The agent-path stand-down * is its ONLY de-arm, so answering an unrecorded staleness wake with a pass would let the * replay ride a book the backstop fired on. It must stand down. */ const INSERTED_VANTAGE_SOURCES: ReadonlySet<WakeSource> = new Set<WakeSource>(["own-fill"]); /** The fail-closed turn: de-arm clean with a logged reason (inventory rides its TP/EXIT — never * liquidates). The recorded/fixed adapters return this rather than crash (RUNTIME §8, CLAUDE fail-closed). */ function standDownTurn(reason: string): AgentTurn { return mkStandDownTurn(reason); } // ───────────────────────────────────────────────────────────────────────────── // The provider seam placeholder (owned by the Agent Harness epic) // ───────────────────────────────────────────────────────────────────────────── /** Token usage observed at the provider call — the on-record source of the attention-efficiency * axis (output + thinking spend, kept distinct from any offline estimate). `inputTokens` is the TOTAL * input the provider billed for (fresh + cache-read + cache-write); the two optional cache counters split * out the prompt-cache economics (kestrel-rul, consumed by m9i.5). All are provider-reported, off the graded * path — a mock/deterministic client omits the cache fields. */ export interface LlmUsage { readonly inputTokens: num