Skip to main content

Pydantic AI: fixing 'No tool output found for function call' after an aborted run or trimmed history

A tool call with no result in your stored message history makes the next request fail. Pydantic AI 2.10.0 and later repair most of these on their own; here's what still raises, a helper for 1.x and older 2.x, and a trim that keeps tool calls with their results.

Updated 16 min readMarkdown

Disclosure: I work on MemorySync (I'm Mohammed, the founder). I drafted this article with AI assistance and checked it against the GitHub issues and pull request, the Pydantic AI message history docs, the release notes and the MemorySync docs listed under Sources on Oct 30, 2026. On Nov 6, 2026 I re-checked the upstream issue/PR/release status and re-ran the mock OpenAI Responses scenarios, close_dangling_tool_calls, keep_last_turns, repair_messages and the Step 5 FunctionModel check on pydantic-ai-slim 2.66.0 (results match the 2.63.0 column below). Original Oct 30 matrix: every Step 1 scenario, close_dangling_tool_calls, keep_last_turns and Step 5 against pydantic-ai-slim 1.107.9, 2.9.1, 2.10.0 and 2.63.0 with FunctionModel or a mocked OpenAIResponsesModel (no provider calls, no network). The mock returns OpenAI's error text as quoted in the issues; it is not the real API. repair_messages ran on 2.63.0 and again on 2.66.0. The ProcessHistory wiring line and the MemorySync snippet were not run as shown.

You stopped a run while a tool was executing (or a stream was cut off, or a worker crashed), the user sent another message, and every request on that conversation now fails with:

status_code: 400, model_name: gpt-4, body: {'message': 'No tool output found for function call call_tYFQBQ4RTmvnbPkV40KWCKCh.', 'type': 'invalid_request_error', 'param': 'input', 'code': None}

That is the error from Pydantic AI issue #3461, hit through the AG-UI integration after an abort, where it arrived in a RUN_ERROR event. Called from Python, the same text comes as a ModelHTTPError. The stored history ended up as user message, assistant tool call, user message, with no tool result in between. As a contributor put it, the model "sees that it called a tool but doesn't see any output from that tool, so it fails with a 400". The same break has other wordings: with Chat Completions it reads "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'" (#562), and the reverse case, a tool result whose call is missing (for example after you slice history), reads "No tool call found for function call output with call_id ..." (#4882).

MemorySync doesn't fix this bug. The broken pair lives in the message_history you store and pass back, so the fix is in that history: upgrade, repair it, or trim it without splitting tool calls from their results. This page does that first. Pydantic AI behaviour comes from the issues, PR #6319, the message history docs and my offline runs.

Skip the memory plumbing around this setup. Once your history is valid again, the MemorySync Pydantic AI guide shows how to add per-user memory with one capability. It recalls through the instructions channel, not as message parts, so it doesn't add tool calls to your history, and it doesn't repair the history you store, so you still need the fix below. When you're ready, see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).

Status (original tests Oct 30; current mock tests Nov 6, 2026)

  • #3461 (opened Nov 2025, 3 comments) is closed. The answer there was to process the history yourself: "Processing the history to remove 'dangling' tool calls is indeed the way to go" (maintainer reply).
  • #4728 asked for this repair to be built in. It was closed by PR #6319, "Make message_history provider-valid out of the box", merged Jul 14, 2026 and listed in the v2.10.0 release notes. From 2.10.0 on, Pydantic AI repairs the history before every model request.
  • repair_messages, which runs the same repair on demand, is listed in the v2.53.0 release notes. It wasn't in 2.52.0 when I checked.
  • The 1.x line doesn't have the repair. On Oct 30 the latest 1.x on PyPI was 1.107.9, which sent the broken history as-is in my run (1.107.10 is current as of Nov 6; I didn't re-run 1.x). 2.63.0 was the original matrix version. Re-running all five current mock scenarios on 2.66.0 gave the same results as the 2.63.0 column.

Step 1: Check which case you have

