Skip to content
Open
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
7 changes: 7 additions & 0 deletions .chronus/changes/wujin-voice-tracing-v2-2026-8-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- azure-ai-agentserver-invocations
---

Added content-free Voice connection and callback tracing, application-declared target-turn spans, W3C propagation filtering, source-aware connection termination facts, and unsampled aggregate metrics.
22 changes: 22 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Release History

## 1.1.0b2 (2026-08-17)

### Features Added

- Added W3C Voice connection tracing with one `agentserver.connection` span,
content-free `voice.callback` dispatch spans, and aggregate duration and
propagation-failure metrics.
- Added application-declared target-turn tracing through
`Session.start_target_turn`, `TargetTurn.activate`, and explicit
`TargetTurn.complete` outcomes. The SDK does not infer response lifecycle or
own application tasks.
- Added source-aware `Session.termination` for classifying unfinished
application work during connection cleanup.

### Samples

- Updated `basic_voice_agent` to declare target turns around real background
generation work and report truthful response, timeout, cancellation,
end-call, and transport outcomes, with bounded per-connection concurrency and
retained model output. Unfinished turns now distinguish clean abandonment,
application/server errors, and protocol or transport loss.

## 1.1.0b1 (2026-08-11)

### Samples
Expand Down
86 changes: 72 additions & 14 deletions sdk/agentserver/azure-ai-agentserver-invocations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,13 +297,9 @@ app.run()
- Calls `await websocket.accept()` before invoking your handler.
- Runs WebSocket Ping/Pong keep-alive in the background — disabled by default; enable by setting the `WS_KEEPALIVE_INTERVAL` environment variable (auto-injected by AgentService into hosted-agent containers). Set the value to `0` to disable. Frames are sent at the WebSocket protocol layer (RFC 6455 opcode `0x9`/`0xA`) by the underlying Hypercorn server, which keeps the connection alive across upstream proxy / load-balancer idle timeouts without any extra application traffic.
- Closes the connection cleanly on handler return (close code `1000`) or maps an uncaught handler exception to close code `1011`.
- Emits a structured close-event log line carrying `azure.ai.agentserver.invocations_ws.session_id`, `azure.ai.agentserver.invocations_ws.close_code`, and `azure.ai.agentserver.invocations_ws.duration_ms`. The same fields are recorded as OpenTelemetry span attributes so the connection lifetime is visible end-to-end.
- Emits a structured close-event log line carrying `azure.ai.agentserver.invocations_ws.session_id`, `azure.ai.agentserver.invocations_ws.close_code`, and `azure.ai.agentserver.invocations_ws.duration_ms`.
- Inherits `/readiness`, OpenTelemetry export, graceful shutdown, and the `x-platform-server` identity header from `azure-ai-agentserver-core`.

### Per-connection tracing

A WebSocket connection is wrapped by the SDK in a single connection-scoped `websocket_session` OpenTelemetry span. The span carries the GenAI semantic-convention attributes plus `azure.ai.agentserver.invocations_ws.session_id`, `close_code`, and `duration_ms`. Any child spans your handler opens — e.g. via `opentelemetry.trace.get_tracer(...).start_as_current_span(...)` — are automatically parented to the connection span.

### Handler signature

The handler receives a Starlette [`WebSocket`][starlette-ws] and returns `None`. The full WebSocket API — `iter_text`, `iter_bytes`, `iter_json`, `send_text`, `send_bytes`, `send_json`, `close`, `headers`, `query_params`, `client`, `state` — is available, so application protocols on top of `invocations_ws` are entirely under your control.
Expand All @@ -317,6 +313,8 @@ The handler receives a Starlette [`WebSocket`][starlette-ws] and returns `None`.
send-only `Session`:

