LangGraph ToolNode & tool_calls Format Errors — Fix Guide (2026)
Agents · ToolNode Severity: High

LangGraph ToolNode & tool_calls Format Errors

ToolNode looks trivial until the model calls a tool you didn't register, or the response comes back without a tool_call_id, or Anthropic rejects the whole conversation because ordering broke. Here's the anatomy of a valid tool round-trip and the three failure modes that account for most of the noise.

TL;DRToolNode reads the last AIMessage's tool_calls, runs each matching tool, and appends one ToolMessage per call with the matching tool_call_id. Failures are almost always (1) unknown tool name (typo or missing registration), (2) ToolMessage emitted without tool_call_id, or (3) parallel tool_calls where one tool fails and stops the whole batch. Fix by registering tools by name, always setting tool_call_id, and using handle_tool_errors=True.

Real error messages you'll see

KeyError — tool not found
KeyError — tool not found
KeyError: 'search_web'
  at ToolNode.__call__ — model called tool 'search_web' but ToolNode only knows ['search', 'calculator'].
# Tool name in the model output must exactly match a registered tool. Check for underscore vs hyphen and pluralization.
BadRequestError — tool_use / tool_result ordering
BadRequestError — tool_use / tool_result ordering
anthropic.BadRequestError: Error code: 400 - {"type": "invalid_request_error",
  "message": "messages: unexpected `tool_use_id` 'toolu_abc123' without a preceding `tool_use` block with that id"}
# You emitted a ToolMessage for a tool_call_id that doesn't appear in the immediately-preceding AIMessage. Ordering matters.
ValidationError — tool args
ValidationError — tool args
pydantic_core._pydantic_core.ValidationError: 1 validation error for SearchArgs
top_k
  Input should be a valid integer [type=int_type, input_value='five', input_type=str]
# Model produced valid JSON but the value for top_k was a string. Add coercion in the schema or catch the error inside the tool and return an error message.

A valid tool round-trip — what the messages look like

StepMessage typeKey fields
1HumanMessagecontent="what is 27*43?"
2AIMessagetool_calls=[{"id": "toolu_x", "name": "calc", "args": {...}}]
3ToolMessagetool_call_id="toolu_x", content="1161"
4AIMessagecontent="27 × 43 = 1,161"
BadToolMessage without id400 from model provider on next turn
BadAIMessage tool_calls followed by no ToolMessageNext model call rejects — unresolved tool_use

Root causes (ranked by frequency)

Based on LangGraph developer reports; percentages sum to 100%.

  • 24%
    Tool name mismatch. Model emitted "search_web" but tool is registered as "search". Usually because two tools have similar purposes and the model picked the wrong one, or the tool was renamed and the model was primed on the old name.
  • 19%
    Missing tool_call_id on ToolMessage. Custom node emitted a ToolMessage without the ID from the originating tool_call. Anthropic and OpenAI both reject the next turn.
  • 14%
    Tool args validation failure. Model produced JSON that doesn't match the tool's Pydantic schema. Type coercion or schema loosening usually fixes it; catching and returning an error message inside the tool always fixes it.
  • 12%
    Parallel tool_calls with one failure. AIMessage has 3 tool_calls; the second raises. Without handle_tool_errors=True, the whole ToolNode aborts and none of the results are emitted.
  • 10%
    Tool response too large. A search that returns 200KB of HTML gets emitted verbatim as ToolMessage content. Next model call blows the context window.
  • 8%
    Async tool run through sync ToolNode. Tool defined as async def but agent invoked with app.invoke. Node runs the coroutine object; result is nonsense.
  • 7%
    Tool raised an exception with no handler. Uncaught exception in tool implementation bubbles up and terminates the graph. Wrap risky logic and return error strings.
  • 6%
    Duplicate tool names across imports. Two @tool functions named search in different files. Whichever is registered last silently wins.

How to fix it

Fix #1

Always emit tool_call_id and keep the ordering strict

Fixes the "unexpected tool_use_id" 400 from the model provider.

Both Anthropic and OpenAI validate tool round-trip ordering: an AIMessage with tool_calls must be immediately followed by one ToolMessage per call, matched by ID, before the next AIMessage. If you're writing a custom tool-handling node instead of using ToolNode, replicate this exactly.

custom_tool_node.pypython
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import BaseTool


