Session 3. Structured outputs — Wed 16 Sep
Parsing is your job
The model does not enforce the schema
You asked for JSON in the system prompt. That is a request, not a guarantee. Even a provider's own structured-output mode is a claim made by the thing you are trying to check. The only enforcement you own is the parser, so the parser is where the contract actually lives:
from bootcamp_agent.schema import AnswerParseError, parse_research_answer
for attempt in [
"The answer is chunking.",
'{"answer": "x", "citations": []}',
'{"answer": "x", "citations": [], "confidence": 7, "needs_human_review": false}',
]:
try:
parse_research_answer(attempt)
except AnswerParseError as error:
print(f"rejected: {error}")
rejected: Not valid JSON: Expecting value: line 1 column 1 (char 0)
rejected: Wrong fields: missing=['confidence', 'needs_human_review'] unknown=[]
rejected: 'confidence' out of range [0, 1]: 7
Three rejections, three different reasons, each naming the field. An error that says "invalid response" would have told you nothing at 2am.
What strict means, gate by gate
parse_research_answer in src/bootcamp_agent/schema.py is a list of gates. In
order:
| Gate | Rejects |
|---|---|
json.loads |
prose, truncated objects, single quotes, a trailing comma |
isinstance(payload, dict) |
a bare list or string that happens to be valid JSON |
keys != REQUIRED_FIELDS |
a missing field and an unknown one, in one comparison |
| type checks | answer empty, citations holding a number, confidence a string |
0.0 <= confidence <= 1.0 |
7, because the model read it as out of ten |
The key gate is set equality, not a subset test. That is a deliberate choice, and it is the one people argue with.
Why an extra field is a rejection
A model that adds "source_url": "https://example.com/chunking" is trying to
help. Accept it and three things follow. Your ResearchAnswer no longer
describes what the object holds, so the next reader guesses. The field appears
on some replies and not others, so any code that uses it needs a fallback that
nobody tests. And a field you never asked for is a field nobody validates,
which is exactly the shape an injected instruction travels in.
rejected: Wrong fields: missing=[] unknown=['source_url']
A contract that accepts anything is not a contract. If you want the field, add it to the schema, give it a type, and write the check. Until then it does not exist.
The one thing the parser does forgive is a single markdown code fence around
the object, because chat-tuned models add one by reflex and stripping it changes
no field. Forgiveness is a decision you make per case, in the parser, in the
open — not a try: except: pass at the call site.
Untrusted input, and the word means it
Model output crosses a trust boundary the same way an HTTP request body does. It arrived from a probabilistic process you do not control, possibly influenced by a document you did not write. So the rules are the ones you already use on a request body: parse at the boundary, reject on the first violation, never let a half-validated object into the rest of the program, and put the reason in the error.
When parsing fails: a budget, not a loop
The parser rejecting is not the end of the story. answer_question in
agent.py spends a fixed budget on repair:
| Attempt | Action |
|---|---|
| 1st parse fails | retry once, appending "Your previous reply was not valid. Return ONLY the JSON object." |
| 2nd parse fails | typed refusal: needs_human_review: true, empty citations, confidence 0.0 |
| any | the trace records both calls and the decision |
Two calls, then it stops. "Retry until it works" has no upper bound on cost or latency, and a model that misread the instruction misreads it the fourth time too. Bounded retries, visible failure, no infinite loops.
The refusal is typed on purpose. Returning None, or raising into the caller,
pushes the decision onto code that has less context than the agent does. A
ResearchAnswer with needs_human_review: true travels through the same
plumbing as a good answer and arrives somewhere a person can see it.
The failure this parser must catch
Three, and the notebook injects each one so you watch it happen.
| Injected | What the parser does | Where you see it |
|---|---|---|
| Malformed JSON — a reply cut off mid-object | Not valid JSON: Expecting ',' delimiter |
the exception, at the boundary |
| An extra field — four required plus one gift | Wrong fields: missing=[] unknown=['source_url'] |
the exception, at the boundary |
| A repair budget that runs out | two llm_call events, then a flagged refusal |
the trace, not the exception |
The third is the one to sit with. Nothing raised. The program returned a valid
ResearchAnswer and carried on. The only evidence that anything went wrong is
needs_human_review: true and two llm_call lines in the trace — which is
precisely why the flag is a field and the trace is not optional.