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.
164 lines • 12.3 kB
TypeScript
/**
* # session/harness/file-handshake — the versioned, identity-stamped file-handshake Agent adapter (kestrel-m9i.7 stage-0)
*
* THE adapter every external CLI harness drives through. It satisfies the ONE ADR-0012 {@link Agent}
* seam — `open(briefing) / decide(frame)` — over a **file handshake**: the runtime writes each
* frozen-at-wake vantage to a run directory (`frame-<ord>.json` + a rendered `frame-<ord>.txt`), then
* BLOCKS on a host wait until the external brain writes its reply (`turn-<ord>.json`, an
* {@link AgentTurn} — or the bare `STAND_DOWN` sentinel). The de-facto v0 of this protocol drove three
* models overnight through a Claude-Code subagent (scratchpad `sim-harness/PROTOCOL.md`); this module
* PROMOTES it into a versioned seam with a stamped harness identity and a fail-closed parse identical
* to `liveAgent`.
*
* ## Matched-interface discipline (kestrel-m9i.7 stage-0)
* The frames written to the run dir are a PURE function of the graded bus (the engine renderer, never
* the harness) — so `first-party-liveagent`, `claude-code`, `codex`, and `opencode` all see BYTE-
* IDENTICAL frames for a given cell. The only degrees of freedom are the external brain and its
* declared identity. Two adapter instances over the same bus therefore write byte-identical frames.
*
* ## Where the determinism boundary sits (ADR-0012 §3, ADR-0013, RUNTIME §0)
* The file wait is a HOST wait — OFF the graded clock. Nothing on the deterministic record path touches
* the wall clock or an RNG: the returned {@link AgentTurn} is the ONLY value that crosses into the graded
* path, and its bytes are identical across reruns. A recorded run dir (turn files pre-present) replays
* byte-identically: `open`/`decide` read the existing turn immediately, re-derive the same graded bus.
*
* ## Fail-closed — the three m9i outcomes, kept DISTINCT (identical to `liveAgent`)
* - **authored** — a valid `turn-<ord>.json` (any validated {@link AgentTurn}, incl. an explicit
* `standDown` or the `STAND_DOWN` sentinel). Parsed through the SAME {@link parseTurn} the live agent
* uses — never repaired.
* - **invalid** — a present-but-unusable turn (bad JSON / wrong shape / an invalid action) ⇒ a PASS
* whose JOURNAL names the defect (never a fabricated action, never a crash). `parseTurn`'s wording.
* - **harness-timeout** — no `turn-<ord>.json` within the deterministic deadline ⇒ a PASS journalled
* `harness-timeout`. Distinct from `invalid` (a provider/brain failure vs an unusable reply), exactly
* as `liveAgent` keeps `provider-error` distinct.
* None is a fabricated `standDown`: a missing/invalid turn de-arms NOTHING on its own — standing Plans
* keep managing (an empty `actions[]` is a legitimate pass, ADR-0012 §3).
*
* ## Harness identity (System Profile v1.1 — mandatory-for-certified)
* The adapter stamps its OWN transport identity `adapter = {@link FILE_HANDSHAKE_ADAPTER}` /
* `adapterVersion = {@link FILE_HANDSHAKE_ADAPTER_VERSION}` (the shared seam — constant across every
* external harness) AND the EXTERNALLY-DECLARED brain identity `harness` / `harnessVersion` onto the
* advertised config. A caller-pinned value always WINS (never overwritten). `harness`/`harnessVersion`
* are CFG-only (the a57.14 envelope stays exactly 14 fields) yet fold into the ConfigId — which the grid
* CellKey keys on — so each harness is a DISTINCT grid column while `file-handshake` remains the one
* transport all four brains share.
*/
import { type Agent, type AgentConfig, type AgentTurn } from "../agent.ts";
/** The transport identity this adapter stamps as `config.adapter` — the shared seam every external CLI
* harness drives through (constant across `claude-code`/`codex`/`opencode`/`first-party-liveagent`). The
* `harness` field carries the brain that pivots the tournament; THIS names the mechanism. */
export declare const FILE_HANDSHAKE_ADAPTER = "file-handshake";
/** The transport revision paired with {@link FILE_HANDSHAKE_ADAPTER}. Bump on any behavioral change to
* this adapter (frame serialization, parse, timeout policy) — it folds into the ConfigId, so a bump mints
* a new grid column rather than contaminating an old one (ADR-0013 (d)). `v2`: the default
* `frame-<ord>.txt` companion is the canonical `src/frame` Rendering, not a JSON dump (kestrel-wa0j.2) —
* a change to the bytes an external brain perceives, so it must mint a new column. */
export declare const FILE_HANDSHAKE_ADAPTER_VERSION = "v2";
/** The bare sentinel a harness may write to `turn-<ord>.json` instead of a JSON turn to de-arm cleanly.
* An AUTHORED outcome (the brain chose to step aside), kept distinct from an `invalid`/unparseable reply. */
export declare const STAND_DOWN_SENTINEL = "STAND_DOWN";
/** The default deterministic deadline (5 min) after which a missing turn fails closed to a
* `harness-timeout` pass. Off the graded clock — it only bounds the host wait. */
export declare const DEFAULT_TIMEOUT_MS = 300000;
/** The classification of one handshake turn — the m9i three-way distinction kept explicit on the capture
* record so a leaderboard never conflates an authored reply, an unusable one, and a brain that never
* answered (ADR-0013 (a)). Mirrors `liveAgent`'s {@link import("./live-agent.ts").TurnOutcome} with
* `provider-error` specialized to `harness-timeout` (the file-handshake failure mode). */
export type HandshakeOutcome = "authored" | "invalid" | "harness-timeout";
/** One frame written to the run dir: the machine envelope `{ kind, ordinal, frame }` serialized to
* `frame-<ord>.json`, and rendered to a human-readable `frame-<ord>.txt` companion. */
export interface FrameEnvelope {
readonly kind: "open" | "wake";
/** `open` for the briefing, else the 4-digit wake ordinal (`0000`, `0001`, …). */
readonly ordinal: string;
readonly frame: unknown;
}
/** Per-turn handshake evidence — recorded OFF the graded path (never a graded input). Keeps the three
* outcomes distinct and points at the exact frame/turn files, so an audit can replay a cell by hand. */
export interface HandshakeCapture {
/** {@link OPEN_ORDINAL} for the open turn, else the 0-based wake ordinal. */
readonly ordinal: number;
/** The `<ord>` label used in the file names (`open` | `0000` | …). */
readonly ordLabel: string;
readonly outcome: HandshakeOutcome;
/** Present for `invalid` / `harness-timeout` — the fail-closed reason journalled onto the pass. */
readonly reason?: string;
readonly framePath: string;
readonly turnPath: string;
}
/** Options for {@link fileHandshakeAgent}. `harness`/`harnessVersion` are the externally-declared brain
* identity (the tournament pivot); `now`/`sleep` are injectable so the host wait — and the timeout — are
* deterministic in tests without a real delay; `render` supplies the rendered-text companion (defaults to
* a pure, matched-interface renderer); `capture` is a sink one record is appended to per turn. */
export interface FileHandshakeOptions {
/** The run directory frames/turns live in. Created if absent. Kept OUT of the graded record path. */
readonly dir: string;
/** The externally-declared brain identity, e.g. `claude-code/2.x` (kestrel-m9i.7). A caller-pinned
* `config.harness` wins over this. */
readonly harness: string;
/** The brain version/revision token, paired with {@link harness}. A caller-pinned `config.harnessVersion`
* wins over this. */
readonly harnessVersion: string;
/** The deterministic deadline in ms; a missing turn past it fails closed to `harness-timeout`. */
readonly timeoutMs?: number;
/** The host-wait poll interval in ms (default 100). Off the graded clock. */
readonly pollMs?: number;
/** Injectable clock for the host wait/deadline (default `Date.now`) — off the graded path. */
readonly now?: () => number;
/** Injectable sleep for the poll loop (default `Bun.sleep`) — off the graded path. */
readonly sleep?: (ms: number) => Promise<void>;
/** The rendered-text companion renderer — a PURE function of the frame (matched-interface: same bus ⇒
* same text regardless of harness). Defaults to {@link renderFrameText}. */
readonly render?: (env: FrameEnvelope) => string;
/** A sink array one {@link HandshakeCapture} is appended to per turn (open + each decide). */
readonly capture?: HandshakeCapture[];
}
/** The `<ord>` label for a wake ordinal: `open` for the OPEN turn ({@link OPEN_ORDINAL}), else the
* 0-based wake ordinal zero-padded to 4 digits (`0000`, `0001`, …) — the PROTOCOL.md convention. Pure. */
export declare function ordLabelOf(ordinal: number): string;
/** Serialize a {@link FrameEnvelope} to the byte-stable `frame-<ord>.json` text — deterministic 2-space
* JSON (a pure function of the frame, so two adapter instances over one bus write identical bytes). */
export declare function serializeFrameEnvelope(env: FrameEnvelope): string;
/** The DEFAULT rendered-text companion for a frame — THE canonical `src/frame` Rendering
* ({@link renderBriefing} for the open envelope, {@link renderWakeDelta} for a wake — an
* {@link ActingFrame} extends {@link WakeDeltaInput}), plus a reply-instruction TRAILER (protocol
* scaffolding, after the frame so the kernel still LEADS). It replaces the pre-wa0j.2 JSON dump: the
* external brain now perceives the SAME screen an in-process agent does; the machine twin stays
* `frame-<ord>.json`. PURE and matched-interface (a function of the frame only, never the harness), so
* every brain sees the same `frame-<ord>.txt`. The `env.kind` discriminant selects the renderer; the
* frame is the driver's own typed value (this module never parses one from disk), so no runtime shape
* re-validation happens here. A caller may still inject its own renderer via `opts.render`. */
export declare function renderFrameText(env: FrameEnvelope): string;
/** The fail-closed `harness-timeout` turn: a PASS whose JOURNAL names the missed deadline (never a
* fabricated standDown — standing Plans keep managing). Pure. */
export declare function timeoutTurn(ordLabel: string, timeoutMs: number): {
readonly turn: AgentTurn;
readonly reason: string;
};
/**
* Parse one turn-file's raw text into a validated {@link AgentTurn} with its {@link HandshakeOutcome} —
* the fail-closed core, IDENTICAL to `liveAgent` (it delegates to the same {@link parseTurn}), never
* repaired. The bare {@link STAND_DOWN_SENTINEL} is intercepted first as an AUTHORED clean de-arm (so a
* brain can step aside without composing JSON); anything else flows through `parseTurn` — `ok` ⇒
* `authored`, otherwise `invalid` carrying `parseTurn`'s reason on a PASS + journal. Pure: no I/O.
*/
export declare function parseHandshakeTurn(text: string): {
readonly turn: AgentTurn;
readonly outcome: HandshakeOutcome;
readonly reason?: string;
};
/** The identity-stamped config the adapter advertises: the caller's config with the transport identity
* ({@link FILE_HANDSHAKE_ADAPTER}/{@link FILE_HANDSHAKE_ADAPTER_VERSION}) and the externally-declared
* `harness`/`harnessVersion` folded in — a caller-pinned value always wins. PURE (no I/O), so a driver can
* derive a ConfigId for a cell WITHOUT opening the run dir. */
export declare function stampHarnessIdentity(config: AgentConfig, harness: string, harnessVersion: string): AgentConfig;
/**
* Build the file-handshake {@link Agent} over a run directory. `open`/`decide` write the frame
* (`frame-<ord>.json` + `frame-<ord>.txt`) then block on a host wait until `turn-<ord>.json` lands (or
* the deadline passes). The returned {@link AgentTurn} is the only value crossing the determinism
* boundary; the wait, the clock, and the files sit entirely OFF the graded path. Fail-closed exactly like
* `liveAgent` (invalid ⇒ pass + journal; missing ⇒ `harness-timeout` pass + journal). The advertised
* `config` carries the stamped harness identity ({@link stampHarnessIdentity}).
*/
export declare function fileHandshakeAgent(config: AgentConfig, opts: FileHandshakeOptions): Agent;
//# sourceMappingURL=file-handshake.d.ts.map