diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py index e318b1322f..1f5cde8742 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/tools.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -50,6 +50,7 @@ async def wrapped_execute_tool_call( call = validated.call name = call.tool_name tool = self.tools.get(name) if self.tools else None + selected_tool_definition = getattr(tool, "tool_def", None) # Determine tool type by checking tool.toolset tool_type = "function" @@ -73,6 +74,7 @@ async def wrapped_execute_tool_call( args_dict, agent, tool_type=tool_type, + tool_definition=selected_tool_definition, ) as span: try: result = await original_execute_tool_call( @@ -127,6 +129,7 @@ async def wrapped_call_tool( # Extract tool info before calling original name = call.tool_name tool = self.tools.get(name) if self.tools else None + selected_tool_definition = getattr(tool, "tool_def", None) # Determine tool type by checking tool.toolset tool_type = "function" # default @@ -150,6 +153,7 @@ async def wrapped_call_tool( args_dict, agent, tool_type=tool_type, + tool_definition=selected_tool_definition, ) as span: try: result = await original_call_tool( diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index cc18302f87..487ffe3e6f 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -9,10 +9,15 @@ if TYPE_CHECKING: from typing import Any, Optional + from pydantic_ai._tool_manager import ToolDefinition # type: ignore def execute_tool_span( - tool_name: str, tool_args: "Any", agent: "Any", tool_type: str = "function" + tool_name: str, + tool_args: "Any", + agent: "Any", + tool_type: str = "function", + tool_definition: "Optional[ToolDefinition]" = None, ) -> "sentry_sdk.tracing.Span": """Create a span for tool execution. @@ -21,6 +26,7 @@ def execute_tool_span( tool_args: The arguments passed to the tool agent: The agent executing the tool tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services) + tool_definition: The definition of the tool, if available """ span = sentry_sdk.start_span( op=OP.GEN_AI_EXECUTE_TOOL, @@ -32,6 +38,12 @@ def execute_tool_span( span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type) span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + if tool_definition is not None: + span.set_data( + SPANDATA.GEN_AI_TOOL_DESCRIPTION, + getattr(tool_definition, "description", None), + ) + _set_agent_data(span, agent) if _should_send_prompts() and tool_args is not None: diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index b0bde0301d..9843adc542 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -16,7 +16,6 @@ from pydantic_ai import Agent from pydantic_ai.messages import BinaryContent, UserPromptPart from pydantic_ai.usage import RequestUsage -from pydantic_ai.models.test import TestModel from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior @@ -2386,7 +2385,9 @@ async def test_execute_tool_span_with_mcp_type(sentry_init, capture_events): Test execute_tool span with MCP tool type. """ import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span + from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( + execute_tool_span, + ) sentry_init( integrations=[PydanticAIIntegration()], @@ -2794,3 +2795,82 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): (span_data,) = event["spans"] assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 80 assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20 + + +@pytest.mark.asyncio +async def test_tool_description_in_execute_tool_span(sentry_init, capture_events): + """ + Test that tool description from the tool's docstring is included in execute_tool spans. + """ + agent = Agent( + "test", + name="test_agent", + system_prompt="You are a helpful test assistant.", + ) + + @agent.tool_plain + def multiply_numbers(a: int, b: int) -> int: + """Multiply two numbers and return the product.""" + return a * b + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + result = await agent.run("What is 5 times 3?") + assert result is not None + + (transaction,) = events + spans = transaction["spans"] + + tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) >= 1 + + tool_span = tool_spans[0] + assert tool_span["data"]["gen_ai.tool.name"] == "multiply_numbers" + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"] + assert "Multiply two numbers" in tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] + + +@pytest.mark.asyncio +async def test_tool_without_description_sets_tool_description_to_none( + sentry_init, capture_events +): + """ + Test that execute_tool spans set tool description to None when the tool has no docstring. + """ + agent = Agent( + "test", + name="test_agent", + system_prompt="You are a helpful test assistant.", + ) + + @agent.tool_plain + def no_docs_tool(a: int, b: int) -> int: + return a + b + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + result = await agent.run("What is 5 + 3?") + assert result is not None + + (transaction,) = events + spans = transaction["spans"] + + tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) >= 1 + + tool_span = tool_spans[0] + assert tool_span["data"]["gen_ai.tool.name"] == "no_docs_tool" + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"] + assert tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] is None