Skip to content

fix(core): a truncated completion is no longer a silent success (#699) - #716

Open
rejifald wants to merge 1 commit into
mainfrom
fix/llm-truncated-completion
Open

fix(core): a truncated completion is no longer a silent success (#699)#716
rejifald wants to merge 1 commit into
mainfrom
fix/llm-truncated-completion

Conversation

@rejifald

@rejifald rejifald commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The bug

A truncated LLM completion was a success on every observer. Both first-party provider
mappings have always lifted finishReason, and nothing ever read it:

evidence file:line (on origin/main, 44a9fcc)
interpret is verdictOf then { ok: true, data: provider.parse(res.body) } packages/core/src/llm.ts:145-149
anthropic lifts stop_reasonfinishReason packages/core/src/llm.ts:308
openai lifts finish_reasonfinishReason packages/core/src/llm.ts:355-356
makeLlmSurface has no export; its only non-comment reference is llm.ts:177 packages/core/src/llm.ts:130
the exported llmSurface is a bare identity — nothing to wrap packages/core/src/llm.ts:117

So a completion the token cap cut off resolved ok: true with findings: [], byte-identical to
a model that finished on its own terms. The caller gets half an answer and no way to know it —
the truncation resurfaces downstream as a parse error, a malformed tool call, or a wrong answer
nobody traces back here.

What changed

1. makeLlmSurface is exported (llm.ts). Pure addition, no behaviour change — and the
unblocker. llmSurface is only the { id: 'llm' } identity, so there was nothing to wrap, and
the real factory closes over the provider and defaults that llm(config) assembles internally.
Without it a caller could not implement the fix themselves, nor adopt the strict policy this PR
deliberately declines to impose. LlmDefaults, its argument, is exported with it — a public
factory taking a private parameter type is not constructible.

llm is published as its own subpath entry ("./llm"lib/llm.* in packages/core/package.json),
and src/index.ts re-exports nothing from it. So the export keyword is the whole publication
step; there is no barrel to update. Verified in the emitted artefacts:
lib/llm.d.mts exports LlmDefaults + makeLlmSurface, and lib/llm.mjs exports
anthropic, llm, llmSurface, makeLlmSurface, openai at runtime.

2. Truncation is observable, in the two places a caller looks:

  • On the resultLlmResult.truncated?: boolean, derived by the surface from the
    already-normalised finishReason, so a caller never has to know anthropic spells the cap
    max_tokens and openai spells it length. Three-state on purpose: true, false, and
    absent when the provider lifted no finish reason at all. A confident false over silence
    would be the same bug this field exists to remove, one level up.
  • As a warn drift finding — so it reaches .inspect().findings, the drift event and the
    trace with no extra wiring.

The finding needed a channel. interpret could previously only fail a call or say nothing, and
truncation is neither — so SurfaceOutcome's success arm gains an optional
findings?: DriftFinding[], which the engine merges into the same stream the output contract
and flagFinding already feed (engine.ts, one merge site). It reuses the coerced change kind
rather than widening SoftDriftChangeflagFinding's own precedent — and coerced already
defaults to warn, so kind and level agree rather than argue.

Extension seam (P21): a BYO provider that already decided the question in its own parse
wins over the two-entry vocabulary; the surface only derives truncated when the provider
didn't declare one. Recognition is case-insensitive so a BYO provider passing its vendor's string
through verbatim isn't defeated by case alone.

Why reported, not thrown

The call succeeded at every layer this surface owns: the transport worked, the body is
well-formed, text holds real tokens. Whether a partial answer is usable is the caller's
question — this change is only about putting them in a position to ask it.

Making truncation fatal is a semver-major behaviour change and a maintainer's decision, not
mine.
It is a deliberate follow-up, as is an opt-in strict-style spelling if one is wanted.
I did not find an existing idiomatic opt-in mechanism for exactly this, so I added none —
rather than invent config vocabulary on a maintainer's behalf. Exporting makeLlmSurface is what
makes the strict policy a five-line interpret wrapper instead of a fork, and that wrapper is
pinned in the tests so the follow-up has a working starting point:

const base = makeLlmSurface({ provider: anthropic, model: 'claude-opus-4-8' });
const strict = {
    ...base,
    interpret: (res, cfg) => {
        const out = base.interpret!(res, cfg);
        return out.ok && out.data.truncated
            ? { ok: false as const, message: 'llm: truncated at the token cap' }
            : out;
    },
};

Semver: minor. Two additive optional fields (LlmResult.truncated, SurfaceOutcome.findings)
and one new export. No call that succeeded before fails now — only level: 'error' findings are
fatal and this one is warn. The one observable change is a new warn finding in
.inspect().findings / the drift stream on truncated completions, which is the point.

Out of scope

  • §2 — the max_tokens: 1024 injection (llm.ts:283): anthropic's buildBody injects a cap
    the caller never wrote, which is why the default config truncates and then reports ok: true.
    Left alone deliberately: it is a defaults question worth its own discussion (anthropic's API
    requires max_tokens, so "just remove it" isn't available — the choice is between a documented
    default, a model-aware one, and failing closed). This PR makes the consequence visible; it does
    not pick the default.
  • §4–§7 — untouched. Hence Refs #699, not Fixes.

Two honest notes

  • A finding from SurfaceOutcome.findings is not levelled by drift.severity, exactly like
    flagFinding today: severity resolves inside the diff, over the kinds the diff produces.
    A severity: 'info' allowlist will not suppress this warn. Pre-existing behaviour, now
    shared by one more finding source, and written into the comment at the merge site.
  • The merge is wired on the non-paginated path only (runFrom), which is where flagFinding
    lives. The paginated path collects no engine-level findings today and llm is not paginated.

Tested

12 new tests in packages/core/test/llm-truncation.spec.ts (style follows llm.spec.ts — a
capturing adapter, no network):

  • anthropic stop_reason: 'max_tokens'truncated: true + a warn finding on .inspect()
  • openai finish_reason: 'length' → likewise
  • end_turn / stoptruncated: false, findings empty
  • no finishReason at all → truncated absent, not false
  • truncation does not fail the call
  • a BYO provider's own truncated wins in both directions
  • case-insensitive recognition (MAX_TOKENS)
  • makeLlmSurface importable, builds a live surface, drives a stitch({ kind }), and wraps to
    make truncation fatal

Gates

All green from the repo root: prettier --check (changed files), check:lint, check:types,
test (1503 passed, 140 files), check:types-d, check-changelog.mjs, check-contract.mjs,
check-unknown-keys.mjs, plus the two the new export could move — check:exports (attw, all
subpaths 🟢) and check:size.

On the size gate: the whole-entry budget reports 0.00 KB left. That is pre-existing on
origin/main
— I measured a stashed tree and got the identical 67.11 KB minified / 24.10 KB
gzip. This change adds zero bytes: llm rides its own subpath and never enters the core
entry, and the surface.ts change is types and comments only.

Refs #699

🤖 Generated with Claude Code

Both first-party llm provider mappings have always lifted `finishReason`
— anthropic's `stop_reason`, openai's `finish_reason` — and nothing ever
read it. A completion the token cap cut off therefore resolved `ok: true`
with `findings: []`, byte-identical on every observer to a model that
finished on its own terms. The failure mode is the worst shape an API can
have: the caller gets half an answer and no way to know it, so the
truncation surfaces downstream as a parse error, a malformed tool call,
or a wrong answer nobody traces back here.

`LlmResult` gains `truncated`, derived by the surface from the normalised
`finishReason` so a caller never has to know that anthropic spells the cap
`max_tokens` and openai spells it `length`. It is three-state on purpose:
`true`, `false`, and ABSENT when the provider lifted no finish reason at
all — a confident `false` over silence would be the same bug this field
exists to remove, one level up. A BYO provider that already decided the
question in its own `parse` wins over the two-entry vocabulary, which is
the P21 seam doing its job rather than a special case.

Alongside it, a `warn` drift finding, so the signal reaches
`.inspect().findings`, the `drift` event and the trace with no wiring.
That needs a channel: `interpret` could previously only fail a call or say
nothing, and truncation is neither. `SurfaceOutcome`'s success arm gains an
optional `findings`, merged by the engine into the same stream the `output`
contract and `flagFinding` already feed. It reuses the `coerced` change
kind rather than widening `SoftDriftChange` — `flagFinding`'s precedent —
and `coerced` already defaults to `warn`, so kind and level agree.

Reported, NOT thrown. The call succeeded at every layer this surface owns:
the transport worked, the body is well-formed, `text` holds real tokens.
Whether a partial answer is usable is the caller's question, and this
change is only about putting them in a position to ask it. Making
truncation fatal is a semver-major behaviour change and a maintainer's
decision; it is deliberately left as a follow-up.

Which is why `makeLlmSurface` is exported, and why that is the unblocker
rather than a nicety. The exported `llmSurface` is a bare `{ id: 'llm' }`
identity with nothing to wrap, and the real factory — which closes over
the provider and defaults `llm(config)` assembles internally — had no
`export`, so a caller could not implement any of this themselves, nor
adopt the strict policy this PR declines to impose. With it public, that
policy is a five-line `interpret` wrapper (pinned in the tests) instead of
a fork. `LlmDefaults`, its argument, is exported with it — a public
factory taking a private parameter type is not constructible.

Twelve tests in llm-truncation.spec.ts: both vendor shapes truncated and
not, the unknown third state, the BYO provider overriding in both
directions, case-insensitive recognition, and `makeLlmSurface` imported,
driven through `stitch({ kind })`, and wrapped to make truncation fatal.

The whole-entry bundle is unchanged (24.10 KB gzip, measured against a
stashed tree); llm rides its own subpath and never enters it.

Refs #699 — §2 (the unrequested `max_tokens: 1024` default for anthropic,
which is what makes the default config truncate in the first place) and
§4–§7 stay open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant