Skip to content

Troubleshooting

This page starts with how to debug an agent run, whether it raised an error or finished but did the wrong thing. It then lists common errors, headed by the error message where one exists. If your problem isn’t covered here or elsewhere in the documentation, ask in the Pydantic Slack or create an issue on GitHub.

Debugging an agent

If the run raised an error, search this page for its message first. If it isn’t listed, the steps below show which model request or tool call failed and what led up to it.

Many agent problems don’t raise an error at all. The run finishes, but the agent called the wrong tool, ignored an instruction, looped, or gave a worse answer than you expected. For this kind of misbehaviour, the cause is often in what the model was sent. Tool bugs, provider errors and lifecycle errors need their own investigation. The same process works when you hand the problem to a coding agent: “find out why my agent does X” goes much faster when the agent can read the run.

Look at what the model saw

  1. Reproduce it in one run. Use the same prompt, message_history, dependencies, model and settings. A problem that only shows up in a long conversation usually needs that history.
  2. Record the run. With Pydantic Logfire, logfire.configure() and logfire.instrument_pydantic_ai() record every run as a trace. Each model request span carries the instructions (gen_ai.system_instructions), the messages as sent (gen_ai.input.messages), the tool definitions with their descriptions and schemas (gen_ai.tool.definitions), the response and its token usage. Message and tool content depends on include_content, and prepared request parameters depend on include_model_request_parameters. Each tool call gets a span with its arguments and result, and the agent run span has the whole history (pydantic_ai.all_messages). Your coding agent can query these traces itself through the Logfire MCP server, and the free tier is enough for local debugging. The instrumentation is plain OpenTelemetry, so the same spans can go to any OpenTelemetry backend instead.
  3. Without a backend, read the messages: result.all_messages() after the run, or capture_run_messages() when the run raises (see Model errors). Each ModelRequest carries the instructions it was sent with, but not the tool definitions.
  4. Read the run in order. Check the instructions and tool descriptions first: are they what you meant, and do they mention the tool you expected? Then, for each response, check which tools the model called and with what arguments, what each tool returned, and whether any RetryPromptPart sent an error back to the model. Last, check model_name on each response, the usage, and where the run stopped.
  5. Pin it down in a test. Write the responses you recorded into a test function and use it with FunctionModel. This checks your tools, validators and history processing without calling a model. Agent.override() swaps the model, dependencies or toolsets for the test. See Unit testing. To check that a prompt or tool description change helps across many inputs, not just the one you looked at, use Pydantic Evals.

Where the cause usually is

  • The model ignores an instruction or picks the wrong tool. Read the instructions and tool definitions as sent. A tool’s description comes from its docstring, and its parameter descriptions from the docstring’s arguments section; dynamic instructions and capabilities can add text you didn’t write yourself.
  • A tool is never called. Check whether it was offered at all: it’s missing from gen_ai.tool.definitions when a prepare function or PrepareTools returned None for it, or when it belongs to an on-demand capability or Tool Search that the model hasn’t loaded yet.
  • The answer changed after a validation error. A failed argument validation, a tool or output validator raising ModelRetry, or output that doesn’t match output_type is sent back to the model, which tries again and may take a different route. See Retries.
  • The agent forgets earlier context. History processors and compaction replace the history the model sees. Compare gen_ai.input.messages on the model request span with the history you passed in.
  • A setting has no effect. See the next section: some settings are dropped for models that don’t support them.
  • Another model answered. With ModelFallback or FallbackModel, the response’s model_name says which model answered and failed_attempts lists the ones that failed before it.
  • The model refuses the task. A safety refusal is a response with a 'content_filter' finish reason, and an empty one raises ContentFilterError. To have another model take over, see Falling back when a model refuses.
  • The answer is cut off. A response that reached the max_tokens limit has a 'length' finish reason. When it has no usable output, it raises OutputTokenLimitExceeded. To have another model take over, see Falling back when the output is cut off.
  • A request is sent many more times than expected, or a fallback is slow to start. The transport, the provider SDK and a durable execution engine can each retry the same request, and their attempts multiply. See Choosing one retry owner.
  • The run stops early. A usage limit raises UsageLimitExceeded; a tool that needs approval or is deferred ends the run with DeferredToolRequests as its output.

Failures that raise no error

