11from __future__ import annotations
22
3- from typing import Any
3+ from typing import Any , Iterator
44from datetime import timedelta
5+ from contextlib import contextmanager
56
67from agentex .types .task import Task
78from agentex .types .agent import Agent
1314from agentex .lib .core .clients .temporal .temporal_client import TemporalClient
1415
1516
17+ @contextmanager
18+ def _acp_dispatch_span (name : str ) -> Iterator [None ]:
19+ """Wrap an ACP -> Temporal dispatch (start_workflow / signal) in an OTel span.
20+
21+ The Temporal OpenTelemetry interceptor propagates trace context by injecting
22+ the CURRENTLY ACTIVE span into the Temporal message headers on the caller
23+ side (``start_workflow`` / ``signal_workflow``); the worker then extracts it
24+ and roots the workflow / activity spans under it. But the ACP server dispatches
25+ from a bare async handler with no active span, so nothing is injected and the
26+ workflow's activities become DETACHED trace roots -- the business work shows up
27+ in Tempo as a fresh trace with no link back to the ``task/create`` /
28+ ``event/send`` that triggered it.
29+
30+ Opening a span here gives the interceptor something to inject. It becomes a
31+ child of the ingress request span when one is active (front-of-request
32+ propagation), or a fresh per-turn root otherwise. Fail-open: never raises if
33+ OpenTelemetry isn't importable.
34+ """
35+ try :
36+ from opentelemetry import trace as _otel_trace
37+ except Exception : # pragma: no cover - obs must never break a dispatch
38+ yield
39+ return
40+ tracer = _otel_trace .get_tracer ("agentex.acp" )
41+ with tracer .start_as_current_span (name , kind = _otel_trace .SpanKind .PRODUCER ):
42+ yield
43+
44+
1645class TemporalTaskService :
1746 """
1847 Submits Agent agent_tasks to the async runtime for execution.
@@ -26,7 +55,6 @@ def __init__(
2655 self ._temporal_client = temporal_client
2756 self ._env_vars = env_vars
2857
29-
3058 async def submit_task (self , agent : Agent , task : Task , params : dict [str , Any ] | None ) -> str :
3159 """
3260 Submit a task to the async runtime for execution.
@@ -37,22 +65,19 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
3765 # indefinitely, which long-lived chat/session agents rely on). A positive
3866 # value bounds the whole continue-as-new chain's wall-clock lifetime.
3967 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- )
68+ execution_timeout = timedelta (seconds = timeout_seconds ) if timeout_seconds and timeout_seconds > 0 else None
69+ with _acp_dispatch_span (f"acp.task_create:{ task .id } " ):
70+ return await self ._temporal_client .start_workflow (
71+ workflow = self ._env_vars .WORKFLOW_NAME ,
72+ arg = CreateTaskParams (
73+ agent = agent ,
74+ task = task ,
75+ params = params ,
76+ ),
77+ id = task .id ,
78+ task_queue = self ._env_vars .WORKFLOW_TASK_QUEUE ,
79+ execution_timeout = execution_timeout ,
80+ )
5681
5782 async def get_state (self , task_id : str ) -> WorkflowState :
5883 """
@@ -63,16 +88,17 @@ async def get_state(self, task_id: str) -> WorkflowState:
6388 )
6489
6590 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- )
91+ with _acp_dispatch_span (f"acp.event_send:{ task .id } " ):
92+ return await self ._temporal_client .send_signal (
93+ workflow_id = task .id ,
94+ signal = SignalName .RECEIVE_EVENT .value ,
95+ payload = SendEventParams (
96+ agent = agent ,
97+ task = task ,
98+ event = event ,
99+ request = request ,
100+ ).model_dump (),
101+ )
76102
77103 async def interrupt (self , agent : Agent , task : Task , request : dict | None = None ) -> None :
78104 """Forward a task/interrupt to the running workflow as a dedicated signal.
0 commit comments