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.
228 lines (227 loc) • 15.1 kB
JavaScript
/**
* # 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { OPEN_ORDINAL } from "../agent.js";
import { renderBriefing, renderWakeDelta } from "../../frame/render.js";
import { parseTurn } from "./prompt.js";
/** 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 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 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 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 const DEFAULT_TIMEOUT_MS = 300_000;
// ─────────────────────────────────────────────────────────────────────────────
// Pure core (no I/O, no clock) — the byte-stable frame/turn contract
// ─────────────────────────────────────────────────────────────────────────────
/** 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 function ordLabelOf(ordinal) {
return ordinal === OPEN_ORDINAL ? "open" : String(ordinal).padStart(4, "0");
}
/** 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 function serializeFrameEnvelope(env) {
return JSON.stringify(env, null, 2);
}
/** 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 function renderFrameText(env) {
// A wake frame that carries a delivered View (kestrel-wa0j.4 — a scheduled wake's stored View,
// already resolved fail-closed by the driver) renders under THAT selection, exactly as the
// in-process live agent does; absent ⇒ the default WAKE panes, byte-identical to before.
const wakeView = env.kind === "wake" ? env.frame.view : undefined;
const { rendered, refusal } = renderFrameBody(env, wakeView);
return [
...(refusal !== undefined ? [refusal, ""] : []),
rendered,
"",
`→ you are the author (the trader): decide on this frozen, date-blind vantage and write turn-${env.ordinal}.json (an AgentTurn, or the bare ${STAND_DOWN_SENTINEL} sentinel).`,
"",
].join("\n");
}
/** Render the frame body under its delivered View, FAIL-CLOSED to the default panes on a materialization
* refusal (kestrel-wa0j.19 §1 — the BLOCKER). The file handshake writes this text to disk for an external
* brain; a delivered View that resolves but cannot materialize (a window arg the frame can't serve, an
* over-budget View) must never crash the frame-text write. On a throw we re-render WITHOUT the View (the
* default WAKE panes — known-good) and return a `refusal` note the caller LEADS the frame with (surfaced,
* never silent). A throw with NO View to blame is a genuine render defect and is re-thrown. Pure. */
function renderFrameBody(env, wakeView) {
if (env.kind === "open")
return { rendered: renderBriefing(env.frame) };
try {
return { rendered: renderWakeDelta(env.frame, wakeView !== undefined ? { view: wakeView } : {}) };
}
catch (e) {
if (wakeView === undefined)
throw e; // no View to blame — a genuine render defect, not agent-authored input
return {
rendered: renderWakeDelta(env.frame, {}),
refusal: `⚠ harness: delivered View "${wakeView.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; showing the default WAKE panes (fail-closed, kestrel-wa0j.19).`,
};
}
}
/** The fail-closed `harness-timeout` turn: a PASS whose JOURNAL names the missed deadline (never a
* fabricated standDown — standing Plans keep managing). Pure. */
export function timeoutTurn(ordLabel, timeoutMs) {
const reason = `harness-timeout — no turn-${ordLabel}.json within ${timeoutMs / 1000}s`;
return { turn: { actions: [], journal: `harness: ${reason}` }, reason };
}
/**
* 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 function parseHandshakeTurn(text) {
if (text.trim() === STAND_DOWN_SENTINEL) {
return { turn: { actions: [{ kind: "standDown", reason: `harness: ${STAND_DOWN_SENTINEL}` }] }, outcome: "authored" };
}
const parsed = parseTurn(text);
if (parsed.ok)
return { turn: parsed.turn, outcome: "authored" };
return { turn: parsed.turn, outcome: "invalid", ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) };
}
/** 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 function stampHarnessIdentity(config, harness, harnessVersion) {
return {
...config,
adapter: config.adapter ?? FILE_HANDSHAKE_ADAPTER,
adapterVersion: config.adapterVersion ?? FILE_HANDSHAKE_ADAPTER_VERSION,
harness: config.harness ?? harness,
harnessVersion: config.harnessVersion ?? harnessVersion,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// The adapter — the ADR-0012 Agent seam over the file handshake
// ─────────────────────────────────────────────────────────────────────────────
/**
* 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 function fileHandshakeAgent(config, opts) {
const dir = opts.dir;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollMs = opts.pollMs ?? 100;
const now = opts.now ?? Date.now;
const sleep = opts.sleep ?? ((ms) => Bun.sleep(ms));
const render = opts.render ?? renderFrameText;
const fullConfig = stampHarnessIdentity(config, opts.harness, opts.harnessVersion);
mkdirSync(dir, { recursive: true });
const awaitTurn = async (kind, ordinal, frame) => {
const ordLabel = ordLabelOf(ordinal);
const framePath = join(dir, `frame-${ordLabel}.json`);
const textPath = join(dir, `frame-${ordLabel}.txt`);
const turnPath = join(dir, `turn-${ordLabel}.json`);
const env = { kind, ordinal: ordLabel, frame };
// Matched-interface: the frame bytes are a pure function of the bus, never of the harness.
writeFileSync(framePath, serializeFrameEnvelope(env));
writeFileSync(textPath, render(env));
const deadline = now() + timeoutMs;
while (!existsSync(turnPath)) {
if (now() >= deadline) {
const { turn, reason } = timeoutTurn(ordLabel, timeoutMs);
opts.capture?.push({ ordinal, ordLabel, outcome: "harness-timeout", reason, framePath, turnPath });
return turn;
}
await sleep(pollMs);
}
let text;
try {
text = readFileSync(turnPath, "utf8");
}
catch (e) {
// A present-but-unreadable turn is an unusable reply — fail closed to an invalid PASS (not a crash).
const reason = `unreadable turn-${ordLabel}.json — ${e instanceof Error ? e.message : String(e)}`;
opts.capture?.push({ ordinal, ordLabel, outcome: "invalid", reason, framePath, turnPath });
return { actions: [], journal: `harness: ${reason}` };
}
const parsed = parseHandshakeTurn(text);
opts.capture?.push({
ordinal,
ordLabel,
outcome: parsed.outcome,
...(parsed.reason !== undefined ? { reason: parsed.reason } : {}),
framePath,
turnPath,
});
return parsed.turn;
};
return {
config: fullConfig,
open(briefing) {
return awaitTurn("open", OPEN_ORDINAL, briefing);
},
decide(frame) {
return awaitTurn("wake", frame.wakeOrdinal, frame);
},
};
}