python-codex 0.1.0__py3-none-any.whl → 0.1.2__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.
- pycodex/__init__.py +2 -0
- pycodex/cli.py +101 -30
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/runtime.py +6 -2
- pycodex/runtime_services.py +7 -3
- pycodex/tools/exec_tool.py +1 -1
- pycodex/tools/unified_exec_manager.py +19 -2
- pycodex/utils/get_env.py +23 -4
- python_codex-0.1.2.dist-info/METADATA +355 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.2.dist-info}/RECORD +25 -12
- responses_server/__init__.py +17 -0
- responses_server/__main__.py +5 -0
- responses_server/app.py +217 -0
- responses_server/config.py +63 -0
- responses_server/payload_processors.py +86 -0
- responses_server/server.py +63 -0
- responses_server/session_store.py +37 -0
- responses_server/stream_router.py +784 -0
- responses_server/tools/__init__.py +4 -0
- responses_server/tools/custom_adapter.py +235 -0
- responses_server/tools/web_search.py +263 -0
- python_codex-0.1.0.dist-info/METADATA +0 -267
- {python_codex-0.1.0.dist-info → python_codex-0.1.2.dist-info}/WHEEL +0 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.2.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from .portable import (
|
|
12
|
+
DEFAULT_STORAGE_SERVER,
|
|
13
|
+
HEALTHCHECK_PATH,
|
|
14
|
+
STORAGE_API_PREFIX,
|
|
15
|
+
_call_id_from_payload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexStorageServer:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
root: str | Path,
|
|
23
|
+
host: str = "127.0.0.1",
|
|
24
|
+
port: int = 5577,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._root = Path(root).resolve()
|
|
27
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._objects_dir = self._root / "objects"
|
|
29
|
+
self._objects_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self._server = ThreadingHTTPServer((host, port), self._build_handler())
|
|
31
|
+
self._thread: threading.Thread | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def host(self) -> str:
|
|
35
|
+
return str(self._server.server_address[0])
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def port(self) -> int:
|
|
39
|
+
return int(self._server.server_address[1])
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def server_address(self) -> str:
|
|
43
|
+
return f"{self.host}:{self.port}"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def base_url(self) -> str:
|
|
47
|
+
return f"http://{self.server_address}{STORAGE_API_PREFIX}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def root(self) -> Path:
|
|
51
|
+
return self._root
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
if self._thread is not None:
|
|
55
|
+
return
|
|
56
|
+
self._thread = threading.Thread(
|
|
57
|
+
target=self._server.serve_forever,
|
|
58
|
+
name="pycodex-storage-server",
|
|
59
|
+
daemon=True,
|
|
60
|
+
)
|
|
61
|
+
self._thread.start()
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._server.shutdown()
|
|
65
|
+
self._server.server_close()
|
|
66
|
+
if self._thread is not None:
|
|
67
|
+
self._thread.join(timeout=5.0)
|
|
68
|
+
self._thread = None
|
|
69
|
+
|
|
70
|
+
def _build_handler(self):
|
|
71
|
+
server = self
|
|
72
|
+
|
|
73
|
+
class Handler(BaseHTTPRequestHandler):
|
|
74
|
+
def do_GET(self) -> None: # noqa: N802
|
|
75
|
+
path = urlparse(self.path).path
|
|
76
|
+
if path == HEALTHCHECK_PATH:
|
|
77
|
+
self._send_json(200, {"ok": True})
|
|
78
|
+
return
|
|
79
|
+
if not path.startswith(f"{STORAGE_API_PREFIX}/call/"):
|
|
80
|
+
self._send_json(404, {"error": "not found"})
|
|
81
|
+
return
|
|
82
|
+
call_id = unquote(path[len(f"{STORAGE_API_PREFIX}/call/") :]).strip()
|
|
83
|
+
if not call_id:
|
|
84
|
+
self._send_json(400, {"error": "missing call_id"})
|
|
85
|
+
return
|
|
86
|
+
object_path = server._object_path(call_id)
|
|
87
|
+
if not object_path.is_file():
|
|
88
|
+
self._send_json(404, {"error": "not found"})
|
|
89
|
+
return
|
|
90
|
+
payload = object_path.read_bytes()
|
|
91
|
+
print(
|
|
92
|
+
"[server] call: "
|
|
93
|
+
f"client={self.client_address[0]} call_id={call_id} path={object_path}",
|
|
94
|
+
flush=True,
|
|
95
|
+
)
|
|
96
|
+
self.send_response(200)
|
|
97
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
98
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
99
|
+
self.send_header("X-Pycodex-Sha256", hashlib.sha256(payload).hexdigest())
|
|
100
|
+
self.send_header("X-Pycodex-Call-Id", call_id)
|
|
101
|
+
self.end_headers()
|
|
102
|
+
self.wfile.write(payload)
|
|
103
|
+
|
|
104
|
+
def do_POST(self) -> None: # noqa: N802
|
|
105
|
+
path = urlparse(self.path).path
|
|
106
|
+
if path != f"{STORAGE_API_PREFIX}/put":
|
|
107
|
+
self._send_json(404, {"error": "not found"})
|
|
108
|
+
return
|
|
109
|
+
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
|
110
|
+
if content_length <= 0:
|
|
111
|
+
self._send_json(400, {"error": "empty body"})
|
|
112
|
+
return
|
|
113
|
+
payload = self.rfile.read(content_length)
|
|
114
|
+
sha256 = hashlib.sha256(payload).hexdigest()
|
|
115
|
+
expected_sha256 = self.headers.get("X-Pycodex-Sha256", "").strip().lower()
|
|
116
|
+
if expected_sha256 and expected_sha256 != sha256:
|
|
117
|
+
self._send_json(400, {"error": "checksum mismatch"})
|
|
118
|
+
return
|
|
119
|
+
call_id = _call_id_from_payload(payload)
|
|
120
|
+
object_path = server._object_path(call_id)
|
|
121
|
+
if not object_path.is_file():
|
|
122
|
+
object_path.write_bytes(payload)
|
|
123
|
+
status = "stored"
|
|
124
|
+
else:
|
|
125
|
+
status = "reused"
|
|
126
|
+
print(
|
|
127
|
+
"[server] put: "
|
|
128
|
+
f"client={self.client_address[0]} "
|
|
129
|
+
f"call_id={call_id} status={status} path={object_path}",
|
|
130
|
+
flush=True,
|
|
131
|
+
)
|
|
132
|
+
host_header = self.headers.get("Host", server.server_address).strip() or server.server_address
|
|
133
|
+
self._send_json(
|
|
134
|
+
200,
|
|
135
|
+
{
|
|
136
|
+
"call_id": call_id,
|
|
137
|
+
"call": f"{call_id}@{host_header}",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def log_message(self, _format: str, *_args) -> None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
def _send_json(self, status: int, payload: dict[str, object]) -> None:
|
|
145
|
+
body = json.dumps(payload).encode("utf-8")
|
|
146
|
+
self.send_response(status)
|
|
147
|
+
self.send_header("Content-Type", "application/json")
|
|
148
|
+
self.send_header("Content-Length", str(len(body)))
|
|
149
|
+
self.end_headers()
|
|
150
|
+
self.wfile.write(body)
|
|
151
|
+
|
|
152
|
+
return Handler
|
|
153
|
+
|
|
154
|
+
def _object_path(self, call_id: str) -> Path:
|
|
155
|
+
return self._objects_dir / f"{call_id}.bin"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
prog="python -m pycodex.portable_server",
|
|
161
|
+
description="Run a pycodex remote storage service for --put/--call testing.",
|
|
162
|
+
)
|
|
163
|
+
parser.add_argument(
|
|
164
|
+
"--root",
|
|
165
|
+
default=str(Path(".tmp") / "pycodex_storage"),
|
|
166
|
+
help="Directory used to store uploaded encrypted bundles.",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--host",
|
|
170
|
+
default=DEFAULT_STORAGE_SERVER.split(":", 1)[0],
|
|
171
|
+
help="Host interface to bind.",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--port",
|
|
175
|
+
type=int,
|
|
176
|
+
default=int(DEFAULT_STORAGE_SERVER.split(":", 1)[1]),
|
|
177
|
+
help="Port to bind.",
|
|
178
|
+
)
|
|
179
|
+
return parser
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
parser = build_parser()
|
|
184
|
+
args = parser.parse_args(argv)
|
|
185
|
+
server = CodexStorageServer(args.root, host=args.host, port=args.port)
|
|
186
|
+
server.start()
|
|
187
|
+
print(f"storage server listening on {server.base_url}", flush=True)
|
|
188
|
+
print(f"storage root: {server.root}", flush=True)
|
|
189
|
+
print(f"put current home: pycodex --put @{server.server_address}", flush=True)
|
|
190
|
+
print(
|
|
191
|
+
f"put custom home: pycodex --put /data/.codex/@{server.server_address}",
|
|
192
|
+
flush=True,
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
if server._thread is not None:
|
|
196
|
+
server._thread.join()
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
return 130
|
|
199
|
+
finally:
|
|
200
|
+
server.stop()
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
raise SystemExit(main())
|
pycodex/runtime.py
CHANGED
|
@@ -3,12 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections import deque
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Literal
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
7
|
|
|
8
8
|
from .agent import AgentLoop, EventHandler, NOOP_EVENT_HANDLER, TurnInterrupted
|
|
9
9
|
from .protocol import AgentEvent, Operation, ShutdownOp, Submission, TurnResult, UserTurnOp
|
|
10
10
|
from .utils import uuid7_string
|
|
11
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .runtime_services import RuntimeEnvironment
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
@dataclass(slots=True)
|
|
14
17
|
class _QueuedSubmission:
|
|
@@ -20,8 +23,9 @@ class _QueuedSubmission:
|
|
|
20
23
|
class AgentRuntime:
|
|
21
24
|
"""Thin outer queue that mirrors the Rust `submission_loop` shape."""
|
|
22
25
|
|
|
23
|
-
def __init__(self, agent_loop: AgentLoop) -> None:
|
|
26
|
+
def __init__(self, agent_loop: AgentLoop, runtime_environment: RuntimeEnvironment | None = None) -> None:
|
|
24
27
|
self._agent_loop = agent_loop
|
|
28
|
+
self.runtime_environment = runtime_environment
|
|
25
29
|
self._enqueue_queue: deque[_QueuedSubmission] = deque()
|
|
26
30
|
self._steer_queue: deque[_QueuedSubmission] = deque()
|
|
27
31
|
self._queue_lock = asyncio.Lock()
|
pycodex/runtime_services.py
CHANGED
|
@@ -346,6 +346,9 @@ class SubAgentManager:
|
|
|
346
346
|
managed.state = "completed"
|
|
347
347
|
finally:
|
|
348
348
|
managed.pending_submission_ids.discard(submission_id)
|
|
349
|
+
if managed.pending_submission_ids and managed.error_message is None:
|
|
350
|
+
managed.completed_message = None
|
|
351
|
+
managed.state = "running"
|
|
349
352
|
async with self._condition:
|
|
350
353
|
self._condition.notify_all()
|
|
351
354
|
|
|
@@ -397,11 +400,12 @@ class RuntimeEnvironment:
|
|
|
397
400
|
self.request_user_input_manager = RequestUserInputManager()
|
|
398
401
|
self.request_permissions_manager = RequestPermissionsManager()
|
|
399
402
|
|
|
400
|
-
def configure_runtime_builder(self, builder: RuntimeBuilder | None) -> None:
|
|
401
|
-
self.subagent_manager.set_runtime_builder(builder)
|
|
402
403
|
|
|
404
|
+
def create_runtime_environment() -> RuntimeEnvironment:
|
|
405
|
+
return RuntimeEnvironment()
|
|
403
406
|
|
|
404
|
-
|
|
407
|
+
|
|
408
|
+
_RUNTIME_ENV = create_runtime_environment()
|
|
405
409
|
|
|
406
410
|
|
|
407
411
|
def get_runtime_environment() -> RuntimeEnvironment:
|
pycodex/tools/exec_tool.py
CHANGED
|
@@ -17,7 +17,7 @@ from ..protocol import JSONValue
|
|
|
17
17
|
from .base_tool import BaseTool, ToolContext
|
|
18
18
|
from .code_mode_manager import CodeModeManager
|
|
19
19
|
|
|
20
|
-
EXEC_FREEFORM_GRAMMAR = """start: pragma_source | plain_source
|
|
20
|
+
EXEC_FREEFORM_GRAMMAR = r"""start: pragma_source | plain_source
|
|
21
21
|
pragma_source: PRAGMA_LINE NEWLINE SOURCE
|
|
22
22
|
plain_source: SOURCE
|
|
23
23
|
|
|
@@ -184,6 +184,9 @@ class _HeadTailBuffer:
|
|
|
184
184
|
self.tail.clear()
|
|
185
185
|
return combined
|
|
186
186
|
|
|
187
|
+
def has_data(self) -> bool:
|
|
188
|
+
return bool(self.head or self.tail)
|
|
189
|
+
|
|
187
190
|
|
|
188
191
|
@dataclass(slots=True)
|
|
189
192
|
class UnifiedExecSession:
|
|
@@ -194,6 +197,7 @@ class UnifiedExecSession:
|
|
|
194
197
|
tty: bool
|
|
195
198
|
unread_output: _HeadTailBuffer = field(default_factory=_HeadTailBuffer)
|
|
196
199
|
reader_task: asyncio.Task | None = None
|
|
200
|
+
output_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
197
201
|
|
|
198
202
|
|
|
199
203
|
class UnifiedExecManager:
|
|
@@ -294,11 +298,22 @@ class UnifiedExecManager:
|
|
|
294
298
|
if session is None:
|
|
295
299
|
return f"Error: session_id {session_id} is not running."
|
|
296
300
|
|
|
297
|
-
|
|
301
|
+
loop = asyncio.get_running_loop()
|
|
302
|
+
start_wait = loop.time()
|
|
298
303
|
try:
|
|
299
304
|
await asyncio.wait_for(session.process.wait(), timeout=yield_time_ms / 1000.0)
|
|
300
305
|
except asyncio.TimeoutError:
|
|
301
|
-
|
|
306
|
+
remaining_seconds = (yield_time_ms / 1000.0) - (loop.time() - start_wait)
|
|
307
|
+
if (
|
|
308
|
+
session.process.returncode is None
|
|
309
|
+
and not session.unread_output.has_data()
|
|
310
|
+
and remaining_seconds > 0
|
|
311
|
+
):
|
|
312
|
+
session.output_event.clear()
|
|
313
|
+
try:
|
|
314
|
+
await asyncio.wait_for(session.output_event.wait(), timeout=remaining_seconds)
|
|
315
|
+
except asyncio.TimeoutError:
|
|
316
|
+
pass
|
|
302
317
|
|
|
303
318
|
if session.reader_task is not None and session.process.returncode is not None:
|
|
304
319
|
await session.reader_task
|
|
@@ -345,6 +360,8 @@ class UnifiedExecManager:
|
|
|
345
360
|
if not chunk:
|
|
346
361
|
break
|
|
347
362
|
session.unread_output.push_chunk(chunk)
|
|
363
|
+
session.output_event.set()
|
|
364
|
+
session.output_event.set()
|
|
348
365
|
|
|
349
366
|
def _resolve_workdir(self, workdir: str | None) -> Path:
|
|
350
367
|
if not workdir:
|
pycodex/utils/get_env.py
CHANGED
|
@@ -83,10 +83,15 @@ def get_package_version() -> str:
|
|
|
83
83
|
detected = _detect_upstream_codex_version()
|
|
84
84
|
if detected is not None:
|
|
85
85
|
return detected
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
for distribution_name in ("python-codex", "pycodex"):
|
|
87
|
+
try:
|
|
88
|
+
return importlib.metadata.version(distribution_name)
|
|
89
|
+
except importlib.metadata.PackageNotFoundError:
|
|
90
|
+
continue
|
|
91
|
+
local_version = _read_local_package_version()
|
|
92
|
+
if local_version is not None:
|
|
93
|
+
return local_version
|
|
94
|
+
return "0.1.0"
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
def get_os_info() -> tuple[str, str]:
|
|
@@ -178,6 +183,20 @@ def _normalize_os_version(version: str) -> str:
|
|
|
178
183
|
return version
|
|
179
184
|
|
|
180
185
|
|
|
186
|
+
def _read_local_package_version() -> str | None:
|
|
187
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
188
|
+
if not pyproject_path.is_file():
|
|
189
|
+
return None
|
|
190
|
+
match = re.search(
|
|
191
|
+
r'^\s*version\s*=\s*"([^"]+)"\s*$',
|
|
192
|
+
pyproject_path.read_text(encoding="utf-8"),
|
|
193
|
+
flags=re.MULTILINE,
|
|
194
|
+
)
|
|
195
|
+
if match is None:
|
|
196
|
+
return None
|
|
197
|
+
return match.group(1).strip() or None
|
|
198
|
+
|
|
199
|
+
|
|
181
200
|
def _tmux_display_message(fmt: str) -> str | None:
|
|
182
201
|
try:
|
|
183
202
|
output = subprocess.run(
|