```python
import asyncio

from azure.ai.agentserver.invocations.voice import (
ResponseCreated,
ResponseDone,
Expand All @@ -325,6 +323,8 @@ from azure.ai.agentserver.invocations.voice import (
SessionReady,
SessionRejected,
SessionStart,
TargetTurnOrigin,
TargetTurnOutcome,
UserMessage,
VoiceAgentServerHost,
new_item_id,
Expand All @@ -349,17 +349,44 @@ async def on_session_start(session: Session, event: SessionStart) -> None:
async def on_user_message(session: Session, event: UserMessage) -> None:
response_id = new_response_id()
item_id = new_item_id()
await session.send(
ResponseCreated(response_id=response_id, in_reply_to=(event.item_id,))
)
await session.send(
ResponseOutputTextDone(
turn = session.start_target_turn(origin=TargetTurnOrigin.USER, input_count=1)
response_started = False
output_item_count = 0
try:
with turn.activate():
await session.send(
ResponseCreated(response_id=response_id, in_reply_to=(event.item_id,))
)
response_started = True
await session.send(
ResponseOutputTextDone(
response_id=response_id,
item_id=item_id,
text="Hello from the hosted text agent.",
)
)
output_item_count = 1
await session.send(ResponseDone(response_id=response_id))
turn.complete(
outcome=TargetTurnOutcome.RESPONSE,
response_id=response_id,
item_id=item_id,
text="Hello from the hosted text agent.",
output_item_count=1,
)
)
await session.send(ResponseDone(response_id=response_id))
except asyncio.CancelledError:
turn.complete(
outcome=TargetTurnOutcome.CANCELLED,
response_id=response_id if response_started else None,
output_item_count=output_item_count,
)
raise
except Exception:
if not turn.is_completed:
turn.complete(
outcome=TargetTurnOutcome.ERROR,
response_id=response_id if response_started else None,
output_item_count=output_item_count,
)
raise
```

The submodule is deliberately a thin typed event relay. It decodes one inbound frame,
Expand All @@ -368,6 +395,37 @@ serializes concurrent WebSocket writes. It does **not** own pending responses,
terminal arbitration, timeout/cancel operations, generation tasks, history, or
reconnect state.

### Voice tracing

The typed Voice endpoint extracts W3C context from each WebSocket upgrade and
creates one `agentserver.connection` span for the physical connection. Each
registered event dispatch creates a sibling `voice.callback` span. Application
code opts into a target-decision `invoke_agent` span by calling
`Session.start_target_turn`, activating the returned handle around all model,
tool, retrieval, and custom descendant work, and completing it once with the
application-known outcome.

```text
Hosted Agents invoke_agent
└── agentserver.connection
├── voice.callback
└── invoke_agent # only after start_target_turn(...)
└── customer model/tool spans
```

The SDK never discovers, retains, or awaits application tasks and never infers
response facts from `Session.send`. Applications that do not call
`start_target_turn` do not receive an automatic target `invoke_agent` span.
`Session.termination` exposes the first source-aware physical terminal fact to
`on_connection_terminating` so the application can classify unfinished work.

Voice propagation accepts only W3C trace context, the five correlation baggage
keys produced by Hosted Agents, and a bounded `x-request-id`. Transcript,
output, prompt, tool argument/result, and arbitrary baggage content are not
added to SDK spans, metrics, or diagnostics. Valid upstream sampling and
`tracestate` are preserved; unsampled operations still contribute aggregate
connection and target duration metrics.

When the peer or proxy closes the WebSocket, `@app.on_disconnect` receives a
local `SessionDisconnected` event. This callback represents only the observed
peer disconnect.
Expand Down
76 changes: 72 additions & 4 deletions sdk/agentserver/azure-ai-agentserver-invocations/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,11 +467,20 @@ namespace azure.ai.agentserver.invocations.voice

@experimental
class azure.ai.agentserver.invocations.voice.Session:
property termination: SessionTermination | None # Read-only

def __init__(self) -> None: ...

async def send(self, message: OutboundVoiceMessage) -> None: ...

def start_target_turn(
self,
*,
input_count: int,
origin: TargetTurnOrigin | str,
trigger_context: SpanContext | None = ...
) -> TargetTurn: ...


@experimental
@dataclass(frozen=True, kw_only=True, repr=False)
Expand Down Expand Up @@ -586,21 +595,73 @@ namespace azure.ai.agentserver.invocations.voice
def __hash__() -> None: ...

def __init__(
self,
*,
caller: Mapping[str, Any] | None = ...,
greeting: str | None = ...,
id: str,
ts: str,
no_input_timeout_ms: int | None = ...,
protocol_version: str,
reconnect: bool,
response_timeouts: ResponseTimeouts,
greeting: str | None = None,
no_input_timeout_ms: int | None = None,
caller: Mapping = None
ts: str
) -> None: ...

def __setattr__() -> None: ...

def _voice_model_repr(self: Any) -> str: ...


@experimental
class azure.ai.agentserver.invocations.voice.SessionTermination(str, Enum, metaclass=CaseInsensitiveEnumMeta):
ACCEPT_ERROR = "accept_error"
CALLBACK_ERROR = "callback_error"
CANCELLED = "cancelled"
COMPLETED = "completed"
INTERNAL_ERROR = "internal_error"
PROTOCOL_ERROR = "protocol_error"
TRANSPORT_ERROR = "transport_error"


