Skip to content

Commit 0252fcc

Browse files
authored
feat(codex): republish todo_list revisions as they are ticked off (#473)
1 parent 152bc75 commit 0252fcc

4 files changed

Lines changed: 163 additions & 9 deletions

File tree

examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,28 @@
1313

1414
logger = make_logger(__name__)
1515

16+
# These reference MCP servers still import the mcp 1.x API (``McpError``), which
17+
# mcp 2.0.0 renamed to ``MCPError``. uvx gives each server its own isolated env and
18+
# resolves ``mcp`` unpinned there, ignoring the version this project pins, so without
19+
# this constraint every server dies at import and the agent silently makes zero tool
20+
# calls. Drop the pin once the servers support mcp 2.x.
21+
_MCP_PIN = ["--with", "mcp<2"]
22+
1623
MCP_SERVERS = [
1724
StdioServerParameters(
1825
command="uvx",
19-
args=["mcp-server-time", "--local-timezone", "America/Los_Angeles"],
26+
args=[*_MCP_PIN, "mcp-server-time", "--local-timezone", "America/Los_Angeles"],
2027
),
2128
StdioServerParameters(
2229
command="uvx",
23-
args=["openai-websearch-mcp"],
30+
args=[*_MCP_PIN, "openai-websearch-mcp"],
2431
env={
2532
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "")
2633
}
2734
),
2835
StdioServerParameters(
2936
command="uvx",
30-
args=["mcp-server-fetch"],
37+
args=[*_MCP_PIN, "mcp-server-fetch"],
3138
),
3239
]
3340

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@
6060
a synthetic ToolRequestContent Full is emitted before the response.
6161
mcp_tool_call -> same as command_execution
6262
web_search -> same as command_execution
63-
todo_list -> same as command_execution
63+
todo_list -> same as command_execution, plus:
64+
item.updated -> StreamTaskMessageFull(ToolResponseContent)
65+
Codex ticks one in-place todo_list item through
66+
item.updated; each revision is republished under
67+
the same tool_call_id so consumers can render the
68+
checklist filling in rather than jumping to its
69+
final state at end of turn.
6470
collab_tool_call -> same as command_execution
6571
error (item type) -> StreamTaskMessageFull(TextContent, error text) on completed only
6672
@@ -75,9 +81,10 @@
7581
without this module needing to know about spans.
7682
item.updated (reasoning): the intermediate cumulative text is discarded;
7783
only item.completed carries the final text.
78-
item.updated (tool): tool item types other than agent_message do not
79-
emit updates; item.started opens the request and
80-
item.completed closes it.
84+
item.updated (tool): only todo_list is republished (see above). For the
85+
other tool item types item.started opens the request
86+
and item.completed closes it; any updates in between
87+
carry no state a consumer could act on.
8188
"""
8289

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

114121

122+
# Tool items codex revises in place rather than reopening. Their item.updated
123+
# events carry real intermediate state, so they are forwarded as responses.
124+
_PROGRESSIVE_TOOL_ITEMS = frozenset({"todo_list"})
125+
126+
115127
def _tool_name_for(item_type: str, payload: dict[str, Any]) -> str:
116128
"""Derive a canonical tool name from a codex item type."""
117129
if item_type == "command_execution":
@@ -467,6 +479,33 @@ def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMe
467479
)
468480
out.append(StreamTaskMessageDone(type="done", index=req_idx))
469481

482+
elif evt_type == "item.updated" and item_type in _PROGRESSIVE_TOOL_ITEMS:
483+
# Codex revises its plan in place: one todo_list item is opened
484+
# at the start of the turn and ticked off through item.updated,
485+
# with item.completed only arriving at the very end. Forwarding
486+
# each revision as a response for the SAME tool_call_id lets a
487+
# consumer show the checklist filling in as the turn runs; the
488+
# last response received is the current state.
489+
if item_id in self._tool_open:
490+
actual_type = self._tool_item_types.get(item_id, item_type)
491+
result_text, is_error = _tool_output_for(actual_type, item)
492+
resp_content: dict[str, Any] = {"result": result_text}
493+
if is_error:
494+
resp_content["is_error"] = True
495+
out.append(
496+
StreamTaskMessageFull(
497+
type="full",
498+
index=self._alloc(),
499+
content=ToolResponseContent(
500+
type="tool_response",
501+
author="agent",
502+
tool_call_id=tool_call_id,
503+
name=_tool_name_for(actual_type, item),
504+
content=resp_content,
505+
),
506+
)
507+
)
508+
470509
elif evt_type == "item.completed":
471510
# file_change items may only emit item.completed (no started).
472511
if item_id not in self._tool_open:

src/agentex/lib/core/tracing/obs_ids.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from __future__ import annotations
2323

2424
import os
25-
from typing import Dict, Optional, Tuple
25+
from typing import Dict, Tuple, Optional
2626

2727
__all__ = ("get_obs_mode", "obs_correlation")
2828

@@ -55,7 +55,7 @@ def _lgtm_ids() -> Optional[Tuple[str, str]]:
5555

5656
def _ddtrace_ids() -> Optional[Tuple[str, str]]:
5757
try:
58-
from ddtrace import tracer
58+
from ddtrace.trace import tracer
5959
except ImportError:
6060
return None
6161
ctx = tracer.current_trace_context()

tests/lib/adk/test_codex_sync.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ async def _collect(stream: AsyncIterator[Any]) -> list[Any]:
4747
return [e async for e in stream]
4848

4949

50+
def _result_text(event: StreamTaskMessageFull) -> str:
51+
content = event.content
52+
assert isinstance(content, ToolResponseContent)
53+
assert isinstance(content.content, dict)
54+
return str(content.content["result"])
55+
56+
5057
# ---------------------------------------------------------------------------
5158
# Helpers
5259
# ---------------------------------------------------------------------------
@@ -360,6 +367,107 @@ async def test_tool_indices_request_before_response(self) -> None:
360367
assert req.index < resp.index
361368

362369

370+
# ---------------------------------------------------------------------------
371+
# Progressive tool items (todo_list)
372+
# ---------------------------------------------------------------------------
373+
374+
375+
def _todo_item(item_id: str, *completed: bool) -> dict[str, Any]:
376+
return {
377+
"id": item_id,
378+
"type": "todo_list",
379+
"items": [{"text": f"step {i + 1}", "completed": done} for i, done in enumerate(completed)],
380+
}
381+
382+
383+
class TestTodoListUpdates:
384+
async def test_each_update_republishes_the_checklist(self) -> None:
385+
"""Codex ticks ONE in-place todo_list item, so every item.updated is
386+
forwarded as a response under the same tool_call_id. Without this a
387+
consumer only sees the plan as first written and then, at end of turn,
388+
as finished."""
389+
events = [
390+
{"type": "item.started", "item": _todo_item("item_1", False, False)},
391+
{"type": "item.updated", "item": _todo_item("item_1", True, False)},
392+
{"type": "item.updated", "item": _todo_item("item_1", True, True)},
393+
{"type": "item.completed", "item": _todo_item("item_1", True, True)},
394+
]
395+
out = await _collect(convert_codex_to_agentex_events(_aiter(events)))
396+
397+
requests = [
398+
e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent)
399+
]
400+
responses = [
401+
e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent)
402+
]
403+
404+
assert len(requests) == 1
405+
assert len(responses) == 3
406+
407+
request_content = requests[0].content
408+
assert isinstance(request_content, ToolRequestContent)
409+
for response in responses:
410+
content = response.content
411+
assert isinstance(content, ToolResponseContent)
412+
assert content.name == "todo_list"
413+
assert content.tool_call_id == request_content.tool_call_id
414+
415+
first, second, final = (json.loads(_result_text(r)) for r in responses)
416+
assert [i["completed"] for i in first["items"]] == [True, False]
417+
assert [i["completed"] for i in second["items"]] == [True, True]
418+
assert [i["completed"] for i in final["items"]] == [True, True]
419+
420+
async def test_counts_the_call_once_across_its_updates(self) -> None:
421+
events = [
422+
{"type": "item.started", "item": _todo_item("item_1", False)},
423+
{"type": "item.updated", "item": _todo_item("item_1", True)},
424+
{"type": "item.completed", "item": _todo_item("item_1", True)},
425+
]
426+
counters: dict[str, Any] = {}
427+
await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=counters.update))
428+
assert counters.get("tool_call_count") == 1
429+
430+
async def test_ignores_an_update_for_an_unopened_item(self) -> None:
431+
"""An update with no preceding start has no request to answer; a
432+
response would dangle with a tool_call_id nothing points at."""
433+
events = [{"type": "item.updated", "item": _todo_item("orphan", True)}]
434+
out = await _collect(convert_codex_to_agentex_events(_aiter(events)))
435+
assert [e for e in out if isinstance(e, StreamTaskMessageFull)] == []
436+
437+
async def test_does_not_republish_other_tool_items(self) -> None:
438+
events = [
439+
{
440+
"type": "item.started",
441+
"item": {"id": "cmd1", "type": "command_execution", "command": "sleep 1"},
442+
},
443+
{
444+
"type": "item.updated",
445+
"item": {
446+
"id": "cmd1",
447+
"type": "command_execution",
448+
"command": "sleep 1",
449+
"aggregated_output": "partial",
450+
},
451+
},
452+
{
453+
"type": "item.completed",
454+
"item": {
455+
"id": "cmd1",
456+
"type": "command_execution",
457+
"command": "sleep 1",
458+
"aggregated_output": "done",
459+
"exit_code": 0,
460+
},
461+
},
462+
]
463+
out = await _collect(convert_codex_to_agentex_events(_aiter(events)))
464+
responses = [
465+
e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent)
466+
]
467+
assert len(responses) == 1
468+
assert _result_text(responses[0]) == "done"
469+
470+
363471
# ---------------------------------------------------------------------------
364472
# Reasoning
365473
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)