Skip to content

Commit 901ea97

Browse files
committed
feat(streaming): tag streamed events with agent_path
Streamed lifecycle/text events carried only a task_id, so events from multiple agents sharing one task stream (e.g. child-workflow sub-agents emitting into the orchestrator's stream) were indistinguishable. Thread an optional agent_path (str | list[str]) end to end: - TaskMessage envelope carries it, so every start/delta/full/done event is attributable via parent_task_message. - ContextInterceptor propagates it workflow->activity via a new ContextVar + header, so the model activity tags live text/reasoning deltas. - TemporalStreamingHooks + stream_lifecycle_content + streaming service stamp it on tool/lifecycle events. Backward compatible: agent_path defaults to None (untagged) everywhere. NOTE: task_message.py is Stainless-generated; the agent_path field is a manual stopgap and must be mirrored in the upstream OpenAPI spec (.stats.yml openapi_spec_url) or the next regen will drop it.
1 parent 0252fcc commit 901ea97

7 files changed

Lines changed: 63 additions & 2 deletions

File tree

src/agentex/lib/adk/_modules/streaming.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def streaming_task_message_context(
5454
initial_content: TaskMessageContent,
5555
streaming_mode: StreamingMode = "coalesced",
5656
created_at: datetime | None = None,
57+
agent_path: str | list[str] | None = None,
5758
) -> StreamingTaskMessageContext:
5859
"""
5960
Create a streaming context for managing TaskMessage lifecycle.
@@ -86,4 +87,5 @@ def streaming_task_message_context(
8687
initial_content=initial_content,
8788
streaming_mode=streaming_mode,
8889
created_at=created_at,
90+
agent_path=agent_path,
8991
)

src/agentex/lib/core/services/adk/streaming.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,12 @@ def __init__(
379379
streaming_service: "StreamingService",
380380
streaming_mode: StreamingMode = "coalesced",
381381
created_at: datetime | None = None,
382+
agent_path: str | list[str] | None = None,
382383
):
383384
self.task_id = task_id
384385
self.initial_content = initial_content
385386
self.task_message: TaskMessage | None = None
387+
self.agent_path = agent_path
386388
self._agentex_client = agentex_client
387389
self._streaming_service = streaming_service
388390
self._is_closed = False
@@ -406,6 +408,11 @@ async def open(self) -> "StreamingTaskMessageContext":
406408
streaming_status="IN_PROGRESS",
407409
created_at=self._created_at if self._created_at is not None else omit,
408410
)
411+
# Stamped on the in-memory envelope only: every start/delta/full/done event
412+
# carries it via parent_task_message. Not sent to messages.create/update —
413+
# the persisted-message contract is unchanged; this is a stream-only tag.
414+
if self.agent_path is not None:
415+
self.task_message.agent_path = self.agent_path
409416

410417
# Send the START event
411418
start_event = StreamTaskMessageStart(
@@ -540,6 +547,7 @@ def streaming_task_message_context(
540547
initial_content: TaskMessageContent,
541548
streaming_mode: StreamingMode = "coalesced",
542549
created_at: datetime | None = None,
550+
agent_path: str | list[str] | None = None,
543551
) -> StreamingTaskMessageContext:
544552
return StreamingTaskMessageContext(
545553
task_id=task_id,
@@ -548,6 +556,7 @@ def streaming_task_message_context(
548556
streaming_service=self,
549557
streaming_mode=streaming_mode,
550558
created_at=created_at,
559+
agent_path=agent_path,
551560
)
552561

553562
async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None:

src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _deserialize_content(data: Dict[str, Any]):
3838
async def stream_lifecycle_content(
3939
task_id: str,
4040
content: Dict[str, Any],
41+
agent_path: str | list[str] | None = None,
4142
) -> None:
4243
"""Stream agent lifecycle content to the AgentEx UI.
4344
@@ -58,6 +59,9 @@ async def stream_lifecycle_content(
5859
- type="text": TextContent (plain text messages, handoff notifications)
5960
- type="tool_request": ToolRequestContent (tool invocation with call_id)
6061
- type="tool_response": ToolResponseContent (tool result with call_id)
62+
agent_path: Optional emitting-agent identifier stamped on the streamed
63+
envelope so consumers can attribute events when multiple agents share
64+
one task stream.
6165
6266
Note:
6367
This activity is non-blocking and will not throw exceptions to the workflow.
@@ -69,6 +73,7 @@ async def stream_lifecycle_content(
6973
async with adk.streaming.streaming_task_message_context(
7074
task_id=task_id,
7175
initial_content=typed_content,
76+
agent_path=agent_path,
7277
) as streaming_context:
7378
await streaming_context.stream_update(
7479
StreamTaskMessageFull(

src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async def on_agent_start(self, context, agent):
121121
emit_handoffs: Whether to stream the handoff text message
122122
trace_id: When set, tool calls are traced to SGP (input + output)
123123
parent_span_id: Parent span for the per-tool spans
124+
agent_path: Emitting-agent identifier stamped on streamed events
124125
"""
125126

126127
def __init__(
@@ -133,6 +134,7 @@ def __init__(
133134
emit_handoffs: bool = True,
134135
trace_id: str | None = None,
135136
parent_span_id: str | None = None,
137+
agent_path: str | list[str] | None = None,
136138
):
137139
"""Initialize the streaming hooks.
138140
@@ -158,6 +160,10 @@ def __init__(
158160
the tool) with the arguments as input and the result as output. When None,
159161
no tool spans are created (token-usage metrics still emit).
160162
parent_span_id: Parent span id the per-tool spans attach to.
163+
agent_path: Identifier of the agent these hooks stream for — a single
164+
id or a root->emitter path (e.g. ["researcher", "subagent-abc"]).
165+
Stamped on every emitted event so consumers can attribute it when
166+
multiple agents share one task stream. Defaults to None (untagged).
161167
"""
162168
super().__init__()
163169
self.task_id = task_id
@@ -167,6 +173,7 @@ def __init__(
167173
self.emit_handoffs = emit_handoffs
168174
self.trace_id = trace_id
169175
self.parent_span_id = parent_span_id
176+
self.agent_path = agent_path
170177
# tool_call_id -> open SGP span, so on_tool_end closes the right one.
171178
self._tool_spans: dict[str, Any] = {}
172179

@@ -247,6 +254,7 @@ async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: To
247254
name=tool.name,
248255
arguments=tool_arguments,
249256
).model_dump(),
257+
self.agent_path,
250258
],
251259
start_to_close_timeout=self.timeout,
252260
)
@@ -286,6 +294,7 @@ async def on_tool_end(
286294
name=tool.name,
287295
content=result,
288296
).model_dump(),
297+
self.agent_path,
289298
],
290299
start_to_close_timeout=self.timeout,
291300
)
@@ -320,6 +329,7 @@ async def on_handoff(
320329
content=f"Handoff from {from_agent.name} to {to_agent.name}",
321330
type="text",
322331
).model_dump(),
332+
self.agent_path,
323333
],
324334
start_to_close_timeout=self.timeout,
325335
)

src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
import logging
10-
from typing import Any, Type, Optional, override
10+
from typing import Any, List, Type, Union, Optional, override
1111
from contextvars import ContextVar
1212

1313
from temporalio import workflow
@@ -30,11 +30,13 @@
3030
streaming_task_id: ContextVar[Optional[str]] = ContextVar('streaming_task_id', default=None)
3131
streaming_trace_id: ContextVar[Optional[str]] = ContextVar('streaming_trace_id', default=None)
3232
streaming_parent_span_id: ContextVar[Optional[str]] = ContextVar('streaming_parent_span_id', default=None)
33+
streaming_agent_path: ContextVar[Optional[Union[str, List[str]]]] = ContextVar('streaming_agent_path', default=None)
3334

3435
# Header keys for passing context
3536
TASK_ID_HEADER = "context-task-id"
3637
TRACE_ID_HEADER = "context-trace-id"
3738
PARENT_SPAN_ID_HEADER = "context-parent-span-id"
39+
AGENT_PATH_HEADER = "context-agent-path"
3840

3941
class ContextInterceptor(Interceptor):
4042
"""Main interceptor that enables context threading through Temporal."""
@@ -90,6 +92,7 @@ def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle:
9092
task_id = getattr(workflow_instance, '_task_id', None)
9193
trace_id = getattr(workflow_instance, '_trace_id', None)
9294
parent_span_id = getattr(workflow_instance, '_parent_span_id', None)
95+
agent_path = getattr(workflow_instance, '_agent_path', None)
9396

9497
if task_id and trace_id and parent_span_id:
9598
if not input.headers:
@@ -101,6 +104,14 @@ def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle:
101104
logger.debug(f"[OutboundInterceptor] Added task_id, trace_id, and parent_span_id to activity headers: {task_id}, {trace_id}, {parent_span_id}")
102105
else:
103106
logger.warning("[OutboundInterceptor] No _task_id, _trace_id, or _parent_span_id found in workflow instance")
107+
108+
# Independent of the three-tuple gate: a child-workflow sub-agent sets
109+
# only _agent_path (task_id/trace/span come from the parent), and events
110+
# from any agent need it to be attributable in a shared task stream.
111+
if agent_path is not None:
112+
if not input.headers:
113+
input.headers = {}
114+
input.headers[AGENT_PATH_HEADER] = self._payload_converter.to_payload(agent_path) # type: ignore[index]
104115
except Exception as e:
105116
logger.error(f"[OutboundInterceptor] Failed to get task_id, trace_id, or parent_span_id from workflow instance: {e}")
106117

@@ -139,6 +150,15 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
139150
else:
140151
logger.debug("[ActivityInterceptor] No task_id, trace_id, or parent_span_id in headers")
141152

153+
# Threaded independently — present only when the emitting agent set it.
154+
if input.headers and AGENT_PATH_HEADER in input.headers:
155+
# Any (not str) — the value is str or list[str]; Any passes the decoded
156+
# JSON through untouched instead of coercing to one variant.
157+
agent_path_value = self._payload_converter.from_payload(
158+
input.headers[AGENT_PATH_HEADER], Any
159+
)
160+
streaming_agent_path.set(agent_path_value)
161+
142162
try:
143163
# Execute the activity
144164
# The TemporalStreamingModel can now read streaming_task_id.get()
@@ -149,5 +169,6 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
149169
streaming_task_id.set(None)
150170
streaming_trace_id.set(None)
151171
streaming_parent_span_id.set(None)
172+
streaming_agent_path.set(None)
152173
logger.debug("[ActivityInterceptor] Cleared task_id, trace_id, and parent_span_id from context")
153174

src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
7272
streaming_task_id,
7373
streaming_trace_id,
74+
streaming_agent_path,
7475
streaming_parent_span_id,
7576
)
7677

@@ -635,6 +636,7 @@ async def get_response(
635636
task_id = streaming_task_id.get()
636637
trace_id = streaming_trace_id.get()
637638
parent_span_id = streaming_parent_span_id.get()
639+
agent_path = streaming_agent_path.get()
638640

639641
if not task_id or not trace_id or not parent_span_id:
640642
raise ValueError("task_id, trace_id, and parent_span_id are required for streaming with Responses API")
@@ -843,6 +845,7 @@ async def get_response(
843845
style="active",
844846
),
845847
streaming_mode=self.streaming_mode,
848+
agent_path=agent_path,
846849
).__aenter__()
847850
elif item and getattr(item, 'type', None) == 'function_call':
848851
# Open a streaming context per function call so argument
@@ -859,6 +862,7 @@ async def get_response(
859862
arguments={},
860863
),
861864
streaming_mode=self.streaming_mode,
865+
agent_path=agent_path,
862866
).__aenter__()
863867
function_calls_in_progress[output_index] = {
864868
'id': getattr(item, 'id', ''),
@@ -879,6 +883,7 @@ async def get_response(
879883
format="markdown",
880884
),
881885
streaming_mode=self.streaming_mode,
886+
agent_path=agent_path,
882887
).__aenter__()
883888

884889
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):

src/agentex/types/task_message.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3-
from typing import Optional
3+
from typing import List, Union, Optional
44
from datetime import datetime
55
from typing_extensions import Literal
66

@@ -27,6 +27,15 @@ class TaskMessage(BaseModel):
2727
task_id: str
2828
"""ID of the task this message belongs to"""
2929

30+
# MANUAL PATCH — mirror into the upstream OpenAPI spec (see .stats.yml
31+
# openapi_spec_url) or the next Stainless regen drops it.
32+
agent_path: Optional[Union[str, List[str]]] = None
33+
"""Identifier of the agent that emitted this message.
34+
35+
A single agent id, or a root->emitter path (e.g. ["researcher", "subagent-abc"])
36+
when nested sub-agents share one task stream. Consumers group/filter events by it.
37+
"""
38+
3039
id: Optional[str] = None
3140
"""The task message's unique id"""
3241

0 commit comments

Comments
 (0)