@experimental
class azure.ai.agentserver.invocations.voice.TargetTurn:
property is_completed: bool # Read-only

def __init__(self) -> None: ...

def activate(self) -> ContextManager[None]: ...

def complete(
self,
*,
outcome: TargetTurnOutcome | str,
output_item_count: int | None = ...,
response_id: str | None = ...
) -> None: ...


@experimental
class azure.ai.agentserver.invocations.voice.TargetTurnOrigin(str, Enum, metaclass=CaseInsensitiveEnumMeta):
NO_INPUT = "no_input"
OTHER = "other"
PROACTIVE = "proactive"
RECOVERY = "recovery"
USER = "user"


@experimental
class azure.ai.agentserver.invocations.voice.TargetTurnOutcome(str, Enum, metaclass=CaseInsensitiveEnumMeta):
ABANDONED = "abandoned"
CANCELLED = "cancelled"
END_CALL = "end_call"
ERROR = "error"
NONE = "none"
OTHER = "other"
RESPONSE = "response"
TIMEOUT = "timeout"
TRANSPORT_ERROR = "transport_error"


@experimental
@dataclass(frozen=True, kw_only=True, repr=False)
class azure.ai.agentserver.invocations.voice.UserMessage(_InboundMessage):
Expand Down Expand Up @@ -680,6 +741,13 @@ namespace azure.ai.agentserver.invocations.voice
property routes: list[BaseRoute] # Read-only
property ws_ping_interval: float # Read-only

async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send
) -> None: ...

def __init__(
self,
*,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
apiMdSha256: 8c81198c94f9b6d95391cc58b11d3769ec0de8850d93a2c5e736046e82d25f63
apiMdSha256: e4e11fea9355277129dc5bea863e6c2252d8df0028b9e30dd2a29a53811389b1
parserVersion: 0.3.31
pythonVersion: 3.11.15
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# ---------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------
from azure.ai.agentserver.core._platform_headers import SESSION_ID as _SESSION_ID # pylint: disable=import-error,no-name-in-module
from azure.ai.agentserver.core._platform_headers import (
SESSION_ID as _SESSION_ID,
) # pylint: disable=import-error,no-name-in-module


class InvocationConstants:
Expand Down Expand Up @@ -35,10 +37,21 @@ class InvocationsWSConstants:

# Close codes (RFC 6455)
CLOSE_NORMAL = 1000 # handler returned cleanly
CLOSE_GOING_AWAY = 1001 # peer is leaving or restarting
CLOSE_INTERNAL_ERROR = 1011 # handler raised an unhandled exception
NORMAL_CLOSE_CODES = frozenset({CLOSE_NORMAL, CLOSE_GOING_AWAY})
PROTOCOL_CLOSE_CODES = frozenset({1002, 1003, 1007, 1008, 1009, 1010})

# Structured-log ``extra`` keys.
ATTR_SPAN_SESSION_ID = "azure.ai.agentserver.invocations_ws.session_id"
ATTR_SPAN_CLOSE_CODE = "azure.ai.agentserver.invocations_ws.close_code"
ATTR_SPAN_DURATION_MS = "azure.ai.agentserver.invocations_ws.duration_ms"
ATTR_SPAN_ERROR_CODE = "azure.ai.agentserver.invocations_ws.error.code"


def _classify_websocket_close_code(close_code: int) -> str:
if close_code in InvocationsWSConstants.NORMAL_CLOSE_CODES:
return "completed"
if close_code in InvocationsWSConstants.PROTOCOL_CLOSE_CODES:
return "protocol_error"
return "transport_error"
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------

VERSION = "1.1.0b1"
VERSION = "1.1.0b2"
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
new_message_id,
new_response_id,
)
from ._session import Session
from ._session import Session, SessionTermination
from ._turn import TargetTurn, TargetTurnOrigin, TargetTurnOutcome
from ._voice_host import (
BargeInCallback,
ConnectionTerminatingCallback,
Expand Down Expand Up @@ -85,6 +86,10 @@
"SessionRejected",
"SessionStart",
"SessionStartCallback",
"SessionTermination",
"TargetTurn",
"TargetTurnOrigin",
"TargetTurnOutcome",
"UserMessage",
"UserMessageCallback",
"UserNoInput",
Expand Down
Loading