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.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
@@ -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"