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.
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, anddecodeStructuredAccepted(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:
The retry succeeded, so the run survived, but one of two validation attempts was spent.
Extraction was active for that attempt. It returns
ok=falseunless 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:no braces heredraft: {"decision":"skip"}I must return JSON, not {}.{}is a candidateThe 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:
A candidate that fails
DisallowUnknownFields, or carries the wrongthread_idorschema_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.