From bf9fb4ed6c6f37ee0fdd21128cacf3027c0af03d Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 28 Oct 2026 09:46:32 -0600 Subject: [PATCH 1/3] Add explicit Agent365 execute-tool transfers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfa03945-cb0e-4471-ba78-5980a3098d0b --- A365_DOCUMENTATION.md | 37 ++++++ CHANGELOG.md | 4 + .../opentelemetry/a365/core/__init__.py | 4 + .../opentelemetry/a365/core/constants.py | 9 ++ .../a365/core/execute_tool_scope.py | 19 +++ .../a365/core/tool_call_details.py | 2 + .../a365/core/transfer_details.py | 45 +++++++ tests/a365/test_execute_tool_scope.py | 111 ++++++++++++++++++ 8 files changed, 231 insertions(+) create mode 100644 src/microsoft/opentelemetry/a365/core/transfer_details.py diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 4e4489694..3afd21c54 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -470,6 +470,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. + +#### 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 1564ad0fc..746cd1c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Release History # Unreleased +### Features Added +- Add typed Agent365 execute-tool transfers with explicit control modes, agent/human/workflow + targets, and separate target-agent attributes (microsoft/opentelemetry-distro-dotnet#157). + ### 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 d1b5cc5b3..d74049e7b 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 72593d26a..7979cfae8 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -20,6 +20,13 @@ GEN_AI_TOOL_TYPE_KEY, SERVER_ADDRESS_KEY, SERVER_PORT_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, USER_EMAIL_KEY, USER_ID_KEY, USER_NAME_KEY, @@ -147,6 +154,18 @@ def __init__( self.set_tag_maybe(GEN_AI_TOOL_DESCRIPTION_KEY, description) self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_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) if endpoint.port and endpoint.port != 443: 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 da18c4b6b..9271db479 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -6,6 +6,7 @@ import unittest from unittest.mock import patch +import pytest from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -259,3 +260,113 @@ def test_unserializable_typed_payloads_are_safe_when_telemetry_is_disabled(self) self.assertIsNone(scope._span) 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 From 07279a98d6662bdfdb0cda5fd1bdf53212ae1aa2 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 28 Oct 2026 16:03:22 -0600 Subject: [PATCH 2/3] Clarify transfer changelog and link Python PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfa03945-cb0e-4471-ba78-5980a3098d0b --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4a50240..e40dd0f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ # Unreleased ### Features Added -- Add typed Agent365 execute-tool transfers with explicit control modes, agent/human/workflow - targets, and separate target-agent attributes (microsoft/opentelemetry-distro-dotnet#157). +- 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)) ### Breaking Changes - Agent365 offline delivery now reuses Azure Monitor's JSON-blob storage provider, From 4f8667bef6626a02adec558a21be9350feedf7e8 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 10:41:39 -0600 Subject: [PATCH 3/3] Use Chat Completions for Agent Framework integration tests Select the released OpenAIChatCompletionClient instead of the Responses client and pass the configured Azure API version explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14661e89-86a3-4afe-a454-263eaeeba021 --- .../test_agentframework_trace_processor.py | 8 +++++--- .../integration/agentframework/test_message_format.py | 11 ++++++----- .../agentframework/test_observability_pipeline.py | 11 ++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/a365/integration/agentframework/test_agentframework_trace_processor.py b/tests/a365/integration/agentframework/test_agentframework_trace_processor.py index 9710d349b..403014782 100644 --- a/tests/a365/integration/agentframework/test_agentframework_trace_processor.py +++ b/tests/a365/integration/agentframework/test_agentframework_trace_processor.py @@ -19,7 +19,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool as ai_function - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError as _e: pytest.skip( f"AgentFramework library and dependencies required for integration tests: {_e}", @@ -47,10 +47,11 @@ def test_agentframework_trace_processor_integration(self, distro_exporter, azure """Test AgentFramework trace processor with real Azure OpenAI call.""" # Create Azure OpenAI ChatClient - chat_client = OpenAIChatClient( + chat_client = OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) # Create agent framework agent @@ -93,10 +94,11 @@ def test_agentframework_trace_processor_with_tool_calls( """Test AgentFramework trace processor with tool calls.""" # Create Azure OpenAI ChatClient - chat_client = OpenAIChatClient( + chat_client = OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) # Create agent framework agent diff --git a/tests/a365/integration/agentframework/test_message_format.py b/tests/a365/integration/agentframework/test_message_format.py index 8a27f49fe..d279a0cea 100644 --- a/tests/a365/integration/agentframework/test_message_format.py +++ b/tests/a365/integration/agentframework/test_message_format.py @@ -21,7 +21,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError: pytest.skip( "AgentFramework library and dependencies required for integration tests", @@ -54,12 +54,13 @@ class TestAgentFrameworkMessageFormat: and verify the A365 structured message format.""" @pytest.fixture - def chat_client(self, azure_openai_config: dict[str, Any]) -> OpenAIChatClient: + def chat_client(self, azure_openai_config: dict[str, Any]) -> OpenAIChatCompletionClient: """Create a real Azure OpenAI chat client.""" - return OpenAIChatClient( + return OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) def _find_chat_spans(self, distro_exporter) -> list[ReadableSpan]: @@ -72,7 +73,7 @@ def _find_chat_spans(self, distro_exporter) -> list[ReadableSpan]: return [s for s in distro_exporter.spans if s.attributes and GEN_AI_INPUT_MESSAGES_KEY in s.attributes] @pytest.mark.asyncio - async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: OpenAIChatClient) -> None: + async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: OpenAIChatCompletionClient) -> None: """Simple chat: verify exported spans contain structured A365 messages after enrichment (no manual mapper call).""" agent = RawAgent( @@ -124,7 +125,7 @@ async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: O print(f"\n=== Enriched output ===\n{json.dumps(output_data, indent=2)}") @pytest.mark.asyncio - async def test_tool_call_message_mapping(self, distro_exporter, chat_client: OpenAIChatClient) -> None: + async def test_tool_call_message_mapping(self, distro_exporter, chat_client: OpenAIChatCompletionClient) -> None: """Tool-calling chat: verify tool_call and tool_call_response parts survive enrichment in exported spans.""" agent = RawAgent( diff --git a/tests/a365/integration/agentframework/test_observability_pipeline.py b/tests/a365/integration/agentframework/test_observability_pipeline.py index a00e17821..d6aa2a463 100644 --- a/tests/a365/integration/agentframework/test_observability_pipeline.py +++ b/tests/a365/integration/agentframework/test_observability_pipeline.py @@ -23,7 +23,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool as ai_function - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError: pytest.skip( "AgentFramework library and dependencies required for integration tests", @@ -87,12 +87,13 @@ class TestAgentFrameworkObservabilityPipeline: """ @pytest.fixture - def chat_client(self, azure_openai_config: dict) -> OpenAIChatClient: + def chat_client(self, azure_openai_config: dict) -> OpenAIChatCompletionClient: """Create a real Azure OpenAI chat client.""" - return OpenAIChatClient( + return OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) @pytest.fixture @@ -119,7 +120,7 @@ def _flush_and_collect(self, distro_exporter) -> list[ReadableSpan]: async def test_pipeline_invoke_agent_with_tool_call( # pylint: disable=too-many-statements self, distro_exporter, - chat_client: OpenAIChatClient, + chat_client: OpenAIChatCompletionClient, agent_details: AgentDetails, agent365_config: dict, ) -> None: @@ -262,7 +263,7 @@ async def test_pipeline_invoke_agent_with_tool_call( # pylint: disable=too-many async def test_pipeline_invoke_agent_simple_inference( self, distro_exporter, - chat_client: OpenAIChatClient, + chat_client: OpenAIChatCompletionClient, agent_details: AgentDetails, agent365_config: dict, ) -> None: