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
text/typescript
/**
* # 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",
});
}