Tag: LangGraph

  • Resolving Pydantic State Deserialization Failures in LangGraph Checkpointers

    Resolving Pydantic State Deserialization Failures in LangGraph Checkpointers

    TL;DR

    • Pydantic state deserialization problems in LangGraph checkpointers come from four distinct mechanisms with different symptoms: msgpack allowlist restrictions under strict mode, classes that cannot be re-imported (dynamic or local scope), schema drift between old checkpoints and newer models, and non-serializable attributes that actually fail when the checkpoint is written.
    • The usual symptom is silent degradation, not a raised exception: JsonPlusSerializer returns the raw kwargs dictionary (or a validation-skipped model_construct object) when it cannot rehydrate a model, so failures often surface later as an AttributeError or KeyError inside a node rather than at load time.
    • Fixes are cause-specific: register models in the msgpack allowlist for allowlist blocks, move models to module top level for import failures, add field defaults plus a model_validator or reducer for schema drift, and keep runtime objects out of state fields for the save-time case.

    Understanding LangGraph State Serialization with JsonPlusSerializer

    In LangGraph, state persistence across execution supersteps is managed by checkpointer implementations extending BaseCheckpointSaver, such as MemorySaver, SqliteSaver, and PostgresSaver. These checkpointers delegate data encoding and decoding to serialization protocols located in langgraph.checkpoint.serde. The default serialization engine is JsonPlusSerializer (langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer), which encodes state primarily through msgpack, with a JSON path as fallback, while preserving standard Python types, LangChain and LangGraph primitives, and Pydantic BaseModel instances.

    When a checkpoint is loaded, the serializer rebuilds each custom object from a stored (module, class_name, kwargs) triple. Success depends on two independent conditions: whether the module passes the deserialization allowlist, and whether the class can actually be imported and constructed. By default—when LANGGRAPH_STRICT_MSGPACK is unset—the msgpack policy is permissive (allowed_msgpack_modules=True): unregistered modules are still reconstructed and only a warning is logged. Strict mode, enabled by LANGGRAPH_STRICT_MSGPACK=true or by passing an explicit allowlist, blocks modules that are not listed. The important detail is that blocking does not raise—the serializer returns the raw data instead.

    Common Causes of Deserialization Failures

    Pydantic state deserialization issues within JsonPlusSerializer stem from four distinct mechanisms. They produce different symptoms and require different fixes, so they are worth separating:

    • msgpack Allowlist Restrictions (strict mode only): Under strict mode, JsonPlusSerializer checks each custom object’s module path before instantiating it. If a Pydantic model’s module is not on the allowlist, the msgpack Pydantic branch returns the stored kwargs dictionary unchanged—no exception, and no visible signal unless you are watching the warning log. The node then receives a dict where it expected a model. InvalidModuleError is a real type, but it is raised only on the separate JSON constructor path, and even there JsonPlusSerializer‘s reviver catches it, logs a warning, and falls back to the generic LangChain reviver—so it is not the symptom to code against.
    • Classes That Cannot Be Re-Imported: Rebuilding a class during deserialization requires resolving module.ClassName back to an importable symbol. Models defined inside a function body or closure, or parametrized generic models with no concrete top-level subclass, expose no such symbol. The lookup fails and the value deserializes as a raw dictionary—independent of allowlist configuration or strict mode. Adding the class to the allowlist does not help, because the problem is resolution, not permission.
    • Schema Drift Between Checkpoint and Model: When a state model gains a required field without a default, renames a field, or tightens a type, reading an older checkpoint does not surface a Pydantic ValidationError from the serializer. It tries cls(**kwargs), and on failure falls back to cls.model_construct(**kwargs), which skips validation and returns a partially populated model; if that also fails, it returns the raw dict. A ValidationError appears only later, when a reducer or node re-validates the value.
    • Non-Serializable Attributes (a save-time failure): Placing runtime artifacts that cannot be encoded—raw exception objects, thread locks, open client sessions, file handles—inside Pydantic BaseModel fields makes the checkpoint fail when it is written, not when it is read. Despite the “deserialization” label, the fix belongs on the encoding side: keep these objects out of persisted state.

    Resolution Strategies and Best Practices

    Each strategy targets a specific mechanism above rather than all of them at once.

    • Register Models in the msgpack Allowlist (cause 1): When running with strict msgpack mode, pass custom Pydantic models to allowed_msgpack_modules on JsonPlusSerializer—as class objects or (module, qualname) tuples—or call serde.with_msgpack_allowlist([...]) to obtain a new serializer with a merged allowlist. allowed_msgpack_modules and allowed_json_modules gate two different decode paths; msgpack carries the large majority of state, so it is the one that usually matters. Compiling a StateGraph with strict mode enabled already derives an allowlist from the state, input, output, and context schemas, so—when the chosen checkpointer supports with_allowlist(...)—top-level state models are covered without manual registration:
    from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
    from langgraph.checkpoint.sqlite import SqliteSaver
    from my_app.schemas import CustomStateModel
    
    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[CustomStateModel]  # or [("my_app.schemas", "CustomStateModel")]
    )
    checkpointer = SqliteSaver(conn, serde=serde)
    
    • Declare State Models at Top-Level Module Scope (cause 2): Define every Pydantic state schema, sub-model, and dataclass at the top level of an importable module rather than inside a nested function or closure. For generic models in Pydantic v2, subclass BaseModel together with typing.Generic[T] and declare an explicit concrete subclass at module scope, so the stored metadata maps to an importable symbol.
    • Design for Schema Resilience (cause 3): Give every new field a default via Field(default=...) or Field(default_factory=...) so old checkpoints still satisfy the current model. Then use @model_validator(mode="before") or a graph channel reducer to coerce any dictionary fallback payload back into the typed BaseModel before nodes observe it.
    • Keep Runtime Objects Out of State (cause 4): Persist identifiers or serializable configuration instead of live handles, and rebuild the runtime object inside the node. For genuinely custom encoding, implement SerializerProtocol (dumps_typed(obj) -> tuple[str, bytes] and loads_typed(data) -> Any) and pass it as the checkpointer’s serde.

    Sources: github.com, langchain.com, langchain.com, langchain.com, langchain.com

    Closing thoughts

    These four failure modes do not share a single root cause, and it is a mistake to treat them all as the price of security hardening. Only the msgpack allowlist check is a security control. Import-resolution failures are a packaging and code-organization problem; schema drift is a data-contract problem between a stored checkpoint and a newer model; non-serializable attributes are an encoding constraint that shows up before anything is ever read back. What the four have in common is only the outcome: JsonPlusSerializer prefers to hand back a raw dictionary or a validation-skipped object rather than raise, so a broken assumption travels silently into your graph.

    The practical consequence is that resilient graphs treat state models as version-stable data contracts and verify what actually comes out of the checkpointer. Define models at module top level so they can be re-imported, give every new field a default so old checkpoints still load, and add a model_validator or channel reducer that turns any dict fallback back into a typed model before a node touches it. Reserve allowlist configuration for the one case it addresses—strict msgpack mode—and enable strict mode deliberately rather than by accident.

    Frequently Asked Questions

    What are the primary causes of Pydantic state deserialization failures in LangGraph?

    Four distinct mechanisms: (1) under strict msgpack mode, a model whose module is not on the allowlist is returned as a raw dict; (2) classes defined in a function body, closure, or as an unsubclassed generic cannot be re-imported and also come back as dicts; (3) schema drift between an old checkpoint and a newer model, where the serializer falls back to model_construct or a raw dict rather than raising; and (4) non-serializable attributes such as locks, sessions, or exception objects in state fields, which fail when the checkpoint is written.

    What does a deserialization failure actually look like at runtime?

    Usually not an exception. JsonPlusSerializer returns the stored kwargs dictionary, or a model_construct object with validation skipped, when it cannot rehydrate a model. The failure typically surfaces later as an AttributeError or KeyError inside a node that expected a typed model. InvalidModuleError exists but is confined to the JSON constructor path and is caught internally; a ValidationError appears only if a reducer or node re-validates the value.

    How do you register custom Pydantic models for the msgpack allowlist?

    Pass them to allowed_msgpack_modules on JsonPlusSerializer, as class objects or (module, qualname) tuples, or call serde.with_msgpack_allowlist([...]) to get a new serializer with a merged allowlist. When you compile a StateGraph with strict mode enabled, LangGraph already derives an allowlist from the state, input, output, and context schemas, so top-level state models are covered without manual registration—provided the chosen checkpointer supports with_allowlist(...).

    Why must state models be defined at top-level module scope?

    Deserialization resolves a stored module.ClassName back to an importable symbol. Models defined inside nested functions, or generics without a concrete top-level subclass, have no such symbol, so they deserialize as raw dictionaries no matter how the allowlist is configured.

    How do you keep old checkpoints loading after a schema change?

    Add defaults with Field(default=...) or Field(default_factory=...) for every new field, and add a @model_validator(mode="before") or a channel reducer that coerces a plain-dict fallback back into the typed model before nodes run.

  • Fixing LangGraph GraphRecursionError and LangChain Agent Infinite Loops

    Fixing LangGraph GraphRecursionError and LangChain Agent Infinite Loops

    TL;DR

    • GraphRecursionError: Recursion limit of N reached without hitting a stop condition means a custom LangGraph run executed N super-steps without any node routing to END. N is whatever recursion_limit is set to for that run.
    • The familiar 25 is the LangGraph JS default and was the LangGraph Python default before v1.0.6. Current LangGraph Python defaults much higher (the docs say 1000; the source constant is 10007), so if you are on recent Python and still see a small number, a template, langgraph-cli, or your own config set it.
    • The prebuilt create_react_agent does not raise on this condition. When its step budget runs low it returns an AIMessage reading Sorry, need more steps to process this request. instead, so “the agent quietly gave up” and GraphRecursionError are two faces of the same loop.
    • Diagnose by streaming with stream_mode="updates" (or a LangSmith trace) to see which node repeats, then fix the routing: make the conditional edge depend on state that actually advances, add a path to END, or cap steps gracefully with the RemainingSteps managed value.
    • Legacy LangChain AgentExecutor loops have their own signatures: a silent max_iterations cutoff or an OutputParserException retry storm (Could not parse LLM output: / ... is not a valid tool, try another one.). Same idea, different knobs, covered at the end.

    GraphRecursionError: what “recursion limit reached” actually means

    When a LangGraph graph runs, execution advances in super-steps. A super-step is one round of the Pregel loop: every node with input available runs, and nodes that run in parallel in the same round count as a single super-step. recursion_limit is the ceiling on super-steps for a run. When the loop reaches it without any node handing control to END, LangGraph raises:

    langgraph.errors.GraphRecursionError: Recursion limit of N reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
    

    N in that message is your configured limit, not a fixed constant. Where the well-known 25 comes from:

    • LangGraph JS still defaults to 25.
    • LangGraph Python before v1.0.6 defaulted to 25 as well.
    • LangGraph Python from v1.0.6 on raised the default sharply. The applied runtime value is the source constant DEFAULT_RECURSION_LIMIT (currently 10007, overridable with the LANGGRAPH_DEFAULT_RECURSION_LIMIT environment variable); the docs round it down to “1000 steps.” That exact number is an internal detail and has shifted between releases, but it is firmly in the thousands, not 25.

    So a small N on recent LangGraph Python is a signal in itself: a tutorial snippet, langgraph-cli, a project template, or an explicit {"recursion_limit": ...} in your own code set it. Search your codebase for recursion_limit before assuming the framework picked the number.

    Whatever N is, the limit is a circuit breaker, not the bug. Hitting it tells you one of two things is true:

    • the graph legitimately needs more than N super-steps for this input (plausible when N is 25; almost never when N is 1000+), or
    • the graph is cycling: a node, or a small group of nodes, keeps handing control back and forth and the state that should move the run toward a stop condition never changes.

    Agents built with create_react_agent from langgraph.prebuilt are a two-node loop: the model node proposes tool calls, the tool node runs them, control returns to the model. If the model keeps asking for tools and never returns a final answer, the loop still has to be stopped, but the prebuilt agent stops it for you. Its default state carries a remaining_steps value, derived from recursion_limit minus the current step. After each model response, _are_more_steps_needed() checks whether that response still wants tools and how many steps are left; when only one or two remain, the agent returns AIMessage("Sorry, need more steps to process this request.") instead of proceeding. Because remaining_steps tracks recursion_limit, raising the limit never converts that message into a GraphRecursionError — you only get the exception from the prebuilt agent if you pass a custom state schema that omits remaining_steps. When recursion_limit is small (JS, older Python, or an explicit low setting) you hit this quickly and see the message; when it is in the thousands, the loop burns that many model calls first, so you usually notice the cost and latency before the message ever appears.

    Sources: GRAPH_RECURSION_LIMIT, LangGraph Graph API

    Step 1 — Unblock the run (and when raising the limit is legitimate)

    First find out what your limit actually is and what set it. recursion_limit is a run-level config value, not a graph-construction argument, so grep for it in your own code, your templates, and any langgraph-cli config. If you are on recent LangGraph Python and the number in the error is small, that search is where the fix usually is — remove the low override, or set it deliberately.

    Set it explicitly when you invoke or stream:

    # per invocation
    result = graph.invoke(inputs, {"recursion_limit": 50})
    
    # streaming
    for chunk in graph.stream(inputs, {"recursion_limit": 50}):
        ...
    
    # bind it once to a prebuilt agent
    agent = create_react_agent(model, tools).with_config({"recursion_limit": 50})
    

    Sizing it: one tool call costs two super-steps (the model node, then the tool node). Count the tool calls a successful run makes, multiply by two for super-steps, then double again for retries and reflection. An agent that normally makes ten tool calls does ~20 super-steps of useful work, so recursion_limit around 40 leaves headroom without hiding a runaway; a fixed three-step pipeline runs fine with 10. A tight explicit limit like this is worth keeping on LangGraph Python too — the ~1000/10007 default is so high that a real loop wastes thousands of model calls before it trips.

    Do not reach for a huge number to make the error disappear. If you cannot explain why the run needs that many super-steps, a bigger limit only buys a longer, more expensive failure. Diagnose first.

    Sources: GRAPH_RECURSION_LIMIT

    Step 2 — Find the node that is looping

    The fastest way to see a loop is to print what each super-step does. Stream with stream_mode="updates", which yields one {node_name: state_delta} dict per node execution:

    for step in graph.stream(inputs, stream_mode="updates"):
        for node, delta in step.items():
            print(node, "->", delta)
    

    In a healthy run the node names advance and each delta carries new information. In a loop you see the same node (or the same short A -> B -> A sequence) repeating, and the deltas are empty, identical, or oscillating between two values. That node, and the edge that routes back into it, is where the bug is.

    A few more ways to narrow it down:

    • stream_mode="debug" emits task and task_result events with the full input and output of every node, which is useful when the delta alone does not explain the routing decision.
    • A LangSmith trace renders the run as a tree; a cycle shows up as an obviously repeating branch. This is the least effort option if tracing is already enabled.
    • Inside any node you can read config["metadata"]["langgraph_step"] to know which super-step you are on, which is handy for a targeted print or breakpoint once you know roughly where the loop is.

    Sources: LangGraph Graph API

    Step 3 — Fix the actual cause

    Once you know which node repeats, the cause is almost always one of these.

    The router never advances

    A conditional edge decides the next node from a field in state. If the node that is supposed to update that field does not (a missing return key, an overwritten reducer, a typo), the router sees the same value forever and keeps choosing the same branch.

    def route(state: State) -> str:
        # loops forever if `state["status"]` is never set to "done" by any node
        return END if state["status"] == "done" else "worker"
    

    Fix it by making sure the node writes the field the router reads, and by routing on something that measurably progresses (a counter, a shrinking work queue, a done flag that a node actually sets).

    There is no path to END

    Every cyclic graph needs at least one edge, usually a conditional one, that can reach END. If every branch leads back into the cycle, the only exit is the recursion limit. Add the terminating condition explicitly.

    create_react_agent keeps asking for tools

    The symptom is usually the Sorry, need more steps to process this request. message (a higher recursion_limit only makes the run longer and more expensive before it returns that same message), because the model keeps emitting tool calls instead of a final answer. Common reasons: a tool always raises and returns an unhelpful error string, so the model retries it; the tool docstring does not say what the result means or when the task is complete; the system prompt never tells the model to answer directly once it has enough information. Fix the tool’s description and its error/return payload, add an explicit “when you have the answer, respond without calling a tool” instruction, raise recursion_limit only if the task genuinely needs more calls, and deduplicate repeated calls (below).

    Two nodes ping-pong

    Unconditional A -> B and B -> A edges are an infinite loop by construction. One of the two edges has to be conditional and able to leave the cycle.

    Cap steps gracefully with RemainingSteps

    To return a partial answer instead of crashing, add the RemainingSteps managed value to your state. LangGraph populates it with how many super-steps are left before the limit, so a node can bail out early:

    from langgraph.managed import RemainingSteps
    from typing import Annotated, TypedDict
    from operator import add
    
    class State(TypedDict):
        messages: Annotated[list, add]
        remaining_steps: RemainingSteps
    
    def worker(state: State):
        if state["remaining_steps"] <= 2:
            return {"messages": [("assistant", "Stopping early with a partial result.")]}
        ...
    

    Break out from inside a node with Command

    A node (or a tool in LangGraph) can return a Command to both update state and jump straight to a terminal node, which is useful when a node detects a cancellation or an unrecoverable condition mid-run:

    from langgraph.graph import END
    from langgraph.types import Command
    
    def guard(state: State) -> Command:
        if state.get("cancelled"):
            return Command(goto=END, update={"messages": [("assistant", "Cancelled.")]})
        return Command(goto="worker")
    

    Sources: LangGraph Graph API, ReAct Agent doesn't throw GraphRecursionError

    Loop-detection guard: stop repeated tool calls

    Most runaway agents repeat the same action. A small amount of state plus one check catches that before the recursion limit does. Record a signature of each tool call and stop when it repeats consecutively:

    import json
    
    def tool_guard(state: State):
        calls = state.get("recent_calls", [])
        last = state["messages"][-1]
        sig = None
        if getattr(last, "tool_calls", None):
            tc = last.tool_calls[0]
            sig = json.dumps([tc["name"], tc["args"]], sort_keys=True)
    
        if sig and calls[-2:] == [sig, sig]:          # 3rd identical call in a row
            return {"messages": [("assistant",
                                  "Repeated the same tool call three times; stopping to summarize.")],
                    "route": "summarize"}
        return {"recent_calls": (calls + [sig])[-5:] if sig else calls}
    

    Wire it in as a node between the model and the tools, or as pre_model_hook / middleware if you are on a framework version that supports it. The point is the same: detect “no progress” (identical payloads, repeated stderr, the same observation N times) and force a stop, summarize, propose alternatives turn instead of another identical step.

    Sources: LangGraph Graph API

    Legacy LangChain AgentExecutor loops

    The pre-LangGraph agent runtime, AgentExecutor (now shipped in langchain / langchain-classic), has its own loop controls and its own failure signatures.

    Execution caps

    • max_iterations limits intermediate steps and defaults to 15. Setting it to None removes the cap entirely, which is what turns a misbehaving agent into an unbounded one.
    • max_execution_time (default None) is a wall-clock limit in seconds.
    • early_stopping_method (default "force") controls what happens when a cap is hit. "force" returns a fixed response, Agent stopped due to iteration limit or time limit. The API also documents "generate" (one more LLM call to synthesize an answer from the steps gathered so far), but several LangChain versions raise a ValueError about an unsupported early_stopping_method when you actually pass it, so treat "force" as the value you can rely on.
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        max_iterations=15,
        max_execution_time=60,
        early_stopping_method="force",
        handle_parsing_errors=True,
    )
    

    ReAct parsing deadlocks

    A ReAct agent ends a run by emitting an exact string (historically a Final Answer: line). If the model’s format drifts, the output parser raises OutputParserException: Could not parse LLM output:. With handle_parsing_errors=True the executor feeds the error back and asks the model to reformat; a model that never produces a parseable answer will do this until max_iterations cuts it off. Tightening the format instructions in the prompt is the real fix; handle_parsing_errors is a seatbelt.

    The “not a valid tool” loop

    A recurring report: an agent calls a tool name that is not registered, gets back ... is not a valid tool, try another one., and its next thought decides it still needs that same tool. It calls the invalid tool again, gets the same observation, and repeats until it gives up with “I don’t know”. One documented case hit this while inspecting a SQL database (repeated list_tables_sql_db calls on LangChain 0.0.215 / Python 3.10.11); a related case appeared when an agent was wrapped as a tool and loaded into a second agent, after which every tool call in the outer agent came back invalid. The mechanism is the same as the modern create_react_agent case: a bad observation the model is not able to recover from, repeated because nothing stops it.

    New code should prefer LangGraph or create_react_agent over AgentExecutor, but the diagnosis is identical: find the step that repeats, then remove the reason it repeats.

    Sources: AgentExecutor reference, list_tables_sql_db is not a valid tool, try another one., {tool_name} is not a valid tool, try another one.

    Closing thoughts

    recursion_limit and max_iterations are financial circuit breakers. They keep a broken run from getting expensive, but a run that hits them is telling you the graph has no reliable path to a stop condition for that input.

    The durable fix is a graph whose state moves monotonically toward termination: every cycle has a conditional edge that can reach END, the field that edge checks is written by a node on every pass, and “no progress” is itself a terminal condition rather than something you wait out. Instrument the run with stream_mode="updates" or LangSmith so a loop is visible in seconds, add RemainingSteps so the graph degrades to a partial answer instead of an exception, and add a small duplicate-call guard so the common “same action forever” failure is caught early. Keep an explicit, tight recursion_limit regardless of platform, since the current LangGraph Python default is high enough to let a loop run for thousands of steps. With those in place, an agent that wanders into a bad path pauses and pivots instead of repeating until the limit and crashing.

    Frequently Asked Questions

    How do I fix GraphRecursionError in LangGraph?

    Decide first whether the graph is looping or just long. Stream the run with stream_mode="updates" and look for a node that repeats with no change to state. If it is a real loop, fix the routing: make the conditional edge depend on a state field that a node updates every pass, ensure some branch can reach END, or add a RemainingSteps check that returns a partial result. Only if the graph genuinely needs more steps, raise the cap for that call with graph.invoke(inputs, {"recursion_limit": 50}).

    What does “Recursion limit of N reached without hitting a stop condition” mean?

    Your LangGraph run executed N super-steps (rounds of the node-execution loop) and no node ever routed to END. N is whatever recursion_limit was set to for the run. The message almost always indicates a cycle where the state that should trigger termination never changes.

    What is the default recursion limit in LangGraph?

    It depends on the platform and version. LangGraph JS defaults to 25. LangGraph Python defaulted to 25 through v1.0.5, then raised it from v1.0.6 on — the applied value is the source constant DEFAULT_RECURSION_LIMIT (currently 10007, settable via LANGGRAPH_DEFAULT_RECURSION_LIMIT), which the docs simplify to “1000 steps.” Treat it as “in the thousands”; the exact figure is an internal detail. Parallel nodes in one round count as a single super-step, so the limit is on rounds of the loop, not total node executions.

    I am on recent LangGraph Python and still see “Recursion limit of 25” — why?

    Because 25 is no longer the Python default. Something set it: a tutorial or docs snippet, a langgraph-cli config, a project template, create_react_agent example code, or an explicit {"recursion_limit": 25} in your invoke/stream call. Grep your code and config for recursion_limit.

    Should I just increase recursion_limit?

    Only if you can explain why the run needs more super-steps. Raising it is correct for a legitimately long graph on a low limit. If you do not know why the graph loops, a higher limit just produces a slower, more expensive failure; diagnose the cycle first.

    How do I find which node is causing the loop?

    Run for step in graph.stream(inputs, stream_mode="updates"): print(step) and watch the node names. A loop shows up as the same node, or a short repeating sequence of nodes, emitting empty or identical state deltas. stream_mode="debug" and a LangSmith trace give the same picture with more detail.

    How is recursion_limit different from LangChain’s max_iterations?

    recursion_limit is a LangGraph run-config value that caps super-steps and raises GraphRecursionError when a custom graph exceeds it. max_iterations is an AgentExecutor constructor argument that caps intermediate steps in the legacy agent runtime and, on reaching the cap, returns a stopped-response string rather than raising (default 15).

    How do I return a partial result instead of raising GraphRecursionError?

    Add the RemainingSteps managed value to your state and check it inside your nodes: when remaining_steps is down to 1 or 2, return a summary or route to a dedicated “wrap up” node instead of continuing the loop. That way the graph stops itself before LangGraph’s hard limit does.

    Why does my LangGraph agent return “Sorry, need more steps to process this request.” instead of an error?

    That message comes from the prebuilt create_react_agent. Its state includes a remaining_steps budget, and when it is nearly exhausted the agent returns AIMessage("Sorry, need more steps to process this request.") rather than raising GraphRecursionError. It means the same thing as the exception: the model kept calling tools without producing a final answer. Raise recursion_limit only if the task genuinely needs more calls; otherwise diagnose the tool/model loop with stream_mode="updates".