Skip to content

feat(adapters/langgraph): make agent structured output not hang or silently vanish - #210

Open
spichen wants to merge 1 commit into
oracle:mainfrom
spichen:feat/agent-single-string-output
Open

feat(adapters/langgraph): make agent structured output not hang or silently vanish#210
spichen wants to merge 1 commit into
oracle:mainfrom
spichen:feat/agent-single-string-output

Conversation

@spichen

@spichen spichen commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Problem

create_agent(response_format=...) ties the agent's exit condition to getting a parseable structured response. The adapter set it for any agent with declared outputs, so a model that won't produce one broke two ways.

With no tools the agent loops. create_agent sets recursion_limit=9999 where LangGraph's default is 25, so it burns thousands of model calls and then raises a GraphRecursionError naming neither the agent nor the schema.

With tools it's quieter and worse. Routing treats a turn with no tool calls as done, so the agent exits, structured_response is never set, and a two-output agent returns zero outputs with no error (langchain#36349, open).

Nothing upstream bounds this. ToolStrategy.handle_errors only covers malformed tool calls; True, False and a custom message all still hang. See also langgraph#6731 (closed, not planned) and this forum thread. setup.py allows langchain>=1.2.0 unbounded, so it isn't one bad version.

Fix

An agent whose only declared output is a string needs no structured generation, so response_format is skipped and the value read from the final message. LlmNodeExecutor already worked this way; AgentNode now matches it.

Everything else keeps response_format plus a StructuredOutputGuard middleware. after_model bounds the loop by counting consecutive turns that end in an AIMessage with no tool calls. after_agent catches the silent exit, which that budget never sees. Turns with tool calls don't count, since under ToolStrategy the structured response is itself a tool call.

is_single_string_output moved to adapters/_utils.py, replacing two copies of the predicate in LlmNodeExecutor, one of them dead. AgentMiddleware now comes from _types.py as a LazyType, so the guard is a plain module-level class and the module still imports without the langgraph extra.

Testing

Against a model that ignores structured output:

before after
single string output GraphRecursionError (9999 calls) {'answer': '42'}
multi-field, no tools GraphRecursionError (9999 calls) error after 3 calls
multi-field, with a tool silently returned 0 of 2 outputs error
multi-field, compliant model works works

That last row is the one that matters: neither hook may fire on a model that does produce the response.

454 passing across tests/adapters, tests/serialization and tests/validation under SKIP_LLM_TESTS=1, pinned to constraints/constraints.txt; failures and skips unchanged from main. black, isort, pyflakes, mypy clean. The 10 new tests build no LLM config, so they run offline instead of skipping.

@spichen
spichen requested a review from a team July 25, 2026 17:22
@oracle-contributor-agreement oracle-contributor-agreement Bot added the OCA Verified All contributors have signed the Oracle Contributor Agreement. label Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

OCA Verified All contributors have signed the Oracle Contributor Agreement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant