@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.
- package/ciel-runtime-menu.py +204 -22
- package/ciel-runtime-tool-guard.py +29 -24
- package/ciel_runtime.py +6023 -2515
- package/ciel_runtime_support/agent_router.py +97 -0
- package/ciel_runtime_support/agy_cli.py +248 -0
- package/ciel_runtime_support/claude_router.py +479 -0
- package/ciel_runtime_support/codex_app_server.py +430 -0
- package/ciel_runtime_support/codex_cli.py +303 -0
- package/ciel_runtime_support/codex_router.py +80 -0
- package/docs/AGY-CLI-Research.md +62 -0
- package/docs/Architecture.md +23 -0
- package/docs/CLI-Reference.md +1 -1
- package/docs/Home.md +1 -1
- package/docs/Installation.md +9 -5
- package/docs/MCP-Channels.md +2 -4
- package/docs/Module-Map.md +29 -1
- package/docs/Providers.md +3 -2
- package/docs/Router.md +1 -1
- package/docs/Test-Suite.md +1 -0
- package/package.json +2 -3
- package/npm-bin/postinstall.js +0 -46
|
@@ -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
|
+
]
|