diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 3b6c8d376..cce2cc73f 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -570,6 +570,43 @@ with ExecuteToolScope.start( scope.record_response(result) ``` +#### Explicit transfers + +Set `ToolCallDetails.transfer_details` to describe a tool that transfers control: + +```python +from microsoft.opentelemetry.a365.core import ( + AgentDetails, ToolCallDetails, TransferDetails, TransferMode, TransferTargetType, +) + +details = ToolCallDetails( + tool_name="handoff", + transfer_details=TransferDetails( + mode=TransferMode.PASS_CONTROL, + target_agent_details=AgentDetails(agent_id="target-agent", agent_name="Specialist"), + ), +) + +human_transfer = TransferDetails( + mode=TransferMode.RETURN_TO_CALLER, + target_type=TransferTargetType.HUMAN, +) +``` + +`TransferMode` supports `RETURN_TO_CALLER` (`return_to_caller`) and `PASS_CONTROL` +(`pass_control`). `TransferTargetType` supports `AGENT` (the default), `HUMAN`, +and `WORKFLOW`, emitted as lowercase tokens. Pass enum members, not raw strings. +Human and workflow targets cannot include `target_agent_details`. + +The scope records `microsoft.a365.transfer.mode` and +`microsoft.a365.transfer.target.type`. For agent targets, only ID, name, blueprint ID, +platform ID and version are emitted under `microsoft.a365.transfer.target.agent.*`. +The original `gen_ai.agent.*` attributes continue to identify the source agent; +target tenant, user, email and description fields are not copied. +No transfer attributes are recorded when `transfer_details` is omitted. + +#### Full endpoint URLs + For remote tools, pass a full endpoint URL to retain its path and query in `url.full`: ```python @@ -594,6 +631,8 @@ Do not pass URLs containing credentials, access tokens, personal data or other s sanitize them before constructing the endpoint. URL capture is not gated by `enable_sensitive_data`. +#### Typed tool payloads + `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee1f288f..f15334c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Release History # Unreleased +### Features Added +- Add handoff details to Agent365 tool spans, including the transfer mode and target agent, human, or workflow. + ([#309](https://github.com/microsoft/opentelemetry-distro-python/pull/309)) +- Record full endpoint URLs on Agent365 tool spans with `ServiceEndpoint.from_url()`. + ([#311](https://github.com/microsoft/opentelemetry-distro-python/pull/311)) + ### Breaking Changes - Agent365 offline delivery now reuses Azure Monitor's JSON-blob storage provider, file leases, Unix permission handling, and Windows ACL handling instead of a @@ -40,10 +46,6 @@ - Include the request's session ID on Agent365 tool and inference spans without requiring a baggage processor. ([#310](https://github.com/microsoft/opentelemetry-distro-python/pull/310)) -### Features Added -- Record full endpoint URLs on Agent365 tool spans with `ServiceEndpoint.from_url()`. - ([#311](https://github.com/microsoft/opentelemetry-distro-python/pull/311)) - ### Other Changes - Update the `AZURE_OPENAI_API_VERSION` fallback version in integration tests diff --git a/src/microsoft/opentelemetry/a365/core/__init__.py b/src/microsoft/opentelemetry/a365/core/__init__.py index 22d086132..00eb5733b 100644 --- a/src/microsoft/opentelemetry/a365/core/__init__.py +++ b/src/microsoft/opentelemetry/a365/core/__init__.py @@ -74,6 +74,7 @@ from microsoft.opentelemetry.a365.core.spans_scopes.output_scope import OutputScope from microsoft.opentelemetry.a365.core.tool_call_details import ToolCallDetails from microsoft.opentelemetry.a365.core.tool_type import ToolType +from microsoft.opentelemetry.a365.core.transfer_details import TransferDetails, TransferMode, TransferTargetType __all__ = [ # Base scope class @@ -98,6 +99,9 @@ "CallerDetails", "UserDetails", "ToolCallDetails", + "TransferDetails", + "TransferMode", + "TransferTargetType", "Channel", "Request", "Response", diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index ed118440a..53ad17398 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -116,6 +116,15 @@ GEN_AI_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result" GEN_AI_TOOL_TYPE_KEY = "gen_ai.tool.type" +# --- Explicit execute-tool transfers --- +TRANSFER_MODE_KEY = "microsoft.a365.transfer.mode" +TRANSFER_TARGET_TYPE_KEY = "microsoft.a365.transfer.target.type" +TRANSFER_TARGET_AGENT_ID_KEY = "microsoft.a365.transfer.target.agent.id" +TRANSFER_TARGET_AGENT_NAME_KEY = "microsoft.a365.transfer.target.agent.name" +TRANSFER_TARGET_AGENT_BLUEPRINT_ID_KEY = "microsoft.a365.transfer.target.agent.blueprint.id" +TRANSFER_TARGET_AGENT_PLATFORM_ID_KEY = "microsoft.a365.transfer.target.agent.platform.id" +TRANSFER_TARGET_AGENT_VERSION_KEY = "microsoft.a365.transfer.target.agent.version" + # --- Human caller --- USER_ID_KEY = "user.id" USER_NAME_KEY = "user.name" diff --git a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py index a65ef9480..b9531a3d5 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -21,6 +21,13 @@ SERVER_ADDRESS_KEY, SERVER_PORT_KEY, SESSION_ID_KEY, + TRANSFER_MODE_KEY, + TRANSFER_TARGET_TYPE_KEY, + TRANSFER_TARGET_AGENT_ID_KEY, + TRANSFER_TARGET_AGENT_NAME_KEY, + TRANSFER_TARGET_AGENT_BLUEPRINT_ID_KEY, + TRANSFER_TARGET_AGENT_PLATFORM_ID_KEY, + TRANSFER_TARGET_AGENT_VERSION_KEY, URL_FULL_KEY, USER_EMAIL_KEY, USER_ID_KEY, @@ -150,6 +157,18 @@ def __init__( self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) self.set_tag_maybe(SESSION_ID_KEY, request.session_id) + transfer = details.transfer_details + if transfer is not None: + self.set_tag_maybe(TRANSFER_MODE_KEY, transfer.mode.value) + self.set_tag_maybe(TRANSFER_TARGET_TYPE_KEY, transfer.target_type.value) + target = transfer.target_agent_details + if target is not None: + self.set_tag_maybe(TRANSFER_TARGET_AGENT_ID_KEY, target.agent_id) + self.set_tag_maybe(TRANSFER_TARGET_AGENT_NAME_KEY, target.agent_name) + self.set_tag_maybe(TRANSFER_TARGET_AGENT_BLUEPRINT_ID_KEY, target.agent_blueprint_id) + self.set_tag_maybe(TRANSFER_TARGET_AGENT_PLATFORM_ID_KEY, target.agent_platform_id) + self.set_tag_maybe(TRANSFER_TARGET_AGENT_VERSION_KEY, target.agent_version) + if endpoint: self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) self.set_tag_maybe(URL_FULL_KEY, endpoint.url) diff --git a/src/microsoft/opentelemetry/a365/core/tool_call_details.py b/src/microsoft/opentelemetry/a365/core/tool_call_details.py index d1a007e13..04868a7b9 100644 --- a/src/microsoft/opentelemetry/a365/core/tool_call_details.py +++ b/src/microsoft/opentelemetry/a365/core/tool_call_details.py @@ -9,6 +9,7 @@ from microsoft.opentelemetry.a365.core.models.tool_call_schema import ExecuteToolCallArguments from microsoft.opentelemetry.a365.core.models.service_endpoint import ServiceEndpoint +from microsoft.opentelemetry.a365.core.transfer_details import TransferDetails @dataclass @@ -21,3 +22,4 @@ class ToolCallDetails: description: str | None = None tool_type: str | None = None endpoint: ServiceEndpoint | None = None + transfer_details: TransferDetails | None = None diff --git a/src/microsoft/opentelemetry/a365/core/transfer_details.py b/src/microsoft/opentelemetry/a365/core/transfer_details.py new file mode 100644 index 000000000..9e8cc60af --- /dev/null +++ b/src/microsoft/opentelemetry/a365/core/transfer_details.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Explicit transfer metadata for execute-tool operations.""" + +from dataclasses import dataclass +from enum import Enum + +from microsoft.opentelemetry.a365.core.agent_details import AgentDetails + + +class TransferMode(str, Enum): + """How control passes to the transfer target.""" + + RETURN_TO_CALLER = "return_to_caller" + PASS_CONTROL = "pass_control" + + +class TransferTargetType(str, Enum): + """Kind of target receiving control.""" + + AGENT = "agent" + HUMAN = "human" + WORKFLOW = "workflow" + + +@dataclass(frozen=True) +class TransferDetails: + """Explicit transfer mode and optional target agent identity. + + Only the target agent's ID, name, blueprint ID, platform ID and version + are recorded. Agent details are not valid for human or workflow targets. + """ + + mode: TransferMode + target_type: TransferTargetType = TransferTargetType.AGENT + target_agent_details: AgentDetails | None = None + + def __post_init__(self) -> None: + if not isinstance(self.mode, TransferMode): + raise TypeError("mode must be a TransferMode member.") + if not isinstance(self.target_type, TransferTargetType): + raise TypeError("target_type must be a TransferTargetType member.") + if self.target_agent_details is not None and self.target_type != TransferTargetType.AGENT: + raise ValueError("Target agent details can only be supplied for an agent transfer.") diff --git a/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py index 821871c13..0a62d2ab5 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -262,6 +262,116 @@ def test_unserializable_typed_payloads_are_safe_when_telemetry_is_disabled(self) self.assertIsNone(raw_scope._span) +@pytest.fixture(name="transfer_tracer") +def _transfer_tracer(monkeypatch): + provider = TracerProvider() + monkeypatch.setenv("ENABLE_OBSERVABILITY", "true") + monkeypatch.setattr(ExecuteToolScope, "_tracer", provider.get_tracer("Agent365Sdk")) + yield + provider.shutdown() + + +@pytest.mark.parametrize("mode", ["RETURN_TO_CALLER", "PASS_CONTROL"]) +@pytest.mark.parametrize("target_type", ["AGENT", "HUMAN", "WORKFLOW"]) +def test_transfer_modes_and_target_types(transfer_tracer, mode, target_type): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode, TransferTargetType + + with ExecuteToolScope.start( + Request(), + ToolCallDetails( + tool_name="handoff", + transfer_details=TransferDetails(TransferMode[mode], TransferTargetType[target_type]), + ), + AgentDetails(agent_id="source-agent"), + ) as scope: + attrs = dict(scope._span.attributes) + assert attrs["microsoft.a365.transfer.mode"] == mode.lower() + assert attrs["microsoft.a365.transfer.target.type"] == target_type.lower() + assert not any(key.startswith("microsoft.a365.transfer.target.agent.") for key in attrs) + + +def test_transfer_records_only_supported_target_fields_and_preserves_source(transfer_tracer): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode + + target = AgentDetails( + agent_id="target-agent", + agent_name="Target", + agent_blueprint_id="target-blueprint", + agent_platform_id="target-platform", + agent_version="2", + tenant_id="target-tenant", + agentic_user_id="target-user", + agentic_user_email="target@example.test", + agent_description="not transferred", + ) + with ExecuteToolScope.start( + Request(), + ToolCallDetails( + tool_name="handoff", + transfer_details=TransferDetails(TransferMode.PASS_CONTROL, target_agent_details=target), + ), + AgentDetails(agent_id="source-agent", agent_name="Source", tenant_id="source-tenant"), + ) as scope: + attrs = dict(scope._span.attributes) + assert attrs["gen_ai.agent.id"] == "source-agent" + assert attrs["gen_ai.agent.name"] == "Source" + assert attrs["microsoft.tenant.id"] == "source-tenant" + assert attrs["microsoft.a365.transfer.target.type"] == "agent" + assert { + key.removeprefix("microsoft.a365.transfer.target.agent."): value + for key, value in attrs.items() + if key.startswith("microsoft.a365.transfer.target.agent.") + } == { + "id": "target-agent", + "name": "Target", + "blueprint.id": "target-blueprint", + "platform.id": "target-platform", + "version": "2", + } + + +@pytest.mark.parametrize("transfer_enabled", [False, True]) +def test_transfer_attributes_follow_explicit_opt_in(transfer_tracer, transfer_enabled): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode + + details = TransferDetails(TransferMode.RETURN_TO_CALLER) if transfer_enabled else None + with ExecuteToolScope.start( + Request(), ToolCallDetails(tool_name="ordinary_tool", transfer_details=details), AgentDetails("source") + ) as scope: + attrs = dict(scope._span.attributes) + assert any(key.startswith("microsoft.a365.transfer.") for key in attrs) == transfer_enabled + + +@pytest.mark.parametrize("target_type", ["HUMAN", "WORKFLOW"]) +def test_transfer_rejects_agent_details_for_non_agent_targets(target_type): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode, TransferTargetType + + with pytest.raises(ValueError, match="agent"): + TransferDetails(TransferMode.PASS_CONTROL, TransferTargetType[target_type], AgentDetails("target")) + + +@pytest.mark.parametrize("field", ["mode", "target_type"]) +def test_transfer_rejects_untyped_enum_values(field): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode, TransferTargetType + + values = {"mode": TransferMode.PASS_CONTROL, "target_type": TransferTargetType.AGENT} + values[field] = "invalid" + with pytest.raises(TypeError, match=field): + TransferDetails(**values) + + +def test_transfer_is_safe_with_telemetry_disabled(monkeypatch): + from microsoft.opentelemetry.a365.core import TransferDetails, TransferMode + + monkeypatch.setenv("ENABLE_OBSERVABILITY", "false") + with ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="handoff", transfer_details=TransferDetails(TransferMode.PASS_CONTROL)), + AgentDetails("source"), + ) as scope: + assert scope._span is None + + @pytest.mark.parametrize("session_id", ["session-123", None]) def test_execute_tool_records_request_session_without_a_baggage_processor(monkeypatch, session_id): provider = TracerProvider()