Skip to content

Truncated model output is misreported as a schema failure and retried at the same cap #49

Description

@seonghobae

Gap

The model clients never read finish_reason. A response truncated because it hit the output cap is indistinguishable from a complete one, and the recovery path makes the situation worse rather than better.

Verified on main (@8c6a2fa): grep -rn "finish_reason\|incomplete" src/four_pillars/ returns nothing. NimClient._content accepts any non-empty choices[0].message.content string, so an HTTP 200 carrying a half-written JSON object is treated as a successful generation.

The generation caps have no recorded basis

location value
nim.py:156 max_tokens: int = 4096
generation.py:35 max_tokens: int = 4096
analysis.py:159 max_tokens=8192 (synthesis)
analysis.py:189 max_tokens=8192 (editorial repair)

Nothing in docs/, CLAUDE.md, or AGENTS.md states where 4096 or 8192 came from — not a provider constraint, not a model catalog entry, not an administrator policy. There is no catalog of per-model output limits anywhere in the repository, so the caps cannot follow the selected model or deployment.

Why truncation gets worse on retry, not better

NimClient.generate treats a parse or schema failure as a repairable schema error (nim.py:185-208). On each round it appends the previous truncated output as an assistant message plus a repair instruction, then re-posts with the same max_tokens:

messages.extend([
    {"role": "assistant", "content": raw_content},     # the truncated text
    {"role": "user", "content": "Return the complete answer again ..."},
])
continue                                                # same max_tokens

So the input grows every round while the output ceiling stays fixed. If the first attempt was cut off for lack of output budget, the retry has strictly less room to succeed. With nim_max_schema_repairs defaulting to 1 there is exactly one such retry, after which the client raises

{provider} output failed schema validation after N repair attempts: ...

which attributes an exhausted output budget to a schema defect. The operator sees a schema error, the job records a quality failure, and the real cause — the cap — is never named.

A second, quieter outcome: if a truncation happens to leave syntactically valid JSON that still satisfies the model schema, the report is accepted and delivered to the customer with content silently missing. Nothing in the pipeline can detect it, because the quality gate in quality.py checks section presence and copy, not whether generation completed.

Required behavior

  1. Read the provider's completion status (finish_reason, and status/incomplete_details where the API provides them) and classify a truncated response as its own outcome, separate from a schema violation and from success.
  2. Do not retry a truncation at the same cap. Either raise the limit for the retry on evidence, or fail explicitly as incomplete; the current same-cap retry is guaranteed to have less room than the attempt that already failed.
  3. Derive the generation limit from the selected model, provider, and deployment rather than from a literal, and record the source, the computed value, and the value actually sent alongside the existing trace fields.
  4. Surface the distinction in GenerationTrace and in the stored job failure reason, so "the model ran out of output budget" is never reported as "the model returned invalid JSON".

Regression cases

  • a 200 response whose finish_reason is length and whose body is valid JSON satisfying the schema must not be accepted as a complete report
  • a truncated response must not be retried at the same cap, and the raised error must name the cap, not the schema
  • a genuine schema violation must still take the existing repair path unchanged
  • the limit must change when the selected model or deployment changes, and tests must not pin it to a literal

Scope note

src/four_pillars/nim.py and generation.py are not claimed by any open PR, but tests/test_nim.py and tests/test_nim_errors.py are owned by #39, and the routing change in #39 decides which catalog the limit should be read from. Filed rather than patched so the limit source and the routing land together.

Internal decision target: 2026-09-21. Next review: 2026-09-18.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions