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(theOutputTypeseam +acceptOutputcontrol 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
- The seam (
OutputType<T>, invariant 2): one interface per expected datatype —name, optionalrequestHint(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)andmaxRetries(the per-type retry policy). Failures are classifiedsyntax(the text is not the format at all) orschema(the format parsed but the shape is wrong), each carryingobserved— a one-line statement of what was seen, phrased to be sent back to the model. - The control loop is deterministic and type-blind (
acceptOutput): parse → ok? done. Failed → re-ask ONLY when the caller supplied areaskcallback AND the type says the failure is retryable AND attempts remain; the re-ask receives the failure'sobservedtext 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. - Markdown is the first type — it normalizes, it never fails, it never retries (
markdownOutput). There is no invalid Markdown (every string renders as something), soparsealways succeeds andretryableis 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. - JSON is the second type (
jsonOutput(schema)): strips a wrapping code fence if present (models add them; accepting one is normalization, not failure —stripJsonFence), thenJSON.parse(throw →syntaxfailure carrying the parser message), then the caller's zod schema (safeParsefail →schemafailure carrying the issue list). Canonical form is the re-serialized parsed value;changedis 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) withmaxRetries2 — 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. - The answer boundary (dispatcher): the run's final answer is canonicalized ONCE, before the
answerevent 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 asraw(redacted liketext), unless including it would push the event past the per-event byte budget (MAX_EVENT_BYTES) — thenrawis dropped and the canonical text stands alone (fail-open; the budget already truncatestextand must not be starved by a second copy).rawabsent means normalization changed nothing — the common case. - Reflection consumes the JSON type:
parseReflection's envelope (JSON → object with afactsarray) isjsonOutput+ 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). - Scope: the contract applies where a caller adopts it — today the answer boundary and reflection. Intermediate
assistantprose 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 oneOutputType— never by putting a model between the record and a surface.
Validation criteria
| Criterion | Proof |
|---|---|
*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) |