diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c96ca356..29e05db55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/.stats.yml b/.stats.yml index 2764fd11e..04c8e43b9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -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 diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py index 954a7566b..7bcb5a6d5 100644 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py @@ -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"], ), ] diff --git a/src/agentex/lib/adk/_modules/_codex_sync.py b/src/agentex/lib/adk/_modules/_codex_sync.py index 951622b13..b71ba9dac 100644 --- a/src/agentex/lib/adk/_modules/_codex_sync.py +++ b/src/agentex/lib/adk/_modules/_codex_sync.py @@ -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 @@ -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 @@ -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": @@ -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: diff --git a/src/agentex/lib/adk/_modules/streaming.py b/src/agentex/lib/adk/_modules/streaming.py index 561b1165d..de9618444 100644 --- a/src/agentex/lib/adk/_modules/streaming.py +++ b/src/agentex/lib/adk/_modules/streaming.py @@ -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. @@ -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, ) diff --git a/src/agentex/lib/core/services/adk/streaming.py b/src/agentex/lib/core/services/adk/streaming.py index 33ca7bc1c..946e56047 100644 --- a/src/agentex/lib/core/services/adk/streaming.py +++ b/src/agentex/lib/core/services/adk/streaming.py @@ -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 @@ -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( @@ -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, @@ -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: diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py index c65ae3c8b..f039a6876 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py @@ -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 @@ -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. @@ -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. @@ -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( diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py index 30d358cc9..66887f3f1 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py @@ -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__( @@ -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. @@ -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 @@ -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] = {} @@ -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, ) @@ -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, ) @@ -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, ) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py index 893f75f28..aa5310549 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py @@ -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 @@ -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.""" @@ -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: @@ -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}") @@ -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() @@ -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") diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py index 7c8690f21..0d6b636a8 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py @@ -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, ) @@ -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") @@ -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 @@ -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', ''), @@ -879,6 +883,7 @@ async def get_response( format="markdown", ), streaming_mode=self.streaming_mode, + agent_path=agent_path, ).__aenter__() elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): diff --git a/src/agentex/lib/core/tracing/obs_ids.py b/src/agentex/lib/core/tracing/obs_ids.py new file mode 100644 index 000000000..99c6b2555 --- /dev/null +++ b/src/agentex/lib/core/tracing/obs_ids.py @@ -0,0 +1,83 @@ +"""Correlate adk business spans with the active observability trace. + +The adk business ``trace_id`` is the agent **task id** (run-level: it spans the +whole agent run across many requests -- task/create, then each message/send turn), +so we must NOT overwrite it with a per-request observability trace_id. Doing so +would collapse the run-level grouping. + +Instead, each business span is *tagged* with the active observability +trace_id/span_id (this is the OpenTelemetry "span link" pattern -- correlate +across trace granularities rather than merging them). You can then pivot from a +persisted business span to the Tempo/Datadog trace for the turn that produced it, +while the business trace still groups the entire run by task id. + +Source selection follows SGP_OBS_MODE, matching egp-api-backend: + - unset / "dd_only": ddtrace context (current stack) + - "dual": OTel/LGTM preferred, ddtrace fallback + - "lgtm": OTel/LGTM only + +This never fabricates ids -- if no observability context is active, it returns +an empty dict and the span is simply not tagged. +""" +from __future__ import annotations + +import os +from typing import Dict, Tuple, Optional + +__all__ = ("get_obs_mode", "obs_correlation") + +DD_ONLY = "dd_only" +DUAL = "dual" +LGTM = "lgtm" +_DEFAULT_MODE = DD_ONLY +_VALID_MODES = (DD_ONLY, DUAL, LGTM) + + +def get_obs_mode() -> str: + """Unset/empty/unrecognized -> ``dd_only`` (current behavior).""" + raw = os.getenv("SGP_OBS_MODE") + if not raw: + return _DEFAULT_MODE + mode = raw.strip().lower() + return mode if mode in _VALID_MODES else _DEFAULT_MODE + + +def _lgtm_ids() -> Optional[Tuple[str, str]]: + try: + from opentelemetry import trace + except ImportError: + return None + ctx = trace.get_current_span().get_span_context() + if ctx and ctx.is_valid: + return format(ctx.trace_id, "032x"), format(ctx.span_id, "016x") + return None + + +def _ddtrace_ids() -> Optional[Tuple[str, str]]: + try: + from ddtrace.trace import tracer + except ImportError: + return None + ctx = tracer.current_trace_context() + if ctx and ctx.trace_id: + return format(ctx.trace_id, "032x"), format(ctx.span_id or 0, "016x") + return None + + +def obs_correlation() -> Dict[str, str]: + """Return ``{"obs.trace_id": ..., "obs.span_id": ...}`` for the active + observability context, or ``{}`` if none is active. + + Never fabricates ids -- this is a correlation tag, not the span's id. + """ + mode = get_obs_mode() + if mode == LGTM: + ids = _lgtm_ids() + elif mode == DUAL: + ids = _lgtm_ids() or _ddtrace_ids() + else: # dd_only + ids = _ddtrace_ids() + + if not ids: + return {} + return {"obs.trace_id": ids[0], "obs.span_id": ids[1]} diff --git a/src/agentex/lib/core/tracing/trace.py b/src/agentex/lib/core/tracing/trace.py index a22bfd658..c3ec91bc3 100644 --- a/src/agentex/lib/core/tracing/trace.py +++ b/src/agentex/lib/core/tracing/trace.py @@ -11,6 +11,7 @@ from agentex.types.span import Span from agentex.lib.utils.logging import make_logger from agentex.lib.utils.model_utils import recursive_model_dump +from agentex.lib.core.tracing.obs_ids import obs_correlation from agentex.lib.core.tracing.span_error import set_span_error from agentex.lib.core.tracing.span_queue import ( SpanEventType, @@ -79,6 +80,12 @@ def start_span( serialized_input = recursive_model_dump(input) if input else None serialized_data = recursive_model_dump(data) if data else None + # Tag the business span with the active observability trace_id/span_id + # (OTel/ddtrace) so it can be correlated to the per-turn obs trace. The + # business trace_id stays the run-level task id -- see obs_ids.py. + obs = obs_correlation() + if obs: + serialized_data = {**(serialized_data or {}), **obs} id = str(uuid.uuid4()) span = Span( @@ -229,6 +236,12 @@ async def start_span( serialized_input = recursive_model_dump(input) if input else None serialized_data = recursive_model_dump(data) if data else None + # Tag the business span with the active observability trace_id/span_id + # (OTel/ddtrace) so it can be correlated to the per-turn obs trace. The + # business trace_id stays the run-level task id -- see obs_ids.py. + obs = obs_correlation() + if obs: + serialized_data = {**(serialized_data or {}), **obs} id = str(uuid.uuid4()) span = Span( diff --git a/src/agentex/resources/agents/schedules.py b/src/agentex/resources/agents/schedules.py index e6576b6d7..1750c513b 100644 --- a/src/agentex/resources/agents/schedules.py +++ b/src/agentex/resources/agents/schedules.py @@ -277,6 +277,7 @@ def list( self, agent_id: str, *, + include_live: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -289,6 +290,8 @@ def list( List run schedules for an agent. Args: + include_live: Include live Temporal state and upcoming action times. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -306,7 +309,13 @@ def list( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"limit": limit}, schedule_list_params.ScheduleListParams), + query=maybe_transform( + { + "include_live": include_live, + "limit": limit, + }, + schedule_list_params.ScheduleListParams, + ), ), cast_to=ScheduleListResponse, ) @@ -1056,6 +1065,7 @@ async def list( self, agent_id: str, *, + include_live: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1068,6 +1078,8 @@ async def list( List run schedules for an agent. Args: + include_live: Include live Temporal state and upcoming action times. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -1085,7 +1097,13 @@ async def list( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform({"limit": limit}, schedule_list_params.ScheduleListParams), + query=await async_maybe_transform( + { + "include_live": include_live, + "limit": limit, + }, + schedule_list_params.ScheduleListParams, + ), ), cast_to=ScheduleListResponse, ) diff --git a/src/agentex/resources/tasks.py b/src/agentex/resources/tasks.py index d82b1a7cb..4ddafa738 100644 --- a/src/agentex/resources/tasks.py +++ b/src/agentex/resources/tasks.py @@ -122,12 +122,13 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TaskListResponse: - """List all tasks. + """List tasks. - Args: - status: Filter tasks by status (e.g. + Returns a lean summary per task and omits `params`; fetch GET + /tasks/{task_id} for the full record including `params`. - RUNNING, COMPLETED). + Args: + status: Filter tasks by status (e.g. RUNNING, COMPLETED). task_metadata: JSON-encoded object used to filter tasks via JSONB containment. Example: @@ -758,12 +759,13 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TaskListResponse: - """List all tasks. + """List tasks. - Args: - status: Filter tasks by status (e.g. + Returns a lean summary per task and omits `params`; fetch GET + /tasks/{task_id} for the full record including `params`. - RUNNING, COMPLETED). + Args: + status: Filter tasks by status (e.g. RUNNING, COMPLETED). task_metadata: JSON-encoded object used to filter tasks via JSONB containment. Example: diff --git a/src/agentex/types/agents/schedule_create_response.py b/src/agentex/types/agents/schedule_create_response.py index 4440629a2..9902bb832 100644 --- a/src/agentex/types/agents/schedule_create_response.py +++ b/src/agentex/types/agents/schedule_create_response.py @@ -86,6 +86,12 @@ class ScheduleCreateResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_list_params.py b/src/agentex/types/agents/schedule_list_params.py index 8a1d5561a..6e767e207 100644 --- a/src/agentex/types/agents/schedule_list_params.py +++ b/src/agentex/types/agents/schedule_list_params.py @@ -8,4 +8,7 @@ class ScheduleListParams(TypedDict, total=False): + include_live: bool + """Include live Temporal state and upcoming action times.""" + limit: int diff --git a/src/agentex/types/agents/schedule_list_response.py b/src/agentex/types/agents/schedule_list_response.py index 182bd53eb..faa19527f 100644 --- a/src/agentex/types/agents/schedule_list_response.py +++ b/src/agentex/types/agents/schedule_list_response.py @@ -86,6 +86,12 @@ class RunSchedule(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_pause_by_name_response.py b/src/agentex/types/agents/schedule_pause_by_name_response.py index 4c0ce1061..61232fdde 100644 --- a/src/agentex/types/agents/schedule_pause_by_name_response.py +++ b/src/agentex/types/agents/schedule_pause_by_name_response.py @@ -86,6 +86,12 @@ class SchedulePauseByNameResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_pause_response.py b/src/agentex/types/agents/schedule_pause_response.py index c14c83443..16e4a85ce 100644 --- a/src/agentex/types/agents/schedule_pause_response.py +++ b/src/agentex/types/agents/schedule_pause_response.py @@ -86,6 +86,12 @@ class SchedulePauseResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_resume_by_name_response.py b/src/agentex/types/agents/schedule_resume_by_name_response.py index 6bdbcd329..5999059c0 100644 --- a/src/agentex/types/agents/schedule_resume_by_name_response.py +++ b/src/agentex/types/agents/schedule_resume_by_name_response.py @@ -86,6 +86,12 @@ class ScheduleResumeByNameResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_resume_response.py b/src/agentex/types/agents/schedule_resume_response.py index 907792401..70e6e2aa0 100644 --- a/src/agentex/types/agents/schedule_resume_response.py +++ b/src/agentex/types/agents/schedule_resume_response.py @@ -86,6 +86,12 @@ class ScheduleResumeResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_retrieve_by_name_response.py b/src/agentex/types/agents/schedule_retrieve_by_name_response.py index 31663f41a..7b21ebb0a 100644 --- a/src/agentex/types/agents/schedule_retrieve_by_name_response.py +++ b/src/agentex/types/agents/schedule_retrieve_by_name_response.py @@ -86,6 +86,12 @@ class ScheduleRetrieveByNameResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_retrieve_response.py b/src/agentex/types/agents/schedule_retrieve_response.py index 20375347e..374ac635f 100644 --- a/src/agentex/types/agents/schedule_retrieve_response.py +++ b/src/agentex/types/agents/schedule_retrieve_response.py @@ -86,6 +86,12 @@ class ScheduleRetrieveResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_skip_response.py b/src/agentex/types/agents/schedule_skip_response.py index f4daf514b..f97216e0b 100644 --- a/src/agentex/types/agents/schedule_skip_response.py +++ b/src/agentex/types/agents/schedule_skip_response.py @@ -86,6 +86,12 @@ class ScheduleSkipResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_trigger_by_name_response.py b/src/agentex/types/agents/schedule_trigger_by_name_response.py index 036cf72f3..4006b7ad7 100644 --- a/src/agentex/types/agents/schedule_trigger_by_name_response.py +++ b/src/agentex/types/agents/schedule_trigger_by_name_response.py @@ -86,6 +86,12 @@ class ScheduleTriggerByNameResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_trigger_response.py b/src/agentex/types/agents/schedule_trigger_response.py index 22695f9c0..dbff651b0 100644 --- a/src/agentex/types/agents/schedule_trigger_response.py +++ b/src/agentex/types/agents/schedule_trigger_response.py @@ -86,6 +86,12 @@ class ScheduleTriggerResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_unskip_response.py b/src/agentex/types/agents/schedule_unskip_response.py index e25f31bbe..96f624220 100644 --- a/src/agentex/types/agents/schedule_unskip_response.py +++ b/src/agentex/types/agents/schedule_unskip_response.py @@ -86,6 +86,12 @@ class ScheduleUnskipResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_update_by_name_response.py b/src/agentex/types/agents/schedule_update_by_name_response.py index 8e8fd2112..e2905593c 100644 --- a/src/agentex/types/agents/schedule_update_by_name_response.py +++ b/src/agentex/types/agents/schedule_update_by_name_response.py @@ -86,6 +86,12 @@ class ScheduleUpdateByNameResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/agents/schedule_update_response.py b/src/agentex/types/agents/schedule_update_response.py index a27701d21..a7416e776 100644 --- a/src/agentex/types/agents/schedule_update_response.py +++ b/src/agentex/types/agents/schedule_update_response.py @@ -86,6 +86,12 @@ class ScheduleUpdateResponse(BaseModel): last_action_time: Optional[datetime] = None """When the schedule last fired.""" + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + next_action_times: Optional[List[datetime]] = None """Upcoming scheduled fire times.""" diff --git a/src/agentex/types/task_list_response.py b/src/agentex/types/task_list_response.py index 73ffcf4a4..8333ec893 100644 --- a/src/agentex/types/task_list_response.py +++ b/src/agentex/types/task_list_response.py @@ -11,7 +11,12 @@ class TaskListResponseItem(BaseModel): - """Task response model with optional related data based on relationships""" + """Lean list-response shape. + + Omits `params` (the arbitrary create-time + payload, which can carry per-caller secrets and PII); fetch GET /tasks/{id} + for the full record. + """ id: str @@ -23,8 +28,6 @@ class TaskListResponseItem(BaseModel): name: Optional[str] = None - params: Optional[Dict[str, object]] = None - status: Optional[ Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] ] = None diff --git a/src/agentex/types/task_message.py b/src/agentex/types/task_message.py index 1f78e9256..793e06f6a 100644 --- a/src/agentex/types/task_message.py +++ b/src/agentex/types/task_message.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import List, Union, Optional from datetime import datetime from typing_extensions import Literal @@ -27,6 +27,15 @@ class TaskMessage(BaseModel): task_id: str """ID of the task this message belongs to""" + # MANUAL PATCH — mirror into the upstream OpenAPI spec (see .stats.yml + # openapi_spec_url) or the next Stainless regen drops it. + agent_path: Optional[Union[str, List[str]]] = None + """Identifier of the agent that emitted this message. + + A single agent id, or a root->emitter path (e.g. ["researcher", "subagent-abc"]) + when nested sub-agents share one task stream. Consumers group/filter events by it. + """ + id: Optional[str] = None """The task message's unique id""" diff --git a/tests/api_resources/agents/test_schedules.py b/tests/api_resources/agents/test_schedules.py index 362d32f77..8b281ee31 100644 --- a/tests/api_resources/agents/test_schedules.py +++ b/tests/api_resources/agents/test_schedules.py @@ -249,6 +249,7 @@ def test_method_list(self, client: Agentex) -> None: def test_method_list_with_all_params(self, client: Agentex) -> None: schedule = client.agents.schedules.list( agent_id="agent_id", + include_live=True, limit=1, ) assert_matches_type(ScheduleListResponse, schedule, path=["response"]) @@ -1205,6 +1206,7 @@ async def test_method_list(self, async_client: AsyncAgentex) -> None: async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: schedule = await async_client.agents.schedules.list( agent_id="agent_id", + include_live=True, limit=1, ) assert_matches_type(ScheduleListResponse, schedule, path=["response"]) diff --git a/tests/lib/adk/test_codex_sync.py b/tests/lib/adk/test_codex_sync.py index 644688dfb..85e67beb9 100644 --- a/tests/lib/adk/test_codex_sync.py +++ b/tests/lib/adk/test_codex_sync.py @@ -47,6 +47,13 @@ async def _collect(stream: AsyncIterator[Any]) -> list[Any]: return [e async for e in stream] +def _result_text(event: StreamTaskMessageFull) -> str: + content = event.content + assert isinstance(content, ToolResponseContent) + assert isinstance(content.content, dict) + return str(content.content["result"]) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -360,6 +367,107 @@ async def test_tool_indices_request_before_response(self) -> None: assert req.index < resp.index +# --------------------------------------------------------------------------- +# Progressive tool items (todo_list) +# --------------------------------------------------------------------------- + + +def _todo_item(item_id: str, *completed: bool) -> dict[str, Any]: + return { + "id": item_id, + "type": "todo_list", + "items": [{"text": f"step {i + 1}", "completed": done} for i, done in enumerate(completed)], + } + + +class TestTodoListUpdates: + async def test_each_update_republishes_the_checklist(self) -> None: + """Codex ticks ONE in-place todo_list item, so every item.updated is + forwarded as a response under the same tool_call_id. Without this a + consumer only sees the plan as first written and then, at end of turn, + as finished.""" + events = [ + {"type": "item.started", "item": _todo_item("item_1", False, False)}, + {"type": "item.updated", "item": _todo_item("item_1", True, False)}, + {"type": "item.updated", "item": _todo_item("item_1", True, True)}, + {"type": "item.completed", "item": _todo_item("item_1", True, True)}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + requests = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) + ] + responses = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + + assert len(requests) == 1 + assert len(responses) == 3 + + request_content = requests[0].content + assert isinstance(request_content, ToolRequestContent) + for response in responses: + content = response.content + assert isinstance(content, ToolResponseContent) + assert content.name == "todo_list" + assert content.tool_call_id == request_content.tool_call_id + + first, second, final = (json.loads(_result_text(r)) for r in responses) + assert [i["completed"] for i in first["items"]] == [True, False] + assert [i["completed"] for i in second["items"]] == [True, True] + assert [i["completed"] for i in final["items"]] == [True, True] + + async def test_counts_the_call_once_across_its_updates(self) -> None: + events = [ + {"type": "item.started", "item": _todo_item("item_1", False)}, + {"type": "item.updated", "item": _todo_item("item_1", True)}, + {"type": "item.completed", "item": _todo_item("item_1", True)}, + ] + counters: dict[str, Any] = {} + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=counters.update)) + assert counters.get("tool_call_count") == 1 + + async def test_ignores_an_update_for_an_unopened_item(self) -> None: + """An update with no preceding start has no request to answer; a + response would dangle with a tool_call_id nothing points at.""" + events = [{"type": "item.updated", "item": _todo_item("orphan", True)}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert [e for e in out if isinstance(e, StreamTaskMessageFull)] == [] + + async def test_does_not_republish_other_tool_items(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "cmd1", "type": "command_execution", "command": "sleep 1"}, + }, + { + "type": "item.updated", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "sleep 1", + "aggregated_output": "partial", + }, + }, + { + "type": "item.completed", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "sleep 1", + "aggregated_output": "done", + "exit_code": 0, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + responses = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + assert len(responses) == 1 + assert _result_text(responses[0]) == "done" + + # --------------------------------------------------------------------------- # Reasoning # ---------------------------------------------------------------------------