def build_custom_tool_node(tools: list[BaseTool]):
    """Roll your own ToolNode — useful when you need custom error handling."""
    tools_by_name = {t.name: t for t in tools}

    def tool_node(state):
        last = state["messages"][-1]
        if not isinstance(last, AIMessage) or not last.tool_calls:
            return {}   # nothing to do

        results = []
        for call in last.tool_calls:
            tool_name = call["name"]
            tool_id = call["id"]              # <-- REQUIRED — pair with the call
            tool_args = call["args"]

            if tool_name not in tools_by_name:
                # Emit an error ToolMessage — do NOT omit the response entirely,
                # or the model provider will reject the next turn.
                results.append(ToolMessage(
                    content=f"Error: tool '{tool_name}' is not available.",
                    tool_call_id=tool_id,
                    status="error",
                ))
                continue

            try:
                output = tools_by_name[tool_name].invoke(tool_args)
                results.append(ToolMessage(
                    content=str(output),
                    tool_call_id=tool_id,     # <-- match by id
                ))
            except Exception as e:
                # Always emit a ToolMessage even on failure
                results.append(ToolMessage(
                    content=f"Tool raised: {type(e).__name__}: {e}",
                    tool_call_id=tool_id,
                    status="error",
                ))

        return {"messages": results}

    return tool_node


# ✅ Wire into a graph
from langgraph.graph import StateGraph, START, END, MessagesState

def call_model(state):
    resp = model.bind_tools(tools).invoke(state["messages"])
    return {"messages": [resp]}


def route(state):
    last = state["messages"][-1]
    if isinstance(last, AIMessage) and last.tool_calls:
        return "tools"
    return END


graph = StateGraph(MessagesState)
graph.add_node("model", call_model)
graph.add_node("tools", build_custom_tool_node([search, calculator]))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", route)
graph.add_edge("tools", "model")   # back to model with tool results
app = graph.compile(checkpointer=memory)
Note: The prebuilt ToolNode handles all of this correctly. Only roll your own when you need custom error responses, per-tool timeouts, tool-call budgets, or logging that ToolNode doesn't expose.
Fix #2

Handle tool errors inside ToolNode — never let them escape

Fixes graph aborts when one of several parallel tool calls fails.

The prebuilt ToolNode takes a handle_tool_errors argument (True by default in current versions, but worth being explicit). When True, exceptions become ToolMessages with error content instead of terminating the graph. For fine control, pass a callable that formats the error message.

toolnode_error_handling.pypython
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool


@tool
def divide(a: float, b: float) -> float:
    """Divide a by b."""
    return a / b       # ZeroDivisionError if b == 0


@tool
def flaky_search(query: str) -> str:
    """Search that sometimes fails (simulating a real external service)."""
    import random
    if random.random() < 0.1:
        raise ConnectionError("Search service unavailable")
    return f"results for {query}"


# ✅ Default — errors become ToolMessage(status="error"), graph continues
tools = [divide, flaky_search]
tool_node = ToolNode(tools, handle_tool_errors=True)


# ✅ Custom error formatter — full control over what the model sees on failure
def format_error(exc: Exception) -> str:
    if isinstance(exc, ZeroDivisionError):
        return "Cannot divide by zero. Ask the user to provide a nonzero divisor."
    if isinstance(exc, ConnectionError):
        return f"Search is temporarily unavailable ({exc}). You can try answering from prior context or ask the user to retry."
    return f"Tool failed: {type(exc).__name__}: {exc}"

tool_node = ToolNode(tools, handle_tool_errors=format_error)


# ✅ Different handling per exception type — pass a callable that raises what you want to abort on
def handler(exc: Exception) -> str:
    if isinstance(exc, KeyboardInterrupt):
        raise                              # let interrupt propagate
    return f"Tool error: {exc}"

tool_node = ToolNode(tools, handle_tool_errors=handler)


# ✅ Add per-call logging with a subclass
class LoggingToolNode(ToolNode):
    def __init__(self, tools, **kw):
        super().__init__(tools, **kw)

    def _run_one(self, call, *args, **kw):
        import time, logging
        t0 = time.perf_counter()
        try:
            result = super()._run_one(call, *args, **kw)
            logging.info(
                "tool_call=%s ms=%d ok",
                call["name"], int((time.perf_counter() - t0) * 1000),
            )
            return result
        except Exception as e:
            logging.warning(
                "tool_call=%s ms=%d err=%s",
                call["name"], int((time.perf_counter() - t0) * 1000), e,
            )
            raise


# NOTE: private-method override is version-fragile — pin your LangGraph version if you rely on it.
tool_node = LoggingToolNode(tools, handle_tool_errors=True)
Note: When handle_tool_errors=True, the model sees the error content in a ToolMessage and can decide whether to retry, ask the user, or abandon the tool. This is the correct shape for graceful degradation — most models handle it sensibly.
Fix #3

Bound tool outputs to a reasonable size

Prevents context-window blowups from a single tool call.

A search tool that returns raw HTML, a database query that returns 10,000 rows, a file-read tool that reads a whole book — any of these can emit a ToolMessage whose content exceeds the model's context window. Truncate at the tool boundary, or emit a summary + a reference the model can drill into with a second call.

bounded_tools.pypython
from langchain_core.tools import tool
import textwrap
import json


