tracefork 0.1.0__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.
- tracefork/__init__.py +6 -0
- tracefork/blame.py +296 -0
- tracefork/cli.py +367 -0
- tracefork/constants.py +24 -0
- tracefork/faults.py +129 -0
- tracefork/fork.py +173 -0
- tracefork/nondet.py +96 -0
- tracefork/py.typed +0 -0
- tracefork/recorder.py +140 -0
- tracefork/replay.py +119 -0
- tracefork/report.py +131 -0
- tracefork/server.py +73 -0
- tracefork/store.py +123 -0
- tracefork/synthetic.py +104 -0
- tracefork/tape.py +135 -0
- tracefork/transport.py +137 -0
- tracefork/validate.py +177 -0
- tracefork/web/report.html +209 -0
- tracefork/wire.py +76 -0
- tracefork-0.1.0.dist-info/METADATA +235 -0
- tracefork-0.1.0.dist-info/RECORD +32 -0
- tracefork-0.1.0.dist-info/WHEEL +4 -0
- tracefork-0.1.0.dist-info/entry_points.txt +2 -0
- tracefork-0.1.0.dist-info/licenses/LICENSE +21 -0
- tracefork_spike/__init__.py +7 -0
- tracefork_spike/__main__.py +3 -0
- tracefork_spike/agent.py +91 -0
- tracefork_spike/fake_llm.py +106 -0
- tracefork_spike/nondet.py +97 -0
- tracefork_spike/spike.py +125 -0
- tracefork_spike/tape.py +79 -0
- tracefork_spike/transport.py +68 -0
tracefork/report.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Report generator: produces a self-contained HTML file from a Tape.
|
|
2
|
+
|
|
3
|
+
In static mode, the entire tape data is serialized as JSON and injected
|
|
4
|
+
into the HTML template as `window.__TRACEFORK_DATA__ = {...}`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_INJECT_MARKER = "</head>"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _template_path() -> Path:
|
|
16
|
+
"""Locate ``web/report.html`` in both an installed wheel and a source checkout.
|
|
17
|
+
|
|
18
|
+
A built wheel force-includes the file at ``tracefork/web/report.html`` (next to
|
|
19
|
+
this module); an editable/source checkout keeps it at the repo root. Resolved at
|
|
20
|
+
call time so importing this module never depends on the file's location.
|
|
21
|
+
"""
|
|
22
|
+
here = Path(__file__).parent
|
|
23
|
+
for cand in (
|
|
24
|
+
here / "web" / "report.html", # installed wheel (force-included)
|
|
25
|
+
here.parent.parent / "web" / "report.html", # repo root (src/tracefork -> repo)
|
|
26
|
+
):
|
|
27
|
+
if cand.exists():
|
|
28
|
+
return cand
|
|
29
|
+
raise FileNotFoundError("web/report.html not found (looked in the package and the repo root)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tape_to_data(tape, blame: dict | None = None) -> dict:
|
|
33
|
+
"""Convert a Tape to the JSON shape expected by the web UI."""
|
|
34
|
+
exchanges = []
|
|
35
|
+
for req_bytes, resp_bytes in tape.exchanges:
|
|
36
|
+
try:
|
|
37
|
+
req_json = json.loads(req_bytes.decode())
|
|
38
|
+
except Exception:
|
|
39
|
+
req_json = {"_raw": req_bytes.hex()}
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
resp_json = json.loads(resp_bytes.decode())
|
|
43
|
+
except Exception:
|
|
44
|
+
# SSE stream — extract first data line
|
|
45
|
+
lines = resp_bytes.decode(errors="replace").splitlines()
|
|
46
|
+
data_lines = [
|
|
47
|
+
line[6:] for line in lines if line.startswith("data: ") and line != "data: [DONE]"
|
|
48
|
+
]
|
|
49
|
+
try:
|
|
50
|
+
resp_json = json.loads(data_lines[0]) if data_lines else {"_raw": "sse"}
|
|
51
|
+
except Exception:
|
|
52
|
+
resp_json = {"_raw": "sse"}
|
|
53
|
+
|
|
54
|
+
# Determine role from response
|
|
55
|
+
role = "unknown"
|
|
56
|
+
if isinstance(resp_json, dict):
|
|
57
|
+
if resp_json.get("type") == "message":
|
|
58
|
+
role = "assistant"
|
|
59
|
+
elif resp_json.get("role") == "user":
|
|
60
|
+
role = "user"
|
|
61
|
+
if "messages" in req_json:
|
|
62
|
+
msgs = req_json["messages"]
|
|
63
|
+
if msgs:
|
|
64
|
+
role = msgs[-1].get("role", role)
|
|
65
|
+
|
|
66
|
+
# Preview: first 80 chars of last user message or response text
|
|
67
|
+
preview = ""
|
|
68
|
+
try:
|
|
69
|
+
if isinstance(resp_json, dict) and resp_json.get("content"):
|
|
70
|
+
for block in resp_json["content"]:
|
|
71
|
+
if block.get("type") == "text":
|
|
72
|
+
preview = block["text"][:80]
|
|
73
|
+
break
|
|
74
|
+
if block.get("type") == "tool_use":
|
|
75
|
+
tool_input_preview = json.dumps(block.get("input", {}))[:60]
|
|
76
|
+
preview = f"→ {block.get('name', 'tool')}({tool_input_preview})"
|
|
77
|
+
break
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
exchanges.append(
|
|
82
|
+
{
|
|
83
|
+
"role": role,
|
|
84
|
+
"preview": preview,
|
|
85
|
+
"request": req_json,
|
|
86
|
+
"response_preview": resp_json,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"agent_name": tape.agent_name,
|
|
92
|
+
"exchanges": exchanges,
|
|
93
|
+
"blame": blame or {},
|
|
94
|
+
"created_at": "",
|
|
95
|
+
"fingerprint": tape.digest()[:16],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _safe_json(data: dict) -> str:
|
|
100
|
+
"""Serialize `data` and escape HTML-significant chars so recorded agent I/O
|
|
101
|
+
(which can contain ``</script>``) cannot break out of the inline <script>.
|
|
102
|
+
|
|
103
|
+
Replacing ``< > &`` with their ``\\uXXXX`` forms yields valid JSON string
|
|
104
|
+
escapes, so the loader's parse still works. The JS line separators
|
|
105
|
+
U+2028/U+2029 are already emitted as ``\\u`` escapes by ``ensure_ascii=True``.
|
|
106
|
+
"""
|
|
107
|
+
return (
|
|
108
|
+
json.dumps(data, indent=2)
|
|
109
|
+
.replace("<", "\\u003c")
|
|
110
|
+
.replace(">", "\\u003e")
|
|
111
|
+
.replace("&", "\\u0026")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def generate_report(
|
|
116
|
+
tape,
|
|
117
|
+
output_path: Path,
|
|
118
|
+
*,
|
|
119
|
+
blame: dict | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Write a self-contained HTML report to `output_path`.
|
|
122
|
+
|
|
123
|
+
The tape data is injected before </head> so the UI loads it synchronously.
|
|
124
|
+
"""
|
|
125
|
+
html = _template_path().read_text()
|
|
126
|
+
data = _tape_to_data(tape, blame)
|
|
127
|
+
inject = f"\n<script>\nwindow.__TRACEFORK_DATA__ = {_safe_json(data)};\n</script>\n"
|
|
128
|
+
html = html.replace(_INJECT_MARKER, inject + _INJECT_MARKER, 1)
|
|
129
|
+
output_path = Path(output_path)
|
|
130
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
output_path.write_text(html)
|
tracefork/server.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""FastAPI server for tracefork live mode.
|
|
2
|
+
|
|
3
|
+
Serves the report HTML at / and JSON endpoints at /api/run/{run_id}
|
|
4
|
+
and /api/branch/{branch_id}. Single-threaded (uvicorn --workers 1).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
11
|
+
|
|
12
|
+
from .report import _tape_to_data, _template_path
|
|
13
|
+
from .store import TapeStore
|
|
14
|
+
|
|
15
|
+
# No CORS middleware: the UI is served same-origin by this app and uvicorn
|
|
16
|
+
# binds to 127.0.0.1 (see the `serve` CLI command), so cross-origin access is
|
|
17
|
+
# neither needed nor desirable.
|
|
18
|
+
app = FastAPI(title="tracefork", docs_url=None, redoc_url=None)
|
|
19
|
+
|
|
20
|
+
_store: TapeStore | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_store() -> TapeStore:
|
|
24
|
+
if _store is None:
|
|
25
|
+
raise RuntimeError("Store not initialized — call init_store() first")
|
|
26
|
+
return _store
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init_store(db_path: str = "store.db") -> None:
|
|
30
|
+
global _store
|
|
31
|
+
_store = TapeStore(db_path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.get("/", response_class=HTMLResponse)
|
|
35
|
+
async def serve_ui() -> HTMLResponse:
|
|
36
|
+
html = _template_path().read_text()
|
|
37
|
+
# Empty server URL → the UI fetches same-origin (works on any --port).
|
|
38
|
+
inject = "\n<script>\nwindow.__TRACEFORK_SERVER_URL__ = '';\n</script>\n"
|
|
39
|
+
html = html.replace("</head>", inject + "</head>", 1)
|
|
40
|
+
return HTMLResponse(html)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.get("/api/runs")
|
|
44
|
+
async def list_runs() -> JSONResponse:
|
|
45
|
+
store = get_store()
|
|
46
|
+
return JSONResponse(store.list_runs())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/api/run/{run_id}")
|
|
50
|
+
async def get_run(run_id: str) -> JSONResponse:
|
|
51
|
+
store = get_store()
|
|
52
|
+
try:
|
|
53
|
+
tape = store.load_tape(run_id)
|
|
54
|
+
except KeyError:
|
|
55
|
+
raise HTTPException(status_code=404, detail=f"run {run_id!r} not found") from None
|
|
56
|
+
data = _tape_to_data(tape)
|
|
57
|
+
data["run_id"] = run_id
|
|
58
|
+
return JSONResponse(data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/api/branch/{branch_id}")
|
|
62
|
+
async def get_branch(branch_id: str) -> JSONResponse:
|
|
63
|
+
store = get_store()
|
|
64
|
+
try:
|
|
65
|
+
branch = store.load_branch(branch_id)
|
|
66
|
+
except KeyError:
|
|
67
|
+
raise HTTPException(status_code=404, detail=f"branch {branch_id!r} not found") from None
|
|
68
|
+
data = _tape_to_data(branch["delta_tape"])
|
|
69
|
+
data["branch_id"] = branch_id
|
|
70
|
+
data["parent_run_id"] = branch["parent_run_id"]
|
|
71
|
+
data["divergence_step"] = branch["divergence_step"]
|
|
72
|
+
data["mutation_desc"] = branch["mutation_desc"]
|
|
73
|
+
return JSONResponse(data)
|
tracefork/store.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""TapeStore — SQLite-backed persistence for tapes and branch metadata.
|
|
2
|
+
|
|
3
|
+
Schema:
|
|
4
|
+
tapes (run_id TEXT PK, agent_name TEXT, tape_bytes BLOB, created_at TEXT)
|
|
5
|
+
branches(branch_id TEXT PK, parent_run_id TEXT, divergence_step INT,
|
|
6
|
+
delta_tape_bytes BLOB, mutation_desc TEXT, created_at TEXT)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from .tape import Tape
|
|
15
|
+
|
|
16
|
+
_DDL = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS tapes (
|
|
18
|
+
run_id TEXT PRIMARY KEY,
|
|
19
|
+
agent_name TEXT NOT NULL,
|
|
20
|
+
tape_bytes BLOB NOT NULL,
|
|
21
|
+
created_at TEXT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS branches (
|
|
25
|
+
branch_id TEXT PRIMARY KEY,
|
|
26
|
+
parent_run_id TEXT NOT NULL,
|
|
27
|
+
divergence_step INTEGER NOT NULL,
|
|
28
|
+
delta_tape_bytes BLOB NOT NULL,
|
|
29
|
+
mutation_desc TEXT NOT NULL DEFAULT '',
|
|
30
|
+
created_at TEXT NOT NULL,
|
|
31
|
+
FOREIGN KEY(parent_run_id) REFERENCES tapes(run_id)
|
|
32
|
+
);
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TapeStore:
|
|
37
|
+
"""SQLite-backed store for tapes and branches."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, db_path: str = "store.db") -> None:
|
|
40
|
+
self._path = db_path
|
|
41
|
+
self._con = sqlite3.connect(db_path, check_same_thread=False)
|
|
42
|
+
self._con.executescript(_DDL)
|
|
43
|
+
self._con.commit()
|
|
44
|
+
|
|
45
|
+
# ── tapes ──────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def save_tape(self, tape: Tape, *, run_id: str | None = None, created_at: str = "") -> str:
|
|
48
|
+
rid = run_id or uuid.uuid4().hex[:12]
|
|
49
|
+
blob = tape.to_bytes()
|
|
50
|
+
self._con.execute(
|
|
51
|
+
"INSERT OR REPLACE INTO tapes(run_id, agent_name, tape_bytes, created_at) "
|
|
52
|
+
"VALUES(?,?,?,?)",
|
|
53
|
+
(rid, tape.agent_name, blob, created_at),
|
|
54
|
+
)
|
|
55
|
+
self._con.commit()
|
|
56
|
+
return rid
|
|
57
|
+
|
|
58
|
+
def load_tape(self, run_id: str) -> Tape:
|
|
59
|
+
row = self._con.execute("SELECT tape_bytes FROM tapes WHERE run_id=?", (run_id,)).fetchone()
|
|
60
|
+
if row is None:
|
|
61
|
+
raise KeyError(f"run_id {run_id!r} not found")
|
|
62
|
+
return Tape.from_bytes(bytes(row[0]))
|
|
63
|
+
|
|
64
|
+
def list_runs(self) -> list[dict]:
|
|
65
|
+
rows = self._con.execute(
|
|
66
|
+
"SELECT run_id, agent_name, created_at FROM tapes ORDER BY created_at DESC"
|
|
67
|
+
).fetchall()
|
|
68
|
+
return [{"run_id": r[0], "agent_name": r[1], "created_at": r[2]} for r in rows]
|
|
69
|
+
|
|
70
|
+
# ── branches ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def save_branch(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
parent_run_id: str,
|
|
76
|
+
divergence_step: int,
|
|
77
|
+
delta_tape: Tape,
|
|
78
|
+
mutation_desc: str = "",
|
|
79
|
+
created_at: str = "",
|
|
80
|
+
) -> str:
|
|
81
|
+
bid = uuid.uuid4().hex[:12]
|
|
82
|
+
blob = delta_tape.to_bytes()
|
|
83
|
+
self._con.execute(
|
|
84
|
+
"""INSERT INTO branches
|
|
85
|
+
(branch_id, parent_run_id, divergence_step, delta_tape_bytes,
|
|
86
|
+
mutation_desc, created_at)
|
|
87
|
+
VALUES(?,?,?,?,?,?)""",
|
|
88
|
+
(bid, parent_run_id, divergence_step, blob, mutation_desc, created_at),
|
|
89
|
+
)
|
|
90
|
+
self._con.commit()
|
|
91
|
+
return bid
|
|
92
|
+
|
|
93
|
+
def load_branch(self, branch_id: str) -> dict:
|
|
94
|
+
row = self._con.execute(
|
|
95
|
+
"""SELECT branch_id, parent_run_id, divergence_step,
|
|
96
|
+
delta_tape_bytes, mutation_desc, created_at
|
|
97
|
+
FROM branches WHERE branch_id=?""",
|
|
98
|
+
(branch_id,),
|
|
99
|
+
).fetchone()
|
|
100
|
+
if row is None:
|
|
101
|
+
raise KeyError(f"branch_id {branch_id!r} not found")
|
|
102
|
+
return {
|
|
103
|
+
"branch_id": row[0],
|
|
104
|
+
"parent_run_id": row[1],
|
|
105
|
+
"divergence_step": row[2],
|
|
106
|
+
"delta_tape": Tape.from_bytes(bytes(row[3])),
|
|
107
|
+
"mutation_desc": row[4],
|
|
108
|
+
"created_at": row[5],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def list_branches(self, parent_run_id: str) -> list[dict]:
|
|
112
|
+
rows = self._con.execute(
|
|
113
|
+
"""SELECT branch_id, divergence_step, mutation_desc, created_at
|
|
114
|
+
FROM branches WHERE parent_run_id=? ORDER BY created_at DESC""",
|
|
115
|
+
(parent_run_id,),
|
|
116
|
+
).fetchall()
|
|
117
|
+
return [
|
|
118
|
+
{"branch_id": r[0], "divergence_step": r[1], "mutation_desc": r[2], "created_at": r[3]}
|
|
119
|
+
for r in rows
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
self._con.close()
|
tracefork/synthetic.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Synthetic Anthropic transports — offline, deterministic stand-ins for the API.
|
|
2
|
+
|
|
3
|
+
These serve real Anthropic wire-format bytes (built by `tracefork.wire`) so the
|
|
4
|
+
genuine SDK parses them, but never touch the network. They are production
|
|
5
|
+
components: the self-validation suite (`tracefork validate`) and the test suite
|
|
6
|
+
both drive the recorder/fork/blame machinery through them at $0.
|
|
7
|
+
|
|
8
|
+
- `ScriptedFakeLLM` — returns a fixed list of responses in order.
|
|
9
|
+
- `AsyncScriptedFakeLLM`— async variant.
|
|
10
|
+
- `FaultAwareFakeLLM` — returns a *failure* script when a fault marker
|
|
11
|
+
appears in the request body, else a *normal* script; this is how an
|
|
12
|
+
injected fault propagates into a flipped outcome during validation.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScriptedFakeLLM(httpx.BaseTransport):
|
|
21
|
+
"""Returns scripted Anthropic wire-format responses in sequence.
|
|
22
|
+
|
|
23
|
+
Pass a list of response bytes (from make_text_response / make_tool_use_response).
|
|
24
|
+
Raises ScriptExhausted if more requests arrive than the script has responses.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
class ScriptExhausted(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def __init__(self, responses: list[bytes]) -> None:
|
|
31
|
+
self._responses = list(responses)
|
|
32
|
+
self._i = 0
|
|
33
|
+
self.requests_received: list[bytes] = []
|
|
34
|
+
|
|
35
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
36
|
+
self.requests_received.append(request.content)
|
|
37
|
+
if self._i >= len(self._responses):
|
|
38
|
+
raise self.ScriptExhausted(
|
|
39
|
+
f"ScriptedFakeLLM exhausted after {len(self._responses)} responses"
|
|
40
|
+
)
|
|
41
|
+
resp = self._responses[self._i]
|
|
42
|
+
self._i += 1
|
|
43
|
+
return httpx.Response(
|
|
44
|
+
200,
|
|
45
|
+
headers={"content-type": "application/json"},
|
|
46
|
+
content=resp,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncScriptedFakeLLM(httpx.AsyncBaseTransport):
|
|
51
|
+
"""Async variant of ScriptedFakeLLM."""
|
|
52
|
+
|
|
53
|
+
class ScriptExhausted(RuntimeError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def __init__(self, responses: list[bytes]) -> None:
|
|
57
|
+
self._responses = list(responses)
|
|
58
|
+
self._i = 0
|
|
59
|
+
self.requests_received: list[bytes] = []
|
|
60
|
+
|
|
61
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
62
|
+
self.requests_received.append(request.content)
|
|
63
|
+
if self._i >= len(self._responses):
|
|
64
|
+
raise self.ScriptExhausted(
|
|
65
|
+
f"AsyncScriptedFakeLLM exhausted after {len(self._responses)} responses"
|
|
66
|
+
)
|
|
67
|
+
resp = self._responses[self._i]
|
|
68
|
+
self._i += 1
|
|
69
|
+
return httpx.Response(
|
|
70
|
+
200,
|
|
71
|
+
headers={"content-type": "application/json"},
|
|
72
|
+
content=resp,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FaultAwareFakeLLM(httpx.BaseTransport):
|
|
77
|
+
"""Returns different response scripts based on a fault marker in the request.
|
|
78
|
+
|
|
79
|
+
If `fault_marker` (bytes) appears anywhere in the request body, serve
|
|
80
|
+
`fault_responses`; otherwise serve `normal_responses`. Each script cycles
|
|
81
|
+
independently. This lets an injected fault — which the agent echoes into a
|
|
82
|
+
later request — deterministically flip the run's outcome.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
normal_responses: list[bytes],
|
|
88
|
+
fault_responses: list[bytes],
|
|
89
|
+
fault_marker: bytes,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._normal = list(normal_responses)
|
|
92
|
+
self._fault = list(fault_responses)
|
|
93
|
+
self._marker = fault_marker
|
|
94
|
+
self._normal_i = 0
|
|
95
|
+
self._fault_i = 0
|
|
96
|
+
|
|
97
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
98
|
+
if self._marker in request.content:
|
|
99
|
+
resp = self._fault[self._fault_i % len(self._fault)]
|
|
100
|
+
self._fault_i += 1
|
|
101
|
+
else:
|
|
102
|
+
resp = self._normal[self._normal_i % len(self._normal)]
|
|
103
|
+
self._normal_i += 1
|
|
104
|
+
return httpx.Response(200, headers={"content-type": "application/json"}, content=resp)
|
tracefork/tape.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Content-addressed, zstd-compressed, persistable tape.
|
|
2
|
+
|
|
3
|
+
A tape is the recorded artifact of one agent run: ordered HTTP exchanges
|
|
4
|
+
(request body + response body) and nondeterminism draws. Blobs are stored
|
|
5
|
+
content-addressed (keyed by sha256) and zstd-compressed so identical bytes
|
|
6
|
+
are stored once. `digest()` is a hash chain over all draws + exchanges.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import sqlite3
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import zstandard as zstd
|
|
16
|
+
|
|
17
|
+
from .constants import BOUNDARY_V1
|
|
18
|
+
|
|
19
|
+
_ZCTX = zstd.ZstdCompressor(level=3)
|
|
20
|
+
_DCTX = zstd.ZstdDecompressor()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha256_hex(data: bytes) -> str:
|
|
24
|
+
return hashlib.sha256(data).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Tape:
|
|
29
|
+
exchanges: list[tuple[bytes, bytes]] = field(default_factory=list)
|
|
30
|
+
draws: list[tuple[str, str]] = field(default_factory=list)
|
|
31
|
+
boundary: str = BOUNDARY_V1
|
|
32
|
+
agent_name: str = ""
|
|
33
|
+
|
|
34
|
+
def append_exchange(self, request_body: bytes, response_body: bytes) -> None:
|
|
35
|
+
self.exchanges.append((request_body, response_body))
|
|
36
|
+
|
|
37
|
+
def exchange(self, i: int) -> tuple[bytes, bytes]:
|
|
38
|
+
return self.exchanges[i]
|
|
39
|
+
|
|
40
|
+
def digest(self) -> str:
|
|
41
|
+
"""sha256 hash chain over draws then exchanges — the tape fingerprint."""
|
|
42
|
+
h = hashlib.sha256()
|
|
43
|
+
for kind, value in self.draws:
|
|
44
|
+
h.update(b"D:" + kind.encode() + b":" + value.encode() + b"\n")
|
|
45
|
+
for req, resp in self.exchanges:
|
|
46
|
+
h.update(b"X:" + sha256_hex(req).encode() + b":" + sha256_hex(resp).encode() + b"\n")
|
|
47
|
+
return h.hexdigest()
|
|
48
|
+
|
|
49
|
+
def to_bytes(self) -> bytes:
|
|
50
|
+
"""Serialize tape to JSON bytes (base64-encoded for binary exchange fields)."""
|
|
51
|
+
import base64
|
|
52
|
+
import json
|
|
53
|
+
|
|
54
|
+
return json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"exchanges": [
|
|
57
|
+
[base64.b64encode(req).decode(), base64.b64encode(resp).decode()]
|
|
58
|
+
for req, resp in self.exchanges
|
|
59
|
+
],
|
|
60
|
+
"draws": self.draws,
|
|
61
|
+
"boundary": self.boundary,
|
|
62
|
+
"agent_name": self.agent_name,
|
|
63
|
+
}
|
|
64
|
+
).encode()
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_bytes(cls, data: bytes) -> Tape:
|
|
68
|
+
"""Deserialize tape from JSON bytes produced by to_bytes()."""
|
|
69
|
+
import base64
|
|
70
|
+
import json
|
|
71
|
+
|
|
72
|
+
d = json.loads(data)
|
|
73
|
+
tape = cls(
|
|
74
|
+
boundary=d["boundary"],
|
|
75
|
+
agent_name=d["agent_name"],
|
|
76
|
+
)
|
|
77
|
+
tape.draws = [tuple(pair) for pair in d["draws"]]
|
|
78
|
+
tape.exchanges = [
|
|
79
|
+
(base64.b64decode(req), base64.b64decode(resp)) for req, resp in d["exchanges"]
|
|
80
|
+
]
|
|
81
|
+
return tape
|
|
82
|
+
|
|
83
|
+
def save(self, path: str) -> None:
|
|
84
|
+
con = sqlite3.connect(path)
|
|
85
|
+
try:
|
|
86
|
+
con.executescript("""
|
|
87
|
+
DROP TABLE IF EXISTS blobs;
|
|
88
|
+
DROP TABLE IF EXISTS events;
|
|
89
|
+
DROP TABLE IF EXISTS meta;
|
|
90
|
+
CREATE TABLE blobs (hash TEXT PRIMARY KEY, data BLOB NOT NULL);
|
|
91
|
+
CREATE TABLE events (
|
|
92
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
kind TEXT NOT NULL, a TEXT NOT NULL, b TEXT NOT NULL
|
|
94
|
+
);
|
|
95
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
96
|
+
""")
|
|
97
|
+
for kind, value in self.draws:
|
|
98
|
+
con.execute("INSERT INTO events (kind, a, b) VALUES ('draw', ?, ?)", (kind, value))
|
|
99
|
+
for req, resp in self.exchanges:
|
|
100
|
+
rh, sh = sha256_hex(req), sha256_hex(resp)
|
|
101
|
+
con.execute(
|
|
102
|
+
"INSERT OR IGNORE INTO blobs VALUES (?, ?)",
|
|
103
|
+
(rh, _ZCTX.compress(req)),
|
|
104
|
+
)
|
|
105
|
+
con.execute(
|
|
106
|
+
"INSERT OR IGNORE INTO blobs VALUES (?, ?)",
|
|
107
|
+
(sh, _ZCTX.compress(resp)),
|
|
108
|
+
)
|
|
109
|
+
con.execute("INSERT INTO events (kind, a, b) VALUES ('exchange', ?, ?)", (rh, sh))
|
|
110
|
+
con.execute("INSERT INTO meta VALUES ('boundary', ?)", (self.boundary,))
|
|
111
|
+
con.execute("INSERT INTO meta VALUES ('agent_name', ?)", (self.agent_name,))
|
|
112
|
+
con.execute("INSERT INTO meta VALUES ('schema_version', '1')")
|
|
113
|
+
con.commit()
|
|
114
|
+
finally:
|
|
115
|
+
con.close()
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def load(cls, path: str) -> Tape:
|
|
119
|
+
con = sqlite3.connect(path)
|
|
120
|
+
try:
|
|
121
|
+
raw_blobs = dict(con.execute("SELECT hash, data FROM blobs").fetchall())
|
|
122
|
+
blobs = {k: _DCTX.decompress(bytes(v)) for k, v in raw_blobs.items()}
|
|
123
|
+
meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
|
|
124
|
+
tape = cls(
|
|
125
|
+
boundary=meta.get("boundary", BOUNDARY_V1),
|
|
126
|
+
agent_name=meta.get("agent_name", ""),
|
|
127
|
+
)
|
|
128
|
+
for kind, a, b in con.execute("SELECT kind, a, b FROM events ORDER BY seq").fetchall():
|
|
129
|
+
if kind == "draw":
|
|
130
|
+
tape.draws.append((a, b))
|
|
131
|
+
elif kind == "exchange":
|
|
132
|
+
tape.exchanges.append((blobs[a], blobs[b]))
|
|
133
|
+
return tape
|
|
134
|
+
finally:
|
|
135
|
+
con.close()
|