The headline features in an agent framework are the ones that let it do more — a new handoff mechanism, a slicker tool decorator, a faster streaming path. The features that keep an agent alive in production are the ones that decide what happens when it does less than you asked. On July 6, 2026, the OpenAI Agents SDK for Python shipped one of the latter in v0.17.8: an invalid_final_output recovery handler, the last member of a small family of run error handlers that started quietly a few releases back.
It is not a glamorous feature. It is the kind you don't think you need until an agent has spent an afternoon's token budget failing in a way that looked, the whole time, like it was working.
The failure that doesn't announce itself#
Most failure modes in an agent are legible. A tool throws, and you get a stack trace. A guardrail trips, and you get a flagged input. The model hits your turn cap, and you get MaxTurnsExceeded. Those are loud; you can catch them.
Structured output has a quieter failure. When you ask an agent for a typed final answer and the model emits something that won't validate against your schema, the run loop faces an ambiguous signal: is this a bad final output, or simply not yet a final output? The default answer is the forgiving one — treat it as unfinished and take another turn. And another. The agent keeps going, each lap looking exactly like healthy progress, until it exhausts max_turns and raises MaxTurnsExceeded at the very end.
A malformed final output doesn't crash the run. It disguises itself as an unfinished one — and the loop pays full price to discover the difference.
The cost is real and it's back-loaded: you don't pay for one bad turn, you pay for every turn between the first invalid answer and the turn cap. On a long-horizon agent with expensive tools, that's the difference between a fifty-cent failure and a fifty-dollar one.
Three keys, one seam#
The fix the SDK added is small and precise. Every Runner entry point now accepts an error_handlers dict, keyed by error kind, with three supported keys:
max_turns— the loop ran out of turns.model_refusal— the model declined to answer. This one landed earlier, in v0.15.0, alongsideModelRefusalError.invalid_final_output— the model produced a final answer that fails the output schema. This is the v0.17.8 addition.
The handler signature is deliberately plain:
def on_max_turns(_data: RunErrorHandlerInput[None]) -> RunErrorHandlerResult:
return RunErrorHandlerResult(
final_output="I couldn't finish within the turn limit.",
include_in_history=False,
)
result = Runner.run_sync(
agent, prompt, max_turns=3,
error_handlers={"max_turns": on_max_turns},
)
For invalid_final_output, the handler returns your OutputType directly — a validated fallback value — or returns None to decline and let the error propagate as before. That None escape valve matters: it lets you recover only the cases you understand and keep failing loudly on the ones you don't.
The non-obvious placement is what makes this worth writing down. Tool errors already had a home — failure_error_function on the tool itself. Flagged inputs and outputs already had a home — guardrails. What had no home was the failure the model commits at the moment of final composition: refusing, or answering in a shape your schema rejects. Run error handlers are that third layer. Once you see the three layers side by side — tool errors, guardrails, run errors — the taxonomy is obvious; before, invalid_final_output was just an unhandled exception with an expensive prologue.
It recovers; it does not retry#
Here is the part that changes how you actually use it. A recovery handler does not re-call the model, and it does not replay tool side effects. Returning a value from invalid_final_output substitutes that value as the final output and ends the run. It is an escape hatch, not a redo.
That single design choice should shape your fallback. Because there's no second model attempt, the value you return is the value you ship — so it has to be a defensible default in its own right, not a placeholder you assume something downstream will fix. For a structured extraction agent, that might mean returning a well-formed object with an explicit confidence: "none" field rather than a guessed answer. For a routing agent, it might mean returning the safe route, not the likely one. The handler is your chance to convert an ambiguous, expensive non-answer into a cheap, honest one — but only if you treat the fallback as a real output, because the SDK will.
And when the fallback is a canned "I couldn't do this," you usually don't want it lingering in the transcript to bias the next turn. That's what include_in_history=False is for: return the controlled output, keep the conversation clean.
Why the small releases are the tell#
v0.17.8 wasn't a marquee drop. Its companion fix, PR #3724, just enforces strict Pydantic validation when strict_json_schema=True meets handoffs — another quiet tightening of the output-integrity story. The next day brought v0.18.0. This is the cadence of a framework hardening its failure surface one seam at a time, and it's worth reading as a signal: the interesting problems in agent tooling have moved from can it call the tool to what happens at the exact boundary where the model hands you something you can't use.
If you run structured-output agents in production, the practical move is short. Set a max_turns you can afford, register an invalid_final_output handler that returns a schema-valid, honest fallback, and return None from it for the cases you'd rather see fail loudly. You will not notice it most days. On the day the model starts quietly emitting output your schema rejects, it's the difference between a bounded, legible failure and an afternoon of turns spent looking busy.



