Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

33 changes: 17 additions & 16 deletions scripts/populate_tox/releases.jsonl

Large diffs are not rendered by default.

80 changes: 79 additions & 1 deletion sentry_sdk/integrations/pydantic_ai/patches/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,85 @@


def _patch_tool_execution() -> None:
if hasattr(ToolManager, "execute_tool_call"):
_patch_execute_tool_call()

elif hasattr(ToolManager, "_call_tool"):
# older versions
_patch_call_tool()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong patch chosen when both methods exist

Medium Severity

_patch_tool_execution prioritizes patching ToolManager.execute_tool_call whenever it exists, even if ToolManager._call_tool also exists. In any pydantic-ai version where _call_tool remains the actual execution choke point, this can silently skip instrumentation (no execute_tool_span) or change which layer is wrapped.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We prefer execute_tool_call because it's the new way of doing things, and only fall back to the old way (_call_tool) if not possible.


def _patch_execute_tool_call() -> None:
original_execute_tool_call = ToolManager.execute_tool_call
Comment on lines +39 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could maybe dedupe but the patches may diverge in future as well so I'm okay with either

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep them separate so that we can someday get rid of the old one easily


@wraps(original_execute_tool_call)
async def wrapped_execute_tool_call(
self: "Any", validated: "Any", *args: "Any", **kwargs: "Any"
) -> "Any":
if not validated or not hasattr(validated, "call"):
return await original_execute_tool_call(self, validated, *args, **kwargs)

# Extract tool info before calling original
call = validated.call
name = call.tool_name
tool = self.tools.get(name) if self.tools else None

# Determine tool type by checking tool.toolset
tool_type = "function"
if tool and HAS_MCP and isinstance(tool.toolset, MCPServer):
tool_type = "mcp"

# Get agent from contextvar
agent = get_current_agent()

if agent and tool:
try:
args_dict = call.args_as_dict()
except Exception:
args_dict = call.args if isinstance(call.args, dict) else {}

# Create execute_tool span
# Nesting is handled by isolation_scope() to ensure proper parent-child relationships
with sentry_sdk.isolation_scope():
with execute_tool_span(
name,
args_dict,
agent,
tool_type=tool_type,
) as span:
try:
result = await original_execute_tool_call(
self,
validated,
*args,
**kwargs,
)
update_execute_tool_span(span, result)
return result
except ToolRetryError as exc:
exc_info = sys.exc_info()
with capture_internal_exceptions():
# Avoid circular import due to multi-file integration structure
from sentry_sdk.integrations.pydantic_ai import (
PydanticAIIntegration,
)

integration = sentry_sdk.get_client().get_integration(
PydanticAIIntegration
)
if (
integration is not None
and integration.handled_tool_call_exceptions
):
_capture_exception(exc, handled=True)
reraise(*exc_info)

return await original_execute_tool_call(self, validated, *args, **kwargs)

ToolManager.execute_tool_call = wrapped_execute_tool_call


Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests added for new patch path

Low Severity

The PR description requests adding tests for the change, but the diff only modifies runtime patching logic and adds no coverage validating the new ToolManager.execute_tool_call instrumentation behavior.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing tests for tool spans like test_agent_with_tools, but we don't test with the newest version here -- let me pull in the newest version

def _patch_call_tool() -> None:
"""
Patch ToolManager._call_tool to create execute_tool spans.

Expand All @@ -39,7 +118,6 @@ def _patch_tool_execution() -> None:
- Dealing with signature mismatches from instrumented MCP servers
- Complex nested toolset handling
"""

original_call_tool = ToolManager._call_tool

@wraps(original_call_tool)
Expand Down
6 changes: 4 additions & 2 deletions tests/integrations/pydantic_ai/test_pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,9 @@ async def test_message_history(sentry_init, capture_events):
from pydantic_ai import messages