These conditions do not raise an exception. Each appears in a different place: a deferred request is the run output, unavailable cost is None on RunUsage, and fallback attempts are on ModelResponse.failed_attempts.

  • thinking is dropped for a model whose profile doesn’t support it. The unified thinking setting is only passed on when the model’s profile sets supports_thinking or thinking_always_enabled, and is removed without a warning otherwise. The model request span records settings after Pydantic AI prepares the request, so dropped thinking is absent. That absence is the clue when you know you set it. This is common for model names behind OpenAI-compatible servers that Pydantic AI doesn’t recognize. If the model does support it, use a narrow profile= override as described under Model Providers below.
  • Unsupported tuning settings are ignored. Settings like temperature or seed that a model doesn’t support are skipped silently, so the same settings work across models. The model request span records the prepared settings, so a dropped setting is absent. Only settings that change what a request guarantees (timeout, max_retries, stop_sequences, parallel_tool_calls=False, extra_headers and extra_body) emit an UnsupportedSettingWarning. Turn the warning into an error with warnings.filterwarnings('error', category=UnsupportedSettingWarning) while debugging.
  • History processors drop messages. A processor or compaction step that removes too much leaves the model without context, with no error. Mutating messages in place instead of building new ones emits a best-effort MessageHistoryMutatedWarning.
  • Deferred and approval-required tools pause the run. The run ends normally with a DeferredToolRequests output instead of the output you expected; see Deferred Tools.
  • Cost isn’t priced. cost is None for models that genai-prices has no price for, including models newer than your install. A run with a cost_limit emits CostNotFoundWarning instead of failing, and UsageLimitUnavailableWarning when responses omit usage under a token or cost limit. See Usage Limits.
  • Text output is cut off. A text answer that reached the max_tokens limit is returned as the output, with a finish_reason of 'length' on result.response. See Output cut off by the token limit.
  • Fallback switches models. A failed model is skipped without an error as long as a later one answers, so you may be running on your second choice. Check failed_attempts.

Jupyter Notebook Errors

RuntimeError: This event loop is already running

Modern Jupyter/IPython (7.0+): This environment supports top-level await natively. You can use Agent.run() directly in notebook cells without additional setup:

from pydantic_ai import Agent

agent = Agent('openai:gpt-6.1-sol')
result = await agent.run('Who let the dogs out?')

Legacy environments or specific integrations: If you encounter event loop conflicts, use nest-asyncio:

import nest_asyncio

from pydantic_ai import Agent

nest_asyncio.apply()

agent = Agent('openai:gpt-6.1-sol')
result = agent.run_sync('Who let the dogs out?')

Note: This also applies to Google Colab and Marimo environments.

RuntimeError: Event loop is closed

Synchronous methods like Agent.run_sync() reuse the thread’s current event loop, and install a fresh one if other code closed it. If this error is raised from inside httpx2 (or legacy httpx) during a model request, the agent was already used before its event loop was closed: the provider’s HTTP connection pool still holds connections bound to the dead loop. Recreate the agent together with its model and provider (or pass a fresh http_client to the provider); reusing an existing Model instance keeps the dead connection pool. Avoid closing an event loop that other code is still using.

UserError: Agent.run_sync() and Agent.run_stream_sync() cannot be used inside a synchronous tool, output function, or other function called during an agent run, …

This error means a synchronous tool, output function, or other function called during an agent run tried to start a nested run with Agent.run_sync() or Agent.run_stream_sync(). The sync run methods can only be used from regular application code, outside of a run: inside one, the parent run is still waiting on your function while the nested run blocks it, which can deadlock, so Pydantic AI raises this error instead.

Make the delegating function async def and await the inner run, as shown in Agent delegation. The parent agent can still be started with run_sync() from normal synchronous application code. If the delegating function also needs to do blocking work, push just that part into asyncio.to_thread().

API Key Configuration

UserError: Set the OPENAI_API_KEY environment variable or pass it via OpenAIProvider(api_key=...) to use the OpenAI provider.

The provider name and environment variable vary. Set the key named in the error, or pass api_key to that provider. See Models for provider setup.

To try Pydantic AI without an API key, use the built-in 'test' model: Agent('test').

Upgrading from V1

V1 migration errors

The V1 → V2 Migration Map lists every renamed or removed API. These common errors have direct replacements:

ErrorV1V2
ImportError: ...OpenAIModel was removed in Pydantic AI V2OpenAIModelOpenAIChatModel
TypeError: Agent.__init__() got an unexpected keyword argument 'result_type'result_type=output_type=
PydanticAIDeprecationWarning: ...usage is a property, not a method (TypeError: 'RunUsage' object is not callable before v2.64.0)result.usage()result.usage
AttributeError: 'FunctionToolResultEvent' object has no attribute 'result'event.resultevent.part
AttributeError: 'dict' object has no attribute 'supports_thinking'profile.supports_thinkingprofile.get('supports_thinking')

