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.
320 lines (300 loc) • 16.8 kB
text/typescript
/**
* # cli/commands/agent — the machine/agent CLI mode as a LOSSLESS SDK PROJECTION (kestrel-djm.6)
*
* `kestrel agent` is the request/response stdio face an autonomous agent drives. It reads a JSONL REQUEST
* stream on stdin (one verb envelope per line: `{ op, ...args }`) and writes a JSONL RESPONSE stream on
* stdout (one versioned protocol object per line), forwarding every verb to the djm.5 SDK
* ({@link createSdk} over `localTransport()` OR `remoteTransport({ baseUrl, fetch })`). It is a THIN
* PROJECTION: it constructs the SAME typed client a programmatic caller constructs and NEVER reaches past
* the SDK into SessionCore / the engine / the controller / the blotter / the bus. The verbs it drives are
* exactly the {@link KestrelSdk}/{@link KestrelSession} surface — catalog / validate / openSession / start /
* advance / revise / submit / resume / finalize / grade / artifact / resumeOperation.
*
* ── LOSSLESS + FAIL-CLOSED discipline (AGENTS.md non-negotiables) ──
* - stdout is ONLY versioned protocol JSONL: each line is `{ v: "kestrel.agent/v1", kind: <op>, value:
* <the djm.2 object the SDK verb returns, VERBATIM> }`. No face-local success/failure vocabulary crosses
* the payload channel — a 402/Offer is DATA (`openSession` emits `{ gated:true, payment }`), never a throw.
* - ALL human diagnostics go to stderr. A typed refusal (a malformed request, an unknown op, an unknown
* subject, an off-contract failure) is emitted to stderr carrying the SDK's OWN stable `code` (agents
* match on `code`, not prose), and the process fails closed with a nonzero exit — never a fabricated ok.
* - endpoint selection changes the TRANSPORT ONLY (identical to {@link resolveBackend}): no `--api` ⇒
* `localTransport()`; `--api <url>` / `KESTREL_API` ⇒ `remoteTransport({ baseUrl, fetch })`. Same request
* stream, byte-identical protocol objects across transports (the djm.5 parity headline, now on the CLI).
*
* ── DESIGN FORK LOGGED (hard rules forbid .beads writes — recorded here like djm.5's sdk/types.ts note) ──
* (1) AGENT MODE IS A SUBCOMMAND `kestrel agent` (a heavy verb, lazy-imported by the router like `run`/`day`).
* (2) TRANSPORT VIA THE EXISTING `--api` FLAG (peeled into `globals.api` by `extractGlobals`) — identical to
* `resolveBackend`; a bare `--api` / `--api default` uses the canonical {@link DEFAULT_API} base.
* (3) VERSIONED ENVELOPE `{ v, kind, value }`, `value` the raw SDK payload byte-for-byte; `openSession`
* emits the offer/session id as DATA (`{ gated:false, sessionId } | { gated:true, payment }`).
* (4) INJECTABLE IO SEAM {@link CliIo} on `main(argv, io?)` — `{ stdin, stdout, stderr, fetch }` (defaults:
* process streams + global fetch; `bin.ts` unchanged) so stdout purity is testable in-memory.
*/
import { DEFAULT_API } from "../backend/remote.ts";
import type { GlobalFlags } from "../context.ts";
import { EXIT } from "../errors.ts";
import {
agentDescribe,
agentOpNames,
ARTIFACT_REF_SHAPE,
ArgShapeError,
asAuthoredResponse,
asBlotters,
asBoundResponse,
asEventCursor,
asRequiredString,
asSessionRef,
createSdk,
localTransport,
OPERATION_ID_SHAPE,
remoteTransport,
VALIDATE_DOCUMENT_SHAPE,
type KestrelSdk,
type KestrelSession,
type OperationRef,
type ProblemDetails,
} from "../../sdk/index.ts";
/** The versioned agent protocol tag every stdout line and every stderr refusal carries. */
export const AGENT_PROTOCOL_VERSION = "kestrel.agent/v1";
/**
* The valid request ops, in handler order (the `describe` self-description op first, then `catalog`). DERIVED
* from {@link AGENT_OP_SCHEMAS} (src/sdk/op-schema.ts) so the advertised set can NEVER fork from the described
* set — the `describe` op emits those same schemas, `kestrel agent --help` renders them, and the MCP face
* reads their shape strings. This is the SAME set the switch below dispatches; it is surfaced in the
* `unknown-op` refusal so a caller that guesses wrong is TOLD the valid ops on its own (stderr) channel.
* Kept in lockstep with the switch and with `kestrel agent --help` (src/cli/commands/meta.ts `SUBHELP.agent`).
*/
export const AGENT_OPS: readonly string[] = agentOpNames;
/** The web `fetch` shape the remote transport speaks (structurally the client's `FetchLike`). */
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
/**
* The optional, backward-compatible IO seam threaded through `main(argv, io?)`. Defaults bind the real
* process streams + global fetch (so `bin.ts` is unchanged); a test drives it in-memory to assert stdout
* purity without global monkeypatching.
*/
export interface CliIo {
/** Read the entire JSONL request stream (defaults to draining `process.stdin`). */
readonly stdin?: () => Promise<string>;
/** Write a chunk to the protocol channel (defaults to `process.stdout`). */
readonly stdout?: (chunk: string) => void;
/** Write a chunk to the diagnostic channel (defaults to `process.stderr`). */
readonly stderr?: (chunk: string) => void;
/** Inject a `fetch` for the remote transport (defaults to `globalThis.fetch`). */
readonly fetch?: FetchLike;
}
/** A fail-closed CLI-level refusal carrying a stable `code` (an agent matches on `code`, not prose). Used
* only for genuinely CLI-protocol conditions (a malformed request line, a session verb before openSession,
* an unknown op); DOMAIN refusals carry the SDK's OWN `code` (extracted by {@link codeOf}). */
class AgentRefusal extends Error {
override readonly name = "AgentRefusal";
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
/** Extract a thrown error's stable `code` — the SDK's own vocabulary (SdkLocalError / KestrelClientError /
* AgentRefusal all expose one), never a face-local string. Falls back to a generic tag. */
function codeOf(e: unknown): string {
if (e !== null && typeof e === "object" && "code" in e) {
const code = (e as { code?: unknown }).code;
if (typeof code === "string" && code.length > 0) return code;
}
return "error";
}
/** Extract the server's problem+json diagnostics off a thrown error when present (a `KestrelClientError`
* enriched by the 109 client, kestrel-3w9r). `undefined` when the error carried no structured `problem` — the
* refusal then degrades to `code` + `message` only, exactly today's shape. Fail-safe: never throws. */
function problemOf(e: unknown): ProblemDetails | undefined {
if (e !== null && typeof e === "object" && "problem" in e) {
const p = (e as { problem?: unknown }).problem;
if (p !== null && typeof p === "object") return p as ProblemDetails;
}
return undefined;
}
/** Build the ONE typed client over the chosen transport. Endpoint selection changes the TRANSPORT ONLY —
* no `--api` ⇒ LOCAL; `--api <url>` (bare / `default` ⇒ {@link DEFAULT_API}) ⇒ REMOTE. */
function buildSdk(api: string | undefined, fetchImpl: FetchLike): KestrelSdk {
if (api === undefined) return createSdk(localTransport());
const baseUrl = api === "" || api === "default" ? DEFAULT_API : api;
return createSdk(remoteTransport({ baseUrl, fetch: fetchImpl }));
}
/**
* Drain fd 0 to a string — the default request source when no IO seam is injected. Under Bun, read via
* `Bun.stdin.text()` (fd 0 directly), NOT `for await (const chunk of process.stdin)`: once `process.stdin.isTTY`
* is touched (it is, on the main path — `resolveFromProcess` reads it before dispatch), async-iterating a
* FILE-redirected `process.stdin` under Bun drains ZERO bytes, which would silently swallow every request
* (`kestrel agent < requests.jsonl` → 0 protocol objects, exit 0 — the fail-closed violation AGENTS.md
* forbids). `Bun.stdin.text()` reads the redirected file faithfully. The async-iter path is retained as the
* node fallback (the bundled `dist/cli.js` under bun-less node drains either stream kind correctly).
*/
async function drainProcessStdin(): Promise<string> {
if (typeof Bun !== "undefined") return await Bun.stdin.text();
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
/** Require an active Session for a session verb; a fail-closed typed refusal otherwise (never a silent no-op). */
function requireSession(s: KestrelSession | undefined): KestrelSession {
if (s === undefined) {
throw new AgentRefusal("no-session", "no active Session — issue `openSession` before a session verb");
}
return s;
}
/**
* Run the agent request/response loop. Reads the whole JSONL request stream, forwards each verb to the SDK,
* and writes one versioned protocol object per request to stdout. Fails closed on the first typed refusal:
* a stable-`code` diagnostic to stderr + a nonzero exit (the payload channel never carries a fabricated ok).
*/
export async function agentCommand(globals: GlobalFlags, io?: CliIo): Promise<number> {
const readIn = io?.stdin ?? drainProcessStdin;
const writeOut = io?.stdout ?? ((s: string) => void process.stdout.write(s));
const writeErr = io?.stderr ?? ((s: string) => void process.stderr.write(s));
const fetchImpl = io?.fetch ?? (globalThis.fetch as FetchLike);
const sdk = buildSdk(globals.api, fetchImpl);
/** Emit one versioned protocol object on the PAYLOAD channel — the SDK value carried verbatim. */
const emit = (kind: string, value: unknown): void => {
writeOut(JSON.stringify({ v: AGENT_PROTOCOL_VERSION, kind, value }) + "\n");
};
/**
* Emit a typed refusal on the DIAGNOSTIC channel — carrying the stable `code`, never on stdout. When the
* SDK/client preserved the server's `application/problem+json` diagnostics (kestrel-3w9r), they ride as an
* ADDITIVE structured `problem` field on the SAME `kestrel.agent/v1` envelope (title/detail/code/remediation)
* — the reason the wire carried, now on the author's channel as structured data (`message` already carries
* title+detail in prose). Adding an optional field is version-compatible: a v1 consumer that ignores
* `problem` reads exactly today's shape.
*/
const refuse = (code: string, message: string, problem?: ProblemDetails): void => {
writeErr(
JSON.stringify({
v: AGENT_PROTOCOL_VERSION,
kind: "refusal",
code,
message,
...(problem !== undefined ? { problem } : {}),
}) + "\n",
);
};
const raw = await readIn();
const requestLines = raw.split("\n").filter((l) => l.trim().length > 0);
// FAIL CLOSED on an empty/absent request stream. Zero request lines off a non-interactive stdin (a pipe or
// a redirected file — the agent scenario) is NOT success: it is either a genuinely empty stream or the
// Bun-drain hazard above, and returning EXIT.OK would be exactly the silent no-op AGENTS.md forbids ('never
// a silent default'). Emit a typed refusal (stable `code`, diagnostic channel) + a nonzero exit. The only
// empty read that is NOT a refusal is an interactive TTY on the DEFAULT drain path (a human gave no input,
// not a redirected stream) — never the injected-IO seam and never a pipe/file.
if (requestLines.length === 0) {
const interactiveTty = io?.stdin === undefined && Boolean(process.stdin.isTTY);
if (!interactiveTty) {
refuse(
"empty-request-stream",
"agent mode read zero request lines from stdin — expected one JSONL verb envelope (`{ op, ... }`) per line",
);
return EXIT.USAGE;
}
}
let session: KestrelSession | undefined;
for (const rawLine of requestLines) {
let req: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(rawLine);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new AgentRefusal("bad-request", "a request line must be a JSON object");
}
req = parsed as Record<string, unknown>;
} catch (e) {
const code = e instanceof AgentRefusal ? e.code : "bad-request";
refuse(code, `unparseable request line: ${rawLine}`);
return EXIT.USAGE;
}
const op = req["op"];
try {
switch (op) {
case "describe":
// SELF-DESCRIPTION (kestrel-yuw4): emit this protocol's own op/argument/response shapes — the ONE
// op-schema catalogue (src/sdk/op-schema.ts), session-free, so a cold integrator learns every
// `{ kind: … }` shape (and the exact accepted field names) from the wire instead of brute-forcing them.
emit("describe", agentDescribe(AGENT_PROTOCOL_VERSION));
break;
case "catalog":
emit("catalog", await sdk.catalog());
break;
case "validate":
emit("validate", await sdk.validate(asRequiredString(req["document"], "document", VALIDATE_DOCUMENT_SHAPE)));
break;
case "openSession": {
// An optional `document` supplies the customer strategy (Kestrel plan text) the managed
// author-no-strategy fence requires (ADR-0012, kestrel-88u2) — the agent-face analogue of the sim
// verb's `--plans`. Absent ⇒ the bare form: for a catalog subject the platform's fence answers, now
// a LEGIBLE refusal (kestrel-3w9r). Present ⇒ threaded to POST /sim as the source.
const document = req["document"] !== undefined ? String(req["document"]) : undefined;
// VALIDATE the subject SHAPE (kestrel-yuw4): a malformed subject (a string, a kind-less object) is a
// fail-closed refusal that NAMES the expected `{ kind: … }` vocabulary — never a blind cast that
// dereferences undefined inside the SDK. A well-formed-but-unresolvable subject passes through to
// the SDK's typed domain refusal.
const gated = await sdk.openSession(asSessionRef(req["subject"]), document);
if (gated.gated) {
emit("openSession", { gated: true, payment: gated.payment });
} else {
session = gated.value;
emit("openSession", { gated: false, sessionId: gated.value.sessionId });
}
break;
}
case "start":
emit("start", await requireSession(session).start());
break;
case "advance":
emit("advance", await requireSession(session).advance(asAuthoredResponse(req["response"])));
break;
case "revise":
emit("revise", await requireSession(session).revise(asAuthoredResponse(req["response"])));
break;
case "submit":
emit("submit", await requireSession(session).submit(asBoundResponse(req["response"])));
break;
case "resume":
emit("resume", await requireSession(session).resume(asEventCursor(req["after"])));
break;
case "finalize":
emit("finalize", await requireSession(session).finalize());
break;
case "grade":
emit("grade", await sdk.grade({ blotters: asBlotters(req["blotters"]) }));
break;
case "artifact":
emit("artifact", await sdk.artifact(asRequiredString(req["ref"], "ref", ARTIFACT_REF_SHAPE)));
break;
case "resumeOperation": {
const after = asEventCursor(req["after"]);
const ref: OperationRef = {
operationId: asRequiredString(req["operationId"], "operationId", OPERATION_ID_SHAPE),
...(after !== undefined ? { after } : {}),
};
emit("resumeOperation", await sdk.resumeOperation(ref));
break;
}
default:
refuse(
"unknown-op",
`unknown agent op ${JSON.stringify(op)} — valid ops: ${AGENT_OPS.join(", ")} (see \`kestrel agent --help\`)`,
);
return EXIT.USAGE;
}
} catch (e) {
// A malformed ARGUMENT OBJECT (kestrel-yuw4): a fail-closed USAGE refusal whose message NAMES the
// expected `{ kind: … }` shape (code `bad-arg`), NOT a mid-stream domain failure — the caller supplied a
// structurally wrong argument, exactly what `describe` documents how to avoid.
if (e instanceof ArgShapeError) {
refuse(e.code, e.message);
return EXIT.USAGE;
}
// A DOMAIN refusal from the SDK (SdkLocalError / KestrelClientError) or a CLI-level AgentRefusal:
// fail closed with the SDK's OWN stable code on the diagnostic channel, never a fabricated success.
// A KestrelClientError may carry the server's problem+json diagnostics (kestrel-3w9r) — surface them.
refuse(codeOf(e), e instanceof Error ? e.message : String(e), problemOf(e));
return EXIT.GENERIC;
}
}
return EXIT.OK;
}