Each row is a stored history passed as message_history, run offline against an OpenAIResponsesModel whose mocked transport returns OpenAI's error text when a tool call has no result or a result has no call:

Stored history 1.107.9 2.9.1 2.10.0 2.63.0
#3461 shape: user, assistant tool call, user; run with no new prompt 400 "No tool output found for function call ..." 400 "No tool output found ..." OK, a placeholder tool result was sent OK, a placeholder tool result was sent
History ending on an unanswered tool call, plus a new user_prompt UserError UserError UserError UserError (message now points to repair_messages())
Same, with that response's state set to 'interrupted' UserError UserError OK, placeholder sent OK, placeholder sent
Trimmed: a tool result whose call was cut off 400 "No tool call found for function call output ..." 400 "No tool call found ..." OK, the orphaned result was dropped OK, the orphaned result was dropped
Rows 1, 2 and 4 after close_dangling_tool_calls (Step 3) OK OK OK OK

The placeholder that 2.10.0 and later send is a ToolReturnPart with the text "The tool call was interrupted before a result was produced." The UserError reads, on 2.63.0:

UserError: Cannot provide a new user prompt when the message history contains unprocessed tool calls. Run the agent with this message history and no new prompt to execute the calls, pass `deferred_tool_results` if they were deferred, or call `pydantic_ai.messages.repair_messages()` on the history first to close them out.

So:

  1. On 1.x or 2.0 to 2.9.x, nothing repairs the history for you. Upgrade (Step 2) or close the calls yourself (Step 3).
  2. On 2.10.0 or later, the 400 should be gone. The one case left to you is the UserError row. Per the docs, Pydantic AI raises there because those calls "can still be answered" by resuming the run or with deferred_tool_results, and it won't abandon them for you. The docs say Pydantic AI marks a response state='interrupted' itself in cases like a cancelled stream; I set it by hand for that row.
  3. On 2.10.0 or later, a bad trim no longer errors, it loses data. In the trimmed row the tool result was dropped without a warning, so the model never saw it. The docs say the same: the repair "is silent". Step 4 trims without splitting pairs.

Step 2: On 2.10.0 or later, repair before a new prompt

If you're on 2.x, upgrading to 2.10.0 or later handles the 400 rows. For a stored history that ends on unanswered tool calls you mean to abandon (the user moved on), close them out before you add the new prompt. On 2.53.0 or later, call repair_messages. This ran on 2.63.0, and again on 2.66.0:

from pydantic_ai.messages import ModelRequest, ModelResponse, ToolCallPart, UserPromptPart, repair_messages

stored = [
    ModelRequest(parts=[UserPromptPart('test')]),
    ModelResponse(parts=[ToolCallPart('retrieve_documents', {'search_query': 'test'}, tool_call_id='call_1')]),
]
runnable = repair_messages(stored)
closing = runnable[-1].parts[-1]
print(type(closing).__name__, closing.tool_call_id, closing.content, closing.metadata)

It printed:

ToolReturnPart call_1 The tool call was interrupted before a result was produced. {'pydantic_ai_synthesized_tool_return': True}

The metadata key lets your own code tell a placeholder from a real tool result, for example in a UI. Per the docs, the repair is "deterministic and idempotent", and repair_messages(..., repair_last_response=False) leaves the latest response's calls alone and repairs the rest. If you want those calls executed rather than abandoned, run the agent with that history and no new prompt.

Step 3: On 1.x or before 2.10.0, close dangling calls yourself

repair_messages isn't there before 2.53.0, and nothing repairs automatically before 2.10.0. This helper does the two repairs from the table: it adds a placeholder result for a tool call with none, and drops a result that doesn't answer a call in the response right before it.

from dataclasses import replace

from pydantic_ai.messages import (
    ModelMessage, ModelRequest, ModelResponse, RetryPromptPart, ToolCallPart, ToolReturnPart,
)

PLACEHOLDER = 'The tool call was interrupted before a result was produced.'


