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
JavaScript
/**
* # 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.