|
4 | 4 | ``UnifiedEmitter`` for a Temporal-durable ACP agent. |
5 | 5 |
|
6 | 6 | KEY CONCEPTS DEMONSTRATED: |
7 | | -- Spawning ``codex exec --json`` as a local asyncio subprocess inside a |
8 | | - Temporal workflow signal handler. The subprocess itself is NOT a Temporal |
9 | | - activity — for a tutorial that is fine. Production agents would wrap the |
10 | | - spawn in a Temporal activity for durability + observability. |
11 | | -- Wrapping the stdout line stream in a ``CodexTurn``. |
| 7 | +- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess |
| 8 | + I/O is not permitted on the Temporal workflow event loop (the deterministic |
| 9 | + sandbox loop does not implement ``subprocess_exec``), so the signal handler |
| 10 | + delegates the turn to an activity, which also gets Temporal's retry + timeout |
| 11 | + guarantees. |
| 12 | +- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity). |
12 | 13 | - Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes |
13 | 14 | ``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time. |
14 | 15 | - Passing ``created_at=workflow.now()`` for deterministic timestamps under |
15 | 16 | Temporal replay (required for Temporal-safe delivery). |
16 | 17 | - Persisting the codex thread ID on the workflow instance itself — Temporal's |
17 | 18 | workflow state is durable, so no external ``adk.state`` round-trip is needed. |
18 | | -
|
19 | | -NOTE: Subprocess spawning is safe inside a Temporal signal handler because |
20 | | -Temporal does NOT replay signal handler bodies (only ``@workflow.run`` is |
21 | | -subject to replay determinism constraints). Signal handlers run in the live |
22 | | -process after the initial replay is complete. |
23 | 19 | """ |
24 | 20 |
|
25 | 21 | from __future__ import annotations |
26 | 22 |
|
27 | 23 | import os |
28 | | -import asyncio |
29 | | -from collections.abc import AsyncIterator |
| 24 | +from datetime import timedelta |
30 | 25 |
|
31 | 26 | from temporalio import workflow |
32 | 27 |
|
33 | 28 | from agentex.lib import adk |
34 | | -from agentex.lib.adk import CodexTurn |
35 | 29 | from agentex.lib.types.acp import SendEventParams, CreateTaskParams |
36 | | -from agentex.lib.core.harness import UnifiedEmitter |
37 | 30 | from agentex.lib.types.tracing import SGPTracingProcessorConfig |
38 | 31 | from agentex.lib.utils.logging import make_logger |
39 | 32 | from agentex.types.text_content import TextContent |
|
42 | 35 | from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow |
43 | 36 | from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config |
44 | 37 |
|
| 38 | +with workflow.unsafe.imports_passed_through(): |
| 39 | + from project.activities import RunCodexTurnParams, run_codex_turn |
| 40 | + |
45 | 41 | add_tracing_processor_config( |
46 | 42 | SGPTracingProcessorConfig( |
47 | 43 | sgp_api_key=os.environ.get("SGP_API_KEY", ""), |
|
62 | 58 | MODEL = os.environ.get("CODEX_MODEL", "o4-mini") |
63 | 59 |
|
64 | 60 |
|
65 | | -async def _spawn_codex( |
66 | | - model: str, |
67 | | - thread_id: str | None = None, |
68 | | -) -> asyncio.subprocess.Process: |
69 | | - """Spawn ``codex exec --json`` locally and return the live process. |
70 | | -
|
71 | | - Injection seam: tests replace this function with a fake that returns a |
72 | | - mock process whose stdout yields pre-recorded event lines. |
73 | | -
|
74 | | - NOTE: This function must NOT be called during Temporal workflow replay |
75 | | - (Temporal's determinism guard would flag it as a non-deterministic side |
76 | | - effect). Signal handler bodies are safe: Temporal does not replay them. |
77 | | -
|
78 | | - The caller writes the prompt to stdin after the process starts, then |
79 | | - closes stdin so codex knows input is complete. |
80 | | - """ |
81 | | - base_flags = [ |
82 | | - "--json", |
83 | | - "--skip-git-repo-check", |
84 | | - "--dangerously-bypass-approvals-and-sandbox", |
85 | | - "--model", |
86 | | - model, |
87 | | - ] |
88 | | - |
89 | | - if thread_id: |
90 | | - cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] |
91 | | - else: |
92 | | - cmd = ["codex", "exec", *base_flags, "-"] |
93 | | - |
94 | | - return await asyncio.create_subprocess_exec( |
95 | | - *cmd, |
96 | | - stdin=asyncio.subprocess.PIPE, |
97 | | - stdout=asyncio.subprocess.PIPE, |
98 | | - stderr=asyncio.subprocess.PIPE, |
99 | | - env={**os.environ}, |
100 | | - ) |
101 | | - |
102 | | - |
103 | | -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: |
104 | | - """Yield newline-delimited JSON lines from the process stdout.""" |
105 | | - assert process.stdout is not None |
106 | | - buffer = "" |
107 | | - while True: |
108 | | - chunk = await process.stdout.read(4096) |
109 | | - if not chunk: |
110 | | - break |
111 | | - buffer += chunk.decode("utf-8", errors="replace") |
112 | | - while "\n" in buffer: |
113 | | - line, buffer = buffer.split("\n", 1) |
114 | | - line = line.strip() |
115 | | - if line: |
116 | | - yield line |
117 | | - if buffer.strip(): |
118 | | - yield buffer.strip() |
119 | | - |
120 | | - |
121 | 61 | @workflow.defn(name=environment_variables.WORKFLOW_NAME) |
122 | 62 | class AtHarnessCodexWorkflow(BaseWorkflow): |
123 | 63 | """Long-running Temporal workflow that runs codex exec for each turn. |
@@ -149,37 +89,33 @@ async def on_task_event_send(self, params: SendEventParams) -> None: |
149 | 89 | name=f"Turn {self._turn_number}", |
150 | 90 | input={"message": user_message}, |
151 | 91 | ) as span: |
152 | | - process = await _spawn_codex(MODEL, thread_id=self._codex_thread_id) |
153 | | - |
154 | | - assert process.stdin is not None |
155 | | - process.stdin.write(user_message.encode("utf-8")) |
156 | | - await process.stdin.drain() |
157 | | - process.stdin.close() |
158 | | - |
159 | | - turn = CodexTurn( |
160 | | - events=_process_stdout(process), |
161 | | - model=MODEL, |
162 | | - ) |
163 | | - |
164 | | - emitter = UnifiedEmitter( |
165 | | - task_id=params.task.id, |
166 | | - trace_id=params.task.id, |
167 | | - parent_span_id=span.id if span else None, |
| 92 | + # Delegate the subprocess turn to an activity: subprocess I/O is not |
| 93 | + # permitted on the Temporal workflow event loop. The activity streams |
| 94 | + # events to the task and returns the final text + codex thread id. |
| 95 | + # workflow.now() gives a deterministic timestamp under replay. |
| 96 | + result = await workflow.execute_activity( |
| 97 | + run_codex_turn, |
| 98 | + RunCodexTurnParams( |
| 99 | + task_id=params.task.id, |
| 100 | + prompt=user_message, |
| 101 | + model=MODEL, |
| 102 | + trace_id=params.task.id, |
| 103 | + parent_span_id=span.id if span else None, |
| 104 | + thread_id=self._codex_thread_id, |
| 105 | + created_at=workflow.now(), |
| 106 | + ), |
| 107 | + start_to_close_timeout=timedelta(minutes=5), |
168 | 108 | ) |
169 | 109 |
|
170 | | - # Pass workflow.now() so Temporal replay stamps messages with a |
171 | | - # deterministic timestamp from the workflow clock, not wall time. |
172 | | - result = await emitter.auto_send_turn(turn, created_at=workflow.now()) |
173 | | - |
174 | | - await process.wait() |
175 | | - |
176 | | - if turn.session_id: |
177 | | - self._codex_thread_id = turn.session_id |
| 110 | + # Persist the codex thread id so the next turn resumes the session. |
| 111 | + session_id = result.get("session_id") |
| 112 | + if session_id: |
| 113 | + self._codex_thread_id = session_id |
178 | 114 |
|
179 | 115 | if span: |
180 | 116 | span.output = { |
181 | | - "final_text": result.final_text, |
182 | | - "model": turn.usage().model, |
| 117 | + "final_text": result.get("final_text"), |
| 118 | + "model": result.get("model"), |
183 | 119 | } |
184 | 120 |
|
185 | 121 | @workflow.run |
|
0 commit comments