11from __future__ import annotations
22
3+ import sys
34from typing import Any
45from datetime import timedelta
6+ from contextlib import contextmanager
7+ from collections .abc import Iterator
58
69from agentex .types .task import Task
710from agentex .types .agent import Agent
1316from agentex .lib .core .clients .temporal .temporal_client import TemporalClient
1417
1518
19+ @contextmanager
20+ def _acp_dispatch_span (name : str , task_id : str | None = None ) -> Iterator [None ]:
21+ """Wrap an ACP -> Temporal dispatch (start_workflow / signal) in an OTel span.
22+
23+ The Temporal OpenTelemetry interceptor propagates trace context by injecting
24+ the CURRENTLY ACTIVE span into the Temporal message headers on the caller
25+ side (``start_workflow`` / ``signal_workflow``); the worker then extracts it
26+ and roots the workflow / activity spans under it. But the ACP server dispatches
27+ from a bare async handler with no active span, so nothing is injected and the
28+ workflow's activities become DETACHED trace roots -- the business work shows up
29+ in Tempo as a fresh trace with no link back to the ``task/create`` /
30+ ``event/send`` that triggered it.
31+
32+ Opening a span here gives the interceptor something to inject. It becomes a
33+ child of the ingress request span when one is active (front-of-request
34+ propagation), or a fresh per-turn root otherwise.
35+
36+ Fail-open across the WHOLE obs setup, not just the import: ``get_tracer`` and
37+ entering ``start_as_current_span`` run the sampler and every
38+ ``SpanProcessor.on_start`` (the SDK does not guard those), so a broken
39+ provider or a custom sampler/processor that raises would otherwise fail the
40+ dispatch itself. If any of it fails we run the dispatch untraced. The dispatch
41+ body (the ``yield``) is OUTSIDE the guard so its exceptions still propagate.
42+ """
43+ span_cm = None
44+ try :
45+ from opentelemetry import trace as _otel_trace
46+
47+ tracer = _otel_trace .get_tracer ("agentex.acp" )
48+ # task_id goes on an attribute, NOT in the span name: a per-task span name is
49+ # high-cardinality and breaks span-name aggregation in Tempo.
50+ attributes = {"agentex.task_id" : task_id } if task_id else None
51+ span_cm = tracer .start_as_current_span (name , kind = _otel_trace .SpanKind .PRODUCER , attributes = attributes )
52+ span_cm .__enter__ ()
53+ except Exception : # pragma: no cover - obs must never break a dispatch
54+ span_cm = None
55+
56+ try :
57+ yield
58+ finally :
59+ if span_cm is not None :
60+ # Pass exc info so the span reflects a failed dispatch; guard __exit__
61+ # so closing the span can never mask the dispatch outcome.
62+ try :
63+ span_cm .__exit__ (* sys .exc_info ())
64+ except Exception : # pragma: no cover - best-effort close
65+ pass
66+
67+
1668class TemporalTaskService :
1769 """
1870 Submits Agent agent_tasks to the async runtime for execution.
@@ -26,7 +78,6 @@ def __init__(
2678 self ._temporal_client = temporal_client
2779 self ._env_vars = env_vars
2880
29-
3081 async def submit_task (self , agent : Agent , task : Task , params : dict [str , Any ] | None ) -> str :
3182 """
3283 Submit a task to the async runtime for execution.
@@ -37,22 +88,19 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
3788 # indefinitely, which long-lived chat/session agents rely on). A positive
3889 # value bounds the whole continue-as-new chain's wall-clock lifetime.
3990 timeout_seconds = self ._env_vars .WORKFLOW_EXECUTION_TIMEOUT_SECONDS
40- execution_timeout = (
41- timedelta (seconds = timeout_seconds )
42- if timeout_seconds and timeout_seconds > 0
43- else None
44- )
45- return await self ._temporal_client .start_workflow (
46- workflow = self ._env_vars .WORKFLOW_NAME ,
47- arg = CreateTaskParams (
48- agent = agent ,
49- task = task ,
50- params = params ,
51- ),
52- id = task .id ,
53- task_queue = self ._env_vars .WORKFLOW_TASK_QUEUE ,
54- execution_timeout = execution_timeout ,
55- )
91+ execution_timeout = timedelta (seconds = timeout_seconds ) if timeout_seconds and timeout_seconds > 0 else None
92+ with _acp_dispatch_span ("acp.task_create" , task_id = task .id ):
93+ return await self ._temporal_client .start_workflow (
94+ workflow = self ._env_vars .WORKFLOW_NAME ,
95+ arg = CreateTaskParams (
96+ agent = agent ,
97+ task = task ,
98+ params = params ,
99+ ),
100+ id = task .id ,
101+ task_queue = self ._env_vars .WORKFLOW_TASK_QUEUE ,
102+ execution_timeout = execution_timeout ,
103+ )
56104
57105 async def get_state (self , task_id : str ) -> WorkflowState :
58106 """
@@ -63,16 +111,17 @@ async def get_state(self, task_id: str) -> WorkflowState:
63111 )
64112
65113 async def send_event (self , agent : Agent , task : Task , event : Event , request : dict | None = None ) -> None :
66- return await self ._temporal_client .send_signal (
67- workflow_id = task .id ,
68- signal = SignalName .RECEIVE_EVENT .value ,
69- payload = SendEventParams (
70- agent = agent ,
71- task = task ,
72- event = event ,
73- request = request ,
74- ).model_dump (),
75- )
114+ with _acp_dispatch_span ("acp.event_send" , task_id = task .id ):
115+ return await self ._temporal_client .send_signal (
116+ workflow_id = task .id ,
117+ signal = SignalName .RECEIVE_EVENT .value ,
118+ payload = SendEventParams (
119+ agent = agent ,
120+ task = task ,
121+ event = event ,
122+ request = request ,
123+ ).model_dump (),
124+ )
76125
77126 async def interrupt (self , agent : Agent , task : Task , request : dict | None = None ) -> None :
78127 """Forward a task/interrupt to the running workflow as a dedicated signal.
0 commit comments