Skip to content
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
lint:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -44,7 +44,7 @@ jobs:
permissions:
contents: read
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand Down Expand Up @@ -87,7 +87,7 @@ jobs:
test:
timeout-minutes: 10
name: test
runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 75
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-b2df5f506330ad5fba5a0d518ab8a4bcf876e8c3684a4fe0d0cc5102fd9c569e.yml
openapi_spec_hash: 132e9efdb0535d9594abadf799431cf5
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-644a4ec06aa1f055c614cbef3379684819a4edd84eeb20d2fb29ae01663622a3.yml
openapi_spec_hash: a6a4dc0c09691ac9783bf38e9653a464
config_hash: 593e89b291976a5e84e4c3c3f8324354
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,28 @@

logger = make_logger(__name__)

# These reference MCP servers still import the mcp 1.x API (``McpError``), which
# mcp 2.0.0 renamed to ``MCPError``. uvx gives each server its own isolated env and
# resolves ``mcp`` unpinned there, ignoring the version this project pins, so without
# this constraint every server dies at import and the agent silently makes zero tool
# calls. Drop the pin once the servers support mcp 2.x.
_MCP_PIN = ["--with", "mcp<2"]

MCP_SERVERS = [
StdioServerParameters(
command="uvx",
args=["mcp-server-time", "--local-timezone", "America/Los_Angeles"],
args=[*_MCP_PIN, "mcp-server-time", "--local-timezone", "America/Los_Angeles"],
),
StdioServerParameters(
command="uvx",
args=["openai-websearch-mcp"],
args=[*_MCP_PIN, "openai-websearch-mcp"],
env={
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "")
}
),
StdioServerParameters(
command="uvx",
args=["mcp-server-fetch"],
args=[*_MCP_PIN, "mcp-server-fetch"],
),
]

Expand Down
47 changes: 43 additions & 4 deletions src/agentex/lib/adk/_modules/_codex_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@
a synthetic ToolRequestContent Full is emitted before the response.
mcp_tool_call -> same as command_execution
web_search -> same as command_execution
todo_list -> same as command_execution
todo_list -> same as command_execution, plus:
item.updated -> StreamTaskMessageFull(ToolResponseContent)
Codex ticks one in-place todo_list item through
item.updated; each revision is republished under
the same tool_call_id so consumers can render the
checklist filling in rather than jumping to its
final state at end of turn.
collab_tool_call -> same as command_execution
error (item type) -> StreamTaskMessageFull(TextContent, error text) on completed only

Expand All @@ -75,9 +81,10 @@
without this module needing to know about spans.
item.updated (reasoning): the intermediate cumulative text is discarded;
only item.completed carries the final text.
item.updated (tool): tool item types other than agent_message do not
emit updates; item.started opens the request and
item.completed closes it.
item.updated (tool): only todo_list is republished (see above). For the
other tool item types item.started opens the request
and item.completed closes it; any updates in between
carry no state a consumer could act on.
"""

from __future__ import annotations
Expand Down Expand Up @@ -112,6 +119,11 @@ def _truncate(text: str, max_len: int = _MAX_RESULT_LENGTH) -> str:
return str(text)[:max_len]


# Tool items codex revises in place rather than reopening. Their item.updated
# events carry real intermediate state, so they are forwarded as responses.
_PROGRESSIVE_TOOL_ITEMS = frozenset({"todo_list"})


def _tool_name_for(item_type: str, payload: dict[str, Any]) -> str:
"""Derive a canonical tool name from a codex item type."""
if item_type == "command_execution":
Expand Down Expand Up @@ -467,6 +479,33 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
)
out.append(StreamTaskMessageDone(type="done", index=req_idx))

elif evt_type == "item.updated" and item_type in _PROGRESSIVE_TOOL_ITEMS:
# Codex revises its plan in place: one todo_list item is opened
# at the start of the turn and ticked off through item.updated,
# with item.completed only arriving at the very end. Forwarding
# each revision as a response for the SAME tool_call_id lets a
# consumer show the checklist filling in as the turn runs; the
# last response received is the current state.
if item_id in self._tool_open:
actual_type = self._tool_item_types.get(item_id, item_type)
result_text, is_error = _tool_output_for(actual_type, item)
resp_content: dict[str, Any] = {"result": result_text}
if is_error:
resp_content["is_error"] = True
out.append(
StreamTaskMessageFull(
type="full",
index=self._alloc(),
content=ToolResponseContent(
type="tool_response",
author="agent",
tool_call_id=tool_call_id,
name=_tool_name_for(actual_type, item),
content=resp_content,
),
)
)

elif evt_type == "item.completed":
# file_change items may only emit item.completed (no started).
if item_id not in self._tool_open:
Expand Down
2 changes: 2 additions & 0 deletions src/agentex/lib/adk/_modules/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def streaming_task_message_context(
initial_content: TaskMessageContent,
streaming_mode: StreamingMode = "coalesced",
created_at: datetime | None = None,
agent_path: str | list[str] | None = None,
) -> StreamingTaskMessageContext:
"""
Create a streaming context for managing TaskMessage lifecycle.
Expand Down Expand Up @@ -86,4 +87,5 @@ def streaming_task_message_context(
initial_content=initial_content,
streaming_mode=streaming_mode,
created_at=created_at,
agent_path=agent_path,
)
9 changes: 9 additions & 0 deletions src/agentex/lib/core/services/adk/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,12 @@ def __init__(
streaming_service: "StreamingService",
streaming_mode: StreamingMode = "coalesced",
created_at: datetime | None = None,
agent_path: str | list[str] | None = None,
):
self.task_id = task_id
self.initial_content = initial_content
self.task_message: TaskMessage | None = None
self.agent_path = agent_path
self._agentex_client = agentex_client
self._streaming_service = streaming_service
self._is_closed = False
Expand All @@ -406,6 +408,11 @@ async def open(self) -> "StreamingTaskMessageContext":
streaming_status="IN_PROGRESS",
created_at=self._created_at if self._created_at is not None else omit,
)
# Stamped on the in-memory envelope only: every start/delta/full/done event
# carries it via parent_task_message. Not sent to messages.create/update —
# the persisted-message contract is unchanged; this is a stream-only tag.
if self.agent_path is not None:
self.task_message.agent_path = self.agent_path

