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/executor.py ADDED
@@ -0,0 +1,102 @@
1
+ """Runs a shell command and observes it from the outside."""
2
+ import hashlib
3
+ import os
4
+ import re
5
+ import time
6
+
7
+ from .error_signatures import extract_go, extract_node
8
+ from .redact import redact_all
9
+ from .streaming import DEFAULT_MAX_STREAM_BYTES, run_streaming
10
+ from .workspace import workspace_hash
11
+
12
+ _NORMALIZERS = [
13
+ (re.compile(r"0x[0-9a-fA-F]+"), "<HEX>"),
14
+ (re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"), "<UUID>"),
15
+ (re.compile(r"/tmp/\S+"), "/tmp/<TMP>"),
16
+ (re.compile(r"\d+"), "<N>"),
17
+ (re.compile(r"\s+"), " "),
18
+ ]
19
+ _ERR_CLASS = re.compile(r"^([A-Za-z_][\w.]*(?:Error|Exception))\b", re.M)
20
+ MAX_KEEP = 4000
21
+
22
+
23
+ def normalize(text: str) -> str:
24
+ for rx, rep in _NORMALIZERS:
25
+ text = rx.sub(rep, text)
26
+ return text.strip().lower()
27
+
28
+
29
+ def fingerprint(text: str) -> str:
30
+ return hashlib.sha256(normalize(text).encode()).hexdigest()[:16]
31
+
32
+
33
+ def error_signature(stderr: str, exit_code: int):
34
+ if exit_code == 0 or not stderr.strip():
35
+ return None, None
36
+ if "Traceback (most recent call last)" not in stderr:
37
+ found = extract_go(stderr) or extract_node(stderr)
38
+ if found:
39
+ cls, msg = found
40
+ return f"{cls}:{fingerprint(cls + ' ' + msg)}", cls
41
+ last = [ln for ln in stderr.strip().splitlines() if ln.strip()][-3:]
42
+ classes = _ERR_CLASS.findall(stderr)
43
+ cls = classes[-1] if classes else None
44
+ sig = fingerprint("\n".join(last))
45
+ return (f"{cls}:{sig}" if cls else sig), cls
46
+
47
+
48
+ def _clip(s: str) -> dict:
49
+ return {
50
+ "text": s if len(s) <= MAX_KEEP else s[: MAX_KEEP // 2] + "\n...[truncated]...\n" + s[-MAX_KEEP // 2 :],
51
+ "total_chars": len(s),
52
+ "sha256": hashlib.sha256(s.encode()).hexdigest(),
53
+ }
54
+
55
+
56
+ def _txt(x) -> str:
57
+ if x is None:
58
+ return ""
59
+ return x.decode("utf-8", "replace") if isinstance(x, bytes) else x
60
+
61
+
62
+ def _stream_record(text: str, cap) -> dict:
63
+ # total_chars is kept for compatibility; it counts bytes seen on the stream.
64
+ return {
65
+ "text": text,
66
+ "total_chars": cap.total,
67
+ "total_bytes": cap.total,
68
+ "truncated": cap.truncated,
69
+ "sha256": hashlib.sha256(text.encode()).hexdigest(), # of the stored, redacted text
70
+ }
71
+
72
+
73
+ def build_observation(cmd, code, out, err, timed_out, exceeded, workspace, duration_ms) -> dict:
74
+ """Turn a finished command into a log record. Used by every backend."""
75
+ out_text, err_text = out.text(), err.text()
76
+ cmd, out_text, err_text, redactions = redact_all(cmd, out_text, err_text)
77
+ sig, cls = error_signature(err_text, code)
78
+ return {
79
+ "redactions": redactions,
80
+ "command": cmd,
81
+ "command_fingerprint": fingerprint(cmd),
82
+ "exit_code": code,
83
+ "timed_out": timed_out,
84
+ "output_limit_exceeded": exceeded,
85
+ "stdout": _stream_record(out_text, out),
86
+ "stderr": _stream_record(err_text, err),
87
+ "error_signature": sig,
88
+ "error_class": cls,
89
+ "workspace_hash": workspace_hash(workspace),
90
+ "duration_ms": duration_ms,
91
+ }
92
+
93
+
94
+ def run_command(cmd: str, workspace: str, timeout_s: int = 60,
95
+ max_output_bytes: int = DEFAULT_MAX_STREAM_BYTES) -> dict:
96
+ os.makedirs(workspace, exist_ok=True)
97
+ t0 = time.monotonic()
98
+ code, out, err, timed_out, exceeded = run_streaming(
99
+ ["bash", "--noprofile", "--norc", "-c", cmd],
100
+ cwd=workspace, timeout_s=timeout_s, max_bytes=max_output_bytes)
101
+ return build_observation(cmd, code, out, err, timed_out, exceeded, workspace,
102
+ int((time.monotonic() - t0) * 1000))
@@ -0,0 +1,71 @@
1
+ """Detects an agent that is stuck."""
2
+ from collections import deque
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class LoopConfig:
8
+ window: int = 10
9
+ exact_warn: int = 3
10
+ exact_crit: int = 5
11
+ error_warn: int = 3
12
+ error_crit: int = 5
13
+ no_progress_warn: int = 4
14
+
15
+
16
+ class LoopDetector:
17
+ def __init__(self, cfg: LoopConfig | None = None):
18
+ self.cfg = cfg or LoopConfig()
19
+ self.win: deque = deque(maxlen=self.cfg.window)
20
+
21
+ def observe(self, step: dict) -> list[dict]:
22
+ self.win.append(step)
23
+ signals = []
24
+ signals += self._exact(step)
25
+ signals += self._error_repeat(step)
26
+ signals += self._no_progress()
27
+ signals += self._cycle()
28
+ return signals
29
+
30
+ @staticmethod
31
+ def _sig(kind, severity, count, hint):
32
+ return {"kind": kind, "severity": severity, "count": count, "hint": hint}
33
+
34
+ def _exact(self, s):
35
+ n = sum(1 for x in self.win if x["command_fingerprint"] == s["command_fingerprint"]
36
+ and x["workspace_hash"] == s["workspace_hash"]) # same command on unchanged files
37
+ if n >= self.cfg.exact_crit:
38
+ return [self._sig("loop.exact", "critical", n, "Same command repeated. Stop and change approach.")]
39
+ if n >= self.cfg.exact_warn:
40
+ return [self._sig("loop.exact", "warn", n, "Same command repeated. Try something different.")]
41
+ return []
42
+
43
+ def _error_repeat(self, s):
44
+ if not s.get("error_signature"):
45
+ return []
46
+ n = sum(1 for x in self.win if x.get("error_signature") == s["error_signature"])
47
+ if n >= self.cfg.error_crit:
48
+ return [self._sig("loop.error_repeat", "critical", n, "Same error again and again. Re-plan.")]
49
+ if n >= self.cfg.error_warn:
50
+ return [self._sig("loop.error_repeat", "warn", n, "Same error recurring. Form a new hypothesis.")]
51
+ return []
52
+
53
+ def _no_progress(self):
54
+ steps = list(self.win)
55
+ run = 0
56
+ for x in reversed(steps):
57
+ if x["workspace_hash"] == steps[-1]["workspace_hash"]:
58
+ run += 1
59
+ else:
60
+ break
61
+ failing = sum(1 for x in steps[-run:] if x["exit_code"] != 0)
62
+ if run >= self.cfg.no_progress_warn and failing >= max(1, int(run * 0.8)):
63
+ return [self._sig("loop.no_progress", "warn", run, "No files changed across several failing steps.")]
64
+ return []
65
+
66
+ def _cycle(self):
67
+ seq = [x["command_fingerprint"] for x in self.win]
68
+ for L in (2, 3):
69
+ if len(seq) >= 2 * L and seq[-L:] == seq[-2 * L : -L] and len(set(seq[-L:])) > 1:
70
+ return [self._sig("loop.cycle", "warn", 2, f"Repeating a cycle of {L} commands.")]
71
+ return []
agentbox/recorder.py ADDED
@@ -0,0 +1,73 @@
1
+ """The main API. Wrap every action your agent takes in `recorder.run(...)`."""
2
+ from pathlib import Path
3
+
4
+ from .executor import run_command
5
+ from .loop_detector import LoopConfig, LoopDetector
6
+ from .redact import merge_counts, redact
7
+ from .signer import ChainWriter, load_or_create_key
8
+
9
+
10
+ def _crash_kind(sbx: dict):
11
+ if sbx.get("oom_events", 0) > 0 or sbx.get("oom_killed"):
12
+ return "oom_kill"
13
+ if sbx.get("running") is False:
14
+ return "container_stopped"
15
+ return None
16
+
17
+
18
+ class Recorder:
19
+ def __init__(self, session_id: str, workspace: str = "./workspace",
20
+ log_dir: str = "./runs", key_path=None,
21
+ loop_cfg: LoopConfig | None = None, sandbox=None,
22
+ overwrite: bool = False, agent_id: str | None = None):
23
+ self.session_id, self.workspace, self.sandbox = session_id, workspace, sandbox
24
+ self.agent_id = agent_id
25
+ self.log_path = Path(log_dir) / f"{session_id}.ndjson"
26
+ self.writer = ChainWriter(self.log_path, load_or_create_key(key_path), overwrite=overwrite)
27
+ self.detector = LoopDetector(loop_cfg)
28
+ self.step_index = 0
29
+ isolation = sandbox.describe() if sandbox else {"backend": "local"}
30
+ start = {"session_id": session_id, "isolation": isolation}
31
+ if agent_id:
32
+ start["agent_id"] = agent_id
33
+ self.writer.append("session.start", start)
34
+
35
+ def run(self, command: str, thought: str = "", timeout_s: int = 60, *,
36
+ tool: str | None = None, parent_step: int | None = None,
37
+ usage: dict | None = None) -> dict:
38
+ self.step_index += 1
39
+ if self.sandbox:
40
+ obs = self.sandbox.run(command, timeout_s)
41
+ else:
42
+ obs = run_command(command, self.workspace, timeout_s)
43
+ sbx = obs.pop("sandbox", None) # reported as separate events, not inside the step
44
+ thought, t_counts = redact(thought)
45
+ redactions = merge_counts(obs.get("redactions", {}), t_counts)
46
+ step = {"session_id": self.session_id, "step_index": self.step_index,
47
+ "thought": thought, **obs, "redactions": redactions}
48
+ if self.agent_id:
49
+ step["agent_id"] = self.agent_id
50
+ if tool:
51
+ step["tool"] = tool
52
+ if parent_step is not None:
53
+ step["parent_step_index"] = int(parent_step)
54
+ if usage:
55
+ step["usage"] = {str(k): int(v) for k, v in usage.items()} # from the caller, never computed
56
+ self.writer.append("step", step)
57
+ if sbx:
58
+ metrics = {k: sbx.get(k) for k in ("mem_bytes", "mem_limit_bytes", "mem_pct", "cpu_pct", "pids")}
59
+ self.writer.append("sandbox.resource", {"step_index": self.step_index, **metrics})
60
+ kind = _crash_kind(sbx)
61
+ if kind:
62
+ self.writer.append("sandbox.crash", {"step_index": self.step_index, "kind": kind,
63
+ "exit_code": obs.get("exit_code"), **sbx})
64
+ signals = self.detector.observe(step)
65
+ for s in signals:
66
+ self.writer.append("analysis.signal", {"step_index": self.step_index, **s})
67
+ return {"observation": obs, "signals": signals, "sandbox": sbx}
68
+
69
+ def close(self, reason: str = "done"):
70
+ self.writer.append("session.end", {"reason": reason, "steps": self.step_index})
71
+ self.writer.write_manifest()
72
+ if self.sandbox:
73
+ self.sandbox.stop()
agentbox/redact.py ADDED
@@ -0,0 +1,71 @@
1
+ """Redact secrets from text before it is logged and signed.
2
+
3
+ Once a record is signed it cannot be edited, so secrets must be removed first.
4
+ Only a count per pattern is recorded, never the value. Patterns are heuristics:
5
+ they will miss some secrets and occasionally hide harmless values. Add your own
6
+ with add_pattern().
7
+ """
8
+ import re
9
+
10
+ _NAME = (r"[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|"
11
+ r"access[_-]?key|private[_-]?key)[A-Za-z0-9_.-]*")
12
+ _NOT_DONE = r"(?!\[REDACTED:)" # do not re-redact text a specific pattern already replaced
13
+
14
+ # (name, compiled regex, replacement). Specific token formats come first,
15
+ # context-based patterns (headers, URLs, KEY=value) last.
16
+ PATTERNS = [
17
+ ("private_key", re.compile(
18
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", re.S),
19
+ "[REDACTED:private_key]"),
20
+ ("aws_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED:aws_key]"),
21
+ ("github_token", re.compile(
22
+ r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})\b"),
23
+ "[REDACTED:github_token]"),
24
+ ("api_key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "[REDACTED:api_key]"),
25
+ ("slack_token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED:slack_token]"),
26
+ ("google_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), "[REDACTED:google_key]"),
27
+ ("jwt", re.compile(
28
+ r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
29
+ "[REDACTED:jwt]"),
30
+ ("auth_header", re.compile(
31
+ r"(?i)(\bauthorization\s*[:=]\s*(?:bearer|basic|token)\s+)" + _NOT_DONE + r"[^\s'\"]+"),
32
+ r"\1[REDACTED:auth_header]"),
33
+ ("url_password", re.compile(
34
+ r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@]+:)" + _NOT_DONE + r"[^\s@/]+(@)"),
35
+ r"\1[REDACTED:url_password]\2"),
36
+ ("secret_assignment", re.compile(
37
+ r"(?i)(\b" + _NAME + r"\s*[:=]\s*)(['\"]?)" + _NOT_DONE + r"[^\s'\";&]{6,}(\2)"),
38
+ r"\1\2[REDACTED:secret_assignment]\3"),
39
+ ]
40
+
41
+
42
+ def add_pattern(name: str, regex: str, replacement: str | None = None):
43
+ """Register an extra pattern, e.g. add_pattern('internal_id', r'INT-[0-9]{8}')."""
44
+ PATTERNS.append((name, re.compile(regex), replacement or f"[REDACTED:{name}]"))
45
+
46
+
47
+ def redact(text: str):
48
+ """Return (clean_text, counts) where counts maps pattern name to matches."""
49
+ counts: dict[str, int] = {}
50
+ if not text:
51
+ return text, counts
52
+ for name, rx, repl in PATTERNS:
53
+ text, n = rx.subn(repl, text)
54
+ if n:
55
+ counts[name] = counts.get(name, 0) + n
56
+ return text, counts
57
+
58
+
59
+ def merge_counts(*parts: dict) -> dict:
60
+ total: dict[str, int] = {}
61
+ for part in parts:
62
+ for k, v in part.items():
63
+ total[k] = total.get(k, 0) + v
64
+ return total
65
+
66
+
67
+ def redact_all(cmd: str, out: str, err: str):
68
+ cmd, c1 = redact(cmd)
69
+ out, c2 = redact(out)
70
+ err, c3 = redact(err)
71
+ return cmd, out, err, merge_counts(c1, c2, c3)
agentbox/report.py ADDED
@@ -0,0 +1,266 @@
1
+ """Report generation for agentbox runs: text, structured JSON, and self-contained HTML."""
2
+ import html
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List
6
+
7
+ _RED = "\033[31m"
8
+ _YELLOW = "\033[33m"
9
+ _RESET = "\033[0m"
10
+
11
+
12
+ def _paint(text: str, color: str, enabled: bool) -> str:
13
+ return f"{color}{text}{_RESET}" if enabled else text
14
+
15
+
16
+ def build_report_data(events: List[Dict[str, Any]]) -> Dict[str, Any]:
17
+ """Aggregate raw log events into a clean structured dictionary."""
18
+ session_info: Dict[str, Any] = {
19
+ "session_id": "unknown",
20
+ "isolation": {},
21
+ "agent_id": None,
22
+ "started_at": None,
23
+ "ended_at": None,
24
+ "duration_s": None,
25
+ "reason": None,
26
+ }
27
+ steps: List[Dict[str, Any]] = []
28
+ signals: List[Dict[str, Any]] = []
29
+ resources: List[Dict[str, Any]] = []
30
+ crashes: List[Dict[str, Any]] = []
31
+ total_redactions = 0
32
+
33
+ for ev in events:
34
+ etype = ev.get("event_type")
35
+ ts = ev.get("ts")
36
+ data = ev.get("data") or {}
37
+
38
+ if etype == "session.start":
39
+ session_info["session_id"] = data.get("session_id", "unknown")
40
+ session_info["isolation"] = data.get("isolation", {})
41
+ session_info["agent_id"] = data.get("agent_id")
42
+ session_info["started_at"] = ts
43
+ elif etype == "session.end":
44
+ session_info["ended_at"] = ts
45
+ session_info["reason"] = data.get("reason")
46
+ elif etype == "step":
47
+ steps.append(data)
48
+ red = data.get("redactions")
49
+ if isinstance(red, dict):
50
+ total_redactions += sum(red.values())
51
+ elif etype == "analysis.signal":
52
+ signals.append(data)
53
+ elif etype == "sandbox.resource":
54
+ resources.append(data)
55
+ elif etype == "sandbox.crash":
56
+ crashes.append(data)
57
+
58
+ if session_info["started_at"] is not None and session_info["ended_at"] is not None:
59
+ session_info["duration_s"] = round(session_info["ended_at"] - session_info["started_at"], 3)
60
+
61
+ successful = sum(1 for s in steps if s.get("exit_code") == 0)
62
+ failed = len(steps) - successful
63
+
64
+ return {
65
+ "session": session_info,
66
+ "summary": {
67
+ "total_steps": len(steps),
68
+ "successful_steps": successful,
69
+ "failed_steps": failed,
70
+ "signals": len(signals),
71
+ "crashes": len(crashes),
72
+ "total_redactions": total_redactions,
73
+ },
74
+ "steps": steps,
75
+ "signals": signals,
76
+ "sandbox_resources": resources,
77
+ "sandbox_crashes": crashes,
78
+ }
79
+
80
+
81
+ def render_text(data: Dict[str, Any], color: bool = False) -> str:
82
+ """Render the human-readable terminal report matching standard cli format."""
83
+ lines: List[str] = []
84
+ steps = data.get("steps", [])
85
+ signals = data.get("signals", [])
86
+ crashes = data.get("sandbox_crashes", [])
87
+
88
+ for d in steps:
89
+ ok = d.get("exit_code") == 0
90
+ mark = "ok " if ok else _paint("ERR", _RED, color)
91
+ cmd_str = (d.get("command") or "")[:70]
92
+ step_idx = d.get("step_index", 0)
93
+ exit_code = d.get("exit_code", -1)
94
+ lines.append(f"[{step_idx:>3}] {mark} exit={exit_code:<3} {cmd_str}")
95
+
96
+ lines.append(f"\n{len(steps)} steps, {len(signals)} signals")
97
+
98
+ for s in signals:
99
+ line = f" step {s['step_index']}: [{s['severity']}] {s['kind']} (x{s['count']}) - {s['hint']}"
100
+ if s.get("severity") == "critical":
101
+ line = _paint(line, _RED, color)
102
+ elif s.get("severity") == "warn":
103
+ line = _paint(line, _YELLOW, color)
104
+ lines.append(line)
105
+
106
+ for c in crashes:
107
+ line = f" step {c.get('step_index', '?')}: crash [{c.get('kind', 'unknown')}] exit={c.get('exit_code')} (oom={c.get('oom_killed')})"
108
+ line = _paint(line, _RED, color)
109
+ lines.append(line)
110
+
111
+ return "\n".join(lines) + "\n"
112
+
113
+
114
+ def render_json(data: Dict[str, Any]) -> str:
115
+ """Render report as indented JSON."""
116
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
117
+
118
+ _HTML_CSS = """
119
+ :root {
120
+ --bg: #0d1117; --card-bg: #161b22; --border: #30363d;
121
+ --text: #c9d1d9; --text-muted: #8b949e; --accent: #58a6ff;
122
+ --green: #3fb950; --red: #f85149; --yellow: #d29922; --mono: monospace;
123
+ }
124
+ @media (prefers-color-scheme: light) {
125
+ :root {
126
+ --bg: #f6f8fa; --card-bg: #ffffff; --border: #d0d7de;
127
+ --text: #1f2328; --text-muted: #656d76; --accent: #0969da;
128
+ --green: #1a7f37; --red: #cf222e; --yellow: #9a6700;
129
+ }
130
+ }
131
+ * { box-sizing: border-box; margin: 0; padding: 0; }
132
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
133
+ background: var(--bg); color: var(--text); padding: 2rem 1rem; line-height: 1.5; }
134
+ .container { max-width: 960px; margin: 0 auto; }
135
+ header { border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; margin-bottom: 2rem; }
136
+ h1 { font-size: 1.75rem; margin-bottom: 0.5rem; }
137
+ .subtitle { color: var(--text-muted); font-size: 0.9rem; }
138
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
139
+ .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; }
140
+ .card-label { font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); }
141
+ .card-val { font-size: 1.5rem; font-weight: bold; margin-top: 0.25rem; }
142
+ .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
143
+ .badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
144
+ .badge-err { background: rgba(248,81,73,0.15); color: var(--red); }
145
+ .badge-warn { background: rgba(210,153,34,0.15); color: var(--yellow); }
146
+ section { margin-bottom: 2.5rem; }
147
+ h2 { font-size: 1.25rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
148
+ .timeline { display: flex; flex-direction: column; gap: 0.75rem; }
149
+ .step-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem 1rem; }
150
+ .step-header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
151
+ .step-cmd { font-family: var(--mono); font-size: 0.9rem; color: var(--accent); word-break: break-all; }
152
+ .step-meta { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; }
153
+ details { margin-top: 0.5rem; border-top: 1px dashed var(--border); padding-top: 0.5rem; font-size: 0.85rem; }
154
+ summary { cursor: pointer; color: var(--text-muted); user-select: none; }
155
+ summary:hover { color: var(--text); }
156
+ pre { font-family: var(--mono); background: rgba(0,0,0,0.15); padding: 0.5rem; border-radius: 4px; overflow-x: auto; margin-top: 0.5rem; }
157
+ .signal-box { background: rgba(210,153,34,0.1); border-left: 4px solid var(--yellow); padding: 0.75rem 1rem; margin-bottom: 0.75rem; border-radius: 0 4px 4px 0; }
158
+ .signal-box.critical { background: rgba(248,81,73,0.1); border-left-color: var(--red); }
159
+ """
160
+
161
+
162
+ def render_html(data: Dict[str, Any]) -> str:
163
+ """Render a single, completely self-contained HTML report with zero external assets."""
164
+ sess = data.get("session", {})
165
+ summary = data.get("summary", {})
166
+ steps = data.get("steps", [])
167
+ signals = data.get("signals", [])
168
+ crashes = data.get("sandbox_crashes", [])
169
+
170
+ started_str = (datetime.fromtimestamp(sess["started_at"]).strftime("%Y-%m-%d %H:%M:%S")
171
+ if sess.get("started_at") else "unknown")
172
+ iso = sess.get("isolation", {})
173
+ backend_str = iso.get("backend", "local")
174
+ if iso.get("image"):
175
+ backend_str += f" ({iso.get('image')})"
176
+
177
+ out = [
178
+ "<!DOCTYPE html>",
179
+ '<html lang="en">',
180
+ "<head>",
181
+ '<meta charset="utf-8">',
182
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">',
183
+ f"<title>agentbox report - {html.escape(str(sess.get('session_id')))}</title>",
184
+ f"<style>{_HTML_CSS}</style>",
185
+ "</head>",
186
+ "<body>",
187
+ '<div class="container">',
188
+ "<header>",
189
+ "<h1>agentbox flight report</h1>",
190
+ f'<div class="subtitle">Session: <strong>{html.escape(str(sess.get("session_id")))}</strong> &bull; '
191
+ f'Started: {html.escape(started_str)} &bull; '
192
+ f'Backend: {html.escape(backend_str)}'
193
+ f'{(" &bull; Agent: " + html.escape(str(sess["agent_id"]))) if sess.get("agent_id") else ""}</div>',
194
+ "</header>",
195
+ '<div class="cards">',
196
+ f'<div class="card"><div class="card-label">Steps</div><div class="card-val">{summary.get("total_steps", 0)}</div></div>',
197
+ f'<div class="card"><div class="card-label">Passed</div><div class="card-val">{summary.get("successful_steps", 0)}</div></div>',
198
+ f'<div class="card"><div class="card-label">Failed</div><div class="card-val">{summary.get("failed_steps", 0)}</div></div>',
199
+ f'<div class="card"><div class="card-label">Signals</div><div class="card-val">{summary.get("signals", 0)}</div></div>',
200
+ f'<div class="card"><div class="card-label">Duration</div><div class="card-val">{sess.get("duration_s", 0)}s</div></div>',
201
+ "</div>",
202
+ ]
203
+
204
+ if signals:
205
+ out.append("<section><h2>Detector Signals</h2>")
206
+ for s in signals:
207
+ sev = s.get("severity", "warn")
208
+ cls = "critical" if sev == "critical" else "warn"
209
+ out.append(
210
+ f'<div class="signal-box {cls}">'
211
+ f'<strong>Step {s.get("step_index")}: [{html.escape(sev)}] {html.escape(s.get("kind", ""))}</strong> '
212
+ f'(fired {s.get("count")}x)<br>'
213
+ f'{html.escape(s.get("hint", ""))}'
214
+ f'</div>'
215
+ )
216
+ out.append("</section>")
217
+
218
+ if crashes:
219
+ out.append("<section><h2>Sandbox Crashes</h2>")
220
+ for c in crashes:
221
+ out.append(
222
+ f'<div class="signal-box critical">'
223
+ f'<strong>Step {c.get("step_index", "?")}: Crash - {html.escape(str(c.get("kind")))}</strong> '
224
+ f'(exit {c.get("exit_code")}, oom={c.get("oom_killed")})'
225
+ f'</div>'
226
+ )
227
+ out.append("</section>")
228
+
229
+ out.append("<section><h2>Step Timeline</h2><div class=\"timeline\">")
230
+ for s in steps:
231
+ ec = s.get("exit_code", 0)
232
+ badge_cls = "badge-ok" if ec == 0 else "badge-err"
233
+ badge_txt = "OK" if ec == 0 else f"ERR ({ec})"
234
+ dur = f"{s.get('duration_ms', 0)}ms"
235
+ cmd = s.get("command", "")
236
+ out.append(
237
+ f'<div class="step-card">'
238
+ f'<div class="step-header">'
239
+ f'<div><span class="badge {badge_cls}">{badge_txt}</span> '
240
+ f'<strong>#{s.get("step_index")}</strong> '
241
+ f'<span class="step-cmd">{html.escape(cmd)}</span></div>'
242
+ f'<span class="step-meta">{html.escape(dur)}</span>'
243
+ f'</div>'
244
+ )
245
+ details_items = []
246
+ if s.get("thought"):
247
+ details_items.append(f'<p><strong>Thought:</strong> {html.escape(s["thought"])}</p>')
248
+ if s.get("tool"):
249
+ details_items.append(f'<p><strong>Tool:</strong> {html.escape(str(s["tool"]))}</p>')
250
+ if s.get("error_class"):
251
+ details_items.append(f'<p><strong>Error Class:</strong> {html.escape(s["error_class"])}</p>')
252
+ if s.get("error_signature"):
253
+ details_items.append(f'<p><strong>Error Signature:</strong> <code>{html.escape(s["error_signature"])}</code></p>')
254
+ stdout_text = (s.get("stdout") or {}).get("text")
255
+ if stdout_text:
256
+ details_items.append(f'<p><strong>stdout:</strong><pre>{html.escape(stdout_text)}</pre></p>')
257
+ stderr_text = (s.get("stderr") or {}).get("text")
258
+ if stderr_text:
259
+ details_items.append(f'<p><strong>stderr:</strong><pre>{html.escape(stderr_text)}</pre></p>')
260
+ if details_items:
261
+ out.append(f'<details><summary>Details &amp; Output</summary>{"".join(details_items)}</details>')
262
+ out.append("</div>")
263
+
264
+ out.append("</div></section>")
265
+ out.append("</div></body></html>\n")
266
+ return "\n".join(out)
@@ -0,0 +1,80 @@
1
+ """Parse what Docker reports about a container: state, resource use and events.
2
+
3
+ These run on the HOST, so the sandboxed agent cannot influence them.
4
+ """
5
+ import json
6
+ import re
7
+ import threading
8
+
9
+ _UNITS = {"b": 1, "kb": 1000, "mb": 1000 ** 2, "gb": 1000 ** 3, "tb": 1000 ** 4,
10
+ "kib": 1024, "mib": 1024 ** 2, "gib": 1024 ** 3, "tib": 1024 ** 4}
11
+
12
+
13
+ def parse_size(text):
14
+ """'12.5MiB' -> bytes. Returns None if it cannot be read."""
15
+ m = re.fullmatch(r"\s*([0-9]*\.?[0-9]+)\s*([A-Za-z]*)\s*", text or "")
16
+ if not m:
17
+ return None
18
+ unit = (m.group(2) or "b").lower()
19
+ if unit not in _UNITS:
20
+ return None
21
+ return int(float(m.group(1)) * _UNITS[unit])
22
+
23
+
24
+ def _pct(text):
25
+ try:
26
+ return float(str(text).strip().rstrip("%"))
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def parse_stats(line: str) -> dict:
32
+ """One line of `docker stats --no-stream --format '{{json .}}'`."""
33
+ try:
34
+ d = json.loads(line)
35
+ except ValueError:
36
+ return {}
37
+ used, _, limit = str(d.get("MemUsage", "")).partition("/")
38
+ try:
39
+ pids = int(d.get("PIDs"))
40
+ except (TypeError, ValueError):
41
+ pids = None
42
+ return {"mem_bytes": parse_size(used), "mem_limit_bytes": parse_size(limit),
43
+ "mem_pct": _pct(d.get("MemPerc")), "cpu_pct": _pct(d.get("CPUPerc")),
44
+ "pids": pids}
45
+
46
+
47
+ def parse_state(text: str) -> dict:
48
+ """Output of `docker inspect --format '{{json .State}}'`."""
49
+ try:
50
+ s = json.loads(text)
51
+ except ValueError:
52
+ return {}
53
+ if not isinstance(s, dict):
54
+ return {}
55
+ return {"running": bool(s.get("Running")), "oom_killed": bool(s.get("OOMKilled")),
56
+ "exit_code": s.get("ExitCode"), "error": s.get("Error") or None}
57
+
58
+
59
+ class EventCounter:
60
+ """Counts container events (oom, die, ...) from `docker events --format '{{json .}}'`."""
61
+
62
+ def __init__(self):
63
+ self._lock = threading.Lock()
64
+ self._counts = {}
65
+
66
+ def feed(self, line: str):
67
+ try:
68
+ d = json.loads(line)
69
+ except ValueError:
70
+ return
71
+ if not isinstance(d, dict):
72
+ return
73
+ action = str(d.get("Action") or d.get("status") or "")
74
+ if action:
75
+ with self._lock:
76
+ self._counts[action] = self._counts.get(action, 0) + 1
77
+
78
+ def count(self, action: str) -> int:
79
+ with self._lock:
80
+ return self._counts.get(action, 0)