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.
@@ -0,0 +1,83 @@
1
+ """pytest integration: one fixture for deterministic agent testing.
2
+
3
+ Activated automatically when backspin is installed (pytest11 entry point)::
4
+
5
+ def test_my_agent(backspin):
6
+ client = FakeOpenAI(["answer"]) # or a real OpenAI client
7
+ with backspin.record(agent="t") as rec:
8
+ run_agent(rec.capture_openai(client))
9
+ backspin.assert_replays_identically() # the run must replay clean
10
+
11
+ ``assert_replays_identically()`` loads the recording, replays every LLM
12
+ call through its cassette and fails the test on any mismatch — catching
13
+ corrupted runs and broken fingerprints before they ship.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Optional
18
+
19
+ import pytest
20
+
21
+ from . import Cassette, Recorder, load_run
22
+ from .replay import ReplayMismatch, stub_client
23
+
24
+
25
+ class _BackspinFixture:
26
+ """Recorder factory + replay assertions, scoped to the test's tmp dir."""
27
+
28
+ def __init__(self, tmp_path):
29
+ self._tmp = tmp_path
30
+ self._rec: Optional[Recorder] = None
31
+
32
+ def record(self, agent: str = "test", **kwargs: Any) -> Recorder:
33
+ self._rec = Recorder(dir=str(self._tmp), agent=agent, **kwargs)
34
+ return self._rec
35
+
36
+ @property
37
+ def path(self) -> Optional[str]:
38
+ return self._rec.path if self._rec is not None else None
39
+
40
+ def assert_replays_identically(self, rec: Optional[Recorder] = None) -> None:
41
+ """Every LLM call in the recording must replay through its cassette.
42
+
43
+ Strict: each call must match a recording by exact request
44
+ fingerprint — order fallbacks are failures, because they mean the
45
+ run is not deterministically replayable.
46
+ """
47
+ rec = rec or self._rec
48
+ if rec is None:
49
+ raise AssertionError("no recording: call backspin.record() first")
50
+ run = load_run(rec.path)
51
+ cassette = Cassette.from_run(run)
52
+ stub = stub_client(cassette)
53
+ for event in run.llm_calls():
54
+ request = dict(event.get("request") or {})
55
+ try:
56
+ resp = stub.chat.completions.create(
57
+ model=event.get("model"),
58
+ messages=request.get("messages"),
59
+ )
60
+ except ReplayMismatch as exc:
61
+ raise AssertionError(
62
+ f"step #{event.get('seq')} is not replayable: {exc}"
63
+ ) from exc
64
+ dumped = resp.model_dump()
65
+ recorded = (event.get("response") or {}).get("choices") or [{}]
66
+ replayed = dumped.get("choices") or [{}]
67
+ if recorded[0].get("message") != replayed[0].get("message"):
68
+ raise AssertionError(
69
+ f"step #{event.get('seq')} replays with a different message"
70
+ )
71
+ mismatches = stub.chat.completions.state.mismatches
72
+ if mismatches:
73
+ raise AssertionError(
74
+ f"{len(mismatches)} LLM call(s) replayed by call-order fallback "
75
+ "instead of exact fingerprint match — the run is not "
76
+ "deterministically replayable (steps: "
77
+ + ", ".join(str(m["call"]) for m in mismatches) + ")"
78
+ )
79
+
80
+
81
+ @pytest.fixture
82
+ def backspin(tmp_path):
83
+ return _BackspinFixture(tmp_path)
backspin/recorder.py ADDED
@@ -0,0 +1,346 @@
1
+ """The flight recorder itself.
2
+
3
+ ``Recorder`` writes events to a run file as they happen. It is designed to
4
+ be safe to wrap around code you do not trust: serialization problems
5
+ degrade to reprs, and exceptions in recorded code are captured as events
6
+ before being re-raised.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ import functools
12
+ import inspect
13
+ import json
14
+ import os
15
+ import tempfile
16
+ import threading
17
+ import time
18
+ import traceback
19
+ import uuid
20
+ import warnings
21
+ from contextlib import contextmanager, suppress
22
+ from typing import Any, Callable, Dict, Literal, Optional
23
+
24
+ from .runfile import (
25
+ ERROR,
26
+ FILE_SUFFIX,
27
+ LLM,
28
+ LOG,
29
+ TOOL,
30
+ fingerprint_request,
31
+ jsonable,
32
+ make_header,
33
+ new_run_id,
34
+ )
35
+
36
+ # Fields that stay in clear even under a redactor: they are structural
37
+ # metadata the viewer, differ and replay matching rely on.
38
+ _STRUCTURAL_FIELDS = frozenset(
39
+ {"model", "name", "duration_ms", "fingerprint", "error_type", "level",
40
+ "span_id", "parent", "depth", "phase", "provider"}
41
+ )
42
+
43
+ # Current span path, per execution context (async tasks get isolated stacks).
44
+ _span_stack: contextvars.ContextVar = contextvars.ContextVar(
45
+ "backspin_span_stack", default=()
46
+ )
47
+
48
+
49
+ class Recorder:
50
+ """Record an agent run to a single ``*.backspin.jsonl`` file.
51
+
52
+ Usable as a context manager (records an error event if the body raises)
53
+ or standalone — recording starts immediately in the constructor.
54
+
55
+ Run files are created by :mod:`tempfile` inside ``dir`` with a unique
56
+ generated name (see ``rec.path`` after construction), so concurrent
57
+ recorders never collide and output can never land outside the resolved
58
+ directory. ``base_dir`` lets embedding hosts further confine ``dir``
59
+ under an allowed root.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ dir: str = "runs",
65
+ *,
66
+ agent: str = "agent",
67
+ metadata: Optional[Dict[str, Any]] = None,
68
+ base_dir: Optional[str] = None,
69
+ redact: Optional[Callable[[Any], Any]] = None,
70
+ ):
71
+ self.run_id = new_run_id()
72
+ self._redact = redact
73
+ root = os.path.realpath(os.path.expanduser(dir))
74
+ if base_dir is not None:
75
+ allowed = os.path.realpath(os.path.expanduser(base_dir))
76
+ if os.path.commonpath([root, allowed]) != allowed:
77
+ raise ValueError(
78
+ f"directory {dir!r} is outside the allowed root {base_dir!r}"
79
+ )
80
+ os.makedirs(root, exist_ok=True)
81
+ slug = "".join(c if c.isalnum() or c in "-_" else "-" for c in agent)
82
+ stamp = time.strftime("%Y%m%d-%H%M%S")
83
+ self._lock = threading.Lock()
84
+ self._seq = 0
85
+ self._closed = False
86
+ # no context manager here on purpose: the handle must outlive the
87
+ # constructor until close(); delete=False keeps a crash from
88
+ # destroying the evidence.
89
+ self._fp = tempfile.NamedTemporaryFile( # noqa: SIM115
90
+ mode="w",
91
+ dir=root,
92
+ prefix=f"{stamp}-{slug}-{self.run_id}-",
93
+ suffix=FILE_SUFFIX,
94
+ delete=False,
95
+ encoding="utf-8",
96
+ newline="\n",
97
+ )
98
+ self.path = self._fp.name
99
+ self._write_raw(make_header(self.run_id, agent, metadata))
100
+
101
+ # -- plumbing ---------------------------------------------------------
102
+
103
+ def _write_raw(self, ev: Dict[str, Any]) -> None:
104
+ with self._lock:
105
+ self._try_write(ev)
106
+
107
+ def _write_line(self, ev: Dict[str, Any]) -> None:
108
+ with self._lock:
109
+ if self._closed:
110
+ return
111
+ self._seq += 1
112
+ ev["seq"] = self._seq
113
+ self._try_write(ev)
114
+
115
+ def _try_write(self, ev: Dict[str, Any]) -> None:
116
+ """A recorder must never crash the agent it watches: on a write
117
+ failure (disk full, file vanished, closed file, ...) warn once and
118
+ stop writing instead of raising out of instrumented code."""
119
+ try:
120
+ self._fp.write(json.dumps(ev, ensure_ascii=False, default=str) + "\n")
121
+ self._fp.flush()
122
+ except (OSError, ValueError) as exc:
123
+ self._closed = True
124
+ warnings.warn(
125
+ f"backspin: stopping recording of run {self.run_id}: "
126
+ f"cannot write {self.path}: {exc}",
127
+ RuntimeWarning,
128
+ stacklevel=3,
129
+ )
130
+
131
+ def _emit(self, kind: str, **payload: Any) -> Dict[str, Any]:
132
+ ev: Dict[str, Any] = {"kind": kind, "ts": time.time()}
133
+ ev.update({k: v for k, v in payload.items() if v is not None})
134
+ if self._redact is not None:
135
+ for key in ev:
136
+ # Structural fields stay in clear: the viewer, the differ
137
+ # and replay matching all depend on them. Everything else
138
+ # (including unknown custom payload keys) goes through the
139
+ # redactor.
140
+ if key not in ("kind", "ts") and key not in _STRUCTURAL_FIELDS:
141
+ ev[key] = self._redact(ev[key])
142
+ stack = _span_stack.get()
143
+ if stack:
144
+ # span events carry their own ids/depths; regular events inherit
145
+ # the innermost open span
146
+ ev.setdefault("span_id", stack[-1][0])
147
+ ev.setdefault("depth", len(stack))
148
+ self._write_line(ev)
149
+ return ev
150
+
151
+ @contextmanager
152
+ def span(self, name: str, meta: Optional[Dict[str, Any]] = None):
153
+ """Open a named span: everything recorded inside is nested under it.
154
+
155
+ Spans nest arbitrarily (agent -> tool -> sub-LLM) and are safe under
156
+ concurrency — each asyncio task (or thread that copies the context)
157
+ gets its own stack, so parallel branches don't interleave. An
158
+ exception inside the span is recorded on the exit event and
159
+ re-raised.
160
+ """
161
+ sid = uuid.uuid4().hex[:8]
162
+ parent_stack = _span_stack.get()
163
+ parent = parent_stack[-1][0] if parent_stack else None
164
+ depth = len(parent_stack)
165
+ self._emit(
166
+ "span", phase="enter", name=name, span_id=sid,
167
+ parent=parent, depth=depth, meta=jsonable(meta),
168
+ )
169
+ token = _span_stack.set((*parent_stack, (sid, name)))
170
+ t0 = time.perf_counter()
171
+ try:
172
+ yield self
173
+ except BaseException as exc:
174
+ self._emit(
175
+ "span", phase="exit", name=name, span_id=sid, parent=parent,
176
+ depth=depth, error=str(exc), error_type=type(exc).__name__,
177
+ duration_ms=round((time.perf_counter() - t0) * 1000, 1),
178
+ )
179
+ raise
180
+ else:
181
+ self._emit(
182
+ "span", phase="exit", name=name, span_id=sid, parent=parent,
183
+ depth=depth, duration_ms=round((time.perf_counter() - t0) * 1000, 1),
184
+ )
185
+ finally:
186
+ with suppress(ValueError):
187
+ _span_stack.reset(token)
188
+
189
+ def close(self) -> None:
190
+ with self._lock:
191
+ self._closed = True
192
+ with suppress(OSError): # release even a descriptor a failed write left open
193
+ self._fp.close()
194
+
195
+ def __enter__(self) -> "Recorder":
196
+ return self
197
+
198
+ def __exit__(self, exc_type, exc, tb) -> Literal[False]:
199
+ if exc is not None:
200
+ self.record_error(exc)
201
+ self.close()
202
+ return False # never swallow
203
+
204
+ # -- recording API ------------------------------------------------------
205
+
206
+ def log(self, message: str, level: str = "info", **extra: Any) -> None:
207
+ """Record a free-form log line inside the run timeline."""
208
+ self._emit(LOG, level=level, message=message, **jsonable(extra))
209
+
210
+ def event(self, kind: str, **payload: Any) -> Dict[str, Any]:
211
+ """Record a custom event kind (rendered as 'custom' in the viewer)."""
212
+ return self._emit(kind, **jsonable(payload))
213
+
214
+ def record_llm(
215
+ self,
216
+ *,
217
+ request: Optional[Dict[str, Any]] = None,
218
+ response: Optional[Any] = None,
219
+ error: Optional[BaseException] = None,
220
+ duration_ms: float = 0.0,
221
+ model: Optional[str] = None,
222
+ usage: Optional[Dict[str, Any]] = None,
223
+ meta: Optional[Dict[str, Any]] = None,
224
+ provider: Optional[str] = None,
225
+ ) -> None:
226
+ """Record one LLM call. Prefer ``capture_openai``/``capture_anthropic``
227
+ over calling directly. ``provider`` labels non-OpenAI protocols."""
228
+ if model is None and request:
229
+ model = request.get("model")
230
+ fp = None
231
+ if request and request.get("messages") is not None:
232
+ fp = fingerprint_request(model, request.get("messages"))
233
+ self._emit(
234
+ LLM,
235
+ duration_ms=round(duration_ms, 1),
236
+ model=model,
237
+ request=jsonable(request),
238
+ response=jsonable(response, max_len=4000),
239
+ usage=jsonable(usage),
240
+ error=str(error) if error else None,
241
+ fingerprint=fp,
242
+ meta=jsonable(meta),
243
+ provider=provider,
244
+ )
245
+
246
+ def record_tool(
247
+ self,
248
+ *,
249
+ name: str,
250
+ args: Optional[Dict[str, Any]] = None,
251
+ result: Optional[Any] = None,
252
+ error: Optional[BaseException] = None,
253
+ duration_ms: float = 0.0,
254
+ meta: Optional[Dict[str, Any]] = None,
255
+ ) -> None:
256
+ """Record one tool call."""
257
+ self._emit(
258
+ TOOL,
259
+ name=name,
260
+ duration_ms=round(duration_ms, 1),
261
+ args=jsonable(args),
262
+ result=jsonable(result, max_len=4000),
263
+ error=str(error) if error else None,
264
+ meta=jsonable(meta),
265
+ )
266
+
267
+ def record_error(self, error: BaseException, **meta: Any) -> None:
268
+ tb = "".join(
269
+ traceback.format_exception(type(error), error, error.__traceback__)
270
+ )
271
+ self._emit(
272
+ ERROR,
273
+ message=str(error),
274
+ error_type=type(error).__name__,
275
+ traceback=tb,
276
+ **jsonable(meta),
277
+ )
278
+
279
+ # -- instrumentation helpers ---------------------------------------------
280
+
281
+ def tool(self, fn: Callable) -> Callable:
282
+ """Decorator: record every call to ``fn`` (sync or async) as a step."""
283
+
284
+ def _record(name, args, kwargs, result=None, error=None, duration_ms=0.0):
285
+ self.record_tool(
286
+ name=name,
287
+ args={"args": jsonable(list(args)), "kwargs": jsonable(dict(kwargs))},
288
+ result=result,
289
+ error=error,
290
+ duration_ms=duration_ms,
291
+ )
292
+
293
+ if inspect.iscoroutinefunction(fn):
294
+
295
+ @functools.wraps(fn)
296
+ async def async_wrapper(*args, **kwargs):
297
+ t0 = time.perf_counter()
298
+ try:
299
+ result = await fn(*args, **kwargs)
300
+ except Exception as exc:
301
+ _record(fn.__name__, args, kwargs, error=exc,
302
+ duration_ms=(time.perf_counter() - t0) * 1000)
303
+ raise
304
+ _record(fn.__name__, args, kwargs, result=result,
305
+ duration_ms=(time.perf_counter() - t0) * 1000)
306
+ return result
307
+
308
+ return async_wrapper
309
+
310
+ @functools.wraps(fn)
311
+ def wrapper(*args, **kwargs):
312
+ t0 = time.perf_counter()
313
+ try:
314
+ result = fn(*args, **kwargs)
315
+ except Exception as exc:
316
+ _record(fn.__name__, args, kwargs, error=exc,
317
+ duration_ms=(time.perf_counter() - t0) * 1000)
318
+ raise
319
+ _record(fn.__name__, args, kwargs, result=result,
320
+ duration_ms=(time.perf_counter() - t0) * 1000)
321
+ return result
322
+
323
+ return wrapper
324
+
325
+ def capture_openai(self, client: Any) -> Any:
326
+ """Patch ``client.chat.completions.create`` so every call is recorded.
327
+
328
+ Works on the real ``openai.OpenAI`` / ``openai.AsyncOpenAI`` clients
329
+ and on anything duck-type-shaped the same way (see
330
+ :mod:`backspin.testing`). Returns the same client for chaining.
331
+ """
332
+ from .integrations.openai import capture_openai
333
+
334
+ return capture_openai(self, client)
335
+
336
+ def capture_anthropic(self, client: Any) -> Any:
337
+ """Patch ``client.messages.create`` so every call is recorded.
338
+
339
+ Works on the real ``anthropic.Anthropic`` / ``anthropic.AsyncAnthropic``
340
+ clients and anything duck-type-shaped the same way. Recorded events
341
+ carry ``provider="anthropic"`` with native payloads; usage is
342
+ normalized so costs and diffs work across providers.
343
+ """
344
+ from .integrations.anthropic import capture_anthropic
345
+
346
+ return capture_anthropic(self, client)
backspin/redaction.py ADDED
@@ -0,0 +1,49 @@
1
+ """Sensitive-data redaction for recordings.
2
+
3
+ Pass ``redact=`` to :class:`~backspin.Recorder` and every payload value is
4
+ transformed before it touches disk::
5
+
6
+ from backspin import Recorder
7
+ from backspin.redaction import mask, redact_strings
8
+
9
+ rec = Recorder(
10
+ agent="support-bot",
11
+ redact=redact_strings(mask(r"sk-[A-Za-z0-9]{8,}")),
12
+ )
13
+
14
+ Recordings still replay, but replayed values are the redacted ones — so
15
+ redact *before* you record and keep raw data out of files entirely.
16
+ LLM request fingerprints are computed from the raw request, so replay
17
+ matching is unaffected by redaction.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any, Callable, Union
23
+
24
+ Transform = Callable[[Any], Any]
25
+
26
+
27
+ def redact_strings(transform: Callable[[str], str]) -> Transform:
28
+ """Build a deep redactor that applies ``transform`` to every string.
29
+
30
+ Dict keys are preserved (only values pass through ``transform``);
31
+ numbers, booleans and nested structures pass through recursively.
32
+ """
33
+
34
+ def deep(obj: Any) -> Any:
35
+ if isinstance(obj, str):
36
+ return transform(obj)
37
+ if isinstance(obj, dict):
38
+ return {k: deep(v) for k, v in obj.items()}
39
+ if isinstance(obj, (list, tuple)):
40
+ return [deep(v) for v in obj]
41
+ return obj
42
+
43
+ return deep
44
+
45
+
46
+ def mask(pattern: Union[str, "re.Pattern[str]"], repl: str = "[redacted]") -> Callable[[str], str]:
47
+ """Build a string transform that replaces regex matches."""
48
+ compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
49
+ return lambda s: compiled.sub(repl, s)