Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b9398f2
feat: add execute-tool arguments schema models
nikhilc-microsoft Sep 11, 2026
7807da0
feat: add execute-tool result schema models
nikhilc-microsoft Sep 11, 2026
9ab470f
feat: integrate execute-tool schema payloads
nikhilc-microsoft Sep 11, 2026
ab7e71d
chore: ignore untyped msal in mypy
nikhilc-microsoft Sep 11, 2026
9fa0d40
docs: describe execute-tool schema models
nikhilc-microsoft Sep 11, 2026
a92ed24
fix: enforce execute-tool schema serializer invariants
nikhilc-microsoft Sep 11, 2026
6a3b4e4
chore: drop unrelated mypy msal ignore
nikhilc-microsoft Sep 11, 2026
125d04a
fix: harden execute-tool typed payload serialization
nikhilc-microsoft Sep 11, 2026
73f9703
docs: document execute-tool schema enums, null handling, and failure …
nikhilc-microsoft Sep 11, 2026
32cb336
fix: make raw execute-tool dict payloads non-throwing and accept coll…
nikhilc-microsoft Sep 11, 2026
fc712ea
docs: add tool call schema module entry
nikhilc-microsoft Sep 12, 2026
251e330
docs: design execute-tool metadata wrapper
nikhilc-microsoft Sep 23, 2026
7a1e66c
docs: plan execute-tool metadata wrapper
nikhilc-microsoft Sep 23, 2026
95c349e
fix: wrap execute-tool extension data
nikhilc-microsoft Sep 24, 2026
f522ae7
test: preserve execute-tool result coverage
nikhilc-microsoft Sep 24, 2026
e04043e
test: cover execute-tool metadata telemetry
nikhilc-microsoft Sep 24, 2026
473fb49
docs: describe execute-tool metadata wrapper
nikhilc-microsoft Sep 24, 2026
34dfdc5
Merge origin/main into copilot/execute-tool-schema
nikhilc-microsoft Sep 24, 2026
afc1b48
fix: preserve execute-tool scalar fidelity
nikhilc-microsoft Sep 24, 2026
f0a97a8
docs: remove duplicate changelog entry
nikhilc-microsoft Sep 24, 2026
711c271
chore: remove internal planning docs
nikhilc-microsoft Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions A365_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,119 @@ with ExecuteToolScope.start(
scope.record_response(result)
```

`ToolCallDetails.arguments` and `ExecuteToolScope.record_response()` also accept typed execute-tool schema models.
Typed top-level arguments and results serialize to the Agent365 JSON contract with `schema_version: "1.0"`,
while raw dictionaries and strings remain supported and keep their existing behavior.

```python
from microsoft.opentelemetry.a365.core import (
ExecuteToolCallArguments,
ExecuteToolCallResult,
ExecuteToolScope,
ToolCallAction,
ToolCallDetails,
ToolCallOutcomeStatus,
ToolCallResource,
ToolCallResultOutcome,
ToolCallResultPagination,
Request,
)

arguments = ExecuteToolCallArguments(
action=ToolCallAction.READ,
resources=[
ToolCallResource(
resource_id="file-1",
uri="https://contoso.example/files/1",
name="Forecast",
resource_type="file",
provider="sharepoint",
)
],
parameters={"city": "Seattle"},
extension_data={"provider_operation": "weather.lookup"},
)

with ExecuteToolScope.start(
request=Request(content="What's the weather?"),
details=ToolCallDetails(tool_name="get_weather", tool_call_id="call_1", arguments=arguments),
agent_details=agent,
) as scope:
scope.record_response(
ExecuteToolCallResult(
outcome=ToolCallResultOutcome(status=ToolCallOutcomeStatus.SUCCESS),
data={"temperature_f": 68},
pagination=ToolCallResultPagination(has_more=False, total_count=1),
)
)
```

The typed arguments above generate a JSON payload shaped like:

```json
{
"schema_version": "1.0",
"action": "read",
"resources": [
{
"id": "file-1",
"uri": "https://contoso.example/files/1",
"name": "Forecast",
"type": "file",
"provider": "sharepoint"
}
],
"parameters": {"city": "Seattle"},
"metadata": {
"provider_operation": "weather.lookup"
}
}
```

#### Schema enums

`action`, `outcome.status`, and `policy.decision` are constrained to the same tokens as the .NET distro:

| Field | Enum | Tokens |
| --- | --- | --- |
| `ExecuteToolCallArguments.action` | `ToolCallAction` | `create`, `read`, `update`, `delete` |
| `ToolCallResultOutcome.status` | `ToolCallOutcomeStatus` | `success`, `failure` |
| `ToolCallResultPolicy.decision` | `ToolPolicyDecision` | `allow`, `deny` |

The exact lowercase strings are accepted as well, so `action="read"` and `action=ToolCallAction.READ` are
equivalent. Any other value — a different casing, an undefined token, a number, a boolean, or a member of a
different enum — is rejected instead of emitting schema-invalid telemetry.

#### Null handling and extension data

Model properties that are `None` are omitted. `False`, zero, empty strings, empty dictionaries, and empty
lists are preserved. `None` inside a dictionary or list you supply (for example `parameters`, `data`, or
`extension_data`) is preserved as JSON `null`, matching the .NET contract.

`extension_data` is serialized under a `metadata` property on the same model. The public Python field name
remains `extension_data`, while the JSON wire contract consistently uses `metadata`. Empty extension
dictionaries omit `metadata`.

Each extensible nested model owns its own metadata object. Extension keys therefore cannot replace declared
properties such as `action`, `schema_version`, `outcome.status`, or `policy.decision`; a matching extension key
remains inside `metadata`.

#### Serialization failures

Typed payload serialization never raises and never leaves a span orphaned. If any value cannot be
represented — an unsupported type, a reference cycle, `NaN`/`Infinity`, or an undefined enum token — the
whole attribute value is replaced by the diagnostic payload and a warning is logged:

```json
{"serialization_error": "Failed to serialize execute tool payload."}
```

`bytes` and `bytearray` are emitted as base64 strings, `datetime`/`date`/`time` as ISO-8601 strings (using
the `Z` designator for UTC), `UUID` as its canonical string, and `Decimal` as a precision-preserving JSON
number. Any `Enum` is emitted as its value, and lists, tuples, sets, and other sized collections as JSON
arrays. Raw dictionary arguments and results keep their existing
serialization but also fall back to the same diagnostic payload instead of raising.

### InferenceScope

```python
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Release History
# Unreleased
### Features Added
- Add typed Agent365 execute-tool argument and result schema models with `schema_version: "1.0"` serialization,
`ToolCallAction`/`ToolCallOutcomeStatus`/`ToolPolicyDecision` enums, provider extension data wrapped under
the JSON `metadata` property, public exports, and `ExecuteToolScope` support while preserving raw
dict/string payloads. Execute-tool payload serialization is non-throwing: unserializable payloads record
`{"serialization_error": "Failed to serialize execute tool payload."}` instead of failing the span.
- Add explicit custom baggage APIs and propagate opted-in attributes to supported GenAI spans, including unmodeled operations. ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264))
- Add Python-native `InvokeAgentScope` request and response parameter models
that emit OpenTelemetry GenAI semantic attributes, including structured
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ use_microsoft_opentelemetry(

See the [A365 guide](https://github.com/microsoft/opentelemetry-distro-python/blob/main/A365_DOCUMENTATION.md) for A365-specific configuration.

Agent365 manual execute-tool spans support typed JSON schema payloads:

```python
from microsoft.opentelemetry.a365.core import ExecuteToolCallArguments, ToolCallAction, ToolCallResource

arguments = ExecuteToolCallArguments(
action=ToolCallAction.READ,
resources=[ToolCallResource(resource_id="file-1", resource_type="file", provider="sharepoint")],
extension_data={"provider_operation": "lookup"},
)
```

The Python `extension_data` dictionary is emitted as a non-empty `metadata` object in the JSON payload,
preventing provider-specific keys from colliding with declared schema fields.

Typed arguments and results emit `schema_version: "1.0"` and serialize with schema field names such as
`id`, `type`, `provider_code`, and `has_more`. Serialization never raises: an unserializable payload is
replaced by `{"serialization_error": "Failed to serialize execute tool payload."}`. Raw dictionary and
string arguments/results remain supported. See the A365 guide for complete examples.

---

## Configuration Reference
Expand Down
1 change: 1 addition & 0 deletions src/microsoft/opentelemetry/a365/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Data models for messages, agents, callers, and responses.
|------|-------------|
| `caller_details.py` | `CallerDetails` dataclass — groups caller identity for agent-to-agent (A2A) scenarios (human user and calling agent). |
| `messages.py` | OTel gen-ai message types: enums (`MessageRole`, `FinishReason`, `Modality`), message parts (`TextPart`, `BlobPart`, `FilePart`, `UriPart`), `ChatMessage`, `OutputMessage`, and wrapper types. |
| `tool_call_schema.py` | Typed execute-tool argument/result schema models and non-throwing JSON serializer for Agent365 payloads. |
| `response.py` | `Response` dataclass — output messages (strings, `OutputMessages`, or tool result dicts). |
| `service_endpoint.py` | `ServiceEndpoint` dataclass — hostname and optional port. |
| `user_details.py` | `UserDetails` dataclass — human user info (ID, email, name, client IP). |
Expand Down
34 changes: 34 additions & 0 deletions src/microsoft/opentelemetry/a365/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@
UriPart,
)
from microsoft.opentelemetry.a365.core.models.response import Response
from microsoft.opentelemetry.a365.core.models.tool_call_schema import (
ExecuteToolCallArguments,
ExecuteToolCallResult,
ToolCallAction,
ToolCallContainer,
ToolCallIdentifier,
ToolCallOutcomeStatus,
ToolCallPayload,
ToolCallResource,
ToolCallResultOutcome,
ToolCallResultPagination,
ToolCallResultPolicy,
ToolCallResultResource,
ToolCallResultSecurity,
ToolCallResultSensitivity,
ToolPolicyDecision,
serialize_tool_call_payload,
)
from microsoft.opentelemetry.a365.core.models.user_details import UserDetails
from microsoft.opentelemetry.a365.core.opentelemetry_scope import OpenTelemetryScope
from microsoft.opentelemetry.a365.core.request import Request
Expand Down Expand Up @@ -86,11 +104,27 @@
"SpanDetails",
"InferenceCallDetails",
"ServiceEndpoint",
"ExecuteToolCallArguments",
"ExecuteToolCallResult",
"ToolCallContainer",
"ToolCallIdentifier",
"ToolCallPayload",
"ToolCallResource",
"ToolCallResultOutcome",
"ToolCallResultPagination",
"ToolCallResultPolicy",
"ToolCallResultResource",
"ToolCallResultSecurity",
"ToolCallResultSensitivity",
"serialize_tool_call_payload",
"GenAiRequestParameters",
"GenAiResponseParameters",
# Enums
"InferenceOperationType",
"ToolType",
"ToolCallAction",
"ToolCallOutcomeStatus",
"ToolPolicyDecision",
# OTEL gen-ai message format types
"MessageRole",
"FinishReason",
Expand Down
51 changes: 46 additions & 5 deletions src/microsoft/opentelemetry/a365/core/execute_tool_scope.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,11 +26,37 @@
)
from microsoft.opentelemetry.a365.core.utils import safe_json_dumps, validate_and_normalize_ip
from microsoft.opentelemetry.a365.core.models.user_details import UserDetails
from microsoft.opentelemetry.a365.core.models.tool_call_schema import (
TOOL_CALL_SERIALIZATION_ERROR_JSON,
ExecuteToolCallArguments,
ExecuteToolCallResult,
serialize_tool_call_payload,
)
from microsoft.opentelemetry.a365.core.opentelemetry_scope import OpenTelemetryScope
from microsoft.opentelemetry.a365.core.request import Request
from microsoft.opentelemetry.a365.core.span_details import SpanDetails
from microsoft.opentelemetry.a365.core.tool_call_details import ToolCallDetails

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())


