diff --git a/src/google/adk/errors/tool_execution_error.py b/src/google/adk/errors/tool_execution_error.py new file mode 100644 index 0000000000..ba69324504 --- /dev/null +++ b/src/google/adk/errors/tool_execution_error.py @@ -0,0 +1,53 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import enum + + +class ToolErrorType(str, enum.Enum): + """HTTP error types conforming to OpenTelemetry semantics.""" + + BAD_REQUEST = '400' + UNAUTHORIZED = '401' + FORBIDDEN = '403' + NOT_FOUND = '404' + REQUEST_TIMEOUT = '408' + INTERNAL_SERVER_ERROR = '500' + BAD_GATEWAY = '502' + SERVICE_UNAVAILABLE = '503' + GATEWAY_TIMEOUT = '504' + + +class ToolExecutionError(Exception): + """Represents an error that occurs during the execution of a tool.""" + + def __init__( + self, message: str, error_type: ToolErrorType | str | None = None + ): + """Initializes the ToolExecutionError exception. + + Args: + message (str): A message describing the error. + error_type (ToolErrorType | str | None): The semantic error type (e.g., + ToolErrorType.REQUEST_TIMEOUT or '500'). Used to populate the `error.type` span + attribute in OpenTelemetry traces. + """ + self.message = message + if isinstance(error_type, ToolErrorType): + self.error_type = error_type.value + else: + self.error_type = error_type + super().__init__(self.message) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 6082e1a745..8e3dacbd13 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -587,14 +587,19 @@ async def _run_with_trace(): with tracer.start_as_current_span(f'execute_tool {tool.name}'): function_response_event = None + caught_error = None try: function_response_event = await _run_with_trace() return function_response_event + except Exception as e: + caught_error = e + raise finally: trace_tool_call( tool=tool, args=function_args, function_response_event=function_response_event, + error=caught_error, ) @@ -785,14 +790,19 @@ async def _run_with_trace(): with tracer.start_as_current_span(f'execute_tool {tool.name}'): function_response_event = None + caught_error = None try: function_response_event = await _run_with_trace() return function_response_event + except Exception as e: + caught_error = e + raise finally: trace_tool_call( tool=tool, args=function_args, function_response_event=function_response_event, + error=caught_error, ) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 707bc31396..fd7f7c3dee 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -41,6 +41,7 @@ from opentelemetry import context as otel_context from opentelemetry import trace from opentelemetry._logs import LogRecord +from opentelemetry.semconv._incubating.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_AGENT_DESCRIPTION from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_AGENT_NAME from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_CONVERSATION_ID @@ -162,6 +163,7 @@ def trace_tool_call( tool: BaseTool, args: dict[str, Any], function_response_event: Event | None, + error: Exception | None = None, ): """Traces tool call. @@ -169,6 +171,7 @@ def trace_tool_call( tool: The tool that was called. args: The arguments to the tool call. function_response_event: The event with the function response details. + error: The exception raised during tool execution, if any. """ span = trace.get_current_span() @@ -180,6 +183,12 @@ def trace_tool_call( # e.g. FunctionTool span.set_attribute(GEN_AI_TOOL_TYPE, tool.__class__.__name__) + if error is not None: + if hasattr(error, 'error_type') and error.error_type is not None: + span.set_attribute(ERROR_TYPE, str(error.error_type)) + else: + span.set_attribute(ERROR_TYPE, type(error).__name__) + # Setting empty llm request and response (as UI expect these) while not # applicable for tool_response. span.set_attribute('gcp.vertex.agent.llm_request', '{}') diff --git a/src/google/adk/tools/example_tool.py b/src/google/adk/tools/example_tool.py index 36439f8172..2eebe9232a 100644 --- a/src/google/adk/tools/example_tool.py +++ b/src/google/adk/tools/example_tool.py @@ -23,6 +23,7 @@ from ..examples import example_util from ..examples.base_example_provider import BaseExampleProvider from ..examples.example import Example +from ...errors.tool_execution_error import ToolErrorType, ToolExecutionError from .base_tool import BaseTool from .tool_configs import BaseToolConfig from .tool_configs import ToolArgsConfig @@ -76,16 +77,20 @@ def from_config( example_tool_config.examples ) if not isinstance(example_provider, BaseExampleProvider): - raise ValueError( - 'Example provider must be an instance of BaseExampleProvider.' + raise ToolExecutionError( + message='Example provider must be an instance of BaseExampleProvider.', + error_type=ToolErrorType.BAD_REQUEST, ) return cls(example_provider) elif isinstance(example_tool_config.examples, list): return cls(example_tool_config.examples) else: - raise ValueError( - 'Example tool config must be a list of examples or a fully-qualified' - ' name to a BaseExampleProvider object in code.' + raise ToolExecutionError( + message=( + 'Example tool config must be a list of examples or a ' + 'fully-qualified name to a BaseExampleProvider object in code.' + ), + error_type=ToolErrorType.BAD_REQUEST, ) diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index 3c061e42a3..34b6dc3287 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -50,6 +50,8 @@ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID import pytest +from google.adk.errors.tool_execution_error import ToolErrorType +from google.adk.errors.tool_execution_error import ToolExecutionError try: from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_DEFINITIONS @@ -1175,3 +1177,83 @@ async def test_generate_content_span_with_experimental_semconv( assert attributes[GEN_AI_AGENT_NAME] == invocation_context.agent.name assert GEN_AI_CONVERSATION_ID in attributes assert attributes[GEN_AI_CONVERSATION_ID] == invocation_context.session.id + +def test_trace_tool_call_with_tool_execution_error( + monkeypatch, mock_span_fixture, mock_tool_fixture +): + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + test_args: Dict[str, Any] = {'param_a': 'value_a'} + test_error = ToolExecutionError( + message='Internal server error', + error_type=ToolErrorType.INTERNAL_SERVER_ERROR, + ) + + trace_tool_call( + tool=mock_tool_fixture, + args=test_args, + function_response_event=None, + error=test_error, + ) + + expected_calls = [ + mock.call('gen_ai.operation.name', 'execute_tool'), + mock.call('gen_ai.tool.name', mock_tool_fixture.name), + mock.call('gen_ai.tool.description', mock_tool_fixture.description), + mock.call('gen_ai.tool.type', 'BaseTool'), + mock.call('error.type', '500'), + mock.call('gcp.vertex.agent.tool_call_args', json.dumps(test_args)), + mock.call('gcp.vertex.agent.tool_response', '{}'), + mock.call('gcp.vertex.agent.llm_request', '{}'), + mock.call('gcp.vertex.agent.llm_response', '{}'), + mock.call('gen_ai.tool.call.id', ''), + ] + + mock_span_fixture.set_attribute.assert_has_calls( + expected_calls, any_order=True + ) + + +def test_trace_tool_call_with_timeout_error( + monkeypatch, mock_span_fixture, mock_tool_fixture +): + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + test_args: Dict[str, Any] = {'param_a': 'value_a'} + test_error = ToolExecutionError( + message='Request timed out', + error_type=ToolErrorType.REQUEST_TIMEOUT, + ) + + trace_tool_call( + tool=mock_tool_fixture, + args=test_args, + function_response_event=None, + error=test_error, + ) + + assert mock.call('error.type', '408') in mock_span_fixture.set_attribute.call_args_list + + +def test_trace_tool_call_with_standard_error( + monkeypatch, mock_span_fixture, mock_tool_fixture +): + monkeypatch.setattr( + 'opentelemetry.trace.get_current_span', lambda: mock_span_fixture + ) + + test_args: Dict[str, Any] = {'param': 1} + test_error = ValueError('Invalid arguments') + + trace_tool_call( + tool=mock_tool_fixture, + args=test_args, + function_response_event=None, + error=test_error, + ) + + assert mock.call('error.type', 'ValueError') in mock_span_fixture.set_attribute.call_args_list