def close_dangling_tool_calls(messages: list[ModelMessage]) -> list[ModelMessage]:
    """For Pydantic AI versions without built-in repair (before 2.10.0).

    - A tool call with no result in the next request gets a placeholder
      ToolReturnPart, put first in that request.
    - A tool result that doesn't answer a call in the response right before it
      is dropped (its call was trimmed away or never existed).
    """
    def is_result(p):
        return isinstance(p, ToolReturnPart) or (isinstance(p, RetryPromptPart) and p.tool_name)

    def closing(calls):
        return [ToolReturnPart(c.tool_name, PLACEHOLDER, tool_call_id=c.tool_call_id) for c in calls]

    out: list[ModelMessage] = []
    open_calls: list[ToolCallPart] = []
    for m in messages:
        if isinstance(m, ModelResponse):
            if open_calls:  # two responses in a row: close the first one's calls
                out.append(ModelRequest(parts=closing(open_calls)))
            out.append(m)
            open_calls = [p for p in m.parts if isinstance(p, ToolCallPart)]
            continue
        open_ids = {c.tool_call_id for c in open_calls}
        keep = [p for p in m.parts if not (is_result(p) and p.tool_call_id not in open_ids)]
        answered = {p.tool_call_id for p in keep if is_result(p)}
        parts = [*closing([c for c in open_calls if c.tool_call_id not in answered]), *keep]
        open_calls = []
        if parts:
            out.append(replace(m, parts=parts))
    if open_calls:  # history ends on a response with unanswered calls
        out.append(ModelRequest(parts=closing(open_calls)))
    return out

Run it on the stored history before you pass it as message_history:

history = close_dangling_tool_calls(load_history(conversation_id))  # your own store
result = await agent.run(user_text, message_history=history)

In my runs it fixed all three broken histories from the table on all four versions, running it twice gave the same list, and the requests it produced matched what 2.10.0 sends for the abort cases. Four limits:

  • It only handles regular tool calls (ToolCallPart / ToolReturnPart). Built-in or provider-native tool parts aren't touched. Pydantic AI's own repair leaves those to each model's serializer too.
  • A dropped orphan result is gone from what you send. If that result mattered, fix the trim (Step 4) instead of relying on this.
  • It also closes calls at the very end of the history. Call it when you're about to add a new prompt, not when you want those calls to run.
  • It doesn't merge adjacent requests, which 2.10.0 and later do. I didn't test it on histories with deferred tools or retries.

The thread in #3461 also has a contributor's clean_orphaned_tool_calls, which removes the dangling call instead of answering it. Either works for the 400; the placeholder keeps a record that the call happened.

Step 4: Trim history without splitting a tool call from its result

A slice like messages[-3:] can start on the tool result and cut its call away. In my run, with three earlier question, tool call, tool result, answer turns plus a new prompt, messages[-3:] gave the "No tool call found for function call output" 400 on 1.107.9 and 2.9.1, and on 2.10.0 and 2.63.0 it ran but dropped that tool result without a warning. Cut only where a user turn starts. The starts_a_turn check below is taken from the docs' summarizing example:

from pydantic_ai.messages import ModelMessage, ModelRequest, ToolReturnPart, UserPromptPart


def starts_a_turn(message: ModelMessage) -> bool:
    # A request with a user prompt and no tool results answers no earlier tool call
    return isinstance(message, ModelRequest) and (
        any(isinstance(part, UserPromptPart) for part in message.parts)
        and not any(isinstance(part, ToolReturnPart) for part in message.parts)
    )


def keep_last_turns(messages: list[ModelMessage], turns: int = 3) -> list[ModelMessage]:
    """Trim history to the last `turns` user turns, cutting only at a turn start,
    so a tool call is never separated from its result."""
    starts = [i for i, m in enumerate(messages) if starts_a_turn(m)]
    if len(starts) <= turns:
        return messages
    return messages[starts[-turns]:]

Wire it in as a history processor:

from functools import partial

from pydantic_ai import Agent
from pydantic_ai.capabilities import ProcessHistory

agent = Agent(
    "openai:your-model",
    capabilities=[ProcessHistory(partial(keep_last_turns, turns=3))],
)

