agenttape 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.
agenttape/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """AgentTape — deterministic record/replay for AI agents.
2
+
3
+ Deterministic record / replay of an agent's external interactions (LLM calls and
4
+ tool calls) into human-readable cassettes, so agent tests run offline, for free,
5
+ with zero side effects.
6
+
7
+ Quickstart::
8
+
9
+ import agenttape
10
+
11
+ with agenttape.use_cassette("hello", mode="record"):
12
+ run_agent() # records real calls
13
+
14
+ with agenttape.use_cassette("hello", mode="none"):
15
+ run_agent() # replays, zero network, deterministic
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .boundaries import memory_read, memory_write, record_call, retrieval, tool
21
+ from .callbacks import AgentTape
22
+ from .config import Config
23
+ from .errors import (
24
+ AgentTapeError,
25
+ CassetteCorruptError,
26
+ CassetteNotFoundError,
27
+ ConfigError,
28
+ DeterminismDriftWarning,
29
+ SchemaVersionError,
30
+ StreamingNotRecordedWarning,
31
+ StreamingReplayError,
32
+ UnmatchedInteractionError,
33
+ )
34
+ from .recorder import Session, active_session, record, replay, use_cassette
35
+ from .schema import Cassette, Interaction
36
+
37
+ try: # populated from package metadata when installed
38
+ from importlib.metadata import version as _version
39
+
40
+ __version__ = _version("agenttape")
41
+ except Exception: # pragma: no cover - editable/source tree fallback
42
+ __version__ = "0.1.0"
43
+
44
+ __all__ = [ # noqa: RUF022 - grouped by concern for readability, not alphabetised
45
+ "__version__",
46
+ # Core API
47
+ "use_cassette",
48
+ "record",
49
+ "replay",
50
+ "Session",
51
+ "active_session",
52
+ # Boundary helpers
53
+ "tool",
54
+ "retrieval",
55
+ "memory_read",
56
+ "memory_write",
57
+ "record_call",
58
+ # Callback object
59
+ "AgentTape",
60
+ # Data model
61
+ "Cassette",
62
+ "Interaction",
63
+ "Config",
64
+ # Errors
65
+ "AgentTapeError",
66
+ "UnmatchedInteractionError",
67
+ "CassetteCorruptError",
68
+ "CassetteNotFoundError",
69
+ "SchemaVersionError",
70
+ "ConfigError",
71
+ "DeterminismDriftWarning",
72
+ "StreamingReplayError",
73
+ "StreamingNotRecordedWarning",
74
+ ]
agenttape/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m agenttape``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
agenttape/_box.py ADDED
@@ -0,0 +1,59 @@
1
+ """A tiny attribute-accessible mapping used to rehydrate replayed responses.
2
+
3
+ When a cassette is replayed offline, recorded responses come back as plain dicts.
4
+ Many SDKs (OpenAI, etc.) hand callers objects with attribute access
5
+ (``resp.choices[0].message.content``). :class:`Box` lets replayed dicts support
6
+ both ``obj.attr`` and ``obj["attr"]`` so user code behaves identically whether or
7
+ not the original SDK is installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+
15
+
16
+ class Box(dict): # type: ignore[type-arg]
17
+ """A dict that also supports attribute access, recursively."""
18
+
19
+ def __getattr__(self, name: str) -> Any:
20
+ try:
21
+ return _wrap(self[name])
22
+ except KeyError as exc:
23
+ raise AttributeError(name) from exc
24
+
25
+ def __setattr__(self, name: str, value: Any) -> None:
26
+ self[name] = value
27
+
28
+ def __iter__(self) -> Iterator[Any]: # pragma: no cover - dict default suffices
29
+ return super().__iter__()
30
+
31
+ def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
32
+ return {k: _unwrap(v) for k, v in self.items()}
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {k: _unwrap(v) for k, v in self.items()}
36
+
37
+
38
+ def _wrap(value: Any) -> Any:
39
+ if isinstance(value, Box):
40
+ return value
41
+ if isinstance(value, dict):
42
+ return Box(value)
43
+ if isinstance(value, list):
44
+ return [_wrap(v) for v in value]
45
+ return value
46
+
47
+
48
+ def _unwrap(value: Any) -> Any:
49
+ if isinstance(value, dict):
50
+ return {k: _unwrap(v) for k, v in value.items()}
51
+ if isinstance(value, list):
52
+ return [_unwrap(v) for v in value]
53
+ return value
54
+
55
+
56
+ def box(data: Any) -> Any:
57
+ """Recursively wrap ``data`` so dicts gain attribute access."""
58
+
59
+ return _wrap(data)
@@ -0,0 +1,68 @@
1
+ """Adapter registry and bulk install/uninstall.
2
+
3
+ The registry is populated at import time with the built-in adapters. A
4
+ :class:`~agenttape.recorder.Session` installs every *available* adapter on enter
5
+ and uninstalls them on exit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .base import Adapter, RefCountedPatch
13
+ from .http import HttpxAdapter, RequestsAdapter
14
+ from .langgraph import LangGraphAdapter
15
+ from .openai import OpenAIAdapter
16
+
17
+ _REGISTRY: list[Adapter] = []
18
+
19
+
20
+ def register(adapter: Adapter) -> None:
21
+ """Register an adapter so sessions will install it when its library is present."""
22
+
23
+ _REGISTRY.append(adapter)
24
+
25
+
26
+ def registry() -> list[Adapter]:
27
+ return list(_REGISTRY)
28
+
29
+
30
+ def install_all(session: Any) -> list[Adapter]:
31
+ installed: list[Adapter] = []
32
+ for adapter in _REGISTRY:
33
+ try:
34
+ if adapter.available():
35
+ adapter.install(session)
36
+ installed.append(adapter)
37
+ except Exception: # pragma: no cover - never let an adapter break a session
38
+ continue
39
+ return installed
40
+
41
+
42
+ def uninstall_all(adapters: list[Adapter]) -> None:
43
+ for adapter in reversed(adapters):
44
+ try:
45
+ adapter.uninstall()
46
+ except Exception: # pragma: no cover
47
+ continue
48
+
49
+
50
+ # Built-in adapters. Order matters only for cosmetic install order.
51
+ register(OpenAIAdapter())
52
+ register(LangGraphAdapter())
53
+ register(HttpxAdapter())
54
+ register(RequestsAdapter())
55
+
56
+
57
+ __all__ = [
58
+ "Adapter",
59
+ "HttpxAdapter",
60
+ "LangGraphAdapter",
61
+ "OpenAIAdapter",
62
+ "RefCountedPatch",
63
+ "RequestsAdapter",
64
+ "install_all",
65
+ "register",
66
+ "registry",
67
+ "uninstall_all",
68
+ ]
@@ -0,0 +1,76 @@
1
+ """Adapter base class and the documented extension interface.
2
+
3
+ An adapter translates a framework's native events into AgentTape's internal schema
4
+ by routing boundary crossings through the active session's engine. Adapters keep
5
+ their third-party imports lazy so the core stays dependency-free, and patch their
6
+ target libraries with **reference-counted** install/uninstall so nested sessions
7
+ share a single patch and route to whichever session is active at call time.
8
+
9
+ To add an adapter:
10
+
11
+ 1. Subclass :class:`Adapter`.
12
+ 2. Implement :meth:`available` (is the target library importable?), :meth:`install`
13
+ and :meth:`uninstall`.
14
+ 3. Register it via ``agenttape.adapters.register(MyAdapter())``.
15
+
16
+ Inside your patched callable, fetch ``agenttape.active_session()``; if it is
17
+ ``None`` call the original (pass-through), otherwise call
18
+ ``session.engine.intercept(kind, request, boundary=..., executor=...)``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ from typing import Any
25
+
26
+
27
+ class Adapter:
28
+ """Base class for framework adapters."""
29
+
30
+ name: str = "adapter"
31
+
32
+ def available(self) -> bool:
33
+ """Return True if this adapter's target library is importable."""
34
+
35
+ return False
36
+
37
+ def install(self, session: Any) -> None:
38
+ """Patch the target library to route through ``session``'s engine."""
39
+
40
+ def uninstall(self) -> None:
41
+ """Restore the target library to its original state."""
42
+
43
+
44
+ class RefCountedPatch:
45
+ """Helper to install a patch once across nested sessions and restore on last exit."""
46
+
47
+ def __init__(self) -> None:
48
+ self._count = 0
49
+ self._restores: list[Any] = []
50
+ # Guards the refcount + restore list so concurrent session enter/exit (across
51
+ # threads) can't corrupt the count and leak or prematurely drop the patch.
52
+ self._lock = threading.RLock()
53
+
54
+ @property
55
+ def active(self) -> bool:
56
+ with self._lock:
57
+ return self._count > 0
58
+
59
+ def acquire(self, install_fn: Any) -> None:
60
+ with self._lock:
61
+ if self._count == 0:
62
+ self._restores = install_fn() or []
63
+ self._count += 1
64
+
65
+ def release(self) -> None:
66
+ with self._lock:
67
+ if self._count == 0:
68
+ return
69
+ self._count -= 1
70
+ if self._count == 0:
71
+ for restore in reversed(self._restores):
72
+ try:
73
+ restore()
74
+ except Exception: # pragma: no cover - defensive cleanup
75
+ pass
76
+ self._restores = []
@@ -0,0 +1,269 @@
1
+ """Raw HTTP fallback adapters for ``httpx`` and ``requests``.
2
+
3
+ These patch the transport layer so that *any* SDK built on httpx/requests — even
4
+ ones AgentTape has no dedicated adapter for — is captured and replayed. Matching is
5
+ based on method + URL + body plus non-volatile headers; secret and volatile headers
6
+ (``Authorization``, ``Cookie``, ``User-Agent`` …) are dropped from the recorded
7
+ request so they neither leak to disk nor destabilise matching.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import functools
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from ..recorder import active_session
18
+ from .base import Adapter, RefCountedPatch
19
+
20
+ # Headers dropped from the *recorded request* (secret or volatile).
21
+ _DROP_REQUEST_HEADERS = frozenset(
22
+ {
23
+ "authorization",
24
+ "proxy-authorization",
25
+ "cookie",
26
+ "x-api-key",
27
+ "openai-api-key",
28
+ "api-key",
29
+ "user-agent",
30
+ "date",
31
+ "content-length",
32
+ "host",
33
+ "connection",
34
+ "accept-encoding",
35
+ "x-request-id",
36
+ "x-stainless-arch",
37
+ "x-stainless-os",
38
+ "x-stainless-runtime",
39
+ "x-stainless-runtime-version",
40
+ "x-stainless-package-version",
41
+ "x-stainless-lang",
42
+ "traceparent",
43
+ }
44
+ )
45
+
46
+
47
+ def _clean_headers(headers: Any) -> dict[str, str]:
48
+ out: dict[str, str] = {}
49
+ try:
50
+ items = headers.items()
51
+ except AttributeError:
52
+ items = dict(headers).items()
53
+ for key, value in items:
54
+ if str(key).lower() in _DROP_REQUEST_HEADERS:
55
+ continue
56
+ out[str(key)] = str(value)
57
+ return out
58
+
59
+
60
+ def _encode_body(content: bytes | None) -> dict[str, Any]:
61
+ if not content:
62
+ return {}
63
+ try:
64
+ return {"text": content.decode("utf-8")}
65
+ except UnicodeDecodeError:
66
+ return {"body_b64": base64.b64encode(content).decode("ascii")}
67
+
68
+
69
+ def _decode_body(payload: dict[str, Any]) -> bytes:
70
+ if "text" in payload:
71
+ return str(payload["text"]).encode("utf-8")
72
+ if "content" in payload:
73
+ return str(payload["content"]).encode("utf-8")
74
+ if "body_b64" in payload:
75
+ return base64.b64decode(payload["body_b64"])
76
+ return b""
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # httpx
81
+ # --------------------------------------------------------------------------- #
82
+
83
+
84
+ class HttpxAdapter(Adapter):
85
+ name = "httpx"
86
+
87
+ def __init__(self) -> None:
88
+ self._patch = RefCountedPatch()
89
+
90
+ def available(self) -> bool:
91
+ try:
92
+ import httpx # noqa: F401
93
+ except Exception:
94
+ return False
95
+ return True
96
+
97
+ def install(self, session: Any) -> None:
98
+ self._patch.acquire(self._do_install)
99
+
100
+ def uninstall(self) -> None:
101
+ self._patch.release()
102
+
103
+ def _do_install(self) -> list[Callable[[], None]]:
104
+ import httpx
105
+
106
+ restores: list[Callable[[], None]] = []
107
+ orig_sync = httpx.Client.send
108
+ orig_async = httpx.AsyncClient.send
109
+
110
+ @functools.wraps(orig_sync)
111
+ def sync_send(client: Any, request: Any, **kwargs: Any) -> Any:
112
+ session = active_session()
113
+ if session is None:
114
+ return orig_sync(client, request, **kwargs)
115
+ req = _httpx_request(request)
116
+ boundary = request.url.host or "http"
117
+
118
+ def executor() -> Any:
119
+ resp = orig_sync(client, request, **kwargs)
120
+ resp.read()
121
+ return _httpx_dump(resp)
122
+
123
+ recorded = session.engine.intercept("http", req, boundary=boundary, executor=executor)
124
+ return _httpx_build(httpx, recorded, request)
125
+
126
+ @functools.wraps(orig_async)
127
+ async def async_send(client: Any, request: Any, **kwargs: Any) -> Any:
128
+ session = active_session()
129
+ if session is None:
130
+ return await orig_async(client, request, **kwargs)
131
+ req = _httpx_request(request)
132
+ boundary = request.url.host or "http"
133
+
134
+ async def executor() -> Any:
135
+ resp = await orig_async(client, request, **kwargs)
136
+ await resp.aread()
137
+ return _httpx_dump(resp)
138
+
139
+ recorded = await session.engine.aintercept(
140
+ "http", req, boundary=boundary, executor=executor
141
+ )
142
+ return _httpx_build(httpx, recorded, request)
143
+
144
+ httpx.Client.send = sync_send # type: ignore[method-assign]
145
+ httpx.AsyncClient.send = async_send # type: ignore[method-assign]
146
+ restores.append(lambda: setattr(httpx.Client, "send", orig_sync))
147
+ restores.append(lambda: setattr(httpx.AsyncClient, "send", orig_async))
148
+ return restores
149
+
150
+
151
+ def _httpx_request(request: Any) -> dict[str, Any]:
152
+ body = bytes(request.content) if request.content else b""
153
+ return {
154
+ "method": request.method,
155
+ "url": str(request.url),
156
+ "headers": _clean_headers(request.headers),
157
+ **_encode_body(body),
158
+ }
159
+
160
+
161
+ def _httpx_dump(resp: Any) -> dict[str, Any]:
162
+ return {
163
+ "status_code": resp.status_code,
164
+ "headers": dict(resp.headers),
165
+ **_encode_body(resp.content),
166
+ }
167
+
168
+
169
+ def _httpx_build(httpx_mod: Any, payload: dict[str, Any], request: Any) -> Any:
170
+ return httpx_mod.Response(
171
+ status_code=int(payload.get("status_code", 200)),
172
+ headers=payload.get("headers", {}),
173
+ content=_decode_body(payload),
174
+ request=request,
175
+ )
176
+
177
+
178
+ # --------------------------------------------------------------------------- #
179
+ # requests
180
+ # --------------------------------------------------------------------------- #
181
+
182
+
183
+ class RequestsAdapter(Adapter):
184
+ name = "requests"
185
+
186
+ def __init__(self) -> None:
187
+ self._patch = RefCountedPatch()
188
+
189
+ def available(self) -> bool:
190
+ try:
191
+ import requests # noqa: F401
192
+ except Exception:
193
+ return False
194
+ return True
195
+
196
+ def install(self, session: Any) -> None:
197
+ self._patch.acquire(self._do_install)
198
+
199
+ def uninstall(self) -> None:
200
+ self._patch.release()
201
+
202
+ def _do_install(self) -> list[Callable[[], None]]:
203
+ import requests
204
+ from requests.adapters import HTTPAdapter
205
+
206
+ orig_send = HTTPAdapter.send
207
+
208
+ @functools.wraps(orig_send)
209
+ def send(adapter: Any, request: Any, **kwargs: Any) -> Any:
210
+ session = active_session()
211
+ if session is None:
212
+ return orig_send(adapter, request, **kwargs)
213
+ req = _requests_request(request)
214
+ boundary = _host_of(request.url)
215
+
216
+ def executor() -> Any:
217
+ resp = orig_send(adapter, request, **kwargs)
218
+ return _requests_dump(resp)
219
+
220
+ recorded = session.engine.intercept("http", req, boundary=boundary, executor=executor)
221
+ return _requests_build(requests, recorded, request)
222
+
223
+ HTTPAdapter.send = send # type: ignore[method-assign]
224
+ return [lambda: setattr(HTTPAdapter, "send", orig_send)]
225
+
226
+
227
+ def _requests_request(request: Any) -> dict[str, Any]:
228
+ body = request.body
229
+ if isinstance(body, str):
230
+ body_bytes = body.encode("utf-8")
231
+ elif isinstance(body, bytes):
232
+ body_bytes = body
233
+ else:
234
+ body_bytes = b""
235
+ return {
236
+ "method": request.method,
237
+ "url": request.url,
238
+ "headers": _clean_headers(request.headers),
239
+ **_encode_body(body_bytes),
240
+ }
241
+
242
+
243
+ def _requests_dump(resp: Any) -> dict[str, Any]:
244
+ return {
245
+ "status_code": resp.status_code,
246
+ "headers": dict(resp.headers),
247
+ "reason": getattr(resp, "reason", ""),
248
+ **_encode_body(resp.content),
249
+ }
250
+
251
+
252
+ def _requests_build(requests_mod: Any, payload: dict[str, Any], request: Any) -> Any:
253
+ resp = requests_mod.models.Response()
254
+ resp.status_code = int(payload.get("status_code", 200))
255
+ resp._content = _decode_body(payload)
256
+ resp.headers = requests_mod.structures.CaseInsensitiveDict(payload.get("headers", {}))
257
+ resp.url = request.url
258
+ resp.reason = payload.get("reason", "")
259
+ resp.request = request
260
+ return resp
261
+
262
+
263
+ def _host_of(url: str) -> str:
264
+ try:
265
+ from urllib.parse import urlparse
266
+
267
+ return urlparse(url).hostname or "http"
268
+ except Exception: # pragma: no cover
269
+ return "http"
@@ -0,0 +1,108 @@
1
+ """LangGraph adapter — graph-state checkpoint capture.
2
+
3
+ LangGraph runs a graph of nodes; LLM and tool calls inside nodes already flow
4
+ through the OpenAI / httpx transport adapters (so they replay for free). This
5
+ adapter adds the piece unique to LangGraph:
6
+
7
+ * **Graph-state checkpoints** — each ``Pregel.invoke`` / ``.stream`` call is wrapped
8
+ and its result recorded as a ``memory_write`` interaction (boundary
9
+ ``graph_state``), enabling state/memory diffs between runs.
10
+
11
+ The implementation is defensive: it adapts to the installed LangGraph version and
12
+ never raises into user code. If the internal API differs, it degrades to capturing
13
+ LLM/tool calls via the transport adapters only.
14
+
15
+ For frameworks that expose callbacks instead of patch points, use the
16
+ :class:`agenttape.AgentTape` callback object documented in ``callbacks.py``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from ..recorder import active_session
26
+ from .base import Adapter, RefCountedPatch
27
+
28
+
29
+ class LangGraphAdapter(Adapter):
30
+ name = "langgraph"
31
+
32
+ def __init__(self) -> None:
33
+ self._patch = RefCountedPatch()
34
+
35
+ def available(self) -> bool:
36
+ try:
37
+ import langgraph # noqa: F401
38
+ except Exception:
39
+ return False
40
+ return True
41
+
42
+ def install(self, session: Any) -> None:
43
+ self._patch.acquire(self._do_install)
44
+
45
+ def uninstall(self) -> None:
46
+ self._patch.release()
47
+
48
+ def _do_install(self) -> list[Callable[[], None]]:
49
+ restores: list[Callable[[], None]] = []
50
+ restores += self._patch_pregel()
51
+ return restores
52
+
53
+ def _patch_pregel(self) -> list[Callable[[], None]]:
54
+ """Wrap ``Pregel.invoke`` / ``.stream`` to checkpoint state as memory_write."""
55
+
56
+ try:
57
+ from langgraph.pregel import Pregel # type: ignore
58
+ except Exception:
59
+ return []
60
+
61
+ restores: list[Callable[[], None]] = []
62
+ for method_name in ("invoke", "stream"):
63
+ original = getattr(Pregel, method_name, None)
64
+ if original is None:
65
+ continue
66
+
67
+ @functools.wraps(original) # type: ignore
68
+ def wrapper( # type: ignore
69
+ self_obj: Any,
70
+ *args: Any,
71
+ __orig: Callable[..., Any] = original,
72
+ __name: str = method_name,
73
+ **kwargs: Any,
74
+ ) -> Any:
75
+ session = active_session()
76
+ if session is None:
77
+ return __orig(self_obj, *args, **kwargs)
78
+ inputs = args[0] if args else kwargs.get("input")
79
+ request = {"node": "__graph__", "input": _safe(inputs)}
80
+
81
+ def executor() -> Any:
82
+ return __orig(self_obj, *args, **kwargs)
83
+
84
+ # Record the final state as a memory_write so runs are diffable.
85
+ result = session.engine.intercept(
86
+ "memory_write",
87
+ request,
88
+ boundary="graph_state",
89
+ executor=executor,
90
+ )
91
+ return result
92
+
93
+ setattr(Pregel, method_name, wrapper)
94
+ restores.append(_restorer(Pregel, method_name, original))
95
+ return restores
96
+
97
+
98
+ def _safe(obj: Any) -> Any:
99
+ from ..engine import _to_jsonable
100
+
101
+ return _to_jsonable(obj)
102
+
103
+
104
+ def _restorer(cls: Any, name: str, original: Any) -> Callable[[], None]:
105
+ def restore() -> None:
106
+ setattr(cls, name, original)
107
+
108
+ return restore