Skip to content
Closed
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
53 changes: 53 additions & 0 deletions src/google/adk/errors/tool_execution_error.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions src/google/adk/flows/llm_flows/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
)


Expand Down
9 changes: 9 additions & 0 deletions src/google/adk/telemetry/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,13 +163,15 @@ def trace_tool_call(
tool: BaseTool,
args: dict[str, Any],
function_response_event: Event | None,
error: Exception | None = None,
):
"""Traces tool call.
Args:
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()

Expand All @@ -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', '{}')
Expand Down
15 changes: 10 additions & 5 deletions src/google/adk/tools/example_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down
82 changes: 82 additions & 0 deletions tests/unittests/telemetry/test_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', '<not specified>'),
]

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
Loading