Skip to content

Structured-output extraction gives up when a reasoning preamble contains its own JSON object #608

Description

@zzwong

Structured-output decoding already recovers a JSON object from surrounding prose. It refuses to do so when the prose itself contains another syntactically valid object, which a reasoning preamble routinely does.

Correction

This issue was originally filed claiming no extraction existed. That was wrong — see the comment below. extractSingleJSONObject (internal/llm/extract.go) has been in place since #152, and decodeStructuredAccepted (internal/llm/adapter.go) calls it for every structured task. The body below describes the actual remaining gap.

Observed

A thread-analysis attempt failed with:

threadanalysis: decode result: invalid character '<' looking for beginning of value

The retry succeeded, so the run survived, but one of two validation attempts was spent.

Extraction was active for that attempt. It returns ok=false unless exactly one syntactically valid top-level object is present, and deliberately so: ambiguous output must not be guessed at. A preamble that merely narrates recovers fine today. A preamble that contains any valid object does not:

preamble before the answer recovered
no braces here yes
draft: {"decision":"skip"} no — two candidates
I must return JSON, not {}. no — {} is a candidate
answer itself truncated no — unbalanced, correctly unrecoverable

The last row is a different failure that extraction cannot and should not fix; the retry is the right mechanism there.

Why it matters

The budget is two validation attempts, and a task that exhausts them is blocking. A model that drafts its answer inside a reasoning tag before emitting it produces the second row consistently rather than occasionally, so the loss is systematic.

Possible shape

Keep the "never guess" property but let the task's own schema decide. On strict-decode failure, collect every balanced valid top-level object and decode each with the same schema decoder:

  • exactly one passes the schema — accept it
  • zero pass, or two or more pass — keep today's error

A candidate that fails DisallowUnknownFields, or carries the wrong thread_id or schema_version, cannot be the answer, so the lone survivor is unambiguous. This never loosens validation: it only changes which bytes reach a decoder whose rules are unchanged.

Open question

Whether the observed failure was this case or a truncated response has not been established — the raw first attempt is not persisted when the retry succeeds. Worth classifying from a task log before implementing, since if truncation is the common case the retry path is the thing to improve instead.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions