backspin 0.5.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.
backspin/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """backspin — the flight recorder for AI agents.
2
+
3
+ Record every LLM and tool call of an agent run to one portable file,
4
+ replay the run deterministically without any API access, and diff two
5
+ runs to find the exact step where behavior diverged.
6
+ """
7
+ from .diff import DiffReport, StepDiff, diff_runs
8
+ from .recorder import Recorder
9
+ from .replay import (
10
+ Cassette,
11
+ ReplayMismatch,
12
+ ReplayMismatchWarning,
13
+ branch,
14
+ branch_agent,
15
+ patch_openai,
16
+ stub_client,
17
+ )
18
+ from .runfile import FILE_SUFFIX, Run, load_run
19
+
20
+ __version__ = "0.5.1"
21
+
22
+ __all__ = [
23
+ "FILE_SUFFIX",
24
+ "Cassette",
25
+ "DiffReport",
26
+ "Recorder",
27
+ "ReplayMismatch",
28
+ "ReplayMismatchWarning",
29
+ "Run",
30
+ "StepDiff",
31
+ "__version__",
32
+ "branch",
33
+ "branch_agent",
34
+ "diff_runs",
35
+ "load_run",
36
+ "patch_openai",
37
+ "stub_client",
38
+ ]
backspin/cli.py ADDED
@@ -0,0 +1,379 @@
1
+ """The ``backspin`` command line: ls, show, diff, ui."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import glob
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .diff import DiffReport, diff_runs
13
+ from .runfile import FILE_SUFFIX, load_run
14
+
15
+ # --- tiny ANSI helpers (ASCII output only, Windows-console safe) ----------
16
+
17
+
18
+ class C:
19
+ """ANSI helpers: c.red("text") when colors are on, plain text otherwise."""
20
+
21
+ def __init__(self, enabled: bool):
22
+ self.on = enabled
23
+
24
+ @property
25
+ def reset(self) -> str:
26
+ return "\033[0m" if self.on else ""
27
+
28
+ def _w(self, code: str, s: str) -> str:
29
+ return f"\033[{code}m{s}\033[0m" if self.on else str(s)
30
+
31
+ def dim(self, s):
32
+ return self._w("2", s)
33
+
34
+ def bold(self, s):
35
+ return self._w("1", s)
36
+
37
+ def red(self, s):
38
+ return self._w("31", s)
39
+
40
+ def green(self, s):
41
+ return self._w("32", s)
42
+
43
+ def yellow(self, s):
44
+ return self._w("33", s)
45
+
46
+ def blue(self, s):
47
+ return self._w("36", s)
48
+
49
+
50
+ def _color() -> C:
51
+ return C(sys.stdout.isatty())
52
+
53
+
54
+ def _fmt_ms(ms: float) -> str:
55
+ if ms >= 1000:
56
+ return f"{ms / 1000:.2f}s"
57
+ return f"{ms:.0f}ms"
58
+
59
+
60
+ def _find_runs(dir_path: str) -> List[str]:
61
+ pattern = os.path.join(dir_path, f"*{FILE_SUFFIX}")
62
+ return sorted(glob.glob(pattern), reverse=True)
63
+
64
+
65
+ # --- subcommands -----------------------------------------------------------
66
+
67
+
68
+ def cmd_ls(args: argparse.Namespace) -> int:
69
+ c = _color()
70
+ files = _find_runs(args.dir)
71
+ if not files:
72
+ print(c.dim(f"no {FILE_SUFFIX} files under {args.dir!r}") + c.reset)
73
+ return 1
74
+ header = f"{'run':<44} {'agent':<14} {'steps':>5} {'llm':>4} {'tool':>4} {'tokens':>7}"
75
+ print(c.bold(header) + c.reset)
76
+ for path in files:
77
+ try:
78
+ run = load_run(path)
79
+ except ValueError as exc:
80
+ print(f"{os.path.basename(path):<44} {c.red('invalid: ' + str(exc))}{c.reset}")
81
+ continue
82
+ t = run.totals()
83
+ print(
84
+ f"{os.path.basename(path):<44} {run.agent:<14} {t['steps']:>5} "
85
+ f"{t['llm_calls']:>4} {t['tool_calls']:>4} {t['total_tokens']:>7}"
86
+ )
87
+ return 0
88
+
89
+
90
+ def cmd_show(args: argparse.Namespace) -> int:
91
+ c = _color()
92
+ run = load_run(args.file)
93
+ if args.json:
94
+ print(json.dumps(run.summary() | {"events": run.events}, ensure_ascii=False, indent=2))
95
+ return 0
96
+ if args.step is not None:
97
+ for ev in run.events:
98
+ if ev.get("seq") == args.step:
99
+ print(json.dumps(ev, ensure_ascii=False, indent=2, default=str))
100
+ return 0
101
+ print(c.red(f"no step #{args.step} in {args.file}") + c.reset)
102
+ return 1
103
+
104
+ t = run.totals()
105
+ meta = run.metadata
106
+ lineage = []
107
+ if "replay_of" in meta:
108
+ lineage.append(f"replay of {meta['replay_of']}")
109
+ if "branch_of" in meta:
110
+ lineage.append(f"branch of {meta['branch_of']}")
111
+ lineage_s = f" ({', '.join(lineage)})" if lineage else ""
112
+ cost = f" ~${t['cost_usd']:.4f}" + ("" if t.get("cost_complete") else "+")
113
+ print(c.bold(f"run {run.run_id}") + f" agent={run.agent}{lineage_s}{cost}")
114
+ print(
115
+ c.dim(
116
+ f"steps={t['steps']} llm={t['llm_calls']} tool={t['tool_calls']} "
117
+ f"tokens={t['prompt_tokens']}+{t['completion_tokens']} "
118
+ f"duration={_fmt_ms(t['duration_ms'])}"
119
+ )
120
+ + c.reset
121
+ )
122
+ print()
123
+ for ev in run.events:
124
+ kind = ev.get("kind", "?")
125
+ seq = ev.get("seq", 0)
126
+ dur = _fmt_ms(ev.get("duration_ms") or 0)
127
+ indent = " " * (ev.get("depth") or 0)
128
+ if kind == "llm":
129
+ usage = ev.get("usage") or {}
130
+ tok = f"tok {usage.get('prompt_tokens', 0)}+{usage.get('completion_tokens', 0)}"
131
+ err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
132
+ print(f"{indent} #{seq:<3} llm {ev.get('model') or '?':<20} {dur:>8} {tok}{err}")
133
+ elif kind == "tool":
134
+ err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
135
+ print(f"{indent} #{seq:<3} tool {ev.get('name') or '?':<20} {dur:>8}{err}")
136
+ elif kind == "span":
137
+ phase = "enter" if ev.get("phase") == "enter" else "exit "
138
+ err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
139
+ print(
140
+ f"{indent} #{seq:<3} span {c.blue('[' + phase + '] ' + str(ev.get('name', '')))}"
141
+ f"{c.reset}{dur:>8}{err}"
142
+ )
143
+ elif kind == "log":
144
+ print(f"{indent} #{seq:<3} log {c.dim(str(ev.get('message', '')))}{c.reset}")
145
+ elif kind == "error":
146
+ etype = str(ev.get("error_type", ""))
147
+ msg = str(ev.get("message", ""))
148
+ print(f"{indent} #{seq:<3} {c.red('error ' + etype + ': ' + msg)}{c.reset}")
149
+ else:
150
+ print(f"{indent} #{seq:<3} {kind}")
151
+ print()
152
+ print(c.dim(f"inspect a step: backspin show {os.path.basename(args.file)} --step N") + c.reset)
153
+ return 0
154
+
155
+
156
+ def cmd_diff(args: argparse.Namespace) -> int:
157
+ c = _color()
158
+ a = load_run(args.a)
159
+ b = load_run(args.b)
160
+ report = diff_runs(a, b, llm_only=args.llm_only)
161
+ _print_diff(report, c)
162
+ return 0 if report.identical else 1
163
+
164
+
165
+ def _print_diff(report: DiffReport, c: C) -> None:
166
+ ta, tb = report.totals_a, report.totals_b
167
+ if report.identical:
168
+ print(c.green("runs are identical (same steps, same LLM requests)") + c.reset)
169
+ elif report.first_divergence is not None:
170
+ print(
171
+ c.yellow(f"runs diverge at step #{report.first_divergence}") + c.reset
172
+ )
173
+ else:
174
+ print(c.yellow("same requests, different step counts") + c.reset)
175
+ print(
176
+ c.dim(
177
+ f"tokens: {ta['total_tokens']} vs {tb['total_tokens']} "
178
+ f"duration: {_fmt_ms(ta['duration_ms'])} vs {_fmt_ms(tb['duration_ms'])} "
179
+ f"steps: {ta['steps']} vs {tb['steps']}"
180
+ )
181
+ + c.reset
182
+ )
183
+ print()
184
+ print(f"{'#':>4} {'kind':<5} {'A':<28} {'B':<28} {'same':>5}")
185
+ for s in report.steps:
186
+ la = s.a["label"] if s.a else "-"
187
+ lb = s.b["label"] if s.b else "-"
188
+ if s.same is None:
189
+ mark = c.red("solo")
190
+ elif s.same:
191
+ mark = c.green("yes")
192
+ else:
193
+ mark = c.red("NO")
194
+ print(f"{s.index:>4} {s.kind:<5} {la:<28} {lb:<28} {mark}")
195
+
196
+
197
+ def cmd_ui(args: argparse.Namespace) -> int:
198
+ try:
199
+ import uvicorn
200
+ except ImportError:
201
+ print("the viewer needs two extra packages:")
202
+ print(" pip install 'backspin[ui]'")
203
+ return 1
204
+ from .server import create_app
205
+
206
+ app = create_app(args.dir)
207
+ print(f"backspin viewer -> http://{args.host}:{args.port} (runs dir: {args.dir})")
208
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
209
+ return 0
210
+
211
+
212
+ def cmd_branch(args: argparse.Namespace) -> int:
213
+ c = _color()
214
+ from .replay import branch as make_branch
215
+
216
+ change: dict = {}
217
+ if args.content is not None:
218
+ change["content"] = args.content
219
+ if args.tool_args:
220
+ try:
221
+ change["tool_arguments"] = json.loads(args.tool_args)
222
+ except json.JSONDecodeError as exc:
223
+ print(c.red(f"--tool-args is not valid JSON: {exc}") + c.reset)
224
+ return 2
225
+ if not change:
226
+ print(c.red("nothing to mutate: pass --content and/or --tool-args") + c.reset)
227
+ return 2
228
+
229
+ path = make_branch(args.file, {args.step: change}, dir=args.dir)
230
+ print(c.bold("branch run:") + f" {path}")
231
+ print()
232
+ report = diff_runs(load_run(args.file), load_run(path), llm_only=True)
233
+ _print_diff(report, c)
234
+ return 0
235
+
236
+
237
+ def cmd_proxy(args: argparse.Namespace) -> int:
238
+ if bool(args.upstream) == bool(args.replay):
239
+ print("choose exactly one: --upstream URL (record) or --replay FILE (replay)")
240
+ return 2
241
+ try:
242
+ import uvicorn
243
+ except ImportError:
244
+ print("proxy mode needs: pip install 'backspin[proxy]'")
245
+ return 1
246
+ from .proxy import create_proxy_app
247
+
248
+ cassette = None
249
+ if args.replay:
250
+ from .replay import Cassette
251
+
252
+ cassette = Cassette.from_run(load_run(args.replay))
253
+ app = create_proxy_app(
254
+ upstream=args.upstream, cassette=cassette, runs_dir=args.dir
255
+ )
256
+ mode = (
257
+ f"replay of {os.path.basename(args.replay)}" if args.replay
258
+ else f"record -> {args.upstream}"
259
+ )
260
+ print(f"backspin proxy [{mode}]")
261
+ print(f"endpoint : http://{args.host}:{args.port}/v1 (point your agent's base_url here)")
262
+ if cassette is None:
263
+ print(f"recording: {app.state.recorder.path}")
264
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
265
+ return 0
266
+
267
+
268
+ def cmd_export(args: argparse.Namespace) -> int:
269
+ from .export import export
270
+
271
+ text = export(load_run(args.file), fmt=args.format)
272
+ if args.out:
273
+ from pathlib import Path
274
+
275
+ Path(args.out).write_text(text, encoding="utf-8")
276
+ print(f"wrote {args.out} ({len(text.splitlines())} lines)")
277
+ else:
278
+ sys.stdout.write(text)
279
+ return 0
280
+
281
+
282
+ def cmd_share(args: argparse.Namespace) -> int:
283
+ from .share import write_share_html
284
+
285
+ out = write_share_html(args.file, args.out)
286
+ print(f"shared viewer written to: {out}")
287
+ print("send it to anyone — it opens in a browser, no install needed")
288
+ return 0
289
+
290
+
291
+ def cmd_tui(args: argparse.Namespace) -> int:
292
+ from .tui import run_tui
293
+
294
+ run_tui(args.dir)
295
+ return 0
296
+
297
+
298
+ # --- parser -----------------------------------------------------------------
299
+
300
+
301
+ def build_parser() -> argparse.ArgumentParser:
302
+ p = argparse.ArgumentParser(
303
+ prog="backspin",
304
+ description="The flight recorder for AI agents: record, replay, diff.",
305
+ )
306
+ p.add_argument("--version", action="version", version=f"backspin {__version__}")
307
+ sub = p.add_subparsers(dest="cmd", required=True)
308
+
309
+ ls = sub.add_parser("ls", help="list recorded runs in a directory")
310
+ ls.add_argument("dir", nargs="?", default="runs", help="runs directory (default: runs)")
311
+ ls.set_defaults(fn=cmd_ls)
312
+
313
+ show = sub.add_parser("show", help="show one run's timeline")
314
+ show.add_argument("file", help="path to a .backspin.jsonl run file")
315
+ show.add_argument("--step", type=int, default=None, help="print one step as JSON")
316
+ show.add_argument("--json", action="store_true", help="dump the whole run as JSON")
317
+ show.set_defaults(fn=cmd_show)
318
+
319
+ diff = sub.add_parser("diff", help="diff two runs; exits 1 when they differ")
320
+ diff.add_argument("a", help="first run file")
321
+ diff.add_argument("b", help="second run file")
322
+ diff.add_argument("--llm-only", action="store_true", help="align LLM calls only")
323
+ diff.set_defaults(fn=cmd_diff)
324
+
325
+ br = sub.add_parser("branch", help="what-if: replay a run with one answer mutated")
326
+ br.add_argument("file", help="run file to branch from")
327
+ br.add_argument("--step", type=int, required=True, help="0-based LLM-call index to mutate")
328
+ br.add_argument("--content", default=None, help="replacement assistant content")
329
+ br.add_argument(
330
+ "--tool-args", default=None,
331
+ help='replacement tool args as JSON, e.g. \'{"city": "Rome"}\'',
332
+ )
333
+ br.add_argument("--dir", default="runs", help="where to write the branch run")
334
+ br.set_defaults(fn=cmd_branch)
335
+
336
+ px = sub.add_parser("proxy", help="OpenAI-compatible local proxy: record or replay")
337
+ px.add_argument("--upstream", default=None, help="record mode: e.g. https://api.openai.com")
338
+ px.add_argument("--replay", default=None, help="replay mode: a recorded run file")
339
+ px.add_argument("--dir", default="runs", help="where recorded runs are written")
340
+ px.add_argument("--host", default="127.0.0.1")
341
+ px.add_argument("--port", type=int, default=8840)
342
+ px.set_defaults(fn=cmd_proxy)
343
+
344
+ ex = sub.add_parser("export", help="export a run as a dataset (JSONL)")
345
+ ex.add_argument("file", help="run file to export")
346
+ ex.add_argument("--format", choices=["pairs", "sft"], default="pairs",
347
+ help="pairs: one line per LLM call; sft: one chat sample per run")
348
+ ex.add_argument("-o", "--out", default=None, help="output file (default: stdout)")
349
+ ex.set_defaults(fn=cmd_export)
350
+
351
+ sh = sub.add_parser("share", help="bundle a run + viewer into one HTML file")
352
+ sh.add_argument("file", help="run file to share")
353
+ sh.add_argument("-o", "--out", default=None, help="output path (default: <run>.share.html)")
354
+ sh.set_defaults(fn=cmd_share)
355
+
356
+ tui = sub.add_parser("tui", help="keyboard-driven terminal viewer")
357
+ tui.add_argument("--dir", default="runs", help="runs directory")
358
+ tui.set_defaults(fn=cmd_tui)
359
+
360
+ ui = sub.add_parser("ui", help="launch the local timeline viewer")
361
+ ui.add_argument("--dir", default="runs", help="runs directory (default: runs)")
362
+ ui.add_argument("--host", default="127.0.0.1")
363
+ ui.add_argument("--port", type=int, default=8787)
364
+ ui.set_defaults(fn=cmd_ui)
365
+
366
+ return p
367
+
368
+
369
+ def main(argv: Optional[List[str]] = None) -> int:
370
+ args = build_parser().parse_args(argv)
371
+ try:
372
+ return args.fn(args)
373
+ except (ValueError, OSError) as exc:
374
+ print(f"error: {exc}", file=sys.stderr)
375
+ return 2
376
+
377
+
378
+ if __name__ == "__main__":
379
+ sys.exit(main())
backspin/cost.py ADDED
@@ -0,0 +1,85 @@
1
+ """Token cost estimation for recorded runs.
2
+
3
+ Prices are USD per 1M tokens from public list pricing (collected 2026-08;
4
+ models drift — override or extend ``PRICE_TABLE`` as needed). Matching is
5
+ exact first, then longest-prefix, so dated snapshots like
6
+ ``gpt-4o-2024-11-20`` resolve to their base model.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, Optional, Tuple
12
+
13
+ # model prefix -> (USD per 1M input tokens, USD per 1M output tokens)
14
+ PRICE_TABLE: Dict[str, Tuple[float, float]] = {
15
+ "gpt-4o-mini": (0.15, 0.60),
16
+ "gpt-4o": (2.50, 10.00),
17
+ "gpt-4.1-mini": (0.40, 1.60),
18
+ "gpt-4.1-nano": (0.10, 0.40),
19
+ "gpt-4.1": (2.00, 8.00),
20
+ "gpt-4-turbo": (10.00, 30.00),
21
+ "gpt-4": (30.00, 60.00),
22
+ "gpt-3.5-turbo": (0.50, 1.50),
23
+ "o3-mini": (1.10, 4.40),
24
+ "o3": (2.00, 8.00),
25
+ "o4-mini": (1.10, 4.40),
26
+ "claude-opus-4": (15.00, 75.00),
27
+ "claude-sonnet-4": (3.00, 15.00),
28
+ "claude-3-7-sonnet": (3.00, 15.00),
29
+ "claude-3-5-haiku": (0.80, 4.00),
30
+ "gemini-2.5-pro": (1.25, 10.00),
31
+ "gemini-2.5-flash": (0.30, 2.50),
32
+ "deepseek-chat": (0.27, 1.10),
33
+ "deepseek-reasoner": (0.55, 2.19),
34
+ }
35
+
36
+ _prefixes = sorted(PRICE_TABLE, key=len, reverse=True)
37
+
38
+
39
+ def lookup_price(model: Optional[str]) -> Optional[Tuple[float, float]]:
40
+ """Find (input, output) per-1M-token prices for a model name."""
41
+ if not model:
42
+ return None
43
+ name = re.sub(r"^(anthropic/|openai/|google/|deepseek/)", "", model.strip(), flags=re.I)
44
+ if name in PRICE_TABLE:
45
+ return PRICE_TABLE[name]
46
+ for prefix in _prefixes:
47
+ if name.startswith(prefix):
48
+ return PRICE_TABLE[prefix]
49
+ return None
50
+
51
+
52
+ def estimate_cost(
53
+ model: Optional[str], prompt_tokens: Optional[int], completion_tokens: Optional[int]
54
+ ) -> Optional[float]:
55
+ """Cost of one LLM call in USD, or None when the model is unknown."""
56
+ price = lookup_price(model)
57
+ if price is None or not (prompt_tokens or completion_tokens):
58
+ return None
59
+ in_price, out_price = price
60
+ return (prompt_tokens or 0) / 1e6 * in_price + (completion_tokens or 0) / 1e6 * out_price
61
+
62
+
63
+ def cost_report(run: Any) -> Dict[str, Any]:
64
+ """Sum estimated cost over a run's LLM calls.
65
+
66
+ Returns ``{"total_usd": float, "complete": bool, "unknown_models": [...]}``.
67
+ ``complete`` is False when at least one LLM call used a model outside
68
+ the price table (its cost is not counted).
69
+ """
70
+ total = 0.0
71
+ unknown = set()
72
+ for ev in run.llm_calls():
73
+ usage = ev.get("usage") or {}
74
+ cost = estimate_cost(
75
+ ev.get("model"), usage.get("prompt_tokens"), usage.get("completion_tokens")
76
+ )
77
+ if cost is None:
78
+ unknown.add(ev.get("model") or "?")
79
+ else:
80
+ total += cost
81
+ return {
82
+ "total_usd": round(total, 6),
83
+ "complete": not unknown,
84
+ "unknown_models": sorted(unknown),
85
+ }
backspin/diff.py ADDED
@@ -0,0 +1,121 @@
1
+ """Compare two runs: where they diverged, and by how much.
2
+
3
+ Runs are aligned step by step. Each step gets a *signature* — what the
4
+ agent chose to do (LLM request fingerprint, tool name, log text) — so the
5
+ first mismatch marks the moment the two runs stopped doing the same thing.
6
+ Durations and token counts are reported as deltas, never compared.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from .runfile import LLM, TOOL, Run
14
+
15
+
16
+ def _signature(ev: Dict[str, Any]) -> Tuple:
17
+ kind = ev.get("kind")
18
+ if kind == LLM:
19
+ fp = ev.get("fingerprint")
20
+ if fp is None:
21
+ req = ev.get("request") or {}
22
+ fp = str(req.get("messages"))
23
+ return (LLM, fp)
24
+ if kind == TOOL:
25
+ return (TOOL, ev.get("name"))
26
+ if kind == "span":
27
+ return ("span", ev.get("phase"), ev.get("name"))
28
+ return (kind, ev.get("message") or ev.get("error_type"))
29
+
30
+
31
+ def _metrics(ev: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
32
+ if ev is None:
33
+ return None
34
+ usage = ev.get("usage") or {}
35
+ label = ev.get("model") or ev.get("name") or (ev.get("message") or "")[:60]
36
+ return {
37
+ "label": label,
38
+ "duration_ms": ev.get("duration_ms") or 0.0,
39
+ "prompt_tokens": usage.get("prompt_tokens") or 0,
40
+ "completion_tokens": usage.get("completion_tokens") or 0,
41
+ "error": ev.get("error") or None,
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class StepDiff:
47
+ index: int
48
+ kind: str
49
+ a: Optional[Dict[str, Any]]
50
+ b: Optional[Dict[str, Any]]
51
+ same: Optional[bool] # None when the step exists on only one side
52
+
53
+
54
+ @dataclass
55
+ class DiffReport:
56
+ a: Dict[str, Any]
57
+ b: Dict[str, Any]
58
+ steps: List[StepDiff] = field(default_factory=list)
59
+ first_divergence: Optional[int] = None
60
+ identical: bool = False
61
+
62
+ @property
63
+ def totals_a(self) -> Dict[str, Any]:
64
+ return self.a["totals"]
65
+
66
+ @property
67
+ def totals_b(self) -> Dict[str, Any]:
68
+ return self.b["totals"]
69
+
70
+ def to_dict(self) -> Dict[str, Any]:
71
+ return {
72
+ "a": self.a,
73
+ "b": self.b,
74
+ "steps": [asdict(s) for s in self.steps],
75
+ "first_divergence": self.first_divergence,
76
+ "identical": self.identical,
77
+ }
78
+
79
+
80
+ def diff_runs(a: Run, b: Run, *, llm_only: bool = False) -> DiffReport:
81
+ """Diff two recorded runs of (nominally) the same agent.
82
+
83
+ ``llm_only=True`` restricts the alignment to LLM calls — useful when
84
+ comparing a :func:`backspin.replay.branch` run (LLM-only shape) against
85
+ the full original.
86
+ """
87
+ if llm_only:
88
+ from .runfile import Run as _Run
89
+
90
+ a = _Run(header=a.header, events=a.llm_calls(), path=a.path)
91
+ b = _Run(header=b.header, events=b.llm_calls(), path=b.path)
92
+ sigs_a = [_signature(e) for e in a.events]
93
+ sigs_b = [_signature(e) for e in b.events]
94
+ n = max(len(a.events), len(b.events))
95
+
96
+ steps: List[StepDiff] = []
97
+ first_divergence: Optional[int] = None
98
+ identical = len(a.events) == len(b.events)
99
+
100
+ for i in range(n):
101
+ ev_a = a.events[i] if i < len(a.events) else None
102
+ ev_b = b.events[i] if i < len(b.events) else None
103
+ same: Optional[bool] = None
104
+ if ev_a is not None and ev_b is not None:
105
+ same = sigs_a[i] == sigs_b[i]
106
+ if same is False and first_divergence is None:
107
+ first_divergence = i
108
+ if same is not True:
109
+ identical = False
110
+ kind = (ev_a or ev_b or {}).get("kind", "?")
111
+ steps.append(
112
+ StepDiff(index=i, kind=kind, a=_metrics(ev_a), b=_metrics(ev_b), same=same)
113
+ )
114
+
115
+ return DiffReport(
116
+ a=a.summary(),
117
+ b=b.summary(),
118
+ steps=steps,
119
+ first_divergence=first_divergence,
120
+ identical=identical,
121
+ )
backspin/export.py ADDED
@@ -0,0 +1,71 @@
1
+ """Export recorded runs as training/eval datasets.
2
+
3
+ Two formats:
4
+
5
+ - ``pairs`` — one JSON line per LLM call: {"messages", "response", "model"}.
6
+ Good for building eval sets from real traffic.
7
+ - ``sft`` — one JSON line per run: the final conversation
8
+ ({"messages": [..., {"role": "assistant", ...}]}) in chat fine-tune style.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Dict, List
14
+
15
+ from .runfile import Run
16
+
17
+
18
+ def _assistant_text(response: Any) -> str:
19
+ """Extract plain assistant text from either protocol's response shape."""
20
+ if not isinstance(response, dict):
21
+ return ""
22
+ if "choices" in response: # OpenAI shape
23
+ message = (response.get("choices") or [{}])[0].get("message") or {}
24
+ content = message.get("content")
25
+ return content if isinstance(content, str) else ""
26
+ if "content" in response: # Anthropic shape
27
+ parts = []
28
+ for block in response.get("content") or []:
29
+ if isinstance(block, dict) and block.get("type") == "text":
30
+ parts.append(block.get("text") or "")
31
+ return "".join(parts)
32
+ return ""
33
+
34
+
35
+ def export_pairs(run: Run) -> List[Dict[str, Any]]:
36
+ rows = []
37
+ for event in run.llm_calls():
38
+ response = event.get("response")
39
+ if not response:
40
+ continue
41
+ request = dict(event.get("request") or {})
42
+ rows.append({
43
+ "messages": request.get("messages") or [],
44
+ "response": _assistant_text(response),
45
+ "model": event.get("model"),
46
+ "run_id": run.run_id,
47
+ "seq": event.get("seq"),
48
+ })
49
+ return rows
50
+
51
+
52
+ def export_sft(run: Run) -> List[Dict[str, Any]]:
53
+ calls = [e for e in run.llm_calls() if e.get("response")]
54
+ if not calls:
55
+ return []
56
+ last = calls[-1]
57
+ messages = list((last.get("request") or {}).get("messages") or [])
58
+ messages = [dict(m) for m in messages]
59
+ messages.append({"role": "assistant", "content": _assistant_text(last["response"])})
60
+ return [{"messages": messages, "model": last.get("model"), "run_id": run.run_id}]
61
+
62
+
63
+ def export(run: Run, fmt: str = "pairs") -> str:
64
+ """Export a run as JSONL text in the given format."""
65
+ if fmt == "pairs":
66
+ rows = export_pairs(run)
67
+ elif fmt == "sft":
68
+ rows = export_sft(run)
69
+ else:
70
+ raise ValueError(f"unknown export format: {fmt!r} (use 'pairs' or 'sft')")
71
+ return "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows)