codex-autorunner 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_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1386 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import metadata as importlib_metadata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Union, cast
|
|
11
|
+
|
|
12
|
+
from ...core.logging_utils import log_event, sanitize_log_value
|
|
13
|
+
|
|
14
|
+
ApprovalDecision = Union[str, Dict[str, Any]]
|
|
15
|
+
ApprovalHandler = Callable[[Dict[str, Any]], Awaitable[ApprovalDecision]]
|
|
16
|
+
NotificationHandler = Callable[[Dict[str, Any]], Awaitable[None]]
|
|
17
|
+
TurnKey = tuple[str, str]
|
|
18
|
+
|
|
19
|
+
APPROVAL_METHODS = {
|
|
20
|
+
"item/commandExecution/requestApproval",
|
|
21
|
+
"item/fileChange/requestApproval",
|
|
22
|
+
}
|
|
23
|
+
_READ_CHUNK_SIZE = 64 * 1024
|
|
24
|
+
_MAX_MESSAGE_BYTES = 50 * 1024 * 1024
|
|
25
|
+
_OVERSIZE_PREVIEW_BYTES = 4096
|
|
26
|
+
_MAX_OVERSIZE_DRAIN_BYTES = 100 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
_RESTART_BACKOFF_INITIAL_SECONDS = 0.5
|
|
29
|
+
_RESTART_BACKOFF_MAX_SECONDS = 30.0
|
|
30
|
+
_RESTART_BACKOFF_JITTER_RATIO = 0.1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CodexAppServerError(Exception):
|
|
34
|
+
"""Base error for app-server client failures."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodexAppServerResponseError(CodexAppServerError):
|
|
38
|
+
"""Raised when the app-server responds with an error payload."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
method: Optional[str],
|
|
44
|
+
code: Optional[int],
|
|
45
|
+
message: str,
|
|
46
|
+
data: Optional[Dict[str, Any]] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.method = method
|
|
50
|
+
self.code = code
|
|
51
|
+
self.data = data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CodexAppServerDisconnected(CodexAppServerError):
|
|
55
|
+
"""Raised when the app-server disconnects mid-flight."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CodexAppServerProtocolError(CodexAppServerError):
|
|
59
|
+
"""Raised when the app-server returns malformed responses."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class TurnResult:
|
|
64
|
+
turn_id: str
|
|
65
|
+
status: Optional[str]
|
|
66
|
+
agent_messages: list[str]
|
|
67
|
+
errors: list[str]
|
|
68
|
+
raw_events: list[Dict[str, Any]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TurnHandle:
|
|
72
|
+
def __init__(
|
|
73
|
+
self, client: "CodexAppServerClient", turn_id: str, thread_id: str
|
|
74
|
+
) -> None:
|
|
75
|
+
self._client = client
|
|
76
|
+
self.turn_id = turn_id
|
|
77
|
+
self.thread_id = thread_id
|
|
78
|
+
|
|
79
|
+
async def wait(self, *, timeout: Optional[float] = None) -> TurnResult:
|
|
80
|
+
return await self._client.wait_for_turn(
|
|
81
|
+
self.turn_id, thread_id=self.thread_id, timeout=timeout
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class _TurnState:
|
|
87
|
+
turn_id: str
|
|
88
|
+
thread_id: Optional[str]
|
|
89
|
+
future: asyncio.Future["TurnResult"]
|
|
90
|
+
agent_messages: list[str]
|
|
91
|
+
errors: list[str]
|
|
92
|
+
raw_events: list[Dict[str, Any]]
|
|
93
|
+
status: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CodexAppServerClient:
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
command: Sequence[str],
|
|
100
|
+
*,
|
|
101
|
+
cwd: Optional[Path] = None,
|
|
102
|
+
env: Optional[Dict[str, str]] = None,
|
|
103
|
+
approval_handler: Optional[ApprovalHandler] = None,
|
|
104
|
+
default_approval_decision: str = "cancel",
|
|
105
|
+
auto_restart: bool = True,
|
|
106
|
+
request_timeout: Optional[float] = None,
|
|
107
|
+
notification_handler: Optional[NotificationHandler] = None,
|
|
108
|
+
logger: Optional[logging.Logger] = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._command = [str(arg) for arg in command]
|
|
111
|
+
self._cwd = str(cwd) if cwd is not None else None
|
|
112
|
+
self._env = env
|
|
113
|
+
self._approval_handler = approval_handler
|
|
114
|
+
self._default_approval_decision = default_approval_decision
|
|
115
|
+
self._auto_restart = auto_restart
|
|
116
|
+
self._request_timeout = request_timeout
|
|
117
|
+
self._notification_handler = notification_handler
|
|
118
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
119
|
+
|
|
120
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
121
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
122
|
+
self._stderr_task: Optional[asyncio.Task] = None
|
|
123
|
+
self._start_lock: Optional[asyncio.Lock] = None
|
|
124
|
+
self._write_lock: Optional[asyncio.Lock] = None
|
|
125
|
+
self._pending: Dict[int, asyncio.Future[Any]] = {}
|
|
126
|
+
self._pending_methods: Dict[int, str] = {}
|
|
127
|
+
self._turns: Dict[TurnKey, _TurnState] = {}
|
|
128
|
+
self._pending_turns: Dict[str, _TurnState] = {}
|
|
129
|
+
self._next_id = 1
|
|
130
|
+
self._initialized = False
|
|
131
|
+
self._initializing = False
|
|
132
|
+
self._closed = False
|
|
133
|
+
self._disconnected: Optional[asyncio.Event] = None
|
|
134
|
+
self._disconnected_set = True
|
|
135
|
+
self._client_version = _client_version()
|
|
136
|
+
self._include_client_version = True
|
|
137
|
+
self._restart_task: Optional[asyncio.Task] = None
|
|
138
|
+
self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
|
|
139
|
+
self._stderr_tail: deque[str] = deque(maxlen=5)
|
|
140
|
+
|
|
141
|
+
async def start(self) -> None:
|
|
142
|
+
await self._ensure_process()
|
|
143
|
+
|
|
144
|
+
async def close(self) -> None:
|
|
145
|
+
self._closed = True
|
|
146
|
+
if self._restart_task is not None:
|
|
147
|
+
self._restart_task.cancel()
|
|
148
|
+
try:
|
|
149
|
+
await self._restart_task
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
pass
|
|
152
|
+
self._restart_task = None
|
|
153
|
+
await self._terminate_process()
|
|
154
|
+
self._fail_pending(CodexAppServerDisconnected("Client closed"))
|
|
155
|
+
|
|
156
|
+
async def wait_for_disconnect(self, *, timeout: Optional[float] = None) -> None:
|
|
157
|
+
disconnected = self._ensure_disconnect_event()
|
|
158
|
+
if timeout is None:
|
|
159
|
+
await disconnected.wait()
|
|
160
|
+
return
|
|
161
|
+
await asyncio.wait_for(disconnected.wait(), timeout)
|
|
162
|
+
|
|
163
|
+
async def request(
|
|
164
|
+
self,
|
|
165
|
+
method: str,
|
|
166
|
+
params: Optional[Dict[str, Any]] = None,
|
|
167
|
+
*,
|
|
168
|
+
timeout: Optional[float] = None,
|
|
169
|
+
) -> Any:
|
|
170
|
+
await self._ensure_process()
|
|
171
|
+
return await self._request_raw(method, params=params, timeout=timeout)
|
|
172
|
+
|
|
173
|
+
async def notify(
|
|
174
|
+
self, method: str, params: Optional[Dict[str, Any]] = None
|
|
175
|
+
) -> None:
|
|
176
|
+
await self._ensure_process()
|
|
177
|
+
log_event(
|
|
178
|
+
self._logger,
|
|
179
|
+
logging.INFO,
|
|
180
|
+
"app_server.notify",
|
|
181
|
+
method=method,
|
|
182
|
+
**_summarize_params(method, params),
|
|
183
|
+
)
|
|
184
|
+
await self._send_message(self._build_message(method, params=params))
|
|
185
|
+
|
|
186
|
+
async def thread_start(self, cwd: str, **kwargs: Any) -> Dict[str, Any]:
|
|
187
|
+
params = {"cwd": cwd}
|
|
188
|
+
params.update(kwargs)
|
|
189
|
+
result = await self.request("thread/start", params)
|
|
190
|
+
if not isinstance(result, dict):
|
|
191
|
+
raise CodexAppServerProtocolError("thread/start returned non-object result")
|
|
192
|
+
thread_id = _extract_thread_id(result)
|
|
193
|
+
if thread_id and "id" not in result:
|
|
194
|
+
result = dict(result)
|
|
195
|
+
result["id"] = thread_id
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
async def thread_resume(self, thread_id: str, **kwargs: Any) -> Dict[str, Any]:
|
|
199
|
+
params = {"threadId": thread_id}
|
|
200
|
+
params.update(kwargs)
|
|
201
|
+
result = await self.request("thread/resume", params)
|
|
202
|
+
if not isinstance(result, dict):
|
|
203
|
+
raise CodexAppServerProtocolError(
|
|
204
|
+
"thread/resume returned non-object result"
|
|
205
|
+
)
|
|
206
|
+
resumed_id = _extract_thread_id(result)
|
|
207
|
+
if resumed_id and "id" not in result:
|
|
208
|
+
result = dict(result)
|
|
209
|
+
result["id"] = resumed_id
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
async def thread_list(self, **kwargs: Any) -> Any:
|
|
213
|
+
params = kwargs if kwargs else {}
|
|
214
|
+
result = await self.request("thread/list", params)
|
|
215
|
+
if isinstance(result, dict) and "threads" not in result:
|
|
216
|
+
for key in ("data", "items", "results"):
|
|
217
|
+
value = result.get(key)
|
|
218
|
+
if isinstance(value, list):
|
|
219
|
+
result = dict(result)
|
|
220
|
+
result["threads"] = value
|
|
221
|
+
break
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
async def turn_start(
|
|
225
|
+
self,
|
|
226
|
+
thread_id: str,
|
|
227
|
+
text: str,
|
|
228
|
+
*,
|
|
229
|
+
input_items: Optional[list[Dict[str, Any]]] = None,
|
|
230
|
+
approval_policy: Optional[str] = None,
|
|
231
|
+
sandbox_policy: Optional[str] = None,
|
|
232
|
+
**kwargs: Any,
|
|
233
|
+
) -> TurnHandle:
|
|
234
|
+
params: Dict[str, Any] = {"threadId": thread_id}
|
|
235
|
+
if input_items is None:
|
|
236
|
+
params["input"] = [{"type": "text", "text": text}]
|
|
237
|
+
else:
|
|
238
|
+
params["input"] = input_items
|
|
239
|
+
if approval_policy:
|
|
240
|
+
params["approvalPolicy"] = approval_policy
|
|
241
|
+
if sandbox_policy:
|
|
242
|
+
params["sandboxPolicy"] = _normalize_sandbox_policy(sandbox_policy)
|
|
243
|
+
params.update(kwargs)
|
|
244
|
+
result = await self.request("turn/start", params)
|
|
245
|
+
if not isinstance(result, dict):
|
|
246
|
+
raise CodexAppServerProtocolError("turn/start returned non-object result")
|
|
247
|
+
turn_id = _extract_turn_id(result)
|
|
248
|
+
if not turn_id:
|
|
249
|
+
raise CodexAppServerProtocolError("turn/start response missing turn id")
|
|
250
|
+
self._register_turn_state(turn_id, thread_id)
|
|
251
|
+
return TurnHandle(self, turn_id, thread_id)
|
|
252
|
+
|
|
253
|
+
async def review_start(
|
|
254
|
+
self,
|
|
255
|
+
thread_id: str,
|
|
256
|
+
*,
|
|
257
|
+
target: Dict[str, Any],
|
|
258
|
+
delivery: str = "inline",
|
|
259
|
+
approval_policy: Optional[str] = None,
|
|
260
|
+
sandbox_policy: Optional[Any] = None,
|
|
261
|
+
**kwargs: Any,
|
|
262
|
+
) -> TurnHandle:
|
|
263
|
+
params: Dict[str, Any] = {
|
|
264
|
+
"threadId": thread_id,
|
|
265
|
+
"target": target,
|
|
266
|
+
"delivery": delivery,
|
|
267
|
+
}
|
|
268
|
+
if approval_policy:
|
|
269
|
+
params["approvalPolicy"] = approval_policy
|
|
270
|
+
if sandbox_policy:
|
|
271
|
+
params["sandboxPolicy"] = _normalize_sandbox_policy(sandbox_policy)
|
|
272
|
+
params.update(kwargs)
|
|
273
|
+
result = await self.request("review/start", params)
|
|
274
|
+
if not isinstance(result, dict):
|
|
275
|
+
raise CodexAppServerProtocolError("review/start returned non-object result")
|
|
276
|
+
turn_id = _extract_turn_id(result)
|
|
277
|
+
if not turn_id:
|
|
278
|
+
raise CodexAppServerProtocolError("review/start response missing turn id")
|
|
279
|
+
self._register_turn_state(turn_id, thread_id)
|
|
280
|
+
return TurnHandle(self, turn_id, thread_id)
|
|
281
|
+
|
|
282
|
+
async def turn_interrupt(
|
|
283
|
+
self, turn_id: str, *, thread_id: Optional[str] = None
|
|
284
|
+
) -> Any:
|
|
285
|
+
if thread_id is None:
|
|
286
|
+
_key, state = self._find_turn_state(turn_id, thread_id=None)
|
|
287
|
+
if state is None or not state.thread_id:
|
|
288
|
+
raise CodexAppServerProtocolError(
|
|
289
|
+
f"Unknown thread id for turn {turn_id}"
|
|
290
|
+
)
|
|
291
|
+
thread_id = state.thread_id
|
|
292
|
+
params = {"turnId": turn_id, "threadId": thread_id}
|
|
293
|
+
return await self.request("turn/interrupt", params)
|
|
294
|
+
|
|
295
|
+
async def wait_for_turn(
|
|
296
|
+
self,
|
|
297
|
+
turn_id: str,
|
|
298
|
+
*,
|
|
299
|
+
thread_id: Optional[str] = None,
|
|
300
|
+
timeout: Optional[float] = None,
|
|
301
|
+
) -> TurnResult:
|
|
302
|
+
key, state = self._find_turn_state(turn_id, thread_id=thread_id)
|
|
303
|
+
if state is None:
|
|
304
|
+
raise CodexAppServerProtocolError(
|
|
305
|
+
f"Unknown turn id {turn_id} (thread {thread_id})"
|
|
306
|
+
)
|
|
307
|
+
if state.future.done():
|
|
308
|
+
result = state.future.result()
|
|
309
|
+
if key is not None:
|
|
310
|
+
self._turns.pop(key, None)
|
|
311
|
+
return result
|
|
312
|
+
timeout = timeout if timeout is not None else self._request_timeout
|
|
313
|
+
if timeout is None:
|
|
314
|
+
result = await state.future
|
|
315
|
+
if key is not None:
|
|
316
|
+
self._turns.pop(key, None)
|
|
317
|
+
return result
|
|
318
|
+
result = await asyncio.wait_for(state.future, timeout)
|
|
319
|
+
if key is not None:
|
|
320
|
+
self._turns.pop(key, None)
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
async def _ensure_process(self) -> None:
|
|
324
|
+
self._ensure_locks()
|
|
325
|
+
start_lock = self._start_lock
|
|
326
|
+
if start_lock is None:
|
|
327
|
+
raise CodexAppServerProtocolError("start lock unavailable")
|
|
328
|
+
async with start_lock:
|
|
329
|
+
if self._closed:
|
|
330
|
+
raise CodexAppServerDisconnected("Client closed")
|
|
331
|
+
if (
|
|
332
|
+
self._process is not None
|
|
333
|
+
and self._process.returncode is None
|
|
334
|
+
and self._initialized
|
|
335
|
+
):
|
|
336
|
+
return
|
|
337
|
+
await self._spawn_process()
|
|
338
|
+
await self._initialize_handshake()
|
|
339
|
+
|
|
340
|
+
async def _spawn_process(self) -> None:
|
|
341
|
+
await self._terminate_process()
|
|
342
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
343
|
+
*self._command,
|
|
344
|
+
cwd=self._cwd,
|
|
345
|
+
env=self._env,
|
|
346
|
+
stdin=asyncio.subprocess.PIPE,
|
|
347
|
+
stdout=asyncio.subprocess.PIPE,
|
|
348
|
+
stderr=asyncio.subprocess.PIPE,
|
|
349
|
+
)
|
|
350
|
+
log_event(
|
|
351
|
+
self._logger,
|
|
352
|
+
logging.INFO,
|
|
353
|
+
"app_server.spawned",
|
|
354
|
+
command=list(self._command),
|
|
355
|
+
cwd=self._cwd,
|
|
356
|
+
)
|
|
357
|
+
disconnected = self._ensure_disconnect_event()
|
|
358
|
+
disconnected.clear()
|
|
359
|
+
self._disconnected_set = False
|
|
360
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
361
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
362
|
+
self._initialized = False
|
|
363
|
+
|
|
364
|
+
async def _initialize_handshake(self) -> None:
|
|
365
|
+
client_info: Dict[str, Any] = {"name": "codex-autorunner"}
|
|
366
|
+
if self._include_client_version:
|
|
367
|
+
client_info["version"] = self._client_version
|
|
368
|
+
params = {"clientInfo": client_info}
|
|
369
|
+
self._initializing = True
|
|
370
|
+
try:
|
|
371
|
+
await self._request_raw("initialize", params=params)
|
|
372
|
+
except CodexAppServerResponseError as exc:
|
|
373
|
+
if self._include_client_version:
|
|
374
|
+
self._include_client_version = False
|
|
375
|
+
log_event(
|
|
376
|
+
self._logger,
|
|
377
|
+
logging.WARNING,
|
|
378
|
+
"app_server.initialize.retry",
|
|
379
|
+
reason="response_error",
|
|
380
|
+
error_code=exc.code,
|
|
381
|
+
)
|
|
382
|
+
raise
|
|
383
|
+
except CodexAppServerDisconnected:
|
|
384
|
+
if self._include_client_version:
|
|
385
|
+
self._include_client_version = False
|
|
386
|
+
log_event(
|
|
387
|
+
self._logger,
|
|
388
|
+
logging.WARNING,
|
|
389
|
+
"app_server.initialize.retry",
|
|
390
|
+
reason="disconnect",
|
|
391
|
+
)
|
|
392
|
+
raise
|
|
393
|
+
finally:
|
|
394
|
+
self._initializing = False
|
|
395
|
+
await self._send_message(self._build_message("initialized", params=None))
|
|
396
|
+
self._initialized = True
|
|
397
|
+
self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
|
|
398
|
+
log_event(self._logger, logging.INFO, "app_server.initialized")
|
|
399
|
+
|
|
400
|
+
async def _request_raw(
|
|
401
|
+
self,
|
|
402
|
+
method: str,
|
|
403
|
+
params: Optional[Dict[str, Any]],
|
|
404
|
+
*,
|
|
405
|
+
timeout: Optional[float] = None,
|
|
406
|
+
) -> Any:
|
|
407
|
+
request_id = self._next_request_id()
|
|
408
|
+
loop = asyncio.get_running_loop()
|
|
409
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
410
|
+
self._pending[request_id] = future
|
|
411
|
+
self._pending_methods[request_id] = method
|
|
412
|
+
log_event(
|
|
413
|
+
self._logger,
|
|
414
|
+
logging.INFO,
|
|
415
|
+
"app_server.request",
|
|
416
|
+
request_id=request_id,
|
|
417
|
+
method=method,
|
|
418
|
+
**_summarize_params(method, params),
|
|
419
|
+
)
|
|
420
|
+
await self._send_message(
|
|
421
|
+
self._build_message(method, params=params, req_id=request_id)
|
|
422
|
+
)
|
|
423
|
+
timeout = timeout if timeout is not None else self._request_timeout
|
|
424
|
+
try:
|
|
425
|
+
if timeout is None:
|
|
426
|
+
return await future
|
|
427
|
+
return await asyncio.wait_for(future, timeout)
|
|
428
|
+
except asyncio.TimeoutError:
|
|
429
|
+
if not future.done():
|
|
430
|
+
future.cancel()
|
|
431
|
+
raise
|
|
432
|
+
finally:
|
|
433
|
+
self._pending.pop(request_id, None)
|
|
434
|
+
self._pending_methods.pop(request_id, None)
|
|
435
|
+
|
|
436
|
+
async def _send_message(self, message: Dict[str, Any]) -> None:
|
|
437
|
+
if not self._process or not self._process.stdin:
|
|
438
|
+
raise CodexAppServerDisconnected("App-server process is not running")
|
|
439
|
+
self._ensure_locks()
|
|
440
|
+
write_lock = self._write_lock
|
|
441
|
+
if write_lock is None:
|
|
442
|
+
raise CodexAppServerProtocolError("write lock unavailable")
|
|
443
|
+
payload = json.dumps(message, separators=(",", ":"))
|
|
444
|
+
async with write_lock:
|
|
445
|
+
self._process.stdin.write((payload + "\n").encode("utf-8"))
|
|
446
|
+
await self._process.stdin.drain()
|
|
447
|
+
|
|
448
|
+
def _build_message(
|
|
449
|
+
self,
|
|
450
|
+
method: Optional[str] = None,
|
|
451
|
+
*,
|
|
452
|
+
params: Optional[Dict[str, Any]] = None,
|
|
453
|
+
req_id: Optional[int] = None,
|
|
454
|
+
result: Optional[Any] = None,
|
|
455
|
+
error: Optional[Dict[str, Any]] = None,
|
|
456
|
+
) -> Dict[str, Any]:
|
|
457
|
+
message: Dict[str, Any] = {}
|
|
458
|
+
if req_id is not None:
|
|
459
|
+
message["id"] = req_id
|
|
460
|
+
if method is not None:
|
|
461
|
+
message["method"] = method
|
|
462
|
+
if params is not None:
|
|
463
|
+
message["params"] = params
|
|
464
|
+
if result is not None:
|
|
465
|
+
message["result"] = result
|
|
466
|
+
if error is not None:
|
|
467
|
+
message["error"] = error
|
|
468
|
+
return message
|
|
469
|
+
|
|
470
|
+
def _next_request_id(self) -> int:
|
|
471
|
+
request_id = self._next_id
|
|
472
|
+
self._next_id += 1
|
|
473
|
+
return request_id
|
|
474
|
+
|
|
475
|
+
def _ensure_locks(self) -> None:
|
|
476
|
+
if self._start_lock is None:
|
|
477
|
+
self._start_lock = asyncio.Lock()
|
|
478
|
+
if self._write_lock is None:
|
|
479
|
+
self._write_lock = asyncio.Lock()
|
|
480
|
+
|
|
481
|
+
def _ensure_disconnect_event(self) -> asyncio.Event:
|
|
482
|
+
if self._disconnected is None:
|
|
483
|
+
self._disconnected = asyncio.Event()
|
|
484
|
+
if self._disconnected_set:
|
|
485
|
+
self._disconnected.set()
|
|
486
|
+
return self._disconnected
|
|
487
|
+
|
|
488
|
+
async def _read_loop(self) -> None:
|
|
489
|
+
assert self._process is not None
|
|
490
|
+
assert self._process.stdout is not None
|
|
491
|
+
buffer = bytearray()
|
|
492
|
+
dropping_oversize = False
|
|
493
|
+
oversize_preview = bytearray()
|
|
494
|
+
oversize_bytes_dropped = 0
|
|
495
|
+
try:
|
|
496
|
+
while True:
|
|
497
|
+
chunk = await self._process.stdout.read(_READ_CHUNK_SIZE)
|
|
498
|
+
if not chunk:
|
|
499
|
+
break
|
|
500
|
+
if dropping_oversize:
|
|
501
|
+
newline_index = chunk.find(b"\n")
|
|
502
|
+
if newline_index == -1:
|
|
503
|
+
if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
|
|
504
|
+
remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
|
|
505
|
+
oversize_preview.extend(chunk[:remaining])
|
|
506
|
+
oversize_bytes_dropped += len(chunk)
|
|
507
|
+
if oversize_bytes_dropped >= _MAX_OVERSIZE_DRAIN_BYTES:
|
|
508
|
+
await self._emit_oversize_warning(
|
|
509
|
+
bytes_dropped=oversize_bytes_dropped,
|
|
510
|
+
preview=oversize_preview,
|
|
511
|
+
aborted=True,
|
|
512
|
+
drain_limit=_MAX_OVERSIZE_DRAIN_BYTES,
|
|
513
|
+
)
|
|
514
|
+
raise ValueError(
|
|
515
|
+
"App-server message exceeded oversize drain limit "
|
|
516
|
+
f"({_MAX_OVERSIZE_DRAIN_BYTES} bytes)"
|
|
517
|
+
)
|
|
518
|
+
continue
|
|
519
|
+
before = chunk[: newline_index + 1]
|
|
520
|
+
after = chunk[newline_index + 1 :]
|
|
521
|
+
if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
|
|
522
|
+
remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
|
|
523
|
+
oversize_preview.extend(before[:remaining])
|
|
524
|
+
oversize_bytes_dropped += len(before)
|
|
525
|
+
await self._emit_oversize_warning(
|
|
526
|
+
bytes_dropped=oversize_bytes_dropped,
|
|
527
|
+
preview=oversize_preview,
|
|
528
|
+
)
|
|
529
|
+
dropping_oversize = False
|
|
530
|
+
oversize_preview = bytearray()
|
|
531
|
+
oversize_bytes_dropped = 0
|
|
532
|
+
if not after:
|
|
533
|
+
continue
|
|
534
|
+
buffer.extend(after)
|
|
535
|
+
else:
|
|
536
|
+
buffer.extend(chunk)
|
|
537
|
+
while True:
|
|
538
|
+
newline_index = buffer.find(b"\n")
|
|
539
|
+
if newline_index == -1:
|
|
540
|
+
break
|
|
541
|
+
line = buffer[:newline_index]
|
|
542
|
+
del buffer[: newline_index + 1]
|
|
543
|
+
await self._handle_payload_line(line)
|
|
544
|
+
if not dropping_oversize and len(buffer) > _MAX_MESSAGE_BYTES:
|
|
545
|
+
oversize_preview = bytearray(buffer[:_OVERSIZE_PREVIEW_BYTES])
|
|
546
|
+
oversize_bytes_dropped = len(buffer)
|
|
547
|
+
buffer.clear()
|
|
548
|
+
dropping_oversize = True
|
|
549
|
+
if dropping_oversize:
|
|
550
|
+
if oversize_bytes_dropped:
|
|
551
|
+
await self._emit_oversize_warning(
|
|
552
|
+
bytes_dropped=oversize_bytes_dropped,
|
|
553
|
+
preview=oversize_preview,
|
|
554
|
+
truncated=True,
|
|
555
|
+
)
|
|
556
|
+
elif buffer:
|
|
557
|
+
if len(buffer) > _MAX_MESSAGE_BYTES:
|
|
558
|
+
await self._emit_oversize_warning(
|
|
559
|
+
bytes_dropped=len(buffer),
|
|
560
|
+
preview=buffer[:_OVERSIZE_PREVIEW_BYTES],
|
|
561
|
+
truncated=True,
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
await self._handle_payload_line(buffer)
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
log_event(self._logger, logging.WARNING, "app_server.read.failed", exc=exc)
|
|
567
|
+
finally:
|
|
568
|
+
await self._handle_disconnect()
|
|
569
|
+
|
|
570
|
+
async def _handle_payload_line(self, line: bytes) -> None:
|
|
571
|
+
if not line:
|
|
572
|
+
return
|
|
573
|
+
payload = line.decode("utf-8", errors="ignore").strip()
|
|
574
|
+
if not payload:
|
|
575
|
+
return
|
|
576
|
+
try:
|
|
577
|
+
message = json.loads(payload)
|
|
578
|
+
except json.JSONDecodeError:
|
|
579
|
+
return
|
|
580
|
+
if not isinstance(message, dict):
|
|
581
|
+
return
|
|
582
|
+
await self._handle_message(message)
|
|
583
|
+
|
|
584
|
+
async def _emit_oversize_warning(
|
|
585
|
+
self,
|
|
586
|
+
*,
|
|
587
|
+
bytes_dropped: int,
|
|
588
|
+
preview: bytes,
|
|
589
|
+
truncated: bool = False,
|
|
590
|
+
aborted: bool = False,
|
|
591
|
+
drain_limit: Optional[int] = None,
|
|
592
|
+
) -> None:
|
|
593
|
+
metadata = _infer_metadata_from_preview(preview)
|
|
594
|
+
log_event(
|
|
595
|
+
self._logger,
|
|
596
|
+
logging.WARNING,
|
|
597
|
+
"app_server.read.oversize_dropped",
|
|
598
|
+
bytes_dropped=bytes_dropped,
|
|
599
|
+
preview_bytes=len(preview),
|
|
600
|
+
preview_excerpt=_preview_excerpt(metadata.get("preview") or ""),
|
|
601
|
+
inferred_method=metadata.get("method"),
|
|
602
|
+
inferred_thread_id=metadata.get("thread_id"),
|
|
603
|
+
inferred_turn_id=metadata.get("turn_id"),
|
|
604
|
+
truncated=truncated,
|
|
605
|
+
aborted=aborted,
|
|
606
|
+
drain_limit=drain_limit,
|
|
607
|
+
)
|
|
608
|
+
if self._notification_handler is None:
|
|
609
|
+
return
|
|
610
|
+
params: Dict[str, Any] = {
|
|
611
|
+
"byteLimit": _MAX_MESSAGE_BYTES,
|
|
612
|
+
"bytesDropped": bytes_dropped,
|
|
613
|
+
}
|
|
614
|
+
inferred_method = metadata.get("method")
|
|
615
|
+
inferred_thread_id = metadata.get("thread_id")
|
|
616
|
+
inferred_turn_id = metadata.get("turn_id")
|
|
617
|
+
if inferred_method:
|
|
618
|
+
params["inferredMethod"] = inferred_method
|
|
619
|
+
if inferred_thread_id:
|
|
620
|
+
params["threadId"] = inferred_thread_id
|
|
621
|
+
if inferred_turn_id:
|
|
622
|
+
params["turnId"] = inferred_turn_id
|
|
623
|
+
if truncated:
|
|
624
|
+
params["truncated"] = True
|
|
625
|
+
if aborted:
|
|
626
|
+
params["aborted"] = True
|
|
627
|
+
if drain_limit is not None:
|
|
628
|
+
params["drainLimit"] = drain_limit
|
|
629
|
+
try:
|
|
630
|
+
await _maybe_await(
|
|
631
|
+
self._notification_handler(
|
|
632
|
+
{
|
|
633
|
+
"method": "car/app_server/oversizedMessageDropped",
|
|
634
|
+
"params": params,
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
log_event(
|
|
640
|
+
self._logger,
|
|
641
|
+
logging.WARNING,
|
|
642
|
+
"app_server.notification_handler.failed",
|
|
643
|
+
method="car/app_server/oversizedMessageDropped",
|
|
644
|
+
handled=False,
|
|
645
|
+
exc=exc,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
async def _drain_stderr(self) -> None:
|
|
649
|
+
if not self._process or not self._process.stderr:
|
|
650
|
+
return
|
|
651
|
+
try:
|
|
652
|
+
while True:
|
|
653
|
+
line = await self._process.stderr.readline()
|
|
654
|
+
if not line:
|
|
655
|
+
break
|
|
656
|
+
text = line.decode("utf-8", errors="ignore").strip()
|
|
657
|
+
if text:
|
|
658
|
+
sanitized = sanitize_log_value(text)
|
|
659
|
+
if isinstance(sanitized, str):
|
|
660
|
+
self._stderr_tail.append(sanitized)
|
|
661
|
+
else:
|
|
662
|
+
self._stderr_tail.append(str(sanitized))
|
|
663
|
+
log_event(
|
|
664
|
+
self._logger,
|
|
665
|
+
logging.DEBUG,
|
|
666
|
+
"app_server.stderr",
|
|
667
|
+
line_len=len(text),
|
|
668
|
+
tail_size=len(self._stderr_tail),
|
|
669
|
+
)
|
|
670
|
+
except Exception:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
async def _handle_message(self, message: Dict[str, Any]) -> None:
|
|
674
|
+
if "id" in message and "method" not in message:
|
|
675
|
+
await self._handle_response(message)
|
|
676
|
+
return
|
|
677
|
+
if "id" in message and "method" in message:
|
|
678
|
+
await self._handle_server_request(message)
|
|
679
|
+
return
|
|
680
|
+
if "method" in message:
|
|
681
|
+
await self._handle_notification(message)
|
|
682
|
+
|
|
683
|
+
async def _handle_response(self, message: Dict[str, Any]) -> None:
|
|
684
|
+
req_id = message.get("id")
|
|
685
|
+
if not isinstance(req_id, int):
|
|
686
|
+
return
|
|
687
|
+
future = self._pending.pop(req_id, None)
|
|
688
|
+
method = self._pending_methods.pop(req_id, None)
|
|
689
|
+
if future is None:
|
|
690
|
+
return
|
|
691
|
+
if future.cancelled():
|
|
692
|
+
return
|
|
693
|
+
if "error" in message and message["error"] is not None:
|
|
694
|
+
err = message.get("error") or {}
|
|
695
|
+
log_event(
|
|
696
|
+
self._logger,
|
|
697
|
+
logging.WARNING,
|
|
698
|
+
"app_server.response.error",
|
|
699
|
+
request_id=req_id,
|
|
700
|
+
method=method,
|
|
701
|
+
error_code=err.get("code"),
|
|
702
|
+
error_message=err.get("message"),
|
|
703
|
+
)
|
|
704
|
+
future.set_exception(
|
|
705
|
+
CodexAppServerResponseError(
|
|
706
|
+
method=method,
|
|
707
|
+
code=err.get("code"),
|
|
708
|
+
message=err.get("message") or "app-server error",
|
|
709
|
+
data=err.get("data"),
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
return
|
|
713
|
+
log_event(
|
|
714
|
+
self._logger,
|
|
715
|
+
logging.INFO,
|
|
716
|
+
"app_server.response",
|
|
717
|
+
request_id=req_id,
|
|
718
|
+
method=method,
|
|
719
|
+
)
|
|
720
|
+
future.set_result(message.get("result"))
|
|
721
|
+
|
|
722
|
+
async def _handle_server_request(self, message: Dict[str, Any]) -> None:
|
|
723
|
+
method = message.get("method")
|
|
724
|
+
req_id = message.get("id")
|
|
725
|
+
if isinstance(method, str) and method in APPROVAL_METHODS:
|
|
726
|
+
params_raw = message.get("params")
|
|
727
|
+
params: Dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
|
|
728
|
+
log_event(
|
|
729
|
+
self._logger,
|
|
730
|
+
logging.INFO,
|
|
731
|
+
"app_server.approval.requested",
|
|
732
|
+
request_id=req_id,
|
|
733
|
+
method=method,
|
|
734
|
+
turn_id=params.get("turnId"),
|
|
735
|
+
)
|
|
736
|
+
decision: ApprovalDecision = self._default_approval_decision
|
|
737
|
+
if self._approval_handler is not None:
|
|
738
|
+
try:
|
|
739
|
+
decision = await _maybe_await(self._approval_handler(message))
|
|
740
|
+
except Exception as exc:
|
|
741
|
+
log_event(
|
|
742
|
+
self._logger,
|
|
743
|
+
logging.WARNING,
|
|
744
|
+
"app_server.approval.failed",
|
|
745
|
+
request_id=req_id,
|
|
746
|
+
method=method,
|
|
747
|
+
exc=exc,
|
|
748
|
+
)
|
|
749
|
+
await self._send_message(
|
|
750
|
+
self._build_message(
|
|
751
|
+
req_id=req_id,
|
|
752
|
+
error={
|
|
753
|
+
"code": -32001,
|
|
754
|
+
"message": "approval handler failed",
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
return
|
|
759
|
+
result = decision if isinstance(decision, dict) else {"decision": decision}
|
|
760
|
+
log_event(
|
|
761
|
+
self._logger,
|
|
762
|
+
logging.INFO,
|
|
763
|
+
"app_server.approval.responded",
|
|
764
|
+
request_id=req_id,
|
|
765
|
+
method=method,
|
|
766
|
+
decision=result.get("decision") if isinstance(result, dict) else None,
|
|
767
|
+
)
|
|
768
|
+
await self._send_message(self._build_message(req_id=req_id, result=result))
|
|
769
|
+
return
|
|
770
|
+
await self._send_message(
|
|
771
|
+
self._build_message(
|
|
772
|
+
req_id=req_id,
|
|
773
|
+
error={"code": -32601, "message": f"Unsupported method: {method}"},
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
async def _handle_notification(self, message: Dict[str, Any]) -> None:
|
|
778
|
+
method = message.get("method")
|
|
779
|
+
params = message.get("params") or {}
|
|
780
|
+
handled = False
|
|
781
|
+
if method == "item/completed":
|
|
782
|
+
turn_id = _extract_turn_id(params) or _extract_turn_id(
|
|
783
|
+
params.get("item") if isinstance(params, dict) else None
|
|
784
|
+
)
|
|
785
|
+
if not turn_id:
|
|
786
|
+
handled = True
|
|
787
|
+
return
|
|
788
|
+
thread_id = _extract_thread_id_for_turn(params)
|
|
789
|
+
_key, state = self._find_turn_state(turn_id, thread_id=thread_id)
|
|
790
|
+
if state is None:
|
|
791
|
+
if thread_id:
|
|
792
|
+
state = self._ensure_turn_state(turn_id, thread_id)
|
|
793
|
+
else:
|
|
794
|
+
state = self._ensure_pending_turn_state(turn_id)
|
|
795
|
+
self._apply_item_completed(state, message, params)
|
|
796
|
+
handled = True
|
|
797
|
+
elif method == "turn/completed":
|
|
798
|
+
turn_id = _extract_turn_id(params)
|
|
799
|
+
if not turn_id:
|
|
800
|
+
handled = True
|
|
801
|
+
return
|
|
802
|
+
thread_id = _extract_thread_id_for_turn(params)
|
|
803
|
+
_key, state = self._find_turn_state(turn_id, thread_id=thread_id)
|
|
804
|
+
if state is None:
|
|
805
|
+
if thread_id:
|
|
806
|
+
state = self._ensure_turn_state(turn_id, thread_id)
|
|
807
|
+
else:
|
|
808
|
+
state = self._ensure_pending_turn_state(turn_id)
|
|
809
|
+
self._apply_turn_completed(state, message, params)
|
|
810
|
+
handled = True
|
|
811
|
+
elif method == "error":
|
|
812
|
+
turn_id = _extract_turn_id(params)
|
|
813
|
+
if not turn_id:
|
|
814
|
+
handled = True
|
|
815
|
+
return
|
|
816
|
+
thread_id = _extract_thread_id_for_turn(params)
|
|
817
|
+
_key, state = self._find_turn_state(turn_id, thread_id=thread_id)
|
|
818
|
+
if state is None:
|
|
819
|
+
if thread_id:
|
|
820
|
+
state = self._ensure_turn_state(turn_id, thread_id)
|
|
821
|
+
else:
|
|
822
|
+
state = self._ensure_pending_turn_state(turn_id)
|
|
823
|
+
self._apply_error(state, message, params)
|
|
824
|
+
handled = True
|
|
825
|
+
if self._notification_handler is not None:
|
|
826
|
+
try:
|
|
827
|
+
await _maybe_await(self._notification_handler(message))
|
|
828
|
+
except Exception as exc:
|
|
829
|
+
log_event(
|
|
830
|
+
self._logger,
|
|
831
|
+
logging.WARNING,
|
|
832
|
+
"app_server.notification_handler.failed",
|
|
833
|
+
method=method,
|
|
834
|
+
handled=handled,
|
|
835
|
+
exc=exc,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def _find_turn_state(
|
|
839
|
+
self, turn_id: str, *, thread_id: Optional[str]
|
|
840
|
+
) -> tuple[Optional[TurnKey], Optional[_TurnState]]:
|
|
841
|
+
key = _turn_key(thread_id, turn_id)
|
|
842
|
+
if key is not None:
|
|
843
|
+
state = self._turns.get(key)
|
|
844
|
+
if state is not None:
|
|
845
|
+
return key, state
|
|
846
|
+
matches = [
|
|
847
|
+
(candidate_key, state)
|
|
848
|
+
for candidate_key, state in self._turns.items()
|
|
849
|
+
if candidate_key[1] == turn_id
|
|
850
|
+
]
|
|
851
|
+
if len(matches) == 1:
|
|
852
|
+
candidate_key, state = matches[0]
|
|
853
|
+
if key is not None and candidate_key != key:
|
|
854
|
+
log_event(
|
|
855
|
+
self._logger,
|
|
856
|
+
logging.WARNING,
|
|
857
|
+
"app_server.turn.thread_mismatch",
|
|
858
|
+
turn_id=turn_id,
|
|
859
|
+
requested_thread_id=thread_id,
|
|
860
|
+
actual_thread_id=candidate_key[0],
|
|
861
|
+
)
|
|
862
|
+
return candidate_key, state
|
|
863
|
+
if len(matches) > 1:
|
|
864
|
+
log_event(
|
|
865
|
+
self._logger,
|
|
866
|
+
logging.WARNING,
|
|
867
|
+
"app_server.turn.ambiguous",
|
|
868
|
+
turn_id=turn_id,
|
|
869
|
+
matches=len(matches),
|
|
870
|
+
)
|
|
871
|
+
return None, None
|
|
872
|
+
|
|
873
|
+
def _ensure_turn_state(self, turn_id: str, thread_id: str) -> _TurnState:
|
|
874
|
+
key = _turn_key(thread_id, turn_id)
|
|
875
|
+
if key is None:
|
|
876
|
+
raise CodexAppServerProtocolError("turn state missing thread id")
|
|
877
|
+
state = self._turns.get(key)
|
|
878
|
+
if state is not None:
|
|
879
|
+
return state
|
|
880
|
+
loop = asyncio.get_running_loop()
|
|
881
|
+
future = cast(asyncio.Future[TurnResult], loop.create_future())
|
|
882
|
+
state = _TurnState(
|
|
883
|
+
turn_id=turn_id,
|
|
884
|
+
thread_id=thread_id,
|
|
885
|
+
future=future,
|
|
886
|
+
agent_messages=[],
|
|
887
|
+
errors=[],
|
|
888
|
+
raw_events=[],
|
|
889
|
+
)
|
|
890
|
+
self._turns[key] = state
|
|
891
|
+
return state
|
|
892
|
+
|
|
893
|
+
def _ensure_pending_turn_state(self, turn_id: str) -> _TurnState:
|
|
894
|
+
state = self._pending_turns.get(turn_id)
|
|
895
|
+
if state is not None:
|
|
896
|
+
return state
|
|
897
|
+
loop = asyncio.get_running_loop()
|
|
898
|
+
future = cast(asyncio.Future[TurnResult], loop.create_future())
|
|
899
|
+
state = _TurnState(
|
|
900
|
+
turn_id=turn_id,
|
|
901
|
+
thread_id=None,
|
|
902
|
+
future=future,
|
|
903
|
+
agent_messages=[],
|
|
904
|
+
errors=[],
|
|
905
|
+
raw_events=[],
|
|
906
|
+
)
|
|
907
|
+
self._pending_turns[turn_id] = state
|
|
908
|
+
return state
|
|
909
|
+
|
|
910
|
+
def _merge_turn_state(self, target: _TurnState, source: _TurnState) -> None:
|
|
911
|
+
if not target.agent_messages:
|
|
912
|
+
target.agent_messages = list(source.agent_messages)
|
|
913
|
+
else:
|
|
914
|
+
target.agent_messages.extend(source.agent_messages)
|
|
915
|
+
if not target.raw_events:
|
|
916
|
+
target.raw_events = list(source.raw_events)
|
|
917
|
+
else:
|
|
918
|
+
target.raw_events.extend(source.raw_events)
|
|
919
|
+
if not target.errors:
|
|
920
|
+
target.errors = list(source.errors)
|
|
921
|
+
else:
|
|
922
|
+
target.errors.extend(source.errors)
|
|
923
|
+
if target.status is None and source.status is not None:
|
|
924
|
+
target.status = source.status
|
|
925
|
+
if source.future.done() and not target.future.done():
|
|
926
|
+
target.future.set_result(
|
|
927
|
+
TurnResult(
|
|
928
|
+
turn_id=target.turn_id,
|
|
929
|
+
status=target.status,
|
|
930
|
+
agent_messages=list(target.agent_messages),
|
|
931
|
+
errors=list(target.errors),
|
|
932
|
+
raw_events=list(target.raw_events),
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
def _register_turn_state(self, turn_id: str, thread_id: str) -> _TurnState:
|
|
937
|
+
key = _turn_key(thread_id, turn_id)
|
|
938
|
+
if key is None:
|
|
939
|
+
raise CodexAppServerProtocolError("turn/start missing thread id")
|
|
940
|
+
pending = self._pending_turns.pop(turn_id, None)
|
|
941
|
+
state = self._turns.get(key)
|
|
942
|
+
if pending is not None:
|
|
943
|
+
if state is None:
|
|
944
|
+
pending.thread_id = thread_id
|
|
945
|
+
self._turns[key] = pending
|
|
946
|
+
return pending
|
|
947
|
+
self._merge_turn_state(state, pending)
|
|
948
|
+
return state
|
|
949
|
+
if state is None:
|
|
950
|
+
return self._ensure_turn_state(turn_id, thread_id)
|
|
951
|
+
return state
|
|
952
|
+
|
|
953
|
+
def _apply_item_completed(
|
|
954
|
+
self, state: _TurnState, message: Dict[str, Any], params: Any
|
|
955
|
+
) -> None:
|
|
956
|
+
item = params.get("item") if isinstance(params, dict) else None
|
|
957
|
+
text = None
|
|
958
|
+
|
|
959
|
+
def append_message(candidate: Optional[str]) -> None:
|
|
960
|
+
if not candidate:
|
|
961
|
+
return
|
|
962
|
+
if state.agent_messages and state.agent_messages[-1] == candidate:
|
|
963
|
+
return
|
|
964
|
+
state.agent_messages.append(candidate)
|
|
965
|
+
|
|
966
|
+
if isinstance(item, dict) and item.get("type") == "agentMessage":
|
|
967
|
+
text = item.get("text")
|
|
968
|
+
if isinstance(text, str):
|
|
969
|
+
append_message(text)
|
|
970
|
+
review_text = _extract_review_text(item)
|
|
971
|
+
if review_text and review_text != text:
|
|
972
|
+
append_message(review_text)
|
|
973
|
+
item_type = item.get("type") if isinstance(item, dict) else None
|
|
974
|
+
log_event(
|
|
975
|
+
self._logger,
|
|
976
|
+
logging.INFO,
|
|
977
|
+
"app_server.item.completed",
|
|
978
|
+
turn_id=state.turn_id,
|
|
979
|
+
item_type=item_type,
|
|
980
|
+
)
|
|
981
|
+
state.raw_events.append(message)
|
|
982
|
+
|
|
983
|
+
def _apply_error(
|
|
984
|
+
self, state: _TurnState, message: Dict[str, Any], params: Any
|
|
985
|
+
) -> None:
|
|
986
|
+
error_message = _extract_error_message(params)
|
|
987
|
+
if error_message:
|
|
988
|
+
state.errors.append(error_message)
|
|
989
|
+
error_payload = params.get("error") if isinstance(params, dict) else None
|
|
990
|
+
error_code = (
|
|
991
|
+
error_payload.get("code") if isinstance(error_payload, dict) else None
|
|
992
|
+
)
|
|
993
|
+
will_retry = params.get("willRetry") if isinstance(params, dict) else None
|
|
994
|
+
log_event(
|
|
995
|
+
self._logger,
|
|
996
|
+
logging.WARNING,
|
|
997
|
+
"app_server.turn_error",
|
|
998
|
+
turn_id=state.turn_id,
|
|
999
|
+
thread_id=state.thread_id,
|
|
1000
|
+
message=error_message,
|
|
1001
|
+
code=error_code,
|
|
1002
|
+
will_retry=will_retry,
|
|
1003
|
+
)
|
|
1004
|
+
state.raw_events.append(message)
|
|
1005
|
+
|
|
1006
|
+
def _apply_turn_completed(
|
|
1007
|
+
self, state: _TurnState, message: Dict[str, Any], params: Any
|
|
1008
|
+
) -> None:
|
|
1009
|
+
state.raw_events.append(message)
|
|
1010
|
+
status = None
|
|
1011
|
+
if isinstance(params, dict):
|
|
1012
|
+
status = params.get("status")
|
|
1013
|
+
state.status = status
|
|
1014
|
+
log_event(
|
|
1015
|
+
self._logger,
|
|
1016
|
+
logging.INFO,
|
|
1017
|
+
"app_server.turn.completed",
|
|
1018
|
+
turn_id=state.turn_id,
|
|
1019
|
+
status=status,
|
|
1020
|
+
)
|
|
1021
|
+
if not state.future.done():
|
|
1022
|
+
state.future.set_result(
|
|
1023
|
+
TurnResult(
|
|
1024
|
+
turn_id=state.turn_id,
|
|
1025
|
+
status=state.status,
|
|
1026
|
+
agent_messages=list(state.agent_messages),
|
|
1027
|
+
errors=list(state.errors),
|
|
1028
|
+
raw_events=list(state.raw_events),
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
async def _handle_disconnect(self) -> None:
|
|
1033
|
+
self._initialized = False
|
|
1034
|
+
self._initializing = False
|
|
1035
|
+
disconnected = self._ensure_disconnect_event()
|
|
1036
|
+
disconnected.set()
|
|
1037
|
+
self._disconnected_set = True
|
|
1038
|
+
process = self._process
|
|
1039
|
+
returncode = process.returncode if process is not None else None
|
|
1040
|
+
pid = process.pid if process is not None else None
|
|
1041
|
+
log_event(
|
|
1042
|
+
self._logger,
|
|
1043
|
+
logging.WARNING,
|
|
1044
|
+
"app_server.disconnected",
|
|
1045
|
+
auto_restart=self._auto_restart,
|
|
1046
|
+
returncode=returncode,
|
|
1047
|
+
pid=pid,
|
|
1048
|
+
pending_requests=len(self._pending),
|
|
1049
|
+
pending_turns=len(self._pending_turns),
|
|
1050
|
+
active_turns=len(self._turns),
|
|
1051
|
+
initializing=self._initializing,
|
|
1052
|
+
initialized=self._initialized,
|
|
1053
|
+
closed=self._closed,
|
|
1054
|
+
stderr_tail=list(self._stderr_tail),
|
|
1055
|
+
)
|
|
1056
|
+
if not self._closed:
|
|
1057
|
+
self._fail_pending(CodexAppServerDisconnected("App-server disconnected"))
|
|
1058
|
+
if self._auto_restart and not self._closed:
|
|
1059
|
+
self._schedule_restart()
|
|
1060
|
+
|
|
1061
|
+
def _fail_pending(self, error: Exception) -> None:
|
|
1062
|
+
for future in list(self._pending.values()):
|
|
1063
|
+
if not future.done():
|
|
1064
|
+
future.set_exception(error)
|
|
1065
|
+
self._pending.clear()
|
|
1066
|
+
for state in list(self._turns.values()):
|
|
1067
|
+
if not state.future.done():
|
|
1068
|
+
state.future.set_exception(error)
|
|
1069
|
+
self._turns.clear()
|
|
1070
|
+
for state in list(self._pending_turns.values()):
|
|
1071
|
+
if not state.future.done():
|
|
1072
|
+
state.future.set_exception(error)
|
|
1073
|
+
self._pending_turns.clear()
|
|
1074
|
+
|
|
1075
|
+
def _schedule_restart(self) -> None:
|
|
1076
|
+
if self._restart_task is not None and not self._restart_task.done():
|
|
1077
|
+
return
|
|
1078
|
+
self._restart_task = asyncio.create_task(self._restart_after_disconnect())
|
|
1079
|
+
|
|
1080
|
+
async def _restart_after_disconnect(self) -> None:
|
|
1081
|
+
delay = max(self._restart_backoff_seconds, _RESTART_BACKOFF_INITIAL_SECONDS)
|
|
1082
|
+
jitter = delay * _RESTART_BACKOFF_JITTER_RATIO
|
|
1083
|
+
if jitter:
|
|
1084
|
+
delay += random.uniform(0, jitter)
|
|
1085
|
+
await asyncio.sleep(delay)
|
|
1086
|
+
if self._closed:
|
|
1087
|
+
return
|
|
1088
|
+
try:
|
|
1089
|
+
await self._ensure_process()
|
|
1090
|
+
self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
|
|
1091
|
+
log_event(
|
|
1092
|
+
self._logger,
|
|
1093
|
+
logging.INFO,
|
|
1094
|
+
"app_server.restarted",
|
|
1095
|
+
delay_seconds=round(delay, 2),
|
|
1096
|
+
)
|
|
1097
|
+
except Exception as exc:
|
|
1098
|
+
next_delay = min(
|
|
1099
|
+
max(
|
|
1100
|
+
self._restart_backoff_seconds * 2, _RESTART_BACKOFF_INITIAL_SECONDS
|
|
1101
|
+
),
|
|
1102
|
+
_RESTART_BACKOFF_MAX_SECONDS,
|
|
1103
|
+
)
|
|
1104
|
+
log_event(
|
|
1105
|
+
self._logger,
|
|
1106
|
+
logging.WARNING,
|
|
1107
|
+
"app_server.restart.failed",
|
|
1108
|
+
delay_seconds=round(delay, 2),
|
|
1109
|
+
next_delay_seconds=round(next_delay, 2),
|
|
1110
|
+
exc=exc,
|
|
1111
|
+
)
|
|
1112
|
+
self._restart_backoff_seconds = next_delay
|
|
1113
|
+
if not self._closed:
|
|
1114
|
+
self._schedule_restart()
|
|
1115
|
+
|
|
1116
|
+
async def _terminate_process(self) -> None:
|
|
1117
|
+
if self._reader_task is not None:
|
|
1118
|
+
self._reader_task.cancel()
|
|
1119
|
+
try:
|
|
1120
|
+
await self._reader_task
|
|
1121
|
+
except asyncio.CancelledError:
|
|
1122
|
+
pass
|
|
1123
|
+
if self._stderr_task is not None:
|
|
1124
|
+
self._stderr_task.cancel()
|
|
1125
|
+
try:
|
|
1126
|
+
await self._stderr_task
|
|
1127
|
+
except asyncio.CancelledError:
|
|
1128
|
+
pass
|
|
1129
|
+
if self._process is None:
|
|
1130
|
+
return
|
|
1131
|
+
if self._process.returncode is None:
|
|
1132
|
+
self._process.terminate()
|
|
1133
|
+
try:
|
|
1134
|
+
await asyncio.wait_for(self._process.wait(), timeout=1)
|
|
1135
|
+
except asyncio.TimeoutError:
|
|
1136
|
+
self._process.kill()
|
|
1137
|
+
await self._process.wait()
|
|
1138
|
+
self._process = None
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _summarize_params(method: str, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1142
|
+
if not isinstance(params, dict):
|
|
1143
|
+
return {}
|
|
1144
|
+
if method == "turn/start":
|
|
1145
|
+
input_items = params.get("input")
|
|
1146
|
+
input_chars = 0
|
|
1147
|
+
if isinstance(input_items, list):
|
|
1148
|
+
for item in input_items:
|
|
1149
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
1150
|
+
text = item.get("text")
|
|
1151
|
+
if isinstance(text, str):
|
|
1152
|
+
input_chars += len(text)
|
|
1153
|
+
summary: Dict[str, Any] = {
|
|
1154
|
+
"thread_id": params.get("threadId"),
|
|
1155
|
+
"input_chars": input_chars,
|
|
1156
|
+
}
|
|
1157
|
+
if "approvalPolicy" in params:
|
|
1158
|
+
summary["approval_policy"] = params.get("approvalPolicy")
|
|
1159
|
+
if "sandboxPolicy" in params:
|
|
1160
|
+
summary["sandbox_policy"] = params.get("sandboxPolicy")
|
|
1161
|
+
return summary
|
|
1162
|
+
if method == "turn/interrupt":
|
|
1163
|
+
return {"turn_id": params.get("turnId"), "thread_id": params.get("threadId")}
|
|
1164
|
+
if method == "thread/start":
|
|
1165
|
+
return {"cwd": params.get("cwd")}
|
|
1166
|
+
if method == "thread/resume":
|
|
1167
|
+
return {"thread_id": params.get("threadId")}
|
|
1168
|
+
if method == "thread/list":
|
|
1169
|
+
return {}
|
|
1170
|
+
if method == "review/start":
|
|
1171
|
+
return {"thread_id": params.get("threadId")}
|
|
1172
|
+
return {"param_keys": list(params.keys())[:10]}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _client_version() -> str:
|
|
1176
|
+
try:
|
|
1177
|
+
return importlib_metadata.version("codex-autorunner")
|
|
1178
|
+
except Exception:
|
|
1179
|
+
return "unknown"
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
async def _maybe_await(value: Any) -> Any:
|
|
1183
|
+
if asyncio.iscoroutine(value):
|
|
1184
|
+
return await value
|
|
1185
|
+
return value
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _first_regex_group(text: str, pattern: str) -> Optional[str]:
|
|
1189
|
+
try:
|
|
1190
|
+
match = re.search(pattern, text)
|
|
1191
|
+
except re.error:
|
|
1192
|
+
return None
|
|
1193
|
+
if not match:
|
|
1194
|
+
return None
|
|
1195
|
+
value = match.group(1)
|
|
1196
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _infer_metadata_from_preview(preview: bytes) -> Dict[str, Optional[str]]:
|
|
1200
|
+
try:
|
|
1201
|
+
text = preview.decode("utf-8", errors="ignore")
|
|
1202
|
+
except Exception:
|
|
1203
|
+
return {"preview": "", "method": None, "thread_id": None, "turn_id": None}
|
|
1204
|
+
method = _first_regex_group(text, r'"method"\s*:\s*"([^"]+)"')
|
|
1205
|
+
thread_id = _first_regex_group(text, r'"threadId"\s*:\s*"([^"]+)"')
|
|
1206
|
+
if not thread_id:
|
|
1207
|
+
thread_id = _first_regex_group(text, r'"thread_id"\s*:\s*"([^"]+)"')
|
|
1208
|
+
turn_id = _first_regex_group(text, r'"turnId"\s*:\s*"([^"]+)"')
|
|
1209
|
+
if not turn_id:
|
|
1210
|
+
turn_id = _first_regex_group(text, r'"turn_id"\s*:\s*"([^"]+)"')
|
|
1211
|
+
return {
|
|
1212
|
+
"preview": text,
|
|
1213
|
+
"method": method,
|
|
1214
|
+
"thread_id": thread_id,
|
|
1215
|
+
"turn_id": turn_id,
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _preview_excerpt(text: str, limit: int = 256) -> str:
|
|
1220
|
+
normalized = " ".join(text.split()).strip()
|
|
1221
|
+
if not normalized:
|
|
1222
|
+
return ""
|
|
1223
|
+
if len(normalized) <= limit:
|
|
1224
|
+
return normalized
|
|
1225
|
+
return f"{normalized[:limit].rstrip()}..."
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _extract_turn_id(payload: Any) -> Optional[str]:
|
|
1229
|
+
if not isinstance(payload, dict):
|
|
1230
|
+
return None
|
|
1231
|
+
for key in ("turnId", "turn_id", "id"):
|
|
1232
|
+
value = payload.get(key)
|
|
1233
|
+
if isinstance(value, str):
|
|
1234
|
+
return value
|
|
1235
|
+
turn = payload.get("turn")
|
|
1236
|
+
if isinstance(turn, dict):
|
|
1237
|
+
for key in ("id", "turnId", "turn_id"):
|
|
1238
|
+
value = turn.get(key)
|
|
1239
|
+
if isinstance(value, str):
|
|
1240
|
+
return value
|
|
1241
|
+
return None
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _turn_key(thread_id: Optional[str], turn_id: Optional[str]) -> Optional[TurnKey]:
|
|
1245
|
+
if not thread_id or not turn_id:
|
|
1246
|
+
return None
|
|
1247
|
+
return (thread_id, turn_id)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
|
|
1251
|
+
if not isinstance(payload, dict):
|
|
1252
|
+
return None
|
|
1253
|
+
for candidate in (payload, payload.get("turn"), payload.get("item")):
|
|
1254
|
+
thread_id = _extract_thread_id_from_container(candidate)
|
|
1255
|
+
if thread_id:
|
|
1256
|
+
return thread_id
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
|
|
1261
|
+
if not isinstance(payload, dict):
|
|
1262
|
+
return None
|
|
1263
|
+
for key in ("threadId", "thread_id"):
|
|
1264
|
+
value = payload.get(key)
|
|
1265
|
+
if isinstance(value, str):
|
|
1266
|
+
return value
|
|
1267
|
+
thread = payload.get("thread")
|
|
1268
|
+
if isinstance(thread, dict):
|
|
1269
|
+
for key in ("id", "threadId", "thread_id"):
|
|
1270
|
+
value = thread.get(key)
|
|
1271
|
+
if isinstance(value, str):
|
|
1272
|
+
return value
|
|
1273
|
+
return None
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _extract_review_text(item: Any) -> Optional[str]:
|
|
1277
|
+
if not isinstance(item, dict):
|
|
1278
|
+
return None
|
|
1279
|
+
exited = item.get("exitedReviewMode")
|
|
1280
|
+
if isinstance(exited, dict):
|
|
1281
|
+
review = exited.get("review")
|
|
1282
|
+
if isinstance(review, str) and review.strip():
|
|
1283
|
+
return review
|
|
1284
|
+
if item.get("type") == "review":
|
|
1285
|
+
text = item.get("text")
|
|
1286
|
+
if isinstance(text, str) and text.strip():
|
|
1287
|
+
return text
|
|
1288
|
+
review = item.get("review")
|
|
1289
|
+
if isinstance(review, str) and review.strip():
|
|
1290
|
+
return review
|
|
1291
|
+
return None
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _extract_error_message(payload: Any) -> Optional[str]:
|
|
1295
|
+
if not isinstance(payload, dict):
|
|
1296
|
+
return None
|
|
1297
|
+
error = payload.get("error")
|
|
1298
|
+
message: Optional[str] = None
|
|
1299
|
+
details: Optional[str] = None
|
|
1300
|
+
if isinstance(error, dict):
|
|
1301
|
+
raw_message = error.get("message")
|
|
1302
|
+
if isinstance(raw_message, str):
|
|
1303
|
+
message = raw_message.strip() or None
|
|
1304
|
+
raw_details = error.get("additionalDetails") or error.get("details")
|
|
1305
|
+
if isinstance(raw_details, str):
|
|
1306
|
+
details = raw_details.strip() or None
|
|
1307
|
+
elif isinstance(error, str):
|
|
1308
|
+
message = error.strip() or None
|
|
1309
|
+
if message is None:
|
|
1310
|
+
fallback = payload.get("message")
|
|
1311
|
+
if isinstance(fallback, str):
|
|
1312
|
+
message = fallback.strip() or None
|
|
1313
|
+
if details and details != message:
|
|
1314
|
+
if message:
|
|
1315
|
+
return f"{message} ({details})"
|
|
1316
|
+
return details
|
|
1317
|
+
return message
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _extract_thread_id(payload: Any) -> Optional[str]:
|
|
1321
|
+
if not isinstance(payload, dict):
|
|
1322
|
+
return None
|
|
1323
|
+
for key in ("threadId", "thread_id", "id"):
|
|
1324
|
+
value = payload.get(key)
|
|
1325
|
+
if isinstance(value, str):
|
|
1326
|
+
return value
|
|
1327
|
+
thread = payload.get("thread")
|
|
1328
|
+
if isinstance(thread, dict):
|
|
1329
|
+
for key in ("id", "threadId", "thread_id"):
|
|
1330
|
+
value = thread.get(key)
|
|
1331
|
+
if isinstance(value, str):
|
|
1332
|
+
return value
|
|
1333
|
+
return None
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
_SANDBOX_POLICY_CANONICAL = {
|
|
1337
|
+
"dangerfullaccess": "dangerFullAccess",
|
|
1338
|
+
"readonly": "readOnly",
|
|
1339
|
+
"workspacewrite": "workspaceWrite",
|
|
1340
|
+
"externalsandbox": "externalSandbox",
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _normalize_sandbox_policy(value: Any) -> Any:
|
|
1345
|
+
if value is None:
|
|
1346
|
+
return None
|
|
1347
|
+
if isinstance(value, dict):
|
|
1348
|
+
type_value = value.get("type")
|
|
1349
|
+
if isinstance(type_value, str):
|
|
1350
|
+
canonical = _normalize_sandbox_policy_type(type_value)
|
|
1351
|
+
if canonical != type_value:
|
|
1352
|
+
updated = dict(value)
|
|
1353
|
+
updated["type"] = canonical
|
|
1354
|
+
return updated
|
|
1355
|
+
return value
|
|
1356
|
+
if isinstance(value, str):
|
|
1357
|
+
raw = value.strip()
|
|
1358
|
+
if not raw:
|
|
1359
|
+
return None
|
|
1360
|
+
canonical = _normalize_sandbox_policy_type(raw)
|
|
1361
|
+
return {"type": canonical}
|
|
1362
|
+
return value
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _normalize_sandbox_policy_type(raw: str) -> str:
|
|
1366
|
+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "", raw.strip())
|
|
1367
|
+
if not cleaned:
|
|
1368
|
+
return raw.strip()
|
|
1369
|
+
canonical = _SANDBOX_POLICY_CANONICAL.get(cleaned.lower())
|
|
1370
|
+
return canonical or raw.strip()
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
__all__ = [
|
|
1374
|
+
"APPROVAL_METHODS",
|
|
1375
|
+
"ApprovalDecision",
|
|
1376
|
+
"ApprovalHandler",
|
|
1377
|
+
"CodexAppServerClient",
|
|
1378
|
+
"CodexAppServerDisconnected",
|
|
1379
|
+
"CodexAppServerError",
|
|
1380
|
+
"CodexAppServerProtocolError",
|
|
1381
|
+
"CodexAppServerResponseError",
|
|
1382
|
+
"NotificationHandler",
|
|
1383
|
+
"TurnHandle",
|
|
1384
|
+
"TurnResult",
|
|
1385
|
+
"_normalize_sandbox_policy",
|
|
1386
|
+
]
|