If a dependency breaks on V2, check whether it allows pydantic-ai>=1 without an upper bound. Pin pydantic-ai<2 until that dependency supports V2.

Installation and Dependencies

ImportError: Please install boto3 to use the Bedrock model, …

Each provider’s SDK is an optional dependency, and the error names the extra that installs it. As of V2, a bare pip install pydantic-ai no longer includes bedrock, groq, mistral, cohere, xai, huggingface, temporal, ag-ui, ui or spec, so code that worked on V1 can hit this after upgrading. Add the extras you use, for example uv add 'pydantic-ai[bedrock,groq]'. Slim Install has the full list.

If you already installed the extra the error names, install the missing package directly as a workaround, and please report it: the extra should have pulled it in.

Model Providers

UserError: Native structured output is not supported by this model.

A new or unrecognized model can be rejected or quietly get the wrong capabilities when your installed model profiles are stale. You may instead see Tool output is not supported by this model., or find that a supported setting has no effect.

Upgrade Pydantic AI first and check the provider prefix and model ID. Then inspect model.profile for the capability Pydantic AI resolved. Use a narrow profile= override only when the upstream model really supports that capability. A partial profile is merged over the provider defaults. See Models, providers, and profiles.

UserError: Model '...' is not served by the Bedrock Converse API. ...

Pydantic AI keeps a list of the OpenAI models that Bedrock serves on its Converse API (the bedrock: prefix) and refuses the others, which AWS serves only through Bedrock Mantle (the bedrock-mantle: prefix). When AWS starts serving a new model on Converse, the list catches up in a release, so upgrade first: Converse support arrived for GPT-5.6 Sol, Luna and Terra in v2.45.0, for GPT-6 Sol, Luna and Astra in v2.49.0, and for GPT-6.1 Sol in v2.55.0. If the model is Mantle-only, use the bedrock-mantle: prefix and install the bedrock-mantle extra; see OpenAI model routes and Bedrock Mantle.

If AWS already serves the model on Converse and no release knows it yet, follow the model profile guidance above. The narrow override is BedrockConverseModel(model_name, profile={'bedrock_supported_on_converse': True}).

ModelHTTPError: status_code: 400, model_name: ..., body: ...

The provider rejected the request, and body is the provider’s own error response, also available as the exception’s body. Read it first: it usually names the field or setting the provider didn’t accept.

For tool and structured-output schemas, check the provider’s supported JSON Schema subset. Constructs such as references, unions or constrained types may be rejected even when the schema is valid JSON Schema. Remove strict=True if the provider cannot enforce the generated schema. You can also switch output modes: ToolOutput sends a tool schema, NativeOutput uses the provider’s native structured-output API, and PromptedOutput puts the schema in the prompt. See Structured output.

To handle specific failures, catch the error categories rather than matching on the message text, which differs between providers: ModelContextWindowExceededError for “maximum context length” or “prompt is too long”, ModelRateLimitError for rate limits, ModelQuotaExceededError for exhausted credits or billing, and ModelAuthenticationError for a 401 or 403. An HTTP error in one of these categories is still a ModelHTTPError too, so existing except ModelHTTPError blocks keep working. The first three arrived in v2.59.0 and ModelAuthenticationError in v2.62.0. See Handling Model API Errors.

To inspect the provider request and response, pass a custom httpx2 or legacy httpx client to the provider. Logfire’s HTTPX integration records the request, response and headers.

Usage Limits and Retries

UsageLimitExceeded: The next request would exceed the request_limit of 50

Every run has a default request_limit of 50 model requests, which stops a model that loops on tool calls from running forever. Long agentic runs can reach it legitimately. Raise it with usage_limits=UsageLimits(request_limit=200) on run(), run_sync() or run_stream(), or pass request_limit=None to remove it.

An agent run from inside a tool or capability with usage=ctx.usage counts its requests against the parent run’s usage, but without usage_limits it applies the default limit of 50 to that shared count. Pass usage_limits=ctx.usage_limits along with usage=ctx.usage, or limits of its own. See Usage Limits and Agent delegation.

UnexpectedModelBehavior: Tool 'my_tool' exceeded max retries count of 1. ...

The model called my_tool with arguments that failed validation, or the tool raised ModelRetry, more times than the tool’s retry budget allows. The default budget is 1 retry per tool. Raise it for one tool with @agent.tool(retries=N), or for every tool with Agent(retries={'tools': N}). If the model keeps sending the same wrong arguments, a clearer docstring or argument descriptions usually help more than extra retries. To see what the model sent, wrap the run in capture_run_messages() as shown in Model errors. Tool retries explains which retry setting wins.

