solvewright 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.
- solvewright/__init__.py +3 -0
- solvewright/__main__.py +3 -0
- solvewright/agents.py +450 -0
- solvewright/cli.py +263 -0
- solvewright/connection.py +137 -0
- solvewright/interviewer/cases/portals/brief.txt +13 -0
- solvewright/interviewer/cases/portals/interviewer.txt +49 -0
- solvewright/interviewer/cases/portals/reported-round.txt +34 -0
- solvewright/interviewer/data/portal-samples/sample-01.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-02.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-03.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-04.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-05.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-06.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-07.json +967 -0
- solvewright/interviewer/data/portal-samples/sample-08.json +967 -0
- solvewright/interviewer/interviewer.txt +113 -0
- solvewright/interviewer/references/case-generation.txt +52 -0
- solvewright/interviewer/references/curriculum.txt +21 -0
- solvewright/interviewer/references/scoring.txt +66 -0
- solvewright/interviewer/references/session-format.txt +60 -0
- solvewright/interviewer/tools/portal_lab.py +217 -0
- solvewright/runtime.py +673 -0
- solvewright/service.py +52 -0
- solvewright/settings.py +52 -0
- solvewright/worker.py +159 -0
- solvewright-0.1.0.dist-info/METADATA +132 -0
- solvewright-0.1.0.dist-info/RECORD +31 -0
- solvewright-0.1.0.dist-info/WHEEL +4 -0
- solvewright-0.1.0.dist-info/entry_points.txt +2 -0
- solvewright-0.1.0.dist-info/licenses/LICENSE +21 -0
solvewright/__init__.py
ADDED
solvewright/__main__.py
ADDED
solvewright/agents.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Running the person's own Claude Code or Codex, locked to one interview folder.
|
|
2
|
+
|
|
3
|
+
Both agents run unmodified and signed in with the person's own account. The
|
|
4
|
+
connector never reads or stores their credentials. What it adds is a boundary:
|
|
5
|
+
|
|
6
|
+
* Claude Code runs in `--restricted --safe-mode` with its OS sandbox for
|
|
7
|
+
commands: file tools stay inside the round folder, commands cannot reach the
|
|
8
|
+
network or read the rest of the home folder, and the person's own CLAUDE.md,
|
|
9
|
+
hooks, plugins and MCP servers are not loaded.
|
|
10
|
+
* Codex runs through its documented app-server with a permission profile that
|
|
11
|
+
denies the home folder except the round folders, its plugins, apps, hooks,
|
|
12
|
+
web search and every configured MCP server switched off.
|
|
13
|
+
|
|
14
|
+
When a platform cannot provide that boundary, the agent loses the capability
|
|
15
|
+
rather than the boundary: Claude Code without its sandbox gets no command
|
|
16
|
+
tool at all, and the connector reports it.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
WINDOWS = sys.platform == "win32"
|
|
33
|
+
|
|
34
|
+
REPLY_SCHEMA = {"type": "object", "properties": {
|
|
35
|
+
"message": {"type": "string"}, "title": {"type": "string"}, "caseFamily": {"type": "string"},
|
|
36
|
+
"concepts": {"type": "array", "items": {"type": "string"}}, "stage": {"type": "string"},
|
|
37
|
+
"part": {"type": "integer"}, "parts": {"type": "integer"}, "finished": {"type": "boolean"}},
|
|
38
|
+
"required": ["message", "title", "caseFamily", "concepts", "stage", "part", "parts", "finished"], "additionalProperties": False}
|
|
39
|
+
|
|
40
|
+
# Claude Code resolves these aliases to the newest model of each family.
|
|
41
|
+
CLAUDE_MODELS = [{"id": "", "name": "Your Claude Code default"}, {"id": "fable", "name": "Fable"},
|
|
42
|
+
{"id": "opus", "name": "Opus"}, {"id": "sonnet", "name": "Sonnet"}, {"id": "haiku", "name": "Haiku"}]
|
|
43
|
+
CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]
|
|
44
|
+
|
|
45
|
+
# Codex features that would give the interviewer reach beyond its folder.
|
|
46
|
+
CODEX_FEATURES_OFF = ("apps", "plugins", "plugin_hooks", "hooks", "computer_use", "browser_use", "in_app_browser",
|
|
47
|
+
"memories", "multi_agent", "multi_agent_v2", "remote_control", "image_generation", "js_repl",
|
|
48
|
+
"enable_mcp_apps", "standalone_web_search", "web_search_request", "tool_suggest",
|
|
49
|
+
"request_permissions_tool")
|
|
50
|
+
CLAUDE_FLAGS = ("--restricted", "--safe-mode", "--permission-prompts", "--strict-mcp-config")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def executable(name: str) -> str | None:
|
|
54
|
+
if WINDOWS:
|
|
55
|
+
native = shutil.which(name + ".exe")
|
|
56
|
+
if native:
|
|
57
|
+
return native
|
|
58
|
+
candidates = [Path.home() / ".local" / "bin" / (name + ".exe")]
|
|
59
|
+
if name == "codex" and os.environ.get("LOCALAPPDATA"):
|
|
60
|
+
candidates.append(Path(os.environ["LOCALAPPDATA"]) / "Programs" / "OpenAI" / "Codex" / "bin" / "codex.exe")
|
|
61
|
+
for candidate in candidates:
|
|
62
|
+
if candidate.is_file():
|
|
63
|
+
return str(candidate)
|
|
64
|
+
found = shutil.which(name)
|
|
65
|
+
fallback = Path.home() / ".local" / "bin" / name
|
|
66
|
+
return found or (str(fallback) if fallback.is_file() else None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def provider_executable(name: str) -> str:
|
|
70
|
+
binary = executable(name)
|
|
71
|
+
label = "Claude Code" if name == "claude" else "Codex"
|
|
72
|
+
if not binary:
|
|
73
|
+
raise ValueError(f"{label} is not installed on this computer. Install it, sign in once in a terminal, then restart the connector.")
|
|
74
|
+
if WINDOWS and Path(binary).suffix.lower() in (".cmd", ".bat", ".ps1"):
|
|
75
|
+
raise ValueError(f"The connector needs the native Windows {name}.exe. Install the native {label} and open a new terminal.")
|
|
76
|
+
return binary
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_quietly(command: list[str], timeout: float = 10) -> str:
|
|
80
|
+
try:
|
|
81
|
+
done = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
82
|
+
except (OSError, subprocess.SubprocessError):
|
|
83
|
+
return ""
|
|
84
|
+
return (done.stdout or "") + (done.stderr or "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def version_of(name: str) -> str | None:
|
|
88
|
+
binary = executable(name)
|
|
89
|
+
if not binary:
|
|
90
|
+
return None
|
|
91
|
+
match = re.search(r"\d+\.\d+\.\d+", run_quietly([binary, "--version"]))
|
|
92
|
+
return match.group(0) if match else "unknown"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def claude_supports_isolation(binary: str) -> bool:
|
|
96
|
+
help_text = run_quietly([binary, "--help"])
|
|
97
|
+
return all(flag in help_text for flag in CLAUDE_FLAGS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def claude_can_sandbox() -> bool:
|
|
101
|
+
if sys.platform == "darwin":
|
|
102
|
+
return Path("/usr/bin/sandbox-exec").exists()
|
|
103
|
+
if sys.platform.startswith("linux"):
|
|
104
|
+
return bool(shutil.which("bwrap") and shutil.which("socat"))
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_described: tuple[float, dict] | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe(max_age: float = 300) -> dict:
|
|
112
|
+
"""What the browser shows about this computer's agents. No network, no model call."""
|
|
113
|
+
global _described
|
|
114
|
+
if _described and time.monotonic() - _described[0] < max_age:
|
|
115
|
+
return json.loads(json.dumps(_described[1]))
|
|
116
|
+
agents = {}
|
|
117
|
+
for name in ("claude", "codex"):
|
|
118
|
+
version = version_of(name)
|
|
119
|
+
if name == "claude":
|
|
120
|
+
isolation = "sandbox" if claude_can_sandbox() else "no-commands"
|
|
121
|
+
else:
|
|
122
|
+
isolation = "sandbox"
|
|
123
|
+
agents[name] = {"available": version is not None, "version": version, "isolation": isolation if version else None}
|
|
124
|
+
_described = (time.monotonic(), agents)
|
|
125
|
+
return json.loads(json.dumps(agents))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def agent_environment() -> dict:
|
|
129
|
+
environment = os.environ.copy()
|
|
130
|
+
environment.update(PYTHONIOENCODING="utf-8", PYTHONUTF8="1")
|
|
131
|
+
if WINDOWS:
|
|
132
|
+
environment["PATH"] = str(Path(sys.executable).parent) + os.pathsep + environment.get("PATH", "")
|
|
133
|
+
return environment
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def python_command() -> str:
|
|
137
|
+
return Path(sys.executable).name if WINDOWS else "python3"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def python_read_roots() -> list[str]:
|
|
141
|
+
"""Where the interviewer's `python3` lives, when that is inside the home folder."""
|
|
142
|
+
home = Path.home().resolve()
|
|
143
|
+
python = shutil.which("python3")
|
|
144
|
+
if not python:
|
|
145
|
+
return []
|
|
146
|
+
roots: set[Path] = set()
|
|
147
|
+
found = Path(python)
|
|
148
|
+
try:
|
|
149
|
+
reply = json.loads(run_quietly([python, "-c", "import json,site,sys;print(json.dumps([sys.base_prefix,sys.prefix,site.getusersitepackages()]))"]).strip().splitlines()[-1])
|
|
150
|
+
roots.update(Path(item) for item in reply if isinstance(item, str))
|
|
151
|
+
except (ValueError, IndexError):
|
|
152
|
+
pass
|
|
153
|
+
if found.is_relative_to(home) and found.parent.parent != home:
|
|
154
|
+
roots.add(found.parent.parent) # A version manager's shims and versions, for example ~/.pyenv.
|
|
155
|
+
return sorted(str(root.resolve()) for root in roots if root.resolve().is_relative_to(home) and root.resolve() != home)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def interrupt_process(process) -> None:
|
|
159
|
+
if process.poll() is not None:
|
|
160
|
+
return
|
|
161
|
+
try:
|
|
162
|
+
process.send_signal(signal.CTRL_BREAK_EVENT if WINDOWS else signal.SIGINT)
|
|
163
|
+
if WINDOWS:
|
|
164
|
+
try:
|
|
165
|
+
process.wait(timeout=3)
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
process.terminate()
|
|
168
|
+
except ProcessLookupError:
|
|
169
|
+
pass
|
|
170
|
+
except OSError:
|
|
171
|
+
if not WINDOWS:
|
|
172
|
+
raise
|
|
173
|
+
process.terminate()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def claude_command(binary: str, directory: Path, state: dict, sandbox: bool, read_roots: list[str]) -> list[str]:
|
|
177
|
+
command = [binary, "--print", "--output-format", "stream-json", "--verbose", "--json-schema", json.dumps(REPLY_SCHEMA),
|
|
178
|
+
"--restricted", "--safe-mode", "--strict-mcp-config",
|
|
179
|
+
"--permission-mode", "acceptEdits", "--permission-prompts", "none"]
|
|
180
|
+
if sandbox:
|
|
181
|
+
settings = {"sandbox": {
|
|
182
|
+
"enabled": True, "failIfUnavailable": True, "allowUnsandboxedCommands": False, "autoAllowBashIfSandboxed": True,
|
|
183
|
+
"filesystem": {"denyRead": ["~"], "allowRead": [str(directory), *read_roots]},
|
|
184
|
+
"network": {"allowedDomains": []}}}
|
|
185
|
+
command += ["--tools", "Read,Write,Edit,Glob,Grep,Bash", "--settings", json.dumps(settings)]
|
|
186
|
+
else:
|
|
187
|
+
command += ["--tools", "Read,Write,Edit,Glob,Grep"]
|
|
188
|
+
if state.get("agentSessionId"):
|
|
189
|
+
command += ["--resume", state["agentSessionId"]]
|
|
190
|
+
model = state["settings"].get("model", "").strip()
|
|
191
|
+
if model:
|
|
192
|
+
command += ["--model", model]
|
|
193
|
+
effort = state["settings"].get("effort", "").strip()
|
|
194
|
+
if effort:
|
|
195
|
+
command += ["--effort", effort]
|
|
196
|
+
return command
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def claude_preamble(sandbox: bool) -> str:
|
|
200
|
+
python = python_command()
|
|
201
|
+
if sandbox:
|
|
202
|
+
return (f"Runtime tool constraint: commands run in a sandbox with no network, and can read and write only inside "
|
|
203
|
+
f"this round folder. Use {python} for checks, for example `{python} .examiner/verify_setup.py`. Use "
|
|
204
|
+
f"Read, Write and Edit for files. If a step is blocked, say so in your structured reply.\n\n")
|
|
205
|
+
return ("Runtime tool constraint: on this computer you have no command tool, because Claude Code's sandbox is not "
|
|
206
|
+
"available here. Verify the setup and candidate work by careful inspection and hand-traces, record in "
|
|
207
|
+
".examiner/manifest.json that the reference was not executed, and say so when it matters.\n\n")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CodexClient:
|
|
211
|
+
"""Newline JSON-RPC client for Codex's documented app-server, isolated."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, logs: Path, write_root: Path):
|
|
214
|
+
self.binary = provider_executable("codex")
|
|
215
|
+
self.write_root = write_root
|
|
216
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
self.log = (logs / "codex-runtime.log").open("a", encoding="utf-8")
|
|
218
|
+
self.lock = threading.Lock()
|
|
219
|
+
self.condition = threading.Condition()
|
|
220
|
+
self.pending, self.finished, self.messages = {}, {}, {}
|
|
221
|
+
self.sequence = 0
|
|
222
|
+
self.loaded: set[str] = set()
|
|
223
|
+
self.closed = threading.Event()
|
|
224
|
+
self.isolation = "sandbox"
|
|
225
|
+
self.process = None
|
|
226
|
+
try:
|
|
227
|
+
# Learn the configured MCP servers without starting any, then start
|
|
228
|
+
# the app-server once with every one of them already switched off.
|
|
229
|
+
self.disable = self._configured_mcp_servers()
|
|
230
|
+
self._launch(self._overrides(profile=True, disable=self.disable))
|
|
231
|
+
still = [name for name, server in self._mcp_servers().items() if server.get("enabled", True)]
|
|
232
|
+
if still:
|
|
233
|
+
raise ValueError("Codex kept MCP servers enabled (" + ", ".join(still) + "); the examiner will not run with them.")
|
|
234
|
+
except Exception:
|
|
235
|
+
self.close()
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
def _configured_mcp_servers(self) -> list[str]:
|
|
239
|
+
features = [part for name in CODEX_FEATURES_OFF for part in ("-c", f"features.{name}=false")]
|
|
240
|
+
try:
|
|
241
|
+
done = subprocess.run([self.binary, "mcp", "list", "--json", *features], capture_output=True, text=True, encoding="utf-8",
|
|
242
|
+
errors="replace", timeout=30, env=agent_environment())
|
|
243
|
+
servers = json.loads(done.stdout) if done.returncode == 0 else None
|
|
244
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
245
|
+
servers = None
|
|
246
|
+
if not isinstance(servers, list):
|
|
247
|
+
raise ValueError("This Codex cannot list its MCP servers, so the connector cannot switch them off. Update Codex, then try again.")
|
|
248
|
+
return sorted({server["name"] for server in servers if isinstance(server, dict) and isinstance(server.get("name"), str)})
|
|
249
|
+
|
|
250
|
+
def _overrides(self, profile: bool, disable=()) -> list[str]:
|
|
251
|
+
values = ["notify=[]", 'web_search="disabled"']
|
|
252
|
+
values += [f"features.{name}=false" for name in CODEX_FEATURES_OFF]
|
|
253
|
+
for name in disable:
|
|
254
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", name):
|
|
255
|
+
raise ValueError(f"Codex has an MCP server named {name!r} that the connector cannot switch off safely.")
|
|
256
|
+
values.append(f"mcp_servers.{name}.enabled=false")
|
|
257
|
+
if profile:
|
|
258
|
+
readable = {"~/.local/bin", *self._install_roots()}
|
|
259
|
+
entries = ['"~"="deny"'] + [f'{json.dumps(path)}="read"' for path in sorted(readable)]
|
|
260
|
+
entries.append(f'{json.dumps(str(self.write_root))}="write"')
|
|
261
|
+
values += ['default_permissions="solvewright"', 'permissions.solvewright.extends=":workspace"',
|
|
262
|
+
"permissions.solvewright.filesystem={" + ", ".join(entries) + "}"]
|
|
263
|
+
self.profile = profile
|
|
264
|
+
return [part for value in values for part in ("-c", value)]
|
|
265
|
+
|
|
266
|
+
def _install_roots(self) -> list[str]:
|
|
267
|
+
home = Path.home().resolve()
|
|
268
|
+
roots = []
|
|
269
|
+
for path in {Path(self.binary), Path(self.binary).resolve()}:
|
|
270
|
+
parent = path.parent
|
|
271
|
+
if parent.resolve().is_relative_to(home):
|
|
272
|
+
# The release folder, e.g. ~/.codex/packages/standalone/releases/<version>/.
|
|
273
|
+
roots.append(str(parent.parent.resolve() if parent.name == "bin" else parent.resolve()))
|
|
274
|
+
return roots
|
|
275
|
+
|
|
276
|
+
def _launch(self, overrides: list[str]) -> None:
|
|
277
|
+
self.closed.clear()
|
|
278
|
+
self.process = subprocess.Popen([self.binary, "app-server", *overrides], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
279
|
+
stderr=self.log, text=True, encoding="utf-8", env=agent_environment(), bufsize=1,
|
|
280
|
+
cwd=str(self.write_root))
|
|
281
|
+
self.reader = threading.Thread(target=self._read, args=(self.process,), daemon=True)
|
|
282
|
+
self.reader.start()
|
|
283
|
+
self.rpc("initialize", {"clientInfo": {"name": "solvewright_connector", "title": "Solvewright Interview Lab", "version": "1"}})
|
|
284
|
+
self.send({"method": "initialized", "params": {}})
|
|
285
|
+
|
|
286
|
+
def _mcp_servers(self) -> dict:
|
|
287
|
+
reply = self.rpc("config/read", {"includeLayers": False})
|
|
288
|
+
config = reply.get("config") or reply
|
|
289
|
+
servers = config.get("mcp_servers") or {}
|
|
290
|
+
return servers if isinstance(servers, dict) else {}
|
|
291
|
+
|
|
292
|
+
def _stop(self) -> None:
|
|
293
|
+
if self.process and self.process.poll() is None:
|
|
294
|
+
self.process.terminate()
|
|
295
|
+
try:
|
|
296
|
+
self.process.wait(timeout=5)
|
|
297
|
+
except subprocess.TimeoutExpired:
|
|
298
|
+
self.process.kill()
|
|
299
|
+
self.process.wait()
|
|
300
|
+
reader = getattr(self, "reader", None)
|
|
301
|
+
if reader and reader is not threading.current_thread():
|
|
302
|
+
reader.join(timeout=5)
|
|
303
|
+
|
|
304
|
+
def send(self, value) -> None:
|
|
305
|
+
with self.lock:
|
|
306
|
+
self.process.stdin.write(json.dumps(value) + "\n")
|
|
307
|
+
self.process.stdin.flush()
|
|
308
|
+
|
|
309
|
+
def rpc(self, method: str, params: dict, timeout: float = 45):
|
|
310
|
+
with self.condition:
|
|
311
|
+
self.sequence += 1
|
|
312
|
+
key = self.sequence
|
|
313
|
+
response: queue.Queue = queue.Queue()
|
|
314
|
+
self.pending[key] = response
|
|
315
|
+
try:
|
|
316
|
+
if self.closed.is_set():
|
|
317
|
+
raise ValueError("Codex disconnected. Send another message to resume this round.")
|
|
318
|
+
self.send({"id": key, "method": method, "params": params})
|
|
319
|
+
message = response.get(timeout=timeout)
|
|
320
|
+
except queue.Empty:
|
|
321
|
+
raise ValueError(f"Codex did not answer {method}. Check the connector's terminal.")
|
|
322
|
+
except (BrokenPipeError, OSError) as error:
|
|
323
|
+
raise ValueError("Codex disconnected. Send another message to resume this round.") from error
|
|
324
|
+
finally:
|
|
325
|
+
with self.condition:
|
|
326
|
+
self.pending.pop(key, None)
|
|
327
|
+
if "error" in message:
|
|
328
|
+
raise ValueError(message["error"].get("message", "Codex request failed."))
|
|
329
|
+
return message.get("result", {})
|
|
330
|
+
|
|
331
|
+
def _read(self, process) -> None:
|
|
332
|
+
try:
|
|
333
|
+
for line in process.stdout:
|
|
334
|
+
try:
|
|
335
|
+
data = json.loads(line)
|
|
336
|
+
if "id" in data and ("result" in data or "error" in data):
|
|
337
|
+
target = self.pending.get(data["id"])
|
|
338
|
+
if target:
|
|
339
|
+
target.put(data)
|
|
340
|
+
elif "id" in data and "method" in data:
|
|
341
|
+
# No approval is ever granted on the person's behalf.
|
|
342
|
+
self.send({"id": data["id"], "error": {"code": -32601, "message": "Approval is unavailable in Interview Lab."}})
|
|
343
|
+
elif data.get("method") == "item/completed":
|
|
344
|
+
params = data["params"]
|
|
345
|
+
item = params.get("item", {})
|
|
346
|
+
if item.get("type") == "agentMessage":
|
|
347
|
+
self.messages[(params["threadId"], params["turnId"])] = item.get("text", "")
|
|
348
|
+
elif data.get("method") == "turn/completed":
|
|
349
|
+
params = data["params"]
|
|
350
|
+
with self.condition:
|
|
351
|
+
self.finished[(params["threadId"], params["turn"]["id"])] = params["turn"]
|
|
352
|
+
self.condition.notify_all()
|
|
353
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
354
|
+
continue
|
|
355
|
+
finally:
|
|
356
|
+
if process is self.process:
|
|
357
|
+
with self.condition:
|
|
358
|
+
self.closed.set()
|
|
359
|
+
for target in self.pending.values():
|
|
360
|
+
target.put({"error": {"message": "Codex disconnected. Send another message to resume this round."}})
|
|
361
|
+
self.condition.notify_all()
|
|
362
|
+
|
|
363
|
+
def _start_thread(self, directory: Path, model: str) -> str:
|
|
364
|
+
params = {"cwd": str(directory), "approvalPolicy": "never", "serviceName": "solvewright-interviews"}
|
|
365
|
+
if not self.profile:
|
|
366
|
+
params["sandbox"] = "workspace-write"
|
|
367
|
+
if model:
|
|
368
|
+
params["model"] = model
|
|
369
|
+
try:
|
|
370
|
+
return self.rpc("thread/start", params)["thread"]["id"]
|
|
371
|
+
except ValueError as error:
|
|
372
|
+
if not self.profile or "not permitted" not in str(error).lower():
|
|
373
|
+
raise
|
|
374
|
+
# This Codex cannot start under the read-restricting profile: keep the
|
|
375
|
+
# write and network limits, and say plainly that reads are not limited.
|
|
376
|
+
self._stop()
|
|
377
|
+
self._launch(self._overrides(profile=False, disable=self.disable))
|
|
378
|
+
self.isolation = "writes"
|
|
379
|
+
params["sandbox"] = "workspace-write"
|
|
380
|
+
return self.rpc("thread/start", params)["thread"]["id"]
|
|
381
|
+
|
|
382
|
+
def turn(self, state: dict, directory: Path, prompt: str, callback, cancelled=None) -> dict:
|
|
383
|
+
if cancelled and cancelled.is_set():
|
|
384
|
+
raise ValueError("The interviewer turn was stopped. Send a message to continue.")
|
|
385
|
+
model = state["settings"].get("model", "").strip()
|
|
386
|
+
if not state.get("agentSessionId"):
|
|
387
|
+
state["agentSessionId"] = self._start_thread(directory, model)
|
|
388
|
+
self.loaded.add(state["agentSessionId"])
|
|
389
|
+
callback()
|
|
390
|
+
thread = state["agentSessionId"]
|
|
391
|
+
if thread not in self.loaded:
|
|
392
|
+
resume = {"threadId": thread, "excludeTurns": True}
|
|
393
|
+
if not self.profile:
|
|
394
|
+
resume["sandbox"] = "workspace-write"
|
|
395
|
+
self.rpc("thread/resume", resume)
|
|
396
|
+
self.loaded.add(thread)
|
|
397
|
+
if cancelled and cancelled.is_set():
|
|
398
|
+
raise ValueError("The interviewer turn was stopped. Send a message to continue.")
|
|
399
|
+
params = {"threadId": thread, "cwd": str(directory), "input": [{"type": "text", "text": prompt}],
|
|
400
|
+
"outputSchema": REPLY_SCHEMA, "approvalPolicy": "never"}
|
|
401
|
+
effort = state["settings"].get("effort", "").strip()
|
|
402
|
+
if effort:
|
|
403
|
+
params["effort"] = effort
|
|
404
|
+
if not self.profile:
|
|
405
|
+
params["sandboxPolicy"] = {"type": "workspaceWrite", "writableRoots": [str(directory)], "networkAccess": False}
|
|
406
|
+
started = self.rpc("turn/start", params)
|
|
407
|
+
turn = started["turn"]["id"]
|
|
408
|
+
state["activeTurn"] = turn
|
|
409
|
+
callback()
|
|
410
|
+
if cancelled and cancelled.is_set():
|
|
411
|
+
self.interrupt(state)
|
|
412
|
+
key = (thread, turn)
|
|
413
|
+
with self.condition:
|
|
414
|
+
while key not in self.finished:
|
|
415
|
+
if self.closed.is_set() or self.process.poll() is not None:
|
|
416
|
+
raise ValueError("Codex stopped. Send another message to resume this round.")
|
|
417
|
+
self.condition.wait(timeout=5)
|
|
418
|
+
completed = self.finished.pop(key)
|
|
419
|
+
if completed["status"] != "completed":
|
|
420
|
+
error = completed.get("error") or {}
|
|
421
|
+
raise ValueError(error.get("message", f"Codex turn {completed['status']}. Your files and conversation were kept."))
|
|
422
|
+
content = self.messages.pop(key, "")
|
|
423
|
+
if not content:
|
|
424
|
+
content = next((item.get("text", "") for item in reversed(completed.get("items", [])) if item.get("type") == "agentMessage"), "")
|
|
425
|
+
return json.loads(content)
|
|
426
|
+
|
|
427
|
+
def models(self) -> list[dict]:
|
|
428
|
+
"""The models this Codex account offers, with the efforts each one supports. No model call."""
|
|
429
|
+
found, cursor = [], None
|
|
430
|
+
for _page in range(5):
|
|
431
|
+
reply = self.rpc("model/list", {"cursor": cursor, "limit": 50} if cursor else {"limit": 50})
|
|
432
|
+
for model in reply.get("data", []):
|
|
433
|
+
if model.get("hidden"):
|
|
434
|
+
continue
|
|
435
|
+
found.append({"id": model.get("model") or model.get("id"), "name": model.get("displayName") or model.get("id"),
|
|
436
|
+
"default": bool(model.get("isDefault")), "defaultEffort": model.get("defaultReasoningEffort"),
|
|
437
|
+
"efforts": [option.get("reasoningEffort") for option in model.get("supportedReasoningEfforts", []) if option.get("reasoningEffort")]})
|
|
438
|
+
cursor = reply.get("nextCursor")
|
|
439
|
+
if not cursor:
|
|
440
|
+
break
|
|
441
|
+
return found
|
|
442
|
+
|
|
443
|
+
def interrupt(self, state: dict) -> None:
|
|
444
|
+
if state.get("activeTurn"):
|
|
445
|
+
self.rpc("turn/interrupt", {"threadId": state["agentSessionId"], "turnId": state["activeTurn"]})
|
|
446
|
+
|
|
447
|
+
def close(self) -> None:
|
|
448
|
+
self._stop()
|
|
449
|
+
self.closed.set()
|
|
450
|
+
self.log.close()
|