temporalio-openai-codex 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- temporalio/openai_codex/__init__.py +24 -0
- temporalio/openai_codex/_activities.py +223 -0
- temporalio/openai_codex/_client.py +715 -0
- temporalio/openai_codex/_compat.py +103 -0
- temporalio/openai_codex/_constants.py +11 -0
- temporalio/openai_codex/_errors.py +46 -0
- temporalio/openai_codex/_heartbeat_decorator.py +36 -0
- temporalio/openai_codex/_plugin.py +127 -0
- temporalio/openai_codex/_routing.py +194 -0
- temporalio/openai_codex/_subprocess.py +148 -0
- temporalio/openai_codex/_types.py +118 -0
- temporalio/openai_codex/py.typed +1 -0
- temporalio_openai_codex-0.0.1.dist-info/METADATA +227 -0
- temporalio_openai_codex-0.0.1.dist-info/RECORD +16 -0
- temporalio_openai_codex-0.0.1.dist-info/WHEEL +4 -0
- temporalio_openai_codex-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Durable OpenAI Codex sessions on Temporal.
|
|
2
|
+
|
|
3
|
+
Installed from the ``temporalio-openai-codex`` distribution as ``temporalio.openai_codex``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._plugin import OpenAICodexPlugin
|
|
7
|
+
from ._subprocess import SubprocessConnector
|
|
8
|
+
from ._types import (
|
|
9
|
+
CodexActivityOptions,
|
|
10
|
+
CodexProcess,
|
|
11
|
+
ConnectActivityInput,
|
|
12
|
+
Connector,
|
|
13
|
+
TemporalCodexConfig,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"OpenAICodexPlugin",
|
|
18
|
+
"TemporalCodexConfig",
|
|
19
|
+
"SubprocessConnector",
|
|
20
|
+
"Connector",
|
|
21
|
+
"CodexProcess",
|
|
22
|
+
"ConnectActivityInput",
|
|
23
|
+
"CodexActivityOptions",
|
|
24
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The Activities a :class:`TemporalCodexClient` runs, which reach Codex through a :class:`Connector`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from openai_codex import CodexError, TransportClosedError
|
|
13
|
+
from temporalio import activity
|
|
14
|
+
from temporalio.service import PayloadLimitsConfig
|
|
15
|
+
|
|
16
|
+
from ._constants import CLOSE, CONNECT, CONNECTION_LOST, READ, WRITE
|
|
17
|
+
from ._errors import activity_error
|
|
18
|
+
from ._heartbeat_decorator import auto_heartbeater
|
|
19
|
+
from ._types import (
|
|
20
|
+
CloseActivityInput,
|
|
21
|
+
CodexProcess,
|
|
22
|
+
ConnectActivityInput,
|
|
23
|
+
Connection,
|
|
24
|
+
Connector,
|
|
25
|
+
Message,
|
|
26
|
+
ReadActivityInput,
|
|
27
|
+
ReadResult,
|
|
28
|
+
WriteActivityInput,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class _Connection:
|
|
34
|
+
"""A Codex ``connect`` started, with the output the Workflow hasn't confirmed it received."""
|
|
35
|
+
|
|
36
|
+
process: CodexProcess
|
|
37
|
+
unreceived: list[Message] = field(default_factory=list[Message])
|
|
38
|
+
"""Message a read returned, kept until the next read confirms the Workflow got it."""
|
|
39
|
+
received: int = 0
|
|
40
|
+
"""How many outputs the Workflow received before ``unreceived``."""
|
|
41
|
+
reading: asyncio.Future[list[Message]] | None = None
|
|
42
|
+
"""The process read that's still running."""
|
|
43
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
44
|
+
writes: dict[int, asyncio.Task[None]] = field(default_factory=dict)
|
|
45
|
+
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
46
|
+
|
|
47
|
+
async def write_process(self, message: Message) -> None:
|
|
48
|
+
"""Keep later writes behind an earlier attempt that outlived its caller."""
|
|
49
|
+
async with self.write_lock:
|
|
50
|
+
await self.process.write(message)
|
|
51
|
+
|
|
52
|
+
async def close(self) -> None:
|
|
53
|
+
"""Close the process and collect pending I/O results."""
|
|
54
|
+
await self.process.close()
|
|
55
|
+
pending: list[asyncio.Future[Any]] = list(self.writes.values())
|
|
56
|
+
if self.reading is not None:
|
|
57
|
+
pending.append(self.reading)
|
|
58
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
59
|
+
|
|
60
|
+
async def read_process(self) -> list[Message]:
|
|
61
|
+
"""Wait for the process's next output.
|
|
62
|
+
|
|
63
|
+
A cancelled attempt leaves the read running, and the next attempt waits
|
|
64
|
+
for it, so no output is lost.
|
|
65
|
+
"""
|
|
66
|
+
if self.reading is None:
|
|
67
|
+
self.reading = asyncio.ensure_future(self.process.read())
|
|
68
|
+
reading = self.reading
|
|
69
|
+
try:
|
|
70
|
+
return await asyncio.shield(reading)
|
|
71
|
+
finally:
|
|
72
|
+
# Once it's done, even with an error, the next attempt reads again.
|
|
73
|
+
# A retryable error, such as a dropped network call, can then succeed.
|
|
74
|
+
if reading.done():
|
|
75
|
+
self.reading = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ConnectionActivities:
|
|
79
|
+
"""One Worker's Activities, with the connector they use, the Codex processes they started, and their connector queue."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, connector: Connector, connector_queue: str) -> None:
|
|
82
|
+
"""Create the Activities, with no Codex running.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
connector: Starts Codex and reaches it.
|
|
86
|
+
connector_queue: The task queue only this process polls, where the
|
|
87
|
+
Activities after ``connect`` must run.
|
|
88
|
+
"""
|
|
89
|
+
self._connector = connector
|
|
90
|
+
self.connector_queue = connector_queue
|
|
91
|
+
# Duplicate plugin instances share this owner's connector Worker.
|
|
92
|
+
self.connector_worker_running = False
|
|
93
|
+
self._connections: dict[str, _Connection] = {}
|
|
94
|
+
|
|
95
|
+
def connected_activities(self) -> list[Callable[..., Any]]:
|
|
96
|
+
"""The Activities that reach a Codex ``connect`` started, so must run in this process."""
|
|
97
|
+
return [self.write, self.read, self.close]
|
|
98
|
+
|
|
99
|
+
@activity.defn(name=CONNECT)
|
|
100
|
+
@auto_heartbeater
|
|
101
|
+
async def connect(self, request: ConnectActivityInput) -> Connection:
|
|
102
|
+
"""Start Codex, unless this launch started it already."""
|
|
103
|
+
info = activity.info()
|
|
104
|
+
handle = f"{info.workflow_run_id}/{info.activity_id}"
|
|
105
|
+
|
|
106
|
+
if handle not in self._connections:
|
|
107
|
+
# Shielded, so a cancelled launch still gets the process to close it.
|
|
108
|
+
connecting = asyncio.ensure_future(self._connector.connect(request))
|
|
109
|
+
try:
|
|
110
|
+
process = await asyncio.shield(connecting)
|
|
111
|
+
# An overlapping attempt may have finished first. Keep that
|
|
112
|
+
# connection and close this attempt's unused process.
|
|
113
|
+
connection = self._connections.setdefault(handle, _Connection(process))
|
|
114
|
+
if connection.process is not process:
|
|
115
|
+
await process.close()
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
with contextlib.suppress(Exception):
|
|
118
|
+
await (await connecting).close()
|
|
119
|
+
raise
|
|
120
|
+
except (CodexError, FileNotFoundError, PermissionError) as error:
|
|
121
|
+
raise activity_error(error) from error
|
|
122
|
+
|
|
123
|
+
return Connection(handle, self.connector_queue)
|
|
124
|
+
|
|
125
|
+
@activity.defn(name=WRITE)
|
|
126
|
+
@auto_heartbeater
|
|
127
|
+
async def write(self, request: WriteActivityInput) -> None:
|
|
128
|
+
"""Write to Codex's stdin."""
|
|
129
|
+
try:
|
|
130
|
+
connection = self._connection(request.handle)
|
|
131
|
+
# One task per logical write. Shielding retains in-flight writes when
|
|
132
|
+
# attempts overlap or the completion is lost. Completed tasks remain
|
|
133
|
+
# cached until close, including permanent failures.
|
|
134
|
+
task = connection.writes.get(request.operation_id)
|
|
135
|
+
if task is None:
|
|
136
|
+
task = asyncio.create_task(connection.write_process(request.message))
|
|
137
|
+
connection.writes[request.operation_id] = task
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.shield(task)
|
|
140
|
+
except Exception as error:
|
|
141
|
+
if (
|
|
142
|
+
not isinstance(error, CodexError)
|
|
143
|
+
and connection.writes.get(request.operation_id) is task
|
|
144
|
+
):
|
|
145
|
+
connection.writes.pop(request.operation_id, None)
|
|
146
|
+
raise
|
|
147
|
+
except CodexError as error:
|
|
148
|
+
raise activity_error(error) from error
|
|
149
|
+
|
|
150
|
+
@activity.defn(name=READ)
|
|
151
|
+
@auto_heartbeater
|
|
152
|
+
async def read(self, request: ReadActivityInput) -> ReadResult:
|
|
153
|
+
"""Wait for Codex's output, and return as much as fits Temporal's payload limit.
|
|
154
|
+
|
|
155
|
+
The output stays here until a later read confirms the Workflow got it.
|
|
156
|
+
The server drops the result of an attempt it timed out, so the retry
|
|
157
|
+
returns the same output again.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
connection = self._connection(request.handle)
|
|
161
|
+
|
|
162
|
+
async with connection.lock:
|
|
163
|
+
if request.received > connection.received:
|
|
164
|
+
del connection.unreceived[: request.received - connection.received]
|
|
165
|
+
connection.received = request.received
|
|
166
|
+
|
|
167
|
+
if not connection.unreceived:
|
|
168
|
+
try:
|
|
169
|
+
connection.unreceived = await connection.read_process()
|
|
170
|
+
finally:
|
|
171
|
+
cancel = activity.cancellation_details()
|
|
172
|
+
if cancel is not None and cancel.worker_shutdown:
|
|
173
|
+
raise TransportClosedError(CONNECTION_LOST)
|
|
174
|
+
|
|
175
|
+
return ReadResult(_fit_payload(connection.unreceived))
|
|
176
|
+
except CodexError as error:
|
|
177
|
+
raise activity_error(error) from error
|
|
178
|
+
|
|
179
|
+
@activity.defn(name=CLOSE)
|
|
180
|
+
@auto_heartbeater
|
|
181
|
+
async def close(self, request: CloseActivityInput) -> None:
|
|
182
|
+
"""Close Codex and everything it started."""
|
|
183
|
+
connection = self._connections.get(request.handle)
|
|
184
|
+
if connection is None: # A retry of a close that succeeded.
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
await connection.close()
|
|
189
|
+
self._connections.pop(request.handle, None)
|
|
190
|
+
except CodexError as error:
|
|
191
|
+
raise activity_error(error) from error
|
|
192
|
+
|
|
193
|
+
async def shut_down(self) -> None:
|
|
194
|
+
"""Close every Codex this Worker started."""
|
|
195
|
+
connections, self._connections = self._connections, {}
|
|
196
|
+
|
|
197
|
+
results = await asyncio.gather(
|
|
198
|
+
*(connection.close() for connection in connections.values()),
|
|
199
|
+
return_exceptions=True,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
for result in results:
|
|
203
|
+
if isinstance(result, BaseException):
|
|
204
|
+
raise result
|
|
205
|
+
|
|
206
|
+
def _connection(self, handle: str) -> _Connection:
|
|
207
|
+
"""The Codex ``connect`` started, if this Worker still holds it."""
|
|
208
|
+
connection = self._connections.get(handle)
|
|
209
|
+
if connection is None:
|
|
210
|
+
# Closed, or started by an earlier run, such as one a reset replaced.
|
|
211
|
+
raise TransportClosedError(CONNECTION_LOST)
|
|
212
|
+
return connection
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _fit_payload(outputs: list[Message]) -> list[Message]:
|
|
216
|
+
"""The most of ``outputs``, from the start, that fits Temporal's payload limit, and at least one."""
|
|
217
|
+
limit = PayloadLimitsConfig().payloads_warn_size
|
|
218
|
+
size = 2 # The surrounding JSON list.
|
|
219
|
+
for count, output in enumerate(outputs):
|
|
220
|
+
size += len(json.dumps(output).encode()) + (2 if count else 0) # ", "
|
|
221
|
+
if count and size > limit:
|
|
222
|
+
return outputs[:count]
|
|
223
|
+
return list(outputs)
|