# ❌ BUG — search dumps raw HTML, easily 50-500KB per call
@tool
def bad_search(query: str) -> str:
    """Search the web and return the raw HTML of the top result."""
    import requests
    r = requests.get(f"https://api.search.example/?q={query}")
    return r.text     # <-- could be huge


# ✅ Fix — bound the output; summarize + link
MAX_TOOL_OUTPUT_CHARS = 8_000

@tool
def search(query: str, top_k: int = 5) -> str:
    """Search the web. Returns up to top_k concise result summaries.
    Use `fetch_url` to read the full content of any result URL.
    """
    import requests
    r = requests.get(
        f"https://api.search.example/", params={"q": query, "n": top_k}
    ).json()
    lines = []
    for hit in r["results"][:top_k]:
        lines.append(f"- [{hit['title']}]({hit['url']})\n  {hit['snippet'][:200]}")
    result = "\n".join(lines)
    return _truncate(result)


@tool
def fetch_url(url: str, max_chars: int = 8_000) -> str:
    """Fetch and return the readable text of a URL. Truncates to max_chars."""
    import requests
    from bs4 import BeautifulSoup
    r = requests.get(url, timeout=10)
    text = BeautifulSoup(r.text, "html.parser").get_text(" ", strip=True)
    return _truncate(text, max_chars)


@tool
def query_db(sql: str) -> str:
    """Run a SQL SELECT and return results as JSON. Caps rows at 50."""
    rows = db.execute(sql).fetchmany(50)
    payload = {
        "rows": [dict(r) for r in rows],
        "truncated": len(rows) >= 50,
    }
    return _truncate(json.dumps(payload, default=str))


def _truncate(s: str, cap: int = MAX_TOOL_OUTPUT_CHARS) -> str:
    if len(s) <= cap:
        return s
    return s[:cap] + f"\n\n[... truncated {len(s) - cap} chars. Refine your query for more specific results.]"


# ✅ At the ToolNode level — hard cap on any tool output
from langgraph.prebuilt import ToolNode
from langchain_core.messages import ToolMessage

class CappedToolNode(ToolNode):
    def _run_one(self, call, *args, **kw):
        msg = super()._run_one(call, *args, **kw)
        if isinstance(msg, ToolMessage) and len(msg.content) > MAX_TOOL_OUTPUT_CHARS:
            msg.content = _truncate(msg.content)
        return msg

tool_node = CappedToolNode([search, fetch_url, query_db])
Note: The "summary + drill-down" pattern (search returns snippets, fetch_url reads one page) is the most efficient tool shape for agents. It costs one extra turn when the model needs full content, but saves many turns when it doesn't.

Prevention checklist

  • Every ToolMessage must include tool_call_id matching the originating tool_call.
  • Register all tools by unique name — check for duplicates across imports at startup.
  • Use handle_tool_errors=True on ToolNode so one bad tool doesn't abort the graph.
  • Cap tool output size at the tool boundary. Never emit unbounded strings into a ToolMessage.
  • For async apps, tools should be async def and the graph invoked via ainvoke/astream.
  • Never leave a tool call unresolved — an AIMessage with tool_calls must be followed by matching ToolMessages before the next model call.
  • Log every tool call's name, duration, and outcome — helps diagnose which tools cause blowups under load.

Frequently asked questions

Does ToolNode handle parallel tool_calls?

Yes. When the model returns multiple tool_calls in one AIMessage, ToolNode runs them concurrently (async) or sequentially (sync) and emits one ToolMessage per call in the same order. With handle_tool_errors=True, a failure in one call still produces a ToolMessage; the batch as a whole completes.

What happens if my tool returns a dict or a list instead of a string?

ToolNode serializes the return with str() before wrapping it in a ToolMessage. That produces Python's repr for dicts and lists, which the model can parse but it's ugly. Prefer to json.dumps the value in your tool so the model sees clean JSON: return json.dumps(result, default=str).

Can I mix sync and async tools in one ToolNode?

Yes as of LangGraph 0.2 — ToolNode introspects each tool and awaits async ones. Just make sure the graph is invoked appropriately: ainvoke if any tool is async, invoke only if all are sync. Mixing async tools with a sync invoke raises at runtime.

How do I stream tool outputs incrementally to the client?

Use astream_events at the graph level and filter for tool events. Tool outputs stream as they complete (not character-by-character; a tool returns its full value when done). If your tool itself streams (e.g. a generator), you can wrap that in a custom node that yields intermediate ToolMessage chunks, but ToolNode doesn't support partial streaming out of the box.

Can two tool_calls in one AIMessage reference the same tool?

Yes — the model may call the same tool twice with different args (e.g. two searches). Each call has a unique id; ToolNode runs both and emits two ToolMessages with matching IDs. Nothing special needed; just make sure your tool implementation is safe under concurrent calls.

Related errors