# Send the START event
start_event = StreamTaskMessageStart(
Expand Down Expand Up @@ -540,6 +547,7 @@ def streaming_task_message_context(
initial_content: TaskMessageContent,
streaming_mode: StreamingMode = "coalesced",
created_at: datetime | None = None,
agent_path: str | list[str] | None = None,
) -> StreamingTaskMessageContext:
return StreamingTaskMessageContext(
task_id=task_id,
Expand All @@ -548,6 +556,7 @@ def streaming_task_message_context(
streaming_service=self,
streaming_mode=streaming_mode,
created_at=created_at,
agent_path=agent_path,
)

async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
to the AgentEx UI, designed to work with TemporalStreamingHooks.
"""

from __future__ import annotations

from typing import Any, Dict

from temporalio import activity
Expand Down Expand Up @@ -38,6 +40,7 @@ def _deserialize_content(data: Dict[str, Any]):
async def stream_lifecycle_content(
task_id: str,
content: Dict[str, Any],
agent_path: str | list[str] | None = None,
) -> None:
"""Stream agent lifecycle content to the AgentEx UI.

Expand All @@ -58,6 +61,9 @@ async def stream_lifecycle_content(
- type="text": TextContent (plain text messages, handoff notifications)
- type="tool_request": ToolRequestContent (tool invocation with call_id)
- type="tool_response": ToolResponseContent (tool result with call_id)
agent_path: Optional emitting-agent identifier stamped on the streamed
envelope so consumers can attribute events when multiple agents share
one task stream.

Note:
This activity is non-blocking and will not throw exceptions to the workflow.
Expand All @@ -69,6 +75,7 @@ async def stream_lifecycle_content(
async with adk.streaming.streaming_task_message_context(
task_id=task_id,
initial_content=typed_content,
agent_path=agent_path,
) as streaming_context:
await streaming_context.stream_update(
StreamTaskMessageFull(
Expand Down
10 changes: 10 additions & 0 deletions src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async def on_agent_start(self, context, agent):
emit_handoffs: Whether to stream the handoff text message
trace_id: When set, tool calls are traced to SGP (input + output)
parent_span_id: Parent span for the per-tool spans
agent_path: Emitting-agent identifier stamped on streamed events
"""

def __init__(
Expand All @@ -133,6 +134,7 @@ def __init__(
emit_handoffs: bool = True,
trace_id: str | None = None,
parent_span_id: str | None = None,
agent_path: str | list[str] | None = None,
):
"""Initialize the streaming hooks.

Expand All @@ -158,6 +160,10 @@ def __init__(
the tool) with the arguments as input and the result as output. When None,
no tool spans are created (token-usage metrics still emit).
parent_span_id: Parent span id the per-tool spans attach to.
agent_path: Identifier of the agent these hooks stream for — a single
id or a root->emitter path (e.g. ["researcher", "subagent-abc"]).
Stamped on every emitted event so consumers can attribute it when
multiple agents share one task stream. Defaults to None (untagged).
"""
super().__init__()
self.task_id = task_id
Expand All @@ -167,6 +173,7 @@ def __init__(
self.emit_handoffs = emit_handoffs
self.trace_id = trace_id
self.parent_span_id = parent_span_id
self.agent_path = agent_path
# tool_call_id -> open SGP span, so on_tool_end closes the right one.
self._tool_spans: dict[str, Any] = {}

Expand Down Expand Up @@ -247,6 +254,7 @@ async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: To
name=tool.name,
arguments=tool_arguments,
).model_dump(),
self.agent_path,
],
start_to_close_timeout=self.timeout,
)
Expand Down Expand Up @@ -286,6 +294,7 @@ async def on_tool_end(
name=tool.name,
content=result,
).model_dump(),
self.agent_path,
],
start_to_close_timeout=self.timeout,
)
Expand Down Expand Up @@ -320,6 +329,7 @@ async def on_handoff(
content=f"Handoff from {from_agent.name} to {to_agent.name}",
type="text",
).model_dump(),
self.agent_path,
],
start_to_close_timeout=self.timeout,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""

import logging
from typing import Any, Type, Optional, override
from typing import Any, List, Type, Union, Optional, override
from contextvars import ContextVar

from temporalio import workflow
Expand All @@ -30,11 +30,13 @@
streaming_task_id: ContextVar[Optional[str]] = ContextVar('streaming_task_id', default=None)
streaming_trace_id: ContextVar[Optional[str]] = ContextVar('streaming_trace_id', default=None)
streaming_parent_span_id: ContextVar[Optional[str]] = ContextVar('streaming_parent_span_id', default=None)
streaming_agent_path: ContextVar[Optional[Union[str, List[str]]]] = ContextVar('streaming_agent_path', default=None)

# Header keys for passing context
TASK_ID_HEADER = "context-task-id"
TRACE_ID_HEADER = "context-trace-id"
PARENT_SPAN_ID_HEADER = "context-parent-span-id"
AGENT_PATH_HEADER = "context-agent-path"

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

if task_id and trace_id and parent_span_id:
if not input.headers:
Expand All @@ -101,6 +104,14 @@ def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle:
logger.debug(f"[OutboundInterceptor] Added task_id, trace_id, and parent_span_id to activity headers: {task_id}, {trace_id}, {parent_span_id}")
else:
logger.warning("[OutboundInterceptor] No _task_id, _trace_id, or _parent_span_id found in workflow instance")

# Independent of the three-tuple gate: a child-workflow sub-agent sets
# only _agent_path (task_id/trace/span come from the parent), and events
# from any agent need it to be attributable in a shared task stream.
if agent_path is not None:
if not input.headers:
input.headers = {}
input.headers[AGENT_PATH_HEADER] = self._payload_converter.to_payload(agent_path) # type: ignore[index]
except Exception as e:
logger.error(f"[OutboundInterceptor] Failed to get task_id, trace_id, or parent_span_id from workflow instance: {e}")

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

# Threaded independently — present only when the emitting agent set it.
if input.headers and AGENT_PATH_HEADER in input.headers:
# Any (not str) — the value is str or list[str]; Any passes the decoded
# JSON through untouched instead of coercing to one variant.
agent_path_value = self._payload_converter.from_payload(
input.headers[AGENT_PATH_HEADER], Any
)
streaming_agent_path.set(agent_path_value)

try:
# Execute the activity
# The TemporalStreamingModel can now read streaming_task_id.get()
Expand All @@ -149,5 +169,6 @@ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
streaming_task_id.set(None)
streaming_trace_id.set(None)
streaming_parent_span_id.set(None)
streaming_agent_path.set(None)
logger.debug("[ActivityInterceptor] Cleared task_id, trace_id, and parent_span_id from context")

Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
streaming_task_id,
streaming_trace_id,
streaming_agent_path,
streaming_parent_span_id,
)

Expand Down Expand Up @@ -635,6 +636,7 @@ async def get_response(
task_id = streaming_task_id.get()
trace_id = streaming_trace_id.get()
parent_span_id = streaming_parent_span_id.get()
agent_path = streaming_agent_path.get()

if not task_id or not trace_id or not parent_span_id:
raise ValueError("task_id, trace_id, and parent_span_id are required for streaming with Responses API")
Expand Down Expand Up @@ -843,6 +845,7 @@ async def get_response(
style="active",
),
streaming_mode=self.streaming_mode,
agent_path=agent_path,
).__aenter__()
elif item and getattr(item, 'type', None) == 'function_call':
# Open a streaming context per function call so argument
Expand All @@ -859,6 +862,7 @@ async def get_response(
arguments={},
),
streaming_mode=self.streaming_mode,
agent_path=agent_path,
).__aenter__()
function_calls_in_progress[output_index] = {
'id': getattr(item, 'id', ''),
Expand All @@ -879,6 +883,7 @@ async def get_response(
format="markdown",
),
streaming_mode=self.streaming_mode,
agent_path=agent_path,
).__aenter__()

elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
Expand Down
Loading
Loading