codex-python-sdk 0.1.0__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.
- codex_python_sdk/__init__.py +57 -0
- codex_python_sdk/_shared.py +99 -0
- codex_python_sdk/async_client.py +1313 -0
- codex_python_sdk/errors.py +18 -0
- codex_python_sdk/examples/__init__.py +2 -0
- codex_python_sdk/examples/demo_smoke.py +304 -0
- codex_python_sdk/factory.py +25 -0
- codex_python_sdk/policy.py +636 -0
- codex_python_sdk/renderer.py +607 -0
- codex_python_sdk/sync_client.py +333 -0
- codex_python_sdk/types.py +48 -0
- codex_python_sdk-0.1.0.dist-info/METADATA +274 -0
- codex_python_sdk-0.1.0.dist-info/RECORD +17 -0
- codex_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- codex_python_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- codex_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from ._shared import (
|
|
10
|
+
diff_change_counts,
|
|
11
|
+
first_nonempty_text,
|
|
12
|
+
token_usage_summary,
|
|
13
|
+
turn_plan_summary,
|
|
14
|
+
)
|
|
15
|
+
from .errors import (
|
|
16
|
+
AppServerConnectionError,
|
|
17
|
+
CodexAgenticError,
|
|
18
|
+
NotAuthenticatedError,
|
|
19
|
+
SessionNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from .types import AgentResponse, ResponseEvent
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .policy import PolicyContext, PolicyEngine, PolicyJudgeConfig, PolicyRubric
|
|
25
|
+
|
|
26
|
+
DEFAULT_CLI_COMMAND = "codex"
|
|
27
|
+
DEFAULT_APP_SERVER_ARGS = ["app-server"]
|
|
28
|
+
DEFAULT_NOTIFICATION_BUFFER_LIMIT = 1024
|
|
29
|
+
DEFAULT_STDERR_BUFFER_LIMIT = 500
|
|
30
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 60.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncCodexAgenticClient:
|
|
34
|
+
"""Async wrapper around `codex app-server` using JSON-RPC over stdio."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
codex_command: str = DEFAULT_CLI_COMMAND,
|
|
40
|
+
app_server_args: list[str] | None = None,
|
|
41
|
+
env: dict[str, str] | None = None,
|
|
42
|
+
process_cwd: str | None = None,
|
|
43
|
+
default_thread_params: dict[str, Any] | None = None,
|
|
44
|
+
default_turn_params: dict[str, Any] | None = None,
|
|
45
|
+
enable_web_search: bool = True,
|
|
46
|
+
server_config_overrides: dict[str, Any] | None = None,
|
|
47
|
+
stream_idle_timeout_seconds: float | None = DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS,
|
|
48
|
+
on_command_approval: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
|
|
49
|
+
on_file_change_approval: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
|
|
50
|
+
on_tool_request_user_input: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
|
|
51
|
+
on_tool_call: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
|
|
52
|
+
policy_engine: "PolicyEngine | None" = None,
|
|
53
|
+
policy_rubric: "PolicyRubric | dict[str, Any] | None" = None,
|
|
54
|
+
policy_judge_config: "PolicyJudgeConfig | None" = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Create an async app-server client.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
codex_command: Executable name/path, usually ``"codex"``.
|
|
60
|
+
app_server_args: CLI args passed after ``codex``; defaults to ``["app-server"]``.
|
|
61
|
+
env: Environment for the child process.
|
|
62
|
+
process_cwd: Working directory used when launching app-server.
|
|
63
|
+
default_thread_params: Baseline params for thread-level requests.
|
|
64
|
+
default_turn_params: Baseline params for turn-level requests.
|
|
65
|
+
enable_web_search: If true, appends ``--enable web_search`` at launch.
|
|
66
|
+
server_config_overrides: Config key-values serialized to ``-c key=value``.
|
|
67
|
+
stream_idle_timeout_seconds: Max consecutive seconds without matching turn events
|
|
68
|
+
before stream wait fails. Set ``None`` to disable this guard.
|
|
69
|
+
on_command_approval: Handler for ``item/commandExecution/requestApproval``.
|
|
70
|
+
on_file_change_approval: Handler for ``item/fileChange/requestApproval``.
|
|
71
|
+
on_tool_request_user_input: Handler for ``item/tool/requestUserInput``.
|
|
72
|
+
on_tool_call: Handler for ``item/tool/call``.
|
|
73
|
+
policy_engine: Optional policy engine used when explicit hooks are absent.
|
|
74
|
+
policy_rubric: Optional rubric used to auto-build a policy engine when
|
|
75
|
+
``policy_engine`` is not provided.
|
|
76
|
+
policy_judge_config: Optional LLM-judge settings when rubric builds an LLM policy.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
self.codex_command = codex_command
|
|
80
|
+
self.app_server_args = app_server_args[:] if app_server_args else DEFAULT_APP_SERVER_ARGS[:]
|
|
81
|
+
self.env = os.environ.copy() if env is None else env.copy()
|
|
82
|
+
|
|
83
|
+
self.process_cwd = os.path.abspath(process_cwd or os.getcwd())
|
|
84
|
+
self.default_thread_params = dict(default_thread_params or {})
|
|
85
|
+
self.default_turn_params = dict(default_turn_params or {})
|
|
86
|
+
self.enable_web_search = enable_web_search
|
|
87
|
+
self.server_config_overrides = dict(server_config_overrides or {})
|
|
88
|
+
self.stream_idle_timeout_seconds = stream_idle_timeout_seconds
|
|
89
|
+
self.on_command_approval = on_command_approval
|
|
90
|
+
self.on_file_change_approval = on_file_change_approval
|
|
91
|
+
self.on_tool_request_user_input = on_tool_request_user_input
|
|
92
|
+
self.on_tool_call = on_tool_call
|
|
93
|
+
self.policy_engine = policy_engine
|
|
94
|
+
if self.policy_engine is None and policy_rubric is not None:
|
|
95
|
+
from .policy import build_policy_engine_from_rubric
|
|
96
|
+
|
|
97
|
+
self.policy_engine = build_policy_engine_from_rubric(
|
|
98
|
+
policy_rubric,
|
|
99
|
+
judge_config=policy_judge_config,
|
|
100
|
+
codex_command=codex_command,
|
|
101
|
+
app_server_args=app_server_args,
|
|
102
|
+
env=env,
|
|
103
|
+
process_cwd=self.process_cwd,
|
|
104
|
+
server_config_overrides=server_config_overrides,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
108
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
109
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
110
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
111
|
+
self._next_id = 1
|
|
112
|
+
self._connected = False
|
|
113
|
+
self._runtime_loop: asyncio.AbstractEventLoop | None = None
|
|
114
|
+
self._connect_lock_ref: asyncio.Lock | None = None
|
|
115
|
+
self._write_lock_ref: asyncio.Lock | None = None
|
|
116
|
+
self._notification_queue_ref: asyncio.Queue[dict[str, Any]] | None = None
|
|
117
|
+
self._notification_buffer: list[dict[str, Any]] = []
|
|
118
|
+
self._notification_buffer_limit = DEFAULT_NOTIFICATION_BUFFER_LIMIT
|
|
119
|
+
self._notification_condition_ref: asyncio.Condition | None = None
|
|
120
|
+
self._notification_router_task: asyncio.Task[None] | None = None
|
|
121
|
+
self._stderr_buffer_limit = DEFAULT_STDERR_BUFFER_LIMIT
|
|
122
|
+
self._stderr_lines: list[str] = []
|
|
123
|
+
self._user_agent: str | None = None
|
|
124
|
+
|
|
125
|
+
async def __aenter__(self) -> "AsyncCodexAgenticClient":
|
|
126
|
+
await self.connect()
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
130
|
+
await self.close()
|
|
131
|
+
|
|
132
|
+
def _ensure_runtime_primitives(self) -> None:
|
|
133
|
+
loop = asyncio.get_running_loop()
|
|
134
|
+
if self._runtime_loop is None:
|
|
135
|
+
self._runtime_loop = loop
|
|
136
|
+
elif self._runtime_loop is not loop:
|
|
137
|
+
raise CodexAgenticError("Async client cannot be shared across different event loops.")
|
|
138
|
+
|
|
139
|
+
if self._connect_lock_ref is None:
|
|
140
|
+
self._connect_lock_ref = asyncio.Lock()
|
|
141
|
+
if self._write_lock_ref is None:
|
|
142
|
+
self._write_lock_ref = asyncio.Lock()
|
|
143
|
+
if self._notification_queue_ref is None:
|
|
144
|
+
self._notification_queue_ref = asyncio.Queue()
|
|
145
|
+
if self._notification_condition_ref is None:
|
|
146
|
+
self._notification_condition_ref = asyncio.Condition()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def _connect_lock(self) -> asyncio.Lock:
|
|
150
|
+
if self._connect_lock_ref is None:
|
|
151
|
+
self._ensure_runtime_primitives()
|
|
152
|
+
assert self._connect_lock_ref is not None
|
|
153
|
+
return self._connect_lock_ref
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def _write_lock(self) -> asyncio.Lock:
|
|
157
|
+
if self._write_lock_ref is None:
|
|
158
|
+
self._ensure_runtime_primitives()
|
|
159
|
+
assert self._write_lock_ref is not None
|
|
160
|
+
return self._write_lock_ref
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def _notification_queue(self) -> asyncio.Queue[dict[str, Any]]:
|
|
164
|
+
if self._notification_queue_ref is None:
|
|
165
|
+
self._ensure_runtime_primitives()
|
|
166
|
+
assert self._notification_queue_ref is not None
|
|
167
|
+
return self._notification_queue_ref
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def _notification_condition(self) -> asyncio.Condition:
|
|
171
|
+
if self._notification_condition_ref is None:
|
|
172
|
+
self._ensure_runtime_primitives()
|
|
173
|
+
assert self._notification_condition_ref is not None
|
|
174
|
+
return self._notification_condition_ref
|
|
175
|
+
|
|
176
|
+
async def connect(self) -> None:
|
|
177
|
+
if self._connected:
|
|
178
|
+
return
|
|
179
|
+
async with self._connect_lock:
|
|
180
|
+
if self._connected:
|
|
181
|
+
return
|
|
182
|
+
try:
|
|
183
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
184
|
+
self.codex_command,
|
|
185
|
+
*self._build_server_args(),
|
|
186
|
+
cwd=self.process_cwd,
|
|
187
|
+
env=self.env,
|
|
188
|
+
stdin=asyncio.subprocess.PIPE,
|
|
189
|
+
stdout=asyncio.subprocess.PIPE,
|
|
190
|
+
stderr=asyncio.subprocess.PIPE,
|
|
191
|
+
)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
self._raise_connection_error(exc)
|
|
194
|
+
|
|
195
|
+
assert self._proc is not None
|
|
196
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
197
|
+
self._stderr_task = asyncio.create_task(self._stderr_loop())
|
|
198
|
+
self._ensure_notification_router()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
init_result = await self._request(
|
|
202
|
+
"initialize",
|
|
203
|
+
{
|
|
204
|
+
"clientInfo": {"name": "codex-python-sdk", "version": "0.1"},
|
|
205
|
+
"capabilities": {"experimentalApi": True},
|
|
206
|
+
},
|
|
207
|
+
ensure_connected=False,
|
|
208
|
+
)
|
|
209
|
+
await self._notify("initialized", None)
|
|
210
|
+
self._user_agent = first_nonempty_text(init_result)
|
|
211
|
+
self._connected = True
|
|
212
|
+
except Exception:
|
|
213
|
+
await self.close()
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
async def close(self) -> None:
|
|
217
|
+
self._connected = False
|
|
218
|
+
await self._close_policy_engine()
|
|
219
|
+
|
|
220
|
+
if self._proc is not None:
|
|
221
|
+
proc = self._proc
|
|
222
|
+
self._proc = None
|
|
223
|
+
try:
|
|
224
|
+
if proc.stdin is not None and not proc.stdin.is_closing():
|
|
225
|
+
proc.stdin.close()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
try:
|
|
229
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
230
|
+
except Exception:
|
|
231
|
+
proc.terminate()
|
|
232
|
+
try:
|
|
233
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
234
|
+
except Exception:
|
|
235
|
+
proc.kill()
|
|
236
|
+
try:
|
|
237
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
for fut in self._pending.values():
|
|
242
|
+
if not fut.done():
|
|
243
|
+
fut.set_exception(AppServerConnectionError("App-server transport closed."))
|
|
244
|
+
self._pending.clear()
|
|
245
|
+
|
|
246
|
+
for task in (self._reader_task, self._stderr_task):
|
|
247
|
+
if task is not None:
|
|
248
|
+
task.cancel()
|
|
249
|
+
self._reader_task = None
|
|
250
|
+
self._stderr_task = None
|
|
251
|
+
if self._notification_router_task is not None:
|
|
252
|
+
self._notification_router_task.cancel()
|
|
253
|
+
self._notification_router_task = None
|
|
254
|
+
while True:
|
|
255
|
+
try:
|
|
256
|
+
self._notification_queue.get_nowait()
|
|
257
|
+
except asyncio.QueueEmpty:
|
|
258
|
+
break
|
|
259
|
+
async with self._notification_condition:
|
|
260
|
+
self._notification_buffer.clear()
|
|
261
|
+
self._notification_condition.notify_all()
|
|
262
|
+
self._stderr_lines.clear()
|
|
263
|
+
|
|
264
|
+
async def _close_policy_engine(self) -> None:
|
|
265
|
+
engine = self.policy_engine
|
|
266
|
+
if engine is None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
for close_name in ("aclose", "close"):
|
|
270
|
+
closer = getattr(engine, close_name, None)
|
|
271
|
+
if not callable(closer):
|
|
272
|
+
continue
|
|
273
|
+
maybe_result = closer()
|
|
274
|
+
if inspect.isawaitable(maybe_result):
|
|
275
|
+
await maybe_result
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
def _build_server_args(self) -> list[str]:
|
|
279
|
+
args = self.app_server_args[:]
|
|
280
|
+
if self.enable_web_search:
|
|
281
|
+
args.extend(["--enable", "web_search"])
|
|
282
|
+
for key, value in self.server_config_overrides.items():
|
|
283
|
+
args.extend(["-c", f"{key}={self._to_toml_literal(value)}"])
|
|
284
|
+
return args
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _to_toml_literal(value: Any) -> str:
|
|
288
|
+
if isinstance(value, bool):
|
|
289
|
+
return "true" if value else "false"
|
|
290
|
+
if isinstance(value, (int, float)):
|
|
291
|
+
return str(value)
|
|
292
|
+
if isinstance(value, list):
|
|
293
|
+
return "[" + ", ".join(AsyncCodexAgenticClient._to_toml_literal(v) for v in value) + "]"
|
|
294
|
+
if isinstance(value, dict):
|
|
295
|
+
fields: list[str] = []
|
|
296
|
+
for key, nested in value.items():
|
|
297
|
+
quoted_key = json.dumps(str(key))
|
|
298
|
+
fields.append(f"{quoted_key} = {AsyncCodexAgenticClient._to_toml_literal(nested)}")
|
|
299
|
+
return "{" + ", ".join(fields) + "}"
|
|
300
|
+
return json.dumps(str(value))
|
|
301
|
+
|
|
302
|
+
async def _reader_loop(self) -> None:
|
|
303
|
+
assert self._proc is not None
|
|
304
|
+
assert self._proc.stdout is not None
|
|
305
|
+
try:
|
|
306
|
+
while True:
|
|
307
|
+
line = await self._proc.stdout.readline()
|
|
308
|
+
if not line:
|
|
309
|
+
break
|
|
310
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
311
|
+
if not text:
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
msg = json.loads(text)
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if isinstance(msg, dict) and "id" in msg and "method" not in msg:
|
|
319
|
+
msg_id = msg.get("id")
|
|
320
|
+
if isinstance(msg_id, int) and msg_id in self._pending:
|
|
321
|
+
fut = self._pending.pop(msg_id)
|
|
322
|
+
if not fut.done():
|
|
323
|
+
fut.set_result(msg)
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if isinstance(msg, dict) and "method" in msg and "id" in msg:
|
|
327
|
+
asyncio.create_task(self._dispatch_server_request(msg))
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
if isinstance(msg, dict) and "method" in msg:
|
|
331
|
+
await self._notification_queue.put(msg)
|
|
332
|
+
finally:
|
|
333
|
+
self._connected = False
|
|
334
|
+
for fut in self._pending.values():
|
|
335
|
+
if not fut.done():
|
|
336
|
+
fut.set_exception(AppServerConnectionError("App-server stream ended unexpectedly."))
|
|
337
|
+
self._pending.clear()
|
|
338
|
+
async with self._notification_condition:
|
|
339
|
+
self._notification_condition.notify_all()
|
|
340
|
+
|
|
341
|
+
async def _stderr_loop(self) -> None:
|
|
342
|
+
assert self._proc is not None
|
|
343
|
+
assert self._proc.stderr is not None
|
|
344
|
+
while True:
|
|
345
|
+
line = await self._proc.stderr.readline()
|
|
346
|
+
if not line:
|
|
347
|
+
break
|
|
348
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
349
|
+
if text:
|
|
350
|
+
self._stderr_lines.append(text)
|
|
351
|
+
overflow = len(self._stderr_lines) - self._stderr_buffer_limit
|
|
352
|
+
if overflow > 0:
|
|
353
|
+
del self._stderr_lines[:overflow]
|
|
354
|
+
|
|
355
|
+
def _ensure_notification_router(self) -> None:
|
|
356
|
+
task = self._notification_router_task
|
|
357
|
+
if task is not None and not task.done():
|
|
358
|
+
return
|
|
359
|
+
self._notification_router_task = asyncio.create_task(self._notification_router_loop())
|
|
360
|
+
|
|
361
|
+
async def _notification_router_loop(self) -> None:
|
|
362
|
+
try:
|
|
363
|
+
while True:
|
|
364
|
+
msg = await self._notification_queue.get()
|
|
365
|
+
async with self._notification_condition:
|
|
366
|
+
self._notification_buffer.append(msg)
|
|
367
|
+
overflow = len(self._notification_buffer) - self._notification_buffer_limit
|
|
368
|
+
if overflow > 0:
|
|
369
|
+
del self._notification_buffer[:overflow]
|
|
370
|
+
self._notification_condition.notify_all()
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
async def _dispatch_server_request(self, msg: dict[str, Any]) -> None:
|
|
375
|
+
try:
|
|
376
|
+
await self._handle_server_request(msg)
|
|
377
|
+
except asyncio.CancelledError:
|
|
378
|
+
raise
|
|
379
|
+
except CodexAgenticError as exc:
|
|
380
|
+
req_id = msg.get("id")
|
|
381
|
+
if not self._is_jsonrpc_request_id(req_id):
|
|
382
|
+
return
|
|
383
|
+
await self._send_server_request_error(req_id=req_id, code=-32000, message=str(exc))
|
|
384
|
+
except Exception:
|
|
385
|
+
req_id = msg.get("id")
|
|
386
|
+
if not self._is_jsonrpc_request_id(req_id):
|
|
387
|
+
return
|
|
388
|
+
await self._send_server_request_error(req_id=req_id, code=-32603, message="Server request handler failed.")
|
|
389
|
+
|
|
390
|
+
async def _send_server_request_error(self, *, req_id: str | int, code: int, message: str) -> None:
|
|
391
|
+
try:
|
|
392
|
+
await self._send_json(
|
|
393
|
+
{
|
|
394
|
+
"jsonrpc": "2.0",
|
|
395
|
+
"id": req_id,
|
|
396
|
+
"error": {"code": code, "message": message},
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
except Exception:
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _notification_matches_stream(notification: dict[str, Any], thread_id: str, turn_id: str | None) -> bool:
|
|
404
|
+
note_thread_id = AsyncCodexAgenticClient._extract_thread_id_from_payload(notification)
|
|
405
|
+
if note_thread_id and note_thread_id != thread_id:
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
note_turn_id = AsyncCodexAgenticClient._extract_turn_id(notification)
|
|
409
|
+
if turn_id and note_turn_id and note_turn_id != turn_id:
|
|
410
|
+
return False
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _is_jsonrpc_request_id(value: Any) -> bool:
|
|
415
|
+
if isinstance(value, bool):
|
|
416
|
+
return False
|
|
417
|
+
return isinstance(value, (str, int))
|
|
418
|
+
|
|
419
|
+
async def _wait_for_matching_notification(self, thread_id: str, turn_id: str | None) -> dict[str, Any]:
|
|
420
|
+
self._ensure_notification_router()
|
|
421
|
+
timeout = 5.0
|
|
422
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
423
|
+
|
|
424
|
+
while True:
|
|
425
|
+
async with self._notification_condition:
|
|
426
|
+
for index, notification in enumerate(self._notification_buffer):
|
|
427
|
+
if self._notification_matches_stream(notification, thread_id, turn_id):
|
|
428
|
+
return self._notification_buffer.pop(index)
|
|
429
|
+
|
|
430
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
431
|
+
if remaining <= 0:
|
|
432
|
+
raise asyncio.TimeoutError
|
|
433
|
+
await asyncio.wait_for(self._notification_condition.wait(), timeout=remaining)
|
|
434
|
+
|
|
435
|
+
async def _handle_server_request(self, msg: dict[str, Any]) -> None:
|
|
436
|
+
method = str(msg.get("method", ""))
|
|
437
|
+
req_id = msg.get("id")
|
|
438
|
+
if not self._is_jsonrpc_request_id(req_id):
|
|
439
|
+
return
|
|
440
|
+
params = msg.get("params")
|
|
441
|
+
if not isinstance(params, dict):
|
|
442
|
+
params = {}
|
|
443
|
+
handlers: dict[str, Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]] = {
|
|
444
|
+
"item/commandExecution/requestApproval": self._handle_command_approval_request,
|
|
445
|
+
"item/fileChange/requestApproval": self._handle_file_change_request,
|
|
446
|
+
"item/tool/requestUserInput": self._handle_tool_user_input_request,
|
|
447
|
+
"item/tool/call": self._handle_tool_call_request,
|
|
448
|
+
}
|
|
449
|
+
handler = handlers.get(method)
|
|
450
|
+
if handler is None:
|
|
451
|
+
await self._send_json(
|
|
452
|
+
{
|
|
453
|
+
"jsonrpc": "2.0",
|
|
454
|
+
"id": req_id,
|
|
455
|
+
"error": {"code": -32601, "message": f"Unsupported server request method: {method}"},
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
result = await handler(method, params)
|
|
461
|
+
await self._send_json({"jsonrpc": "2.0", "id": req_id, "result": result})
|
|
462
|
+
|
|
463
|
+
async def _handle_command_approval_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
464
|
+
del method
|
|
465
|
+
handler = self.on_command_approval
|
|
466
|
+
if handler is None and self.policy_engine is not None:
|
|
467
|
+
context = self._build_policy_context("command", "item/commandExecution/requestApproval", params)
|
|
468
|
+
|
|
469
|
+
def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
|
|
470
|
+
assert self.policy_engine is not None
|
|
471
|
+
return self.policy_engine.on_command_approval(payload, context)
|
|
472
|
+
|
|
473
|
+
handler = _policy_handler
|
|
474
|
+
return await self._resolve_server_request(
|
|
475
|
+
"item/commandExecution/requestApproval",
|
|
476
|
+
params,
|
|
477
|
+
handler,
|
|
478
|
+
{"decision": "accept"},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def _handle_file_change_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
482
|
+
del method
|
|
483
|
+
handler = self.on_file_change_approval
|
|
484
|
+
if handler is None and self.policy_engine is not None:
|
|
485
|
+
context = self._build_policy_context("file_change", "item/fileChange/requestApproval", params)
|
|
486
|
+
|
|
487
|
+
def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
|
|
488
|
+
assert self.policy_engine is not None
|
|
489
|
+
return self.policy_engine.on_file_change_approval(payload, context)
|
|
490
|
+
|
|
491
|
+
handler = _policy_handler
|
|
492
|
+
return await self._resolve_server_request(
|
|
493
|
+
"item/fileChange/requestApproval",
|
|
494
|
+
params,
|
|
495
|
+
handler,
|
|
496
|
+
{"decision": "accept"},
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
async def _handle_tool_user_input_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
500
|
+
del method
|
|
501
|
+
handler = self.on_tool_request_user_input
|
|
502
|
+
if handler is None and self.policy_engine is not None:
|
|
503
|
+
context = self._build_policy_context("tool_user_input", "item/tool/requestUserInput", params)
|
|
504
|
+
|
|
505
|
+
def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
|
|
506
|
+
assert self.policy_engine is not None
|
|
507
|
+
return self.policy_engine.on_tool_request_user_input(payload, context)
|
|
508
|
+
|
|
509
|
+
handler = _policy_handler
|
|
510
|
+
return await self._resolve_server_request(
|
|
511
|
+
"item/tool/requestUserInput",
|
|
512
|
+
params,
|
|
513
|
+
handler,
|
|
514
|
+
{"answers": {}},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
async def _handle_tool_call_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
518
|
+
del method
|
|
519
|
+
return await self._resolve_server_request(
|
|
520
|
+
"item/tool/call",
|
|
521
|
+
params,
|
|
522
|
+
self.on_tool_call,
|
|
523
|
+
{
|
|
524
|
+
"success": False,
|
|
525
|
+
"contentItems": [
|
|
526
|
+
{
|
|
527
|
+
"type": "inputText",
|
|
528
|
+
"text": "No tool-call handler configured.",
|
|
529
|
+
}
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
async def _resolve_server_request(
|
|
535
|
+
self,
|
|
536
|
+
method: str,
|
|
537
|
+
params: dict[str, Any],
|
|
538
|
+
handler: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None,
|
|
539
|
+
default_result: dict[str, Any],
|
|
540
|
+
) -> dict[str, Any]:
|
|
541
|
+
if handler is None:
|
|
542
|
+
return default_result
|
|
543
|
+
try:
|
|
544
|
+
maybe_result = handler(params)
|
|
545
|
+
if inspect.isawaitable(maybe_result):
|
|
546
|
+
maybe_result = await maybe_result
|
|
547
|
+
except Exception as exc:
|
|
548
|
+
raise CodexAgenticError(f"Handler failed for server request '{method}': {exc}") from exc
|
|
549
|
+
if not isinstance(maybe_result, dict):
|
|
550
|
+
raise CodexAgenticError(
|
|
551
|
+
f"Handler for server request '{method}' must return a dict, got {type(maybe_result).__name__}."
|
|
552
|
+
)
|
|
553
|
+
return maybe_result
|
|
554
|
+
|
|
555
|
+
@staticmethod
|
|
556
|
+
def _build_policy_context(request_type: str, method: str, params: dict[str, Any]) -> "PolicyContext":
|
|
557
|
+
from .policy import PolicyContext
|
|
558
|
+
|
|
559
|
+
thread_id = params.get("threadId")
|
|
560
|
+
turn_id = params.get("turnId")
|
|
561
|
+
item_id = params.get("itemId")
|
|
562
|
+
return PolicyContext(
|
|
563
|
+
request_type=request_type, # type: ignore[arg-type]
|
|
564
|
+
method=method,
|
|
565
|
+
thread_id=thread_id if isinstance(thread_id, str) else None,
|
|
566
|
+
turn_id=turn_id if isinstance(turn_id, str) else None,
|
|
567
|
+
item_id=item_id if isinstance(item_id, str) else None,
|
|
568
|
+
params=dict(params),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
async def _send_json(self, payload: dict[str, Any]) -> None:
|
|
572
|
+
if self._proc is None or self._proc.stdin is None:
|
|
573
|
+
raise AppServerConnectionError("App-server process is not running.")
|
|
574
|
+
data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
|
|
575
|
+
async with self._write_lock:
|
|
576
|
+
self._proc.stdin.write(data)
|
|
577
|
+
await self._proc.stdin.drain()
|
|
578
|
+
|
|
579
|
+
async def _notify(self, method: str, params: dict[str, Any] | None) -> None:
|
|
580
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
581
|
+
if params is not None:
|
|
582
|
+
payload["params"] = params
|
|
583
|
+
await self._send_json(payload)
|
|
584
|
+
|
|
585
|
+
async def _request(
|
|
586
|
+
self,
|
|
587
|
+
method: str,
|
|
588
|
+
params: dict[str, Any] | None,
|
|
589
|
+
*,
|
|
590
|
+
ensure_connected: bool = True,
|
|
591
|
+
timeout_seconds: float | None = None,
|
|
592
|
+
) -> dict[str, Any]:
|
|
593
|
+
if ensure_connected:
|
|
594
|
+
await self.connect()
|
|
595
|
+
elif self._proc is None or self._proc.stdin is None:
|
|
596
|
+
raise AppServerConnectionError("App-server process is not running.")
|
|
597
|
+
req_id = self._next_id
|
|
598
|
+
self._next_id += 1
|
|
599
|
+
|
|
600
|
+
fut: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
601
|
+
self._pending[req_id] = fut
|
|
602
|
+
|
|
603
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
604
|
+
if params is not None:
|
|
605
|
+
payload["params"] = params
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
await self._send_json(payload)
|
|
609
|
+
if timeout_seconds is None:
|
|
610
|
+
raw_resp = await fut
|
|
611
|
+
else:
|
|
612
|
+
raw_resp = await asyncio.wait_for(fut, timeout=timeout_seconds)
|
|
613
|
+
except Exception:
|
|
614
|
+
self._pending.pop(req_id, None)
|
|
615
|
+
raise
|
|
616
|
+
|
|
617
|
+
if "error" in raw_resp:
|
|
618
|
+
self._raise_rpc_error(raw_resp.get("error"))
|
|
619
|
+
result = raw_resp.get("result")
|
|
620
|
+
if isinstance(result, dict):
|
|
621
|
+
return result
|
|
622
|
+
return {"value": result}
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _raise_rpc_error(error: Any) -> None:
|
|
626
|
+
msg = first_nonempty_text(error) or "App-server returned an RPC error."
|
|
627
|
+
lower = msg.lower()
|
|
628
|
+
if AsyncCodexAgenticClient._is_auth_error_text(msg):
|
|
629
|
+
raise NotAuthenticatedError(AsyncCodexAgenticClient._not_authenticated_message(msg))
|
|
630
|
+
if "not found" in lower and ("thread" in lower or "session" in lower):
|
|
631
|
+
raise SessionNotFoundError(msg)
|
|
632
|
+
raise CodexAgenticError(msg)
|
|
633
|
+
|
|
634
|
+
@staticmethod
|
|
635
|
+
def _is_no_rollout_found_error(exc: Exception) -> bool:
|
|
636
|
+
lower = str(exc).lower()
|
|
637
|
+
return "no rollout found" in lower or "rollout not found" in lower
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def _is_auth_error_text(text: str) -> bool:
|
|
641
|
+
lower = text.lower()
|
|
642
|
+
return any(token in lower for token in ("login", "auth", "credential", "unauthorized"))
|
|
643
|
+
|
|
644
|
+
@staticmethod
|
|
645
|
+
def _not_authenticated_message(detail: str) -> str:
|
|
646
|
+
base = detail.strip() or "Codex authentication is unavailable or invalid."
|
|
647
|
+
guidance = "Run 'codex login' in your terminal and retry."
|
|
648
|
+
return f"{base} {guidance}"
|
|
649
|
+
|
|
650
|
+
@staticmethod
|
|
651
|
+
def _phase_for_method(method: str) -> str:
|
|
652
|
+
if method == "turn/completed":
|
|
653
|
+
return "completed"
|
|
654
|
+
if method == "error":
|
|
655
|
+
return "error"
|
|
656
|
+
if method.startswith("item/agentMessage"):
|
|
657
|
+
return "assistant"
|
|
658
|
+
if method == "item/reasoning/summaryPartAdded":
|
|
659
|
+
return "planning"
|
|
660
|
+
if "reasoning" in method or method.startswith("turn/plan"):
|
|
661
|
+
return "planning"
|
|
662
|
+
if "/commandExecution/" in method or "/mcpToolCall/" in method or "/fileChange/" in method:
|
|
663
|
+
return "tool"
|
|
664
|
+
return "system"
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _extract_thread_id_from_payload(payload: dict[str, Any]) -> str | None:
|
|
668
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
669
|
+
for key in ("threadId", "conversationId", "sessionId"):
|
|
670
|
+
value = params.get(key)
|
|
671
|
+
if isinstance(value, str) and value:
|
|
672
|
+
return value
|
|
673
|
+
thread = params.get("thread")
|
|
674
|
+
if isinstance(thread, dict):
|
|
675
|
+
value = thread.get("id")
|
|
676
|
+
if isinstance(value, str) and value:
|
|
677
|
+
return value
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
@staticmethod
|
|
681
|
+
def _extract_turn_id(payload: dict[str, Any]) -> str | None:
|
|
682
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
683
|
+
value = params.get("turnId")
|
|
684
|
+
if isinstance(value, str) and value:
|
|
685
|
+
return value
|
|
686
|
+
turn = params.get("turn")
|
|
687
|
+
if isinstance(turn, dict):
|
|
688
|
+
turn_id = turn.get("id")
|
|
689
|
+
if isinstance(turn_id, str) and turn_id:
|
|
690
|
+
return turn_id
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
@staticmethod
|
|
694
|
+
def _notification_to_event(payload: dict[str, Any]) -> ResponseEvent:
|
|
695
|
+
method = str(payload.get("method", "unknown"))
|
|
696
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
697
|
+
parsed = AsyncCodexAgenticClient._parse_notification_fields(method, params)
|
|
698
|
+
|
|
699
|
+
req = params.get("requestId")
|
|
700
|
+
|
|
701
|
+
return ResponseEvent(
|
|
702
|
+
type=method,
|
|
703
|
+
phase=AsyncCodexAgenticClient._phase_for_method(method),
|
|
704
|
+
text_delta=parsed.get("text_delta"),
|
|
705
|
+
message_text=parsed.get("message_text"),
|
|
706
|
+
request_id=req if isinstance(req, str) else None,
|
|
707
|
+
session_id=AsyncCodexAgenticClient._extract_thread_id_from_payload(payload),
|
|
708
|
+
turn_id=AsyncCodexAgenticClient._extract_turn_id(payload),
|
|
709
|
+
item_id=parsed.get("item_id"),
|
|
710
|
+
summary_index=parsed.get("summary_index"),
|
|
711
|
+
thread_name=parsed.get("thread_name"),
|
|
712
|
+
token_usage=parsed.get("token_usage"),
|
|
713
|
+
plan=parsed.get("plan"),
|
|
714
|
+
diff=parsed.get("diff"),
|
|
715
|
+
raw=payload,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def _parse_notification_fields(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
720
|
+
parser_map: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|
721
|
+
"item/agentMessage/delta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
722
|
+
"item/reasoning/summaryTextDelta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
723
|
+
"item/reasoning/textDelta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
724
|
+
"item/commandExecution/outputDelta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
725
|
+
"item/fileChange/outputDelta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
726
|
+
"item/plan/delta": AsyncCodexAgenticClient._parse_delta_notification,
|
|
727
|
+
"item/mcpToolCall/progress": AsyncCodexAgenticClient._parse_mcp_progress_notification,
|
|
728
|
+
"thread/name/updated": AsyncCodexAgenticClient._parse_thread_name_notification,
|
|
729
|
+
"thread/tokenUsage/updated": AsyncCodexAgenticClient._parse_token_usage_notification,
|
|
730
|
+
"turn/diff/updated": AsyncCodexAgenticClient._parse_diff_notification,
|
|
731
|
+
"turn/plan/updated": AsyncCodexAgenticClient._parse_plan_notification,
|
|
732
|
+
"item/reasoning/summaryPartAdded": AsyncCodexAgenticClient._parse_summary_part_notification,
|
|
733
|
+
"thread/compacted": AsyncCodexAgenticClient._parse_thread_compacted_notification,
|
|
734
|
+
"item/completed": AsyncCodexAgenticClient._parse_item_completed_notification,
|
|
735
|
+
"turn/completed": AsyncCodexAgenticClient._parse_turn_completed_notification,
|
|
736
|
+
"deprecationNotice": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
737
|
+
"configWarning": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
738
|
+
"windows/worldWritableWarning": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
739
|
+
"authStatusChange": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
740
|
+
"account/updated": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
741
|
+
"account/rateLimits/updated": AsyncCodexAgenticClient._parse_text_message_notification,
|
|
742
|
+
}
|
|
743
|
+
parser = parser_map.get(method)
|
|
744
|
+
if parser is not None:
|
|
745
|
+
return parser(params)
|
|
746
|
+
if method.startswith("codex/event/"):
|
|
747
|
+
return AsyncCodexAgenticClient._parse_text_message_notification(params)
|
|
748
|
+
return {}
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _parse_delta_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
752
|
+
delta = params.get("delta")
|
|
753
|
+
if isinstance(delta, str):
|
|
754
|
+
return {"text_delta": delta, "message_text": delta}
|
|
755
|
+
return {}
|
|
756
|
+
|
|
757
|
+
@staticmethod
|
|
758
|
+
def _parse_mcp_progress_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
759
|
+
message = params.get("message")
|
|
760
|
+
if isinstance(message, str):
|
|
761
|
+
return {"message_text": message}
|
|
762
|
+
return {}
|
|
763
|
+
|
|
764
|
+
@staticmethod
|
|
765
|
+
def _parse_thread_name_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
766
|
+
name = params.get("threadName")
|
|
767
|
+
if isinstance(name, str):
|
|
768
|
+
return {"thread_name": name, "message_text": f"thread name: {name}"}
|
|
769
|
+
if name is None:
|
|
770
|
+
return {"message_text": "thread name cleared"}
|
|
771
|
+
return {}
|
|
772
|
+
|
|
773
|
+
@staticmethod
|
|
774
|
+
def _parse_token_usage_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
775
|
+
usage = params.get("tokenUsage")
|
|
776
|
+
if not isinstance(usage, dict):
|
|
777
|
+
return {}
|
|
778
|
+
return {
|
|
779
|
+
"token_usage": usage,
|
|
780
|
+
"message_text": token_usage_summary(usage) or "token usage updated",
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _parse_diff_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
785
|
+
current_diff = params.get("diff")
|
|
786
|
+
if not isinstance(current_diff, str):
|
|
787
|
+
return {}
|
|
788
|
+
added, removed = diff_change_counts(current_diff)
|
|
789
|
+
return {"diff": current_diff, "message_text": f"+{added} -{removed}"}
|
|
790
|
+
|
|
791
|
+
@staticmethod
|
|
792
|
+
def _parse_plan_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
793
|
+
plan_value = params.get("plan")
|
|
794
|
+
parsed_plan: list[dict[str, Any]] | None = None
|
|
795
|
+
if isinstance(plan_value, list):
|
|
796
|
+
parsed_plan = [entry for entry in plan_value if isinstance(entry, dict)]
|
|
797
|
+
return {"plan": parsed_plan, "message_text": turn_plan_summary(params)}
|
|
798
|
+
|
|
799
|
+
@staticmethod
|
|
800
|
+
def _parse_summary_part_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
801
|
+
out: dict[str, Any] = {"message_text": "summary part added"}
|
|
802
|
+
maybe_item_id = params.get("itemId")
|
|
803
|
+
if isinstance(maybe_item_id, str):
|
|
804
|
+
out["item_id"] = maybe_item_id
|
|
805
|
+
maybe_summary_index = params.get("summaryIndex")
|
|
806
|
+
if isinstance(maybe_summary_index, int):
|
|
807
|
+
out["summary_index"] = maybe_summary_index
|
|
808
|
+
out["message_text"] = f"summary part #{maybe_summary_index}"
|
|
809
|
+
return out
|
|
810
|
+
|
|
811
|
+
@staticmethod
|
|
812
|
+
def _parse_thread_compacted_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
813
|
+
del params
|
|
814
|
+
return {"message_text": "thread compacted"}
|
|
815
|
+
|
|
816
|
+
@staticmethod
|
|
817
|
+
def _parse_text_message_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
818
|
+
text = first_nonempty_text(params)
|
|
819
|
+
if text:
|
|
820
|
+
return {"message_text": text}
|
|
821
|
+
return {}
|
|
822
|
+
|
|
823
|
+
@staticmethod
|
|
824
|
+
def _parse_item_completed_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
825
|
+
item = params.get("item")
|
|
826
|
+
if not isinstance(item, dict) or item.get("type") != "agentMessage":
|
|
827
|
+
return {}
|
|
828
|
+
text = item.get("text")
|
|
829
|
+
if isinstance(text, str):
|
|
830
|
+
return {"message_text": text}
|
|
831
|
+
return {}
|
|
832
|
+
|
|
833
|
+
@staticmethod
|
|
834
|
+
def _parse_turn_completed_notification(params: dict[str, Any]) -> dict[str, Any]:
|
|
835
|
+
turn = params.get("turn")
|
|
836
|
+
if not isinstance(turn, dict):
|
|
837
|
+
return {}
|
|
838
|
+
err = turn.get("error")
|
|
839
|
+
text = first_nonempty_text(err)
|
|
840
|
+
if text:
|
|
841
|
+
return {"message_text": text}
|
|
842
|
+
return {}
|
|
843
|
+
|
|
844
|
+
@staticmethod
|
|
845
|
+
def _merge_params(
|
|
846
|
+
default_params: dict[str, Any],
|
|
847
|
+
request_params: dict[str, Any] | None,
|
|
848
|
+
) -> dict[str, Any]:
|
|
849
|
+
merged = dict(default_params)
|
|
850
|
+
if request_params:
|
|
851
|
+
for key, value in request_params.items():
|
|
852
|
+
if value is not None:
|
|
853
|
+
merged[key] = value
|
|
854
|
+
return merged
|
|
855
|
+
|
|
856
|
+
async def responses_events(
|
|
857
|
+
self,
|
|
858
|
+
*,
|
|
859
|
+
prompt: str,
|
|
860
|
+
session_id: str | None = None,
|
|
861
|
+
thread_params: dict[str, Any] | None = None,
|
|
862
|
+
turn_params: dict[str, Any] | None = None,
|
|
863
|
+
) -> AsyncIterator[ResponseEvent]:
|
|
864
|
+
"""Run one prompt and yield structured events in real time.
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
prompt: User prompt text.
|
|
868
|
+
session_id: Existing thread id. If omitted, a new thread is started.
|
|
869
|
+
thread_params: Per-request thread params merged over defaults.
|
|
870
|
+
turn_params: Per-request turn params merged over defaults.
|
|
871
|
+
|
|
872
|
+
Yields:
|
|
873
|
+
``ResponseEvent`` objects in arrival order.
|
|
874
|
+
"""
|
|
875
|
+
|
|
876
|
+
merged_thread_params = self._merge_params(self.default_thread_params, thread_params)
|
|
877
|
+
merged_turn_params = self._merge_params(self.default_turn_params, turn_params)
|
|
878
|
+
|
|
879
|
+
if session_id is None:
|
|
880
|
+
started = await self._request("thread/start", merged_thread_params)
|
|
881
|
+
thread = started.get("thread") if isinstance(started.get("thread"), dict) else {}
|
|
882
|
+
thread_id = thread.get("id") if isinstance(thread.get("id"), str) else None
|
|
883
|
+
if not thread_id:
|
|
884
|
+
raise CodexAgenticError("thread/start did not return a thread id.")
|
|
885
|
+
else:
|
|
886
|
+
thread_id = session_id
|
|
887
|
+
try:
|
|
888
|
+
resumed = await self._request(
|
|
889
|
+
"thread/resume",
|
|
890
|
+
{**merged_thread_params, "threadId": session_id},
|
|
891
|
+
)
|
|
892
|
+
except CodexAgenticError as exc:
|
|
893
|
+
if not self._is_no_rollout_found_error(exc):
|
|
894
|
+
raise
|
|
895
|
+
else:
|
|
896
|
+
thread = resumed.get("thread") if isinstance(resumed.get("thread"), dict) else {}
|
|
897
|
+
thread_id = thread.get("id") if isinstance(thread.get("id"), str) else session_id
|
|
898
|
+
|
|
899
|
+
yield ResponseEvent(
|
|
900
|
+
type="thread/ready",
|
|
901
|
+
phase="system",
|
|
902
|
+
session_id=thread_id,
|
|
903
|
+
raw={"threadId": thread_id},
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
turn_start_params: dict[str, Any] = dict(merged_turn_params)
|
|
907
|
+
turn_start_params["threadId"] = thread_id
|
|
908
|
+
turn_start_params["input"] = [{"type": "text", "text": prompt, "text_elements": []}]
|
|
909
|
+
turn_result = await self._request("turn/start", turn_start_params)
|
|
910
|
+
turn = turn_result.get("turn") if isinstance(turn_result.get("turn"), dict) else {}
|
|
911
|
+
turn_id = turn.get("id") if isinstance(turn.get("id"), str) else None
|
|
912
|
+
idle_deadline: float | None = None
|
|
913
|
+
if self.stream_idle_timeout_seconds is not None:
|
|
914
|
+
idle_deadline = asyncio.get_running_loop().time() + max(0.0, self.stream_idle_timeout_seconds)
|
|
915
|
+
|
|
916
|
+
while True:
|
|
917
|
+
try:
|
|
918
|
+
notification = await self._wait_for_matching_notification(thread_id, turn_id)
|
|
919
|
+
except asyncio.TimeoutError:
|
|
920
|
+
if self._proc is None:
|
|
921
|
+
raise AppServerConnectionError("App-server process is not running.")
|
|
922
|
+
if self._proc.returncode is not None:
|
|
923
|
+
raise AppServerConnectionError("App-server process exited while waiting for notifications.")
|
|
924
|
+
if not self._connected:
|
|
925
|
+
async with self._notification_condition:
|
|
926
|
+
if not self._notification_buffer:
|
|
927
|
+
raise AppServerConnectionError("App-server transport closed while waiting for notifications.")
|
|
928
|
+
if idle_deadline is not None and asyncio.get_running_loop().time() >= idle_deadline:
|
|
929
|
+
raise AppServerConnectionError(
|
|
930
|
+
f"Timed out waiting for matching notifications for {self.stream_idle_timeout_seconds} seconds."
|
|
931
|
+
)
|
|
932
|
+
continue
|
|
933
|
+
if self.stream_idle_timeout_seconds is not None:
|
|
934
|
+
idle_deadline = asyncio.get_running_loop().time() + max(0.0, self.stream_idle_timeout_seconds)
|
|
935
|
+
method = str(notification.get("method", ""))
|
|
936
|
+
|
|
937
|
+
event = self._notification_to_event(notification)
|
|
938
|
+
if not event.session_id:
|
|
939
|
+
event.session_id = thread_id
|
|
940
|
+
yield event
|
|
941
|
+
|
|
942
|
+
if method == "error":
|
|
943
|
+
msg = first_nonempty_text(notification.get("params")) or "App-server emitted an error notification."
|
|
944
|
+
raise CodexAgenticError(msg)
|
|
945
|
+
|
|
946
|
+
if method == "turn/completed":
|
|
947
|
+
params = notification.get("params") if isinstance(notification.get("params"), dict) else {}
|
|
948
|
+
turn_obj = params.get("turn") if isinstance(params.get("turn"), dict) else {}
|
|
949
|
+
status = turn_obj.get("status")
|
|
950
|
+
if status == "failed":
|
|
951
|
+
err = turn_obj.get("error") if isinstance(turn_obj.get("error"), dict) else {}
|
|
952
|
+
msg = first_nonempty_text(err) or "Turn failed without detailed error."
|
|
953
|
+
raise CodexAgenticError(msg)
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
async def responses_stream_text(
|
|
957
|
+
self,
|
|
958
|
+
*,
|
|
959
|
+
prompt: str,
|
|
960
|
+
session_id: str | None = None,
|
|
961
|
+
thread_params: dict[str, Any] | None = None,
|
|
962
|
+
turn_params: dict[str, Any] | None = None,
|
|
963
|
+
) -> AsyncIterator[str]:
|
|
964
|
+
"""Run one prompt and yield text deltas only."""
|
|
965
|
+
|
|
966
|
+
async for event in self.responses_events(
|
|
967
|
+
prompt=prompt,
|
|
968
|
+
session_id=session_id,
|
|
969
|
+
thread_params=thread_params,
|
|
970
|
+
turn_params=turn_params,
|
|
971
|
+
):
|
|
972
|
+
if event.text_delta:
|
|
973
|
+
yield event.text_delta
|
|
974
|
+
|
|
975
|
+
async def responses_create(
|
|
976
|
+
self,
|
|
977
|
+
*,
|
|
978
|
+
prompt: str,
|
|
979
|
+
session_id: str | None = None,
|
|
980
|
+
thread_params: dict[str, Any] | None = None,
|
|
981
|
+
turn_params: dict[str, Any] | None = None,
|
|
982
|
+
include_events: bool = False,
|
|
983
|
+
) -> AgentResponse:
|
|
984
|
+
"""Run one prompt and return the final aggregated response.
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
prompt: User prompt text.
|
|
988
|
+
session_id: Existing thread id. If omitted, a new thread is started.
|
|
989
|
+
thread_params: Per-request thread params merged over defaults.
|
|
990
|
+
turn_params: Per-request turn params merged over defaults.
|
|
991
|
+
include_events: If true, include all streamed ``ResponseEvent`` objects in the result.
|
|
992
|
+
"""
|
|
993
|
+
|
|
994
|
+
chunks: list[str] = []
|
|
995
|
+
assistant_chunks: list[str] = []
|
|
996
|
+
assistant_message: str | None = None
|
|
997
|
+
last_message: str | None = None
|
|
998
|
+
thread_id: str | None = session_id
|
|
999
|
+
request_id: str | None = None
|
|
1000
|
+
last_event_raw: dict[str, Any] | None = None
|
|
1001
|
+
events: list[ResponseEvent] | None = [] if include_events else None
|
|
1002
|
+
|
|
1003
|
+
async for event in self.responses_events(
|
|
1004
|
+
prompt=prompt,
|
|
1005
|
+
session_id=session_id,
|
|
1006
|
+
thread_params=thread_params,
|
|
1007
|
+
turn_params=turn_params,
|
|
1008
|
+
):
|
|
1009
|
+
last_event_raw = event.raw
|
|
1010
|
+
if events is not None:
|
|
1011
|
+
events.append(event)
|
|
1012
|
+
if event.session_id and not thread_id:
|
|
1013
|
+
thread_id = event.session_id
|
|
1014
|
+
if event.request_id:
|
|
1015
|
+
request_id = event.request_id
|
|
1016
|
+
if event.text_delta:
|
|
1017
|
+
chunks.append(event.text_delta)
|
|
1018
|
+
if event.type == "item/agentMessage/delta":
|
|
1019
|
+
assistant_chunks.append(event.text_delta)
|
|
1020
|
+
extracted_assistant = self._extract_assistant_text_from_event(event)
|
|
1021
|
+
if extracted_assistant:
|
|
1022
|
+
assistant_message = extracted_assistant
|
|
1023
|
+
if event.message_text:
|
|
1024
|
+
last_message = event.message_text
|
|
1025
|
+
|
|
1026
|
+
if not thread_id:
|
|
1027
|
+
raise CodexAgenticError("No thread id was produced by app-server.")
|
|
1028
|
+
|
|
1029
|
+
text = (
|
|
1030
|
+
assistant_message
|
|
1031
|
+
or "".join(assistant_chunks).strip()
|
|
1032
|
+
or last_message
|
|
1033
|
+
or "".join(chunks).strip()
|
|
1034
|
+
)
|
|
1035
|
+
return AgentResponse(
|
|
1036
|
+
text=text,
|
|
1037
|
+
session_id=thread_id,
|
|
1038
|
+
request_id=request_id,
|
|
1039
|
+
tool_name="app-server",
|
|
1040
|
+
raw=last_event_raw or {},
|
|
1041
|
+
events=events,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
@staticmethod
|
|
1045
|
+
def _extract_assistant_text_from_event(event: ResponseEvent) -> str | None:
|
|
1046
|
+
if event.type == "codex/event/agent_message":
|
|
1047
|
+
return first_nonempty_text(event.message_text)
|
|
1048
|
+
|
|
1049
|
+
if event.type != "item/completed":
|
|
1050
|
+
return None
|
|
1051
|
+
|
|
1052
|
+
raw = event.raw if isinstance(event.raw, dict) else {}
|
|
1053
|
+
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
|
|
1054
|
+
item = params.get("item") if isinstance(params.get("item"), dict) else {}
|
|
1055
|
+
if item.get("type") != "agentMessage":
|
|
1056
|
+
return None
|
|
1057
|
+
|
|
1058
|
+
text = item.get("text")
|
|
1059
|
+
if isinstance(text, str) and text:
|
|
1060
|
+
return text
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
async def thread_start(
|
|
1064
|
+
self,
|
|
1065
|
+
*,
|
|
1066
|
+
params: dict[str, Any] | None = None,
|
|
1067
|
+
) -> dict[str, Any]:
|
|
1068
|
+
"""Create a new thread via ``thread/start``."""
|
|
1069
|
+
|
|
1070
|
+
return await self._request("thread/start", self._merge_params(self.default_thread_params, params))
|
|
1071
|
+
|
|
1072
|
+
async def thread_read(self, thread_id: str, *, include_turns: bool = False) -> dict[str, Any]:
|
|
1073
|
+
"""Read one thread by id."""
|
|
1074
|
+
|
|
1075
|
+
result = await self._request("thread/read", {"threadId": thread_id, "includeTurns": include_turns})
|
|
1076
|
+
thread = result.get("thread") if isinstance(result.get("thread"), dict) else None
|
|
1077
|
+
if thread is None:
|
|
1078
|
+
raise SessionNotFoundError(f"Unknown thread_id: {thread_id}")
|
|
1079
|
+
return thread
|
|
1080
|
+
|
|
1081
|
+
async def thread_list(self, limit: int = 50, *, sort_key: str = "updated_at") -> list[dict[str, Any]]:
|
|
1082
|
+
"""List available threads."""
|
|
1083
|
+
|
|
1084
|
+
result = await self._request("thread/list", {"limit": limit, "sortKey": sort_key})
|
|
1085
|
+
data = result.get("data")
|
|
1086
|
+
if isinstance(data, list):
|
|
1087
|
+
return [item for item in data if isinstance(item, dict)]
|
|
1088
|
+
return []
|
|
1089
|
+
|
|
1090
|
+
async def thread_archive(self, thread_id: str) -> dict[str, Any]:
|
|
1091
|
+
"""Archive one thread."""
|
|
1092
|
+
|
|
1093
|
+
return await self._request("thread/archive", {"threadId": thread_id})
|
|
1094
|
+
|
|
1095
|
+
async def thread_fork(
|
|
1096
|
+
self,
|
|
1097
|
+
thread_id: str,
|
|
1098
|
+
*,
|
|
1099
|
+
params: dict[str, Any] | None = None,
|
|
1100
|
+
) -> dict[str, Any]:
|
|
1101
|
+
merged = self._merge_params(self.default_thread_params, params)
|
|
1102
|
+
return await self._request("thread/fork", {**merged, "threadId": thread_id})
|
|
1103
|
+
|
|
1104
|
+
async def thread_name_set(self, thread_id: str, name: str) -> dict[str, Any]:
|
|
1105
|
+
return await self._request("thread/name/set", {"threadId": thread_id, "name": name})
|
|
1106
|
+
|
|
1107
|
+
async def thread_unarchive(self, thread_id: str) -> dict[str, Any]:
|
|
1108
|
+
return await self._request("thread/unarchive", {"threadId": thread_id})
|
|
1109
|
+
|
|
1110
|
+
async def thread_compact_start(self, thread_id: str) -> dict[str, Any]:
|
|
1111
|
+
return await self._request("thread/compact/start", {"threadId": thread_id})
|
|
1112
|
+
|
|
1113
|
+
async def thread_rollback(self, thread_id: str, num_turns: int) -> dict[str, Any]:
|
|
1114
|
+
return await self._request("thread/rollback", {"threadId": thread_id, "numTurns": num_turns})
|
|
1115
|
+
|
|
1116
|
+
async def thread_loaded_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
|
|
1117
|
+
params: dict[str, Any] = {}
|
|
1118
|
+
if limit is not None:
|
|
1119
|
+
params["limit"] = limit
|
|
1120
|
+
if cursor is not None:
|
|
1121
|
+
params["cursor"] = cursor
|
|
1122
|
+
return await self._request("thread/loaded/list", params)
|
|
1123
|
+
|
|
1124
|
+
async def skills_list(
|
|
1125
|
+
self,
|
|
1126
|
+
*,
|
|
1127
|
+
cwds: list[str] | None = None,
|
|
1128
|
+
force_reload: bool | None = None,
|
|
1129
|
+
) -> dict[str, Any]:
|
|
1130
|
+
params: dict[str, Any] = {}
|
|
1131
|
+
if cwds is not None:
|
|
1132
|
+
params["cwds"] = cwds
|
|
1133
|
+
if force_reload is not None:
|
|
1134
|
+
params["forceReload"] = force_reload
|
|
1135
|
+
return await self._request("skills/list", params)
|
|
1136
|
+
|
|
1137
|
+
async def skills_remote_read(self) -> dict[str, Any]:
|
|
1138
|
+
return await self._request("skills/remote/read", {})
|
|
1139
|
+
|
|
1140
|
+
async def skills_remote_write(self, hazelnut_id: str, is_preload: bool) -> dict[str, Any]:
|
|
1141
|
+
return await self._request(
|
|
1142
|
+
"skills/remote/write",
|
|
1143
|
+
{"hazelnutId": hazelnut_id, "isPreload": is_preload},
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
async def app_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
|
|
1147
|
+
params: dict[str, Any] = {}
|
|
1148
|
+
if limit is not None:
|
|
1149
|
+
params["limit"] = limit
|
|
1150
|
+
if cursor is not None:
|
|
1151
|
+
params["cursor"] = cursor
|
|
1152
|
+
return await self._request("app/list", params)
|
|
1153
|
+
|
|
1154
|
+
async def skills_config_write(self, path: str, enabled: bool) -> dict[str, Any]:
|
|
1155
|
+
return await self._request("skills/config/write", {"path": path, "enabled": enabled})
|
|
1156
|
+
|
|
1157
|
+
async def turn_interrupt(self, thread_id: str, turn_id: str) -> dict[str, Any]:
|
|
1158
|
+
return await self._request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
|
|
1159
|
+
|
|
1160
|
+
async def turn_steer(self, thread_id: str, turn_id: str, prompt: str) -> dict[str, Any]:
|
|
1161
|
+
return await self._request(
|
|
1162
|
+
"turn/steer",
|
|
1163
|
+
{
|
|
1164
|
+
"threadId": thread_id,
|
|
1165
|
+
"expectedTurnId": turn_id,
|
|
1166
|
+
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
|
1167
|
+
},
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
async def review_start(
|
|
1171
|
+
self,
|
|
1172
|
+
thread_id: str,
|
|
1173
|
+
target: dict[str, Any],
|
|
1174
|
+
*,
|
|
1175
|
+
delivery: str | None = None,
|
|
1176
|
+
) -> dict[str, Any]:
|
|
1177
|
+
params: dict[str, Any] = {"threadId": thread_id, "target": target}
|
|
1178
|
+
if delivery is not None:
|
|
1179
|
+
params["delivery"] = delivery
|
|
1180
|
+
return await self._request("review/start", params)
|
|
1181
|
+
|
|
1182
|
+
async def model_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
|
|
1183
|
+
params: dict[str, Any] = {}
|
|
1184
|
+
if limit is not None:
|
|
1185
|
+
params["limit"] = limit
|
|
1186
|
+
if cursor is not None:
|
|
1187
|
+
params["cursor"] = cursor
|
|
1188
|
+
return await self._request("model/list", params)
|
|
1189
|
+
|
|
1190
|
+
async def account_rate_limits_read(self) -> dict[str, Any]:
|
|
1191
|
+
return await self._request("account/rateLimits/read", None)
|
|
1192
|
+
|
|
1193
|
+
async def account_read(self, *, refresh_token: bool | None = None) -> dict[str, Any]:
|
|
1194
|
+
params: dict[str, Any] = {}
|
|
1195
|
+
if refresh_token is not None:
|
|
1196
|
+
params["refreshToken"] = refresh_token
|
|
1197
|
+
return await self._request("account/read", params)
|
|
1198
|
+
|
|
1199
|
+
async def command_exec(
|
|
1200
|
+
self,
|
|
1201
|
+
command: list[str],
|
|
1202
|
+
*,
|
|
1203
|
+
cwd: str | None = None,
|
|
1204
|
+
timeout_ms: int | None = None,
|
|
1205
|
+
sandbox_policy: dict[str, Any] | None = None,
|
|
1206
|
+
) -> dict[str, Any]:
|
|
1207
|
+
params: dict[str, Any] = {"command": command}
|
|
1208
|
+
request_timeout_seconds: float | None = None
|
|
1209
|
+
if cwd is not None:
|
|
1210
|
+
params["cwd"] = cwd
|
|
1211
|
+
if timeout_ms is not None:
|
|
1212
|
+
params["timeoutMs"] = timeout_ms
|
|
1213
|
+
# Honor the caller's command timeout and leave room for transport overhead.
|
|
1214
|
+
request_timeout_seconds = max(0.0, timeout_ms / 1000.0) + 30.0
|
|
1215
|
+
if sandbox_policy is not None:
|
|
1216
|
+
params["sandboxPolicy"] = sandbox_policy
|
|
1217
|
+
return await self._request(
|
|
1218
|
+
"command/exec",
|
|
1219
|
+
params,
|
|
1220
|
+
timeout_seconds=request_timeout_seconds,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
async def config_read(self, *, cwd: str | None = None, include_layers: bool = False) -> dict[str, Any]:
|
|
1224
|
+
params: dict[str, Any] = {}
|
|
1225
|
+
if cwd is not None:
|
|
1226
|
+
params["cwd"] = cwd
|
|
1227
|
+
if include_layers:
|
|
1228
|
+
params["includeLayers"] = include_layers
|
|
1229
|
+
return await self._request("config/read", params)
|
|
1230
|
+
|
|
1231
|
+
async def config_value_write(
|
|
1232
|
+
self,
|
|
1233
|
+
key_path: str,
|
|
1234
|
+
value: Any,
|
|
1235
|
+
*,
|
|
1236
|
+
merge_strategy: str = "upsert",
|
|
1237
|
+
file_path: str | None = None,
|
|
1238
|
+
expected_version: str | None = None,
|
|
1239
|
+
) -> dict[str, Any]:
|
|
1240
|
+
params: dict[str, Any] = {
|
|
1241
|
+
"keyPath": key_path,
|
|
1242
|
+
"value": value,
|
|
1243
|
+
"mergeStrategy": merge_strategy,
|
|
1244
|
+
}
|
|
1245
|
+
if file_path is not None:
|
|
1246
|
+
params["filePath"] = file_path
|
|
1247
|
+
if expected_version is not None:
|
|
1248
|
+
params["expectedVersion"] = expected_version
|
|
1249
|
+
return await self._request("config/value/write", params)
|
|
1250
|
+
|
|
1251
|
+
async def config_batch_write(
|
|
1252
|
+
self,
|
|
1253
|
+
edits: list[dict[str, Any]],
|
|
1254
|
+
*,
|
|
1255
|
+
file_path: str | None = None,
|
|
1256
|
+
expected_version: str | None = None,
|
|
1257
|
+
) -> dict[str, Any]:
|
|
1258
|
+
params: dict[str, Any] = {"edits": edits}
|
|
1259
|
+
if file_path is not None:
|
|
1260
|
+
params["filePath"] = file_path
|
|
1261
|
+
if expected_version is not None:
|
|
1262
|
+
params["expectedVersion"] = expected_version
|
|
1263
|
+
return await self._request("config/batchWrite", params)
|
|
1264
|
+
|
|
1265
|
+
async def config_requirements_read(self) -> dict[str, Any]:
|
|
1266
|
+
return await self._request("configRequirements/read", None)
|
|
1267
|
+
|
|
1268
|
+
async def config_mcp_server_reload(self) -> dict[str, Any]:
|
|
1269
|
+
return await self._request("config/mcpServer/reload", None)
|
|
1270
|
+
|
|
1271
|
+
async def mcp_server_status_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
|
|
1272
|
+
params: dict[str, Any] = {}
|
|
1273
|
+
if limit is not None:
|
|
1274
|
+
params["limit"] = limit
|
|
1275
|
+
if cursor is not None:
|
|
1276
|
+
params["cursor"] = cursor
|
|
1277
|
+
return await self._request("mcpServerStatus/list", params)
|
|
1278
|
+
|
|
1279
|
+
async def mcp_server_oauth_login(
|
|
1280
|
+
self,
|
|
1281
|
+
name: str,
|
|
1282
|
+
*,
|
|
1283
|
+
scopes: list[str] | None = None,
|
|
1284
|
+
timeout_secs: int | None = None,
|
|
1285
|
+
) -> dict[str, Any]:
|
|
1286
|
+
params: dict[str, Any] = {"name": name}
|
|
1287
|
+
if scopes is not None:
|
|
1288
|
+
params["scopes"] = scopes
|
|
1289
|
+
if timeout_secs is not None:
|
|
1290
|
+
params["timeoutSecs"] = timeout_secs
|
|
1291
|
+
return await self._request("mcpServer/oauth/login", params)
|
|
1292
|
+
|
|
1293
|
+
async def fuzzy_file_search(
|
|
1294
|
+
self,
|
|
1295
|
+
query: str,
|
|
1296
|
+
*,
|
|
1297
|
+
roots: list[str] | None = None,
|
|
1298
|
+
cancellation_token: str | None = None,
|
|
1299
|
+
) -> dict[str, Any]:
|
|
1300
|
+
params: dict[str, Any] = {
|
|
1301
|
+
"query": query,
|
|
1302
|
+
"roots": roots[:] if roots is not None else [self.process_cwd],
|
|
1303
|
+
}
|
|
1304
|
+
if cancellation_token is not None:
|
|
1305
|
+
params["cancellationToken"] = cancellation_token
|
|
1306
|
+
return await self._request("fuzzyFileSearch", params)
|
|
1307
|
+
|
|
1308
|
+
@staticmethod
|
|
1309
|
+
def _raise_connection_error(exc: Exception) -> None:
|
|
1310
|
+
text = str(exc)
|
|
1311
|
+
if AsyncCodexAgenticClient._is_auth_error_text(text):
|
|
1312
|
+
raise NotAuthenticatedError(AsyncCodexAgenticClient._not_authenticated_message(text)) from exc
|
|
1313
|
+
raise AppServerConnectionError(text) from exc
|