agentboxx 0.2.1__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.
agentbox/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .recorder import Recorder
2
+ from .signer import ChainWriter, load_or_create_key, verify_file, verify_log
3
+
4
+ __all__ = ["Recorder", "ChainWriter", "load_or_create_key", "verify_file", "verify_log"]
5
+ __version__ = "0.2.1"
agentbox/agent.py ADDED
@@ -0,0 +1,138 @@
1
+ """A minimal ReAct-style agent loop wired to the recorder.
2
+
3
+ Loop: model proposes a command -> sandbox runs it -> recorder logs it ->
4
+ the observation (plus any loop-detector hint) goes back to the model.
5
+ """
6
+ import json
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are a coding agent working in a sandbox with a bash tool. "
10
+ "Reply with ONLY one JSON object, no other text. "
11
+ 'To run a command: {"thought": "why", "command": "bash command"}. '
12
+ 'When the goal is done: {"thought": "why", "final": "short answer"}. '
13
+ "If you receive an [agentbox hint], change your approach."
14
+ )
15
+
16
+
17
+ class ScriptedModel:
18
+ """A fake model for demos and tests. Retries a failing command, then
19
+ changes approach once it receives an [agentbox hint]."""
20
+
21
+ def __init__(self):
22
+ self.plan = [
23
+ ("Create a small script.", "printf 'x = 0\\nprint(10 / x)\\n' > main.py"),
24
+ ("Run it.", "python3 main.py"),
25
+ ]
26
+ self.recovery = [
27
+ ("The hint is right: x = 0 causes the crash. Fix the value.", "sed -i 's/x = 0/x = 2/' main.py"),
28
+ ("Run it again.", "python3 main.py"),
29
+ ("DONE", None),
30
+ ]
31
+ self.i, self.recovering = 0, False
32
+
33
+ def next_action(self, messages):
34
+ if not self.recovering and "[agentbox hint]" in messages[-1]["content"]:
35
+ self.recovering, self.i = True, 0
36
+ steps = self.recovery if self.recovering else self.plan
37
+ thought, cmd = steps[min(self.i, len(steps) - 1)]
38
+ self.i += 1
39
+ if cmd is None:
40
+ return {"thought": "The script runs now.", "final": "Fixed the divide-by-zero; script prints 5.0."}
41
+ return {"thought": thought, "command": cmd}
42
+
43
+
44
+ class AnthropicModel:
45
+ """Real model via the Anthropic API. Needs `pip install anthropic` and
46
+ the ANTHROPIC_API_KEY environment variable."""
47
+
48
+ def __init__(self, model="claude-sonnet-5-5"):
49
+ import anthropic
50
+ self.client, self.model = anthropic.Anthropic(), model
51
+
52
+ def next_action(self, messages):
53
+ r = self.client.messages.create(model=self.model, max_tokens=1000,
54
+ system=SYSTEM_PROMPT, messages=messages)
55
+ text = r.content[0].text.strip()
56
+ if text.startswith("```"):
57
+ text = text.strip("`").removeprefix("json").strip()
58
+ try:
59
+ return json.loads(text)
60
+ except json.JSONDecodeError:
61
+ return {"thought": "unparseable reply", "final": text[:500]}
62
+
63
+
64
+
65
+ class GeminiModel:
66
+ """Real model via the Google Gemini REST API (stdlib only, no SDK needed).
67
+ Needs the GEMINI_API_KEY environment variable."""
68
+
69
+ def __init__(self, model="gemini-flash-lite-latest"):
70
+ import os
71
+ key = os.environ.get("GEMINI_API_KEY")
72
+ if not key:
73
+ raise RuntimeError("set the GEMINI_API_KEY environment variable")
74
+ self._model = model
75
+ self._url = (
76
+ f"https://generativelanguage.googleapis.com/v1beta/"
77
+ f"models/{model}:generateContent?key={key}"
78
+ )
79
+
80
+ def next_action(self, messages):
81
+ import urllib.request
82
+ contents = []
83
+ for m in messages:
84
+ role = "model" if m["role"] == "assistant" else "user"
85
+ contents.append({"role": role, "parts": [{"text": m["content"]}]})
86
+ body = json.dumps({
87
+ "system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
88
+ "contents": contents,
89
+ }).encode("utf-8")
90
+ req = urllib.request.Request(
91
+ self._url, data=body,
92
+ headers={"Content-Type": "application/json"},
93
+ )
94
+ with urllib.request.urlopen(req, timeout=60) as resp:
95
+ data = json.loads(resp.read())
96
+ text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
97
+ if text.startswith("```"):
98
+ text = text.strip("`").removeprefix("json").strip()
99
+ try:
100
+ return json.loads(text)
101
+ except json.JSONDecodeError:
102
+ return {"thought": "unparseable reply", "final": text[:500]}
103
+
104
+
105
+ def _format_observation(obs: dict) -> str:
106
+ return (f"exit_code={obs['exit_code']}\n"
107
+ f"stdout:\n{obs['stdout']['text'][-1500:]}\n"
108
+ f"stderr:\n{obs['stderr']['text'][-1500:]}")
109
+
110
+
111
+ def run_agent(goal, model, recorder, max_steps=15, verbose=True) -> dict:
112
+ messages = [{"role": "user", "content": f"Goal: {goal}"}]
113
+ reason, final = "max_steps", None
114
+ for _ in range(max_steps):
115
+ action = model.next_action(messages)
116
+ messages.append({"role": "assistant", "content": json.dumps(action)})
117
+ if action.get("final") is not None:
118
+ reason, final = "final_answer", action["final"]
119
+ break
120
+ cmd = action.get("command")
121
+ if not cmd:
122
+ messages.append({"role": "user", "content": "Reply with a command or a final answer."})
123
+ continue
124
+ result = recorder.run(cmd, thought=action.get("thought", ""))
125
+ obs, signals = result["observation"], result["signals"]
126
+ if verbose:
127
+ print(f"[{recorder.step_index}] {action.get('thought', '')}\n $ {cmd} -> exit {obs['exit_code']}")
128
+ for s in signals:
129
+ print(f" !! {s['severity']}: {s['kind']}")
130
+ critical = [s for s in signals if s["severity"] == "critical"]
131
+ if critical:
132
+ reason = f"terminated: {critical[0]['kind']}"
133
+ break
134
+ text = _format_observation(obs)
135
+ if signals:
136
+ text += "\n\n[agentbox hint] " + " ".join(sorted({s["hint"] for s in signals}))
137
+ messages.append({"role": "user", "content": text})
138
+ return {"reason": reason, "final": final}
agentbox/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """Command-line interface: verify, report, list and demo."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .signer import load_or_create_key, verify_log
13
+ from .report import build_report_data, render_text, render_json, render_html
14
+
15
+ EXIT_OK = 0
16
+ EXIT_TAMPERED = 1
17
+ EXIT_INCOMPLETE = 2
18
+ EXIT_ERROR = 3 # missing, empty or unreadable input (not a verdict on the log)
19
+
20
+ _RED, _YELLOW, _RESET = "\033[31m", "\033[33m", "\033[0m"
21
+
22
+
23
+ class CliError(Exception):
24
+ """A problem with the input that should be shown as a message, not a traceback."""
25
+
26
+
27
+ def _use_color(stream=None) -> bool:
28
+ """Color only on a terminal, and never when NO_COLOR is set (https://no-color.org)."""
29
+ stream = stream if stream is not None else sys.stdout
30
+ if os.environ.get("NO_COLOR"):
31
+ return False
32
+ if os.environ.get("TERM") == "dumb":
33
+ return False
34
+ isatty = getattr(stream, "isatty", None)
35
+ return bool(isatty and isatty())
36
+
37
+
38
+ def _paint(text: str, color: str, enabled: bool) -> str:
39
+ return f"{color}{text}{_RESET}" if enabled else text
40
+
41
+
42
+ def _check_readable(path) -> None:
43
+ p = Path(path)
44
+ if not p.exists():
45
+ raise CliError(f"file not found: {path}")
46
+ if p.is_dir():
47
+ raise CliError(f"{path} is a directory, not a log file")
48
+ try:
49
+ size = p.stat().st_size
50
+ except OSError as e:
51
+ raise CliError(f"cannot read {path}: {e.strerror or e}")
52
+ if size == 0:
53
+ raise CliError(f"log file is empty: {path}")
54
+
55
+
56
+ def _run_verify(path, key=None):
57
+ """Return (problems, incomplete). Raises CliError for missing, empty or unreadable files."""
58
+ _check_readable(path) # before the key lookup, so a typo creates no key
59
+ key = key if key is not None else load_or_create_key()
60
+ try:
61
+ return verify_log(path, key)
62
+ except (OSError, UnicodeDecodeError) as e:
63
+ raise CliError(f"cannot read {path}: {getattr(e, 'strerror', None) or e}")
64
+ except (KeyError, TypeError, AttributeError):
65
+ return ["a record is malformed (not a valid agentbox record)"], []
66
+
67
+
68
+ def _verify_and_print(path, key=None) -> int:
69
+ problems, incomplete = _run_verify(path, key)
70
+ if problems:
71
+ print("LOG HAS BEEN TAMPERED WITH OR IS CORRUPT:")
72
+ for p in problems:
73
+ print(" -", p)
74
+ return EXIT_TAMPERED
75
+ if incomplete:
76
+ print("INCOMPLETE LOG (chain and signatures are valid so far):")
77
+ for p in incomplete:
78
+ print(" -", p)
79
+ return EXIT_INCOMPLETE
80
+ print("OK: log is intact (chain and signatures valid).")
81
+ return EXIT_OK
82
+
83
+
84
+ def cmd_verify(args) -> int:
85
+ return _verify_and_print(args.logfile)
86
+
87
+
88
+ def _read_events(path) -> list:
89
+ _check_readable(path)
90
+ events = []
91
+ try:
92
+ with open(path, encoding="utf-8") as f:
93
+ for n, line in enumerate(f, 1):
94
+ if not line.strip():
95
+ continue
96
+ try:
97
+ ev = json.loads(line)["event"]
98
+ ev["event_type"]
99
+ except (ValueError, KeyError, TypeError):
100
+ raise CliError(f"{path}: line {n} is not a valid agentbox record")
101
+ events.append(ev)
102
+ except (OSError, UnicodeDecodeError) as e:
103
+ raise CliError(f"cannot read {path}: {getattr(e, 'strerror', None) or e}")
104
+ return events
105
+
106
+
107
+ def cmd_report(args) -> int:
108
+ events = _read_events(args.logfile)
109
+ fmt = getattr(args, "format", "text") or "text"
110
+ out_path = getattr(args, "output", None)
111
+ try:
112
+ data = build_report_data(events)
113
+ if fmt == "json":
114
+ rendered = render_json(data)
115
+ elif fmt == "html":
116
+ rendered = render_html(data)
117
+ else:
118
+ color = _use_color(stream=sys.stdout if not out_path else None)
119
+ rendered = render_text(data, color=color)
120
+ except (KeyError, TypeError) as e:
121
+ raise CliError(f"{args.logfile}: a record is missing the field {e}")
122
+
123
+ if out_path and out_path != "-":
124
+ try:
125
+ Path(out_path).write_text(rendered, encoding="utf-8")
126
+ except OSError as e:
127
+ raise CliError(f"cannot write to {out_path}: {getattr(e, 'strerror', None) or e}")
128
+ else:
129
+ print(rendered, end="")
130
+ return EXIT_OK
131
+
132
+
133
+ def _peek(path):
134
+ """Cheap, tolerant read for `list`: (session_id, start timestamp, step count)."""
135
+ session, started, steps = Path(path).stem, None, 0
136
+ with open(path, encoding="utf-8", errors="replace") as f:
137
+ for line in f:
138
+ try:
139
+ ev = json.loads(line)["event"]
140
+ etype = ev["event_type"]
141
+ except (ValueError, KeyError, TypeError):
142
+ continue
143
+ if started is None and isinstance(ev.get("ts"), (int, float)):
144
+ started = ev["ts"]
145
+ data = ev.get("data")
146
+ if etype == "session.start" and isinstance(data, dict):
147
+ session = str(data.get("session_id", session))
148
+ elif etype == "step":
149
+ steps += 1
150
+ return session, started, steps
151
+
152
+
153
+ def cmd_list(args) -> int:
154
+ root = Path(args.directory)
155
+ if not root.is_dir():
156
+ raise CliError(f"directory not found: {args.directory}")
157
+ logs = sorted(root.glob("*.ndjson"))
158
+ if not logs:
159
+ print(f"No logs found in {args.directory}")
160
+ return EXIT_OK
161
+ key = load_or_create_key()
162
+ rows = []
163
+ for path in logs:
164
+ try:
165
+ session, started, steps = _peek(path)
166
+ problems, incomplete = _run_verify(path, key)
167
+ status = "TAMPERED" if problems else "INCOMPLETE" if incomplete else "OK"
168
+ except CliError:
169
+ session, started, steps = path.stem, None, 0
170
+ status = "EMPTY" if path.stat().st_size == 0 else "UNREADABLE"
171
+ except OSError:
172
+ session, started, steps, status = path.stem, None, 0, "UNREADABLE"
173
+ when = (datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S")
174
+ if started is not None else "-")
175
+ rows.append((started if started is not None else float("inf"), session, when, str(steps), status))
176
+ rows.sort(key=lambda r: (r[0], r[1]))
177
+ table = [("SESSION", "STARTED", "STEPS", "VERIFIES")] + [r[1:] for r in rows]
178
+ widths = [max(len(r[i]) for r in table) for i in range(4)]
179
+ for r in table:
180
+ print(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)).rstrip())
181
+ return EXIT_OK
182
+
183
+
184
+ def cmd_demo(args) -> int:
185
+ """Run the scripted stuck-agent scenario in a temp directory (local backend, no Docker)."""
186
+ from .agent import ScriptedModel, run_agent
187
+ from .recorder import Recorder
188
+
189
+ missing = [t for t in ("bash", "python3") if shutil.which(t) is None]
190
+ if missing:
191
+ raise CliError("the demo needs " + " and ".join(missing) + " on your PATH")
192
+ with tempfile.TemporaryDirectory(prefix="agentbox-demo-") as tmp:
193
+ tmp = Path(tmp)
194
+ key_path = tmp / "key" # throwaway key: the demo never touches ~/.agentbox
195
+ rec = Recorder("demo", workspace=str(tmp / "workspace"), log_dir=str(tmp / "runs"),
196
+ key_path=key_path)
197
+ print("agentbox demo: a scripted agent repeats a failing command, gets a hint, recovers.\n")
198
+ try:
199
+ out = run_agent("Make main.py run without errors.", ScriptedModel(), rec)
200
+ finally:
201
+ rec.close("demo finished") # every way a run stops is written to the log
202
+ print(f"\nResult: {out}\n")
203
+ print("--- agentbox report ---")
204
+ cmd_report(argparse.Namespace(logfile=rec.log_path))
205
+ print("\n--- agentbox verify ---")
206
+ code = _verify_and_print(rec.log_path, load_or_create_key(key_path))
207
+ print("\n(This ran in a temporary directory that is deleted when the command exits.)")
208
+ return code if code != EXIT_OK else (EXIT_OK if out["reason"] == "final_answer" else 1)
209
+
210
+
211
+ def build_parser() -> argparse.ArgumentParser:
212
+ p = argparse.ArgumentParser(prog="agentbox")
213
+ p.add_argument("--version", action="version", version=f"agentbox {__version__}")
214
+ sub = p.add_subparsers(dest="cmd", required=True)
215
+ v = sub.add_parser("verify", help="check a log for tampering")
216
+ v.add_argument("logfile")
217
+ v.set_defaults(fn=cmd_verify)
218
+ r = sub.add_parser("report", help="print a readable summary")
219
+ r.add_argument("logfile")
220
+ r.add_argument(
221
+ "--format", "-f",
222
+ choices=["text", "json", "html"],
223
+ default="text",
224
+ help="output format (text, json, html; default: text)",
225
+ )
226
+ r.add_argument(
227
+ "--output", "-o",
228
+ default=None,
229
+ help="write report to a file instead of stdout",
230
+ )
231
+ r.set_defaults(fn=cmd_report)
232
+ ls = sub.add_parser("list", help="list the logs in a directory (default: ./runs)")
233
+ ls.add_argument("directory", nargs="?", default="runs")
234
+ ls.set_defaults(fn=cmd_list)
235
+ d = sub.add_parser("demo", help="run a self-contained demo (no Docker, no API key)")
236
+ d.set_defaults(fn=cmd_demo)
237
+ return p
238
+
239
+
240
+ def main(argv=None) -> int:
241
+ if argv is not None:
242
+ argv = [str(a) for a in argv]
243
+ args = build_parser().parse_args(argv)
244
+ try:
245
+ return args.fn(args)
246
+ except CliError as e:
247
+ print(f"agentbox: error: {e}", file=sys.stderr)
248
+ return EXIT_ERROR
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """Run agent commands inside an isolated Docker container (v0.2).
2
+
3
+ The signing key and log files stay on the HOST. The container only sees
4
+ the workspace folder, has no network by default, a read-only filesystem,
5
+ no Linux capabilities, and resource limits.
6
+ """
7
+ import os
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ import uuid
12
+
13
+ from .executor import build_observation
14
+ from .sandbox_stats import EventCounter, parse_state, parse_stats
15
+ from .streaming import DEFAULT_MAX_STREAM_BYTES, run_streaming
16
+
17
+ DEFAULT_IMAGE = "python:3.12-slim"
18
+
19
+
20
+ class DockerNotAvailable(RuntimeError):
21
+ pass
22
+
23
+
24
+ def container_user() -> str:
25
+ """The uid:gid the container runs as.
26
+
27
+ Unix: the host user, so the mounted workspace stays writable.
28
+ Windows has no uid or gid. Docker Desktop makes bind mounts writable for any user,
29
+ so a fixed non-root user is used. The sandbox is never run as root by default.
30
+ """
31
+ getuid, getgid = getattr(os, "getuid", None), getattr(os, "getgid", None)
32
+ if getuid and getgid:
33
+ return f"{getuid()}:{getgid()}"
34
+ return "1000:1000"
35
+
36
+
37
+ def docker_available() -> bool:
38
+ try:
39
+ r = subprocess.run(["docker", "info"], capture_output=True, timeout=20)
40
+ return r.returncode == 0
41
+ except (OSError, subprocess.TimeoutExpired):
42
+ return False
43
+
44
+
45
+ class DockerSandbox:
46
+ def __init__(self, workspace="./workspace-docker", image=DEFAULT_IMAGE,
47
+ network="none", memory="512m", cpus="1", pids=128, resource_stats=True, user=None):
48
+ self.workspace = os.path.abspath(workspace)
49
+ self.image, self.network = image, network
50
+ self.memory, self.cpus, self.pids = memory, cpus, pids
51
+ self.name = f"agentbox-{uuid.uuid4().hex[:8]}"
52
+ self.started = False
53
+ self.resource_stats = resource_stats # adds about a second per step
54
+ self.user = user or container_user() # "uid:gid"; override if needed
55
+ self.events = EventCounter()
56
+ self._events_proc = None
57
+
58
+ def describe(self) -> dict:
59
+ return {"backend": "docker", "image": self.image, "network": self.network,
60
+ "memory": self.memory, "cpus": self.cpus, "pids_limit": self.pids,
61
+ "read_only_rootfs": True, "cap_drop": "ALL", "resource_stats": self.resource_stats, "user": self.user}
62
+
63
+ def start(self):
64
+ if not docker_available():
65
+ raise DockerNotAvailable("Docker is not running or not installed.")
66
+ os.makedirs(self.workspace, exist_ok=True)
67
+ cmd = [
68
+ "docker", "run", "-d", "--rm", "--name", self.name,
69
+ "--network", self.network,
70
+ "--read-only",
71
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
72
+ "--cap-drop", "ALL",
73
+ "--security-opt", "no-new-privileges:true",
74
+ "--pids-limit", str(self.pids),
75
+ "--memory", self.memory, "--memory-swap", self.memory,
76
+ "--cpus", str(self.cpus),
77
+ "--user", self.user,
78
+ "-e", "HOME=/tmp",
79
+ "-v", f"{self.workspace}:/workspace",
80
+ "-w", "/workspace",
81
+ self.image, "sleep", "infinity",
82
+ ]
83
+ r = subprocess.run(cmd, capture_output=True, text=True)
84
+ if r.returncode != 0:
85
+ raise DockerNotAvailable(f"Could not start container: {r.stderr.strip()}")
86
+ self.started = True
87
+ self._start_event_watcher()
88
+
89
+ def stop(self):
90
+ if self.started:
91
+ self._stop_event_watcher()
92
+ subprocess.run(["docker", "rm", "-f", self.name], capture_output=True)
93
+ self.started = False
94
+
95
+ def _start_event_watcher(self):
96
+ """Follow `docker events` for this container (host side, so the agent cannot touch it)."""
97
+ since = str(int(time.time()) - 2) # --since avoids missing events during start-up
98
+ try:
99
+ proc = subprocess.Popen(
100
+ ["docker", "events", "--since", since, "--filter", f"container={self.name}",
101
+ "--format", "{{json .}}"],
102
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
103
+ except OSError:
104
+ return
105
+ self._events_proc = proc
106
+
107
+ def pump():
108
+ for line in proc.stdout:
109
+ self.events.feed(line)
110
+
111
+ threading.Thread(target=pump, daemon=True).start()
112
+
113
+ def _stop_event_watcher(self):
114
+ if self._events_proc is not None:
115
+ self._events_proc.terminate()
116
+ self._events_proc = None
117
+
118
+ def _snapshot(self, oom_before: int, code: int, exceeded: bool) -> dict:
119
+ """What Docker says about the container after a step. Never raises."""
120
+ snap: dict = {}
121
+ try:
122
+ r = subprocess.run(["docker", "inspect", "--format", "{{json .State}}", self.name],
123
+ capture_output=True, text=True, timeout=15)
124
+ snap.update(parse_state(r.stdout))
125
+ except (OSError, subprocess.TimeoutExpired):
126
+ pass
127
+ if self.resource_stats and snap.get("running", True):
128
+ try:
129
+ r = subprocess.run(["docker", "stats", "--no-stream", "--format", "{{json .}}", self.name],
130
+ capture_output=True, text=True, timeout=30)
131
+ lines = r.stdout.strip().splitlines()
132
+ snap.update(parse_stats(lines[0] if lines else ""))
133
+ except (OSError, subprocess.TimeoutExpired):
134
+ pass
135
+ if code == 137 and not exceeded: # a kill: give the OOM event a moment to arrive
136
+ deadline = time.monotonic() + 2.0
137
+ while self.events.count("oom") == oom_before and time.monotonic() < deadline:
138
+ time.sleep(0.1)
139
+ snap["oom_events"] = self.events.count("oom") - oom_before
140
+ return snap
141
+
142
+ def _kill_stragglers(self):
143
+ """Kill everything in the container except PID 1 (the idle `sleep`)."""
144
+ try:
145
+ subprocess.run(["docker", "exec", self.name, "sh", "-c", 'for p in /proc/[0-9]*; do pid=${p#/proc/}; if [ "$pid" -gt 1 ] && [ "$pid" != "$$" ]; then kill -9 "$pid" 2>/dev/null; fi; done'],
146
+ capture_output=True, timeout=15)
147
+ except (OSError, subprocess.TimeoutExpired):
148
+ pass
149
+
150
+ def run(self, cmd: str, timeout_s: int = 60,
151
+ max_output_bytes: int = DEFAULT_MAX_STREAM_BYTES) -> dict:
152
+ if not self.started:
153
+ raise RuntimeError("Call start() before run().")
154
+ argv = ["docker", "exec", "-w", "/workspace", self.name,
155
+ "timeout", "-k", "2", str(timeout_s),
156
+ "bash", "--noprofile", "--norc", "-c", cmd]
157
+ oom_before = self.events.count("oom")
158
+ t0 = time.monotonic()
159
+ # The in-container `timeout` is the normal deadline (exit 124); the host-side
160
+ # deadline is a backstop if `docker exec` itself hangs.
161
+ code, out, err, timed_out, exceeded = run_streaming(
162
+ argv, cwd=None, timeout_s=timeout_s + 10, max_bytes=max_output_bytes,
163
+ cleanup=self._kill_stragglers)
164
+ obs = build_observation(cmd, code, out, err, timed_out or code == 124, exceeded,
165
+ self.workspace, int((time.monotonic() - t0) * 1000))
166
+ obs["sandbox"] = self._snapshot(oom_before, code, exceeded)
167
+ return obs
@@ -0,0 +1,46 @@
1
+ """Language-specific error extractors for error signatures.
2
+
3
+ Each extractor returns (error_class, key_message) or None when the text does not
4
+ look like that language's error output. Only the stable part of an error is
5
+ returned (class + message), never stack frames, so the same failure produces the
6
+ same signature even when line numbers, paths or versions change.
7
+ """
8
+ import re
9
+
10
+ # --- Go -------------------------------------------------------------------
11
+ _GO_PANIC = re.compile(r"^panic: (.+)$", re.M)
12
+ _GO_FATAL = re.compile(r"^fatal error: (.+)$", re.M)
13
+ _GO_COMPILE = re.compile(r"^\S*\.go:\d+(?::\d+)?: (.+)$", re.M)
14
+
15
+ # --- Node.js --------------------------------------------------------------
16
+ _NODE_HEADER = re.compile(
17
+ r"^(?:Uncaught\s+)?((?:[A-Za-z_$][\w$]*)?(?:Error|Exception))(?:\s+\[([A-Z0-9_]+)\])?:\s*(.*)$",
18
+ re.M,
19
+ )
20
+ _NODE_FRAME = re.compile(r"^\s+at \S", re.M)
21
+ _NODE_VERSION = re.compile(r"^Node\.js v\d+", re.M)
22
+
23
+
24
+ def extract_go(text: str):
25
+ m = _GO_PANIC.search(text)
26
+ if m:
27
+ return "GoPanic", m.group(1)
28
+ m = _GO_FATAL.search(text)
29
+ if m:
30
+ return "GoFatalError", m.group(1)
31
+ m = _GO_COMPILE.search(text)
32
+ if m:
33
+ return "GoCompileError", m.group(1)
34
+ return None
35
+
36
+
37
+ def extract_node(text: str):
38
+ # Require Node-style stack frames or the version footer, so a bare
39
+ # "Error: ..." line from some other tool is not misclassified.
40
+ if not (_NODE_FRAME.search(text) or _NODE_VERSION.search(text)):
41
+ return None
42
+ m = _NODE_HEADER.search(text)
43
+ if not m:
44
+ return None
45
+ cls, code, msg = m.groups()
46
+ return (f"{cls}[{code}]" if code else cls), msg