A retry can mask a flaky tool when a later attempt succeeds. With instrumentation, each retried call has its own span with ERROR status, retry attributes and the feedback sent to the model. Without a trace, the retry appears in message history as a RetryPromptPart. A tool’s retry count resets after it succeeds. A capability implementing on_tool_execute_error can also turn an exception into a tool result. See Auditing rejected tool calls.

A tool raises its original exception and ends the run

Ordinary exceptions from a tool propagate and end the run. Raise ModelRetry when the model can correct its arguments or choose another approach. Let real application failures propagate so callers can handle them.

UnexpectedModelBehavior: Exceeded maximum output retries (1)

The model’s final answer failed validation against your output_type, or an output validator raised ModelRetry, more often than the output retry budget allows. A response with no usable output, such as empty text or plain text when the output type needs structured data, also uses up this budget. The default is 1: raise it with Agent(retries={'output': N}), or per run with agent.run(..., retries={'output': N}).

Smaller and local models often answer in text instead of calling the output tool. For those, try NativeOutput where the model supports it, or PromptedOutput. If an empty answer is acceptable, make the output type optional (output_type=str | None) so that an empty response ends the run with None; see Optional output. Use capture_run_messages() to inspect the response that failed, and see Output retries.

OutputTokenLimitExceeded: Model token limit (...) exceeded ...

The response reached the max_tokens limit, shown in parentheses (or provider default when you didn’t set one), before the model finished its output. The rest of the message says where it was cut off: before any output was generated, often while the model was thinking; while generating structured output, leaving incomplete JSON; or, as IncompleteToolCall, while generating a tool call’s arguments. For the first two, re-prompting the model with the same limit would run into it again, so the run ends right away; IncompleteToolCall is raised once the tool call’s retries are used up. Raise max_tokens, ask for a shorter answer, or have another model take over. See Output cut off by the token limit.

Message History

UserError: Cannot provide a new user prompt when the message history contains unprocessed tool calls. ...

The message_history you passed ends with a model response whose tool calls have no results yet, and you also passed a new prompt. This usually happens when your application saved the history of a run that stopped after the model asked for tools but before they ran. Either run without a new prompt, which executes the pending tool calls and continues the run; pass their results with deferred_tool_results=... if the calls were deferred; or, to abandon them, pass the history through repair_messages() first. See Making histories provider-valid.

Before v2.38.0, a run cancelled during its first tool call also left a history that raised this error. Upgrade if that is how you got here.

Reasoning input is not enabled for this model

Thinking and reasoning history can contain provider-signed or encrypted data. Another provider, model family, API endpoint or account may reject it. Pydantic AI sends stateless artifacts back only when the new model has the same canonical system or declares it compatible. It otherwise uses that model’s fallback, such as rendering thinking as text or omitting a foreign compaction item.

Upgrade first, then pass the original serialized ModelMessage objects so their provider provenance is preserved. Do not copy signatures or encrypted fields into hand-built messages. If old history lacks that provenance, start a new conversation with a text summary instead of reusing its provider-specific thinking. See Sending history to another provider.

Durable Execution

UserError: Toolsets that are ‘leaves’ (i.e. those that implement their own tool listing and calling) need to have a unique id in order to be used with Temporal. …

Durable execution names each toolset’s activities after the toolset’s id, so that a restarted workflow runs the same code. Give every toolset that runs its own tools a stable id: FunctionToolset(id=...), MCPToolset(..., id=...) or DynamicToolset(id=...). When a capability contributes the toolset, set the capability’s id instead, for example Capability(id='search', tools=[...]) or MCP(url='...', id='docs'). A capability function passed directly to capabilities= can’t carry an id, so wrap it: DynamicCapability(my_func, id='...'). DBOS and Prefect raise the same error under their own names. See Agent Names and Toolset IDs.

Observability

A banner saying observability: off appears in logs or in your application’s output

The first agent run can print this banner to stderr until instrumentation is configured. See The first-run banner to configure instrumentation or turn it off.

Upgrade First

Many errors are fixed in newer Pydantic AI versions, so upgrade first and check the release notes. This includes TypeError: Cannot instantiate typing.Union or TypeError: 'typing.Union' object is not callable, ModuleNotFoundError: No module named 'httpx' with MistralModel, and PydanticSchemaGenerationError: Unable to generate pydantic-core schema for <class 'temporalio.workflow._event_groups.EventGroup'>.