opal-optimizer 0.3.5__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.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/backends/claude.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Claude Code, headless, one persistent session (--session-id, then --resume), confined to the workspace.
|
|
2
|
+
|
|
3
|
+
The CLI runs with customisations off (--safe-mode: no CLAUDE.md, skills, hooks, plugins of the user), only the file
|
|
4
|
+
tools and Bash, and Bash inside the OS sandbox with no network and reads limited to the workspace and the Python
|
|
5
|
+
installation. Where the OS sandbox cannot start (Linux without bubblewrap and socat, or where bubblewrap may not create
|
|
6
|
+
namespaces), Bash is removed and the agent keeps only Read/Edit/Write.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .. import programs
|
|
25
|
+
from . import unavailable
|
|
26
|
+
from .base import Backend, Cancelled, agent_env, error_tail
|
|
27
|
+
from ._privacy import redact_secrets
|
|
28
|
+
|
|
29
|
+
_SYSTEM_READ_MAC = (
|
|
30
|
+
"/System",
|
|
31
|
+
"/bin",
|
|
32
|
+
"/sbin",
|
|
33
|
+
"/usr",
|
|
34
|
+
"/Library/Developer/CommandLineTools",
|
|
35
|
+
"/private/var/select",
|
|
36
|
+
"/dev",
|
|
37
|
+
)
|
|
38
|
+
_SYSTEM_READ_LINUX = ("/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc", "/proc", "/dev", "/opt")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_OPTIONAL_FLAGS = (
|
|
42
|
+
"--safe-mode",
|
|
43
|
+
"--restricted",
|
|
44
|
+
"--no-chrome",
|
|
45
|
+
"--permission-prompts",
|
|
46
|
+
"--setting-sources",
|
|
47
|
+
"--strict-mcp-config",
|
|
48
|
+
"--effort",
|
|
49
|
+
"--max-budget-usd",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
SANDBOX_NOTE = (
|
|
53
|
+
"Your Bash tool runs in a sandbox with no network; it can read only this folder and the Python installation of "
|
|
54
|
+
"the training run (the same interpreter, with torch, on CPU only: no GPU is visible to it), for small checks. "
|
|
55
|
+
"Each Bash call may end with "
|
|
56
|
+
"'zsh:1: operation not permitted: /tmp/claude-.../cwd-...' and exit code 1: that is the sandbox's own "
|
|
57
|
+
"bookkeeping, not your command; judge a command by its output."
|
|
58
|
+
)
|
|
59
|
+
# the agent's shell sees no GPU, whatever machine runs it (the training process, or a broker on a GPU node)
|
|
60
|
+
NO_GPU = {"CUDA_VISIBLE_DEVICES": "", "HIP_VISIBLE_DEVICES": ""}
|
|
61
|
+
NO_BASH_NOTE = "You have file tools only (Read, Edit, Write) in this folder; there is no shell on this machine."
|
|
62
|
+
|
|
63
|
+
# Claude Code 2.1.302's answers when a session id cannot be used (read from the binary, checked with the real CLI)
|
|
64
|
+
_IN_USE = "is already in use"
|
|
65
|
+
_NOT_FOUND = "No conversation found"
|
|
66
|
+
# a usage or spend limit, as Claude Code words it ("You've hit your limit · resets 3pm"): the turn did not run, also
|
|
67
|
+
# when the CLI exits 0 with it as its answer
|
|
68
|
+
_LIMIT = unavailable.LIMIT
|
|
69
|
+
# a failed call of the agent's own turn (an exit code, a crash) is made again after BACKOFF s, then twice as long, ...
|
|
70
|
+
# (at most BACKOFF_MAX s per wait), while the decision's time allows; an unavailable agent (no login, a usage or spend
|
|
71
|
+
# limit, an overloaded API, no network: backends/unavailable.py) is waited for instead, without a deadline
|
|
72
|
+
BACKOFF, BACKOFF_MAX = 15.0, 600.0
|
|
73
|
+
MIN_CALL = 30.0 # seconds a call made again must have before the decision's time runs out (at most a tenth of it)
|
|
74
|
+
# the agent of the blog's runs; another model or effort is allowed, with a warning when the run starts
|
|
75
|
+
DEFAULT_MODEL, DEFAULT_EFFORT = "claude-opus-5-5", "high"
|
|
76
|
+
BLOG_URL = "https://xingjianbai.com/reactive-optimizers/" # the blog post
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _login_prefixes():
|
|
80
|
+
"""Environment variables Claude Code needs to log in: its own, and the cloud's when it runs on Bedrock or Vertex."""
|
|
81
|
+
keep = ["ANTHROPIC_", "CLAUDE_"]
|
|
82
|
+
if os.environ.get("CLAUDE_CODE_USE_BEDROCK"):
|
|
83
|
+
keep += ["AWS_"]
|
|
84
|
+
if os.environ.get("CLAUDE_CODE_USE_VERTEX"):
|
|
85
|
+
keep += ["GOOGLE_", "CLOUD_ML_", "VERTEX_"]
|
|
86
|
+
return tuple(keep)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _shell_denied_env(env):
|
|
90
|
+
"""Login variables the CLI needs and the agent's shell must not see: unset for sandboxed commands
|
|
91
|
+
(sandbox.credentials.envVars, mode deny)."""
|
|
92
|
+
login = ("ANTHROPIC_", "AWS_", "GOOGLE_", "CLOUD_ML_", "VERTEX_")
|
|
93
|
+
names = [k for k in env if k.startswith(login) or k == "CLAUDE_CODE_OAUTH_TOKEN"]
|
|
94
|
+
return sorted(n for n in names if not n.startswith("ANTHROPIC_BASE_URL"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _real(p):
|
|
98
|
+
return os.path.realpath(p)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _contains(folder, path):
|
|
102
|
+
"""True if `path` is `folder` or lies inside it (real paths)."""
|
|
103
|
+
folder, path = _real(folder), _real(path)
|
|
104
|
+
return path == folder or path.startswith(folder.rstrip(os.sep) + os.sep)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _looks_like_python_prefix(d):
|
|
108
|
+
"""A folder that holds a Python installation or a venv, not just any parent of a bin/ folder (such as $HOME)."""
|
|
109
|
+
return (
|
|
110
|
+
os.path.isfile(os.path.join(d, "pyvenv.cfg"))
|
|
111
|
+
or os.path.isdir(os.path.join(d, "conda-meta"))
|
|
112
|
+
or bool(glob.glob(os.path.join(d, "lib", "python3*")))
|
|
113
|
+
or os.path.isdir(os.path.join(d, "Lib"))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _python_paths(protect=()):
|
|
118
|
+
"""The folders the agent's shell may read to run the training interpreter, with torch: the interpreter's
|
|
119
|
+
prefixes (venv and base installation, as given and resolved), the folder of each link from sys.executable to the
|
|
120
|
+
real binary (the shell starts `python` from the first), the prefix above each of them when it holds a Python
|
|
121
|
+
installation, and the site-packages folders. Never a folder that contains one of `protect` (the workspace, the run
|
|
122
|
+
folder) or the home folder. Returns (paths, dropped)."""
|
|
123
|
+
cand = []
|
|
124
|
+
for p in (sys.prefix, sys.base_prefix, sys.exec_prefix, getattr(sys, "base_exec_prefix", sys.base_prefix)):
|
|
125
|
+
cand += [p, _real(p)]
|
|
126
|
+
p = sys.executable
|
|
127
|
+
for _ in range(20):
|
|
128
|
+
for d in {os.path.dirname(p), os.path.dirname(_real(p))}:
|
|
129
|
+
cand.append(d)
|
|
130
|
+
up = os.path.dirname(d)
|
|
131
|
+
if _looks_like_python_prefix(up):
|
|
132
|
+
cand += [up, _real(up)]
|
|
133
|
+
if not os.path.islink(p):
|
|
134
|
+
break
|
|
135
|
+
p = os.path.join(os.path.dirname(p), os.readlink(p))
|
|
136
|
+
for entry in sys.path:
|
|
137
|
+
if entry and "site-packages" in entry and os.path.isdir(entry):
|
|
138
|
+
cand += [entry, _real(entry)]
|
|
139
|
+
home = os.path.expanduser("~")
|
|
140
|
+
out, dropped = set(), set()
|
|
141
|
+
for c in cand:
|
|
142
|
+
if not c or c == os.sep:
|
|
143
|
+
continue
|
|
144
|
+
if _real(c) == _real(home) or any(_contains(c, x) for x in (*protect, home)):
|
|
145
|
+
dropped.add(c)
|
|
146
|
+
else:
|
|
147
|
+
out.add(c)
|
|
148
|
+
return sorted(out), sorted(dropped - out)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def linux_sandbox_problem():
|
|
152
|
+
"""None if Claude Code's Linux sandbox can start here, else why not. Claude Code 2.1.302 needs bubblewrap and
|
|
153
|
+
socat, found on its PATH, and bubblewrap must be allowed to create namespaces; with failIfUnavailable it exits at
|
|
154
|
+
startup otherwise. The check runs bwrap once with the flags Claude Code uses."""
|
|
155
|
+
missing = [n for n in ("bwrap", "socat") if shutil.which(n) is None]
|
|
156
|
+
if missing:
|
|
157
|
+
return " and ".join(missing) + " not found on PATH"
|
|
158
|
+
cmd = [shutil.which("bwrap"), "--new-session", "--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev"]
|
|
159
|
+
cmd += ["--proc", "/proc", "--unshare-pid", "--unshare-net", "true"]
|
|
160
|
+
try:
|
|
161
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
162
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
163
|
+
return f"bubblewrap could not run ({exc})"
|
|
164
|
+
if p.returncode != 0:
|
|
165
|
+
return "bubblewrap cannot create a sandbox here: " + (error_tail(p.stderr) or f"exit {p.returncode}")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _sandbox_available() -> bool:
|
|
170
|
+
"""A quick check (no process started): macOS always; Linux when bwrap and socat are on PATH. preflight() runs the
|
|
171
|
+
full check."""
|
|
172
|
+
if platform.system() == "Darwin":
|
|
173
|
+
return True
|
|
174
|
+
return platform.system() == "Linux" and all(shutil.which(n) for n in ("bwrap", "socat"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ClaudeBackend(Backend):
|
|
178
|
+
name = "claude"
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
model=DEFAULT_MODEL,
|
|
183
|
+
effort=DEFAULT_EFFORT,
|
|
184
|
+
sandbox="auto",
|
|
185
|
+
timeout=None,
|
|
186
|
+
binary="claude",
|
|
187
|
+
extra_args=(),
|
|
188
|
+
shell_timeout=None,
|
|
189
|
+
max_budget_usd=None,
|
|
190
|
+
retries=None,
|
|
191
|
+
backoff=BACKOFF,
|
|
192
|
+
backoff_max=BACKOFF_MAX,
|
|
193
|
+
unavailable_timeout=None,
|
|
194
|
+
):
|
|
195
|
+
self.model, self.effort, self.binary = model, effort, binary
|
|
196
|
+
# seconds an unavailable agent is waited for before the decision fails (None: no deadline)
|
|
197
|
+
self.unavailable_timeout = unavailable_timeout
|
|
198
|
+
self.training_waits = None # set by the run before each decision: whether training waits for it (the wait line)
|
|
199
|
+
self.on_unavailable = None # fn(reason or None), called when a wait for availability starts and ends (broker)
|
|
200
|
+
self.retries = retries # calls made again after a failed one: None while the decision's time allows; 0: none
|
|
201
|
+
self.backoff, self.backoff_max = float(backoff), float(backoff_max)
|
|
202
|
+
self._spent = 0.0 # what this decision's calls have cost so far: the next call's budget is what is left
|
|
203
|
+
self.timeout_given = timeout is not None # a broker may then lengthen it to what the agent was told
|
|
204
|
+
self.timeout = 3600 if timeout is None else timeout
|
|
205
|
+
self.shell_timeout = shell_timeout # seconds per shell command of the agent (Claude Code's own cap otherwise)
|
|
206
|
+
self.max_budget_usd = max_budget_usd # per decision, passed as --max-budget-usd
|
|
207
|
+
self.budget_left = None # set by the caller before a decision: what the run may still spend
|
|
208
|
+
self.extra_args = list(extra_args)
|
|
209
|
+
self.sandbox_auto = sandbox == "auto"
|
|
210
|
+
self.sandbox = _sandbox_available() if self.sandbox_auto else bool(sandbox)
|
|
211
|
+
self.sandbox_problem = None
|
|
212
|
+
self.session = None
|
|
213
|
+
self.started = False
|
|
214
|
+
self.session_cwd = None # the folder the session belongs to: Claude Code keys sessions by working directory
|
|
215
|
+
self.session_cost = 0.0 # Claude Code reports total_cost_usd for the whole session so far (2.1.302)
|
|
216
|
+
|
|
217
|
+
def setup(self, run_dir, workspace):
|
|
218
|
+
self.control = (Path(run_dir) / "control").resolve()
|
|
219
|
+
self.control.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
220
|
+
# the Bash tool keeps its cwd file in a temp dir; give it one the sandbox may read and write
|
|
221
|
+
self.tmpdir = (Path(run_dir) / "agent-tmp").resolve()
|
|
222
|
+
self.tmpdir.mkdir(exist_ok=True)
|
|
223
|
+
self.run_dir = Path(run_dir).resolve()
|
|
224
|
+
self.workspace = Path(workspace).resolve()
|
|
225
|
+
(self.control / "settings.json").write_text(json.dumps(self._settings(), indent=1))
|
|
226
|
+
|
|
227
|
+
def _tool_dirs(self):
|
|
228
|
+
"""Folders of bwrap and socat when they are not in the system folders: Claude Code looks them up on the PATH
|
|
229
|
+
its settings give it, and socat runs inside the sandbox."""
|
|
230
|
+
if platform.system() != "Linux":
|
|
231
|
+
return []
|
|
232
|
+
out = []
|
|
233
|
+
for n in ("bwrap", "socat"):
|
|
234
|
+
exe = shutil.which(n)
|
|
235
|
+
if exe:
|
|
236
|
+
for d in (os.path.dirname(exe), os.path.dirname(_real(exe))):
|
|
237
|
+
if d not in out and d not in ("/usr/bin", "/bin", "/usr/sbin", "/sbin"):
|
|
238
|
+
out.append(d)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _settings(self):
|
|
242
|
+
ws, control = str(self.workspace), str(self.control.resolve())
|
|
243
|
+
allow = [f"{t}(/{ws}/**)" for t in ("Read", "Write", "Edit")]
|
|
244
|
+
s = {
|
|
245
|
+
"permissions": {
|
|
246
|
+
"allow": allow + (["Bash"] if self.sandbox else []),
|
|
247
|
+
"deny": [f"{t}(/{control}/**)" for t in ("Read", "Write", "Edit")],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
s["env"] = dict(NO_GPU) # also without the sandbox (no Bash then), so the env is the same everywhere
|
|
251
|
+
if self.sandbox:
|
|
252
|
+
py, dropped = _python_paths(protect=(ws, str(self.run_dir)))
|
|
253
|
+
if dropped and not getattr(self, "_warned_dropped", False):
|
|
254
|
+
self._warned_dropped = True
|
|
255
|
+
print(
|
|
256
|
+
"[opal] the agent's shell may not read "
|
|
257
|
+
+ ", ".join(dropped)
|
|
258
|
+
+ " (it holds your home folder or the run folder), so the training interpreter may not start in "
|
|
259
|
+
"its shell; a venv or conda environment of its own avoids this.",
|
|
260
|
+
file=sys.stderr,
|
|
261
|
+
)
|
|
262
|
+
tools = [d for d in self._tool_dirs() if not any(_contains(d, x) for x in (ws, str(self.run_dir)))]
|
|
263
|
+
tmp = [str(self.tmpdir)]
|
|
264
|
+
system = _SYSTEM_READ_MAC if platform.system() == "Darwin" else _SYSTEM_READ_LINUX
|
|
265
|
+
path = [os.path.dirname(sys.executable), *tools, "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
266
|
+
s["env"]["PATH"] = ":".join(dict.fromkeys(path))
|
|
267
|
+
s["sandbox"] = {
|
|
268
|
+
"enabled": True,
|
|
269
|
+
"failIfUnavailable": True,
|
|
270
|
+
"autoAllowBashIfSandboxed": True,
|
|
271
|
+
"allowUnsandboxedCommands": False,
|
|
272
|
+
"filesystem": {
|
|
273
|
+
"denyRead": ["/", control],
|
|
274
|
+
"allowRead": [ws, *py, *tools, *system, *tmp],
|
|
275
|
+
"allowWrite": tmp,
|
|
276
|
+
"denyWrite": [control],
|
|
277
|
+
},
|
|
278
|
+
"network": {"allowedDomains": [], "strictAllowlist": True, "allowLocalBinding": False},
|
|
279
|
+
}
|
|
280
|
+
hidden = _shell_denied_env(agent_env(_login_prefixes()))
|
|
281
|
+
if hidden:
|
|
282
|
+
s["sandbox"]["credentials"] = {"envVars": [dict(name=n, mode="deny") for n in hidden]}
|
|
283
|
+
return s
|
|
284
|
+
|
|
285
|
+
def preflight(self):
|
|
286
|
+
exe = shutil.which(self.binary)
|
|
287
|
+
if exe is None:
|
|
288
|
+
raise RuntimeError(
|
|
289
|
+
f"opal: the Claude Code CLI ({self.binary!r}) is not on PATH. Install Claude Code and run `claude` "
|
|
290
|
+
"once to log in, or use backend='mailbox:<dir>' with `opal broker` on a machine "
|
|
291
|
+
"that has an agent."
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
text = subprocess.run([exe, "--help"], capture_output=True, text=True, timeout=60).stdout
|
|
295
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
296
|
+
text = ""
|
|
297
|
+
self.supported = (
|
|
298
|
+
{f for f in _OPTIONAL_FLAGS if f in text} if text else set(_OPTIONAL_FLAGS) - {"--max-budget-usd"}
|
|
299
|
+
)
|
|
300
|
+
missing = sorted(set(_OPTIONAL_FLAGS) - self.supported - {"--max-budget-usd"})
|
|
301
|
+
if missing:
|
|
302
|
+
print(
|
|
303
|
+
f"[opal] this Claude Code version lacks {', '.join(missing)}; running without them "
|
|
304
|
+
"(the agent is less isolated from your Claude Code settings). Updating Claude Code fixes this.",
|
|
305
|
+
file=sys.stderr,
|
|
306
|
+
)
|
|
307
|
+
if platform.system() == "Linux" and (self.sandbox or self.sandbox_auto):
|
|
308
|
+
self.sandbox_problem = linux_sandbox_problem()
|
|
309
|
+
if self.sandbox_problem and not self.sandbox_auto:
|
|
310
|
+
raise RuntimeError(
|
|
311
|
+
f"opal: sandbox=True, but the Linux sandbox for the agent's shell cannot start here "
|
|
312
|
+
f"({self.sandbox_problem}). Install bubblewrap and socat, or use sandbox=False (file tools only)."
|
|
313
|
+
)
|
|
314
|
+
self.sandbox = self.sandbox_problem is None
|
|
315
|
+
if self.sandbox_problem:
|
|
316
|
+
print(
|
|
317
|
+
f"[opal] the sandbox for the agent's shell cannot start here ({self.sandbox_problem}); the "
|
|
318
|
+
"agent gets file tools only (Read, Edit, Write), no shell. Install bubblewrap and socat to give "
|
|
319
|
+
"it a sandboxed shell.",
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def reproduction_note(self):
|
|
324
|
+
"""One line when this agent is not the blog's (claude-opus-5-5 at effort high), else None. Call after
|
|
325
|
+
preflight(), which learns whether this Claude Code takes --effort."""
|
|
326
|
+
model = self.model or "the CLI's default model"
|
|
327
|
+
effort = self.effort if "--effort" in getattr(self, "supported", {"--effort"}) else None
|
|
328
|
+
if self.model == DEFAULT_MODEL and effort == DEFAULT_EFFORT:
|
|
329
|
+
return None
|
|
330
|
+
effort = (
|
|
331
|
+
f"effort {effort}"
|
|
332
|
+
if effort
|
|
333
|
+
else ("the CLI's default effort" + ("" if not self.effort else " (this Claude Code has no --effort)"))
|
|
334
|
+
)
|
|
335
|
+
return (
|
|
336
|
+
f"the blog's results ({BLOG_URL}) were made with {DEFAULT_MODEL} at effort {DEFAULT_EFFORT}; with "
|
|
337
|
+
f"{model} at {effort} the agent may not reproduce them"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _budget(self):
|
|
341
|
+
caps = [c for c in (self.max_budget_usd, self.budget_left) if c is not None]
|
|
342
|
+
return max(0.0, min(caps) - self._spent) if caps else None
|
|
343
|
+
|
|
344
|
+
def _cmd(self, system):
|
|
345
|
+
ok = self.supported if hasattr(self, "supported") else set(_OPTIONAL_FLAGS) - {"--max-budget-usd"}
|
|
346
|
+
tools = "Bash,Edit,Read,Write" if self.sandbox else "Edit,Read,Write"
|
|
347
|
+
cmd = [self.binary, "--print"]
|
|
348
|
+
cmd += [f for f in ("--safe-mode", "--restricted", "--no-chrome") if f in ok]
|
|
349
|
+
cmd += ["--tools", tools, "--permission-mode", "dontAsk"]
|
|
350
|
+
if "--permission-prompts" in ok:
|
|
351
|
+
cmd += ["--permission-prompts", "none"]
|
|
352
|
+
if "--setting-sources" in ok:
|
|
353
|
+
cmd += ["--setting-sources", ""]
|
|
354
|
+
cmd += ["--settings", str(self.control / "settings.json")]
|
|
355
|
+
if "--strict-mcp-config" in ok:
|
|
356
|
+
cmd += ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}']
|
|
357
|
+
cmd += ["--output-format", "stream-json", "--verbose"]
|
|
358
|
+
if self.model:
|
|
359
|
+
cmd += ["--model", self.model]
|
|
360
|
+
if self.effort and "--effort" in ok:
|
|
361
|
+
cmd += ["--effort", self.effort]
|
|
362
|
+
budget = self._budget()
|
|
363
|
+
if budget is not None and "--max-budget-usd" in ok:
|
|
364
|
+
cmd += ["--max-budget-usd", f"{budget:.4f}"]
|
|
365
|
+
# the system prompt goes with every call: on --resume it would otherwise be lost once the session compacts
|
|
366
|
+
cmd += ["--session-id", self.session] if not self.started else ["--resume", self.session]
|
|
367
|
+
if system:
|
|
368
|
+
note = SANDBOX_NOTE if self.sandbox else NO_BASH_NOTE
|
|
369
|
+
cmd += ["--append-system-prompt", system + "\n\n" + note]
|
|
370
|
+
return cmd + self.extra_args
|
|
371
|
+
|
|
372
|
+
def _new_session(self):
|
|
373
|
+
self.session, self.started, self.session_cost, self.session_cwd = str(uuid.uuid4()), False, 0.0, None
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def lost_note(workspace) -> str:
|
|
377
|
+
"""The first lines of a message that starts a new conversation mid-run: what the folder still holds (the
|
|
378
|
+
earlier decisions' inputs are there only if inbox/ has them)."""
|
|
379
|
+
inbox = Path(workspace) / "inbox"
|
|
380
|
+
ks = sorted(
|
|
381
|
+
int(m.group(1)) for f in inbox.glob("decision-*.json") if (m := re.match(r"decision-(\d+)", f.name))
|
|
382
|
+
)
|
|
383
|
+
earlier = ks[:-1] # the newest file is this decision's input
|
|
384
|
+
if earlier:
|
|
385
|
+
held = (
|
|
386
|
+
f"inbox/ holds the inputs of decision {earlier[0]}"
|
|
387
|
+
if len(earlier) == 1
|
|
388
|
+
else f"inbox/ holds the inputs of {len(earlier)} earlier decisions ({earlier[0]} to {earlier[-1]})"
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
held = "the inputs of earlier decisions are not in this folder"
|
|
392
|
+
notes = any(f.is_file() and f.name.lower().startswith("notes") for f in Path(workspace).iterdir())
|
|
393
|
+
mine = "your current programs and your notes are" if notes else "your current programs are"
|
|
394
|
+
return (
|
|
395
|
+
f"(Your earlier conversation could not be resumed, so this is a new conversation: {held}, and {mine} "
|
|
396
|
+
"in this folder.)\n\n"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def decide(self, workspace, message, system, record_dir):
|
|
400
|
+
"""One decision, within `timeout` seconds. A call that fails in the agent's own turn (an exit code, a crash) is
|
|
401
|
+
made again after `backoff` seconds, then twice as long, ... (at most `backoff_max` per wait) while the
|
|
402
|
+
decision's time allows, up to `retries` times (None: no cap), each time from the programs the decision started
|
|
403
|
+
with: a failed call's edits are dropped, as when a decision fails. A call that fails because the agent is
|
|
404
|
+
unavailable (no login, a usage or spend limit, an overloaded API, no network, no CLI: backends/unavailable.py)
|
|
405
|
+
is made again after 60 s, then at most every 5 min, for as long as it takes (at most `unavailable_timeout`
|
|
406
|
+
seconds when given); that time does not count against `timeout`, and the agent's message is the same unless
|
|
407
|
+
the failed call had started to work (then the folder is set back and a note says so). Every failure is logged
|
|
408
|
+
with the CLI's own words and kept in attempts.jsonl next to the transcript."""
|
|
409
|
+
t0 = time.time()
|
|
410
|
+
deadline, start = t0 + self.timeout, programs.read_dir(workspace)
|
|
411
|
+
self._spent, costs, n, failures, note = 0.0, [], 0, 0, ""
|
|
412
|
+
waiting, waited = None, 0.0 # the wait for an unavailable agent (backends/unavailable.py), its seconds
|
|
413
|
+
while True:
|
|
414
|
+
n += 1
|
|
415
|
+
t_call, was_started, session_cost = time.time(), self.started, self.session_cost
|
|
416
|
+
out = self._call(workspace, note + message, system, record_dir, max(1.0, deadline - time.time()))
|
|
417
|
+
final = out.pop("final", False)
|
|
418
|
+
down = out.pop("unavailable", None)
|
|
419
|
+
if out.get("cost_usd") is not None:
|
|
420
|
+
costs.append(out["cost_usd"])
|
|
421
|
+
self._spent += out["cost_usd"]
|
|
422
|
+
if out["returncode"] == 0 and not out.get("error"):
|
|
423
|
+
break
|
|
424
|
+
why = error_tail(out.get("error") or f"exit code {out['returncode']}")
|
|
425
|
+
transcript = Path(record_dir) / "backend.jsonl"
|
|
426
|
+
if down is not None and not getattr(self, "cancelled", False):
|
|
427
|
+
worked = self._worked(transcript, workspace, start)
|
|
428
|
+
if not worked: # the turn never ran: the session and its cost are as they were
|
|
429
|
+
self.started = was_started
|
|
430
|
+
if not out.get("cost_usd"):
|
|
431
|
+
self.session_cost = session_cost
|
|
432
|
+
if waiting is None:
|
|
433
|
+
waiting = unavailable.Wait(self._say, "Claude Code", self.training_waits, self.unavailable_timeout)
|
|
434
|
+
if self.on_unavailable is not None:
|
|
435
|
+
self.on_unavailable(down[1])
|
|
436
|
+
pause = waiting.note(*down)
|
|
437
|
+
line = dict(call=n, returncode=out["returncode"], error=why, unavailable=down[0], next_call_in=pause)
|
|
438
|
+
with open(Path(record_dir) / "attempts.jsonl", "a") as f:
|
|
439
|
+
f.write(json.dumps(dict(line, seconds=round(out.get("seconds") or 0.0, 1))) + "\n")
|
|
440
|
+
if pause is None:
|
|
441
|
+
break
|
|
442
|
+
if transcript.exists():
|
|
443
|
+
transcript.replace(Path(record_dir) / f"backend-call{n}.jsonl")
|
|
444
|
+
ok = self._pause(pause)
|
|
445
|
+
deadline += time.time() - t_call # the time without an agent does not count against `timeout`
|
|
446
|
+
if not ok:
|
|
447
|
+
break
|
|
448
|
+
if worked:
|
|
449
|
+
programs.write_dir(workspace, start)
|
|
450
|
+
note = self._retry_note(why)
|
|
451
|
+
continue
|
|
452
|
+
if waiting is not None: # available again (this call failed in the agent's own turn)
|
|
453
|
+
waited += self._end_wait(waiting)
|
|
454
|
+
waiting = None
|
|
455
|
+
failures += 1
|
|
456
|
+
wait = None if final else self._next_wait(failures, deadline)
|
|
457
|
+
line = dict(call=n, returncode=out["returncode"], error=why, seconds=round(out.get("seconds") or 0.0, 1))
|
|
458
|
+
with open(Path(record_dir) / "attempts.jsonl", "a") as f:
|
|
459
|
+
f.write(json.dumps(dict(line, next_call_in=wait)) + "\n")
|
|
460
|
+
if wait is None:
|
|
461
|
+
break
|
|
462
|
+
self._say(f"agent call {n} failed (exit code {out['returncode']}): {why}; calling again in {wait:.0f} s")
|
|
463
|
+
if transcript.exists():
|
|
464
|
+
transcript.replace(Path(record_dir) / f"backend-call{n}.jsonl")
|
|
465
|
+
if not self._pause(wait):
|
|
466
|
+
break
|
|
467
|
+
programs.write_dir(workspace, start)
|
|
468
|
+
note = self._retry_note(why)
|
|
469
|
+
if waiting is not None: # answered after a wait, or given up (unavailable_timeout, the run cancelled it)
|
|
470
|
+
if out["returncode"] == 0 and not out.get("error"):
|
|
471
|
+
waited += self._end_wait(waiting)
|
|
472
|
+
else:
|
|
473
|
+
waited += waiting.seconds
|
|
474
|
+
if self.on_unavailable is not None:
|
|
475
|
+
self.on_unavailable(None)
|
|
476
|
+
out = dict(out, cost_usd=sum(costs) if costs else None, seconds=time.time() - t0 - waited)
|
|
477
|
+
if waited:
|
|
478
|
+
out["unavailable_seconds"] = round(waited, 1)
|
|
479
|
+
if n > 1 and (out["returncode"] != 0 or out.get("error")):
|
|
480
|
+
out["error"] = f"{out.get('error') or ''} ({n} calls over {time.time() - t0:.0f} s)".strip()
|
|
481
|
+
return out
|
|
482
|
+
|
|
483
|
+
def _end_wait(self, waiting):
|
|
484
|
+
if self.on_unavailable is not None:
|
|
485
|
+
self.on_unavailable(None)
|
|
486
|
+
return waiting.end()
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _retry_note(why):
|
|
490
|
+
return (
|
|
491
|
+
f"(The previous call for this decision stopped before it finished ({why[:200]}). This folder was set "
|
|
492
|
+
"back to the programs as they were when the decision started; edits made in that call were not "
|
|
493
|
+
"kept.)\n\n"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _worked(transcript, workspace, start):
|
|
498
|
+
"""Whether a failed call had started to work: it changed the programs or used a tool."""
|
|
499
|
+
try:
|
|
500
|
+
if programs.read_dir(workspace) != start:
|
|
501
|
+
return True
|
|
502
|
+
for line in open(transcript):
|
|
503
|
+
if '"tool_use"' in line:
|
|
504
|
+
return True
|
|
505
|
+
except OSError:
|
|
506
|
+
pass
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
def _next_wait(self, n, deadline):
|
|
510
|
+
"""Seconds to wait before the call after the n-th failed one, or None: no further call (retries used up, the
|
|
511
|
+
run cancelled the decision, its budget spent, or too little of its time left)."""
|
|
512
|
+
if getattr(self, "cancelled", False) or (self.retries is not None and n > self.retries):
|
|
513
|
+
return None
|
|
514
|
+
budget = self._budget()
|
|
515
|
+
if budget is not None and budget <= 0:
|
|
516
|
+
return None
|
|
517
|
+
left = deadline - time.time() - min(MIN_CALL, self.timeout / 10)
|
|
518
|
+
if left <= 0:
|
|
519
|
+
return None
|
|
520
|
+
return min(self.backoff * 2 ** (n - 1), self.backoff_max, left)
|
|
521
|
+
|
|
522
|
+
def _pause(self, seconds):
|
|
523
|
+
"""Wait, unless the run cancels the decision meanwhile (then False)."""
|
|
524
|
+
end = time.time() + seconds
|
|
525
|
+
while time.time() < end and not getattr(self, "cancelled", False):
|
|
526
|
+
time.sleep(min(0.5, max(0.0, end - time.time())))
|
|
527
|
+
return not getattr(self, "cancelled", False)
|
|
528
|
+
|
|
529
|
+
def _say(self, text):
|
|
530
|
+
text = redact_secrets(text)
|
|
531
|
+
if self.log is not None:
|
|
532
|
+
self.log(text)
|
|
533
|
+
else:
|
|
534
|
+
print(f"[opal] {text}", file=sys.stderr, flush=True)
|
|
535
|
+
|
|
536
|
+
def _call(self, workspace, message, system, record_dir, timeout):
|
|
537
|
+
"""One call of the CLI in the run's session (resumed, or a new one when it cannot be)."""
|
|
538
|
+
end = time.time() + timeout
|
|
539
|
+
ws = str(Path(workspace).resolve())
|
|
540
|
+
note = ""
|
|
541
|
+
if self.started and self.session_cwd and self.session_cwd != ws:
|
|
542
|
+
# Claude Code keys sessions by working directory: one started in another folder cannot be resumed here
|
|
543
|
+
self._new_session()
|
|
544
|
+
note = self.lost_note(workspace)
|
|
545
|
+
out = self._decide(workspace, note + message, system, record_dir, timeout)
|
|
546
|
+
err = out.get("error") or ""
|
|
547
|
+
if out["returncode"] != 0 and not self.started and _IN_USE in err:
|
|
548
|
+
# the session exists already (saved before its first call had started it, then requeued): resume it
|
|
549
|
+
self.started = True
|
|
550
|
+
out = self._decide(workspace, note + message, system, record_dir, max(1.0, end - time.time()))
|
|
551
|
+
err = out.get("error") or ""
|
|
552
|
+
if out["returncode"] != 0 and self.started and _NOT_FOUND in err:
|
|
553
|
+
# the session lives on another machine (a requeued job), or in a folder that is gone: start a new one
|
|
554
|
+
self._new_session()
|
|
555
|
+
msg = self.lost_note(workspace) + message
|
|
556
|
+
out = self._decide(workspace, msg, system, record_dir, max(1.0, end - time.time()))
|
|
557
|
+
return out
|
|
558
|
+
|
|
559
|
+
def _decide(self, workspace, message, system, record_dir, timeout=None):
|
|
560
|
+
timeout = self.timeout if timeout is None else timeout
|
|
561
|
+
t0 = time.time()
|
|
562
|
+
transcript = Path(record_dir) / "backend.jsonl"
|
|
563
|
+
if self.session is None:
|
|
564
|
+
self._new_session()
|
|
565
|
+
timed_out = final = missing = False # final: calling again cannot help (the run cancelled the decision)
|
|
566
|
+
with open(transcript, "w") as out:
|
|
567
|
+
try:
|
|
568
|
+
p = self._run(
|
|
569
|
+
self._cmd(system),
|
|
570
|
+
input=message,
|
|
571
|
+
stdout=out,
|
|
572
|
+
cwd=workspace,
|
|
573
|
+
timeout=timeout,
|
|
574
|
+
env=agent_env(_login_prefixes(), self._env()),
|
|
575
|
+
)
|
|
576
|
+
code, err = p.returncode, p.stderr[-2000:]
|
|
577
|
+
except subprocess.TimeoutExpired:
|
|
578
|
+
code, err, timed_out = -1, f"timed out after {timeout:.0f} s", True
|
|
579
|
+
except FileNotFoundError:
|
|
580
|
+
code, err, missing = -1, f"{self.binary!r} not found: install Claude Code and log in", True
|
|
581
|
+
except Cancelled as exc:
|
|
582
|
+
code, err, final = -1, str(exc), True
|
|
583
|
+
events, plain = [], []
|
|
584
|
+
for line in open(transcript):
|
|
585
|
+
if line.strip().startswith("{"):
|
|
586
|
+
try:
|
|
587
|
+
events.append(json.loads(line))
|
|
588
|
+
continue
|
|
589
|
+
except json.JSONDecodeError:
|
|
590
|
+
pass
|
|
591
|
+
if line.strip():
|
|
592
|
+
plain.append(line.strip()) # the CLI's own words outside the stream (a usage limit, a crash)
|
|
593
|
+
result = next((e for e in reversed(events) if e.get("type") == "result"), {})
|
|
594
|
+
init = any(e.get("type") == "system" and e.get("subtype") == "init" for e in events)
|
|
595
|
+
if code == 0 or result or init:
|
|
596
|
+
self.started = True # the session exists from its init event on: later calls must --resume it
|
|
597
|
+
self.session_cwd = str(Path(workspace).resolve())
|
|
598
|
+
if timed_out and result and not result.get("is_error"):
|
|
599
|
+
code, err = 0, "" # the answer was complete; the CLI (or a process it left) only failed to exit in time
|
|
600
|
+
total, cost = result.get("total_cost_usd"), None
|
|
601
|
+
if total is not None: # this decision's cost: the session total minus what earlier decisions reported
|
|
602
|
+
total = float(total)
|
|
603
|
+
cost = total - self.session_cost if total >= self.session_cost else total
|
|
604
|
+
self.session_cost = total
|
|
605
|
+
error = None
|
|
606
|
+
said = str(result.get("result") or "").strip()
|
|
607
|
+
limit = next((t for t in [*plain, said] if _LIMIT.search(t) and len(t) < 500), None)
|
|
608
|
+
if code != 0 or result.get("is_error") or result.get("subtype", "success") != "success" or limit:
|
|
609
|
+
# a failed call always carries an error, also when stderr is empty (an API error or usage limit reported
|
|
610
|
+
# in the stream, the CLI killed by a signal), so it is never taken for a decision that kept the programs
|
|
611
|
+
errors = "; ".join(str(e) for e in result.get("errors") or []) # "Reached maximum budget ($0.01)"
|
|
612
|
+
error = err.strip() or limit or said or errors or " ".join(plain)[-500:]
|
|
613
|
+
if not error:
|
|
614
|
+
error = (
|
|
615
|
+
f"the Claude Code CLI was killed by signal {-code} ({_signame(-code)})"
|
|
616
|
+
if code < 0
|
|
617
|
+
else f"the Claude Code CLI exited with code {code} and gave no reason"
|
|
618
|
+
)
|
|
619
|
+
# an agent that cannot run a turn at all (no login, a limit, an overloaded API, no network, no CLI) is waited for
|
|
620
|
+
down = None
|
|
621
|
+
if error is not None and not final:
|
|
622
|
+
down = ("missing", error) if missing else unavailable.classify([*plain, said, err.strip(), errors])
|
|
623
|
+
return dict(
|
|
624
|
+
text=result.get("result", "") or "",
|
|
625
|
+
cost_usd=cost,
|
|
626
|
+
returncode=code,
|
|
627
|
+
error=error,
|
|
628
|
+
seconds=time.time() - t0,
|
|
629
|
+
**({"final": True} if final else {}),
|
|
630
|
+
**({"unavailable": down} if down else {}),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def _env(self):
|
|
634
|
+
"""The agent's environment: its own temp dir; no git context from folders around the workspace, no CLAUDE.md,
|
|
635
|
+
memory or background shells (checked with Claude Code 2.1.302 against a mock API: the gitStatus block (branch,
|
|
636
|
+
user name, untracked files, commits) leaves the context; the account e-mail and 'Is a git repository' stay)."""
|
|
637
|
+
env = {
|
|
638
|
+
"CLAUDE_CODE_TMPDIR": str(self.tmpdir),
|
|
639
|
+
"TMPDIR": str(self.tmpdir),
|
|
640
|
+
"GIT_CEILING_DIRECTORIES": str(self.workspace.parent),
|
|
641
|
+
"CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
|
|
642
|
+
"CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1",
|
|
643
|
+
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
|
|
644
|
+
"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
|
|
645
|
+
**NO_GPU,
|
|
646
|
+
}
|
|
647
|
+
if self.shell_timeout:
|
|
648
|
+
ms = str(int(self.shell_timeout * 1000))
|
|
649
|
+
env.update(BASH_DEFAULT_TIMEOUT_MS=ms, BASH_MAX_TIMEOUT_MS=ms)
|
|
650
|
+
return env
|
|
651
|
+
|
|
652
|
+
def state(self):
|
|
653
|
+
return dict(session=self.session, started=self.started, session_cost=self.session_cost, cwd=self.session_cwd)
|
|
654
|
+
|
|
655
|
+
def restore(self, state):
|
|
656
|
+
self.session, self.started = state.get("session"), state.get("started", False)
|
|
657
|
+
self.session_cost = float(state.get("session_cost", 0.0))
|
|
658
|
+
self.session_cwd = state.get("cwd")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _signame(n):
|
|
662
|
+
try:
|
|
663
|
+
return signal.Signals(n).name
|
|
664
|
+
except ValueError:
|
|
665
|
+
return "unknown"
|