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.

305 lines (304 loc) 20.7 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 { parse } from "../lang/index.js"; import { standDownTurn as mkStandDownTurn } from "../engine/disarm.js"; /** * 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, blocker, remedy) { 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) { return provider === "anthropic" || provider === "bedrock"; } /** * 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 = [ "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) { 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 {@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 = "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) { 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 = { 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 = new Set(["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) { return mkStandDownTurn(reason); } // ───────────────────────────────────────────────────────────────────────────── // The four adapters (one seam) // ───────────────────────────────────────────────────────────────────────────── // The live model adapter — {@link liveAgent} — is the sole non-deterministic point, above the // determinism line. Its concrete body lives in the Agent Harness module `./harness/live-agent.ts` // (kestrel-rul), NOT here: it is a PURE function of an injected {@link LlmClient} (no provider SDK // import), so this deterministic-core module — which `./simulate.ts` imports — stays provider-free // (ADR-0013: "the deterministic core imports no provider SDK"). The BYOK AI-SDK `LlmClient` that // actually touches the wire is `./harness/ai-sdk-client.ts`, which is the ONLY file importing the // provider packages and is never pulled in by `./simulate.ts` or `src/index.ts`. /** * Replay a prior run's captured turns byte-identically (no model call, no Rendering). Its contract * is a byte-identity claim: re-projecting the recorded run's graded Bus is byte-identical to the * original. This is what "re-runnable as a Backtest" concretely means. * * Deterministic substrate: the replay is keyed ONLY by the stable {@link WakeKey} ({@link OPEN_WAKE_KEY} * for `open`, {@link wakeKeyOf} for `decide`) — the rest of the delivered {@link ActingFrame} is IGNORED * (rendering is above the determinism line, RUNTIME §0). Synchronous — no model call. * * Two misses, two proportionate answers (kestrel-5zl.19 — the shakedown-2 finding). A replay may be driven * by an engine the recording never ran under (the canonical case: an UNSEEDED recording regraded under a * SEEDED engine, which raises `own-fill` management wakes the original session never delivered), so a * missing key is not one failure but two: * - **An INSERTED vantage** — the wake's {@link WakeSource} is one of {@link INSERTED_VANTAGE_SOURCES} AND * the recording holds no wake from it at all: that frontier did not exist when the agent decided. The * agent made no decision here and cannot be made to have made one, so replay a LOGGED empty pass (a * legitimate pass — the standing Plans keep managing, exactly as they did between the recorded * vantages). Standing down instead would inject a de-arm the agent never authored and collapse the rest * of the replay — the same trap `frozenStrategist` already holds armed for in `./harness/plan-fixture.ts`. * - **EVERY other miss** — a source OUTSIDE the allowlist (`agent`/`seed`/`structural`/`staleness`), or an * allowlisted source that IS in the recording but not at this wake: the recorded stream genuinely * diverged. That fails closed to a {@link standDownTurn} (never a crash, never a silent pass). A * faithful replay of a driver-captured run never hits it, because the driver captures every turn it * consumed (incl. empty passes) under this same key. * * The leniency is an ALLOWLIST, never a set-membership miss ({@link INSERTED_VANTAGE_SOURCES}), so a * WakeSource added later cannot silently inherit it — most of all `staleness`, whose backstop is the * platform's fail-closed safety floor (no Action removes it; the agent-path stand-down is the only de-arm). * An unrecorded `staleness` wake is a DIVERGENCE and stands down, never an empty pass. * * The discriminator needs EVIDENCE, so an OPEN-only recording (no wake turns at all — the frozen-plan * fixture of `./harness/plan-fixture.ts`) cannot tell the two apart for any wake, and every wake fails * closed to a stand-down exactly as before: ambiguity resolves to the fail-closed branch, never the * lenient one. */ export function recordedAgent(config, turns) { /** The sources the recording actually holds a decision for — the inserted-vs-gap discriminator, read * off the keys themselves (no second record to keep in sync). */ const recordedSources = new Set(); for (const key of turns.keys()) { if (key !== OPEN_WAKE_KEY) recordedSources.add(key.slice(0, key.indexOf("#"))); } return { config, open() { return (turns.get(OPEN_WAKE_KEY)?.turn ?? standDownTurn(`recordedAgent: no captured OPEN turn (key ${OPEN_WAKE_KEY})`)); }, decide(frame) { const key = wakeKeyOf(frame); const turn = turns.get(key)?.turn; if (turn !== undefined) return turn; // The lenient branch is scoped to the ENGINE-INSERTED vantage classes and nothing else: an unrecorded // wake from any other source (`staleness` above all) is a divergence and falls through to the // stand-down below. if (recordedSources.size > 0 && INSERTED_VANTAGE_SOURCES.has(frame.wakeSource) && !recordedSources.has(frame.wakeSource)) { return { actions: [], journal: `recordedAgent: no recorded '${frame.wakeSource}' vantage — this engine inserted wake ${key}; standing book rides (logged pass, kestrel-5zl.19)`, }; } return standDownTurn(`recordedAgent: no captured turn for wake ${key}`); }, // The replay-mode cost source (ADR-0040 §7): recorded cost for a captured Seat turn; `null` for a // key the capture never held (the synthesized-turn classes above — cost 0, no buffer, no record); // `undefined` for a captured-but-costless entry (a latency-blind/legacy capture — the driver // refuses it at open under clockHonest, never a silent cost-0 stand-in). recordedCostMs(key) { const entry = turns.get(key); if (entry === undefined) return null; return entry.measuredMs; }, }; } /** * Arm one document at `open` and pass forever = a Backtest expressed through the seam (the * degenerate case where discretionary and static coincide). Synchronous and deterministic — the * tracer-bullet stub agent. * * The `open` turn is a single {@link SupersedeAction} carrying `document` (the driver's initial arm); * every subsequent `decide` returns an empty `actions[]` (a legitimate pass — the standing Plans keep * managing autonomously between wakes). Fail-closed (RUNTIME §8): the document parse is pre-validated * ONCE at construction, and a parse escape turns the `open` turn into a {@link StandDownAction} — the * agent can never hand the driver an unparseable document, and can never crash the Session. */ export function fixedPlanAgent(document, config = BACKTEST_CONFIG) { let parseError = null; try { parse(document); // pre-validate the Kestrel surface (`print(parse(text))` round-trips, ADR-0001) } catch (e) { parseError = e instanceof Error ? e.message : String(e); } const openTurn = parseError === null ? { actions: [{ kind: "supersede", document }] } : standDownTurn(`fixedPlanAgent: document parse escape — ${parseError}`); return { config, open() { return openTurn; }, decide() { return { actions: [] }; // pass forever — the standing document is the whole strategy }, }; } // The file-handshake adapter — the versioned, identity-stamped seam every external CLI harness // (Claude Code / Codex / OpenCode) drives through — is PROMOTED to `./harness/file-handshake.ts` // (kestrel-m9i.7 stage-0), NOT here: it imports `node:fs` (frames/turns are files) and stamps a // harness identity, both of which belong ABOVE the deterministic core this module defines. Import // `fileHandshakeAgent` from `./harness/file-handshake.ts` (or `../index.ts`). The seam it satisfies // is the SAME {@link Agent} — `open(briefing)/decide(frame)` — so the driver never learns which // brain answered; only the returned {@link AgentTurn} crosses the determinism boundary.