def _serialize_tool_payload(payload: ExecuteToolCallArguments | ExecuteToolCallResult | dict[str, object]) -> str:
"""Serialize an execute-tool payload without ever raising.

Typed payloads use the Agent365 schema contract; raw dictionaries keep their
existing ``safe_json_dumps`` behavior. Either way a payload that cannot be
serialized records the diagnostic payload instead of failing the span.
"""
if isinstance(payload, (ExecuteToolCallArguments, ExecuteToolCallResult)):
serialized = serialize_tool_call_payload(payload)
return serialized if serialized is not None else TOOL_CALL_SERIALIZATION_ERROR_JSON
try:
return safe_json_dumps(payload)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Failed to serialize execute tool payload: %r", exc)
return TOOL_CALL_SERIALIZATION_ERROR_JSON


class ExecuteToolScope(OpenTelemetryScope):
"""Provides OpenTelemetry tracing scope for AI tool execution operations."""
Expand Down Expand Up @@ -108,7 +136,11 @@ def __init__(

self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, tool_name)
if arguments is not None:
serialized = safe_json_dumps(arguments) if isinstance(arguments, dict) else arguments
serialized = (
_serialize_tool_payload(arguments)
if isinstance(arguments, (ExecuteToolCallArguments, dict))
else arguments
)
self.set_tag_maybe(GEN_AI_TOOL_ARGS_KEY, serialized)
self.set_tag_maybe(GEN_AI_TOOL_TYPE_KEY, tool_type)
self.set_tag_maybe(GEN_AI_TOOL_CALL_ID_KEY, tool_call_id)
Expand All @@ -135,15 +167,24 @@ def __init__(
validate_and_normalize_ip(user_details.user_client_ip),
)

def record_response(self, result: dict[str, object] | str) -> None: # pylint: disable=arguments-renamed
def record_response( # pylint: disable=arguments-renamed
self, result: ExecuteToolCallResult | dict[str, object] | str | None
) -> None:
"""Record the tool call result for telemetry tracking.

Per OTEL spec, the result is expected to be an object. If a string
is provided, it is recorded as-is (JSON string fallback). If a dict
is provided, it is serialized to JSON.
is provided, it is serialized to JSON. Typed
:class:`~microsoft.opentelemetry.a365.core.models.tool_call_schema.ExecuteToolCallResult`
payloads are serialized with the Agent365 schema contract; a payload that cannot
be serialized records the diagnostic payload instead of raising.

Args:
result: Tool call result as a structured dict or JSON string
result: Tool call result as a typed schema model, a structured dict,
a JSON string, or None to omit the attribute
"""
serialized = safe_json_dumps(result) if isinstance(result, dict) else result
if isinstance(result, (ExecuteToolCallResult, dict)):
serialized: str | None = _serialize_tool_payload(result)
else:
serialized = result
self.set_tag_maybe(GEN_AI_TOOL_CALL_RESULT_KEY, serialized)
Loading
Loading