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 +74 -0
- agenttape/__main__.py +8 -0
- agenttape/_box.py +59 -0
- agenttape/adapters/__init__.py +68 -0
- agenttape/adapters/base.py +76 -0
- agenttape/adapters/http.py +269 -0
- agenttape/adapters/langgraph.py +108 -0
- agenttape/adapters/openai.py +274 -0
- agenttape/assets.py +118 -0
- agenttape/boundaries.py +131 -0
- agenttape/callbacks.py +147 -0
- agenttape/canonical.py +116 -0
- agenttape/cassette.py +121 -0
- agenttape/cli.py +322 -0
- agenttape/config.py +232 -0
- agenttape/diff.py +323 -0
- agenttape/engine.py +595 -0
- agenttape/errors.py +153 -0
- agenttape/events.py +54 -0
- agenttape/export.py +110 -0
- agenttape/freeze.py +410 -0
- agenttape/matchers.py +137 -0
- agenttape/metrics.py +98 -0
- agenttape/py.typed +0 -0
- agenttape/pytest_plugin.py +192 -0
- agenttape/recorder.py +283 -0
- agenttape/redaction.py +148 -0
- agenttape/schema.py +141 -0
- agenttape/timeline.py +116 -0
- agenttape/validate.py +123 -0
- agenttape/viewer.py +138 -0
- agenttape/yaml_io.py +656 -0
- agenttape-0.1.0.dist-info/METADATA +208 -0
- agenttape-0.1.0.dist-info/RECORD +37 -0
- agenttape-0.1.0.dist-info/WHEEL +4 -0
- agenttape-0.1.0.dist-info/entry_points.txt +5 -0
- agenttape-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|