Skip to content

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

Open
spichen wants to merge 2 commits into
mainfrom
feat/agent-single-string-output
Open

feat(adapters/langgraph): make agent structured output not hang or silently vanish#29
spichen wants to merge 2 commits into
mainfrom
feat/agent-single-string-output

Conversation

@spichen

@spichen spichen commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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. Upgrading doesn't help: the behaviour is identical at both ends of the range the pins allow, checked on langchain 1.3.1/1.3.14, langchain-core 1.4.0/1.4.9 and langgraph 1.2.0/1.2.9. Same loop, same silent exit, same recursion_limit=9999.

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 force-pushed the feat/agent-single-string-output branch 3 times, most recently from 711abb5 to bd33dfd Compare July 25, 2026 12:00
@spichen spichen changed the title feat(adapters/langgraph): take a single string agent output from the final message feat(adapters/langgraph): make agent structured output not hang on non-compliant models Jul 25, 2026
@spichen
spichen force-pushed the feat/agent-single-string-output branch 3 times, most recently from 47a3d39 to cd19935 Compare July 25, 2026 16:48
@spichen spichen changed the title feat(adapters/langgraph): make agent structured output not hang on non-compliant models feat(adapters/langgraph): make agent structured output not hang or silently vanish Jul 25, 2026
@spichen
spichen force-pushed the feat/agent-single-string-output branch from cd19935 to 4373852 Compare July 25, 2026 16:55
- dunder-wrap the guard's state key (__structured_output_attempts__) so
  it cannot collide with a field the agent's own state schema declares
- document how after_model classifies each turn and why an
  unclassifiable one leaves the counter untouched instead of resetting
- rename _structured_output.py to _agent_output_guard.py and the test
  file to match; the is_single_string_output test moves next to _utils
@spichen
spichen force-pushed the feat/agent-single-string-output branch from 3bd39a5 to 1b2491c Compare August 31, 2026 17:18
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.

1 participant