Skip to content

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

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

spichen wants to merge 5 commits 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
@cesarebernardis
cesarebernardis self-requested a review August 31, 2026 12:53

@cesarebernardis cesarebernardis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the contribution, looks good to me. I just left a couple of minor comments, let me know when you have addressed them. I would also recommend to rebase on updated main. I will take over from there and merge the PR.

Comment thread pyagentspec/src/pyagentspec/adapters/langgraph/_structured_output.py Outdated
Comment thread pyagentspec/tests/adapters/langgraph/test_agent_output_guard.py
- 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
@dhilloulinoracle

Copy link
Copy Markdown
Contributor

Internal regression succeeded 🍏: Build ID #567

@fede-kamel fede-kamel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked out the PR head (24610c2) and ran tests/adapters/langgraph, tests/adapters/test_utils.py and the template-rendering tests with SKIP_LLM_TESTS=1: 160 passed, 90 skipped. Then reproduced the claims with a FakeMessagesListChatModel injected through the adapter (patched _llm_convert_to_langgraph, recursion_limit=12), both as a standalone agent and as an AgentNode inside a Flow:

case result on this PR
2 outputs, no tools, model answers in prose StructuredOutputNotProducedError after 3 model calls
2 outputs, one tool, tool call then prose (standalone and in an AgentNode) StructuredOutputNotProducedError after 2 calls ("ended its run without one")
2 outputs, one tool, compliant structured call terminates, outputs correct, guard silent
single string output without default, prose (AgentNode) {'llm_output': '180'}
single string output with default: '', prose (AgentNode) {'llm_output': ''} (finding 1)
2 outputs, one tool, structured call with invalid arguments repeated GraphRecursionError after 6 model calls at limit 12 (finding 3)

So the guard behaves as described, including inside Flows. Three findings and one housekeeping note:

  1. _node_execution.py, extract_outputs_from_invoke_result, new single-string branch. Declared defaults are merged into outputs before the if title not in outputs check, so a single string output that has a default returns the default instead of the final message. That is the shape of the CTS simple_agent_component.yaml (llm_output with default: ''), which is one of the configs behind #227, so the CTS agent tests would still get ''. Deciding "already provided" from the result rather than from outputs fixes it:

    provided = title in result or title in dict(result.get("structured_response") or {})
    if not provided:
        ...  # read the final message
  2. Same branch, content type. messages[-1].content can be a list of content blocks (AIMessage(content=[{"type": "text", "text": "180"}])); the node executor then json.dumps it into '[{"type": "text", "text": "180"}]'. messages[-1].text (a property in langchain-core 1.x) returns the concatenated text.

  3. Counter reset on any tool call. A model that keeps calling the structured-output tool with invalid arguments never increments the counter (each turn has a tool call), and ToolStrategy(handle_errors=True) keeps retrying, so that case is still bounded only by the recursion limit (9999 under create_agent, i.e. thousands of calls). Is that in scope here? Two cheap options would be counting consecutive turns whose only tool call is the structured-output tool, or documenting that handle_errors governs it.

  4. The branch is based on 6f0b6ae; main has since reorganized the changelog (1362da5), so the changelog hunk will conflict on rebase.

Two notes on interplay with PRs of mine, for the maintainers' ordering decision:

  • #260 (for #224) also edits extract_outputs_from_invoke_result: when no structured response exists it recovers the outputs from the final message if they fit the declared schema, and otherwise falls back to defaults with a warning. With this PR the prose-after-tool case raises instead, so the two choose different behaviour for the same situation. If this PR lands first I will cut #260 down to the default-precedence fix from finding 1; alternatively the guard could attempt that recovery before failing.
  • #261 passes the flow run's recursion_limit on to AgentNode agents; it is independent of this PR and would also bound finding 3 inside Flows.

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.

4 participants