Skip to content

feat(adapters/langgraph): bound an agent whose model will not produce structured output - #43

Merged
spichen merged 1 commit into
spichen:oi-forkfrom
MAabdelmoumen:guard/OA-1681-structured-output-guard
Sep 24, 2026
Merged

spichen merged 1 commit into
spichen:oi-forkfrom
MAabdelmoumen:guard/OA-1681-structured-output-guard

Conversation

@MAabdelmoumen

Copy link
Copy Markdown

Backports StructuredOutputGuard from oracle#210.

Why

oi-fork took the single-string shortcut from oracle#210 in 0242c9a, but not the guard that pairs with it. So every agent here that keeps response_format is unbounded.

Without it, a model that never produces a structured response fails two ways:

  • no tools — the agent loops. create_agent sets recursion_limit=9999, so that is thousands of model calls before a GraphRecursionError naming neither the agent nor the schema.
  • with tools — routing treats a turn with no tool calls as done, so the agent exits with no structured_response and the declared outputs come back empty, with no error at all (langchain#36349).

This became more pressing after f111e7a: gating on the declared output format moves one-field JSON schemas out of the shortcut and into the response_format path, which is the unguarded one.

What it does

after_model counts consecutive turns ending in an AIMessage with no tool calls. after_agent catches the silent exit that budget never sees. Turns with tool calls do not count, since under ToolStrategy the structured response is itself a tool call.

Attached only when a response model was built, so it composes with f111e7a: an agent declaring TEXT never builds one and never sees the guard.

Testing

Local kind stack, model nvidia/Qwen3.8-27B-NVFP4:

case result
JSON, one String field {"sentiment":"positive"}, 2 steps, guard fires after_model then after_agent
Output format TEXT prose, 1 step, zero StructuredOutputGuard.* events
JSON, two fields both fields, 2 steps
flow: agent → branch on the field routes correctly, no ValueError

The TEXT case is the one that matters: the raw event stream shows the guard is never attached when no response model is built, so plain chat agents are untouched.

Not exercised: a model that actually refuses structured output — the registry here offers only one model and it complies. The guard's firing path is therefore covered by oracle#210's own tests rather than mine.

Scope

Guard source is taken unchanged from oracle#210. Only the wiring and the _types.py exports are local. oracle#210 also moves is_single_string_output to adapters/_utils.py; that is omitted here to keep the diff small.

Refs OA-1681

… structured output

Backports StructuredOutputGuard from oracle#210. oi-fork took the
single-string shortcut from that PR in 0242c9a but not the guard that pairs
with it, so every agent that keeps response_format is unbounded here.

Without it a model that never produces a structured response fails two ways:

* no tools: the agent loops. create_agent sets recursion_limit=9999, so that
  is thousands of model calls before a GraphRecursionError naming neither the
  agent nor the schema.
* with tools: routing treats a turn with no tool calls as done, so the agent
  exits with no structured_response and the declared outputs come back empty,
  with no error at all (langchain#36349).

The guard bounds both. after_model counts consecutive turns ending in an
AIMessage with no tool calls; after_agent catches the silent exit that budget
never sees. Turns with tool calls do not count, since under ToolStrategy the
structured response is itself a tool call.

Attached only when a response model was built, so it composes with the output
format gate added in f111e7a: an agent that declares TEXT never builds one and
never sees the guard.

Guard source is taken unchanged from oracle#210. The wiring, _types.py exports and
this commit are the only local parts; oracle#210 also moves is_single_string_output
to adapters/_utils.py, which is omitted here to keep the diff small.

Refs OA-1681
@spichen
spichen merged commit 60e23e2 into spichen:oi-fork Sep 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants