Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4b4c2736 | |||
| ad654b523a | |||
| 172c0a9507 |
@@ -1004,39 +1004,6 @@ def normalize_tools(
|
||||
return normalized
|
||||
|
||||
|
||||
def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""Parse the tools to a dict.
|
||||
|
||||
Args:
|
||||
tools: The tools to parse. Can be a single tool or a sequence of tools.
|
||||
|
||||
Returns:
|
||||
A list of tool specifications as dictionaries, or None if no tools provided.
|
||||
"""
|
||||
normalized_tools = normalize_tools(tools)
|
||||
if not normalized_tools:
|
||||
return None
|
||||
|
||||
results: list[str | dict[str, Any]] = []
|
||||
for tool_item in normalized_tools:
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
results.append(tool_item.to_json_schema_spec())
|
||||
continue
|
||||
if isinstance(tool_item, BaseModel):
|
||||
results.append(tool_item.model_dump(exclude_none=True))
|
||||
continue
|
||||
if isinstance(tool_item, SerializationMixin):
|
||||
results.append(tool_item.to_dict())
|
||||
continue
|
||||
if isinstance(tool_item, dict):
|
||||
results.append(tool_item) # type: ignore[reportUnknownArgumentType]
|
||||
continue
|
||||
logger.warning("Can't parse tool.")
|
||||
return results
|
||||
|
||||
|
||||
# region AI Function Decorator
|
||||
|
||||
|
||||
|
||||
@@ -2211,6 +2211,181 @@ def _get_instructions_from_options(options: Any) -> str | list[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
# region OTel tool definitions
|
||||
|
||||
# Per-item in-memory cache of computed OTel tool definitions, keyed by the tool
|
||||
# object's identity. Tool objects (e.g. ``FunctionTool``, ``MCPTool``) are often
|
||||
# reused across runs, so caching their converted definitions avoids repeating the
|
||||
# isinstance checks, schema generation, and dict construction on every invocation.
|
||||
# A ``WeakKeyDictionary`` lets entries be garbage collected with their tools.
|
||||
# Unhashable / non-weak-referenceable specs (e.g. plain dicts) bypass the cache.
|
||||
_TOOL_OTEL_DEFINITION_CACHE: weakref.WeakKeyDictionary[Any, dict[str, Any] | None] = weakref.WeakKeyDictionary()
|
||||
# Sentinel distinguishing "not cached" from a cached ``None`` (unparseable tool).
|
||||
_CACHE_MISS: Final = object()
|
||||
|
||||
|
||||
def _tools_to_dict(
|
||||
tools: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Convert tools into OpenTelemetry GenAI tool definitions.
|
||||
|
||||
The output conforms to the OTel GenAI tool-definitions schema, where each
|
||||
entry is either a ``FunctionToolDefinition`` (``type="function"`` with
|
||||
``name`` and optional ``description``/``parameters``) or a
|
||||
``GenericToolDefinition`` (any ``type`` plus a ``name``). See
|
||||
https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-tool-definitions.json.
|
||||
|
||||
Args:
|
||||
tools: The tools to parse. Can be a single tool or a sequence of tools.
|
||||
|
||||
Returns:
|
||||
A list of OTel-conformant tool-definition dicts, or ``None`` when
|
||||
``tools`` is empty or no tool can be represented.
|
||||
"""
|
||||
from ._tools import normalize_tools
|
||||
|
||||
normalized_tools = normalize_tools(tools)
|
||||
if not normalized_tools:
|
||||
return None
|
||||
results: list[dict[str, Any]] = []
|
||||
for tool_item in normalized_tools:
|
||||
otel_def = _tool_to_otel_definition(tool_item)
|
||||
if otel_def is not None:
|
||||
results.append(otel_def)
|
||||
return results or None
|
||||
|
||||
|
||||
def _tool_to_otel_definition(tool_item: Any) -> dict[str, Any] | None:
|
||||
"""Convert a single tool spec into an OTel GenAI tool-definition dict.
|
||||
|
||||
Results are cached per tool object (keyed by identity) so repeated runs that
|
||||
reuse the same tool instances skip the conversion work. Specs that cannot be
|
||||
weakly referenced (e.g. plain dicts) are converted without caching.
|
||||
|
||||
Returns ``None`` and emits a warning when the input cannot be represented
|
||||
as either a ``FunctionToolDefinition`` or a ``GenericToolDefinition``.
|
||||
"""
|
||||
try:
|
||||
cached = _TOOL_OTEL_DEFINITION_CACHE.get(tool_item, _CACHE_MISS)
|
||||
except TypeError:
|
||||
# Unhashable spec (e.g. a plain dict); convert without caching.
|
||||
return _build_tool_otel_definition(tool_item)
|
||||
if cached is not _CACHE_MISS:
|
||||
return cast("dict[str, Any] | None", cached)
|
||||
|
||||
definition = _build_tool_otel_definition(tool_item)
|
||||
with contextlib.suppress(TypeError):
|
||||
# Object may not support weak references; skip caching when that is the case.
|
||||
_TOOL_OTEL_DEFINITION_CACHE[tool_item] = definition
|
||||
return definition
|
||||
|
||||
|
||||
def _build_tool_otel_definition(tool_item: Any) -> dict[str, Any] | None:
|
||||
"""Convert a single tool spec into an OTel GenAI tool-definition dict (uncached)."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._mcp import MCPTool
|
||||
from ._serialization import SerializationMixin
|
||||
from ._tools import FunctionTool
|
||||
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
definition: dict[str, Any] = {"type": "function", "name": tool_item.name}
|
||||
if tool_item.description:
|
||||
definition["description"] = tool_item.description
|
||||
parameters = tool_item.parameters()
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
return definition
|
||||
|
||||
if isinstance(tool_item, MCPTool):
|
||||
definition = {"type": "mcp", "name": tool_item.name}
|
||||
if tool_item.description:
|
||||
definition["description"] = tool_item.description
|
||||
return definition
|
||||
|
||||
raw: Mapping[str, Any] | None = None
|
||||
if isinstance(tool_item, BaseModel):
|
||||
raw = tool_item.model_dump(exclude_none=True)
|
||||
elif isinstance(tool_item, SerializationMixin):
|
||||
raw = tool_item.to_dict()
|
||||
elif isinstance(tool_item, Mapping):
|
||||
raw = cast("Mapping[str, Any]", tool_item)
|
||||
|
||||
if raw is None:
|
||||
logger.warning(
|
||||
"Can't parse tool to OpenTelemetry tool definition: %s",
|
||||
type(tool_item).__name__, # type: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
return None
|
||||
return _otel_definition_from_mapping(raw)
|
||||
|
||||
|
||||
def _otel_definition_from_mapping(raw: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""Reshape a tool spec mapping into an OTel GenAI tool-definition dict.
|
||||
|
||||
Handles the nested OpenAI Chat Completions function shape
|
||||
(``{"type": "function", "function": {...}}``) by flattening it into the
|
||||
OTel shape.
|
||||
"""
|
||||
# OpenAI Chat Completions nests the function spec one level deeper; flatten it.
|
||||
nested_function = raw.get("function") if raw.get("type") == "function" else None
|
||||
if isinstance(nested_function, Mapping):
|
||||
nested = cast("Mapping[str, Any]", nested_function)
|
||||
name = nested.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'name'.")
|
||||
return None
|
||||
definition: dict[str, Any] = {"type": "function", "name": name}
|
||||
description = nested.get("description")
|
||||
if description:
|
||||
definition["description"] = description
|
||||
parameters = nested.get("parameters")
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
# Forward extra properties from both layers, preferring the inner spec.
|
||||
for source in (nested, raw):
|
||||
for key, value in source.items():
|
||||
if key in {"type", "function", "name", "description", "parameters"}:
|
||||
continue
|
||||
definition.setdefault(key, value)
|
||||
return definition
|
||||
|
||||
type_value = raw.get("type")
|
||||
if not isinstance(type_value, str) or not type_value:
|
||||
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'type'.")
|
||||
return None
|
||||
|
||||
name_value = raw.get("name")
|
||||
if not isinstance(name_value, str) or not name_value:
|
||||
# Hosted tools sometimes omit ``name`` (e.g. ``{"type": "code_interpreter"}``);
|
||||
# fall back to the type so the OTel definition stays valid.
|
||||
name_value = type_value
|
||||
|
||||
if type_value == "function":
|
||||
definition = {"type": "function", "name": name_value}
|
||||
description = raw.get("description")
|
||||
if description:
|
||||
definition["description"] = description
|
||||
parameters = raw.get("parameters")
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
for key, value in raw.items():
|
||||
if key in {"type", "name", "description", "parameters"}:
|
||||
continue
|
||||
definition.setdefault(key, value)
|
||||
return definition
|
||||
|
||||
definition = {"type": type_value, "name": name_value}
|
||||
for key, value in raw.items():
|
||||
if key in {"type", "name"}:
|
||||
continue
|
||||
definition[key] = value
|
||||
return definition
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# Mapping configuration for extracting span attributes
|
||||
# Each entry: source_keys -> (otel_attribute_key, transform_func, check_options_first, default_value)
|
||||
# - source_keys: single key or list of keys to check (first non-None value wins)
|
||||
@@ -2246,11 +2421,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non
|
||||
# Tools with validation - returns None if no valid tools
|
||||
"tools": (
|
||||
OtelAttr.TOOL_DEFINITIONS,
|
||||
lambda tools: (
|
||||
json.dumps(tools_dict, ensure_ascii=False)
|
||||
if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools))
|
||||
else None
|
||||
),
|
||||
lambda tools: json.dumps(tools_dict, ensure_ascii=False) if (tools_dict := _tools_to_dict(tools)) else None,
|
||||
True,
|
||||
None,
|
||||
),
|
||||
|
||||
@@ -3132,6 +3132,223 @@ def test_get_span_attributes_with_agent_info():
|
||||
assert attrs[OtelAttr.AGENT_DESCRIPTION] == "A test agent"
|
||||
|
||||
|
||||
def test_get_span_attributes_emits_otel_tool_definitions() -> None:
|
||||
"""``tools`` are serialized to OTel GenAI tool definitions on the span."""
|
||||
import json as _json
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import OtelAttr, _get_span_attributes
|
||||
|
||||
@tool(name="echo", description="Echo input")
|
||||
def echo(value: str) -> str:
|
||||
return value
|
||||
|
||||
attrs = _get_span_attributes(
|
||||
operation_name="chat",
|
||||
provider_name="openai",
|
||||
model="gpt-4",
|
||||
service_url="https://api.openai.com",
|
||||
tools=[
|
||||
echo,
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup by id",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{"type": "web_search", "name": "web_search"},
|
||||
],
|
||||
)
|
||||
|
||||
assert OtelAttr.TOOL_DEFINITIONS in attrs
|
||||
definitions = _json.loads(attrs[OtelAttr.TOOL_DEFINITIONS])
|
||||
assert definitions == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "echo",
|
||||
"description": "Echo input",
|
||||
"parameters": echo.parameters(),
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup by id",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
},
|
||||
{"type": "web_search", "name": "web_search"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_span_attributes_omits_tool_definitions_when_unparseable() -> None:
|
||||
"""When no tool can be converted, the tool definitions attribute is omitted."""
|
||||
from agent_framework.observability import OtelAttr, _get_span_attributes
|
||||
|
||||
attrs = _get_span_attributes(
|
||||
operation_name="chat",
|
||||
provider_name="openai",
|
||||
model="gpt-4",
|
||||
service_url="https://api.openai.com",
|
||||
tools=[{"kind": "not_an_otel_tool"}],
|
||||
)
|
||||
|
||||
assert OtelAttr.TOOL_DEFINITIONS not in attrs
|
||||
|
||||
|
||||
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
|
||||
"""Pydantic-based tool specs are reshaped into the OTel GenAI tool-definition shape."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
class ProviderTool(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
note: str | None = None
|
||||
|
||||
result = _tools_to_dict([ProviderTool(type="web_search", name="web_search")])
|
||||
|
||||
assert result == [{"type": "web_search", "name": "web_search", "enabled": True}]
|
||||
|
||||
|
||||
def test_tools_to_dict_returns_none_for_empty_input() -> None:
|
||||
"""``_tools_to_dict`` returns None when no tools are supplied."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
assert _tools_to_dict(None) is None
|
||||
assert _tools_to_dict([]) is None
|
||||
|
||||
|
||||
def test_tools_to_dict_function_tool_uses_otel_function_definition() -> None:
|
||||
"""``FunctionTool`` instances are emitted as flat OTel FunctionToolDefinition dicts."""
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
@tool(name="add", description="Add two numbers")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = _tools_to_dict([add])
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
definition = result[0]
|
||||
assert definition["type"] == "function"
|
||||
assert definition["name"] == "add"
|
||||
assert definition["description"] == "Add two numbers"
|
||||
assert definition["parameters"]["type"] == "object"
|
||||
assert set(definition["parameters"]["required"]) == {"x", "y"}
|
||||
# The legacy OpenAI Chat Completions ``function`` wrapper is not part of the OTel shape.
|
||||
assert "function" not in definition
|
||||
|
||||
|
||||
def test_tools_to_dict_flattens_openai_chat_completions_function_spec() -> None:
|
||||
"""OpenAI Chat Completions nested ``function`` spec is flattened to the OTel shape."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
openai_spec = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_user",
|
||||
"description": "Look up a user by id",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = _tools_to_dict([openai_spec])
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup_user",
|
||||
"description": "Look up a user by id",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"strict": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tools_to_dict_passes_through_hosted_tool_dicts() -> None:
|
||||
"""Hosted-tool dicts pass through with the OTel required keys preserved."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
result = _tools_to_dict([{"type": "web_search", "name": "web_search", "max_results": 5}])
|
||||
|
||||
assert result == [{"type": "web_search", "name": "web_search", "max_results": 5}]
|
||||
|
||||
|
||||
def test_tools_to_dict_falls_back_to_type_when_name_missing() -> None:
|
||||
"""Hosted-tool dicts without ``name`` fall back to the ``type`` value."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
result = _tools_to_dict([{"type": "code_interpreter"}])
|
||||
|
||||
assert result == [{"type": "code_interpreter", "name": "code_interpreter"}]
|
||||
|
||||
|
||||
def test_tools_to_dict_warns_when_type_missing(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Tools without an extractable ``type`` are skipped with a warning."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
with caplog.at_level("WARNING", logger="agent_framework"):
|
||||
result = _tools_to_dict([{"kind": "not_an_otel_tool"}])
|
||||
|
||||
assert result is None
|
||||
assert any("missing 'type'" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_tools_to_dict_warns_for_unknown_tool_object(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Tools that are neither callable, mapping, BaseModel, nor known type are skipped."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
class _Opaque:
|
||||
pass
|
||||
|
||||
with caplog.at_level("WARNING", logger="agent_framework"):
|
||||
result = _tools_to_dict([_Opaque()])
|
||||
|
||||
assert result is None
|
||||
assert any("OpenTelemetry tool definition" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_tool_to_otel_definition_caches_per_tool_object() -> None:
|
||||
"""Converting the same tool object twice reuses the cached OTel definition."""
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import _build_tool_otel_definition, _tool_to_otel_definition
|
||||
|
||||
@tool(name="add", description="Add two numbers")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
first = _tool_to_otel_definition(add)
|
||||
second = _tool_to_otel_definition(add)
|
||||
|
||||
# The cached result is returned as the same object on subsequent conversions.
|
||||
assert first is second
|
||||
# A fresh (uncached) build produces an equal but distinct object.
|
||||
assert _build_tool_otel_definition(add) == first
|
||||
|
||||
|
||||
def test_tool_to_otel_definition_skips_cache_for_unhashable_specs() -> None:
|
||||
"""Plain-dict tool specs are converted without raising despite being uncacheable."""
|
||||
from agent_framework.observability import _tool_to_otel_definition
|
||||
|
||||
spec = {"type": "web_search", "name": "web_search"}
|
||||
|
||||
assert _tool_to_otel_definition(spec) == {"type": "web_search", "name": "web_search"}
|
||||
|
||||
|
||||
# region Test _capture_response
|
||||
|
||||
|
||||
|
||||
@@ -19,26 +19,12 @@ from agent_framework._middleware import FunctionInvocationContext
|
||||
from agent_framework._tools import (
|
||||
_parse_annotation,
|
||||
_parse_inputs,
|
||||
_tools_to_dict,
|
||||
)
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
# region FunctionTool and tool decorator tests
|
||||
|
||||
|
||||
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
|
||||
"""Pydantic-based tool specs are serialized without logging parse warnings."""
|
||||
|
||||
class ProviderTool(BaseModel):
|
||||
kind: str
|
||||
enabled: bool = True
|
||||
note: str | None = None
|
||||
|
||||
result = _tools_to_dict([ProviderTool(kind="google_search")])
|
||||
|
||||
assert result == [{"kind": "google_search", "enabled": True}]
|
||||
|
||||
|
||||
def test_tool_decorator():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user