Skip to content

Typed LLM output contract

Every LLM call's output is an assumption the system made — this feature makes the assumption explicit, validated, and typed. A per-datatype module declares what a call is expected to return, deterministically classifies what came back (syntax violation vs schema violation vs ok), normalizes it to ONE canonical form, and hands everything downstream a strongly-typed value. The run record stores both the model's raw text and the canonical form, so every projection (Slack, GitHub, the run page, memory) reads consistent input while forensics keep what the model actually said.

  • Code: src/core/llmOutput/types.ts (the OutputType seam + acceptOutput control loop), markdown.ts (prose canonicalization), json.ts (fence-strip + JSON.parse + zod)
  • Consumers: src/core/dispatcher.ts (the answer boundary), src/core/memory/reflection.ts (the reflection envelope)
  • Tests: src/core/llmOutput/markdown.test.ts, src/core/llmOutput/json.test.ts, src/core/llmOutput/types.test.ts, src/core/dispatcher.test.ts::typed answer output

Behavior

  1. The seam (OutputType<T>, invariant 2): one interface per expected datatype — name, optional requestHint (system-prompt steering toward the shape; the request side of the contract), parse(raw) (deterministic: classify → normalize → zod-typed value + a canonical string + whether normalization changed anything), retryable(failure) and maxRetries (the per-type retry policy). Failures are classified syntax (the text is not the format at all) or schema (the format parsed but the shape is wrong), each carrying observed — a one-line statement of what was seen, phrased to be sent back to the model.
  2. The control loop is deterministic and type-blind (acceptOutput): parse → ok? done. Failed → re-ask ONLY when the caller supplied a reask callback AND the type says the failure is retryable AND attempts remain; the re-ask receives the failure's observed text so the model is told exactly what was wrong. Exhausted or non-retryable → the failure is returned, never thrown — the caller decides fail-open vs fail-closed. All logic about WHAT is valid lives in the type module; the loop only sequences.
  3. Markdown is the first type — it normalizes, it never fails, it never retries (markdownOutput). There is no invalid Markdown (every string renders as something), so parse always succeeds and retryable is constant-false: prose is fail-open by construction. What it fixes is dialect variance: models alternate between *x* (mrkdwn bold) and **x** (Markdown bold), and per-surface renderers disagreed on which was meant (bold on Slack after slack-channel.md item 4, italic on the run page). Canonicalization parses with a real CommonMark parser (mdast-util-from-markdown) and promotes single-asterisk emphasis to strong (*x***x**) positionally — two * insertions at the node's boundaries, everything else byte-identical. Parser-guided, not regex: *x* inside code fences/inline code is never emphasis to the parser, so it is never touched. Skipped (left as written, fail-open) when the marker is _ (that IS italic), when the node is part of a ***bold-italic*** run, or when the emphasis contains nested emphasis/strong (rewriting nested marker runs risks re-parse ambiguity — the projector-side bold fallback still renders those). Underscore emphasis, strikethrough, bullets, links, headers pass through untouched — the existing projectors already agree on them.
  4. JSON is the second type (jsonOutput(schema)): strips a wrapping code fence if present (models add them; accepting one is normalization, not failure — stripJsonFence), then JSON.parse (throw → syntax failure carrying the parser message), then the caller's zod schema (safeParse fail → schema failure carrying the issue list). Canonical form is the re-serialized parsed value; changed is semantic, never cosmetic — re-serialization noise (whitespace, key order, a stripped fence) does not fire it, the schema stripping or transforming something the model sent does. Retryable by default (both kinds) with maxRetries 2 — JSON violations are crisp and a re-ask can fix them, unlike prose. This is also the ready seam for provider-native JSON output modes: the type module stays, only the request side changes.
  5. The answer boundary (dispatcher): the run's final answer is canonicalized ONCE, before the answer event is published — so the event text, the channel reply, the GitHub review post, and the memory reflection all read the same canonical Markdown, and the run page and Slack can no longer disagree about the same reply. When normalization changed anything, the model's raw text rides on the answer event as raw (redacted like text), unless including it would push the event past the per-event byte budget (MAX_EVENT_BYTES) — then raw is dropped and the canonical text stands alone (fail-open; the budget already truncates text and must not be starved by a second copy). raw absent means normalization changed nothing — the common case.
  6. Reflection consumes the JSON type: parseReflection's envelope (JSON → object with a facts array) is jsonOutput + a zod schema; its domain-level leniency (bad facts dropped one by one, audience defaulting) is unchanged and stays in reflection — the type module owns the format, the consumer owns the meaning. No re-ask is wired there (reflection is fire-and-forget; a bad envelope logs and drops, as before).
  7. Scope: the contract applies where a caller adopts it — today the answer boundary and reflection. Intermediate assistant prose events are recorded verbatim (they are narration, not projected structure); tool-use turns are already typed by the tool schemas. A future consumer (verdicts as JSON, provider-native JSON modes, block output) adopts the seam by writing one OutputType — never by putting a model between the record and a surface.

Validation criteria

CriterionProof
*x***x** positionally; **x**, _x_, ***x***, code fences/inline code, bullets, links untouched; intraword and multi-node cases; nested emphasis skipped[unit] src/core/llmOutput/markdown.test.ts (red-verified against a pass-through stub)
Markdown never fails and never retries; changed is true only when bytes changed[unit] src/core/llmOutput/markdown.test.ts
JSON type: fence-stripped, syntax vs schema failures classified with observed, zod-typed value on ok[unit] src/core/llmOutput/json.test.ts
Control loop: ok passes through; retryable failure re-asks with observed up to maxRetries; non-retryable or no-callback returns the failure without throwing[unit] src/core/llmOutput/types.test.ts
The answer event, channel reply, and GitHub post body all carry the canonical text; raw on the event iff normalization changed it; raw is redacted; oversized raw dropped[unit] src/core/dispatcher.test.ts::typed answer output (docs/reference/specs/llm-output.md) (red-verified)
Reflection envelope via the JSON type: fenced JSON accepted, non-JSON → error, non-object → error, missing facts → error; fact-level leniency unchanged[unit] src/core/memory/reflection.test.ts (existing suite, error text updated)