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
text/typescript
/**
* # 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