history = [
messages.UserPromptPart(content="Hello, I'm Alice"),
messages.ModelRequest(
parts=[messages.UserPromptPart(content="Hello, I'm Alice")]
),
messages.ModelResponse(
parts=[messages.TextPart(content="Hello Alice! How can I help you?")],
model_name="test",
Expand Down Expand Up @@ -1493,7 +1495,7 @@ async def test_message_formatting_with_different_parts(sentry_init, capture_even

# Create message history with different part types
history = [
messages.UserPromptPart(content="Hello"),
messages.ModelRequest(parts=[messages.UserPromptPart(content="Hello")]),
messages.ModelResponse(
parts=[
messages.TextPart(content="Hi there!"),
Expand Down
50 changes: 25 additions & 25 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ envlist =
{py3.10,py3.11,py3.12}-openai_agents-v0.0.19
{py3.10,py3.12,py3.13}-openai_agents-v0.3.3
{py3.10,py3.13,py3.14,py3.14t}-openai_agents-v0.6.9
{py3.10,py3.13,py3.14,py3.14t}-openai_agents-v0.9.3
{py3.10,py3.13,py3.14,py3.14t}-openai_agents-v0.10.1

{py3.10,py3.12,py3.13}-pydantic_ai-v1.0.18
{py3.10,py3.12,py3.13}-pydantic_ai-v1.20.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.40.0
{py3.10,py3.13,py3.14}-pydantic_ai-v1.62.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.21.0
{py3.10,py3.12,py3.13}-pydantic_ai-v1.42.0
{py3.10,py3.13,py3.14}-pydantic_ai-v1.63.0


# ~~~ AI Workflow ~~~
Expand Down Expand Up @@ -116,22 +116,22 @@ envlist =
{py3.9,py3.12,py3.13}-litellm-v1.77.7
{py3.9,py3.12,py3.13}-litellm-v1.78.7
{py3.9,py3.12,py3.13}-litellm-v1.79.3
{py3.9,py3.12,py3.13}-litellm-v1.81.14
{py3.9,py3.12,py3.13}-litellm-v1.81.15

{py3.8,py3.11,py3.12}-openai-base-v1.0.1
{py3.8,py3.12,py3.13}-openai-base-v1.109.1
{py3.9,py3.13,py3.14,py3.14t}-openai-base-v2.21.0
{py3.9,py3.13,py3.14,py3.14t}-openai-base-v2.23.0

{py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1
{py3.8,py3.12,py3.13}-openai-notiktoken-v1.109.1
{py3.9,py3.13,py3.14,py3.14t}-openai-notiktoken-v2.21.0
{py3.9,py3.13,py3.14,py3.14t}-openai-notiktoken-v2.23.0


# ~~~ Cloud ~~~
{py3.6,py3.7}-boto3-v1.12.49
{py3.6,py3.9,py3.10}-boto3-v1.21.46
{py3.7,py3.11,py3.12}-boto3-v1.33.13
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.54
{py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.55

{py3.6,py3.7,py3.8}-chalice-v1.16.0
{py3.9,py3.12,py3.13}-chalice-v1.32.0
Expand Down Expand Up @@ -191,7 +191,7 @@ envlist =
{py3.8,py3.12,py3.13}-graphene-v3.4.3

{py3.8,py3.10,py3.11}-strawberry-v0.209.8
{py3.10,py3.13,py3.14,py3.14t}-strawberry-v0.305.0
{py3.10,py3.13,py3.14,py3.14t}-strawberry-v0.306.0


# ~~~ Network ~~~
Expand Down Expand Up @@ -256,9 +256,9 @@ envlist =
{py3.10,py3.13,py3.14,py3.14t}-starlette-v0.52.1

{py3.6,py3.9,py3.10}-fastapi-v0.79.1
{py3.7,py3.10,py3.11}-fastapi-v0.96.1
{py3.8,py3.11,py3.12}-fastapi-v0.113.0
{py3.10,py3.13,py3.14,py3.14t}-fastapi-v0.131.0
{py3.7,py3.10,py3.11}-fastapi-v0.97.0
{py3.8,py3.12,py3.13}-fastapi-v0.115.14
{py3.10,py3.13,py3.14,py3.14t}-fastapi-v0.133.0


# ~~~ Web 2 ~~~
Expand Down Expand Up @@ -398,13 +398,13 @@ deps =
openai_agents-v0.0.19: openai-agents==0.0.19
openai_agents-v0.3.3: openai-agents==0.3.3
openai_agents-v0.6.9: openai-agents==0.6.9
openai_agents-v0.9.3: openai-agents==0.9.3
openai_agents-v0.10.1: openai-agents==0.10.1
openai_agents: pytest-asyncio

pydantic_ai-v1.0.18: pydantic-ai==1.0.18
pydantic_ai-v1.20.0: pydantic-ai==1.20.0
pydantic_ai-v1.40.0: pydantic-ai==1.40.0
pydantic_ai-v1.62.0: pydantic-ai==1.62.0
pydantic_ai-v1.21.0: pydantic-ai==1.21.0
pydantic_ai-v1.42.0: pydantic-ai==1.42.0
pydantic_ai-v1.63.0: pydantic-ai==1.63.0
pydantic_ai: pytest-asyncio


Expand Down Expand Up @@ -463,18 +463,18 @@ deps =
litellm-v1.77.7: litellm==1.77.7
litellm-v1.78.7: litellm==1.78.7
litellm-v1.79.3: litellm==1.79.3
litellm-v1.81.14: litellm==1.81.14
litellm-v1.81.15: litellm==1.81.15

openai-base-v1.0.1: openai==1.0.1
openai-base-v1.109.1: openai==1.109.1
openai-base-v2.21.0: openai==2.21.0
openai-base-v2.23.0: openai==2.23.0
openai-base: pytest-asyncio
openai-base: tiktoken
openai-base-v1.0.1: httpx<0.28

openai-notiktoken-v1.0.1: openai==1.0.1
openai-notiktoken-v1.109.1: openai==1.109.1
openai-notiktoken-v2.21.0: openai==2.21.0
openai-notiktoken-v2.23.0: openai==2.23.0
openai-notiktoken: pytest-asyncio
openai-notiktoken-v1.0.1: httpx<0.28

Expand All @@ -483,7 +483,7 @@ deps =
boto3-v1.12.49: boto3==1.12.49
boto3-v1.21.46: boto3==1.21.46
boto3-v1.33.13: boto3==1.33.13
boto3-v1.42.54: boto3==1.42.54
boto3-v1.42.55: boto3==1.42.55
{py3.7,py3.8}-boto3: urllib3<2.0.0

chalice-v1.16.0: chalice==1.16.0
Expand Down Expand Up @@ -562,7 +562,7 @@ deps =
{py3.6}-graphene: aiocontextvars

strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8
strawberry-v0.305.0: strawberry-graphql[fastapi,flask]==0.305.0
strawberry-v0.306.0: strawberry-graphql[fastapi,flask]==0.306.0
strawberry: httpx
strawberry-v0.209.8: pydantic<2.11

Expand Down Expand Up @@ -687,16 +687,16 @@ deps =
{py3.6}-starlette: aiocontextvars

fastapi-v0.79.1: fastapi==0.79.1
fastapi-v0.96.1: fastapi==0.96.1
fastapi-v0.113.0: fastapi==0.113.0
fastapi-v0.131.0: fastapi==0.131.0
fastapi-v0.97.0: fastapi==0.97.0
fastapi-v0.115.14: fastapi==0.115.14
fastapi-v0.133.0: fastapi==0.133.0
fastapi: httpx
fastapi: pytest-asyncio
fastapi: python-multipart
fastapi: requests
fastapi: anyio<4
fastapi-v0.79.1: httpx<0.28.0
fastapi-v0.96.1: httpx<0.28.0
fastapi-v0.97.0: httpx<0.28.0
{py3.6}-fastapi: aiocontextvars


Expand Down
Loading