Skip to content

Commit d246712

Browse files
ericapisaniclaude
andcommitted
feat(pydantic-ai): Add tool description to execute_tool spans
Include the tool's description (from its docstring) in gen_ai.execute_tool spans via the gen_ai.tool.description span data attribute. The description is sourced from the tool's ToolDefinition, which is derived from the tool's docstring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f665331 commit d246712

File tree

3 files changed

+155
-2
lines changed

3 files changed

+155
-2
lines changed

sentry_sdk/integrations/pydantic_ai/patches/tools.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def wrapped_execute_tool_call(
5050
call = validated.call
5151
name = call.tool_name
5252
tool = self.tools.get(name) if self.tools else None
53+
selected_tool_definition = getattr(tool, "tool_def", None)
5354

5455
# Determine tool type by checking tool.toolset
5556
tool_type = "function"
@@ -73,6 +74,7 @@ async def wrapped_execute_tool_call(
7374
args_dict,
7475
agent,
7576
tool_type=tool_type,
77+
tool_definition=selected_tool_definition,
7678
) as span:
7779
try:
7880
result = await original_execute_tool_call(
@@ -127,6 +129,7 @@ async def wrapped_call_tool(
127129
# Extract tool info before calling original
128130
name = call.tool_name
129131
tool = self.tools.get(name) if self.tools else None
132+
selected_tool_definition = getattr(tool, "tool_def", None)
130133

131134
# Determine tool type by checking tool.toolset
132135
tool_type = "function" # default
@@ -150,6 +153,7 @@ async def wrapped_call_tool(
150153
args_dict,
151154
agent,
152155
tool_type=tool_type,
156+
tool_definition=selected_tool_definition,
153157
) as span:
154158
try:
155159
result = await original_call_tool(

sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99

1010
if TYPE_CHECKING:
1111
from typing import Any, Optional
12+
from pydantic_ai._tool_manager import ToolDefinition # type: ignore
1213

1314

1415
def execute_tool_span(
15-
tool_name: str, tool_args: "Any", agent: "Any", tool_type: str = "function"
16+
tool_name: str,
17+
tool_args: "Any",
18+
agent: "Any",
19+
tool_type: str = "function",
20+
tool_definition: Optional["ToolDefinition"] = None,
1621
) -> "sentry_sdk.tracing.Span":
1722
"""Create a span for tool execution.
1823
@@ -21,6 +26,7 @@ def execute_tool_span(
2126
tool_args: The arguments passed to the tool
2227
agent: The agent executing the tool
2328
tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services)
29+
tool_definition: The definition of the tool, if available
2430
"""
2531
span = sentry_sdk.start_span(
2632
op=OP.GEN_AI_EXECUTE_TOOL,
@@ -32,6 +38,9 @@ def execute_tool_span(
3238
span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type)
3339
span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name)
3440

41+
if tool_definition:
42+
span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_definition.description)
43+
3544
_set_agent_data(span, agent)
3645

3746
if _should_send_prompts() and tool_args is not None:

tests/integrations/pydantic_ai/test_pydantic_ai.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration
1313
from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
1414
from sentry_sdk.integrations.pydantic_ai.spans.utils import _set_usage_data
15+
from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span
1516

1617
from pydantic_ai import Agent
1718
from pydantic_ai.messages import BinaryContent, UserPromptPart
1819
from pydantic_ai.usage import RequestUsage
19-
from pydantic_ai.models.test import TestModel
2020
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
21+
from pydantic_ai._tool_manager import ToolDefinition
2122

2223

2324
@pytest.fixture
@@ -2794,3 +2795,142 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events):
27942795
(span_data,) = event["spans"]
27952796
assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 80
27962797
assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20
2798+
2799+
2800+
@pytest.mark.asyncio
2801+
async def test_tool_description_in_execute_tool_span(sentry_init, capture_events):
2802+
"""
2803+
Test that tool description from the tool's docstring is included in execute_tool spans.
2804+
"""
2805+
agent = Agent(
2806+
"test",
2807+
name="test_agent",
2808+
system_prompt="You are a helpful test assistant.",
2809+
)
2810+
2811+
@agent.tool_plain
2812+
def multiply_numbers(a: int, b: int) -> int:
2813+
"""Multiply two numbers and return the product."""
2814+
return a * b
2815+
2816+
sentry_init(
2817+
integrations=[PydanticAIIntegration()],
2818+
traces_sample_rate=1.0,
2819+
send_default_pii=True,
2820+
)
2821+
2822+
events = capture_events()
2823+
2824+
result = await agent.run("What is 5 times 3?")
2825+
assert result is not None
2826+
2827+
(transaction,) = events
2828+
spans = transaction["spans"]
2829+
2830+
tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
2831+
assert len(tool_spans) >= 1
2832+
2833+
tool_span = tool_spans[0]
2834+
assert tool_span["data"]["gen_ai.tool.name"] == "multiply_numbers"
2835+
assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"]
2836+
assert "Multiply two numbers" in tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION]
2837+
2838+
2839+
@pytest.mark.asyncio
2840+
async def test_tool_without_description_omits_tool_description(
2841+
sentry_init, capture_events
2842+
):
2843+
"""
2844+
Test that execute_tool spans omit tool description when the tool has no docstring.
2845+
"""
2846+
agent = Agent(
2847+
"test",
2848+
name="test_agent",
2849+
system_prompt="You are a helpful test assistant.",
2850+
)
2851+
2852+
@agent.tool_plain
2853+
def no_docs_tool(a: int, b: int) -> int:
2854+
return a + b
2855+
2856+
sentry_init(
2857+
integrations=[PydanticAIIntegration()],
2858+
traces_sample_rate=1.0,
2859+
send_default_pii=True,
2860+
)
2861+
2862+
events = capture_events()
2863+
2864+
result = await agent.run("What is 5 + 3?")
2865+
assert result is not None
2866+
2867+
(transaction,) = events
2868+
spans = transaction["spans"]
2869+
2870+
tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
2871+
assert len(tool_spans) >= 1
2872+
2873+
tool_span = tool_spans[0]
2874+
assert tool_span["data"]["gen_ai.tool.name"] == "no_docs_tool"
2875+
assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_span["data"]
2876+
2877+
2878+
@pytest.mark.asyncio
2879+
async def test_execute_tool_span_with_tool_definition(sentry_init, capture_events):
2880+
"""
2881+
Test execute_tool_span helper correctly sets tool description from a ToolDefinition.
2882+
"""
2883+
2884+
sentry_init(
2885+
integrations=[PydanticAIIntegration()],
2886+
traces_sample_rate=1.0,
2887+
send_default_pii=True,
2888+
)
2889+
2890+
events = capture_events()
2891+
2892+
tool_def = ToolDefinition(
2893+
name="my_tool",
2894+
description="A tool that does something useful.",
2895+
parameters_json_schema={"type": "object", "properties": {}},
2896+
)
2897+
2898+
with sentry_sdk.start_transaction(op="test", name="test"):
2899+
with execute_tool_span(
2900+
"my_tool", {"arg": "value"}, None, "function", tool_definition=tool_def
2901+
) as span:
2902+
assert span is not None
2903+
2904+
(event,) = events
2905+
tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"]
2906+
assert len(tool_spans) == 1
2907+
assert (
2908+
tool_spans[0]["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION]
2909+
== "A tool that does something useful."
2910+
)
2911+
2912+
2913+
@pytest.mark.asyncio
2914+
async def test_execute_tool_span_without_tool_definition(sentry_init, capture_events):
2915+
"""
2916+
Test execute_tool_span helper omits tool description when tool_definition is None.
2917+
"""
2918+
2919+
sentry_init(
2920+
integrations=[PydanticAIIntegration()],
2921+
traces_sample_rate=1.0,
2922+
send_default_pii=True,
2923+
)
2924+
2925+
events = capture_events()
2926+
2927+
with sentry_sdk.start_transaction(op="test", name="test"):
2928+
with execute_tool_span(
2929+
"my_tool", {"arg": "value"}, None, "function", tool_definition=None
2930+
) as span:
2931+
assert span is not None
2932+
2933+
(event,) = events
2934+
tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"]
2935+
assert len(tool_spans) == 1
2936+
assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_spans[0]["data"]

0 commit comments

Comments
 (0)