@oneciel-ai/ciel-runtime 0.1.0 → 0.1.1

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.
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import queue
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Iterable
12
+
13
+
14
+ class CodexAppServerError(RuntimeError):
15
+ """Raised when the Codex app-server JSON-RPC transport fails."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CodexAppServerState:
20
+ thread_id: str | None = None
21
+ active_turn_id: str | None = None
22
+ last_turn_status: str | None = None
23
+
24
+
25
+ def codex_app_server_has_explicit_transport(args: Iterable[str]) -> bool:
26
+ values = list(args)
27
+ for idx, arg in enumerate(values):
28
+ text = str(arg)
29
+ if text == "--stdio" or text.startswith("--listen="):
30
+ return True
31
+ if text == "--listen" and idx + 1 < len(values):
32
+ return True
33
+ return False
34
+
35
+
36
+ def codex_app_server_has_subcommand(args: Iterable[str]) -> bool:
37
+ values = list(args)
38
+ i = 0
39
+ while i < len(values):
40
+ arg = str(values[i])
41
+ if arg == "--":
42
+ return bool(i + 1 < len(values))
43
+ if arg in ("-c", "--config", "--listen", "--enable", "--disable", "--ws-auth", "--ws-token-file", "--ws-token-sha256", "--ws-shared-secret-file", "--ws-issuer", "--ws-audience", "--ws-max-clock-skew-seconds"):
44
+ i += 2
45
+ continue
46
+ if arg.startswith("-"):
47
+ i += 1
48
+ continue
49
+ return arg in {"daemon", "proxy", "generate-ts", "generate-json-schema", "help"}
50
+ return False
51
+
52
+
53
+ def codex_app_server_launch_args(
54
+ passthrough: Iterable[str],
55
+ *,
56
+ config_args: Iterable[str] = (),
57
+ default_listen_url: str | None = None,
58
+ ) -> list[str]:
59
+ args = [str(arg) for arg in passthrough]
60
+ out = ["app-server", *[str(arg) for arg in config_args]]
61
+ if default_listen_url and not codex_app_server_has_explicit_transport(args) and not codex_app_server_has_subcommand(args):
62
+ out.extend(["--listen", default_listen_url])
63
+ out.extend(args)
64
+ return out
65
+
66
+
67
+ def command_for_popen(executable: str, args: Iterable[str]) -> list[str]:
68
+ argv = [str(executable), *[str(arg) for arg in args]]
69
+ if os.name == "nt" and str(executable).lower().endswith((".cmd", ".bat")):
70
+ comspec = os.environ.get("COMSPEC") or "cmd.exe"
71
+ return [comspec, "/d", "/s", "/c", subprocess.list2cmdline(argv)]
72
+ return argv
73
+
74
+
75
+ def text_user_input(text: str) -> dict[str, Any]:
76
+ return {"type": "text", "text": text}
77
+
78
+
79
+ def responses_user_message_item(text: str) -> dict[str, Any]:
80
+ return {
81
+ "type": "message",
82
+ "role": "user",
83
+ "content": [{"type": "input_text", "text": text}],
84
+ }
85
+
86
+
87
+ def _omit_none(values: dict[str, Any]) -> dict[str, Any]:
88
+ return {key: value for key, value in values.items() if value is not None}
89
+
90
+
91
+ class CodexAppServerClient:
92
+ """Small JSON-RPC client for `codex app-server` stdio.
93
+
94
+ Codex app-server intentionally omits the JSON-RPC `"jsonrpc": "2.0"`
95
+ field on the wire. Requests are still matched by `id`.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ process: subprocess.Popen[str],
101
+ *,
102
+ now: Callable[[], float] = time.monotonic,
103
+ ) -> None:
104
+ self.process = process
105
+ self._now = now
106
+ self._next_id = 1
107
+ self._lock = threading.RLock()
108
+ self._condition = threading.Condition(self._lock)
109
+ self._responses: dict[int, dict[str, Any]] = {}
110
+ self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
111
+ self._reader_error: BaseException | None = None
112
+ self._state = CodexAppServerState()
113
+ if process.stdout is None or process.stdin is None:
114
+ raise CodexAppServerError("codex app-server process must have stdin/stdout pipes")
115
+ self._reader = threading.Thread(target=self._read_loop, name="codex-app-server-reader", daemon=True)
116
+ self._reader.start()
117
+
118
+ @classmethod
119
+ def spawn(
120
+ cls,
121
+ codex_executable: str,
122
+ args: Iterable[str] = (),
123
+ *,
124
+ env: dict[str, str] | None = None,
125
+ cwd: str | os.PathLike[str] | None = None,
126
+ ) -> "CodexAppServerClient":
127
+ cmd = command_for_popen(codex_executable, ["app-server", "--stdio", *[str(arg) for arg in args]])
128
+ proc = subprocess.Popen(
129
+ cmd,
130
+ stdin=subprocess.PIPE,
131
+ stdout=subprocess.PIPE,
132
+ stderr=subprocess.PIPE,
133
+ text=True,
134
+ encoding="utf-8",
135
+ errors="replace",
136
+ env=env,
137
+ cwd=str(cwd) if cwd is not None else None,
138
+ bufsize=1,
139
+ )
140
+ return cls(proc)
141
+
142
+ @property
143
+ def state(self) -> CodexAppServerState:
144
+ with self._lock:
145
+ return self._state
146
+
147
+ def close(self) -> None:
148
+ try:
149
+ if self.process.stdin is not None:
150
+ self.process.stdin.close()
151
+ except Exception:
152
+ pass
153
+ if self.process.poll() is None:
154
+ try:
155
+ self.process.terminate()
156
+ except Exception:
157
+ pass
158
+
159
+ def initialize(
160
+ self,
161
+ *,
162
+ client_name: str,
163
+ client_title: str | None,
164
+ client_version: str,
165
+ experimental_api: bool = True,
166
+ request_attestation: bool = False,
167
+ ) -> dict[str, Any]:
168
+ result = self.request(
169
+ "initialize",
170
+ {
171
+ "clientInfo": {
172
+ "name": client_name,
173
+ "title": client_title,
174
+ "version": client_version,
175
+ },
176
+ "capabilities": {
177
+ "experimentalApi": bool(experimental_api),
178
+ "requestAttestation": bool(request_attestation),
179
+ },
180
+ },
181
+ )
182
+ self.notify("initialized")
183
+ return result
184
+
185
+ def start_thread(
186
+ self,
187
+ *,
188
+ cwd: str | os.PathLike[str] | None = None,
189
+ model: str | None = None,
190
+ model_provider: str | None = None,
191
+ permissions: str | None = None,
192
+ approval_policy: str | None = None,
193
+ ) -> dict[str, Any]:
194
+ result = self.request(
195
+ "thread/start",
196
+ _omit_none(
197
+ {
198
+ "cwd": str(cwd) if cwd is not None else None,
199
+ "model": model,
200
+ "modelProvider": model_provider,
201
+ "permissions": permissions,
202
+ "approvalPolicy": approval_policy,
203
+ }
204
+ ),
205
+ )
206
+ thread_id = _thread_id_from_response(result)
207
+ if thread_id:
208
+ with self._lock:
209
+ self._state = CodexAppServerState(
210
+ thread_id=thread_id,
211
+ active_turn_id=self._state.active_turn_id,
212
+ last_turn_status=self._state.last_turn_status,
213
+ )
214
+ return result
215
+
216
+ def resume_thread(
217
+ self,
218
+ thread_id: str,
219
+ *,
220
+ cwd: str | os.PathLike[str] | None = None,
221
+ model: str | None = None,
222
+ model_provider: str | None = None,
223
+ exclude_turns: bool = True,
224
+ ) -> dict[str, Any]:
225
+ result = self.request(
226
+ "thread/resume",
227
+ _omit_none(
228
+ {
229
+ "threadId": thread_id,
230
+ "cwd": str(cwd) if cwd is not None else None,
231
+ "model": model,
232
+ "modelProvider": model_provider,
233
+ "excludeTurns": exclude_turns,
234
+ }
235
+ ),
236
+ )
237
+ with self._lock:
238
+ self._state = CodexAppServerState(
239
+ thread_id=thread_id,
240
+ active_turn_id=self._state.active_turn_id,
241
+ last_turn_status=self._state.last_turn_status,
242
+ )
243
+ return result
244
+
245
+ def turn_start(
246
+ self,
247
+ thread_id: str,
248
+ text: str,
249
+ *,
250
+ cwd: str | os.PathLike[str] | None = None,
251
+ client_user_message_id: str | None = None,
252
+ responsesapi_client_metadata: dict[str, str] | None = None,
253
+ model: str | None = None,
254
+ permissions: str | None = None,
255
+ ) -> dict[str, Any]:
256
+ return self.request(
257
+ "turn/start",
258
+ _omit_none(
259
+ {
260
+ "threadId": thread_id,
261
+ "clientUserMessageId": client_user_message_id,
262
+ "input": [text_user_input(text)],
263
+ "responsesapiClientMetadata": responsesapi_client_metadata,
264
+ "cwd": str(cwd) if cwd is not None else None,
265
+ "model": model,
266
+ "permissions": permissions,
267
+ }
268
+ ),
269
+ )
270
+
271
+ def turn_steer(
272
+ self,
273
+ thread_id: str,
274
+ expected_turn_id: str,
275
+ text: str,
276
+ *,
277
+ client_user_message_id: str | None = None,
278
+ responsesapi_client_metadata: dict[str, str] | None = None,
279
+ ) -> dict[str, Any]:
280
+ return self.request(
281
+ "turn/steer",
282
+ _omit_none(
283
+ {
284
+ "threadId": thread_id,
285
+ "clientUserMessageId": client_user_message_id,
286
+ "input": [text_user_input(text)],
287
+ "responsesapiClientMetadata": responsesapi_client_metadata,
288
+ "expectedTurnId": expected_turn_id,
289
+ }
290
+ ),
291
+ )
292
+
293
+ def compact_thread(self, thread_id: str) -> dict[str, Any]:
294
+ return self.request("thread/compact/start", {"threadId": thread_id})
295
+
296
+ def inject_user_message_item(self, thread_id: str, text: str) -> dict[str, Any]:
297
+ return self.request("thread/inject_items", {"threadId": thread_id, "items": [responses_user_message_item(text)]})
298
+
299
+ def request(self, method: str, params: dict[str, Any] | None = None, *, timeout: float = 30.0) -> dict[str, Any]:
300
+ with self._lock:
301
+ request_id = self._next_id
302
+ self._next_id += 1
303
+ payload: dict[str, Any] = {"id": request_id, "method": method}
304
+ if params is not None:
305
+ payload["params"] = params
306
+ self._write_json(payload)
307
+ deadline = self._now() + timeout
308
+ with self._condition:
309
+ while True:
310
+ if request_id in self._responses:
311
+ response = self._responses.pop(request_id)
312
+ break
313
+ if self._reader_error is not None:
314
+ raise CodexAppServerError(f"codex app-server reader failed: {self._reader_error}") from self._reader_error
315
+ remaining = deadline - self._now()
316
+ if remaining <= 0:
317
+ raise CodexAppServerError(f"timed out waiting for codex app-server response to {method}")
318
+ self._condition.wait(timeout=remaining)
319
+ if "error" in response:
320
+ raise CodexAppServerError(f"codex app-server {method} failed: {response['error']}")
321
+ result = response.get("result")
322
+ return result if isinstance(result, dict) else {}
323
+
324
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
325
+ payload: dict[str, Any] = {"method": method}
326
+ if params is not None:
327
+ payload["params"] = params
328
+ self._write_json(payload)
329
+
330
+ def next_notification(self, *, timeout: float = 0.0) -> dict[str, Any] | None:
331
+ try:
332
+ return self._notifications.get(timeout=timeout)
333
+ except queue.Empty:
334
+ return None
335
+
336
+ def _write_json(self, payload: dict[str, Any]) -> None:
337
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
338
+ stdin = self.process.stdin
339
+ if stdin is None:
340
+ raise CodexAppServerError("codex app-server stdin is closed")
341
+ stdin.write(data)
342
+ stdin.flush()
343
+
344
+ def _read_loop(self) -> None:
345
+ try:
346
+ stdout = self.process.stdout
347
+ if stdout is None:
348
+ return
349
+ for line in stdout:
350
+ stripped = line.strip()
351
+ if not stripped:
352
+ continue
353
+ try:
354
+ message = json.loads(stripped)
355
+ except json.JSONDecodeError as exc:
356
+ self._reader_error = exc
357
+ with self._condition:
358
+ self._condition.notify_all()
359
+ return
360
+ if not isinstance(message, dict):
361
+ continue
362
+ request_id = message.get("id")
363
+ if isinstance(request_id, int):
364
+ with self._condition:
365
+ self._responses[request_id] = message
366
+ self._condition.notify_all()
367
+ continue
368
+ self._track_notification(message)
369
+ self._notifications.put(message)
370
+ except BaseException as exc:
371
+ self._reader_error = exc
372
+ with self._condition:
373
+ self._condition.notify_all()
374
+
375
+ def _track_notification(self, message: dict[str, Any]) -> None:
376
+ method = message.get("method")
377
+ params = message.get("params")
378
+ if not isinstance(params, dict):
379
+ return
380
+ with self._lock:
381
+ thread_id = self._state.thread_id
382
+ active_turn_id = self._state.active_turn_id
383
+ last_turn_status = self._state.last_turn_status
384
+ if method in ("thread/started", "thread/status/changed", "turn/started", "turn/completed"):
385
+ candidate_thread_id = params.get("threadId")
386
+ if isinstance(candidate_thread_id, str) and candidate_thread_id:
387
+ thread_id = candidate_thread_id
388
+ turn = params.get("turn")
389
+ if isinstance(turn, dict):
390
+ turn_id = turn.get("id")
391
+ status = turn.get("status")
392
+ if isinstance(status, str):
393
+ last_turn_status = status
394
+ if method == "turn/started" and isinstance(turn_id, str) and turn_id:
395
+ active_turn_id = turn_id
396
+ elif method == "turn/completed":
397
+ active_turn_id = None
398
+ self._state = CodexAppServerState(
399
+ thread_id=thread_id,
400
+ active_turn_id=active_turn_id,
401
+ last_turn_status=last_turn_status,
402
+ )
403
+
404
+
405
+ def _thread_id_from_response(result: dict[str, Any]) -> str | None:
406
+ thread = result.get("thread")
407
+ if isinstance(thread, dict):
408
+ thread_id = thread.get("id")
409
+ if isinstance(thread_id, str) and thread_id:
410
+ return thread_id
411
+ thread_id = result.get("threadId")
412
+ if isinstance(thread_id, str) and thread_id:
413
+ return thread_id
414
+ path = result.get("path")
415
+ if isinstance(path, (str, Path)) and str(path):
416
+ return None
417
+ return None
418
+
419
+
420
+ __all__ = [
421
+ "CodexAppServerClient",
422
+ "CodexAppServerError",
423
+ "CodexAppServerState",
424
+ "codex_app_server_has_explicit_transport",
425
+ "codex_app_server_has_subcommand",
426
+ "codex_app_server_launch_args",
427
+ "command_for_popen",
428
+ "responses_user_message_item",
429
+ "text_user_input",
430
+ ]