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.

320 lines (300 loc) 16.8 kB
/** * # 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; }