Compare commits

...

3 Commits

Author SHA1 Message Date
Tao Chen db4b4c2736 Cache serialized tools 2026-06-23 09:54:07 -07:00
Tao Chen ad654b523a Merge branch 'main' into local-branch-6300 2026-06-23 09:21:26 -07:00
Tao Chen 172c0a9507 Align serialized tool format to OTel GenAI tool def format 2026-06-16 14:17:21 -07:00
4 changed files with 393 additions and 52 deletions
@@ -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."""