diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index ccf70e36..fd60e72a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -466,6 +466,119 @@ with ExecuteToolScope.start( scope.record_response(result) ``` +`ToolCallDetails.arguments` and `ExecuteToolScope.record_response()` also accept typed execute-tool schema models. +Typed top-level arguments and results serialize to the Agent365 JSON contract with `schema_version: "1.0"`, +while raw dictionaries and strings remain supported and keep their existing behavior. + +```python +from microsoft.opentelemetry.a365.core import ( + ExecuteToolCallArguments, + ExecuteToolCallResult, + ExecuteToolScope, + ToolCallAction, + ToolCallDetails, + ToolCallOutcomeStatus, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + Request, +) + +arguments = ExecuteToolCallArguments( + action=ToolCallAction.READ, + resources=[ + ToolCallResource( + resource_id="file-1", + uri="https://contoso.example/files/1", + name="Forecast", + resource_type="file", + provider="sharepoint", + ) + ], + parameters={"city": "Seattle"}, + extension_data={"provider_operation": "weather.lookup"}, +) + +with ExecuteToolScope.start( + request=Request(content="What's the weather?"), + details=ToolCallDetails(tool_name="get_weather", tool_call_id="call_1", arguments=arguments), + agent_details=agent, +) as scope: + scope.record_response( + ExecuteToolCallResult( + outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), + data={"temperature_f": 68}, + pagination=ToolCallResultPagination(has_more=False, total_count=1), + ) + ) +``` + +The typed arguments above generate a JSON payload shaped like: + +```json +{ + "schema_version": "1.0", + "action": "read", + "resources": [ + { + "id": "file-1", + "uri": "https://contoso.example/files/1", + "name": "Forecast", + "type": "file", + "provider": "sharepoint" + } + ], + "parameters": {"city": "Seattle"}, + "metadata": { + "provider_operation": "weather.lookup" + } +} +``` + +#### Schema enums + +`action`, `outcome.status`, and `policy.decision` are constrained to the same tokens as the .NET distro: + +| Field | Enum | Tokens | +| --- | --- | --- | +| `ExecuteToolCallArguments.action` | `ToolCallAction` | `create`, `read`, `update`, `delete` | +| `ToolCallResultOutcome.status` | `ToolCallOutcomeStatus` | `success`, `failure` | +| `ToolCallResultPolicy.decision` | `ToolPolicyDecision` | `allow`, `deny` | + +The exact lowercase strings are accepted as well, so `action="read"` and `action=ToolCallAction.READ` are +equivalent. Any other value — a different casing, an undefined token, a number, a boolean, or a member of a +different enum — is rejected instead of emitting schema-invalid telemetry. + +#### Null handling and extension data + +Model properties that are `None` are omitted. `False`, zero, empty strings, empty dictionaries, and empty +lists are preserved. `None` inside a dictionary or list you supply (for example `parameters`, `data`, or +`extension_data`) is preserved as JSON `null`, matching the .NET contract. + +`extension_data` is serialized under a `metadata` property on the same model. The public Python field name +remains `extension_data`, while the JSON wire contract consistently uses `metadata`. Empty extension +dictionaries omit `metadata`. + +Each extensible nested model owns its own metadata object. Extension keys therefore cannot replace declared +properties such as `action`, `schema_version`, `outcome.status`, or `policy.decision`; a matching extension key +remains inside `metadata`. + +#### Serialization failures + +Typed payload serialization never raises and never leaves a span orphaned. If any value cannot be +represented — an unsupported type, a reference cycle, `NaN`/`Infinity`, or an undefined enum token — the +whole attribute value is replaced by the diagnostic payload and a warning is logged: + +```json +{"serialization_error": "Failed to serialize execute tool payload."} +``` + +`bytes` and `bytearray` are emitted as base64 strings, `datetime`/`date`/`time` as ISO-8601 strings (using +the `Z` designator for UTC), `UUID` as its canonical string, and `Decimal` as a precision-preserving JSON +number. Any `Enum` is emitted as its value, and lists, tuples, sets, and other sized collections as JSON +arrays. Raw dictionary arguments and results keep their existing +serialization but also fall back to the same diagnostic payload instead of raising. + ### InferenceScope ```python diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd0bc7f..06ad15cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Release History # Unreleased ### Features Added +- Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization, + `ToolCallAction`/`ToolCallOutcomeStatus`/`ToolPolicyDecision` enums, provider extension data wrapped under + the JSON `metadata` property, public exports, and `ExecuteToolScope` support while preserving raw + dict/string payloads. Execute-tool payload serialization is non-throwing: unserializable payloads record + `{"serialization_error": "Failed to serialize execute tool payload."}` instead of failing the span. - Add explicit custom baggage APIs and propagate opted-in attributes to supported GenAI spans, including unmodeled operations. ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) - Add Python-native `InvokeAgentScope` request and response parameter models that emit OpenTelemetry GenAI semantic attributes, including structured diff --git a/README.md b/README.md index 4875e67d..3e486cdb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,26 @@ use_microsoft_opentelemetry( See the [A365 guide](https://github.com/microsoft/opentelemetry-distro-python/blob/main/A365_DOCUMENTATION.md) for A365-specific configuration. +Agent365 manual execute-tool spans support typed JSON schema payloads: + +```python +from microsoft.opentelemetry.a365.core import ExecuteToolCallArguments, ToolCallAction, ToolCallResource + +arguments = ExecuteToolCallArguments( + action=ToolCallAction.READ, + resources=[ToolCallResource(resource_id="file-1", resource_type="file", provider="sharepoint")], + extension_data={"provider_operation": "lookup"}, +) +``` + +The Python `extension_data` dictionary is emitted as a non-empty `metadata` object in the JSON payload, +preventing provider-specific keys from colliding with declared schema fields. + +Typed arguments and results emit `schema_version: "1.0"` and serialize with schema field names such as +`id`, `type`, `provider_code`, and `has_more`. Serialization never raises: an unserializable payload is +replaced by `{"serialization_error": "Failed to serialize execute tool payload."}`. Raw dictionary and +string arguments/results remain supported. See the A365 guide for complete examples. + --- ## Configuration Reference diff --git a/src/microsoft/opentelemetry/a365/README.md b/src/microsoft/opentelemetry/a365/README.md index 8c7a6789..4c229e1e 100644 --- a/src/microsoft/opentelemetry/a365/README.md +++ b/src/microsoft/opentelemetry/a365/README.md @@ -65,6 +65,7 @@ Data models for messages, agents, callers, and responses. |------|-------------| | `caller_details.py` | `CallerDetails` dataclass — groups caller identity for agent-to-agent (A2A) scenarios (human user and calling agent). | | `messages.py` | OTel gen-ai message types: enums (`MessageRole`, `FinishReason`, `Modality`), message parts (`TextPart`, `BlobPart`, `FilePart`, `UriPart`), `ChatMessage`, `OutputMessage`, and wrapper types. | +| `tool_call_schema.py` | Typed execute-tool argument/result schema models and non-throwing JSON serializer for Agent365 payloads. | | `response.py` | `Response` dataclass — output messages (strings, `OutputMessages`, or tool result dicts). | | `service_endpoint.py` | `ServiceEndpoint` dataclass — hostname and optional port. | | `user_details.py` | `UserDetails` dataclass — human user info (ID, email, name, client IP). | diff --git a/src/microsoft/opentelemetry/a365/core/__init__.py b/src/microsoft/opentelemetry/a365/core/__init__.py index 5f033c32..22d08613 100644 --- a/src/microsoft/opentelemetry/a365/core/__init__.py +++ b/src/microsoft/opentelemetry/a365/core/__init__.py @@ -48,6 +48,24 @@ UriPart, ) from microsoft.opentelemetry.a365.core.models.response import Response +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( + ExecuteToolCallArguments, + ExecuteToolCallResult, + ToolCallAction, + ToolCallContainer, + ToolCallIdentifier, + ToolCallOutcomeStatus, + ToolCallPayload, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + ToolCallResultPolicy, + ToolCallResultResource, + ToolCallResultSecurity, + ToolCallResultSensitivity, + ToolPolicyDecision, + serialize_tool_call_payload, +) from microsoft.opentelemetry.a365.core.models.user_details import UserDetails from microsoft.opentelemetry.a365.core.opentelemetry_scope import OpenTelemetryScope from microsoft.opentelemetry.a365.core.request import Request @@ -86,11 +104,27 @@ "SpanDetails", "InferenceCallDetails", "ServiceEndpoint", + "ExecuteToolCallArguments", + "ExecuteToolCallResult", + "ToolCallContainer", + "ToolCallIdentifier", + "ToolCallPayload", + "ToolCallResource", + "ToolCallResultOutcome", + "ToolCallResultPagination", + "ToolCallResultPolicy", + "ToolCallResultResource", + "ToolCallResultSecurity", + "ToolCallResultSensitivity", + "serialize_tool_call_payload", "GenAiRequestParameters", "GenAiResponseParameters", # Enums "InferenceOperationType", "ToolType", + "ToolCallAction", + "ToolCallOutcomeStatus", + "ToolPolicyDecision", # OTEL gen-ai message format types "MessageRole", "FinishReason", diff --git a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py index 8313274a..72593d26 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import logging + from opentelemetry.trace import SpanKind from microsoft.opentelemetry.a365.core.agent_details import AgentDetails @@ -24,11 +26,37 @@ ) from microsoft.opentelemetry.a365.core.utils import safe_json_dumps, validate_and_normalize_ip from microsoft.opentelemetry.a365.core.models.user_details import UserDetails +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( + TOOL_CALL_SERIALIZATION_ERROR_JSON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + serialize_tool_call_payload, +) from microsoft.opentelemetry.a365.core.opentelemetry_scope import OpenTelemetryScope from microsoft.opentelemetry.a365.core.request import Request from microsoft.opentelemetry.a365.core.span_details import SpanDetails from microsoft.opentelemetry.a365.core.tool_call_details import ToolCallDetails +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +def _serialize_tool_payload(payload: ExecuteToolCallArguments | ExecuteToolCallResult | dict[str, object]) -> str: + """Serialize an execute-tool payload without ever raising. + + Typed payloads use the Agent365 schema contract; raw dictionaries keep their + existing ``safe_json_dumps`` behavior. Either way a payload that cannot be + serialized records the diagnostic payload instead of failing the span. + """ + if isinstance(payload, (ExecuteToolCallArguments, ExecuteToolCallResult)): + serialized = serialize_tool_call_payload(payload) + return serialized if serialized is not None else TOOL_CALL_SERIALIZATION_ERROR_JSON + try: + return safe_json_dumps(payload) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.warning("Failed to serialize execute tool payload: %r", exc) + return TOOL_CALL_SERIALIZATION_ERROR_JSON + class ExecuteToolScope(OpenTelemetryScope): """Provides OpenTelemetry tracing scope for AI tool execution operations.""" @@ -108,7 +136,11 @@ def __init__( self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, tool_name) if arguments is not None: - serialized = safe_json_dumps(arguments) if isinstance(arguments, dict) else arguments + serialized = ( + _serialize_tool_payload(arguments) + if isinstance(arguments, (ExecuteToolCallArguments, dict)) + else arguments + ) self.set_tag_maybe(GEN_AI_TOOL_ARGS_KEY, serialized) self.set_tag_maybe(GEN_AI_TOOL_TYPE_KEY, tool_type) self.set_tag_maybe(GEN_AI_TOOL_CALL_ID_KEY, tool_call_id) @@ -135,15 +167,24 @@ def __init__( validate_and_normalize_ip(user_details.user_client_ip), ) - def record_response(self, result: dict[str, object] | str) -> None: # pylint: disable=arguments-renamed + def record_response( # pylint: disable=arguments-renamed + self, result: ExecuteToolCallResult | dict[str, object] | str | None + ) -> None: """Record the tool call result for telemetry tracking. Per OTEL spec, the result is expected to be an object. If a string is provided, it is recorded as-is (JSON string fallback). If a dict - is provided, it is serialized to JSON. + is provided, it is serialized to JSON. Typed + :class:`~microsoft.opentelemetry.a365.core.models.tool_call_schema.ExecuteToolCallResult` + payloads are serialized with the Agent365 schema contract; a payload that cannot + be serialized records the diagnostic payload instead of raising. Args: - result: Tool call result as a structured dict or JSON string + result: Tool call result as a typed schema model, a structured dict, + a JSON string, or None to omit the attribute """ - serialized = safe_json_dumps(result) if isinstance(result, dict) else result + if isinstance(result, (ExecuteToolCallResult, dict)): + serialized: str | None = _serialize_tool_payload(result) + else: + serialized = result self.set_tag_maybe(GEN_AI_TOOL_CALL_RESULT_KEY, serialized) diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py new file mode 100644 index 00000000..10b2e725 --- /dev/null +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -0,0 +1,385 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Typed Agent365 execute-tool argument and result schema models. + +The models mirror the Agent365 JSON contract used by the .NET distro. Payloads are +serialized with :func:`serialize_tool_call_payload`, which never raises: any failure is +replaced by :data:`TOOL_CALL_SERIALIZATION_ERROR_JSON` so a span is never orphaned and no +caller exception escapes the telemetry path. +""" + +from __future__ import annotations + +import base64 +import datetime +import json +import logging +import uuid +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass, field, fields, is_dataclass +from decimal import Decimal +from enum import Enum +from typing import Any + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +#: Schema version emitted on every typed execute-tool payload. +TOOL_CALL_SCHEMA_VERSION = "1.0" + +#: Diagnostic payload emitted when a typed execute-tool payload cannot be serialized. +TOOL_CALL_SERIALIZATION_ERROR_JSON = '{"serialization_error":"Failed to serialize execute tool payload."}' + +_JSON_NAME = "json_name" +_ENUM_TYPE = "enum_type" +_EXTENSION_DATA_FIELD = "extension_data" +_EXTENSION_DATA_JSON_NAME = "metadata" + + +class ToolCallAction(str, Enum): + """Well-known actions taken by an execute-tool call.""" + + #: Creates a resource. + CREATE = "create" + #: Reads a resource. + READ = "read" + #: Updates a resource. + UPDATE = "update" + #: Deletes a resource. + DELETE = "delete" + + +class ToolCallOutcomeStatus(str, Enum): + """Well-known statuses for an execute-tool outcome.""" + + #: The tool call completed successfully. + SUCCESS = "success" + #: The tool call failed. + FAILURE = "failure" + + +class ToolPolicyDecision(str, Enum): + """Well-known policy decisions for an execute-tool result resource.""" + + #: The policy allows the tool call. + ALLOW = "allow" + #: The policy denies the tool call. + DENY = "deny" + + +@dataclass +class ToolCallIdentifier: + """Resource identifier for an execute-tool payload.""" + + identifier_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) + value: str | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallContainer: + """Container that owns or scopes an execute-tool resource.""" + + container_id: str | None = field(default=None, metadata={_JSON_NAME: "id"}) + uri: str | None = None + container_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResource: + """Resource referenced by an execute-tool arguments payload.""" + + resource_id: str | None = field(default=None, metadata={_JSON_NAME: "id"}) + uri: str | None = None + name: str | None = None + resource_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) + provider: str | None = None + identifiers: list[ToolCallIdentifier] | None = None + container: ToolCallContainer | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ExecuteToolCallArguments: + """Structured arguments for an execute-tool call.""" + + action: ToolCallAction | str | None = field(default=None, metadata={_ENUM_TYPE: ToolCallAction}) + resources: list[ToolCallResource] | None = None + parameters: dict[str, object] | None = None + schema_version: str = TOOL_CALL_SCHEMA_VERSION + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultOutcome: + """Outcome metadata for an execute-tool result.""" + + status: ToolCallOutcomeStatus | str | None = field(default=None, metadata={_ENUM_TYPE: ToolCallOutcomeStatus}) + code: str | None = None + provider_code: str | None = None + message: str | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultSensitivity: + """Sensitivity metadata for an execute-tool result resource.""" + + label_id: str | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultPolicy: + """Policy metadata for an execute-tool result resource.""" + + decision: ToolPolicyDecision | str | None = field(default=None, metadata={_ENUM_TYPE: ToolPolicyDecision}) + policy_id: str | None = field(default=None, metadata={_JSON_NAME: "id"}) + name: str | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultSecurity: + """Security metadata for an execute-tool result resource.""" + + xpia_detected: bool | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultPagination: + """Pagination metadata for an execute-tool result.""" + + has_more: bool | None = None + next_cursor: str | None = None + total_count: int | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ToolCallResultResource: + """Resource included in an execute-tool result payload.""" + + resource_id: str | None = field(default=None, metadata={_JSON_NAME: "id"}) + uri: str | None = None + name: str | None = None + resource_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) + provider: str | None = None + identifiers: list[ToolCallIdentifier] | None = None + container: ToolCallContainer | None = None + outcome: ToolCallResultOutcome | None = None + sensitivity: ToolCallResultSensitivity | None = None + policy: ToolCallResultPolicy | None = None + security: ToolCallResultSecurity | None = None + data: dict[str, object] | None = None + extension_data: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ExecuteToolCallResult: + """Structured result for an execute-tool call.""" + + outcome: ToolCallResultOutcome | None = None + resources: list[ToolCallResultResource] | None = None + data: dict[str, object] | None = None + pagination: ToolCallResultPagination | None = None + schema_version: str = TOOL_CALL_SCHEMA_VERSION + extension_data: dict[str, object] = field(default_factory=dict) + + +ToolCallPayload = ExecuteToolCallArguments | ExecuteToolCallResult + + +def _to_base64(value: bytes | bytearray | memoryview) -> str: + """Return the base64 text for a binary value, matching the .NET byte-array contract.""" + return base64.b64encode(bytes(value)).decode("ascii") + + +def _to_isoformat(value: datetime.datetime | datetime.date | datetime.time) -> str: + """Return the ISO-8601 text for a date/time value.""" + text = value.isoformat() + return f"{text[:-6]}Z" if text.endswith("+00:00") else text + + +def _preserve_decimal(value: Decimal) -> Decimal: + """Keep a decimal value intact until JSON-number encoding.""" + return value + + +# Ordered scalar conversions; ``bool`` must precede ``int`` and ``str`` must precede ``Collection``. +_SCALAR_CONVERTERS: tuple[tuple[type | tuple[type, ...], Callable[[Any], Any]], ...] = ( + (str, str), + (bool, bool), + (int, int), + (float, float), + ((bytes, bytearray, memoryview), _to_base64), + ((datetime.datetime, datetime.date, datetime.time), _to_isoformat), + (uuid.UUID, str), + (Decimal, _preserve_decimal), +) + + +def _encode_json_value(value: Any) -> str: # pylint: disable=too-many-return-statements + """Encode a normalized payload value, preserving decimal JSON numbers.""" + if value is None: + return "null" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return json.dumps(value, allow_nan=False) + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("Out of range Decimal values are not JSON compliant.") + return str(value) + if isinstance(value, list): + return f"[{', '.join(_encode_json_value(item) for item in value)}]" + if isinstance(value, dict): + items = ( + f"{json.dumps(key, ensure_ascii=False)}: {_encode_json_value(item_value)}" + for key, item_value in value.items() + ) + return f"{{{', '.join(items)}}}" + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable.") + + +def serialize_tool_call_payload(value: ToolCallPayload | None) -> str | None: + """Serialize a typed execute-tool payload using the Agent365 JSON contract. + + The call never raises. When any value in the payload cannot be represented in the + schema — an unsupported type, a reference cycle, a non-finite float, an undefined + enum value, or invalid extension data — the whole payload is replaced by + :data:`TOOL_CALL_SERIALIZATION_ERROR_JSON`. + + Args: + value: The typed payload to serialize, or ``None``. + + Returns: + The JSON payload, the diagnostic payload on failure, or ``None`` when + ``value`` is ``None``. + """ + if value is None: + return None + + try: + return _encode_json_value(_to_json_value(value, set())) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.warning("Failed to serialize execute tool payload: %r", exc) + return TOOL_CALL_SERIALIZATION_ERROR_JSON + + +def _to_json_value(value: Any, stack: set[int]) -> Any: + """Convert a payload value into a JSON-compatible value, raising on unsupported input.""" + if value is None: + return None + if isinstance(value, Enum): + return _to_json_value(value.value, stack) + for scalar_types, convert in _SCALAR_CONVERTERS: + if isinstance(value, scalar_types): + return convert(value) + if is_dataclass(value) and not isinstance(value, type): + return _dataclass_to_json_value(value, stack) + if isinstance(value, Mapping): + return _mapping_to_json_value(value, stack) + if isinstance(value, Collection): + return _collection_to_json_value(value, stack) + raise TypeError(f"Object of type {type(value).__name__} is not supported in an execute tool payload.") + + +def _enter(value: Any, stack: set[int]) -> int: + """Push a container onto the active recursion path, rejecting reference cycles.""" + marker = id(value) + if marker in stack: + raise ValueError("Circular reference detected in execute tool payload.") + stack.add(marker) + return marker + + +def _dataclass_to_json_value(value: Any, stack: set[int]) -> dict[str, Any]: + """Serialize a schema model, omitting ``None`` properties and wrapping extension data.""" + marker = _enter(value, stack) + try: + serialized: dict[str, Any] = {} + extension_data: Any = {} + for item in fields(value): + item_value = getattr(value, item.name) + if item.name == _EXTENSION_DATA_FIELD: + extension_data = item_value if item_value is not None else {} + continue + if item_value is None: + continue + json_name = item.metadata.get(_JSON_NAME, item.name) + enum_type = item.metadata.get(_ENUM_TYPE) + if enum_type is None: + serialized[json_name] = _to_json_value(item_value, stack) + else: + serialized[json_name] = _coerce_enum(item_value, enum_type, json_name) + + if not isinstance(extension_data, Mapping): + raise TypeError(f"Extension data must be a mapping; got {type(extension_data).__name__}.") + if extension_data: + serialized[_EXTENSION_DATA_JSON_NAME] = _mapping_to_json_value(extension_data, stack) + return serialized + finally: + stack.discard(marker) + + +def _mapping_to_json_value(value: Mapping[Any, Any], stack: set[int]) -> dict[str, Any]: + """Serialize a caller-supplied mapping, preserving ``None`` values as JSON ``null``.""" + marker = _enter(value, stack) + try: + return {_json_object_key(key): _to_json_value(item_value, stack) for key, item_value in value.items()} + finally: + stack.discard(marker) + + +def _collection_to_json_value(value: Collection[Any], stack: set[int]) -> list[Any]: + """Serialize a caller-supplied collection as a JSON array, preserving ``None`` items.""" + marker = _enter(value, stack) + try: + return [_to_json_value(item_value, stack) for item_value in value] + finally: + stack.discard(marker) + + +def _json_object_key(key: Any) -> str: + """Return the JSON object key for a mapping key, rejecting non-string keys.""" + key_value = key.value if isinstance(key, Enum) else key + if not isinstance(key_value, str): + raise TypeError(f"Execute tool payload object keys must be strings; got {type(key).__name__}.") + return str(key_value) + + +def _coerce_enum(value: Any, enum_type: type[Enum], json_name: str) -> str: + """Return the schema token for an enum-valued property. + + Enum members are accepted, as are the exact lowercase string tokens for backward + compatibility. Undefined tokens, numeric values, booleans, and members of other + enums are rejected so schema-invalid data is never emitted. + """ + if isinstance(value, enum_type): + member_value = value.value + elif isinstance(value, str) and not isinstance(value, Enum): + try: + member_value = enum_type(value).value + except ValueError as exc: + raise ValueError(f"'{json_name}' must be one of {_allowed_enum_values(enum_type)}; got {value!r}.") from exc + else: + raise TypeError( + f"'{json_name}' must be a {enum_type.__name__} member or one of " + f"{_allowed_enum_values(enum_type)}; got {type(value).__name__}." + ) + if not isinstance(member_value, str): + raise TypeError(f"'{json_name}' must serialize to a string; got {type(member_value).__name__}.") + return str(member_value) + + +def _allowed_enum_values(enum_type: type[Enum]) -> str: + """Return a display list of the accepted string tokens for an enum.""" + return ", ".join(repr(member.value) for member in enum_type) diff --git a/src/microsoft/opentelemetry/a365/core/tool_call_details.py b/src/microsoft/opentelemetry/a365/core/tool_call_details.py index 26ef3d39..d1a007e1 100644 --- a/src/microsoft/opentelemetry/a365/core/tool_call_details.py +++ b/src/microsoft/opentelemetry/a365/core/tool_call_details.py @@ -7,6 +7,7 @@ from dataclasses import dataclass +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ExecuteToolCallArguments from microsoft.opentelemetry.a365.core.models.service_endpoint import ServiceEndpoint @@ -15,7 +16,7 @@ class ToolCallDetails: """Details of a tool call made by an agent in the system.""" tool_name: str - arguments: dict[str, object] | str | None = None + arguments: ExecuteToolCallArguments | dict[str, object] | str | None = None tool_call_id: str | None = None description: str | None = None tool_type: str | None = None diff --git a/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py new file mode 100644 index 00000000..da18c4b6 --- /dev/null +++ b/tests/a365/test_execute_tool_scope.py @@ -0,0 +1,261 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os +import unittest +from unittest.mock import patch + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider + +from microsoft.opentelemetry.a365.core.agent_details import AgentDetails +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_TOOL_ARGS_KEY, + GEN_AI_TOOL_CALL_RESULT_KEY, +) +from microsoft.opentelemetry.a365.core.execute_tool_scope import ExecuteToolScope +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( + TOOL_CALL_SERIALIZATION_ERROR_JSON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + ToolCallAction, + ToolCallOutcomeStatus, + ToolCallResource, + ToolCallResultOutcome, +) +from microsoft.opentelemetry.a365.core.opentelemetry_scope import OpenTelemetryScope +from microsoft.opentelemetry.a365.core.request import Request +from microsoft.opentelemetry.a365.core.tool_call_details import ToolCallDetails + + +@patch.dict(os.environ, {"ENABLE_OBSERVABILITY": "true"}) +class TestExecuteToolScope(unittest.TestCase): + def setUp(self): + self._provider = TracerProvider() + trace.set_tracer_provider(self._provider) + OpenTelemetryScope._tracer = None + + def tearDown(self): + self._provider.shutdown() + OpenTelemetryScope._tracer = None + + def _make_agent_details(self): + return AgentDetails(agent_id="agent-1") + + def test_typed_arguments_include_schema_version(self): + scope = ExecuteToolScope.start( + Request(conversation_id="conversation-1"), + ToolCallDetails( + tool_name="read_file", + arguments=ExecuteToolCallArguments( + action=ToolCallAction.READ, + resources=[ + ToolCallResource( + resource_id="file-1", + extension_data={"tenant_id": "tenant-123"}, + ) + ], + extension_data={"provider_trace_id": "trace-789"}, + ), + ), + self._make_agent_details(), + ) + try: + attrs = dict(scope._span.attributes) + payload = json.loads(attrs[GEN_AI_TOOL_ARGS_KEY]) + + self.assertEqual(payload["schema_version"], "1.0") + self.assertEqual(payload["action"], "read") + self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) + self.assertEqual( + payload["resources"], + [{"id": "file-1", "metadata": {"tenant_id": "tenant-123"}}], + ) + finally: + scope.dispose() + + def test_typed_result_include_schema_version(self): + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file"), + self._make_agent_details(), + ) + try: + scope.record_response( + ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, + extension_data={"provider_status": "accepted"}, + ), + data={"count": 0}, + extension_data={"provider_trace_id": "trace-789"}, + ) + ) + attrs = dict(scope._span.attributes) + payload = json.loads(attrs[GEN_AI_TOOL_CALL_RESULT_KEY]) + + self.assertEqual(payload["schema_version"], "1.0") + self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) + self.assertEqual( + payload["outcome"], + { + "status": "success", + "metadata": {"provider_status": "accepted"}, + }, + ) + self.assertEqual(payload["data"], {"count": 0}) + finally: + scope.dispose() + + def test_raw_arguments_and_results_are_unchanged(self): + arguments = {"existing": True} + result = {"ok": False} + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file", arguments=arguments), + self._make_agent_details(), + ) + try: + scope.record_response(result) + attrs = dict(scope._span.attributes) + + self.assertEqual(attrs[GEN_AI_TOOL_ARGS_KEY], json.dumps(arguments)) + self.assertEqual(attrs[GEN_AI_TOOL_CALL_RESULT_KEY], json.dumps(result)) + finally: + scope.dispose() + + def test_raw_argument_and_result_strings_are_unchanged(self): + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file", arguments="raw arguments"), + self._make_agent_details(), + ) + try: + scope.record_response("raw result") + attrs = dict(scope._span.attributes) + + self.assertEqual(attrs[GEN_AI_TOOL_ARGS_KEY], "raw arguments") + self.assertEqual(attrs[GEN_AI_TOOL_CALL_RESULT_KEY], "raw result") + finally: + scope.dispose() + + def test_unserializable_typed_arguments_record_diagnostic_payload(self): + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails( + tool_name="read_file", + arguments=ExecuteToolCallArguments(extension_data={"bad": object()}), + ), + self._make_agent_details(), + ) + try: + attrs = dict(scope._span.attributes) + + self.assertEqual(attrs[GEN_AI_TOOL_ARGS_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + finally: + scope.dispose() + + def test_unserializable_typed_result_records_diagnostic_payload(self): + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file"), + self._make_agent_details(), + ) + try: + result = ExecuteToolCallResult() + result.extension_data["self"] = result + scope.record_response(result) + attrs = dict(scope._span.attributes) + + self.assertEqual(attrs[GEN_AI_TOOL_CALL_RESULT_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + finally: + scope.dispose() + + def test_none_result_omits_result_tag(self): + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file"), + self._make_agent_details(), + ) + try: + scope.record_response(None) + + self.assertNotIn(GEN_AI_TOOL_CALL_RESULT_KEY, dict(scope._span.attributes)) + finally: + scope.dispose() + + def test_unserializable_payloads_do_not_orphan_the_span(self): + with ExecuteToolScope.start( + Request(), + ToolCallDetails( + tool_name="read_file", + arguments=ExecuteToolCallArguments(extension_data={"bad": object()}), + ), + self._make_agent_details(), + ) as scope: + span = scope._span + self.assertTrue(span.is_recording()) + scope.record_response(ExecuteToolCallResult(data={"bad": float("nan")})) + + attrs = dict(span.attributes) + self.assertIsNotNone(span.end_time) + self.assertFalse(span.is_recording()) + self.assertEqual(attrs[GEN_AI_TOOL_ARGS_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + self.assertEqual(attrs[GEN_AI_TOOL_CALL_RESULT_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + + def test_unserializable_raw_dict_payloads_do_not_orphan_the_span(self): + cyclic_arguments: dict[str, object] = {} + cyclic_arguments["self"] = cyclic_arguments + cyclic_result: dict[str, object] = {} + cyclic_result["self"] = cyclic_result + + with ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file", arguments=cyclic_arguments), + self._make_agent_details(), + ) as scope: + span = scope._span + scope.record_response(cyclic_result) + + attrs = dict(span.attributes) + self.assertIsNotNone(span.end_time) + self.assertEqual(attrs[GEN_AI_TOOL_ARGS_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + self.assertEqual(attrs[GEN_AI_TOOL_CALL_RESULT_KEY], TOOL_CALL_SERIALIZATION_ERROR_JSON) + + +@patch.dict(os.environ, {"ENABLE_OBSERVABILITY": "false"}) +class TestExecuteToolScopeWithTelemetryDisabled(unittest.TestCase): + def setUp(self): + OpenTelemetryScope._tracer = None + + def tearDown(self): + OpenTelemetryScope._tracer = None + + def test_unserializable_typed_payloads_are_safe_when_telemetry_is_disabled(self): + result = ExecuteToolCallResult() + result.extension_data["self"] = result + cyclic_arguments: dict[str, object] = {} + cyclic_arguments["self"] = cyclic_arguments + + scope = ExecuteToolScope.start( + Request(), + ToolCallDetails( + tool_name="read_file", + arguments=ExecuteToolCallArguments(extension_data={"bad": object()}), + ), + AgentDetails(agent_id="agent-1"), + ) + scope.record_response(result) + scope.dispose() + + raw_scope = ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="read_file", arguments=cyclic_arguments), + AgentDetails(agent_id="agent-1"), + ) + raw_scope.record_response(cyclic_arguments) + raw_scope.dispose() + + self.assertIsNone(scope._span) + self.assertIsNone(raw_scope._span) diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py new file mode 100644 index 00000000..9ebfc57c --- /dev/null +++ b/tests/a365/test_tool_call_schema.py @@ -0,0 +1,551 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime +import json +import uuid +from collections.abc import Mapping +from decimal import Decimal +from enum import Enum + +import pytest + +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( + TOOL_CALL_SCHEMA_VERSION, + TOOL_CALL_SERIALIZATION_ERROR_JSON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + ToolCallAction, + ToolCallContainer, + ToolCallIdentifier, + ToolCallOutcomeStatus, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + ToolCallResultPolicy, + ToolCallResultResource, + ToolCallResultSecurity, + ToolCallResultSensitivity, + ToolPolicyDecision, + serialize_tool_call_payload, +) + +# Exact diagnostic contract emitted by the .NET distro (MessageUtils.SerializeToolPayload). +DOTNET_SERIALIZATION_ERROR = '{"serialization_error":"Failed to serialize execute tool payload."}' + + +class _OtherEnum(str, Enum): + READ = "read" + + +class _ThrowingMapping(Mapping): + """Mapping whose enumeration fails, mirroring the .NET ThrowingEnumerable test double.""" + + def __getitem__(self, key): + raise RuntimeError("test") + + def __iter__(self): + raise RuntimeError("test") + + def __len__(self): + return 1 + + +def test_serialization_error_matches_dotnet_contract(): + assert TOOL_CALL_SERIALIZATION_ERROR_JSON == DOTNET_SERIALIZATION_ERROR + assert json.loads(TOOL_CALL_SERIALIZATION_ERROR_JSON) == { + "serialization_error": "Failed to serialize execute tool payload." + } + + +def test_schema_version_default_matches_dotnet_contract(): + assert TOOL_CALL_SCHEMA_VERSION == "1.0" + assert ExecuteToolCallArguments().schema_version == "1.0" + assert ExecuteToolCallResult().schema_version == "1.0" + + +def test_public_enums_match_dotnet_values(): + assert [member.value for member in ToolCallAction] == ["create", "read", "update", "delete"] + assert [member.value for member in ToolCallOutcomeStatus] == ["success", "failure"] + assert [member.value for member in ToolPolicyDecision] == ["allow", "deny"] + assert ToolCallAction.READ == "read" + + +def test_execute_tool_arguments_serialize_with_schema_names(): + payload = ExecuteToolCallArguments( + action=ToolCallAction.READ, + resources=[ + ToolCallResource( + resource_id="file-1", + uri="https://example/file", + name="report", + resource_type="file", + provider="sharepoint", + identifiers=[ + ToolCallIdentifier( + identifier_type="drive_id", + value="d1", + extension_data={"identifier_scope": "tenant"}, + ) + ], + container=ToolCallContainer( + container_id="site-1", + uri="https://example", + container_type="site", + extension_data={"site_collection_id": "site-456"}, + ), + extension_data={"tenant_id": "tenant-123"}, + ) + ], + parameters={"page": 1}, + extension_data={"provider_trace_id": "trace-789"}, + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "action": "read", + "resources": [ + { + "id": "file-1", + "uri": "https://example/file", + "name": "report", + "type": "file", + "provider": "sharepoint", + "identifiers": [ + { + "type": "drive_id", + "value": "d1", + "metadata": {"identifier_scope": "tenant"}, + } + ], + "container": { + "id": "site-1", + "uri": "https://example", + "type": "site", + "metadata": {"site_collection_id": "site-456"}, + }, + "metadata": {"tenant_id": "tenant-123"}, + } + ], + "parameters": {"page": 1}, + "metadata": {"provider_trace_id": "trace-789"}, + } + + +def test_execute_tool_result_serializes_with_schema_names(): + payload = ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, + code="ok", + provider_code="sharepoint_ok", + message="Read completed", + extension_data={"outcome_detail": "accepted"}, + ), + resources=[ + ToolCallResultResource( + resource_id="file-1", + uri="https://example/file", + name="report", + resource_type="file", + provider="sharepoint", + identifiers=[ + ToolCallIdentifier( + identifier_type="drive_id", + value="d1", + extension_data={"identifier_scope": "tenant"}, + ) + ], + container=ToolCallContainer( + container_id="site-1", + uri="https://example", + container_type="site", + extension_data={"site_collection_id": "site-456"}, + ), + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, + extension_data={"resource_outcome": "accepted"}, + ), + sensitivity=ToolCallResultSensitivity( + label_id="confidential", + extension_data={"label_source": "provider"}, + ), + policy=ToolCallResultPolicy( + decision=ToolPolicyDecision.ALLOW, + policy_id="policy-1", + name="Sharing policy", + extension_data={"policy_version": "2"}, + ), + security=ToolCallResultSecurity( + xpia_detected=False, + extension_data={"scanner": "provider"}, + ), + data={"bytes": 0}, + extension_data={"resource_region": "westus"}, + ) + ], + data={"content": ""}, + pagination=ToolCallResultPagination( + has_more=False, + next_cursor="cursor-2", + total_count=0, + extension_data={"page_source": "cache"}, + ), + extension_data={"provider_trace_id": "trace-789"}, + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "outcome": { + "status": "success", + "code": "ok", + "provider_code": "sharepoint_ok", + "message": "Read completed", + "metadata": {"outcome_detail": "accepted"}, + }, + "resources": [ + { + "id": "file-1", + "uri": "https://example/file", + "name": "report", + "type": "file", + "provider": "sharepoint", + "identifiers": [ + { + "type": "drive_id", + "value": "d1", + "metadata": {"identifier_scope": "tenant"}, + } + ], + "container": { + "id": "site-1", + "uri": "https://example", + "type": "site", + "metadata": {"site_collection_id": "site-456"}, + }, + "outcome": { + "status": "success", + "metadata": {"resource_outcome": "accepted"}, + }, + "sensitivity": { + "label_id": "confidential", + "metadata": {"label_source": "provider"}, + }, + "policy": { + "decision": "allow", + "id": "policy-1", + "name": "Sharing policy", + "metadata": {"policy_version": "2"}, + }, + "security": { + "xpia_detected": False, + "metadata": {"scanner": "provider"}, + }, + "data": {"bytes": 0}, + "metadata": {"resource_region": "westus"}, + } + ], + "data": {"content": ""}, + "pagination": { + "has_more": False, + "next_cursor": "cursor-2", + "total_count": 0, + "metadata": {"page_source": "cache"}, + }, + "metadata": {"provider_trace_id": "trace-789"}, + } + + +def test_execute_tool_arguments_omit_none_properties_and_preserve_empty_or_false_values(): + payload = ExecuteToolCallArguments( + resources=[], + parameters={ + "include_archived": False, + "page": 0, + "filters": {}, + "tags": [], + }, + extension_data={"provider_options": {}}, + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "resources": [], + "parameters": { + "include_archived": False, + "page": 0, + "filters": {}, + "tags": [], + }, + "metadata": {"provider_options": {}}, + } + + +def test_none_is_omitted_for_model_properties_but_preserved_inside_mappings_and_lists(): + payload = ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, + provider_code=None, + extension_data={"provider_outcome": None, "attempts": 0}, + ), + data={"content": None, "matches": [None, 1]}, + extension_data={"provider_result": None, "cached": False}, + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "outcome": { + "status": "success", + "metadata": {"provider_outcome": None, "attempts": 0}, + }, + "data": {"content": None, "matches": [None, 1]}, + "metadata": {"provider_result": None, "cached": False}, + } + + +def test_extension_data_keys_matching_declared_fields_remain_inside_metadata(): + payload = ExecuteToolCallArguments( + action=ToolCallAction.READ, + extension_data={ + "action": "write", + "schema_version": "9.9", + }, + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "action": "read", + "metadata": { + "action": "write", + "schema_version": "9.9", + }, + } + + +def test_nested_extension_data_keys_matching_declared_fields_remain_inside_metadata(): + payload = ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + code="ok", + extension_data={"code": "provider-code"}, + ), + ) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "outcome": { + "code": "ok", + "metadata": {"code": "provider-code"}, + }, + } + + +def test_empty_extension_data_omits_metadata(): + assert json.loads(serialize_tool_call_payload(ExecuteToolCallArguments())) == {"schema_version": "1.0"} + + +def test_serialize_none_payload_returns_none(): + assert serialize_tool_call_payload(None) is None + + +def test_serialize_is_non_throwing_for_unsupported_values(): + payload = ExecuteToolCallResult(extension_data={"good": 42, "bad": object()}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_when_a_single_value_raises(): + payload = ExecuteToolCallResult(extension_data={"good": 42, "bad": _ThrowingMapping()}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_for_excessively_nested_payloads(): + nested: dict[str, object] = {} + node = nested + for _ in range(10_000): + child: dict[str, object] = {} + node["child"] = child + node = child + + assert serialize_tool_call_payload(ExecuteToolCallResult(data=nested)) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_for_self_referencing_payloads(): + payload = ExecuteToolCallResult() + payload.extension_data["self"] = payload + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_for_nested_mapping_cycles(): + cycle: dict[str, object] = {} + cycle["self"] = cycle + + assert serialize_tool_call_payload(ExecuteToolCallResult(data=cycle)) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_for_nested_list_cycles(): + cycle: list[object] = [] + cycle.append(cycle) + + payload = ExecuteToolCallResult(data={"items": cycle}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_repeated_references_that_are_not_cycles_still_serialize(): + shared = {"shared": True} + payload = ExecuteToolCallResult(data={"first": shared, "second": shared}) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "data": {"first": {"shared": True}, "second": {"shared": True}}, + } + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_serialize_is_non_throwing_for_non_finite_floats(value): + payload = ExecuteToolCallResult(data={"good": 42, "bad": value}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_serialize_is_non_throwing_for_non_string_mapping_keys(): + payload = ExecuteToolCallResult(data={1: "one"}) # type: ignore[dict-item] + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_bytes_serialize_as_base64_like_dotnet(): + payload = ExecuteToolCallResult(data={"bytes": bytes([0, 1, 2, 3]), "buffer": bytearray([0, 1, 2, 3])}) + + assert json.loads(serialize_tool_call_payload(payload))["data"] == { + "bytes": "AAECAw==", + "buffer": "AAECAw==", + } + + +def test_well_known_scalar_types_serialize_as_strings_or_numbers(): + identifier = uuid.UUID("12345678-1234-5678-1234-567812345678") + payload = ExecuteToolCallResult( + data={ + "timestamp": datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc), + "day": datetime.date(2026, 1, 2), + "time": datetime.time(3, 4, 5), + "id": identifier, + "amount": Decimal("1.5"), + } + ) + + assert json.loads(serialize_tool_call_payload(payload))["data"] == { + "timestamp": "2026-01-02T03:04:05Z", + "day": "2026-01-02", + "time": "03:04:05", + "id": "12345678-1234-5678-1234-567812345678", + "amount": 1.5, + } + + +def test_decimal_serialization_preserves_precision(): + amount = Decimal("12345678901234567890.1234") + payload = ExecuteToolCallResult(data={"amount": amount}) + + serialized = serialize_tool_call_payload(payload) + + assert serialized is not None + assert json.loads(serialized, parse_float=Decimal)["data"]["amount"] == amount + + +def test_enum_values_inside_mappings_serialize_to_their_values(): + payload = ExecuteToolCallResult(data={"action": ToolCallAction.READ, "status": ToolCallOutcomeStatus.FAILURE}) + + assert json.loads(serialize_tool_call_payload(payload))["data"] == { + "action": "read", + "status": "failure", + } + + +def test_tuples_serialize_as_json_arrays(): + payload = ExecuteToolCallResult(data={"items": (1, 2)}) + + assert json.loads(serialize_tool_call_payload(payload))["data"] == {"items": [1, 2]} + + +def test_sets_and_views_serialize_as_json_arrays_like_dotnet_collections(): + payload = ExecuteToolCallResult( + data={ + "scopes": {"read"}, + "frozen": frozenset(["write"]), + "keys": {"a": 1}.keys(), + "values": {"a": 1}.values(), + } + ) + + assert json.loads(serialize_tool_call_payload(payload))["data"] == { + "scopes": ["read"], + "frozen": ["write"], + "keys": ["a"], + "values": [1], + } + + +def test_generators_are_not_supported(): + payload = ExecuteToolCallResult(data={"items": (index for index in range(2))}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + (ExecuteToolCallArguments(action=ToolCallAction.UPDATE), {"action": "update"}), + (ExecuteToolCallArguments(action="update"), {"action": "update"}), + ], +) +def test_enum_fields_accept_members_and_exact_lowercase_strings(payload, expected): + assert json.loads(serialize_tool_call_payload(payload))["action"] == expected["action"] + + +@pytest.mark.parametrize("value", ["Read", "READ", "reed", "", 0, 1, True, _OtherEnum.READ, ["read"]]) +def test_action_rejects_undefined_and_non_string_values(value): + payload = ExecuteToolCallArguments(action=value) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +@pytest.mark.parametrize("value", ["Success", "ok", 0, 1, True]) +def test_outcome_status_rejects_undefined_and_non_string_values(value): + payload = ExecuteToolCallResult(outcome=ToolCallResultOutcome(status=value)) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +@pytest.mark.parametrize("value", ["Allow", "permit", 0, 1, True]) +def test_policy_decision_rejects_undefined_and_non_string_values(value): + payload = ExecuteToolCallResult( + resources=[ToolCallResultResource(policy=ToolCallResultPolicy(decision=value))], + ) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_outcome_status_accepts_exact_lowercase_strings(): + payload = ExecuteToolCallResult(outcome=ToolCallResultOutcome(status="failure")) + + assert json.loads(serialize_tool_call_payload(payload))["outcome"] == {"status": "failure"} + + +def test_policy_decision_accepts_exact_lowercase_strings(): + payload = ExecuteToolCallResult( + resources=[ToolCallResultResource(policy=ToolCallResultPolicy(decision="deny"))], + ) + + assert json.loads(serialize_tool_call_payload(payload))["resources"] == [{"policy": {"decision": "deny"}}] + + +def test_serialization_failure_is_logged(caplog): + payload = ExecuteToolCallResult(extension_data={"bad": object()}) + + with caplog.at_level("WARNING", logger="microsoft.opentelemetry.a365.core.models.tool_call_schema"): + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + assert any("Failed to serialize execute tool payload" in record.message for record in caplog.records)