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.

196 lines (180 loc) 10.8 kB
/** * # session/harness/decoding — CONSTRAINED DECODING per lane (bead cfg-earn-retest) * * The watcher's whole benchmark claim rests on separating "the model has no edge" from "the model could * not SPELL the turn" (bead kestrel-y31). `artifacts/grammar/watcher.gbnf` removes the second failure * mode wherever the serving stack can take a CFG — llama.cpp, vLLM, Fireworks. But the two lanes the * #39 re-test most needs a frontier reference from, **OpenAI and Azure OpenAI, cannot take a GBNF at * all**: their only constrained-decoding lever is STRUCTURED OUTPUTS (a strict JSON Schema). * * So the surface ships in two machine encodings, generated from ONE table by `scripts/gen-grammar.ts`, * and this module is the router that hands each lane the encoding it can actually honor: * * | lane | encoding | wire field | * |----------------------------|---------------------------------|---------------------------------------------------| * | `fireworks` | GBNF (`watcher.gbnf`) | `response_format: {type:"grammar", grammar}` | * | `openai` / `azure` | strict JSON Schema | `response_format: {type:"json_schema", json_schema}` | * | `local` | GBNF, served-side | `--grammar-file` on the server (NOT a request field) | * | `bedrock`/`anthropic`/… | none | — (no constrained-decoding lever on these lanes) | * * ## The schema lane is the LOOSER lane — say so, don't paper over it * `watcher.schema.json` is NOT the GBNF's equal (key order, whitespace, numeric bounds, and `price` as a * regex rather than a CFG — the artifact's own `looserThanGbnf` list is the authority). A turn that slips * past the schema still meets `parseTurn`/`validateAction`, which fail CLOSED and score it as an * `invalidTurn`. So a residual invalid-rate on the OpenAI/Azure lanes is EXPECTED and is a real datum; * on `fireworks` under GBNF it should be ~0, and a nonzero rate there is a bug worth chasing. * * ## Artifacts are read LAZILY and FAIL CLOSED * The grammar/schema are read off disk only when a lane actually asks for them (so importing the harness * barrel from a packaged `dist/` — which ships no `artifacts/` — never touches the filesystem). When a * lane DOES ask and the artifact is missing or stale, this throws: silently sending an unconstrained * request on a lane the caller believes is constrained would quietly reintroduce exactly the emission * failure the grammar exists to remove, and the run would look like a JUDGMENT result. */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; /** The constrained-decoding lever a lane can honor. */ export type DecodingConstraint = /** A GBNF context-free grammar (llama.cpp / vLLM / Fireworks). The TIGHT encoding. */ | { readonly mode: "gbnf"; readonly grammar: string } /** A strict JSON Schema (OpenAI / Azure structured outputs). The LOOSER encoding — see the module doc. */ | { readonly mode: "json-schema"; readonly name: string; readonly schema: Record<string, unknown> }; /** Where the generated artifacts live, relative to this module (repo root `artifacts/grammar/`). */ const GRAMMAR_DIR = new URL("../../../artifacts/grammar/", import.meta.url); function readArtifact(file: string): string { const path = fileURLToPath(new URL(file, GRAMMAR_DIR)); try { return readFileSync(path, "utf8"); } catch (e) { throw new Error( `constrained decoding: cannot read ${file} at ${path}${e instanceof Error ? e.message : String(e)}.\n` + `Run \`bun run gen-grammar\` to emit it. Refusing to send an UNCONSTRAINED request on a lane the ` + `caller asked to constrain: that would silently reintroduce the emission failures the grammar ` + `exists to remove (bead kestrel-y31), and the run would be misread as a judgment result.`, ); } } let gbnfCache: string | undefined; let schemaCache: { readonly name: string; readonly schema: Record<string, unknown> } | undefined; /** The committed GBNF grammar text (`artifacts/grammar/watcher.gbnf`). Memoized; throws if absent. */ export function watcherGbnf(): string { if (gbnfCache === undefined) gbnfCache = readArtifact("watcher.gbnf"); return gbnfCache; } /** The committed strict JSON Schema + its `json_schema.name`, unwrapped from the artifact's envelope * (`artifacts/grammar/watcher.schema.json`). Only `.schema` goes on the wire — the envelope's provenance * fields (`vocabHash`, `looserThanGbnf`, …) are for readers, and a strict-mode validator would reject * them as unknown keywords. Memoized; throws if absent or malformed. */ export function watcherJsonSchema(): { readonly name: string; readonly schema: Record<string, unknown> } { if (schemaCache === undefined) { const raw = readArtifact("watcher.schema.json"); const env = JSON.parse(raw) as { name?: unknown; schema?: unknown }; if (typeof env.name !== "string" || typeof env.schema !== "object" || env.schema === null) { throw new Error(`constrained decoding: watcher.schema.json is malformed (expected {name, schema}). Run \`bun run gen-grammar\`.`); } schemaCache = { name: env.name, schema: env.schema as Record<string, unknown> }; } return schemaCache; } /** TEST SEAM ONLY: drop the memoized artifacts so a test can re-read them. Never called in a live run. */ export function resetDecodingCache(): void { gbnfCache = undefined; schemaCache = undefined; } /** * The constraint a lane should carry by DEFAULT — the whole point of routing this centrally is that a * runner (`eval-watcher.ts`) does not have to know, per provider, which encoding the wire speaks. * * `local` is deliberately ABSENT: an mlx_lm/vLLM/llama.cpp server takes its grammar at SERVE time * (`--grammar-file artifacts/grammar/watcher.gbnf`, see `scripts/serve/watcher-llama-server.sh`), not as * a request field, and sending one in the body would be ignored at best. Bedrock / Anthropic / Google * expose no constrained-decoding lever at all, so they get none. */ export function defaultDecodingFor(provider: string): DecodingConstraint | undefined { switch (provider) { case "fireworks": return { mode: "gbnf", grammar: watcherGbnf() }; case "openai": case "azure": { const { name, schema } = watcherJsonSchema(); return { mode: "json-schema", name, schema }; } default: return undefined; } } /** The AI-SDK `providerOptions` key an OpenAI-COMPATIBLE provider reads its passthrough options from. * `createOpenAICompatible({ name })` sets it, and any key it does not recognize is spread verbatim into * the request body — which is exactly how a non-standard field like Fireworks' grammar `response_format` * reaches the wire through a provider package that has never heard of it. */ export const FIREWORKS_PROVIDER_NAME = "fireworks"; /** The shape of an AI-SDK language-model call's params, narrowed to the two fields this module sets. */ export interface DecodingParams { readonly responseFormat?: { readonly type: "json"; readonly schema: Record<string, unknown>; readonly name: string }; readonly providerOptions?: Record<string, Record<string, unknown>>; } /** * The exact params patch a constraint becomes on an AI-SDK model call. PURE — this is the function the * offline tests assert against, and the same function the live middleware applies, so "the schema is * attached to the outbound request" is proven on the code path that actually runs. * * - **json-schema** → `responseFormat: {type:"json", schema, name}`. Both `@ai-sdk/openai` and * `@ai-sdk/azure` translate that into `response_format: {type:"json_schema", json_schema:{name, strict, * schema}}`, with `strict` defaulting to TRUE — which is the mode we want (it is what makes the * sampler honor `enum`/`required`/`additionalProperties:false` rather than merely validate afterwards). * * - **gbnf** → `providerOptions.fireworks.response_format = {type:"grammar", grammar}`. Fireworks' OpenAI- * compatible endpoint accepts a GBNF grammar in `response_format`; `@ai-sdk/openai-compatible` has no * named setting for it, but it SPREADS unrecognized `providerOptions[name]` keys into the request body * (and the spread lands after its own `response_format`, so this overrides it). That is the supported * extra-body escape hatch, not a hack around the SDK. */ export function decodingParams(c: DecodingConstraint): DecodingParams { if (c.mode === "json-schema") { return { responseFormat: { type: "json", schema: c.schema, name: c.name } }; } return { providerOptions: { [FIREWORKS_PROVIDER_NAME]: { response_format: { type: "grammar", grammar: c.grammar } }, }, }; } /** A minimal structural view of the AI-SDK call params this middleware rewrites. */ interface CallParams { readonly responseFormat?: unknown; readonly providerOptions?: Record<string, Record<string, unknown>>; readonly [k: string]: unknown; } /** * Apply a constraint to a model call's params. MERGES `providerOptions` (never clobbers a caller's * prompt-cache breakpoint or reasoning options) and sets `responseFormat` outright (the harness never * sets one itself — the watcher's reply is read as text and parsed by `parseTurn`). * * Exported so the offline test can drive the EXACT transform the live middleware performs. */ export function applyDecoding(params: CallParams, c: DecodingConstraint): CallParams { const patch = decodingParams(c); const out: Record<string, unknown> = { ...params }; if (patch.responseFormat !== undefined) out.responseFormat = patch.responseFormat; if (patch.providerOptions !== undefined) { const merged: Record<string, Record<string, unknown>> = { ...(params.providerOptions ?? {}) }; for (const [provider, opts] of Object.entries(patch.providerOptions)) { merged[provider] = { ...(merged[provider] ?? {}), ...opts }; } out.providerOptions = merged; } return out as CallParams; } /** * The constraint as an AI-SDK `LanguageModelMiddleware` — wrapped around the model in * `ai-sdk-client.ts#buildModel`, so the constraint rides EVERY call that model makes and cannot be * forgotten at a call site. Typed structurally (not against the SDK's middleware type) to keep this * module free of the provider packages; `ai-sdk-client.ts` is the one file allowed to import them. */ export function decodingMiddleware(c: DecodingConstraint): { readonly transformParams: (opts: { readonly params: CallParams }) => Promise<CallParams>; } { return { transformParams: async ({ params }) => applyDecoding(params, c), }; }