outerloop-science 0.1.0.dev0__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.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/harness.py
ADDED
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
"""The harness seam: one agent session in, one result out.
|
|
2
|
+
|
|
3
|
+
`Harness.run` takes rendered brief text and a workspace directory and returns
|
|
4
|
+
a :class:`SessionResult`. Provider quirks (auth, CLI flags, output parsing)
|
|
5
|
+
live inside adapters; context policy lives in `brief` and is shared by every
|
|
6
|
+
backend — that separation is what makes backend comparisons honest
|
|
7
|
+
(docs/design/architecture.md, "The backend seam").
|
|
8
|
+
|
|
9
|
+
Sessions run in a scrubbed environment: an explicit allowlist plus the one
|
|
10
|
+
API key the backend needs. The bot PAT and anything else in the orchestrator's
|
|
11
|
+
environment never reach a session (threat model: credential theft).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import stat
|
|
22
|
+
import subprocess
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Protocol
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# What a session's environment contains — nothing else survives from the
|
|
31
|
+
# parent. HOME is deliberately NOT inherited: it is redirected to a per-run
|
|
32
|
+
# directory so a session cannot read key files under the real home or poison
|
|
33
|
+
# other runs via ~/.claude state. (Residual risk: the filesystem itself is
|
|
34
|
+
# not sandboxed — same-user absolute paths remain readable. See the threat
|
|
35
|
+
# model; the bot PAT must not live on the account that runs sessions until
|
|
36
|
+
# OS-level sandboxing lands.)
|
|
37
|
+
SESSION_ENV_ALLOWLIST = ("PATH", "TERM", "LANG", "LC_ALL", "TMPDIR")
|
|
38
|
+
|
|
39
|
+
# Fallbacks only — every orchestrated path threads effective_limits in
|
|
40
|
+
# explicitly (climb/steward CLIs pass turns and minutes; the follow-up CLI
|
|
41
|
+
# derives its timeout from the job walltime). Kept at the session ceilings
|
|
42
|
+
# so a site that forgets still grants the intended budget rather than
|
|
43
|
+
# silently undercutting it.
|
|
44
|
+
DEFAULT_TIMEOUT_S = 5400
|
|
45
|
+
DEFAULT_MAX_TURNS = 120
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SessionResult:
|
|
50
|
+
"""What happened in one session — everything the orchestrator needs to
|
|
51
|
+
judge, bill, and report it. The workspace diff is captured by the caller
|
|
52
|
+
(it owns the git clone); the harness owns only the session.
|
|
53
|
+
|
|
54
|
+
On the "timeout" path, cost and session id are unknown (the CLI is killed
|
|
55
|
+
before it reports); budget accounting must treat a timeout as worst-case
|
|
56
|
+
spend, not zero.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
stop_reason: str # backend's stop reason, or "timeout" / "spawn-error"
|
|
60
|
+
is_error: bool
|
|
61
|
+
cost_usd: float
|
|
62
|
+
num_turns: int
|
|
63
|
+
session_id: str
|
|
64
|
+
final_text: str # the agent's closing message (the research report draft)
|
|
65
|
+
transcript_path: str # raw backend output, api-key-redacted, on disk
|
|
66
|
+
# human-readable cause when is_error: the backend's error subtype and
|
|
67
|
+
# messages (e.g. "error_max_turns: Reached maximum number of turns
|
|
68
|
+
# (60)"), or our own explanation on the timeout path. This is what
|
|
69
|
+
# reports and issue comments show — stop_reason alone reads as noise
|
|
70
|
+
# ("tool_use") when a session dies mid-tool-call.
|
|
71
|
+
error_detail: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Harness(Protocol):
|
|
75
|
+
"""One coding session over a workspace. Implementations are adapters.
|
|
76
|
+
|
|
77
|
+
A *run* (one hypothesis) may span many sessions: a session that launches a
|
|
78
|
+
long experiment ends, and when results arrive the orchestrator wakes the
|
|
79
|
+
agent with `resume_session_id` — restoring its full working context — and
|
|
80
|
+
a wake prompt carrying the results. Session state lives in the per-run
|
|
81
|
+
HOME next to the workspace, so wakes survive orchestrator restarts and can
|
|
82
|
+
land on a different cluster node (shared filesystem).
|
|
83
|
+
|
|
84
|
+
A backend MAY declare a class attribute `supports_resume = False` when it
|
|
85
|
+
has no trustworthy headless resume. The revise loop checks it (via getattr,
|
|
86
|
+
default True) and DRAFTS instead of calling run() with a resume id — a resume
|
|
87
|
+
that silently starts fresh or fails would revise blind or lose a verified
|
|
88
|
+
improvement. All three current backends resume (`supports_resume=True`;
|
|
89
|
+
hermes via saved-transcript rehydration). Optional, so test doubles need not
|
|
90
|
+
declare it."""
|
|
91
|
+
|
|
92
|
+
def run(
|
|
93
|
+
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
|
|
94
|
+
) -> SessionResult: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def session_env(api_key: str, key_variable: str, home: Path) -> dict[str, str]:
|
|
98
|
+
"""The scrubbed environment a session runs with."""
|
|
99
|
+
env = {name: os.environ[name] for name in SESSION_ENV_ALLOWLIST if name in os.environ}
|
|
100
|
+
env["HOME"] = str(home)
|
|
101
|
+
env[key_variable] = api_key
|
|
102
|
+
return env
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _open_nofollow_dir(name: str, dir_fd: int) -> int:
|
|
106
|
+
"""`openat` `name` as a directory under `dir_fd` without following a final
|
|
107
|
+
symlink; -1 if it is missing, a symlink (ELOOP), or not a directory."""
|
|
108
|
+
try:
|
|
109
|
+
return os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, dir_fd=dir_fd)
|
|
110
|
+
except OSError:
|
|
111
|
+
return -1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rmtree_at(dir_fd: int, name: str) -> None:
|
|
115
|
+
"""Recursively delete directory `name` under `dir_fd`, anchored on file
|
|
116
|
+
descriptors and `O_NOFOLLOW` at every level. No path component is ever
|
|
117
|
+
resolved by name after the first open, so a session that swaps a directory
|
|
118
|
+
for a symlink mid-delete cannot divert it outside the tree (TOCTOU-safe).
|
|
119
|
+
Best-effort: a missing entry, a symlink, or a non-directory `name` is a
|
|
120
|
+
no-op."""
|
|
121
|
+
fd = _open_nofollow_dir(name, dir_fd)
|
|
122
|
+
if fd < 0:
|
|
123
|
+
return # gone, a symlink (ELOOP), or not a directory
|
|
124
|
+
try:
|
|
125
|
+
for child in os.listdir(fd):
|
|
126
|
+
try:
|
|
127
|
+
st = os.stat(child, dir_fd=fd, follow_symlinks=False)
|
|
128
|
+
except OSError:
|
|
129
|
+
continue
|
|
130
|
+
if stat.S_ISDIR(st.st_mode):
|
|
131
|
+
_rmtree_at(fd, child)
|
|
132
|
+
else:
|
|
133
|
+
with contextlib.suppress(OSError):
|
|
134
|
+
os.unlink(child, dir_fd=fd)
|
|
135
|
+
finally:
|
|
136
|
+
os.close(fd)
|
|
137
|
+
with contextlib.suppress(OSError):
|
|
138
|
+
os.rmdir(name, dir_fd=dir_fd)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class VertexConfig:
|
|
143
|
+
"""Claude-on-Vertex auth (ADC): set to bill Anthropic sessions to a GCP
|
|
144
|
+
project instead of an Anthropic API key. The single owner of the
|
|
145
|
+
AUTORESEARCH_VERTEX_* env contract is `vertex_from_env`."""
|
|
146
|
+
|
|
147
|
+
project: str
|
|
148
|
+
region: str = "global"
|
|
149
|
+
# GOOGLE_APPLICATION_CREDENTIALS. Sessions run with a scrubbed per-run
|
|
150
|
+
# HOME, so ambient ADC discovery inside the session can never find the
|
|
151
|
+
# real ~/.config/gcloud — vertex_from_env resolves that default to an
|
|
152
|
+
# explicit path up front. "" only where a metadata server provides
|
|
153
|
+
# credentials (GCE / workload identity).
|
|
154
|
+
adc_file: str = ""
|
|
155
|
+
|
|
156
|
+
def env(self) -> dict[str, str]:
|
|
157
|
+
out = {
|
|
158
|
+
"CLAUDE_CODE_USE_VERTEX": "1",
|
|
159
|
+
"ANTHROPIC_VERTEX_PROJECT_ID": self.project,
|
|
160
|
+
"CLOUD_ML_REGION": self.region,
|
|
161
|
+
}
|
|
162
|
+
if self.adc_file:
|
|
163
|
+
out["GOOGLE_APPLICATION_CREDENTIALS"] = self.adc_file
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def vertex_from_env() -> VertexConfig | None:
|
|
168
|
+
"""The deployment's Vertex coordinates, or None (Anthropic-direct). A live
|
|
169
|
+
flip needs only the env: set AUTORESEARCH_VERTEX_PROJECT to route every
|
|
170
|
+
claude session through Vertex; unset it to fall back to the API key."""
|
|
171
|
+
project = os.environ.get("AUTORESEARCH_VERTEX_PROJECT", "").strip()
|
|
172
|
+
if not project:
|
|
173
|
+
return None
|
|
174
|
+
adc = os.path.expanduser(os.environ.get("AUTORESEARCH_VERTEX_ADC", "").strip())
|
|
175
|
+
if not adc:
|
|
176
|
+
# resolve the gcloud default NOW, under the real HOME — the session's
|
|
177
|
+
# HOME is a scrubbed per-run directory where ambient discovery would
|
|
178
|
+
# find nothing. Absent file: leave "" for metadata-server credentials.
|
|
179
|
+
default = os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
|
|
180
|
+
if os.path.isfile(default):
|
|
181
|
+
adc = default
|
|
182
|
+
return VertexConfig(
|
|
183
|
+
project=project,
|
|
184
|
+
region=os.environ.get("AUTORESEARCH_VERTEX_REGION", "global").strip() or "global",
|
|
185
|
+
adc_file=adc,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def redact(text: str, secrets: tuple[str, ...]) -> str:
|
|
190
|
+
"""Strip known secrets from text before it is stored anywhere. Installation
|
|
191
|
+
tokens minted after a call site snapshotted its tuple are covered too: the
|
|
192
|
+
App provider rotates ~hourly, so the process-wide issued set is consulted
|
|
193
|
+
at write time, not capture time."""
|
|
194
|
+
from outerloop.appauth import issued_tokens
|
|
195
|
+
|
|
196
|
+
for secret in (*secrets, *issued_tokens()):
|
|
197
|
+
if secret:
|
|
198
|
+
text = text.replace(secret, "[redacted]")
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _error_result(stop_reason: str, transcript_path: str = "", detail: str = "") -> SessionResult:
|
|
203
|
+
return SessionResult(
|
|
204
|
+
stop_reason=stop_reason,
|
|
205
|
+
is_error=True,
|
|
206
|
+
error_detail=(detail or stop_reason)[:500],
|
|
207
|
+
cost_usd=0.0,
|
|
208
|
+
num_turns=0,
|
|
209
|
+
session_id="",
|
|
210
|
+
final_text="",
|
|
211
|
+
transcript_path=transcript_path,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Substrings that mean "the API itself is unavailable to us" — credit,
|
|
216
|
+
# limit, auth, throttling. Matched only against error surfaces of an
|
|
217
|
+
# is_error result (backend error text, never agent prose). The first group is
|
|
218
|
+
# Anthropic-shaped; the second matches the OpenAI-compatible / hermes+OpenRouter
|
|
219
|
+
# 401 shapes, whose text differs (e.g. "HTTP 401: Missing Authentication
|
|
220
|
+
# header", "No auth credentials found") so the Anthropic patterns miss them.
|
|
221
|
+
OUTAGE_PATTERNS = (
|
|
222
|
+
"credit balance",
|
|
223
|
+
"usage limit",
|
|
224
|
+
"spending limit",
|
|
225
|
+
"billing",
|
|
226
|
+
"authentication_error",
|
|
227
|
+
"invalid x-api-key",
|
|
228
|
+
"rate_limit_error",
|
|
229
|
+
"overloaded_error",
|
|
230
|
+
"401",
|
|
231
|
+
"missing authentication",
|
|
232
|
+
"no auth credentials",
|
|
233
|
+
"invalid api key",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def backend_id(harness: object) -> str:
|
|
238
|
+
"""`backend/model` attribution for a round stamp — which reviewer wrote
|
|
239
|
+
this. Empty for unknown harness types (test fakes): the stamp then omits
|
|
240
|
+
the reviewer clause rather than naming something misleading."""
|
|
241
|
+
names = {
|
|
242
|
+
"ClaudeCodeHarness": "claude",
|
|
243
|
+
"CodexHarness": "codex",
|
|
244
|
+
"HermesHarness": "hermes",
|
|
245
|
+
}
|
|
246
|
+
backend = names.get(type(harness).__name__, "")
|
|
247
|
+
if not backend:
|
|
248
|
+
return ""
|
|
249
|
+
model = str(getattr(harness, "model", "") or "").strip()
|
|
250
|
+
return f"{backend}/{model}" if model else backend
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def outage(result: SessionResult) -> bool:
|
|
254
|
+
"""True when the session failed because the API refused us — dead
|
|
255
|
+
credits, spend cap, bad key, throttling — not because of anything in
|
|
256
|
+
the run. Callers treat this as an infrastructure outage: pause the
|
|
257
|
+
lanes, and never bill the failure against a run's retry caps or a
|
|
258
|
+
work order's attempts."""
|
|
259
|
+
if not result.is_error:
|
|
260
|
+
return False
|
|
261
|
+
# error_detail is backend error text and always wins; final_text is
|
|
262
|
+
# consulted ONLY when no detail exists AND it carries the legacy CLI
|
|
263
|
+
# error shape ("API Error ..."), never agent prose — a failed session's
|
|
264
|
+
# report that merely MENTIONS billing or limits must not trip a latch
|
|
265
|
+
# that pauses every lane.
|
|
266
|
+
surface = result.error_detail.casefold()
|
|
267
|
+
if not surface:
|
|
268
|
+
text = result.final_text.strip().casefold()
|
|
269
|
+
if not text.startswith("api error"):
|
|
270
|
+
return False
|
|
271
|
+
surface = text
|
|
272
|
+
return any(pattern in surface for pattern in OUTAGE_PATTERNS)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def budget_exhausted(result: SessionResult) -> bool:
|
|
276
|
+
"""True when the session stopped because OUR limits ran out — turns or
|
|
277
|
+
session walltime — rather than because anything failed. Callers report
|
|
278
|
+
this as the budget-exhausted ending, never as an error: "caps hit
|
|
279
|
+
mid-run" is one of the six honest deaths, not a malfunction."""
|
|
280
|
+
return result.stop_reason == "timeout" or result.error_detail.startswith("error_max_turns")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _write_private(directory: Path, stem: str, suffix: str, text: str) -> str:
|
|
284
|
+
"""Atomically create a fresh owner-only file and write `text` to it.
|
|
285
|
+
|
|
286
|
+
O_EXCL closes the TOCTOU between name choice and creation, and it also
|
|
287
|
+
refuses symlinks (a session could plant a dangling link where its own
|
|
288
|
+
transcript will land, redirecting the write to an arbitrary same-user
|
|
289
|
+
file). Returns the path written, or "" — storage failures must not crash
|
|
290
|
+
the adapter."""
|
|
291
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
292
|
+
for n in range(1, 1000):
|
|
293
|
+
path = directory / (f"{stem}{suffix}" if n == 1 else f"{stem}-{n}{suffix}")
|
|
294
|
+
try:
|
|
295
|
+
fd = os.open(path, flags, 0o600)
|
|
296
|
+
except FileExistsError:
|
|
297
|
+
continue
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
log.warning("could not store transcript at %s: %s", path, exc)
|
|
300
|
+
return ""
|
|
301
|
+
with os.fdopen(fd, "w") as handle:
|
|
302
|
+
handle.write(text)
|
|
303
|
+
return str(path)
|
|
304
|
+
log.warning("could not find a free transcript name in %s", directory)
|
|
305
|
+
return ""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _write_private_fixed(path: Path, text: str) -> bool:
|
|
309
|
+
"""Write `text` to a fixed path, refusing to follow a symlink (a
|
|
310
|
+
session-writable dir could hold a planted link redirecting the write to an
|
|
311
|
+
arbitrary same-user file). True on success. Truncates an existing regular
|
|
312
|
+
file; O_NOFOLLOW makes os.open raise on a symlink."""
|
|
313
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
314
|
+
try:
|
|
315
|
+
fd = os.open(path, flags, 0o600)
|
|
316
|
+
with os.fdopen(fd, "w") as handle:
|
|
317
|
+
handle.write(text)
|
|
318
|
+
except OSError:
|
|
319
|
+
return False
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _read_no_follow(path: Path) -> str | None:
|
|
324
|
+
"""Read a file's text without following a symlink. None on any error (a
|
|
325
|
+
missing file lost to a race, or a planted link O_NOFOLLOW refuses)."""
|
|
326
|
+
try:
|
|
327
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
328
|
+
with os.fdopen(fd, "r", errors="replace") as handle:
|
|
329
|
+
return handle.read()
|
|
330
|
+
except OSError:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _collect_hermes_sample(session_home: Path) -> Any:
|
|
335
|
+
"""Load this run's trajectory (newest `sample_*.json`) and delete every one
|
|
336
|
+
of them. Hardened for a session-writable dir: the mtime sort key cannot
|
|
337
|
+
raise out of run() on a vanished file, the read does not follow symlinks,
|
|
338
|
+
and unlink removes the link itself (never its target). Returns the parsed
|
|
339
|
+
sample, or None."""
|
|
340
|
+
|
|
341
|
+
def _mtime(p: Path) -> float:
|
|
342
|
+
try:
|
|
343
|
+
return p.stat().st_mtime
|
|
344
|
+
except OSError:
|
|
345
|
+
return 0.0
|
|
346
|
+
|
|
347
|
+
candidates = sorted(session_home.glob("sample_*.json"), key=_mtime, reverse=True)
|
|
348
|
+
sample: Any = None
|
|
349
|
+
if candidates:
|
|
350
|
+
text = _read_no_follow(candidates[0])
|
|
351
|
+
if text is not None:
|
|
352
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
353
|
+
sample = json.loads(text)
|
|
354
|
+
for stale in candidates:
|
|
355
|
+
with contextlib.suppress(OSError):
|
|
356
|
+
stale.unlink() # trajectories can embed the brief; don't accumulate
|
|
357
|
+
return sample
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- hermes headless resume -------------------------------------------------
|
|
361
|
+
# A headless CLI "resumes" by restarting with its prior context restored (that
|
|
362
|
+
# is all claude --resume and codex exec resume do). Hermes has no --resume flag,
|
|
363
|
+
# but it reads its brief from a file, so resume = the next brief carries the
|
|
364
|
+
# prior conversation. The harness keeps its OWN linear transcript (the message
|
|
365
|
+
# it sent + the assistant reply it parsed each turn), rehydrates it into the
|
|
366
|
+
# resume brief, and persists it in the per-run home beside the other resume
|
|
367
|
+
# state — so a resumed hermes session physically cannot start context-blind.
|
|
368
|
+
|
|
369
|
+
_RESUME_STEM = "resume-"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _resume_transcript_path(session_home: Path, session_id: str) -> Path:
|
|
373
|
+
# session_id is harness-minted (uuid hex) — no path traversal — but keep it
|
|
374
|
+
# to the basename defensively.
|
|
375
|
+
return session_home / f"{_RESUME_STEM}{os.path.basename(session_id)}.json"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _load_resume_transcript(session_home: Path, session_id: str) -> list[dict[str, str]] | None:
|
|
379
|
+
"""Load a saved transcript for `session_id`. None = not found (the caller
|
|
380
|
+
must NOT silently start fresh — a resume with no restored context is an
|
|
381
|
+
error). The per-run home is session-writable, so read without following a
|
|
382
|
+
symlink."""
|
|
383
|
+
text = _read_no_follow(_resume_transcript_path(session_home, session_id))
|
|
384
|
+
if text is None:
|
|
385
|
+
return None
|
|
386
|
+
try:
|
|
387
|
+
data = json.loads(text)
|
|
388
|
+
except json.JSONDecodeError:
|
|
389
|
+
return None
|
|
390
|
+
turns = data.get("turns") if isinstance(data, dict) else None
|
|
391
|
+
if not isinstance(turns, list) or not turns:
|
|
392
|
+
return None
|
|
393
|
+
# Accept ONLY a COMPLETE, well-formed conversation, and reject the whole file
|
|
394
|
+
# on any deviation. The transcript is one we write ([user, assistant] pairs
|
|
395
|
+
# with non-blank text) into a session-writable home, so anything else —
|
|
396
|
+
# empty, whitespace-only, a non-dict entry, an unknown role, or a partial
|
|
397
|
+
# exchange missing the user instructions or the assistant reply — is
|
|
398
|
+
# corruption or tampering and must surface as "unavailable" (the caller
|
|
399
|
+
# errors) rather than resume with missing context, the exact context-blind
|
|
400
|
+
# resume this path exists to reject. We distrust the file as a whole rather
|
|
401
|
+
# than salvage a subset.
|
|
402
|
+
cleaned: list[dict[str, str]] = []
|
|
403
|
+
for t in turns:
|
|
404
|
+
if not isinstance(t, dict):
|
|
405
|
+
return None
|
|
406
|
+
role = str(t.get("role", ""))
|
|
407
|
+
content = str(t.get("text", ""))
|
|
408
|
+
if role not in ("user", "assistant") or not content.strip():
|
|
409
|
+
return None
|
|
410
|
+
cleaned.append({"role": role, "text": content})
|
|
411
|
+
roles = {t["role"] for t in cleaned}
|
|
412
|
+
if "user" not in roles or "assistant" not in roles:
|
|
413
|
+
return None # a real prior conversation has instructions AND a reply
|
|
414
|
+
return cleaned
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _save_resume_transcript(
|
|
418
|
+
session_home: Path, session_id: str, turns: list[dict[str, str]]
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Persist the transcript (0600, symlink-refusing) so the next resume can
|
|
421
|
+
rehydrate it. Best-effort: a failure just means the next resume errors
|
|
422
|
+
(resume-unavailable) rather than starting blind."""
|
|
423
|
+
_write_private_fixed(
|
|
424
|
+
_resume_transcript_path(session_home, session_id), json.dumps({"turns": turns})
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _render_resume_transcript(turns: list[dict[str, str]]) -> str:
|
|
429
|
+
"""Render prior turns as a readable prefix for the resume brief."""
|
|
430
|
+
blocks = ["=== Earlier in this session (your prior context) ==="]
|
|
431
|
+
for t in turns:
|
|
432
|
+
who = "You were told" if t["role"] == "user" else "You replied"
|
|
433
|
+
blocks.append(f"[{who}]\n{t['text']}")
|
|
434
|
+
blocks.append("=== End of prior context; continue with the new instructions below ===")
|
|
435
|
+
return "\n\n".join(blocks)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _float(value: Any, default: float = 0.0) -> float:
|
|
439
|
+
try:
|
|
440
|
+
return float(value)
|
|
441
|
+
except (ValueError, TypeError):
|
|
442
|
+
return default
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _int(value: Any, default: int = 0) -> int:
|
|
446
|
+
try:
|
|
447
|
+
return int(value)
|
|
448
|
+
except (ValueError, TypeError):
|
|
449
|
+
return default
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@dataclass
|
|
453
|
+
class ClaudeCodeHarness:
|
|
454
|
+
"""Headless Claude Code (`claude -p`): the JSON output carries cost,
|
|
455
|
+
usage, session id, and stop reason.
|
|
456
|
+
|
|
457
|
+
`run` never raises: every failure comes back as an error SessionResult.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
api_key: str
|
|
461
|
+
binary: str = "claude"
|
|
462
|
+
model: str = "claude-opus-5"
|
|
463
|
+
max_turns: int = DEFAULT_MAX_TURNS
|
|
464
|
+
timeout_s: int = DEFAULT_TIMEOUT_S
|
|
465
|
+
# The working set for code + running the repo's own tests. Note the env
|
|
466
|
+
# DOES hold the session API key (the CLI needs it), so Bash here is a
|
|
467
|
+
# trusted-ish surface; the brief is the only untrusted-ish input and it
|
|
468
|
+
# passes the task-source gate upstream.
|
|
469
|
+
allowed_tools: tuple[str, ...] = ("Write", "Edit", "Read", "Glob", "Grep", "Bash")
|
|
470
|
+
extra_args: tuple[str, ...] = field(default_factory=tuple)
|
|
471
|
+
# --bare skips hooks, plugin sync, auto-memory, and CLAUDE.md
|
|
472
|
+
# auto-discovery, and restricts auth to ANTHROPIC_API_KEY. REQUIRED for
|
|
473
|
+
# judge sessions whose cwd contains an untrusted checkout: a PR-authored
|
|
474
|
+
# CLAUDE.md or .claude/settings.json (hooks run commands) must never load
|
|
475
|
+
# as instructions. Off for author sessions, where the target repo's own
|
|
476
|
+
# CLAUDE.md is useful contributor guidance.
|
|
477
|
+
bare: bool = False
|
|
478
|
+
# Apptainer image for session containment. When set,
|
|
479
|
+
# the session runs under `apptainer exec --containall --cleanenv`: no host
|
|
480
|
+
# $HOME, no host env, no same-user absolute paths — the session sees only
|
|
481
|
+
# the workspace, its per-run HOME, and the read-only claude binary. This
|
|
482
|
+
# closes the threat model's shared-filesystem residual risk. Images stay
|
|
483
|
+
# generic (python + uv + git); the binary is bind-mounted in.
|
|
484
|
+
container_image: str = ""
|
|
485
|
+
apptainer_binary: str = "apptainer"
|
|
486
|
+
# Claude-on-Vertex (ADC) instead of the Anthropic API key; the api_key is
|
|
487
|
+
# ignored when set. Contained sessions get the ADC file bind-mounted.
|
|
488
|
+
vertex: VertexConfig | None = None
|
|
489
|
+
|
|
490
|
+
CONTAINER_CLAUDE = "/opt/agent/claude"
|
|
491
|
+
CONTAINER_ADC = "/opt/agent/adc.json"
|
|
492
|
+
supports_resume = True # native --resume
|
|
493
|
+
|
|
494
|
+
def run(
|
|
495
|
+
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
|
|
496
|
+
) -> SessionResult:
|
|
497
|
+
# Both live OUTSIDE the git clone: the transcript must never enter the
|
|
498
|
+
# diff that gets committed/pushed, and the per-RUN home (0700; reused
|
|
499
|
+
# across this run's sessions, never across runs — the orchestrator
|
|
500
|
+
# gives every run a fresh workspace path) is what lets a later wake
|
|
501
|
+
# restore the agent's working context.
|
|
502
|
+
transcript_stem = f"{workspace.name}-session"
|
|
503
|
+
session_home = workspace.parent / f"{workspace.name}-home"
|
|
504
|
+
try:
|
|
505
|
+
session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
506
|
+
# mkdir's mode is umask-masked and ignored entirely on reuse;
|
|
507
|
+
# enforce it either way.
|
|
508
|
+
os.chmod(session_home, 0o700)
|
|
509
|
+
except OSError as exc:
|
|
510
|
+
log.warning("could not create session home %s: %s", session_home, exc)
|
|
511
|
+
return _error_result("workspace-error")
|
|
512
|
+
# A read-only session (no mutating tool) gets a permission mode that
|
|
513
|
+
# denies edits rather than auto-accepting them — defense in depth
|
|
514
|
+
# behind the tool allowlist, so a stray edit tool cannot auto-apply.
|
|
515
|
+
mutating = {"Write", "Edit", "Bash"}.intersection(self.allowed_tools)
|
|
516
|
+
permission_mode = "acceptEdits" if mutating else "default"
|
|
517
|
+
claude_argv = [
|
|
518
|
+
self.CONTAINER_CLAUDE if self.container_image else self.binary,
|
|
519
|
+
"-p",
|
|
520
|
+
# The brief travels on stdin: argv is world-readable via /proc on
|
|
521
|
+
# shared nodes, and briefs carry private research text.
|
|
522
|
+
"Follow the brief provided on stdin.",
|
|
523
|
+
"--model",
|
|
524
|
+
self.model,
|
|
525
|
+
"--output-format",
|
|
526
|
+
"json",
|
|
527
|
+
"--max-turns",
|
|
528
|
+
str(self.max_turns),
|
|
529
|
+
"--allowedTools",
|
|
530
|
+
",".join(self.allowed_tools),
|
|
531
|
+
"--permission-mode",
|
|
532
|
+
permission_mode,
|
|
533
|
+
*(["--bare"] if self.bare else []),
|
|
534
|
+
*self.extra_args,
|
|
535
|
+
]
|
|
536
|
+
if resume_session_id:
|
|
537
|
+
claude_argv += ["--resume", resume_session_id]
|
|
538
|
+
if self.container_image:
|
|
539
|
+
# bind sources must be absolute or apptainer fails at mount time
|
|
540
|
+
workspace = workspace.resolve()
|
|
541
|
+
session_home = session_home.resolve()
|
|
542
|
+
if not os.path.isabs(self.binary):
|
|
543
|
+
# a relative bind source fails at mount time deep inside
|
|
544
|
+
# apptainer; catch the misconfiguration here instead
|
|
545
|
+
log.warning("container sessions need an absolute claude path")
|
|
546
|
+
return _error_result("config-error")
|
|
547
|
+
command = [
|
|
548
|
+
self.apptainer_binary,
|
|
549
|
+
"exec",
|
|
550
|
+
"--containall",
|
|
551
|
+
"--cleanenv",
|
|
552
|
+
"--bind",
|
|
553
|
+
f"{workspace}:{workspace}",
|
|
554
|
+
# --home (NOT --env HOME=..., which apptainer silently
|
|
555
|
+
# refuses): mounts the per-run home at the same path inside
|
|
556
|
+
# and sets $HOME to it — native resume state survives
|
|
557
|
+
# contained/uncontained flips and lands on the shared FS,
|
|
558
|
+
# not a tmpfs that evaporates at session end.
|
|
559
|
+
"--home",
|
|
560
|
+
f"{session_home}:{session_home}",
|
|
561
|
+
"--bind",
|
|
562
|
+
f"{self.binary}:{self.CONTAINER_CLAUDE}:ro",
|
|
563
|
+
*(
|
|
564
|
+
["--bind", f"{Path(self.vertex.adc_file).resolve()}:{self.CONTAINER_ADC}:ro"]
|
|
565
|
+
if self.vertex is not None and self.vertex.adc_file
|
|
566
|
+
else []
|
|
567
|
+
),
|
|
568
|
+
"--pwd",
|
|
569
|
+
str(workspace),
|
|
570
|
+
self.container_image,
|
|
571
|
+
*claude_argv,
|
|
572
|
+
]
|
|
573
|
+
else:
|
|
574
|
+
command = claude_argv
|
|
575
|
+
try:
|
|
576
|
+
# start_new_session puts the CLI and every descendant (Bash-tool
|
|
577
|
+
# children included) in one process group we can kill as a unit —
|
|
578
|
+
# a timed-out session must not leave orphans holding the API key
|
|
579
|
+
# and writing into the clone.
|
|
580
|
+
if self.vertex is not None:
|
|
581
|
+
# vertex auth: ADC only — ANTHROPIC_API_KEY is deliberately
|
|
582
|
+
# absent so the CLI cannot fall back to direct billing
|
|
583
|
+
env = session_env("", "ANTHROPIC_API_KEY", session_home)
|
|
584
|
+
del env["ANTHROPIC_API_KEY"]
|
|
585
|
+
vertex_env = dict(self.vertex.env())
|
|
586
|
+
if self.container_image and self.vertex.adc_file:
|
|
587
|
+
vertex_env["GOOGLE_APPLICATION_CREDENTIALS"] = self.CONTAINER_ADC
|
|
588
|
+
env |= vertex_env
|
|
589
|
+
if self.container_image:
|
|
590
|
+
for k, v in vertex_env.items():
|
|
591
|
+
env[f"APPTAINERENV_{k}"] = v
|
|
592
|
+
else:
|
|
593
|
+
env = session_env(self.api_key, "ANTHROPIC_API_KEY", session_home)
|
|
594
|
+
if self.container_image:
|
|
595
|
+
# --cleanenv drops the host environment inside the container
|
|
596
|
+
# EXCEPT APPTAINERENV_* variables, which apptainer injects
|
|
597
|
+
# with the prefix stripped — the key travels via the
|
|
598
|
+
# environment, never argv (argv is world-readable in /proc).
|
|
599
|
+
env["APPTAINERENV_ANTHROPIC_API_KEY"] = self.api_key
|
|
600
|
+
process = subprocess.Popen(
|
|
601
|
+
command,
|
|
602
|
+
cwd=workspace,
|
|
603
|
+
env=env,
|
|
604
|
+
stdin=subprocess.PIPE,
|
|
605
|
+
stdout=subprocess.PIPE,
|
|
606
|
+
stderr=subprocess.PIPE,
|
|
607
|
+
text=True,
|
|
608
|
+
start_new_session=True,
|
|
609
|
+
)
|
|
610
|
+
except OSError as exc:
|
|
611
|
+
log.warning("could not spawn %s: %s", self.binary, exc)
|
|
612
|
+
return _error_result("spawn-error")
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
|
|
616
|
+
except subprocess.TimeoutExpired:
|
|
617
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
618
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
619
|
+
# Bounded drain: a descendant that left the process group (setsid)
|
|
620
|
+
# can hold the pipe open past the kill; run() must still return.
|
|
621
|
+
try:
|
|
622
|
+
stdout, _ = process.communicate(timeout=10)
|
|
623
|
+
except subprocess.TimeoutExpired:
|
|
624
|
+
process.kill()
|
|
625
|
+
stdout = ""
|
|
626
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
627
|
+
stdout, _ = process.communicate(timeout=5)
|
|
628
|
+
path = _write_private(
|
|
629
|
+
workspace.parent, transcript_stem, ".json", redact(stdout or "", (self.api_key,))
|
|
630
|
+
)
|
|
631
|
+
log.warning("session timed out after %ss in %s", self.timeout_s, workspace)
|
|
632
|
+
return _error_result(
|
|
633
|
+
"timeout",
|
|
634
|
+
path,
|
|
635
|
+
detail=f"session hit its {self.timeout_s}s walltime and was killed",
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
stdout = redact(stdout, (self.api_key,))
|
|
639
|
+
transcript_path = _write_private(workspace.parent, transcript_stem, ".json", stdout)
|
|
640
|
+
data = _parse_result(stdout)
|
|
641
|
+
if data is None:
|
|
642
|
+
stderr_tail = redact(stderr, (self.api_key,))[-500:]
|
|
643
|
+
log.warning("unparseable session output (exit %s): %s", process.returncode, stderr_tail)
|
|
644
|
+
return _error_result("unparseable-output", transcript_path)
|
|
645
|
+
# Field-level salvage: a quirky cost value must not cost us the
|
|
646
|
+
# session id (which resume depends on) or vice versa.
|
|
647
|
+
is_error = bool(data.get("is_error", process.returncode != 0))
|
|
648
|
+
subtype = str(data.get("subtype") or "")
|
|
649
|
+
errors = data.get("errors")
|
|
650
|
+
# capture a real backend cause in ANY form — a list of messages, or a
|
|
651
|
+
# non-list (dict/str) the CLI might return — so the lift guard below
|
|
652
|
+
# never mistakes a present cause for an absent one.
|
|
653
|
+
if isinstance(errors, list):
|
|
654
|
+
messages = "; ".join(str(e) for e in errors if e)
|
|
655
|
+
elif errors:
|
|
656
|
+
messages = str(errors)
|
|
657
|
+
else:
|
|
658
|
+
messages = ""
|
|
659
|
+
detail = f"{subtype}: {messages}" if subtype and messages else (subtype or messages)
|
|
660
|
+
final_text = str(data.get("result") or "")
|
|
661
|
+
# The CLI can flag is_error while stamping a content-free subtype
|
|
662
|
+
# ("success") and leaving the real cause only in `result` (is_error,
|
|
663
|
+
# subtype "success", result "API Error: 400 ... usage limits"). That
|
|
664
|
+
# machine "API Error ..." text (never agent prose) is the
|
|
665
|
+
# authoritative cause, so surface it as the detail: downstream notes,
|
|
666
|
+
# the operator log, and the outage latch (its classifier AND its
|
|
667
|
+
# throttle-duration check, which reads rate_limit/overloaded off the
|
|
668
|
+
# detail) all then see the real error instead of "success". Lift ONLY
|
|
669
|
+
# for the contradiction we have actually observed: an is_error whose
|
|
670
|
+
# subtype is empty or the self-contradictory "success", with no backend
|
|
671
|
+
# `messages`. Under those, `result` is machine error text, not agent
|
|
672
|
+
# prose. A REAL subtype (error_max_turns, error_during_execution, ...) is
|
|
673
|
+
# left alone: `result` there may be the agent's own closing message, and
|
|
674
|
+
# lifting a message that merely starts "API Error" would let agent prose
|
|
675
|
+
# trip the latch — a false outage pausing every lane is worse than the
|
|
676
|
+
# rare mis-scoped one.
|
|
677
|
+
if (
|
|
678
|
+
is_error
|
|
679
|
+
and not messages
|
|
680
|
+
and subtype in ("", "success")
|
|
681
|
+
and final_text.strip().casefold().startswith("api error")
|
|
682
|
+
):
|
|
683
|
+
detail = final_text.strip()
|
|
684
|
+
# bounded here so every downstream note/report/comment inherits it
|
|
685
|
+
detail = detail[:500]
|
|
686
|
+
return SessionResult(
|
|
687
|
+
stop_reason=str(data.get("stop_reason") or data.get("subtype") or "unknown"),
|
|
688
|
+
is_error=is_error,
|
|
689
|
+
error_detail=detail if is_error else "",
|
|
690
|
+
cost_usd=_float(data.get("total_cost_usd")),
|
|
691
|
+
num_turns=_int(data.get("num_turns")),
|
|
692
|
+
session_id=str(data.get("session_id") or ""),
|
|
693
|
+
final_text=final_text,
|
|
694
|
+
transcript_path=transcript_path,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _parse_result(stdout: str) -> dict[str, Any] | None:
|
|
699
|
+
"""The result object from the CLI's JSON output, or None.
|
|
700
|
+
|
|
701
|
+
When stdout is not one clean JSON object, prefer the FIRST candidate that
|
|
702
|
+
looks like the CLI's result (carries session_id/total_cost_usd): the CLI
|
|
703
|
+
prints its result before any stray output that follows it, so forward
|
|
704
|
+
order keeps a trailing look-alike from substituting its fields into
|
|
705
|
+
billing and resume.
|
|
706
|
+
"""
|
|
707
|
+
text = stdout.strip()
|
|
708
|
+
if not text:
|
|
709
|
+
return None
|
|
710
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
711
|
+
data = json.loads(text)
|
|
712
|
+
return data if isinstance(data, dict) else None
|
|
713
|
+
candidates: list[dict[str, Any]] = []
|
|
714
|
+
for chunk in (*text.splitlines(), text[text.find("{") : text.rfind("}") + 1]):
|
|
715
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
716
|
+
data = json.loads(chunk)
|
|
717
|
+
if isinstance(data, dict):
|
|
718
|
+
candidates.append(data)
|
|
719
|
+
for candidate in candidates:
|
|
720
|
+
if "session_id" in candidate or "total_cost_usd" in candidate:
|
|
721
|
+
return candidate
|
|
722
|
+
return candidates[0] if candidates else None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _codex_command(
|
|
726
|
+
binary: str,
|
|
727
|
+
model: str,
|
|
728
|
+
sandbox: str,
|
|
729
|
+
workspace: Path,
|
|
730
|
+
last_message_path: Path,
|
|
731
|
+
resume_session_id: str | None,
|
|
732
|
+
extra_args: tuple[str, ...],
|
|
733
|
+
) -> list[str]:
|
|
734
|
+
"""Argv for one headless `codex exec` run.
|
|
735
|
+
|
|
736
|
+
The prompt is NOT an argument: `codex exec` reads it from stdin when no
|
|
737
|
+
positional prompt is given, keeping the brief out of world-readable /proc
|
|
738
|
+
argv (the same rule as the Claude adapter). Flags verified against
|
|
739
|
+
codex-cli 0.130.0 (`codex exec[ resume] --help`): --json, --model,
|
|
740
|
+
--output-last-message, --skip-git-repo-check, and the stdin prompt behavior.
|
|
741
|
+
|
|
742
|
+
Fresh and resume take DIFFERENT flags. `codex exec` has `--sandbox` and
|
|
743
|
+
`--cd`; `codex exec resume <id>` has NEITHER (passing them is an argparse
|
|
744
|
+
error) — it restores the recorded session, INCLUDING that session's sandbox
|
|
745
|
+
and cwd (verified on codex-cli 0.130.0). A `danger-full-access` session
|
|
746
|
+
(every role: the deployment's container or ephemeral runner is the
|
|
747
|
+
boundary, not codex's own sandbox) adds
|
|
748
|
+
`--dangerously-bypass-approvals-and-sandbox` on resume — the sandbox is
|
|
749
|
+
inherited anyway, but the flag also skips approvals so a headless
|
|
750
|
+
write-heavy turn cannot stall.
|
|
751
|
+
"""
|
|
752
|
+
# --model is omitted when empty so codex uses its configured default; a
|
|
753
|
+
# wrong model id is a 404 ("Model not found"), so only pin a verified one.
|
|
754
|
+
model_flag = ["--model", model] if model else []
|
|
755
|
+
tail = [
|
|
756
|
+
"--output-last-message", # final message -> file (reliable final_text)
|
|
757
|
+
str(last_message_path),
|
|
758
|
+
"--skip-git-repo-check",
|
|
759
|
+
*extra_args,
|
|
760
|
+
]
|
|
761
|
+
if resume_session_id:
|
|
762
|
+
# no --sandbox/--cd on resume: the recorded session's sandbox is
|
|
763
|
+
# inherited; the bypass flag also skips approvals so a headless turn
|
|
764
|
+
# cannot stall waiting for one.
|
|
765
|
+
bypass = (
|
|
766
|
+
["--dangerously-bypass-approvals-and-sandbox"]
|
|
767
|
+
if sandbox == "danger-full-access"
|
|
768
|
+
else []
|
|
769
|
+
)
|
|
770
|
+
return [binary, "exec", "resume", resume_session_id, "--json", *model_flag, *bypass, *tail]
|
|
771
|
+
return [
|
|
772
|
+
binary,
|
|
773
|
+
"exec",
|
|
774
|
+
"--json", # JSONL events on stdout (session id, usage)
|
|
775
|
+
*model_flag,
|
|
776
|
+
"--sandbox",
|
|
777
|
+
sandbox, # "danger-full-access": the deployment's boundary, not codex's
|
|
778
|
+
"--cd",
|
|
779
|
+
str(workspace),
|
|
780
|
+
*tail,
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _parse_codex_result(
|
|
785
|
+
stdout: str, last_message: str, returncode: int, transcript_path: str = "", stderr: str = ""
|
|
786
|
+
) -> SessionResult:
|
|
787
|
+
"""Best-effort SessionResult from `codex exec --json` output.
|
|
788
|
+
|
|
789
|
+
`final_text` comes from the --output-last-message file, which is reliable.
|
|
790
|
+
`session_id` is the `thread.started` event's `thread_id`, verified against
|
|
791
|
+
codex-cli 0.130.0 (a `codex exec resume <thread_id>` recalls the session).
|
|
792
|
+
Cost is left at 0 (these backends are subscription or token metered; the
|
|
793
|
+
budget layer meters them by a session/token proxy). Never raises.
|
|
794
|
+
"""
|
|
795
|
+
# Event schema verified against codex-cli 0.130.0:
|
|
796
|
+
# thread.started -> thread_id (the session id)
|
|
797
|
+
# error -> message
|
|
798
|
+
# turn.failed -> error.message
|
|
799
|
+
# turn.completed -> success (usage carries tokens)
|
|
800
|
+
session_id = ""
|
|
801
|
+
saw_error = False
|
|
802
|
+
errors: list[str] = []
|
|
803
|
+
for line in stdout.splitlines():
|
|
804
|
+
text = line.strip()
|
|
805
|
+
if not text.startswith("{"):
|
|
806
|
+
continue # timestamped log lines interleave the JSONL; skip them
|
|
807
|
+
try:
|
|
808
|
+
event = json.loads(text)
|
|
809
|
+
except json.JSONDecodeError:
|
|
810
|
+
continue
|
|
811
|
+
if not isinstance(event, dict):
|
|
812
|
+
continue
|
|
813
|
+
etype = str(event.get("type", ""))
|
|
814
|
+
if etype == "thread.started" and not session_id:
|
|
815
|
+
thread_id = event.get("thread_id")
|
|
816
|
+
if isinstance(thread_id, str):
|
|
817
|
+
session_id = thread_id
|
|
818
|
+
elif etype == "error":
|
|
819
|
+
saw_error = True
|
|
820
|
+
message = event.get("message")
|
|
821
|
+
if isinstance(message, str):
|
|
822
|
+
errors.append(message)
|
|
823
|
+
elif etype == "turn.failed":
|
|
824
|
+
saw_error = True
|
|
825
|
+
err = event.get("error")
|
|
826
|
+
message = err.get("message") if isinstance(err, dict) else None
|
|
827
|
+
if isinstance(message, str):
|
|
828
|
+
errors.append(message)
|
|
829
|
+
is_error = returncode != 0 or saw_error
|
|
830
|
+
detail = "; ".join(errors)[:500]
|
|
831
|
+
# Fall back to stderr so a failed run (e.g. a bad flag, no matching event)
|
|
832
|
+
# carries some cause instead of an empty detail.
|
|
833
|
+
if is_error and not detail and stderr.strip():
|
|
834
|
+
detail = stderr.strip()[-500:]
|
|
835
|
+
return SessionResult(
|
|
836
|
+
stop_reason="error" if is_error else "completed",
|
|
837
|
+
is_error=is_error,
|
|
838
|
+
error_detail=detail if is_error else "",
|
|
839
|
+
cost_usd=0.0,
|
|
840
|
+
num_turns=0,
|
|
841
|
+
session_id=session_id,
|
|
842
|
+
final_text=last_message.strip(),
|
|
843
|
+
transcript_path=transcript_path,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@dataclass
|
|
848
|
+
class CodexHarness:
|
|
849
|
+
"""Headless OpenAI Codex CLI (`codex exec`) — a second Harness backend.
|
|
850
|
+
|
|
851
|
+
Stage 1's swappability proof (docs/design/consolidation.md). CLI flags AND
|
|
852
|
+
headless resume are verified against codex-cli 0.130.0 (`session_id` = the
|
|
853
|
+
`thread.started` `thread_id`; resume recalls it); cost parsing stays
|
|
854
|
+
best-effort (these backends are metered by a session/token proxy in the
|
|
855
|
+
budget layer).
|
|
856
|
+
|
|
857
|
+
CONTAINED mode (`container_image` set): both `codex login` and `codex exec`
|
|
858
|
+
run inside `apptainer exec --containall --cleanenv`, sharing a bound
|
|
859
|
+
`--home` so the login's auth.json is visible to the exec. apptainer is the
|
|
860
|
+
boundary — no host FS beyond the two binds; `--cleanenv` scrubs the env
|
|
861
|
+
down to the role's own key, so the process tree carries no foreign token
|
|
862
|
+
for a /proc read to lift (the boundary is the scrubbed env, not PID
|
|
863
|
+
isolation) — the same posture as the Claude backend. `--sandbox
|
|
864
|
+
danger-full-access` uniformly: codex's own sandbox (`workspace-write`)
|
|
865
|
+
needs bubblewrap, absent in the image and unreliable nested in apptainer,
|
|
866
|
+
and the deployment's boundary (this container, or the ephemeral runner in
|
|
867
|
+
the uncontained case) already confines the session. The host codex binary
|
|
868
|
+
is bind-mounted read-only into the container (like claude), so the image
|
|
869
|
+
stays codex-free and codex updates by swapping one host binary.
|
|
870
|
+
|
|
871
|
+
`run` never raises: every failure comes back as an error SessionResult.
|
|
872
|
+
"""
|
|
873
|
+
|
|
874
|
+
api_key: str
|
|
875
|
+
binary: str = "codex"
|
|
876
|
+
# empty -> codex's configured default (a wrong id 404s); pin only a verified one
|
|
877
|
+
model: str = ""
|
|
878
|
+
# the deployment's container or ephemeral runner is the boundary; codex's own
|
|
879
|
+
# sandbox stays off (it needs bubblewrap — absent/unreliable in apptainer)
|
|
880
|
+
sandbox: str = "danger-full-access"
|
|
881
|
+
timeout_s: int = DEFAULT_TIMEOUT_S
|
|
882
|
+
extra_args: tuple[str, ...] = field(default_factory=tuple)
|
|
883
|
+
# when set, login+exec run inside this apptainer image; empty = uncontained
|
|
884
|
+
# (the deployment's runner is the boundary)
|
|
885
|
+
container_image: str = ""
|
|
886
|
+
apptainer_binary: str = "apptainer"
|
|
887
|
+
# in-container path the host codex binary is bound to (bind-from-host, like
|
|
888
|
+
# ClaudeCodeHarness.CONTAINER_CLAUDE — the image stays codex-free, and codex
|
|
889
|
+
# is updated by swapping one host binary, no rebuild). A bare class attribute
|
|
890
|
+
# (no annotation) so the dataclass does not treat it as a field.
|
|
891
|
+
CONTAINER_CODEX = "/opt/agent/codex"
|
|
892
|
+
# Headless resume is validated on codex-cli 0.130.0: a contained
|
|
893
|
+
# `codex exec resume <thread_id>` recalls prior-turn context (the session id
|
|
894
|
+
# is the `thread.started` event's `thread_id`, restored from the bound
|
|
895
|
+
# --home). So the revise/wake/followup loops resume codex like Claude.
|
|
896
|
+
supports_resume = True
|
|
897
|
+
|
|
898
|
+
def _apptainer_argv(
|
|
899
|
+
self, inner: list[str], session_home: Path, workspace: Path | None
|
|
900
|
+
) -> list[str]:
|
|
901
|
+
"""Wrap a codex argv in `apptainer exec --containall --cleanenv`. The
|
|
902
|
+
run-home is bound via `--home` (auth.json survives login->exec and lands
|
|
903
|
+
on the shared FS); the host codex binary is bind-mounted read-only (like
|
|
904
|
+
the claude author); the workspace is bound and set as --pwd only for the
|
|
905
|
+
exec, never the login."""
|
|
906
|
+
argv = [
|
|
907
|
+
self.apptainer_binary,
|
|
908
|
+
"exec",
|
|
909
|
+
"--containall",
|
|
910
|
+
"--cleanenv",
|
|
911
|
+
"--home",
|
|
912
|
+
f"{session_home}:{session_home}",
|
|
913
|
+
"--bind",
|
|
914
|
+
f"{self.binary}:{self.CONTAINER_CODEX}:ro",
|
|
915
|
+
]
|
|
916
|
+
if workspace is not None:
|
|
917
|
+
argv += ["--bind", f"{workspace}:{workspace}", "--pwd", str(workspace)]
|
|
918
|
+
return [*argv, self.container_image, *inner]
|
|
919
|
+
|
|
920
|
+
def _login(self, session_home: Path) -> SessionResult | None:
|
|
921
|
+
"""Write auth.json into the per-run HOME. None on success, an error
|
|
922
|
+
SessionResult on failure. The key travels on stdin, never argv. In
|
|
923
|
+
AUTHOR mode the login runs inside apptainer with the same bound --home,
|
|
924
|
+
so auth.json lands where the contained exec will read it."""
|
|
925
|
+
env = session_env(self.api_key, "OPENAI_API_KEY", session_home)
|
|
926
|
+
if self.container_image:
|
|
927
|
+
# --cleanenv drops host env inside the container except APPTAINERENV_*
|
|
928
|
+
# (prefix stripped by apptainer): the key travels via env, not argv.
|
|
929
|
+
env["APPTAINERENV_OPENAI_API_KEY"] = self.api_key
|
|
930
|
+
login_argv = self._apptainer_argv(
|
|
931
|
+
[self.CONTAINER_CODEX, "login", "--with-api-key"], session_home, None
|
|
932
|
+
)
|
|
933
|
+
else:
|
|
934
|
+
login_argv = [self.binary, "login", "--with-api-key"]
|
|
935
|
+
try:
|
|
936
|
+
proc = subprocess.run(
|
|
937
|
+
login_argv,
|
|
938
|
+
input=self.api_key,
|
|
939
|
+
cwd=session_home,
|
|
940
|
+
env=env,
|
|
941
|
+
capture_output=True,
|
|
942
|
+
text=True,
|
|
943
|
+
timeout=60,
|
|
944
|
+
)
|
|
945
|
+
except OSError as exc:
|
|
946
|
+
log.warning("could not spawn codex login: %s", exc)
|
|
947
|
+
return _error_result("codex-login-error", detail=str(exc)[:200])
|
|
948
|
+
except subprocess.TimeoutExpired:
|
|
949
|
+
return _error_result("codex-login-error", detail="codex login timed out")
|
|
950
|
+
if proc.returncode != 0:
|
|
951
|
+
detail = redact((proc.stderr or proc.stdout or "").strip(), (self.api_key,))[-300:]
|
|
952
|
+
log.warning("codex login failed: %s", detail)
|
|
953
|
+
return _error_result("codex-login-error", detail=detail or "codex login failed")
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
def _purge_auth(self, session_home: Path) -> None:
|
|
957
|
+
"""Delete the API key that `codex login` wrote to auth.json, so it does
|
|
958
|
+
not persist on disk past the session."""
|
|
959
|
+
with contextlib.suppress(OSError):
|
|
960
|
+
(session_home / ".codex" / "auth.json").unlink()
|
|
961
|
+
|
|
962
|
+
def run(
|
|
963
|
+
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
|
|
964
|
+
) -> SessionResult:
|
|
965
|
+
# Contained runs bind workspace + home into apptainer, whose bind sources
|
|
966
|
+
# must be ABSOLUTE (a relative one fails at mount time); resolving here
|
|
967
|
+
# also keeps codex's --cd / --output-last-message aligned with the mount
|
|
968
|
+
# points inside the container.
|
|
969
|
+
if self.container_image:
|
|
970
|
+
workspace = workspace.resolve()
|
|
971
|
+
if not os.path.isabs(self.binary):
|
|
972
|
+
# the host codex is bind-mounted; a relative bind source fails at
|
|
973
|
+
# mount time deep inside apptainer — catch it here instead
|
|
974
|
+
log.warning("contained codex needs an absolute binary path")
|
|
975
|
+
return _error_result("config-error")
|
|
976
|
+
transcript_stem = f"{workspace.name}-codex"
|
|
977
|
+
session_home = workspace.parent / f"{workspace.name}-home"
|
|
978
|
+
try:
|
|
979
|
+
session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
980
|
+
os.chmod(session_home, 0o700)
|
|
981
|
+
except OSError as exc:
|
|
982
|
+
log.warning("could not create session home %s: %s", session_home, exc)
|
|
983
|
+
return _error_result("workspace-error")
|
|
984
|
+
# Codex leaks temp directories into .codex/.tmp and fails to remove
|
|
985
|
+
# them (the "stale arg0 temp dirs: Directory not empty" aborts); across
|
|
986
|
+
# a run's wakes they pile up into tens of thousands of files, the bulk
|
|
987
|
+
# of the per-run home. Clear codex's scratch before each run — its
|
|
988
|
+
# durable state (auth.json, sessions, the sqlite) is elsewhere under
|
|
989
|
+
# .codex and untouched. A prior session owns this home, so resolve every
|
|
990
|
+
# component it can write — the run home and .codex — with O_NOFOLLOW,
|
|
991
|
+
# anchored on the run directory the orchestrator owns (a contained
|
|
992
|
+
# session's binds expose only the run home and workspace, never their
|
|
993
|
+
# parent; an uncontained session runs as a plain host process, so
|
|
994
|
+
# guarding these two components is the boundary either way). A swap of
|
|
995
|
+
# ws-home or .codex for a symlink then cannot divert the delete out of
|
|
996
|
+
# the run home. Best-effort — this must never abort the run it precedes
|
|
997
|
+
# (the contract is to return a SessionResult, not raise), so an
|
|
998
|
+
# adversarially deep tree's RecursionError or any other error is caught.
|
|
999
|
+
try:
|
|
1000
|
+
run_fd = os.open(session_home.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
1001
|
+
except OSError:
|
|
1002
|
+
run_fd = -1
|
|
1003
|
+
if run_fd >= 0:
|
|
1004
|
+
home_fd = _open_nofollow_dir(session_home.name, run_fd)
|
|
1005
|
+
os.close(run_fd)
|
|
1006
|
+
if home_fd >= 0:
|
|
1007
|
+
codex_fd = _open_nofollow_dir(".codex", home_fd)
|
|
1008
|
+
if codex_fd >= 0:
|
|
1009
|
+
try:
|
|
1010
|
+
for scratch in (".tmp", "tmp"):
|
|
1011
|
+
_rmtree_at(codex_fd, scratch)
|
|
1012
|
+
except Exception as exc:
|
|
1013
|
+
log.warning("codex scratch cleanup skipped: %s", exc)
|
|
1014
|
+
finally:
|
|
1015
|
+
os.close(codex_fd)
|
|
1016
|
+
os.close(home_fd)
|
|
1017
|
+
# Codex authenticates from ~/.codex/auth.json, not OPENAI_API_KEY alone
|
|
1018
|
+
# (the responses endpoint 401s on env-only). Write auth.json
|
|
1019
|
+
# into the scrubbed per-run HOME with `codex login --with-api-key`
|
|
1020
|
+
# (key on stdin, never argv) before exec.
|
|
1021
|
+
login_error = self._login(session_home)
|
|
1022
|
+
if login_error is not None:
|
|
1023
|
+
return login_error
|
|
1024
|
+
# --output-last-message target lives inside the per-run home (0700),
|
|
1025
|
+
# not the shared parent, so model output is not exposed there while
|
|
1026
|
+
# codex is writing it; cleared first so a stale file can never be read
|
|
1027
|
+
# as this run's result, and deleted again after reading.
|
|
1028
|
+
last_message_path = session_home / "codex-last-message.txt"
|
|
1029
|
+
with contextlib.suppress(OSError):
|
|
1030
|
+
last_message_path.unlink()
|
|
1031
|
+
# contained runs invoke the image's codex (on PATH); uncontained the
|
|
1032
|
+
# host binary. The exec binds the workspace and sets it as --pwd.
|
|
1033
|
+
codex_argv = _codex_command(
|
|
1034
|
+
self.CONTAINER_CODEX if self.container_image else self.binary,
|
|
1035
|
+
self.model,
|
|
1036
|
+
self.sandbox,
|
|
1037
|
+
workspace,
|
|
1038
|
+
last_message_path,
|
|
1039
|
+
resume_session_id,
|
|
1040
|
+
self.extra_args,
|
|
1041
|
+
)
|
|
1042
|
+
command = (
|
|
1043
|
+
self._apptainer_argv(codex_argv, session_home, workspace)
|
|
1044
|
+
if self.container_image
|
|
1045
|
+
else codex_argv
|
|
1046
|
+
)
|
|
1047
|
+
try:
|
|
1048
|
+
env = session_env(self.api_key, "OPENAI_API_KEY", session_home)
|
|
1049
|
+
if self.container_image:
|
|
1050
|
+
env["APPTAINERENV_OPENAI_API_KEY"] = self.api_key
|
|
1051
|
+
process = subprocess.Popen(
|
|
1052
|
+
command,
|
|
1053
|
+
cwd=workspace,
|
|
1054
|
+
env=env,
|
|
1055
|
+
stdin=subprocess.PIPE,
|
|
1056
|
+
stdout=subprocess.PIPE,
|
|
1057
|
+
stderr=subprocess.PIPE,
|
|
1058
|
+
text=True,
|
|
1059
|
+
start_new_session=True,
|
|
1060
|
+
)
|
|
1061
|
+
except OSError as exc:
|
|
1062
|
+
log.warning("could not spawn %s: %s", self.binary, exc)
|
|
1063
|
+
self._purge_auth(session_home)
|
|
1064
|
+
return _error_result("spawn-error")
|
|
1065
|
+
try:
|
|
1066
|
+
stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
|
|
1067
|
+
except subprocess.TimeoutExpired:
|
|
1068
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1069
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
1070
|
+
try:
|
|
1071
|
+
stdout, _ = process.communicate(timeout=10)
|
|
1072
|
+
except subprocess.TimeoutExpired:
|
|
1073
|
+
process.kill()
|
|
1074
|
+
stdout = ""
|
|
1075
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
1076
|
+
stdout, _ = process.communicate(timeout=5)
|
|
1077
|
+
path = _write_private(
|
|
1078
|
+
workspace.parent, transcript_stem, ".jsonl", redact(stdout or "", (self.api_key,))
|
|
1079
|
+
)
|
|
1080
|
+
with contextlib.suppress(OSError):
|
|
1081
|
+
last_message_path.unlink() # clean up on the timeout path too
|
|
1082
|
+
self._purge_auth(session_home)
|
|
1083
|
+
log.warning("codex session timed out after %ss in %s", self.timeout_s, workspace)
|
|
1084
|
+
return _error_result(
|
|
1085
|
+
"timeout",
|
|
1086
|
+
path,
|
|
1087
|
+
detail=f"session hit its {self.timeout_s}s walltime and was killed",
|
|
1088
|
+
)
|
|
1089
|
+
stdout = redact(stdout, (self.api_key,))
|
|
1090
|
+
transcript_path = _write_private(workspace.parent, transcript_stem, ".jsonl", stdout)
|
|
1091
|
+
last_message = ""
|
|
1092
|
+
# errors="replace": a non-UTF-8 last-message file must not raise
|
|
1093
|
+
# UnicodeDecodeError (not an OSError) and break the never-raises contract.
|
|
1094
|
+
with contextlib.suppress(OSError):
|
|
1095
|
+
last_message = redact(last_message_path.read_text(errors="replace"), (self.api_key,))
|
|
1096
|
+
# The final message is preserved in the 0600 transcript; drop the raw
|
|
1097
|
+
# file so model output is not left behind.
|
|
1098
|
+
with contextlib.suppress(OSError):
|
|
1099
|
+
last_message_path.unlink()
|
|
1100
|
+
self._purge_auth(session_home)
|
|
1101
|
+
return _parse_codex_result(
|
|
1102
|
+
stdout,
|
|
1103
|
+
last_message,
|
|
1104
|
+
process.returncode,
|
|
1105
|
+
transcript_path,
|
|
1106
|
+
stderr=redact(stderr, (self.api_key,)),
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _hermes_command(
|
|
1111
|
+
repo_dir: Path,
|
|
1112
|
+
query: str,
|
|
1113
|
+
model: str,
|
|
1114
|
+
base_url: str,
|
|
1115
|
+
max_turns: int,
|
|
1116
|
+
enabled_toolsets: tuple[str, ...],
|
|
1117
|
+
disabled_toolsets: tuple[str, ...],
|
|
1118
|
+
extra_args: tuple[str, ...],
|
|
1119
|
+
) -> list[str]:
|
|
1120
|
+
"""Argv for one headless hermes run (`run_agent.py`, fire-style flags,
|
|
1121
|
+
hermes-agent v0.20.1).
|
|
1122
|
+
|
|
1123
|
+
The BRIEF is never in argv — it is written to a file and `query` is only a
|
|
1124
|
+
short pointer instruction. The API key is never in argv either: hermes
|
|
1125
|
+
reads it from the env var its SEEDED PROVIDER's registry names (the
|
|
1126
|
+
harness exports it under key_env; openrouter reads OPENROUTER_API_KEY,
|
|
1127
|
+
openai-api reads OPENAI_API_KEY). --save_sample makes hermes write a JSON
|
|
1128
|
+
trajectory to its cwd, which is the machine-readable result channel."""
|
|
1129
|
+
argv = [
|
|
1130
|
+
"uv",
|
|
1131
|
+
"run",
|
|
1132
|
+
"--project",
|
|
1133
|
+
str(repo_dir),
|
|
1134
|
+
"python",
|
|
1135
|
+
str(repo_dir / "run_agent.py"),
|
|
1136
|
+
f"--query={query}",
|
|
1137
|
+
f"--max_turns={max_turns}",
|
|
1138
|
+
"--save_sample",
|
|
1139
|
+
]
|
|
1140
|
+
if model:
|
|
1141
|
+
argv.append(f"--model={model}")
|
|
1142
|
+
if base_url:
|
|
1143
|
+
argv.append(f"--base_url={base_url}")
|
|
1144
|
+
# Embedded quotes are load-bearing: fire literal-evals flag values, so a
|
|
1145
|
+
# bare `a,b` becomes a Python TUPLE and hermes's .split(",") crashes.
|
|
1146
|
+
# `"a,b"` evals to the string hermes expects.
|
|
1147
|
+
if enabled_toolsets:
|
|
1148
|
+
argv.append(f'--enabled_toolsets="{",".join(enabled_toolsets)}"')
|
|
1149
|
+
if disabled_toolsets:
|
|
1150
|
+
argv.append(f'--disabled_toolsets="{",".join(disabled_toolsets)}"')
|
|
1151
|
+
return [*argv, *extra_args]
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _parse_hermes_result(
|
|
1155
|
+
stdout: str, sample: Any, returncode: int, transcript_path: str = ""
|
|
1156
|
+
) -> SessionResult:
|
|
1157
|
+
"""Best-effort SessionResult from a hermes run.
|
|
1158
|
+
|
|
1159
|
+
Prefers the --save_sample trajectory JSON (assistant messages + turn
|
|
1160
|
+
count); falls back to raw stdout as the final text. Cost is left at 0
|
|
1161
|
+
(metered by the budget layer's proxy). Never raises."""
|
|
1162
|
+
final_text = ""
|
|
1163
|
+
num_turns = 0
|
|
1164
|
+
# hermes saves ShareGPT-format trajectories ({"from": "gpt", "value": ...}),
|
|
1165
|
+
# either as a bare list or wrapped in a dict; accept role/content too.
|
|
1166
|
+
messages: list[Any] = []
|
|
1167
|
+
if isinstance(sample, list):
|
|
1168
|
+
messages = sample
|
|
1169
|
+
elif isinstance(sample, dict):
|
|
1170
|
+
# run_agent.py --save_sample wraps the ShareGPT turns under
|
|
1171
|
+
# "conversations" (hermes v0.20.1); accept "messages"/"trajectory"
|
|
1172
|
+
# too for other paths. Missing this key makes num_turns==0 and
|
|
1173
|
+
# drops a real verdict as a bogus error.
|
|
1174
|
+
wrapped = (
|
|
1175
|
+
sample.get("conversations") or sample.get("messages") or sample.get("trajectory") or []
|
|
1176
|
+
)
|
|
1177
|
+
messages = wrapped if isinstance(wrapped, list) else []
|
|
1178
|
+
assistant = [
|
|
1179
|
+
m
|
|
1180
|
+
for m in messages
|
|
1181
|
+
if isinstance(m, dict)
|
|
1182
|
+
and (m.get("role") == "assistant" or m.get("from") == "gpt")
|
|
1183
|
+
and (m.get("content") or m.get("value"))
|
|
1184
|
+
]
|
|
1185
|
+
num_turns = len(assistant)
|
|
1186
|
+
if assistant:
|
|
1187
|
+
content = assistant[-1].get("content") or assistant[-1].get("value")
|
|
1188
|
+
final_text = content if isinstance(content, str) else str(content)
|
|
1189
|
+
if not final_text:
|
|
1190
|
+
final_text = stdout.strip()[-20_000:]
|
|
1191
|
+
# hermes exits 0 even when every API call failed; a run with no
|
|
1192
|
+
# assistant output is a failure, not a report
|
|
1193
|
+
is_error = returncode != 0 or num_turns == 0
|
|
1194
|
+
return SessionResult(
|
|
1195
|
+
stop_reason="error" if is_error else "completed",
|
|
1196
|
+
is_error=is_error,
|
|
1197
|
+
error_detail=stdout.strip()[-500:] if is_error else "",
|
|
1198
|
+
cost_usd=0.0,
|
|
1199
|
+
num_turns=num_turns,
|
|
1200
|
+
session_id="", # HermesHarness.run injects the resume id (saved-transcript seam)
|
|
1201
|
+
final_text=final_text,
|
|
1202
|
+
transcript_path=transcript_path,
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
@dataclass
|
|
1207
|
+
class HermesHarness:
|
|
1208
|
+
"""Headless hermes-agent (Nous Research, MIT) — the OSS backend behind the
|
|
1209
|
+
Harness seam, driven via `uv run <repo>/run_agent.py` from a pinned clone.
|
|
1210
|
+
|
|
1211
|
+
A first-class, interchangeable backend: like claude and codex, its boundary
|
|
1212
|
+
is the deployment's (a container where one exists, the ephemeral runner
|
|
1213
|
+
where one doesn't) plus the tokenless split — the session is spawned with a
|
|
1214
|
+
scrubbed environment and no credential beyond its own key, so its `file`
|
|
1215
|
+
toolset's arbitrary-path reads (including /proc) find nothing to lift, and
|
|
1216
|
+
its writes are confined to the disposable workspace. Roles differ by
|
|
1217
|
+
prompt, never by a hermes-specific posture.
|
|
1218
|
+
Instruction-file surface: hermes auto-loads AGENTS.md from the workspace,
|
|
1219
|
+
which sanitize_checkout already neutralizes in untrusted trees.
|
|
1220
|
+
|
|
1221
|
+
RESUME: hermes has no `--resume` flag, but it reads its brief from a file, so
|
|
1222
|
+
a resume rehydrates the prior conversation into the next brief (the harness
|
|
1223
|
+
keeps its own transcript in the per-run home). A resumed session therefore
|
|
1224
|
+
carries its full prior context — it cannot silently start blind — so the
|
|
1225
|
+
revise/wake loops resume hermes like the other backends.
|
|
1226
|
+
|
|
1227
|
+
`run` never raises: every failure comes back as an error SessionResult.
|
|
1228
|
+
"""
|
|
1229
|
+
|
|
1230
|
+
supports_resume = True # via saved-transcript rehydration (no native flag)
|
|
1231
|
+
|
|
1232
|
+
api_key: str # exported via key_env (never argv)
|
|
1233
|
+
repo_dir: Path # pinned hermes-agent checkout
|
|
1234
|
+
# hermes resolves credentials from provider-specific env vars (a registry:
|
|
1235
|
+
# OPENROUTER_API_KEY for the OpenRouter default, ANTHROPIC_API_KEY for the
|
|
1236
|
+
# direct anthropic provider, ...); name the one matching the provider
|
|
1237
|
+
key_env: str = "OPENROUTER_API_KEY"
|
|
1238
|
+
# hermes needs a provider in ~/.hermes/config.yaml (a scrubbed HOME has
|
|
1239
|
+
# none, and it refuses to run "unconfigured"); when set, the harness
|
|
1240
|
+
# pre-seeds a minimal config in the per-run home
|
|
1241
|
+
provider: str = ""
|
|
1242
|
+
# approvals.deny: fnmatch globs hermes refuses before any yolo/mode-off
|
|
1243
|
+
# bypass (headless: a clean deny, never a hang). NOTE it matches SHELL
|
|
1244
|
+
# COMMANDS (the terminal tool), NOT the write_file/patch tool calls.
|
|
1245
|
+
# Useful to hardline-forbid specific dangerous commands.
|
|
1246
|
+
approvals_deny: tuple[str, ...] = ()
|
|
1247
|
+
model: str = "" # OpenRouter format (provider/model); empty -> hermes default
|
|
1248
|
+
base_url: str = "" # empty -> the seeded provider's own endpoint
|
|
1249
|
+
max_turns: int = DEFAULT_MAX_TURNS
|
|
1250
|
+
timeout_s: int = DEFAULT_TIMEOUT_S
|
|
1251
|
+
enabled_toolsets: tuple[str, ...] = ("file",)
|
|
1252
|
+
disabled_toolsets: tuple[str, ...] = (
|
|
1253
|
+
"terminal",
|
|
1254
|
+
"web",
|
|
1255
|
+
"search",
|
|
1256
|
+
"browser",
|
|
1257
|
+
"computer_use",
|
|
1258
|
+
"code_execution",
|
|
1259
|
+
"delegation",
|
|
1260
|
+
"cronjob",
|
|
1261
|
+
"skills",
|
|
1262
|
+
"memory",
|
|
1263
|
+
)
|
|
1264
|
+
extra_args: tuple[str, ...] = field(default_factory=tuple)
|
|
1265
|
+
# Apptainer image for session containment, same stance as the other
|
|
1266
|
+
# backends: when set, the session runs under `apptainer exec --containall
|
|
1267
|
+
# --cleanenv` seeing only the workspace, the per-run home, and a read-only
|
|
1268
|
+
# bind of the pinned hermes repo. The project venv and uv cache live in
|
|
1269
|
+
# the per-run home (UV_PROJECT_ENVIRONMENT/UV_CACHE_DIR), so the repo
|
|
1270
|
+
# bind stays read-only and nothing survives across runs.
|
|
1271
|
+
container_image: str = ""
|
|
1272
|
+
apptainer_binary: str = "apptainer"
|
|
1273
|
+
|
|
1274
|
+
def run(
|
|
1275
|
+
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
|
|
1276
|
+
) -> SessionResult:
|
|
1277
|
+
transcript_stem = f"{workspace.name}-hermes"
|
|
1278
|
+
session_home = workspace.parent / f"{workspace.name}-home"
|
|
1279
|
+
try:
|
|
1280
|
+
session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1281
|
+
os.chmod(session_home, 0o700)
|
|
1282
|
+
except OSError as exc:
|
|
1283
|
+
log.warning("could not create session home %s: %s", session_home, exc)
|
|
1284
|
+
return _error_result("workspace-error")
|
|
1285
|
+
# Resume: rehydrate the prior conversation into this brief. A missing
|
|
1286
|
+
# transcript is a hard error, NOT a blind fresh start (the deployment
|
|
1287
|
+
# must preserve the per-run home for resume, same as claude's $HOME /
|
|
1288
|
+
# codex's --home). A fresh run mints a session id to persist under.
|
|
1289
|
+
session_id = resume_session_id or uuid.uuid4().hex[:16]
|
|
1290
|
+
prior_turns: list[dict[str, str]] = []
|
|
1291
|
+
brief_to_send = brief_text
|
|
1292
|
+
if resume_session_id:
|
|
1293
|
+
loaded = _load_resume_transcript(session_home, resume_session_id)
|
|
1294
|
+
if loaded is None:
|
|
1295
|
+
log.warning("hermes resume %s: no saved transcript in %s", session_id, session_home)
|
|
1296
|
+
return _error_result(
|
|
1297
|
+
"resume-unavailable",
|
|
1298
|
+
detail="no saved transcript to restore this session's context",
|
|
1299
|
+
)
|
|
1300
|
+
prior_turns = loaded
|
|
1301
|
+
brief_to_send = f"{_render_resume_transcript(prior_turns)}\n\n{brief_text}"
|
|
1302
|
+
if self.provider:
|
|
1303
|
+
# minimal headless config: provider + default model, nothing else
|
|
1304
|
+
hermes_dir = session_home / ".hermes"
|
|
1305
|
+
try:
|
|
1306
|
+
hermes_dir.mkdir(mode=0o700, exist_ok=True)
|
|
1307
|
+
config_lines = ["model:\n", f' provider: "{self.provider}"\n']
|
|
1308
|
+
if self.model:
|
|
1309
|
+
config_lines.insert(1, f' default: "{self.model}"\n')
|
|
1310
|
+
if self.approvals_deny:
|
|
1311
|
+
config_lines.append("approvals:\n deny:\n")
|
|
1312
|
+
config_lines += [f' - "{glob}"\n' for glob in self.approvals_deny]
|
|
1313
|
+
if not _write_private_fixed(hermes_dir / "config.yaml", "".join(config_lines)):
|
|
1314
|
+
raise OSError("hermes config write refused (symlink?) or failed")
|
|
1315
|
+
except OSError as exc:
|
|
1316
|
+
log.warning("could not seed hermes config: %s", exc)
|
|
1317
|
+
return _error_result("workspace-error", detail="could not seed hermes config")
|
|
1318
|
+
# The brief travels via a 0600 file (argv is world-readable in /proc);
|
|
1319
|
+
# the --query is only a fixed pointer to it. On resume this file carries
|
|
1320
|
+
# the rehydrated prior context ahead of the new instructions.
|
|
1321
|
+
brief_path = Path(_write_private(session_home, "brief", ".md", brief_to_send))
|
|
1322
|
+
if not brief_path.name:
|
|
1323
|
+
return _error_result("workspace-error", detail="could not store the brief")
|
|
1324
|
+
query = (
|
|
1325
|
+
f"Read the file {brief_path} and follow it as your complete brief. "
|
|
1326
|
+
f"The tree to work on is at {workspace.resolve()}."
|
|
1327
|
+
)
|
|
1328
|
+
command = _hermes_command(
|
|
1329
|
+
Path(self.repo_dir),
|
|
1330
|
+
query,
|
|
1331
|
+
self.model,
|
|
1332
|
+
self.base_url,
|
|
1333
|
+
self.max_turns,
|
|
1334
|
+
self.enabled_toolsets,
|
|
1335
|
+
self.disabled_toolsets,
|
|
1336
|
+
self.extra_args,
|
|
1337
|
+
)
|
|
1338
|
+
if self.container_image:
|
|
1339
|
+
workspace_abs = workspace.resolve()
|
|
1340
|
+
home_abs = session_home.resolve()
|
|
1341
|
+
repo_abs = Path(self.repo_dir).resolve()
|
|
1342
|
+
command = [
|
|
1343
|
+
self.apptainer_binary,
|
|
1344
|
+
"exec",
|
|
1345
|
+
"--containall",
|
|
1346
|
+
"--cleanenv",
|
|
1347
|
+
"--bind",
|
|
1348
|
+
f"{workspace_abs}:{workspace_abs}",
|
|
1349
|
+
# --home mounts the per-run home at the same path inside and
|
|
1350
|
+
# sets $HOME to it — hermes config, brief, samples, and the
|
|
1351
|
+
# resume transcript all persist on the shared FS
|
|
1352
|
+
"--home",
|
|
1353
|
+
f"{home_abs}:{home_abs}",
|
|
1354
|
+
"--bind",
|
|
1355
|
+
f"{repo_abs}:{repo_abs}:ro",
|
|
1356
|
+
"--pwd",
|
|
1357
|
+
str(home_abs),
|
|
1358
|
+
self.container_image,
|
|
1359
|
+
*command,
|
|
1360
|
+
]
|
|
1361
|
+
try:
|
|
1362
|
+
env = session_env(self.api_key, self.key_env, session_home)
|
|
1363
|
+
# the repo bind is read-only: uv builds the project venv and its
|
|
1364
|
+
# cache in the per-run home instead (fresh per run; nothing shared
|
|
1365
|
+
# across sessions)
|
|
1366
|
+
env["UV_PROJECT_ENVIRONMENT"] = str(session_home / "venv")
|
|
1367
|
+
env["UV_CACHE_DIR"] = str(session_home / "uv-cache")
|
|
1368
|
+
env["UV_LINK_MODE"] = "copy"
|
|
1369
|
+
if self.container_image:
|
|
1370
|
+
# --cleanenv drops the host env inside the container EXCEPT
|
|
1371
|
+
# APPTAINERENV_* — the key travels via env, never argv
|
|
1372
|
+
for k in (self.key_env, "UV_PROJECT_ENVIRONMENT", "UV_CACHE_DIR", "UV_LINK_MODE"):
|
|
1373
|
+
env[f"APPTAINERENV_{k}"] = env[k]
|
|
1374
|
+
# cwd is the per-run home, NOT the workspace: --save_sample writes
|
|
1375
|
+
# its trajectory JSON to cwd, and artifacts must never land in the
|
|
1376
|
+
# clone (they would enter the diff).
|
|
1377
|
+
process = subprocess.Popen(
|
|
1378
|
+
command,
|
|
1379
|
+
cwd=session_home,
|
|
1380
|
+
env=env,
|
|
1381
|
+
stdin=subprocess.DEVNULL,
|
|
1382
|
+
stdout=subprocess.PIPE,
|
|
1383
|
+
stderr=subprocess.STDOUT,
|
|
1384
|
+
text=True,
|
|
1385
|
+
start_new_session=True,
|
|
1386
|
+
)
|
|
1387
|
+
except OSError as exc:
|
|
1388
|
+
log.warning("could not spawn hermes: %s", exc)
|
|
1389
|
+
return _error_result("spawn-error", detail=str(exc)[:200])
|
|
1390
|
+
try:
|
|
1391
|
+
stdout, _ = process.communicate(timeout=self.timeout_s)
|
|
1392
|
+
except subprocess.TimeoutExpired:
|
|
1393
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1394
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
1395
|
+
try:
|
|
1396
|
+
stdout, _ = process.communicate(timeout=10)
|
|
1397
|
+
except subprocess.TimeoutExpired:
|
|
1398
|
+
process.kill()
|
|
1399
|
+
stdout = ""
|
|
1400
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
1401
|
+
stdout, _ = process.communicate(timeout=5)
|
|
1402
|
+
path = _write_private(
|
|
1403
|
+
workspace.parent, transcript_stem, ".log", redact(stdout or "", (self.api_key,))
|
|
1404
|
+
)
|
|
1405
|
+
log.warning("hermes session timed out after %ss in %s", self.timeout_s, workspace)
|
|
1406
|
+
_collect_hermes_sample(session_home) # drop trajectories (may embed the brief) too
|
|
1407
|
+
with contextlib.suppress(OSError):
|
|
1408
|
+
brief_path.unlink() # the brief holds PR content; clean up on timeout too
|
|
1409
|
+
return _error_result(
|
|
1410
|
+
"timeout",
|
|
1411
|
+
path,
|
|
1412
|
+
detail=f"session hit its {self.timeout_s}s walltime and was killed",
|
|
1413
|
+
)
|
|
1414
|
+
stdout = redact(stdout, (self.api_key,))
|
|
1415
|
+
transcript_path = _write_private(workspace.parent, transcript_stem, ".log", stdout)
|
|
1416
|
+
sample = _collect_hermes_sample(session_home)
|
|
1417
|
+
with contextlib.suppress(OSError):
|
|
1418
|
+
brief_path.unlink() # the brief holds PR content; don't leave it at rest
|
|
1419
|
+
result = _parse_hermes_result(stdout, sample, process.returncode, transcript_path)
|
|
1420
|
+
# Record this turn so a later resume can restore the context, and hand
|
|
1421
|
+
# back the session id it lives under. Only on a real reply — a failed
|
|
1422
|
+
# turn leaves the prior transcript intact (nothing useful to append).
|
|
1423
|
+
# REDACT before persisting: final_text comes from the sample, not the
|
|
1424
|
+
# already-redacted stdout, so an agent reply that echoed its session key
|
|
1425
|
+
# must not be written to the resume transcript at rest.
|
|
1426
|
+
if not result.is_error:
|
|
1427
|
+
secrets = (self.api_key,)
|
|
1428
|
+
prior_turns.append({"role": "user", "text": redact(brief_text, secrets)})
|
|
1429
|
+
prior_turns.append({"role": "assistant", "text": redact(result.final_text, secrets)})
|
|
1430
|
+
_save_resume_transcript(session_home, session_id, prior_turns)
|
|
1431
|
+
return replace(result, session_id=session_id)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
@dataclass
|
|
1435
|
+
class FakeHarness:
|
|
1436
|
+
"""Deterministic in-process harness for tests and dry runs."""
|
|
1437
|
+
|
|
1438
|
+
result: SessionResult
|
|
1439
|
+
script: Any = None # optional callable(brief_text, workspace) for side effects
|
|
1440
|
+
calls: list[tuple[str, str, str | None]] = field(default_factory=list)
|
|
1441
|
+
supports_resume: bool = True # a field so tests can exercise the no-resume path
|
|
1442
|
+
|
|
1443
|
+
def run(
|
|
1444
|
+
self, brief_text: str, workspace: Path, resume_session_id: str | None = None
|
|
1445
|
+
) -> SessionResult:
|
|
1446
|
+
self.calls.append((brief_text, str(workspace), resume_session_id))
|
|
1447
|
+
if self.script is not None:
|
|
1448
|
+
self.script(brief_text, workspace)
|
|
1449
|
+
return self.result
|