On 2.9.1, 2.10.0, 2.63.0 and 2.66.0, Agent(history_processors=...) raised TypeError in my run, and 1.107.9 printed a deprecation warning pointing to ProcessHistory, which imported and ran on the original versions. The trim helper passed on 2.66.0. The processor sees the new prompt too, so turns=3 keeps the new prompt and the two turns before it. With turns=2 the run sent the last full turn (question, tool call, tool result, answer) plus the new prompt, with no error on any version. The Agent(...) lines above weren't run as shown; I ran the same processor on the mock model.

Step 5: Check it with FunctionModel

This check makes no provider calls, and it passes with the helper on 2.66.0 too. FunctionModel stands in for the provider and fails the way it would if pairing is broken. Without close_dangling_tool_calls, it raised AssertionError: No tool output found for {'call_1'} on 1.107.9 and 2.9.1 and printed ok on 2.10.0 and 2.63.0, where the built-in repair ran first. With it, all four printed ok:

from pydantic_ai import Agent
from pydantic_ai.messages import (
    ModelMessage, ModelRequest, ModelResponse, TextPart, ToolCallPart, ToolReturnPart, UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel

from close_dangling_tool_calls import close_dangling_tool_calls


def check_pairing(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
    """Stands in for the provider: fail the way it would if pairing is broken."""
    open_ids: set[str] = set()
    for m in messages:
        for p in m.parts:
            if isinstance(p, ToolCallPart):
                open_ids.add(p.tool_call_id)
            elif isinstance(p, ToolReturnPart):
                assert p.tool_call_id in open_ids, f'No tool call found for {p.tool_call_id}'
                open_ids.discard(p.tool_call_id)
        if isinstance(m, ModelRequest):
            assert not open_ids, f'No tool output found for {open_ids}'
    return ModelResponse(parts=[TextPart('ok')])


agent = Agent(FunctionModel(check_pairing))

# The shape of the stored history in pydantic/pydantic-ai#3461
stored = [
    ModelRequest(parts=[UserPromptPart('test')]),
    ModelResponse(parts=[ToolCallPart('retrieve_documents', {'search_query': 'test'}, tool_call_id='call_1')]),
    ModelRequest(parts=[UserPromptPart('test')]),
]

result = agent.run_sync(None, message_history=close_dangling_tool_calls(stored))
print(result.output)  # expect: ok

Point it at one of your real stored histories that fails, in place of stored, before changing anything in production.

Skip the plumbing with MemorySync

Disclosure: I build MemorySync.

This doesn't change the fix above, and it won't repair a stored history. What the MemorySync Pydantic AI guide documents is MemorySyncCapability, which adds recall through Pydantic AI's instructions channel (ModelRequest.instructions), "never a conversation part". After each successful run it sends the user's prompt to fact extraction in after_run, which, per the guide, Pydantic AI "fires only when the run succeeds"; model replies, tool calls and retry prompts aren't sent. So it adds no tool parts to the history you store, and because recall doesn't come from message_history, trimming harder (Step 4) doesn't remove what it recalls for that user.

Two limits from the guide: it requires pydantic-ai (or pydantic-ai-slim) 2.0+, so it isn't an option on 1.x, and it lists pydantic-ai-memorysync 1.1.0. I haven't run it with the steps on this page.

From the guide:

Unverified (not run):

pip install pydantic-ai-memorysync
from pydantic_ai import Agent
from pydantic_ai_memorysync import MemorySyncCapability

agent = Agent(
    "openai:your-model",
    instructions="You are a helpful assistant.",
    capabilities=[MemorySyncCapability(user_id="customer-7")],
)

result = await agent.run("What should I cook tonight?", message_history=history)

Snippet adapted from the MemorySync Pydantic AI guide; I added message_history=history and haven't run it for this page. Set MEMORYSYNC_API_KEY in the environment, as the guide says. Setup from scratch, including identity from deps and the agent tools, is in my earlier tutorial, Add long-term memory to a Pydantic AI agent.

Try it: MemorySync is optional here: the steps above work without it. If you want the memory layer from the snippet, see MemorySync, create an API key in the dashboard and drop the snippet into your agent.

Troubleshooting

  • The 400 is still there after upgrading. Check the version that's actually installed (pip show pydantic-ai-slim). The repair is in 2.10.0 and later, not in 1.x.
  • UserError: Cannot provide a new user prompt ... The stored history ends on tool calls with no results. Run with no new prompt to execute them, or close them out with repair_messages (2.53.0+) or close_dangling_tool_calls (Step 3), then add the prompt.
  • No error, but the agent forgot a tool result. On 2.10.0 and later a trim that cut off a tool call drops the matching result without a warning. Trim at turn starts (Step 4).
  • "No tool call found for function call output" with a FallbackModel. That is the case in #4882: a trimmed history sent to the fallback model. PR #6319 lists it as related, and it was closed the day that PR merged. Upgrade, or trim at turn starts.
  • It happens with AG-UI or another UI adapter. The history comes back from the client, so the client's copy can hold the dangling call, as in #3461. Repair it on the server before the run. I didn't run the AG-UI adapter for this page.

Common questions

Is "No tool output found for function call" a Pydantic AI bug? The 400 comes from the provider, which rejects a tool call with no result. In my runs on 1.107.9 and 2.9.1 Pydantic AI passed such histories through unchanged; since 2.10.0 it repairs them before each request (PR #6319).

Should I delete the dangling tool call or answer it? Either clears the 400. Pydantic AI's built-in repair answers it with a placeholder result, which keeps the record that the call happened. Deleting it, as the helper in #3461 does, gives a shorter history.

Is there a check I can run before saving a history? A public validator, is_provider_valid(), was proposed in #5637. That issue is closed, but 2.66.0 has no is_provider_valid in pydantic_ai.messages when I checked; the public helper there is repair_messages. Step 5 shows a pairing check you can run yourself.

Does this affect Anthropic or Gemini too? PR #6319 names Anthropic, Gemini and OpenAI-compatible providers as rejecting broken pairing. I only tested the OpenAI Responses error path, with a mock.

Sources

  • github.com/pydantic/pydantic-ai/issues/3461 (opened 2025-11-18, closed 2025-11-19 as completed, 3 comments; exact error after an AG-UI abort; contributor and maintainer replies; read 2026-10-30)
  • github.com/pydantic/pydantic-ai/issues/4728 (built-in repair request; closed 2026-07-15 IST by PR #6319) and pull/6319 (merged 2026-07-14 UTC; description read 2026-10-30)
  • github.com/pydantic/pydantic-ai/issues/4882 ("No tool call found for function call output with call_id ..." with a trimmed history and FallbackModel), issues/562 (Chat Completions wording) and issues/5637 (is_provider_valid proposal; closed); read 2026-10-30
  • Pydantic AI release notes v2.10.0 (PR #6319) and v2.53.0 (repair_messages); PyPI release list for pydantic-ai-slim (read 2026-10-30)
  • Pydantic AI docs: Messages and chat history, pydantic.dev/docs/ai/core-concepts/message-history/ ("Making histories provider-valid", repair_messages, "What slicing costs", summarizing example; ai.pydantic.dev/message-history/ redirects here; fetched 2026-10-30)
  • Offline runs against pydantic-ai-slim 1.107.9, 2.9.1, 2.10.0 and 2.63.0 with FunctionModel and a mocked OpenAI Responses transport (2026-10-30); repair, pairing and history processor checks run again on 2.66.0 (Nov 6, 2026; offline mock logs retained internally)
  • MemorySync Pydantic AI guide: docs.memorysync.io/guides/pydantic-ai (fetched 2026-10-30)

Try it

For an overview of MemorySync, see the MemorySync homepage.

Next step: read the MemorySync Pydantic AI guide, then see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).

About the author

  • Building MemorySync (memorysync.io) — persistent memory infrastructure for AI agents, Cursor, and Claude Code.

No comments yet. Be the first to comment.