Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions A365_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/microsoft/opentelemetry/a365/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -98,6 +99,9 @@
"CallerDetails",
"UserDetails",
"ToolCallDetails",
"TransferDetails",
"TransferMode",
"TransferTargetType",
"Channel",
"Request",
"Response",
Expand Down
9 changes: 9 additions & 0 deletions src/microsoft/opentelemetry/a365/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions src/microsoft/opentelemetry/a365/core/execute_tool_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/microsoft/opentelemetry/a365/core/tool_call_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,3 +22,4 @@ class ToolCallDetails:
description: str | None = None
tool_type: str | None = None
endpoint: ServiceEndpoint | None = None
transfer_details: TransferDetails | None = None
45 changes: 45 additions & 0 deletions src/microsoft/opentelemetry/a365/core/transfer_details.py
Original file line number Diff line number Diff line change
@@ -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.")
110 changes: 110 additions & 0 deletions tests/a365/test_execute_tool_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading