Conversation
cesarebernardis
left a comment
There was a problem hiding this comment.
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.
- 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
3bd39a5 to
1b2491c
Compare
|
Internal regression succeeded 🍏: Build ID #567 |
fede-kamel
left a comment
There was a problem hiding this comment.
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:
-
_node_execution.py,extract_outputs_from_invoke_result, new single-string branch. Declared defaults are merged intooutputsbefore theif title not in outputscheck, so a single string output that has a default returns the default instead of the final message. That is the shape of the CTSsimple_agent_component.yaml(llm_outputwithdefault: ''), which is one of the configs behind #227, so the CTS agent tests would still get''. Deciding "already provided" from the result rather than fromoutputsfixes it:provided = title in result or title in dict(result.get("structured_response") or {}) if not provided: ... # read the final message
-
Same branch,
contenttype.messages[-1].contentcan be a list of content blocks (AIMessage(content=[{"type": "text", "text": "180"}])); the node executor thenjson.dumpsit into'[{"type": "text", "text": "180"}]'.messages[-1].text(a property in langchain-core 1.x) returns the concatenated text. -
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 undercreate_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 thathandle_errorsgoverns it. -
The branch is based on 6f0b6ae;
mainhas 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_limiton toAgentNodeagents; it is independent of this PR and would also bound finding 3 inside Flows.
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_agentsetsrecursion_limit=9999where LangGraph's default is 25, so it burns thousands of model calls and then raises aGraphRecursionErrornaming 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_responseis never set, and a two-output agent returns zero outputs with no error (langchain#36349, open).Nothing upstream bounds this.
ToolStrategy.handle_errorsonly covers malformed tool calls;True,Falseand a custom message all still hang. See also langgraph#6731 (closed, not planned) and this forum thread.setup.pyallowslangchain>=1.2.0unbounded, so it isn't one bad version.Fix
An agent whose only declared output is a string needs no structured generation, so
response_formatis skipped and the value read from the final message.LlmNodeExecutoralready worked this way;AgentNodenow matches it.Everything else keeps
response_formatplus aStructuredOutputGuardmiddleware.after_modelbounds the loop by counting consecutive turns that end in anAIMessagewith no tool calls.after_agentcatches the silent exit, which that budget never sees. Turns with tool calls don't count, since underToolStrategythe structured response is itself a tool call.is_single_string_outputmoved toadapters/_utils.py, replacing two copies of the predicate inLlmNodeExecutor, one of them dead.AgentMiddlewarenow comes from_types.pyas aLazyType, so the guard is a plain module-level class and the module still imports without thelanggraphextra.Testing
Against a model that ignores structured output:
GraphRecursionError(9999 calls){'answer': '42'}GraphRecursionError(9999 calls)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/serializationandtests/validationunderSKIP_LLM_TESTS=1, pinned toconstraints/constraints.txt; failures and skips unchanged frommain.black,isort,pyflakes,mypyclean. The 10 new tests build no LLM config, so they run offline instead of skipping.