From b9398f2e3a8c17632d527d9e3f6e2101315ff214 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:17:46 -0600 Subject: [PATCH 01/20] feat: add execute-tool arguments schema models Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/models/tool_call_schema.py | 92 +++++++++++++++++++ tests/a365/test_tool_call_schema.py | 84 +++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py create mode 100644 tests/a365/test_tool_call_schema.py 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..0a2e6920 --- /dev/null +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, fields, is_dataclass +from typing import Any + + +@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: str | None = None + resources: list[ToolCallResource] | None = None + parameters: dict[str, object] | None = None + schema_version: str = field(default="1.0", metadata={"json_name": "schema_version"}) + extension_data: dict[str, object] = field(default_factory=dict) + + +def serialize_tool_call_payload(value: ExecuteToolCallArguments) -> str: + """Serialize a typed execute-tool payload using the Agent365 JSON contract.""" + + return json.dumps(_to_json_value(value), ensure_ascii=False) + + +def _to_json_value(value: Any) -> Any: + if value is None: + return None + + if is_dataclass(value): + serialized: dict[str, object] = {} + extension_data: Mapping[str, object] | None = None + for item in fields(value): + item_value = getattr(value, item.name) + if item.name == "extension_data": + extension_data = item_value + continue + if item_value is None: + continue + json_name = item.metadata.get("json_name", item.name) + serialized[json_name] = _to_json_value(item_value) + + if extension_data: + for key, item_value in extension_data.items(): + if key in serialized: + raise ValueError(f"Extension data cannot overwrite model property '{key}'.") + serialized[key] = _to_json_value(item_value) + return serialized + + if isinstance(value, Mapping): + return {key: _to_json_value(item_value) for key, item_value in value.items()} + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_to_json_value(item_value) for item_value in value] + + return value diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py new file mode 100644 index 00000000..4a5b4c3f --- /dev/null +++ b/tests/a365/test_tool_call_schema.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +import json + +import pytest + + +def test_execute_tool_arguments_serialize_with_schema_names(): + schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") + + payload = schema.ExecuteToolCallArguments( + action="read", + resources=[ + schema.ToolCallResource( + resource_id="file-1", + uri="https://example/file", + name="report", + resource_type="file", + provider="sharepoint", + identifiers=[schema.ToolCallIdentifier(identifier_type="drive_id", value="d1")], + container=schema.ToolCallContainer( + container_id="site-1", + uri="https://example", + container_type="site", + ), + ) + ], + parameters={"page": 1}, + ) + + assert json.loads(schema.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"}], + "container": {"id": "site-1", "uri": "https://example", "type": "site"}, + } + ], + "parameters": {"page": 1}, + } + + +def test_execute_tool_arguments_omit_none_and_preserve_empty_or_false_values(): + schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") + + payload = schema.ExecuteToolCallArguments( + resources=[], + parameters={ + "include_archived": False, + "page": 0, + "filters": {}, + "tags": [], + }, + extension_data={"provider_options": {}}, + ) + + assert json.loads(schema.serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "resources": [], + "parameters": { + "include_archived": False, + "page": 0, + "filters": {}, + "tags": [], + }, + "provider_options": {}, + } + + +def test_execute_tool_arguments_reject_extension_property_collisions(): + schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") + + with pytest.raises(ValueError, match="action"): + schema.serialize_tool_call_payload( + schema.ExecuteToolCallArguments(action="read", extension_data={"action": "write"}) + ) From 7807da0f35eccf6d9299d3932f0fdd9bde902426 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:18:22 -0600 Subject: [PATCH 02/20] feat: add execute-tool result schema models Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/models/tool_call_schema.py | 83 ++++++++++++++++++- tests/a365/test_tool_call_schema.py | 71 ++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py index 0a2e6920..dc8667bf 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -53,7 +53,88 @@ class ExecuteToolCallArguments: extension_data: dict[str, object] = field(default_factory=dict) -def serialize_tool_call_payload(value: ExecuteToolCallArguments) -> str: +@dataclass +class ToolCallResultOutcome: + """Outcome metadata for an execute-tool result.""" + + status: str | None = None + 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: str | None = None + 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 = field(default="1.0", metadata={"json_name": "schema_version"}) + extension_data: dict[str, object] = field(default_factory=dict) + + +ToolCallPayload = ExecuteToolCallArguments | ExecuteToolCallResult + + +def serialize_tool_call_payload(value: ToolCallPayload) -> str: """Serialize a typed execute-tool payload using the Agent365 JSON contract.""" return json.dumps(_to_json_value(value), ensure_ascii=False) diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index 4a5b4c3f..ebbd42e2 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -82,3 +82,74 @@ def test_execute_tool_arguments_reject_extension_property_collisions(): schema.serialize_tool_call_payload( schema.ExecuteToolCallArguments(action="read", extension_data={"action": "write"}) ) + + +def test_execute_tool_result_serializes_with_schema_names(): + schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") + + payload = schema.ExecuteToolCallResult( + outcome=schema.ToolCallResultOutcome( + status="success", + code="ok", + provider_code="sharepoint_ok", + message="Read completed", + ), + resources=[ + schema.ToolCallResultResource( + resource_id="file-1", + uri="https://example/file", + name="report", + resource_type="file", + provider="sharepoint", + identifiers=[schema.ToolCallIdentifier(identifier_type="drive_id", value="d1")], + container=schema.ToolCallContainer( + container_id="site-1", + uri="https://example", + container_type="site", + ), + outcome=schema.ToolCallResultOutcome(status="success"), + sensitivity=schema.ToolCallResultSensitivity(label_id="confidential"), + policy=schema.ToolCallResultPolicy( + decision="allow", + policy_id="policy-1", + name="Sharing policy", + ), + security=schema.ToolCallResultSecurity(xpia_detected=False), + data={"bytes": 0}, + ) + ], + data={"content": ""}, + pagination=schema.ToolCallResultPagination( + has_more=False, + next_cursor="cursor-2", + total_count=0, + ), + ) + + assert json.loads(schema.serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "outcome": { + "status": "success", + "code": "ok", + "provider_code": "sharepoint_ok", + "message": "Read completed", + }, + "resources": [ + { + "id": "file-1", + "uri": "https://example/file", + "name": "report", + "type": "file", + "provider": "sharepoint", + "identifiers": [{"type": "drive_id", "value": "d1"}], + "container": {"id": "site-1", "uri": "https://example", "type": "site"}, + "outcome": {"status": "success"}, + "sensitivity": {"label_id": "confidential"}, + "policy": {"decision": "allow", "id": "policy-1", "name": "Sharing policy"}, + "security": {"xpia_detected": False}, + "data": {"bytes": 0}, + } + ], + "data": {"content": ""}, + "pagination": {"has_more": False, "next_cursor": "cursor-2", "total_count": 0}, + } From 9ab470f769a5590d78b4fadf64d28a21985fa82f Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:19:08 -0600 Subject: [PATCH 03/20] feat: integrate execute-tool schema payloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../opentelemetry/a365/core/__init__.py | 28 +++++ .../a365/core/execute_tool_scope.py | 19 ++- .../a365/core/tool_call_details.py | 3 +- tests/a365/test_execute_tool_scope.py | 117 ++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/a365/test_execute_tool_scope.py diff --git a/src/microsoft/opentelemetry/a365/core/__init__.py b/src/microsoft/opentelemetry/a365/core/__init__.py index a3e5347f..0ad14536 100644 --- a/src/microsoft/opentelemetry/a365/core/__init__.py +++ b/src/microsoft/opentelemetry/a365/core/__init__.py @@ -46,6 +46,21 @@ UriPart, ) from microsoft.opentelemetry.a365.core.models.response import Response +from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( + ExecuteToolCallArguments, + ExecuteToolCallResult, + ToolCallContainer, + ToolCallIdentifier, + ToolCallPayload, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + ToolCallResultPolicy, + ToolCallResultResource, + ToolCallResultSecurity, + ToolCallResultSensitivity, + 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 @@ -84,6 +99,19 @@ "SpanDetails", "InferenceCallDetails", "ServiceEndpoint", + "ExecuteToolCallArguments", + "ExecuteToolCallResult", + "ToolCallContainer", + "ToolCallIdentifier", + "ToolCallPayload", + "ToolCallResource", + "ToolCallResultOutcome", + "ToolCallResultPagination", + "ToolCallResultPolicy", + "ToolCallResultResource", + "ToolCallResultSecurity", + "ToolCallResultSensitivity", + "serialize_tool_call_payload", # Enums "InferenceOperationType", "ToolType", diff --git a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py index 8313274a..76571777 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -24,6 +24,11 @@ ) 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 ( + 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 @@ -108,7 +113,10 @@ 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 + if isinstance(arguments, ExecuteToolCallArguments): + serialized = serialize_tool_call_payload(arguments) + else: + serialized = safe_json_dumps(arguments) if isinstance(arguments, 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,7 +143,9 @@ 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: """Record the tool call result for telemetry tracking. Per OTEL spec, the result is expected to be an object. If a string @@ -145,5 +155,8 @@ def record_response(self, result: dict[str, object] | str) -> None: # pylint: d Args: result: Tool call result as a structured dict or JSON string """ - serialized = safe_json_dumps(result) if isinstance(result, dict) else result + if isinstance(result, ExecuteToolCallResult): + serialized = serialize_tool_call_payload(result) + else: + serialized = safe_json_dumps(result) if isinstance(result, dict) else result self.set_tag_maybe(GEN_AI_TOOL_CALL_RESULT_KEY, serialized) 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..b115dda4 --- /dev/null +++ b/tests/a365/test_execute_tool_scope.py @@ -0,0 +1,117 @@ +# 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 ( + ExecuteToolCallArguments, + ExecuteToolCallResult, + 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="read", + resources=[ToolCallResource(resource_id="file-1")], + ), + ), + 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["resources"], [{"id": "file-1"}]) + 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="success"), + data={"count": 0}, + ) + ) + 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["outcome"], {"status": "success"}) + 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() From ab7e71d37a4338be8fc917e84485c8011bdb4048 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:24:09 -0600 Subject: [PATCH 04/20] chore: ignore untyped msal in mypy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy.ini b/mypy.ini index 31475234..618402fb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -31,3 +31,6 @@ ignore_missing_imports = true [mypy-wrapt] ignore_missing_imports = true + +[mypy-msal] +ignore_missing_imports = true From 9fa0d408703b309c63bbf41036278f177ec3bfc2 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:24:09 -0600 Subject: [PATCH 05/20] docs: describe execute-tool schema models Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 68 +++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 5 ++++ README.md | 16 ++++++++++ 3 files changed, 89 insertions(+) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 17aa49ac..3cf61941 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -386,6 +386,74 @@ 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, + ToolCallDetails, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + Request, +) + +arguments = ExecuteToolCallArguments( + action="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="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"}, + "provider_operation": "weather.lookup" +} +``` + +Model fields that are `None` are omitted. `False`, zero, empty dictionaries, and empty lists are preserved. +`extension_data` is merged into the same JSON object as the model and cannot overwrite schema-owned keys. + ### InferenceScope ```python diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da53e9e..efd1a598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Release History +# Unreleased +### Features Added +- Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization, + extension data collision checks, public exports, and `ExecuteToolScope` support while preserving raw dict/string payloads. + # 1.3.9 (2026-09-09) ### Features Added - Update OpenTelemetry dependencies to latest versions, bump `langchain-core` minimum version to address S360, and support the new `httpx2` entry point exposed by `opentelemetry-instrumentation-httpx`. diff --git a/README.md b/README.md index edae89b4..64db3afe 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ 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, ToolCallResource + +arguments = ExecuteToolCallArguments( + action="read", + resources=[ToolCallResource(resource_id="file-1", resource_type="file", provider="sharepoint")], + extension_data={"provider_operation": "lookup"}, +) +``` + +Typed arguments and results emit `schema_version: "1.0"` and serialize with schema field names such as +`id`, `type`, `provider_code`, and `has_more`. Raw dictionary and string arguments/results remain supported. +See the A365 guide for complete examples. + --- ## Configuration Reference From a92ed24724d175707a35366df815902e94b5dde0 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:25:39 -0600 Subject: [PATCH 06/20] fix: enforce execute-tool schema serializer invariants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/models/tool_call_schema.py | 20 +++++++++++----- tests/a365/test_tool_call_schema.py | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py index dc8667bf..d12e55b7 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -147,25 +147,33 @@ def _to_json_value(value: Any) -> Any: if is_dataclass(value): serialized: dict[str, object] = {} extension_data: Mapping[str, object] | None = None + json_names: set[str] = set() for item in fields(value): - item_value = getattr(value, item.name) if item.name == "extension_data": - extension_data = item_value + extension_data = getattr(value, item.name) continue + json_name = item.metadata.get("json_name", item.name) + json_names.add(json_name) + item_value = getattr(value, item.name) if item_value is None: continue - json_name = item.metadata.get("json_name", item.name) serialized[json_name] = _to_json_value(item_value) if extension_data: for key, item_value in extension_data.items(): - if key in serialized: + if key in json_names: raise ValueError(f"Extension data cannot overwrite model property '{key}'.") - serialized[key] = _to_json_value(item_value) + json_value = _to_json_value(item_value) + if json_value is not None: + serialized[key] = json_value return serialized if isinstance(value, Mapping): - return {key: _to_json_value(item_value) for key, item_value in value.items()} + return { + key: json_value + for key, item_value in value.items() + if (json_value := _to_json_value(item_value)) is not None + } if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return [_to_json_value(item_value) for item_value in value] diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index ebbd42e2..ebb21eb8 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -83,6 +83,30 @@ def test_execute_tool_arguments_reject_extension_property_collisions(): schema.ExecuteToolCallArguments(action="read", extension_data={"action": "write"}) ) + with pytest.raises(ValueError, match="action"): + schema.serialize_tool_call_payload(schema.ExecuteToolCallArguments(extension_data={"action": "write"})) + + +def test_execute_tool_payload_omits_none_values_recursively(): + schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") + + payload = schema.ExecuteToolCallResult( + outcome=schema.ToolCallResultOutcome( + status="success", + provider_code=None, + extension_data={"provider_outcome": None, "attempts": 0}, + ), + data={"content": None, "matches": []}, + extension_data={"provider_result": None, "cached": False}, + ) + + assert json.loads(schema.serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "outcome": {"status": "success", "attempts": 0}, + "data": {"matches": []}, + "cached": False, + } + def test_execute_tool_result_serializes_with_schema_names(): schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") From 6a3b4e47ef33a5ff7d28d2e2edc59c845952e746 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:51:16 -0600 Subject: [PATCH 07/20] chore: drop unrelated mypy msal ignore The tox mypy commands already pass --ignore-missing-imports, so the msal section was unnecessary and unrelated to the execute-tool schema feature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mypy.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/mypy.ini b/mypy.ini index 618402fb..31475234 100644 --- a/mypy.ini +++ b/mypy.ini @@ -31,6 +31,3 @@ ignore_missing_imports = true [mypy-wrapt] ignore_missing_imports = true - -[mypy-msal] -ignore_missing_imports = true From 125d04a9c67508a7e08e11ef8a69ddef25de5972 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:51:42 -0600 Subject: [PATCH 08/20] fix: harden execute-tool typed payload serialization Serialization of typed execute-tool payloads is now non-throwing and matches the .NET contract (PR microsoft/opentelemetry-distro-dotnet#149). Any per-value failure, reference cycle, unsupported type, non-finite float (allow_nan=False), undefined enum token, or extension-data collision replaces the payload with the diagnostic object {"serialization_error":"Failed to serialize execute tool payload."} instead of leaking an exception or orphaning the span. Disabled telemetry paths are safe for the same reason. None is now omitted only for model properties; None inside caller-supplied mappings and lists is preserved as JSON null. Bytes serialize as base64, datetime/date/time as ISO-8601, UUID and enums as their string values, and Decimal as a JSON number. Adds public ToolCallAction, ToolCallOutcomeStatus, and ToolPolicyDecision str enums matching .NET. Typed enum fields accept enum members and the exact lowercase tokens for backward compatibility, rejecting undefined, mis-cased, numeric, boolean, and foreign-enum values. Extension-data collisions are checked only against properties that are actually serialized, so extension data may supply a key whose model property is None while still being rejected when it would overwrite a present property. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../opentelemetry/a365/core/__init__.py | 6 + .../a365/core/execute_tool_scope.py | 10 +- .../a365/core/models/tool_call_schema.py | 270 ++++++++++-- tests/a365/test_execute_tool_scope.py | 97 +++- tests/a365/test_tool_call_schema.py | 417 ++++++++++++++---- 5 files changed, 663 insertions(+), 137 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/__init__.py b/src/microsoft/opentelemetry/a365/core/__init__.py index 0ad14536..ccffab41 100644 --- a/src/microsoft/opentelemetry/a365/core/__init__.py +++ b/src/microsoft/opentelemetry/a365/core/__init__.py @@ -49,8 +49,10 @@ from microsoft.opentelemetry.a365.core.models.tool_call_schema import ( ExecuteToolCallArguments, ExecuteToolCallResult, + ToolCallAction, ToolCallContainer, ToolCallIdentifier, + ToolCallOutcomeStatus, ToolCallPayload, ToolCallResource, ToolCallResultOutcome, @@ -59,6 +61,7 @@ ToolCallResultResource, ToolCallResultSecurity, ToolCallResultSensitivity, + ToolPolicyDecision, serialize_tool_call_payload, ) from microsoft.opentelemetry.a365.core.models.user_details import UserDetails @@ -115,6 +118,9 @@ # 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 76571777..069e4114 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -144,16 +144,20 @@ def __init__( ) def record_response( # pylint: disable=arguments-renamed - self, result: ExecuteToolCallResult | dict[str, object] | str + 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 """ if isinstance(result, ExecuteToolCallResult): serialized = serialize_tool_call_payload(result) diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py index d12e55b7..b1b90ebb 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -1,19 +1,77 @@ # 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 -from collections.abc import Mapping, Sequence +import logging +import uuid +from collections.abc import Callable, Mapping, Sequence 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" + + +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"}) + identifier_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) value: str | None = None extension_data: dict[str, object] = field(default_factory=dict) @@ -22,9 +80,9 @@ class ToolCallIdentifier: class ToolCallContainer: """Container that owns or scopes an execute-tool resource.""" - container_id: str | None = field(default=None, metadata={"json_name": "id"}) + 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"}) + container_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) extension_data: dict[str, object] = field(default_factory=dict) @@ -32,10 +90,10 @@ class ToolCallContainer: class ToolCallResource: """Resource referenced by an execute-tool arguments payload.""" - resource_id: str | None = field(default=None, metadata={"json_name": "id"}) + 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"}) + resource_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) provider: str | None = None identifiers: list[ToolCallIdentifier] | None = None container: ToolCallContainer | None = None @@ -46,10 +104,10 @@ class ToolCallResource: class ExecuteToolCallArguments: """Structured arguments for an execute-tool call.""" - action: str | None = None + 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 = field(default="1.0", metadata={"json_name": "schema_version"}) + schema_version: str = TOOL_CALL_SCHEMA_VERSION extension_data: dict[str, object] = field(default_factory=dict) @@ -57,7 +115,7 @@ class ExecuteToolCallArguments: class ToolCallResultOutcome: """Outcome metadata for an execute-tool result.""" - status: str | None = None + status: ToolCallOutcomeStatus | str | None = field(default=None, metadata={_ENUM_TYPE: ToolCallOutcomeStatus}) code: str | None = None provider_code: str | None = None message: str | None = None @@ -76,8 +134,8 @@ class ToolCallResultSensitivity: class ToolCallResultPolicy: """Policy metadata for an execute-tool result resource.""" - decision: str | None = None - policy_id: str | None = field(default=None, metadata={"json_name": "id"}) + 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) @@ -104,10 +162,10 @@ class ToolCallResultPagination: class ToolCallResultResource: """Resource included in an execute-tool result payload.""" - resource_id: str | None = field(default=None, metadata={"json_name": "id"}) + 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"}) + resource_type: str | None = field(default=None, metadata={_JSON_NAME: "type"}) provider: str | None = None identifiers: list[ToolCallIdentifier] | None = None container: ToolCallContainer | None = None @@ -127,55 +185,171 @@ class ExecuteToolCallResult: resources: list[ToolCallResultResource] | None = None data: dict[str, object] | None = None pagination: ToolCallResultPagination | None = None - schema_version: str = field(default="1.0", metadata={"json_name": "schema_version"}) + schema_version: str = TOOL_CALL_SCHEMA_VERSION extension_data: dict[str, object] = field(default_factory=dict) ToolCallPayload = ExecuteToolCallArguments | ExecuteToolCallResult -def serialize_tool_call_payload(value: ToolCallPayload) -> str: - """Serialize a typed execute-tool payload using the Agent365 JSON contract.""" +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") + - return json.dumps(_to_json_value(value), ensure_ascii=False) +def _to_isoformat(value: datetime.datetime | datetime.date | datetime.time) -> str: + """Return the ISO-8601 text for a date/time value.""" + return value.isoformat() -def _to_json_value(value: Any) -> Any: +# Ordered scalar conversions; ``bool`` must precede ``int`` and ``str`` must precede ``Sequence``. +_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, float), +) + + +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 extension data colliding with a serialized model property — 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 - if is_dataclass(value): - serialized: dict[str, object] = {} - extension_data: Mapping[str, object] | None = None - json_names: set[str] = set() + try: + return json.dumps(_to_json_value(value, set()), ensure_ascii=False, allow_nan=False) + 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, Sequence): + return _sequence_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 merging extension data.""" + marker = _enter(value, stack) + try: + serialized: dict[str, Any] = {} + extension_data: Any = {} for item in fields(value): - if item.name == "extension_data": - extension_data = getattr(value, item.name) - continue - json_name = item.metadata.get("json_name", item.name) - json_names.add(json_name) 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 - serialized[json_name] = _to_json_value(item_value) - - if extension_data: - for key, item_value in extension_data.items(): - if key in json_names: - raise ValueError(f"Extension data cannot overwrite model property '{key}'.") - json_value = _to_json_value(item_value) - if json_value is not None: - serialized[key] = json_value + 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__}.") + + for key, item_value in extension_data.items(): + json_key = _json_object_key(key) + if json_key in serialized: + raise ValueError(f"Extension data cannot overwrite execute tool payload property '{json_key}'.") + serialized[json_key] = _to_json_value(item_value, stack) return serialized - - if isinstance(value, Mapping): - return { - key: json_value - for key, item_value in value.items() - if (json_value := _to_json_value(item_value)) is not None - } - - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [_to_json_value(item_value) for item_value in value] - - return value + 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 _sequence_to_json_value(value: Sequence[Any], stack: set[int]) -> list[Any]: + """Serialize a caller-supplied sequence, preserving ``None`` items as JSON ``null``.""" + 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/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py index b115dda4..ba9df0db 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -16,8 +16,11 @@ ) 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, ) @@ -46,7 +49,7 @@ def test_typed_arguments_include_schema_version(self): ToolCallDetails( tool_name="read_file", arguments=ExecuteToolCallArguments( - action="read", + action=ToolCallAction.READ, resources=[ToolCallResource(resource_id="file-1")], ), ), @@ -71,7 +74,7 @@ def test_typed_result_include_schema_version(self): try: scope.record_response( ExecuteToolCallResult( - outcome=ToolCallResultOutcome(status="success"), + outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), data={"count": 0}, ) ) @@ -115,3 +118,93 @@ def test_raw_argument_and_result_strings_are_unchanged(self): 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) + + +@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 + + 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() + + self.assertIsNone(scope._span) diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index ebb21eb8..6d47a916 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -1,26 +1,88 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import importlib +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_execute_tool_arguments_serialize_with_schema_names(): - schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") - payload = schema.ExecuteToolCallArguments( - action="read", +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=[ - schema.ToolCallResource( + ToolCallResource( resource_id="file-1", uri="https://example/file", name="report", resource_type="file", provider="sharepoint", - identifiers=[schema.ToolCallIdentifier(identifier_type="drive_id", value="d1")], - container=schema.ToolCallContainer( + identifiers=[ToolCallIdentifier(identifier_type="drive_id", value="d1")], + container=ToolCallContainer( container_id="site-1", uri="https://example", container_type="site", @@ -30,7 +92,7 @@ def test_execute_tool_arguments_serialize_with_schema_names(): parameters={"page": 1}, ) - assert json.loads(schema.serialize_tool_call_payload(payload)) == { + assert json.loads(serialize_tool_call_payload(payload)) == { "schema_version": "1.0", "action": "read", "resources": [ @@ -48,109 +110,47 @@ def test_execute_tool_arguments_serialize_with_schema_names(): } -def test_execute_tool_arguments_omit_none_and_preserve_empty_or_false_values(): - schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") - - payload = schema.ExecuteToolCallArguments( - resources=[], - parameters={ - "include_archived": False, - "page": 0, - "filters": {}, - "tags": [], - }, - extension_data={"provider_options": {}}, - ) - - assert json.loads(schema.serialize_tool_call_payload(payload)) == { - "schema_version": "1.0", - "resources": [], - "parameters": { - "include_archived": False, - "page": 0, - "filters": {}, - "tags": [], - }, - "provider_options": {}, - } - - -def test_execute_tool_arguments_reject_extension_property_collisions(): - schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") - - with pytest.raises(ValueError, match="action"): - schema.serialize_tool_call_payload( - schema.ExecuteToolCallArguments(action="read", extension_data={"action": "write"}) - ) - - with pytest.raises(ValueError, match="action"): - schema.serialize_tool_call_payload(schema.ExecuteToolCallArguments(extension_data={"action": "write"})) - - -def test_execute_tool_payload_omits_none_values_recursively(): - schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") - - payload = schema.ExecuteToolCallResult( - outcome=schema.ToolCallResultOutcome( - status="success", - provider_code=None, - extension_data={"provider_outcome": None, "attempts": 0}, - ), - data={"content": None, "matches": []}, - extension_data={"provider_result": None, "cached": False}, - ) - - assert json.loads(schema.serialize_tool_call_payload(payload)) == { - "schema_version": "1.0", - "outcome": {"status": "success", "attempts": 0}, - "data": {"matches": []}, - "cached": False, - } - - def test_execute_tool_result_serializes_with_schema_names(): - schema = importlib.import_module("microsoft.opentelemetry.a365.core.models.tool_call_schema") - - payload = schema.ExecuteToolCallResult( - outcome=schema.ToolCallResultOutcome( - status="success", + payload = ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, code="ok", provider_code="sharepoint_ok", message="Read completed", ), resources=[ - schema.ToolCallResultResource( + ToolCallResultResource( resource_id="file-1", uri="https://example/file", name="report", resource_type="file", provider="sharepoint", - identifiers=[schema.ToolCallIdentifier(identifier_type="drive_id", value="d1")], - container=schema.ToolCallContainer( + identifiers=[ToolCallIdentifier(identifier_type="drive_id", value="d1")], + container=ToolCallContainer( container_id="site-1", uri="https://example", container_type="site", ), - outcome=schema.ToolCallResultOutcome(status="success"), - sensitivity=schema.ToolCallResultSensitivity(label_id="confidential"), - policy=schema.ToolCallResultPolicy( - decision="allow", + outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), + sensitivity=ToolCallResultSensitivity(label_id="confidential"), + policy=ToolCallResultPolicy( + decision=ToolPolicyDecision.ALLOW, policy_id="policy-1", name="Sharing policy", ), - security=schema.ToolCallResultSecurity(xpia_detected=False), + security=ToolCallResultSecurity(xpia_detected=False), data={"bytes": 0}, ) ], data={"content": ""}, - pagination=schema.ToolCallResultPagination( + pagination=ToolCallResultPagination( has_more=False, next_cursor="cursor-2", total_count=0, ), ) - assert json.loads(schema.serialize_tool_call_payload(payload)) == { + assert json.loads(serialize_tool_call_payload(payload)) == { "schema_version": "1.0", "outcome": { "status": "success", @@ -177,3 +177,252 @@ def test_execute_tool_result_serializes_with_schema_names(): "data": {"content": ""}, "pagination": {"has_more": False, "next_cursor": "cursor-2", "total_count": 0}, } + + +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": [], + }, + "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", "provider_outcome": None, "attempts": 0}, + "data": {"content": None, "matches": [None, 1]}, + "provider_result": None, + "cached": False, + } + + +def test_extension_data_may_supply_a_key_whose_model_property_is_none(): + payload = ExecuteToolCallArguments(extension_data={"action": "write"}) + + assert json.loads(serialize_tool_call_payload(payload)) == { + "schema_version": "1.0", + "action": "write", + } + + +def test_extension_data_cannot_overwrite_a_serialized_model_property(): + payload = ExecuteToolCallArguments(action=ToolCallAction.READ, extension_data={"action": "write"}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_extension_data_cannot_overwrite_schema_version(): + payload = ExecuteToolCallResult(extension_data={"schema_version": "9.9"}) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +def test_extension_data_collision_is_detected_on_nested_models(): + payload = ExecuteToolCallResult( + outcome=ToolCallResultOutcome(code="ok", extension_data={"code": "overwrite"}), + ) + + assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + + +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:05+00:00", + "day": "2026-01-02", + "time": "03:04:05", + "id": "12345678-1234-5678-1234-567812345678", + "amount": 1.5, + } + + +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]} + + +@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) From 73f9703a1a921e25bf7b9a0b801207da14cc2221 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 16:51:52 -0600 Subject: [PATCH 09/20] docs: document execute-tool schema enums, null handling, and failure modes Covers the ToolCallAction, ToolCallOutcomeStatus, and ToolPolicyDecision tokens, the accepted exact lowercase strings, null retention inside caller mappings and lists, extension-data collision rules, and the non-throwing diagnostic payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 44 +++++++++++++++++++++++++++++++++++++++---- CHANGELOG.md | 5 ++++- README.md | 9 +++++---- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 3cf61941..ac52fb00 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -395,7 +395,9 @@ from microsoft.opentelemetry.a365.core import ( ExecuteToolCallArguments, ExecuteToolCallResult, ExecuteToolScope, + ToolCallAction, ToolCallDetails, + ToolCallOutcomeStatus, ToolCallResource, ToolCallResultOutcome, ToolCallResultPagination, @@ -403,7 +405,7 @@ from microsoft.opentelemetry.a365.core import ( ) arguments = ExecuteToolCallArguments( - action="read", + action=ToolCallAction.READ, resources=[ ToolCallResource( resource_id="file-1", @@ -424,7 +426,7 @@ with ExecuteToolScope.start( ) as scope: scope.record_response( ExecuteToolCallResult( - outcome=ToolCallResultOutcome(status="success"), + outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), data={"temperature_f": 68}, pagination=ToolCallResultPagination(has_more=False, total_count=1), ) @@ -451,8 +453,42 @@ The typed arguments above generate a JSON payload shaped like: } ``` -Model fields that are `None` are omitted. `False`, zero, empty dictionaries, and empty lists are preserved. -`extension_data` is merged into the same JSON object as the model and cannot overwrite schema-owned keys. +#### 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 merged into the same JSON object as the model it belongs to. It may supply a key whose +model property was left `None`, but it cannot overwrite a property that is actually serialized. + +#### 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`, an undefined enum token, or an +extension-data collision — 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, `UUID` +as its canonical string, `Decimal` as a JSON number, and any `Enum` as its value. ### InferenceScope diff --git a/CHANGELOG.md b/CHANGELOG.md index efd1a598..c6414e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ # Unreleased ### Features Added - Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization, - extension data collision checks, public exports, and `ExecuteToolScope` support while preserving raw dict/string payloads. + `ToolCallAction`/`ToolCallOutcomeStatus`/`ToolPolicyDecision` enums, extension data collision checks, public + exports, and `ExecuteToolScope` support while preserving raw dict/string payloads. Typed payload serialization + is non-throwing: unserializable payloads record + `{"serialization_error": "Failed to serialize execute tool payload."}` instead of failing the span. # 1.3.9 (2026-09-09) ### Features Added diff --git a/README.md b/README.md index 64db3afe..b551ba8e 100644 --- a/README.md +++ b/README.md @@ -75,18 +75,19 @@ See the [A365 guide](https://github.com/microsoft/opentelemetry-distro-python/bl Agent365 manual execute-tool spans support typed JSON schema payloads: ```python -from microsoft.opentelemetry.a365.core import ExecuteToolCallArguments, ToolCallResource +from microsoft.opentelemetry.a365.core import ExecuteToolCallArguments, ToolCallAction, ToolCallResource arguments = ExecuteToolCallArguments( - action="read", + action=ToolCallAction.READ, resources=[ToolCallResource(resource_id="file-1", resource_type="file", provider="sharepoint")], extension_data={"provider_operation": "lookup"}, ) ``` Typed arguments and results emit `schema_version: "1.0"` and serialize with schema field names such as -`id`, `type`, `provider_code`, and `has_more`. Raw dictionary and string arguments/results remain supported. -See the A365 guide for complete examples. +`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. --- From 32cb336f400c282cff1842149b772f9916b30543 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 17:06:08 -0600 Subject: [PATCH 10/20] fix: make raw execute-tool dict payloads non-throwing and accept collections Raw dictionary arguments and results now share the diagnostic fallback with the typed models, matching .NET PR microsoft/opentelemetry-distro-dotnet#149 where the dictionary overloads also route through MessageUtils.SerializeToolPayload. Previously a cyclic or otherwise unserializable dictionary raised out of ExecuteToolScope.start(), leaving the span started but never ended. The existing safe_json_dumps output is unchanged for payloads that already serialized. Typed payload serialization now emits sets, frozensets, and other sized collections as JSON arrays instead of discarding the whole payload, matching how .NET writes any IEnumerable value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 4 +- CHANGELOG.md | 4 +- .../a365/core/execute_tool_scope.py | 38 +++++++++++++++---- .../a365/core/models/tool_call_schema.py | 12 +++--- tests/a365/test_execute_tool_scope.py | 30 +++++++++++++++ tests/a365/test_tool_call_schema.py | 24 ++++++++++++ 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index ac52fb00..87e202c6 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -488,7 +488,9 @@ logged: ``` `bytes` and `bytearray` are emitted as base64 strings, `datetime`/`date`/`time` as ISO-8601 strings, `UUID` -as its canonical string, `Decimal` as a JSON number, and any `Enum` as its value. +as its canonical string, `Decimal` as a JSON number, any `Enum` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c6414e81..d42d49c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ### Features Added - Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization, `ToolCallAction`/`ToolCallOutcomeStatus`/`ToolPolicyDecision` enums, extension data collision checks, public - exports, and `ExecuteToolScope` support while preserving raw dict/string payloads. Typed payload serialization - is non-throwing: unserializable payloads record + 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. # 1.3.9 (2026-09-09) diff --git a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py index 069e4114..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 @@ -25,6 +27,7 @@ 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, @@ -34,6 +37,26 @@ 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.""" @@ -113,10 +136,11 @@ def __init__( self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, tool_name) if arguments is not None: - if isinstance(arguments, ExecuteToolCallArguments): - serialized = serialize_tool_call_payload(arguments) - else: - 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) @@ -159,8 +183,8 @@ def record_response( # pylint: disable=arguments-renamed result: Tool call result as a typed schema model, a structured dict, a JSON string, or None to omit the attribute """ - if isinstance(result, ExecuteToolCallResult): - serialized = serialize_tool_call_payload(result) + if isinstance(result, (ExecuteToolCallResult, dict)): + serialized: str | None = _serialize_tool_payload(result) else: - serialized = safe_json_dumps(result) if isinstance(result, dict) else result + 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 index b1b90ebb..2151f524 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -16,7 +16,7 @@ import json import logging import uuid -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping from dataclasses import dataclass, field, fields, is_dataclass from decimal import Decimal from enum import Enum @@ -202,7 +202,7 @@ def _to_isoformat(value: datetime.datetime | datetime.date | datetime.time) -> s return value.isoformat() -# Ordered scalar conversions; ``bool`` must precede ``int`` and ``str`` must precede ``Sequence``. +# 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), @@ -253,8 +253,8 @@ def _to_json_value(value: Any, stack: set[int]) -> Any: return _dataclass_to_json_value(value, stack) if isinstance(value, Mapping): return _mapping_to_json_value(value, stack) - if isinstance(value, Sequence): - return _sequence_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.") @@ -309,8 +309,8 @@ def _mapping_to_json_value(value: Mapping[Any, Any], stack: set[int]) -> dict[st stack.discard(marker) -def _sequence_to_json_value(value: Sequence[Any], stack: set[int]) -> list[Any]: - """Serialize a caller-supplied sequence, preserving ``None`` items as JSON ``null``.""" +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] diff --git a/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py index ba9df0db..d5dcabc1 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -183,6 +183,25 @@ def test_unserializable_payloads_do_not_orphan_the_span(self): 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): @@ -195,6 +214,8 @@ def tearDown(self): 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(), @@ -207,4 +228,13 @@ def test_unserializable_typed_payloads_are_safe_when_telemetry_is_disabled(self) 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 index 6d47a916..dbf289fa 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -371,6 +371,30 @@ def test_tuples_serialize_as_json_arrays(): 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"), [ From fc712eaa20bf5c379a858a8bc8e44d1adbedd7d3 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 18:06:44 -0600 Subject: [PATCH 11/20] docs: add tool call schema module entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/microsoft/opentelemetry/a365/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/microsoft/opentelemetry/a365/README.md b/src/microsoft/opentelemetry/a365/README.md index 16cdae73..2e156c95 100644 --- a/src/microsoft/opentelemetry/a365/README.md +++ b/src/microsoft/opentelemetry/a365/README.md @@ -63,6 +63,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). | From 251e330e183b1f87f1f3c5058bcbdff144280df8 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 23 Sep 2026 15:33:10 -0600 Subject: [PATCH 12/20] docs: design execute-tool metadata wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...23-execute-tool-metadata-wrapper-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md diff --git a/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md b/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md new file mode 100644 index 00000000..4ee94bf0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md @@ -0,0 +1,198 @@ +# Execute-Tool Metadata Wrapper Design + +## Summary + +Execute-tool schema models currently flatten provider-specific extension entries into +the containing JSON object. This permits extension keys to collide with declared +schema properties such as `action`, bypassing typed validation when the declared +property is omitted. + +Provider-specific values will instead serialize under a dedicated `metadata` JSON +property. The change applies to every currently extensible execute-tool object. +Existing language-specific public property names remain unchanged to preserve source +compatibility. + +## Goals + +- Prevent provider extensions from overwriting or bypassing declared schema fields. +- Give provider-specific values an explicit namespace in the wire contract. +- Preserve existing Python, .NET, and JavaScript public property names. +- Use the same `metadata` JSON representation in every language implementation. +- Avoid adding empty `metadata` objects to existing payloads. + +## Non-Goals + +- Renaming existing public extension properties. +- Adding a setter or builder API for individual metadata entries. +- Defining provider-specific metadata keys or values. +- Supporting both flattened and wrapped extension output simultaneously. + +## Public API + +Public model signatures remain unchanged. + +Python continues to expose: + +```python +extension_data: dict[str, object] = field(default_factory=dict) +``` + +.NET continues to expose: + +```csharp +public IDictionary AdditionalProperties { get; set; } +``` + +JavaScript retains its existing extension-data property name. + +Callers continue assigning provider-specific values through those properties. No +additional setter method is required because the existing dictionaries support +constructor assignment and later mutation. + +## Wire Contract + +A non-empty extension dictionary serializes as the declared JSON property +`metadata`. The entries are not flattened into the containing object. + +For example, this Python input: + +```python +ExecuteToolCallArguments( + action=ToolCallAction.READ, + parameters={}, + extension_data={ + "provider_trace_id": "trace-789", + "tenant_id": "tenant-123", + }, +) +``` + +serializes as: + +```json +{ + "schema_version": "1.0", + "action": "read", + "parameters": {}, + "metadata": { + "provider_trace_id": "trace-789", + "tenant_id": "tenant-123" + } +} +``` + +When the extension dictionary is empty, `metadata` is omitted: + +```json +{ + "schema_version": "1.0", + "action": "read", + "parameters": {} +} +``` + +## Model Scope + +The wrapper applies independently at every currently extensible object: + +- `ExecuteToolCallArguments` +- `ToolCallResource` +- `ToolCallIdentifier` +- `ToolCallContainer` +- `ExecuteToolCallResult` +- `ToolCallResultOutcome` +- `ToolCallResultSensitivity` +- `ToolCallResultPolicy` +- `ToolCallResultSecurity` +- `ToolCallResultPagination` +- `ToolCallResultResource` + +Each object's extension dictionary becomes that object's `metadata` property. For +example: + +```json +{ + "resources": [ + { + "id": "resource-id", + "metadata": { + "tenant_id": "tenant-123" + }, + "container": { + "id": "container-id", + "metadata": { + "site_collection_id": "site-456" + } + } + } + ], + "metadata": { + "provider_trace_id": "trace-789" + } +} +``` + +## Serialization Behavior + +The Python serializer will treat `extension_data` as a normal declared model field +whose JSON name is `metadata`. It will recursively serialize the dictionary using the +existing non-throwing diagnostic behavior. + +The .NET implementation will stop using `[JsonExtensionData]` for +`AdditionalProperties` and map that property to `metadata` instead. JavaScript will +perform the equivalent explicit property mapping. + +An extension dictionary entry named `metadata` is valid because it remains nested: + +```json +{ + "metadata": { + "metadata": "provider-defined value" + } +} +``` + +It cannot overwrite the outer declared `metadata` property. + +## Compatibility and Rollout + +Public source APIs remain compatible, but the serialized wire format changes from +flattened properties to a `metadata` wrapper. Python, .NET, JavaScript, the published +schema, and downstream parsers must adopt the new representation as one coordinated +contract change. + +Consumers must not depend on provider-specific properties appearing at the same level +as declared schema fields. During rollout, producers and consumers should be released +in an order that prevents new wrapped payloads from reaching parsers that reject the +`metadata` property. + +## Error Handling + +The existing serializer contract remains in effect: + +- Invalid extension containers produce the repository-standard diagnostic payload. +- A failure in a metadata value does not throw from telemetry instrumentation. +- Cycles and unsupported values follow existing safe-serialization behavior. +- Declared enum fields continue to use typed validation and cannot be supplied through + metadata as top-level replacements. + +## Testing + +Tests must verify: + +- Non-empty extension dictionaries serialize under `metadata`. +- Empty extension dictionaries omit `metadata`. +- Metadata is wrapped at the root and at every nested extensible model. +- Keys matching declared schema fields remain inside `metadata` and cannot replace the + declared fields. +- Existing safe serialization behavior applies recursively to metadata values. +- Raw dictionary and string payload support remains unchanged. +- Scope and ETW integrations emit the same wrapped representation. +- Documentation examples match the new contract. + +## Documentation + +Update the execute-tool schema documentation and usage examples to describe +provider-specific metadata as a nested `metadata` object. The documentation must note +that language SDK property names may differ while the JSON contract consistently uses +`metadata`. From 7a1e66c9ed4967dcb70675fc9f52b6db0333b068 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 23 Sep 2026 15:35:41 -0600 Subject: [PATCH 13/20] docs: plan execute-tool metadata wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...026-09-23-execute-tool-metadata-wrapper.md | 550 ++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md diff --git a/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md b/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md new file mode 100644 index 00000000..3e2a8c8a --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md @@ -0,0 +1,550 @@ +# Execute-Tool Metadata Wrapper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Serialize every execute-tool model's existing `extension_data` dictionary under a non-empty `metadata` JSON wrapper instead of flattening its entries. + +**Architecture:** Preserve all Python public dataclass signatures and change only the shared dataclass serializer's wire mapping. Unit tests establish wrapping, omission, nesting, collision isolation, and safe-serialization behavior; scope integration tests verify emitted telemetry attributes; documentation defines the coordinated cross-language contract. + +**Tech Stack:** Python 3.10+, dataclasses, `json`, pytest, unittest, OpenTelemetry span attributes. + +## Global Constraints + +- Keep `extension_data: dict[str, object] = field(default_factory=dict)` unchanged on every Python model. +- Serialize non-empty extension dictionaries under the JSON key `metadata`. +- Omit `metadata` when the extension dictionary is empty or `None`. +- Apply the wrapper independently to every currently extensible execute-tool model. +- Do not emit flattened extension keys. +- Preserve existing non-throwing diagnostic serialization behavior. +- Preserve raw dictionary and string argument/result behavior. +- Coordinate the `metadata` wire format with .NET, JavaScript, the published schema, and downstream parsers. + +--- + +### Task 1: Wrap Schema Model Extension Data + +**Files:** +- Modify: `tests/a365/test_tool_call_schema.py:70-285` +- Modify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py:25-30` +- Modify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py:269-298` + +**Interfaces:** +- Consumes: Existing model field `extension_data: dict[str, object]`. +- Produces: `serialize_tool_call_payload(payload: ToolCallPayload | None) -> str | None` output containing `metadata` for non-empty extension dictionaries. + +- [ ] **Step 1: Update the argument serialization test to require nested metadata** + +In `test_execute_tool_arguments_serialize_with_schema_names`, add extension data at the arguments, resource, identifier, and container levels: + +```python +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"}, +) +``` + +Require the corresponding portions of the expected dictionary to be: + +```python +{ + "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"}, +} +``` + +- [ ] **Step 2: Update the result serialization test to cover every result-side extensible model** + +Add extension data to the existing result tree: + +```python +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", + identifiers=[ + ToolCallIdentifier( + identifier_type="drive_id", + value="d1", + extension_data={"identifier_scope": "tenant"}, + ) + ], + container=ToolCallContainer( + container_id="site-1", + 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", + extension_data={"policy_version": "2"}, + ), + security=ToolCallResultSecurity( + xpia_detected=False, + extension_data={"scanner": "provider"}, + ), + extension_data={"resource_region": "westus"}, + ) + ], + pagination=ToolCallResultPagination( + has_more=False, + extension_data={"page_source": "cache"}, + ), + extension_data={"provider_trace_id": "trace-789"}, +) +``` + +Assert each extension dictionary appears under `metadata` at its owning object: + +```python +serialized = json.loads(serialize_tool_call_payload(payload)) + +assert serialized["metadata"] == {"provider_trace_id": "trace-789"} +assert serialized["outcome"]["metadata"] == {"outcome_detail": "accepted"} +resource = serialized["resources"][0] +assert resource["metadata"] == {"resource_region": "westus"} +assert resource["identifiers"][0]["metadata"] == {"identifier_scope": "tenant"} +assert resource["container"]["metadata"] == {"site_collection_id": "site-456"} +assert resource["outcome"]["metadata"] == {"resource_outcome": "accepted"} +assert resource["sensitivity"]["metadata"] == {"label_source": "provider"} +assert resource["policy"]["metadata"] == {"policy_version": "2"} +assert resource["security"]["metadata"] == {"scanner": "provider"} +assert serialized["pagination"]["metadata"] == {"page_source": "cache"} +``` + +- [ ] **Step 3: Replace collision tests with isolation and omission tests** + +Replace the four tests beginning with +`test_extension_data_may_supply_a_key_whose_model_property_is_none` through +`test_extension_data_collision_is_detected_on_nested_models` with: + +```python +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" + } +``` + +Update the existing preservation tests so their expected output contains: + +```python +"metadata": {"provider_options": {}} +``` + +and: + +```python +"outcome": { + "status": "success", + "metadata": {"provider_outcome": None, "attempts": 0}, +}, +"metadata": {"provider_result": None, "cached": False}, +``` + +- [ ] **Step 4: Run the focused tests and verify they fail** + +Run: + +```powershell +python -m pytest tests\a365\test_tool_call_schema.py -q +``` + +Expected: failures show extension keys such as `provider_trace_id`, `action`, and `provider_options` are still flattened and no `metadata` object is emitted. + +- [ ] **Step 5: Add the metadata JSON-name constant** + +Near `_EXTENSION_DATA_FIELD`, add: + +```python +_EXTENSION_DATA_JSON_NAME = "metadata" +``` + +- [ ] **Step 6: Replace extension-data merging with wrapped serialization** + +Replace `_dataclass_to_json_value` with: + +```python +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) +``` + +- [ ] **Step 7: Run the schema tests and verify they pass** + +Run: + +```powershell +python -m pytest tests\a365\test_tool_call_schema.py -q +``` + +Expected: all tests pass, including unsupported-value, throwing-mapping, cycle, enum, collection, and scalar conversion cases. + +- [ ] **Step 8: Commit the serializer and unit tests** + +```powershell +git add src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py +git commit -m "fix: wrap execute-tool extension data" +``` + +--- + +### Task 2: Verify Telemetry Integration + +**Files:** +- Modify: `tests/a365/test_execute_tool_scope.py:39-118` + +**Interfaces:** +- Consumes: `serialize_tool_call_payload()` metadata-wrapper output from Task 1. +- Produces: Span attributes `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result` containing the same wrapped JSON. + +- [ ] **Step 1: Add extension data to the typed arguments scope test** + +Update `test_typed_arguments_include_schema_version`: + +```python +arguments=ExecuteToolCallArguments( + action=ToolCallAction.READ, + resources=[ + ToolCallResource( + resource_id="file-1", + extension_data={"tenant_id": "tenant-123"}, + ) + ], + extension_data={"provider_trace_id": "trace-789"}, +), +``` + +Add these assertions: + +```python +self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) +self.assertEqual( + payload["resources"], + [{"id": "file-1", "metadata": {"tenant_id": "tenant-123"}}], +) +``` + +- [ ] **Step 2: Add extension data to the typed result scope test** + +Update `test_typed_result_include_schema_version`: + +```python +scope.record_response( + ExecuteToolCallResult( + outcome=ToolCallResultOutcome( + status=ToolCallOutcomeStatus.SUCCESS, + extension_data={"provider_status": "accepted"}, + ), + data={"count": 0}, + extension_data={"provider_trace_id": "trace-789"}, + ) +) +``` + +Add these assertions: + +```python +self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) +self.assertEqual( + payload["outcome"], + { + "status": "success", + "metadata": {"provider_status": "accepted"}, + }, +) +``` + +- [ ] **Step 3: Run the scope tests** + +Run: + +```powershell +python -m pytest tests\a365\test_execute_tool_scope.py -q +``` + +Expected: all scope tests pass; raw dictionary/string payload tests remain unchanged. + +- [ ] **Step 4: Run the combined targeted suite** + +Run: + +```powershell +python -m pytest tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -q +``` + +Expected: all targeted schema and integration tests pass. + +- [ ] **Step 5: Commit the integration coverage** + +```powershell +git add tests\a365\test_execute_tool_scope.py +git commit -m "test: cover execute-tool metadata telemetry" +``` + +--- + +### Task 3: Document the Metadata Wire Contract + +**Files:** +- Modify: `A365_DOCUMENTATION.md:386-488` +- Modify: `README.md:73-91` +- Modify: `CHANGELOG.md:2-8` + +**Interfaces:** +- Consumes: The `metadata` wire behavior implemented in Tasks 1 and 2. +- Produces: User-facing examples and release notes that distinguish public `extension_data` from JSON `metadata`. + +- [ ] **Step 1: Update the full A365 JSON example** + +In `A365_DOCUMENTATION.md`, change: + +```json +"provider_operation": "weather.lookup" +``` + +to: + +```json +"metadata": { + "provider_operation": "weather.lookup" +} +``` + +- [ ] **Step 2: Replace the extension-data behavior explanation** + +Replace the existing merging/collision paragraphs with: + +```markdown +`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`. +``` + +Remove `extension-data collision` from the serialization-failure list because matching keys are now isolated, +not rejected. + +- [ ] **Step 3: Clarify the README example** + +After the typed-arguments code sample in `README.md`, add: + +```markdown +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. +``` + +- [ ] **Step 4: Update the changelog entry** + +Change the feature description to: + +```markdown +- 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. +``` + +- [ ] **Step 5: Run formatting and targeted validation** + +Run: + +```powershell +python -m black --check src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py +python -m pytest tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -q +``` + +Expected: Black reports no changes required and all targeted tests pass. + +- [ ] **Step 6: Commit the documentation** + +```powershell +git add A365_DOCUMENTATION.md README.md CHANGELOG.md +git commit -m "docs: describe execute-tool metadata wrapper" +``` + +--- + +### Task 4: Final Verification + +**Files:** +- Verify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py` +- Verify: `tests/a365/test_tool_call_schema.py` +- Verify: `tests/a365/test_execute_tool_scope.py` +- Verify: `A365_DOCUMENTATION.md` +- Verify: `README.md` +- Verify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: All implementation, tests, and documentation from Tasks 1-3. +- Produces: A review-ready Python PR whose public API remains stable and whose wire contract uses `metadata`. + +- [ ] **Step 1: Run the complete A365 unit-test directory** + +```powershell +python -m pytest tests\a365 -q --ignore=tests\a365\integration +``` + +Expected: all A365 unit tests pass. + +- [ ] **Step 2: Run static checks for the changed Python files** + +```powershell +python -m black --check src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py +python -m pylint src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py +python -m mypy --python-version 3.10 --show-error-codes --ignore-missing-imports src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py +``` + +Expected: Black, Pylint, and Mypy exit successfully. + +- [ ] **Step 3: Inspect the final diff** + +```powershell +git diff fork/copilot/execute-tool-schema...HEAD --check +git status --short +``` + +Expected: no whitespace errors and no uncommitted files. + +- [ ] **Step 4: Confirm cross-language rollout dependency in the PR description** + +Update the PR description's summary or compatibility note to state: + +```markdown +The JSON wire contract now wraps provider-specific extension values under `metadata`. The corresponding +.NET, JavaScript, schema, and downstream parser updates must roll out as a coordinated contract change. +``` + +- [ ] **Step 5: Commit only if the PR-description note is also tracked in repository documentation** + +If Step 4 required no repository changes, do not create an empty commit. If repository documentation was +adjusted during final review, stage only those exact files and commit: + +```powershell +git commit -m "docs: note metadata rollout requirements" +``` From 95c349e68c7ee969d0a9f65d6d1ef9c6f8eeff24 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:07:46 -0600 Subject: [PATCH 14/20] fix: wrap execute-tool extension data Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/models/tool_call_schema.py | 15 +- tests/a365/test_tool_call_schema.py | 154 +++++++++++------- 2 files changed, 104 insertions(+), 65 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py index 2151f524..c1661d4b 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -34,6 +34,7 @@ _JSON_NAME = "json_name" _ENUM_TYPE = "enum_type" _EXTENSION_DATA_FIELD = "extension_data" +_EXTENSION_DATA_JSON_NAME = "metadata" class ToolCallAction(str, Enum): @@ -220,8 +221,8 @@ def serialize_tool_call_payload(value: ToolCallPayload | None) -> str | None: 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 extension data colliding with a serialized model property — the whole - payload is replaced by :data:`TOOL_CALL_SERIALIZATION_ERROR_JSON`. + 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``. @@ -268,7 +269,7 @@ def _enter(value: Any, stack: set[int]) -> int: def _dataclass_to_json_value(value: Any, stack: set[int]) -> dict[str, Any]: - """Serialize a schema model, omitting ``None`` properties and merging extension data.""" + """Serialize a schema model, omitting ``None`` properties and wrapping extension data.""" marker = _enter(value, stack) try: serialized: dict[str, Any] = {} @@ -289,12 +290,8 @@ def _dataclass_to_json_value(value: Any, stack: set[int]) -> dict[str, Any]: if not isinstance(extension_data, Mapping): raise TypeError(f"Extension data must be a mapping; got {type(extension_data).__name__}.") - - for key, item_value in extension_data.items(): - json_key = _json_object_key(key) - if json_key in serialized: - raise ValueError(f"Extension data cannot overwrite execute tool payload property '{json_key}'.") - serialized[json_key] = _to_json_value(item_value, stack) + if extension_data: + serialized[_EXTENSION_DATA_JSON_NAME] = _mapping_to_json_value(extension_data, stack) return serialized finally: stack.discard(marker) diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index dbf289fa..24355cd8 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -81,15 +81,24 @@ def test_execute_tool_arguments_serialize_with_schema_names(): name="report", resource_type="file", provider="sharepoint", - identifiers=[ToolCallIdentifier(identifier_type="drive_id", value="d1")], + 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)) == { @@ -102,11 +111,24 @@ def test_execute_tool_arguments_serialize_with_schema_names(): "name": "report", "type": "file", "provider": "sharepoint", - "identifiers": [{"type": "drive_id", "value": "d1"}], - "container": {"id": "site-1", "uri": "https://example", "type": "site"}, + "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"}, } @@ -117,6 +139,7 @@ def test_execute_tool_result_serializes_with_schema_names(): code="ok", provider_code="sharepoint_ok", message="Read completed", + extension_data={"outcome_detail": "accepted"}, ), resources=[ ToolCallResultResource( @@ -125,21 +148,39 @@ def test_execute_tool_result_serializes_with_schema_names(): name="report", resource_type="file", provider="sharepoint", - identifiers=[ToolCallIdentifier(identifier_type="drive_id", value="d1")], + 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"}, ), - outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), - sensitivity=ToolCallResultSensitivity(label_id="confidential"), 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"}, ), - security=ToolCallResultSecurity(xpia_detected=False), data={"bytes": 0}, + extension_data={"resource_region": "westus"}, ) ], data={"content": ""}, @@ -147,36 +188,24 @@ def test_execute_tool_result_serializes_with_schema_names(): 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", - }, - "resources": [ - { - "id": "file-1", - "uri": "https://example/file", - "name": "report", - "type": "file", - "provider": "sharepoint", - "identifiers": [{"type": "drive_id", "value": "d1"}], - "container": {"id": "site-1", "uri": "https://example", "type": "site"}, - "outcome": {"status": "success"}, - "sensitivity": {"label_id": "confidential"}, - "policy": {"decision": "allow", "id": "policy-1", "name": "Sharing policy"}, - "security": {"xpia_detected": False}, - "data": {"bytes": 0}, - } - ], - "data": {"content": ""}, - "pagination": {"has_more": False, "next_cursor": "cursor-2", "total_count": 0}, - } + serialized = json.loads(serialize_tool_call_payload(payload)) + + assert serialized["metadata"] == {"provider_trace_id": "trace-789"} + assert serialized["outcome"]["metadata"] == {"outcome_detail": "accepted"} + resource = serialized["resources"][0] + assert resource["metadata"] == {"resource_region": "westus"} + assert resource["identifiers"][0]["metadata"] == {"identifier_scope": "tenant"} + assert resource["container"]["metadata"] == {"site_collection_id": "site-456"} + assert resource["outcome"]["metadata"] == {"resource_outcome": "accepted"} + assert resource["sensitivity"]["metadata"] == {"label_source": "provider"} + assert resource["policy"]["metadata"] == {"policy_version": "2"} + assert resource["security"]["metadata"] == {"scanner": "provider"} + assert serialized["pagination"]["metadata"] == {"page_source": "cache"} def test_execute_tool_arguments_omit_none_properties_and_preserve_empty_or_false_values(): @@ -200,7 +229,7 @@ def test_execute_tool_arguments_omit_none_properties_and_preserve_empty_or_false "filters": {}, "tags": [], }, - "provider_options": {}, + "metadata": {"provider_options": {}}, } @@ -217,40 +246,53 @@ def test_none_is_omitted_for_model_properties_but_preserved_inside_mappings_and_ assert json.loads(serialize_tool_call_payload(payload)) == { "schema_version": "1.0", - "outcome": {"status": "success", "provider_outcome": None, "attempts": 0}, + "outcome": { + "status": "success", + "metadata": {"provider_outcome": None, "attempts": 0}, + }, "data": {"content": None, "matches": [None, 1]}, - "provider_result": None, - "cached": False, + "metadata": {"provider_result": None, "cached": False}, } -def test_extension_data_may_supply_a_key_whose_model_property_is_none(): - payload = ExecuteToolCallArguments(extension_data={"action": "write"}) +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": "write", + "action": "read", + "metadata": { + "action": "write", + "schema_version": "9.9", + }, } -def test_extension_data_cannot_overwrite_a_serialized_model_property(): - payload = ExecuteToolCallArguments(action=ToolCallAction.READ, extension_data={"action": "write"}) - - assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR - - -def test_extension_data_cannot_overwrite_schema_version(): - payload = ExecuteToolCallResult(extension_data={"schema_version": "9.9"}) - - assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR - - -def test_extension_data_collision_is_detected_on_nested_models(): +def test_nested_extension_data_keys_matching_declared_fields_remain_inside_metadata(): payload = ExecuteToolCallResult( - outcome=ToolCallResultOutcome(code="ok", extension_data={"code": "overwrite"}), + outcome=ToolCallResultOutcome( + code="ok", + extension_data={"code": "provider-code"}, + ), ) - assert serialize_tool_call_payload(payload) == DOTNET_SERIALIZATION_ERROR + 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(): From f522ae7fce028ec21b2964702ac0c8f53c959894 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:10:55 -0600 Subject: [PATCH 15/20] test: preserve execute-tool result coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/a365/test_tool_call_schema.py | 73 ++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index 24355cd8..193567c5 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -193,19 +193,66 @@ def test_execute_tool_result_serializes_with_schema_names(): extension_data={"provider_trace_id": "trace-789"}, ) - serialized = json.loads(serialize_tool_call_payload(payload)) - - assert serialized["metadata"] == {"provider_trace_id": "trace-789"} - assert serialized["outcome"]["metadata"] == {"outcome_detail": "accepted"} - resource = serialized["resources"][0] - assert resource["metadata"] == {"resource_region": "westus"} - assert resource["identifiers"][0]["metadata"] == {"identifier_scope": "tenant"} - assert resource["container"]["metadata"] == {"site_collection_id": "site-456"} - assert resource["outcome"]["metadata"] == {"resource_outcome": "accepted"} - assert resource["sensitivity"]["metadata"] == {"label_source": "provider"} - assert resource["policy"]["metadata"] == {"policy_version": "2"} - assert resource["security"]["metadata"] == {"scanner": "provider"} - assert serialized["pagination"]["metadata"] == {"page_source": "cache"} + 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(): From e04043eba76825cd69b2a1d472600af109ca410b Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:13:32 -0600 Subject: [PATCH 16/20] test: cover execute-tool metadata telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/a365/test_execute_tool_scope.py | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py index d5dcabc1..da18c4b6 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -50,7 +50,13 @@ def test_typed_arguments_include_schema_version(self): tool_name="read_file", arguments=ExecuteToolCallArguments( action=ToolCallAction.READ, - resources=[ToolCallResource(resource_id="file-1")], + resources=[ + ToolCallResource( + resource_id="file-1", + extension_data={"tenant_id": "tenant-123"}, + ) + ], + extension_data={"provider_trace_id": "trace-789"}, ), ), self._make_agent_details(), @@ -61,7 +67,11 @@ def test_typed_arguments_include_schema_version(self): self.assertEqual(payload["schema_version"], "1.0") self.assertEqual(payload["action"], "read") - self.assertEqual(payload["resources"], [{"id": "file-1"}]) + self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) + self.assertEqual( + payload["resources"], + [{"id": "file-1", "metadata": {"tenant_id": "tenant-123"}}], + ) finally: scope.dispose() @@ -74,15 +84,26 @@ def test_typed_result_include_schema_version(self): try: scope.record_response( ExecuteToolCallResult( - outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS), + 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["outcome"], {"status": "success"}) + 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() From 473fb49c467f1dc6ffab51144f7780da5b9b0975 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:15:25 -0600 Subject: [PATCH 17/20] docs: describe execute-tool metadata wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 18 ++++++++++++------ CHANGELOG.md | 6 +++--- README.md | 3 +++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 87e202c6..d34be8c8 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -449,7 +449,9 @@ The typed arguments above generate a JSON payload shaped like: } ], "parameters": {"city": "Seattle"}, - "provider_operation": "weather.lookup" + "metadata": { + "provider_operation": "weather.lookup" + } } ``` @@ -473,15 +475,19 @@ Model properties that are `None` are omitted. `False`, zero, empty strings, empt 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 merged into the same JSON object as the model it belongs to. It may supply a key whose -model property was left `None`, but it cannot overwrite a property that is actually serialized. +`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`, an undefined enum token, or an -extension-data collision — the whole attribute value is replaced by the diagnostic payload and a warning is -logged: +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."} diff --git a/CHANGELOG.md b/CHANGELOG.md index d42d49c7..e55f762c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ # Unreleased ### Features Added - Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization, - `ToolCallAction`/`ToolCallOutcomeStatus`/`ToolPolicyDecision` enums, extension data collision checks, public - exports, and `ExecuteToolScope` support while preserving raw dict/string payloads. Execute-tool payload - serialization is non-throwing: unserializable payloads record + `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. # 1.3.9 (2026-09-09) diff --git a/README.md b/README.md index b551ba8e..357c8eb3 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ arguments = ExecuteToolCallArguments( ) ``` +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 From afc1b4839f711652137cb41bbae2af4a36242477 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:52:48 -0600 Subject: [PATCH 18/20] fix: preserve execute-tool scalar fidelity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 7 ++-- .../a365/core/models/tool_call_schema.py | 39 +++++++++++++++++-- tests/a365/test_tool_call_schema.py | 12 +++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 5d5c6746..fd60e72a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -573,9 +573,10 @@ whole attribute value is replaced by the diagnostic payload and a warning is log {"serialization_error": "Failed to serialize execute tool payload."} ``` -`bytes` and `bytearray` are emitted as base64 strings, `datetime`/`date`/`time` as ISO-8601 strings, `UUID` -as its canonical string, `Decimal` as a JSON number, any `Enum` as its value, and lists, tuples, sets, and -other sized collections as JSON arrays. Raw dictionary arguments and results keep their existing +`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 diff --git a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py index c1661d4b..10b2e725 100644 --- a/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py +++ b/src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py @@ -200,7 +200,13 @@ def _to_base64(value: bytes | bytearray | memoryview) -> str: def _to_isoformat(value: datetime.datetime | datetime.date | datetime.time) -> str: """Return the ISO-8601 text for a date/time value.""" - return value.isoformat() + 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``. @@ -212,10 +218,37 @@ def _to_isoformat(value: datetime.datetime | datetime.date | datetime.time) -> s ((bytes, bytearray, memoryview), _to_base64), ((datetime.datetime, datetime.date, datetime.time), _to_isoformat), (uuid.UUID, str), - (Decimal, float), + (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. @@ -235,7 +268,7 @@ def serialize_tool_call_payload(value: ToolCallPayload | None) -> str | None: return None try: - return json.dumps(_to_json_value(value, set()), ensure_ascii=False, allow_nan=False) + 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 diff --git a/tests/a365/test_tool_call_schema.py b/tests/a365/test_tool_call_schema.py index 193567c5..9ebfc57c 100644 --- a/tests/a365/test_tool_call_schema.py +++ b/tests/a365/test_tool_call_schema.py @@ -437,7 +437,7 @@ def test_well_known_scalar_types_serialize_as_strings_or_numbers(): ) assert json.loads(serialize_tool_call_payload(payload))["data"] == { - "timestamp": "2026-01-02T03:04:05+00:00", + "timestamp": "2026-01-02T03:04:05Z", "day": "2026-01-02", "time": "03:04:05", "id": "12345678-1234-5678-1234-567812345678", @@ -445,6 +445,16 @@ def test_well_known_scalar_types_serialize_as_strings_or_numbers(): } +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}) From f0a97a88d2b3b7a94462134e6e8cb0bbc28663bf Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 10:56:50 -0600 Subject: [PATCH 19/20] docs: remove duplicate changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e67ca6d..06ad15cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,9 @@ PR #120. - Remove optional dependency for langchain-core and document guidance for installation ([#269](https://github.com/microsoft/opentelemetry-distro-python/pull/269)) + ### Bugs Fixed - Restrict A365 identity and baggage enrichment to recognized GenAI spans while preserving supported span-start signals. ([#265](https://github.com/microsoft/opentelemetry-distro-python/pull/265)) -- Restrict A365 identity and baggage enrichment to recognized GenAI spans while preserving supported span-start signals. ([#265](https://github.com/microsoft/opentelemetry-distro-python/pull/265)) # 1.3.9 (2026-09-09) ### Features Added From 711c27182e4b54dcd44f4ad56e52de9c0ecdabaa Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 24 Sep 2026 11:42:24 -0600 Subject: [PATCH 20/20] chore: remove internal planning docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...026-09-23-execute-tool-metadata-wrapper.md | 550 ------------------ ...23-execute-tool-metadata-wrapper-design.md | 198 ------- 2 files changed, 748 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md delete mode 100644 docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md diff --git a/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md b/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md deleted file mode 100644 index 3e2a8c8a..00000000 --- a/docs/superpowers/plans/2026-09-23-execute-tool-metadata-wrapper.md +++ /dev/null @@ -1,550 +0,0 @@ -# Execute-Tool Metadata Wrapper Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Serialize every execute-tool model's existing `extension_data` dictionary under a non-empty `metadata` JSON wrapper instead of flattening its entries. - -**Architecture:** Preserve all Python public dataclass signatures and change only the shared dataclass serializer's wire mapping. Unit tests establish wrapping, omission, nesting, collision isolation, and safe-serialization behavior; scope integration tests verify emitted telemetry attributes; documentation defines the coordinated cross-language contract. - -**Tech Stack:** Python 3.10+, dataclasses, `json`, pytest, unittest, OpenTelemetry span attributes. - -## Global Constraints - -- Keep `extension_data: dict[str, object] = field(default_factory=dict)` unchanged on every Python model. -- Serialize non-empty extension dictionaries under the JSON key `metadata`. -- Omit `metadata` when the extension dictionary is empty or `None`. -- Apply the wrapper independently to every currently extensible execute-tool model. -- Do not emit flattened extension keys. -- Preserve existing non-throwing diagnostic serialization behavior. -- Preserve raw dictionary and string argument/result behavior. -- Coordinate the `metadata` wire format with .NET, JavaScript, the published schema, and downstream parsers. - ---- - -### Task 1: Wrap Schema Model Extension Data - -**Files:** -- Modify: `tests/a365/test_tool_call_schema.py:70-285` -- Modify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py:25-30` -- Modify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py:269-298` - -**Interfaces:** -- Consumes: Existing model field `extension_data: dict[str, object]`. -- Produces: `serialize_tool_call_payload(payload: ToolCallPayload | None) -> str | None` output containing `metadata` for non-empty extension dictionaries. - -- [ ] **Step 1: Update the argument serialization test to require nested metadata** - -In `test_execute_tool_arguments_serialize_with_schema_names`, add extension data at the arguments, resource, identifier, and container levels: - -```python -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"}, -) -``` - -Require the corresponding portions of the expected dictionary to be: - -```python -{ - "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"}, -} -``` - -- [ ] **Step 2: Update the result serialization test to cover every result-side extensible model** - -Add extension data to the existing result tree: - -```python -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", - identifiers=[ - ToolCallIdentifier( - identifier_type="drive_id", - value="d1", - extension_data={"identifier_scope": "tenant"}, - ) - ], - container=ToolCallContainer( - container_id="site-1", - 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", - extension_data={"policy_version": "2"}, - ), - security=ToolCallResultSecurity( - xpia_detected=False, - extension_data={"scanner": "provider"}, - ), - extension_data={"resource_region": "westus"}, - ) - ], - pagination=ToolCallResultPagination( - has_more=False, - extension_data={"page_source": "cache"}, - ), - extension_data={"provider_trace_id": "trace-789"}, -) -``` - -Assert each extension dictionary appears under `metadata` at its owning object: - -```python -serialized = json.loads(serialize_tool_call_payload(payload)) - -assert serialized["metadata"] == {"provider_trace_id": "trace-789"} -assert serialized["outcome"]["metadata"] == {"outcome_detail": "accepted"} -resource = serialized["resources"][0] -assert resource["metadata"] == {"resource_region": "westus"} -assert resource["identifiers"][0]["metadata"] == {"identifier_scope": "tenant"} -assert resource["container"]["metadata"] == {"site_collection_id": "site-456"} -assert resource["outcome"]["metadata"] == {"resource_outcome": "accepted"} -assert resource["sensitivity"]["metadata"] == {"label_source": "provider"} -assert resource["policy"]["metadata"] == {"policy_version": "2"} -assert resource["security"]["metadata"] == {"scanner": "provider"} -assert serialized["pagination"]["metadata"] == {"page_source": "cache"} -``` - -- [ ] **Step 3: Replace collision tests with isolation and omission tests** - -Replace the four tests beginning with -`test_extension_data_may_supply_a_key_whose_model_property_is_none` through -`test_extension_data_collision_is_detected_on_nested_models` with: - -```python -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" - } -``` - -Update the existing preservation tests so their expected output contains: - -```python -"metadata": {"provider_options": {}} -``` - -and: - -```python -"outcome": { - "status": "success", - "metadata": {"provider_outcome": None, "attempts": 0}, -}, -"metadata": {"provider_result": None, "cached": False}, -``` - -- [ ] **Step 4: Run the focused tests and verify they fail** - -Run: - -```powershell -python -m pytest tests\a365\test_tool_call_schema.py -q -``` - -Expected: failures show extension keys such as `provider_trace_id`, `action`, and `provider_options` are still flattened and no `metadata` object is emitted. - -- [ ] **Step 5: Add the metadata JSON-name constant** - -Near `_EXTENSION_DATA_FIELD`, add: - -```python -_EXTENSION_DATA_JSON_NAME = "metadata" -``` - -- [ ] **Step 6: Replace extension-data merging with wrapped serialization** - -Replace `_dataclass_to_json_value` with: - -```python -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) -``` - -- [ ] **Step 7: Run the schema tests and verify they pass** - -Run: - -```powershell -python -m pytest tests\a365\test_tool_call_schema.py -q -``` - -Expected: all tests pass, including unsupported-value, throwing-mapping, cycle, enum, collection, and scalar conversion cases. - -- [ ] **Step 8: Commit the serializer and unit tests** - -```powershell -git add src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py -git commit -m "fix: wrap execute-tool extension data" -``` - ---- - -### Task 2: Verify Telemetry Integration - -**Files:** -- Modify: `tests/a365/test_execute_tool_scope.py:39-118` - -**Interfaces:** -- Consumes: `serialize_tool_call_payload()` metadata-wrapper output from Task 1. -- Produces: Span attributes `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result` containing the same wrapped JSON. - -- [ ] **Step 1: Add extension data to the typed arguments scope test** - -Update `test_typed_arguments_include_schema_version`: - -```python -arguments=ExecuteToolCallArguments( - action=ToolCallAction.READ, - resources=[ - ToolCallResource( - resource_id="file-1", - extension_data={"tenant_id": "tenant-123"}, - ) - ], - extension_data={"provider_trace_id": "trace-789"}, -), -``` - -Add these assertions: - -```python -self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) -self.assertEqual( - payload["resources"], - [{"id": "file-1", "metadata": {"tenant_id": "tenant-123"}}], -) -``` - -- [ ] **Step 2: Add extension data to the typed result scope test** - -Update `test_typed_result_include_schema_version`: - -```python -scope.record_response( - ExecuteToolCallResult( - outcome=ToolCallResultOutcome( - status=ToolCallOutcomeStatus.SUCCESS, - extension_data={"provider_status": "accepted"}, - ), - data={"count": 0}, - extension_data={"provider_trace_id": "trace-789"}, - ) -) -``` - -Add these assertions: - -```python -self.assertEqual(payload["metadata"], {"provider_trace_id": "trace-789"}) -self.assertEqual( - payload["outcome"], - { - "status": "success", - "metadata": {"provider_status": "accepted"}, - }, -) -``` - -- [ ] **Step 3: Run the scope tests** - -Run: - -```powershell -python -m pytest tests\a365\test_execute_tool_scope.py -q -``` - -Expected: all scope tests pass; raw dictionary/string payload tests remain unchanged. - -- [ ] **Step 4: Run the combined targeted suite** - -Run: - -```powershell -python -m pytest tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -q -``` - -Expected: all targeted schema and integration tests pass. - -- [ ] **Step 5: Commit the integration coverage** - -```powershell -git add tests\a365\test_execute_tool_scope.py -git commit -m "test: cover execute-tool metadata telemetry" -``` - ---- - -### Task 3: Document the Metadata Wire Contract - -**Files:** -- Modify: `A365_DOCUMENTATION.md:386-488` -- Modify: `README.md:73-91` -- Modify: `CHANGELOG.md:2-8` - -**Interfaces:** -- Consumes: The `metadata` wire behavior implemented in Tasks 1 and 2. -- Produces: User-facing examples and release notes that distinguish public `extension_data` from JSON `metadata`. - -- [ ] **Step 1: Update the full A365 JSON example** - -In `A365_DOCUMENTATION.md`, change: - -```json -"provider_operation": "weather.lookup" -``` - -to: - -```json -"metadata": { - "provider_operation": "weather.lookup" -} -``` - -- [ ] **Step 2: Replace the extension-data behavior explanation** - -Replace the existing merging/collision paragraphs with: - -```markdown -`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`. -``` - -Remove `extension-data collision` from the serialization-failure list because matching keys are now isolated, -not rejected. - -- [ ] **Step 3: Clarify the README example** - -After the typed-arguments code sample in `README.md`, add: - -```markdown -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. -``` - -- [ ] **Step 4: Update the changelog entry** - -Change the feature description to: - -```markdown -- 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. -``` - -- [ ] **Step 5: Run formatting and targeted validation** - -Run: - -```powershell -python -m black --check src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -python -m pytest tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -q -``` - -Expected: Black reports no changes required and all targeted tests pass. - -- [ ] **Step 6: Commit the documentation** - -```powershell -git add A365_DOCUMENTATION.md README.md CHANGELOG.md -git commit -m "docs: describe execute-tool metadata wrapper" -``` - ---- - -### Task 4: Final Verification - -**Files:** -- Verify: `src/microsoft/opentelemetry/a365/core/models/tool_call_schema.py` -- Verify: `tests/a365/test_tool_call_schema.py` -- Verify: `tests/a365/test_execute_tool_scope.py` -- Verify: `A365_DOCUMENTATION.md` -- Verify: `README.md` -- Verify: `CHANGELOG.md` - -**Interfaces:** -- Consumes: All implementation, tests, and documentation from Tasks 1-3. -- Produces: A review-ready Python PR whose public API remains stable and whose wire contract uses `metadata`. - -- [ ] **Step 1: Run the complete A365 unit-test directory** - -```powershell -python -m pytest tests\a365 -q --ignore=tests\a365\integration -``` - -Expected: all A365 unit tests pass. - -- [ ] **Step 2: Run static checks for the changed Python files** - -```powershell -python -m black --check src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -python -m pylint src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py tests\a365\test_tool_call_schema.py tests\a365\test_execute_tool_scope.py -python -m mypy --python-version 3.10 --show-error-codes --ignore-missing-imports src\microsoft\opentelemetry\a365\core\models\tool_call_schema.py -``` - -Expected: Black, Pylint, and Mypy exit successfully. - -- [ ] **Step 3: Inspect the final diff** - -```powershell -git diff fork/copilot/execute-tool-schema...HEAD --check -git status --short -``` - -Expected: no whitespace errors and no uncommitted files. - -- [ ] **Step 4: Confirm cross-language rollout dependency in the PR description** - -Update the PR description's summary or compatibility note to state: - -```markdown -The JSON wire contract now wraps provider-specific extension values under `metadata`. The corresponding -.NET, JavaScript, schema, and downstream parser updates must roll out as a coordinated contract change. -``` - -- [ ] **Step 5: Commit only if the PR-description note is also tracked in repository documentation** - -If Step 4 required no repository changes, do not create an empty commit. If repository documentation was -adjusted during final review, stage only those exact files and commit: - -```powershell -git commit -m "docs: note metadata rollout requirements" -``` diff --git a/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md b/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md deleted file mode 100644 index 4ee94bf0..00000000 --- a/docs/superpowers/specs/2026-09-23-execute-tool-metadata-wrapper-design.md +++ /dev/null @@ -1,198 +0,0 @@ -# Execute-Tool Metadata Wrapper Design - -## Summary - -Execute-tool schema models currently flatten provider-specific extension entries into -the containing JSON object. This permits extension keys to collide with declared -schema properties such as `action`, bypassing typed validation when the declared -property is omitted. - -Provider-specific values will instead serialize under a dedicated `metadata` JSON -property. The change applies to every currently extensible execute-tool object. -Existing language-specific public property names remain unchanged to preserve source -compatibility. - -## Goals - -- Prevent provider extensions from overwriting or bypassing declared schema fields. -- Give provider-specific values an explicit namespace in the wire contract. -- Preserve existing Python, .NET, and JavaScript public property names. -- Use the same `metadata` JSON representation in every language implementation. -- Avoid adding empty `metadata` objects to existing payloads. - -## Non-Goals - -- Renaming existing public extension properties. -- Adding a setter or builder API for individual metadata entries. -- Defining provider-specific metadata keys or values. -- Supporting both flattened and wrapped extension output simultaneously. - -## Public API - -Public model signatures remain unchanged. - -Python continues to expose: - -```python -extension_data: dict[str, object] = field(default_factory=dict) -``` - -.NET continues to expose: - -```csharp -public IDictionary AdditionalProperties { get; set; } -``` - -JavaScript retains its existing extension-data property name. - -Callers continue assigning provider-specific values through those properties. No -additional setter method is required because the existing dictionaries support -constructor assignment and later mutation. - -## Wire Contract - -A non-empty extension dictionary serializes as the declared JSON property -`metadata`. The entries are not flattened into the containing object. - -For example, this Python input: - -```python -ExecuteToolCallArguments( - action=ToolCallAction.READ, - parameters={}, - extension_data={ - "provider_trace_id": "trace-789", - "tenant_id": "tenant-123", - }, -) -``` - -serializes as: - -```json -{ - "schema_version": "1.0", - "action": "read", - "parameters": {}, - "metadata": { - "provider_trace_id": "trace-789", - "tenant_id": "tenant-123" - } -} -``` - -When the extension dictionary is empty, `metadata` is omitted: - -```json -{ - "schema_version": "1.0", - "action": "read", - "parameters": {} -} -``` - -## Model Scope - -The wrapper applies independently at every currently extensible object: - -- `ExecuteToolCallArguments` -- `ToolCallResource` -- `ToolCallIdentifier` -- `ToolCallContainer` -- `ExecuteToolCallResult` -- `ToolCallResultOutcome` -- `ToolCallResultSensitivity` -- `ToolCallResultPolicy` -- `ToolCallResultSecurity` -- `ToolCallResultPagination` -- `ToolCallResultResource` - -Each object's extension dictionary becomes that object's `metadata` property. For -example: - -```json -{ - "resources": [ - { - "id": "resource-id", - "metadata": { - "tenant_id": "tenant-123" - }, - "container": { - "id": "container-id", - "metadata": { - "site_collection_id": "site-456" - } - } - } - ], - "metadata": { - "provider_trace_id": "trace-789" - } -} -``` - -## Serialization Behavior - -The Python serializer will treat `extension_data` as a normal declared model field -whose JSON name is `metadata`. It will recursively serialize the dictionary using the -existing non-throwing diagnostic behavior. - -The .NET implementation will stop using `[JsonExtensionData]` for -`AdditionalProperties` and map that property to `metadata` instead. JavaScript will -perform the equivalent explicit property mapping. - -An extension dictionary entry named `metadata` is valid because it remains nested: - -```json -{ - "metadata": { - "metadata": "provider-defined value" - } -} -``` - -It cannot overwrite the outer declared `metadata` property. - -## Compatibility and Rollout - -Public source APIs remain compatible, but the serialized wire format changes from -flattened properties to a `metadata` wrapper. Python, .NET, JavaScript, the published -schema, and downstream parsers must adopt the new representation as one coordinated -contract change. - -Consumers must not depend on provider-specific properties appearing at the same level -as declared schema fields. During rollout, producers and consumers should be released -in an order that prevents new wrapped payloads from reaching parsers that reject the -`metadata` property. - -## Error Handling - -The existing serializer contract remains in effect: - -- Invalid extension containers produce the repository-standard diagnostic payload. -- A failure in a metadata value does not throw from telemetry instrumentation. -- Cycles and unsupported values follow existing safe-serialization behavior. -- Declared enum fields continue to use typed validation and cannot be supplied through - metadata as top-level replacements. - -## Testing - -Tests must verify: - -- Non-empty extension dictionaries serialize under `metadata`. -- Empty extension dictionaries omit `metadata`. -- Metadata is wrapped at the root and at every nested extensible model. -- Keys matching declared schema fields remain inside `metadata` and cannot replace the - declared fields. -- Existing safe serialization behavior applies recursively to metadata values. -- Raw dictionary and string payload support remains unchanged. -- Scope and ETW integrations emit the same wrapped representation. -- Documentation examples match the new contract. - -## Documentation - -Update the execute-tool schema documentation and usage examples to describe -provider-specific metadata as a nested `metadata` object. The documentation must note -that language SDK property names may differ while the JSON contract consistently uses -`metadata`.