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.

384 lines (352 loc) 17 kB
/** * # cli/backend/remote — HTTP+SSE client vs kestrel.markets (ADR-0004/0010). * * Speaks the CANONICAL platform contract, byte-for-byte (kestrel-5rb): * - `POST /capabilities/trial` → mint an anonymous trial capability; the response * `capability` is the `Authorization: Bearer` token. * - `POST /validate` {source} → ValidateResult (pure; no Operation). * - `POST /sim` SimRequest{source, dataset:{artifact_id}} → **201 Operation** * (direct), or **402 OfferResponse** at the paid-dataset boundary. * - `POST /grade` GradeRequest{blotters:[id,…]} → **201 Operation**. * - `GET /operations/{id}/events` → SSE, `event:` ∈ the `operation.*` * OperationEvent.type enum, opaque-STRING `id:` cursor, resume by `?cursor=` and * `Last-Event-ID` (the two must agree — we send the same value on both). * * Wire shapes (snake_case, {@link ./wire.ts Money}, opaque cursor) live in `wire.ts` * and are decoded to the OSS `src/protocol` domain types at the boundary. The * structured **402/Offer is surfaced as DATA** (never a browser redirect; * `human_action.url` is never fetched). Effectful POSTs carry a DETERMINISTIC * `Idempotency-Key` (a sha256 of the canonical request) so a replay is effect-once * (ADR-0009) without any wall-clock/RNG on the runtime path. * * Type-only protocol imports (erased) + a pure sha256 → no chdb/bun:sqlite; SSE * reads the web `ReadableStream` on `response.body` → node AND bun compatible. */ import type { TrialCapability, GradeResult, CertifiedGrade, Blotter, } from "../../protocol/index.ts"; import type { EpisodeReport } from "../heavy.ts"; import { type KestrelNode } from "../../lang/index.ts"; import { canonicalPlansText } from "../../canonical/plans.ts"; import { CliError, EXIT } from "../errors.ts"; import { type WireValidateResult, type WireOperation, decodeOperation, } from "./wire.ts"; import { type FetchLike, httpErr, unknownEvent } from "./sse.ts"; import { jsonHeaders, bearerHeader, mintTrialCapability, postEffectful, consumeOperationStream, parseValidateDiagnostics, } from "../../client/platform-wire.ts"; import type { ExecutionBackend, Gated, PerceptArgs, PerceptFrame, PlanResult, SessionArgs, DayArgs, RemoteRunResult, GradeArgs, SessionScope, } from "./index.ts"; /** The single canonical managed API base, owned by {@link ../../client/platform-wire.ts} * and re-exported here so `import { DEFAULT_API } from "./remote.ts"` keeps resolving for * the CLI's `select`/`sim`/`agent` callers (kestrel-z473.2). */ export { DEFAULT_API } from "../../client/platform-wire.ts"; export interface RemoteOptions { readonly baseUrl: string; readonly fetch?: FetchLike; readonly capability?: TrialCapability; // pre-seeded (tests); else minted lazily /** * A pre-resolved bearer token from a stored REGISTERED-agent credential * (`kestrel register`, kestrel-markets-0t0). When present, it is used as the * `Authorization: Bearer` on every call and NO anonymous trial is minted — the * registered durable capability supersedes the anon path (PLAT-ADR-0021 tier 1). */ readonly bearer?: string; /** * A KEYPAIR-JWT signer (kestrel-markets-0t0 slice 2). When present it takes precedence * over `bearer`: EACH authorized request mints a FRESH short-lived JWT (unique jti) that * the platform verifies against the agent's enrolled key. This is the possession-proof * auth path — the durable capability is never sent. Absent ⇒ the bearer/anon path is used * unchanged (kcap stays fully working; JWT is additive). */ readonly jwtSigner?: () => Promise<string>; } /** The opaque domain payload accumulated from an Operation's SSE artifact/receipt * events (the protocol pins only the OperationEvent envelope; the body rides in * `data`). */ type StreamPayload = Record<string, unknown>; export class RemoteBackend implements ExecutionBackend { readonly kind = "remote" as const; private readonly base: string; private readonly fetch: FetchLike; private cap?: TrialCapability; private bearer?: string; private readonly jwtSigner?: () => Promise<string>; constructor(opts: RemoteOptions) { this.base = opts.baseUrl.replace(/\/+$/, ""); const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined); if (f === undefined) throw new CliError({ code: "RUNTIME_UNAVAILABLE", exit: EXIT.RUNTIME_UNAVAILABLE, message: "global fetch unavailable — need node ≥18 or bun for --api", }); this.fetch = f; if (opts.jwtSigner !== undefined) { // The keypair-JWT path (kestrel-markets-0t0 slice 2) supersedes the bearer: each request // mints a fresh short-lived JWT. No anon trial is minted and the durable kcap is not sent. this.jwtSigner = opts.jwtSigner; } if (opts.capability !== undefined) { this.cap = opts.capability; this.bearer = opts.capability.capabilityId; } else if (opts.bearer !== undefined) { // A stored REGISTERED credential (kestrel-markets-0t0) — present it directly; the // registered durable capability supersedes the anonymous trial mint. this.bearer = opts.bearer; } } /** * Ensure a bearer is set before an authorized call. If a REGISTERED credential (or a * pre-seeded capability) already supplied one, this is a no-op — no anon trial is * minted. Otherwise it lazily mints an anonymous trial (proof-before-account). */ private async ensureBearer(): Promise<void> { if (this.bearer !== undefined) return; await this.bootstrapCapability(); } // ── capability bootstrap: POST /capabilities/trial (mintTrialCapability) ── async bootstrapCapability(): Promise<TrialCapability> { if (this.cap) return this.cap; // TrialCapabilityRequest: an optional agent-supplied subject commitment; omit it and let // the platform mint one (ADR-0009). The mint (POST + Idempotency-Key + WireTrialCapability // decode) is the shared platform-wire helper; this face supplies its CliError factory and // owns the caching. const { capability, bearer } = await mintTrialCapability({ fetch: this.fetch, base: this.base, onHttpError: remoteHttpErr, }); this.bearer = bearer; // the bearer token (openapi: `capability`) this.cap = capability; return this.cap; } // ── the View/briefing frame has NO M1 contract route → FAIL CLOSED ── async getPercept(_args: PerceptArgs): Promise<Gated<PerceptFrame>> { // The M1 surface (docs/contract §"The M1 route surface") is exactly // capability-discovery / catalog / validate / sim / grade / operations / // offers-settlement / proof / events — there is NO percept/View/briefing route. // Fail closed rather than fabricate one (kestrel-5rb OWNER-NOTE: a remote View // projection is an owner decision — local-only face vs a new platform op). throw new CliError({ code: "REMOTE_UNSUPPORTED", exit: EXIT.USAGE, message: "the frame/percept View has no remote contract route (M1: catalog, validate, sim, grade, operations, offers, proof, events)", hint: "run `frame` locally — omit --api; a remote View projection is not in the platform contract", }); } // ── submitPlan → POST /validate (validateSource): pure, no Operation ── async submitPlan(documents: readonly KestrelNode[]): Promise<Gated<PlanResult>> { const auth = await this.authHeaders(); const canonicalText = canonicalPlansText(documents); // ValidateRequest{source}. Validation is pure (no side effect) → NO Idempotency-Key. const res = await this.fetch(`${this.base}/validate`, { method: "POST", headers: { ...jsonHeaders(), ...auth }, body: JSON.stringify({ source: canonicalText }), }); if (res.status === 422) { // fail-closed parse/validate failure: surface diagnostics, ok:false (never a // 200 hiding a silent default). return { gated: false, value: { ok: false, canonicalText, documents, diagnostics: await parseValidateDiagnostics(res) }, }; } if (!res.ok) throw await remoteHttpErr(res, "POST /validate failed"); const wire = (await res.json()) as WireValidateResult; return { gated: false, value: { ok: wire.valid === true, canonicalText, documents, diagnostics: (wire.diagnostics ?? []).map((d) => `${d.severity} ${d.code}: ${d.message}`), }, }; } // ── sessions → POST /sim (runSim): 201 Operation (direct) | 402 OfferResponse ── async openSession(_scope: SessionScope, args: SessionArgs): Promise<Gated<RemoteRunResult>> { return this.runSim(args, /*day*/ false); } async openDaySession(_scope: SessionScope, args: DayArgs): Promise<Gated<RemoteRunResult>> { return this.runSim(args, /*day*/ true); } private async runSim(args: SessionArgs & Partial<DayArgs>, _day: boolean): Promise<Gated<RemoteRunResult>> { const source = canonicalPlansText(args.documents); // SimRequest{source, dataset:{artifact_id}, params?}. The paid boundary is a // property of the DATASET (out-of-catalog → 402), NOT a client-sent scope — the // contract has no `scope` field on SimRequest. const artifactId = args.busPath; if (artifactId === undefined) throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: "remote sim requires a dataset — pass a catalog artifact id (the bus/data reference)", }); const req = { source, dataset: { artifact_id: artifactId }, params: { fill_model: args.fillModel, r_usd: args.rUsd }, }; const opened = await this.effectfulPost("/sim", req); if (opened.gated) return opened; // 402 before any work → surface the Offer as data const { payload } = await this.consumeStream(opened.value); const report = payload["report"] as EpisodeReport | undefined; if (report === undefined) throw httpErr(502, "sim stream carried no report artifact"); // A RemoteRunResult certifies a Blotter — fail closed if the stream omitted one (never // synthesize a silent default; the type asserts remote always carries the execution record). const blotter = payload["blotter"] as Blotter | undefined; if (blotter === undefined) throw httpErr(502, "sim stream carried no blotter artifact"); const wakes = (payload["wakes"] ?? []) as RemoteRunResult["wakes"]; return { gated: false, value: { report, plansText: source, wakes, operation: decodeOperation(opened.value), blotter, }, }; } // ── grade → POST /grade (runGrade): 201 Operation; result over SSE ── async grade(subject: Blotter | EpisodeReport, opts: GradeArgs): Promise<Gated<GradeResult | CertifiedGrade>> { // GradeRequest{blotters:[artifact_id,…]} — ids, NOT the whole Blotter. A Blotter's // `sessionId` is its artifact id; a EpisodeReport is not a persisted artifact and // cannot be graded remotely (fail closed). const blotters = [blotterId(subject), ...(opts.corpus ?? []).map((b) => b.sessionId)]; const req = { blotters }; const opened = await this.effectfulPost("/grade", req); if (opened.gated) return opened; const { payload } = await this.consumeStream(opened.value); const result = (payload["grade"] ?? payload["result"]) as GradeResult | CertifiedGrade | undefined; if (result === undefined) throw httpErr(502, "grade stream carried no grade artifact"); return { gated: false, value: result }; } // ── HTTP core: effectful POST (Idempotency-Key) with structured-402 gating ── private async effectfulPost(path: string, body: unknown): Promise<Gated<WireOperation>> { return postEffectful({ fetch: this.fetch, base: this.base, path, body, authHeaders: await this.authHeaders(), httpError: httpErr, onHttpError: remoteHttpErr, }); } // ── SSE: GET /operations/{id}/events, opaque-string cursor, operation.* events ── /** Read the Operation's canonical event stream to a terminal event, accumulating the * domain payload from artifact/receipt events. The whole decode/accumulate loop (framing, * opaque-cursor resume, fail-closed on an off-vocabulary event) is the shared platform-wire * helper {@link consumeOperationStream}; this face supplies only its {@link CliError} * factories. */ private async consumeStream(op: WireOperation): Promise<{ payload: StreamPayload }> { return consumeOperationStream({ fetch: this.fetch, base: this.base, operationId: op.operation_id, cursor: op.cursor, headers: await this.authHeaders(), httpError: httpErr, unknownEventError: unknownEvent, }); } /** * The `Authorization` header for an authorized request. The keypair-JWT path wins when * configured — it mints a FRESH short-lived JWT per call (unique jti, so the platform's * replay guard never trips on a legitimate second request). Otherwise the static durable * bearer (or a lazily-minted anon trial) is used, exactly as before (kcap path intact). */ private async authHeaders(): Promise<Record<string, string>> { if (this.jwtSigner !== undefined) { return { authorization: `Bearer ${await this.jwtSigner()}` }; } await this.ensureBearer(); return bearerHeader(this.bearer); } } /* ---------- helpers (module-scope; node+bun) ---------- */ /** * A `Response`→{@link CliError} factory that LIFTS the platform's domain `problem.code` to * the error's TOP-LEVEL `code`, so a wire client matching on `code` (Kestrel CLI v1 §6: * "agents match on `code`, not prose") reads the SAME stable DOMAIN code across transports — * `empty-grade` / `unknown-artifact` remotely EXACTLY as it reads them locally, never the * raw transport status `HTTP_422` / `HTTP_404` (kestrel-y1o0). The remote body already * CARRIES the domain code (`application/problem+json`, RFC 9457); before this, the top-level * field clients were told to match on was the HTTP status, so transport-agnostic error * handling was impossible without special-casing. * * The `exit` code still derives from the HTTP status (the transport tier is preserved — a * 404 stays NOT_FOUND, a 5xx stays RUNTIME_UNAVAILABLE), and the message keeps its * `(HTTP <status>)` marker. FAIL CLOSED: an absent / non-JSON / non-problem-shaped body (or * an absurdly long `code` a MITM'd error page might inject) degrades to the bare * `HTTP_<status>` code exactly as before — reading the body never throws inside error * handling (a throw here would mask the original failure). */ async function remoteHttpErr(res: Response, message: string): Promise<CliError> { const base = httpErr(res.status, message); const domainCode = await readProblemCode(res); if (domainCode === undefined) return base; return new CliError({ code: domainCode, exit: base.exit, message: base.message, ...(base.hint !== undefined ? { hint: base.hint } : {}), }); } /** The number of characters a lifted domain `code` may run to before we distrust it and * fall back to the transport status. Real domain codes are short kebab-case slugs; a * longer value is not a code we should ask an agent to match on. */ const MAX_DOMAIN_CODE = 128; /** * The server's DOMAIN `code` off a non-2xx body, or `undefined` when there is nothing * problem-shaped to lift. Accepts `application/problem+json` AND any JSON object bearing a * non-empty string `code` (the platform's refusals are problem-shaped regardless of the * exact content-type). Fail-safe: any read/parse failure, or a `code` longer than * {@link MAX_DOMAIN_CODE}, returns `undefined` (the caller degrades to `HTTP_<status>`) — it * never throws inside error handling. */ async function readProblemCode(res: Response): Promise<string | undefined> { try { const body: unknown = await res.json(); if (body === null || typeof body !== "object") return undefined; const code = (body as Record<string, unknown>)["code"]; if (typeof code !== "string" || code.length === 0 || code.length > MAX_DOMAIN_CODE) return undefined; return code; } catch { return undefined; // absent / non-JSON / truncated body → degrade, never throw } } /** The Blotter's artifact id (its `sessionId`). A EpisodeReport is not a persisted * artifact and cannot be graded remotely — fail closed. */ function blotterId(subject: Blotter | EpisodeReport): string { if (typeof (subject as Blotter).sessionId === "string") return (subject as Blotter).sessionId; throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: "remote grade requires a Blotter artifact id — run the sim remotely first, then grade its Blotter", }); }