trajectory-causal-attribution 1.0.0__tar.gz
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.
- trajectory_causal_attribution-1.0.0/.gitignore +40 -0
- trajectory_causal_attribution-1.0.0/LICENSE +21 -0
- trajectory_causal_attribution-1.0.0/PKG-INFO +593 -0
- trajectory_causal_attribution-1.0.0/README.md +555 -0
- trajectory_causal_attribution-1.0.0/benchmarks/README.md +50 -0
- trajectory_causal_attribution-1.0.0/benchmarks/whowhen.py +227 -0
- trajectory_causal_attribution-1.0.0/examples/connect_any_framework.py +50 -0
- trajectory_causal_attribution-1.0.0/examples/quickstart.py +29 -0
- trajectory_causal_attribution-1.0.0/examples/test_agent_with_pytest.py +36 -0
- trajectory_causal_attribution-1.0.0/huggingface/README.md +25 -0
- trajectory_causal_attribution-1.0.0/pyproject.toml +86 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/__init__.py +99 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/_ambient.py +34 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/ablation.py +235 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/adapters/__init__.py +15 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/adapters/langchain.py +103 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/adapters/openai_sdk.py +107 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/aggregate.py +218 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/analyze.py +95 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/attribution.py +420 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/cli.py +555 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/drift.py +347 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/errors.py +26 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/explain.py +329 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/faithfulness.py +193 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/hashing.py +37 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/instrument.py +406 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/interop.py +307 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/multiverse.py +344 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/pytest_plugin.py +206 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/recorder.py +437 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/repair.py +163 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/replayer.py +307 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/report.py +302 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/serve.py +199 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/session.py +81 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/stats.py +131 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/store.py +281 -0
- trajectory_causal_attribution-1.0.0/src/agent_replay/types.py +341 -0
- trajectory_causal_attribution-1.0.0/tests/_demo_agent.py +106 -0
- trajectory_causal_attribution-1.0.0/tests/conftest.py +34 -0
- trajectory_causal_attribution-1.0.0/tests/test_ablation.py +59 -0
- trajectory_causal_attribution-1.0.0/tests/test_action_observation.py +149 -0
- trajectory_causal_attribution-1.0.0/tests/test_adaptive.py +103 -0
- trajectory_causal_attribution-1.0.0/tests/test_aggregate.py +83 -0
- trajectory_causal_attribution-1.0.0/tests/test_async.py +121 -0
- trajectory_causal_attribution-1.0.0/tests/test_attribution.py +89 -0
- trajectory_causal_attribution-1.0.0/tests/test_benchmark.py +40 -0
- trajectory_causal_attribution-1.0.0/tests/test_branching.py +85 -0
- trajectory_causal_attribution-1.0.0/tests/test_cache.py +75 -0
- trajectory_causal_attribution-1.0.0/tests/test_cli.py +239 -0
- trajectory_causal_attribution-1.0.0/tests/test_drift.py +113 -0
- trajectory_causal_attribution-1.0.0/tests/test_end_to_end.py +78 -0
- trajectory_causal_attribution-1.0.0/tests/test_explain.py +103 -0
- trajectory_causal_attribution-1.0.0/tests/test_faithfulness.py +89 -0
- trajectory_causal_attribution-1.0.0/tests/test_instrument.py +180 -0
- trajectory_causal_attribution-1.0.0/tests/test_interop.py +127 -0
- trajectory_causal_attribution-1.0.0/tests/test_multiverse.py +179 -0
- trajectory_causal_attribution-1.0.0/tests/test_openai_adapter.py +61 -0
- trajectory_causal_attribution-1.0.0/tests/test_properties.py +110 -0
- trajectory_causal_attribution-1.0.0/tests/test_pytest_plugin.py +100 -0
- trajectory_causal_attribution-1.0.0/tests/test_recorder.py +47 -0
- trajectory_causal_attribution-1.0.0/tests/test_repair.py +38 -0
- trajectory_causal_attribution-1.0.0/tests/test_repair_v2.py +70 -0
- trajectory_causal_attribution-1.0.0/tests/test_replayer.py +57 -0
- trajectory_causal_attribution-1.0.0/tests/test_report.py +64 -0
- trajectory_causal_attribution-1.0.0/tests/test_serve.py +76 -0
- trajectory_causal_attribution-1.0.0/tests/test_soundness.py +178 -0
- trajectory_causal_attribution-1.0.0/tests/test_stats.py +56 -0
- trajectory_causal_attribution-1.0.0/tests/test_store.py +92 -0
- trajectory_causal_attribution-1.0.0/tests/test_synthetic_scm.py +105 -0
- trajectory_causal_attribution-1.0.0/tests/test_virtual_time.py +104 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
env/
|
|
11
|
+
|
|
12
|
+
# Test / coverage
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.hypothesis/
|
|
15
|
+
.coverage
|
|
16
|
+
.coverage.*
|
|
17
|
+
htmlcov/
|
|
18
|
+
coverage.xml
|
|
19
|
+
|
|
20
|
+
# Tooling
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
|
|
24
|
+
# Generated artifacts
|
|
25
|
+
*.sqlite
|
|
26
|
+
*.sqlite-journal
|
|
27
|
+
report.html
|
|
28
|
+
report.json
|
|
29
|
+
demo.sqlite
|
|
30
|
+
|
|
31
|
+
# OS / editor
|
|
32
|
+
.DS_Store
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
|
|
36
|
+
# Research source material (not published)
|
|
37
|
+
*.docx
|
|
38
|
+
|
|
39
|
+
# MkDocs build output
|
|
40
|
+
site/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agent-replay contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trajectory-causal-attribution
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Record AI agent trajectories and find which step caused a failure via counterfactual step-ablation replay.
|
|
5
|
+
Project-URL: Homepage, https://github.com/krishddd/Trajectory_Causal_Attribution
|
|
6
|
+
Project-URL: Documentation, https://krishddd.github.io/Trajectory_Causal_Attribution/
|
|
7
|
+
Project-URL: Repository, https://github.com/krishddd/Trajectory_Causal_Attribution
|
|
8
|
+
Project-URL: Issues, https://github.com/krishddd/Trajectory_Causal_Attribution/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/krishddd/Trajectory_Causal_Attribution/blob/main/CHANGELOG.md
|
|
10
|
+
Author: agent-replay contributors
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,causal-inference,counterfactual,debugging,llm,observability,replay,shapley
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: hypothesis>=6.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
33
|
+
Provides-Extra: langchain
|
|
34
|
+
Requires-Dist: langchain-core>=0.1; extra == 'langchain'
|
|
35
|
+
Provides-Extra: openai
|
|
36
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# agent-replay
|
|
40
|
+
|
|
41
|
+
**Find *which step* caused your AI agent to fail — with causal proof, not correlational guesswork.**
|
|
42
|
+
|
|
43
|
+
`agent-replay` records an AI agent's trajectory (LLM calls, tool calls, memory
|
|
44
|
+
operations) as a checkpointed, deterministically-replayable session, then
|
|
45
|
+
attributes a failure to a specific step by **counterfactual step-ablation**:
|
|
46
|
+
re-run the trajectory with one step perturbed and measure how the failure
|
|
47
|
+
probability shifts.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
attribution(step i) = P(fail | step i kept) − P(fail | step i ablated)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This is the software-level realization of the *Trajectory Causal Attribution*
|
|
54
|
+
method: formalize the run as a Structural Causal Model, intervene on one step at
|
|
55
|
+
a time, and use the **Point-of-Commitment Rule** and **Shapley-value attribution**
|
|
56
|
+
to localize the true root cause — instead of blaming the step that mechanically
|
|
57
|
+
executed the harmful action (a category error) or an LLM-as-judge (≈14% accuracy
|
|
58
|
+
on the *Who&When* benchmark).
|
|
59
|
+
|
|
60
|
+
- 🎯 **Causal, not correlational** — real `do()`-calculus interventions on a replayable trajectory.
|
|
61
|
+
- 🧩 **Framework-agnostic** — a tiny decorator/wrapper API that works with *any* Python agent. Optional LangChain / OpenAI-SDK adapters included.
|
|
62
|
+
- 🌿 **Branch-safe** — live replay calls bind to recorded steps by **idempotency key** (kind + name + inputs), so agents whose step sequence depends on earlier outputs are attributed correctly, not just linear ones.
|
|
63
|
+
- 💾 **Checkpointed** — SQLite store with content-addressable, deduplicated blobs and a Merkle-linked step chain.
|
|
64
|
+
- 🔁 **Deterministic replay** — the VCR/cassette pattern: recorded steps are served verbatim; only ablated steps re-run.
|
|
65
|
+
- 📊 **Rigorous** — Wilson score + bootstrap confidence intervals, antithetic Shapley sampling, no coalition caching.
|
|
66
|
+
- 🛠 **Actionable** — searches for a *minimal counterfactual repair* and emits an HTML + JSON failure-attribution report.
|
|
67
|
+
- 🔎 **Honest** — attributing a *passing* run raises by default (or runs the symmetric **credit** analysis: which step secured success); observation-only / non-resamplable steps are flagged, never silently scored zero.
|
|
68
|
+
- 🪶 **Zero runtime dependencies** — pure Python standard library.
|
|
69
|
+
|
|
70
|
+
📖 **[Documentation site](https://krishddd.github.io/Trajectory_Causal_Attribution/)** · [Changelog](CHANGELOG.md)
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install trajectory-causal-attribution
|
|
78
|
+
# optional integrations:
|
|
79
|
+
pip install "trajectory-causal-attribution[langchain]"
|
|
80
|
+
pip install "trajectory-causal-attribution[openai]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The distribution is named `trajectory-causal-attribution`; the **import package
|
|
84
|
+
and CLI stay `agent_replay` / `agent-replay`** (like `scikit-learn` → `sklearn`):
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import agent_replay # after `pip install trajectory-causal-attribution`
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Prefer installing straight from GitHub (no PyPI needed)? Both work today:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# from the tagged source
|
|
94
|
+
pip install "git+https://github.com/krishddd/Trajectory_Causal_Attribution.git@v1.0.0"
|
|
95
|
+
# or from the release wheel
|
|
96
|
+
pip install https://github.com/krishddd/Trajectory_Causal_Attribution/releases/download/v1.0.0/trajectory_causal_attribution-1.0.0-py3-none-any.whl
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Requires Python 3.9+.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Quickstart (20-line integration)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from agent_replay import Session, attribute
|
|
107
|
+
|
|
108
|
+
def my_agent(ctx, question):
|
|
109
|
+
plan = ctx.llm("plan", produce=lambda: {"q": question}, prompt=question)
|
|
110
|
+
hits = ctx.tool("search", produce=lambda: ("bug" if ctx.rng.random() < 0.7 else "ok"), q=plan["q"])
|
|
111
|
+
draft = ctx.llm("write", produce=lambda: hits, context=hits)
|
|
112
|
+
return {"answer": draft, "ok": draft == "ok"}
|
|
113
|
+
|
|
114
|
+
def verifier(result): # 1.0 == success, 0.0 == failure
|
|
115
|
+
return 1.0 if result["ok"] else 0.0
|
|
116
|
+
|
|
117
|
+
with Session("demo.sqlite") as session:
|
|
118
|
+
traj = session.record(my_agent, {"question": "why did it fail?"}, seed=3, verifier=verifier)
|
|
119
|
+
result = attribute(traj, my_agent, verifier, rollouts=60, method="both", repair=True)
|
|
120
|
+
result.to_html("report.html")
|
|
121
|
+
result.to_json("report.json")
|
|
122
|
+
print("culprit step:", result.culprit_index) # -> the step that caused the failure
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The only thing your agent has to do is route its non-deterministic work through
|
|
126
|
+
the context handle — `ctx.llm(...)`, `ctx.tool(...)`, `ctx.memory(...)` — passing
|
|
127
|
+
a `produce` callable that *is* the policy for that step, and drawing any
|
|
128
|
+
randomness from `ctx.rng`. The **same function** is used for recording and for
|
|
129
|
+
every counterfactual rollout; that is what makes attribution possible.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## The CLI
|
|
134
|
+
|
|
135
|
+
The CLI points at **your own** agent and verifier via `module:function`
|
|
136
|
+
entrypoints — the package ships no bundled agents. Given a `myproject/agents.py`
|
|
137
|
+
that exposes an agent (routing its work through `ctx`, as in the Quickstart) and a
|
|
138
|
+
verifier:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# 1. record a factual run into a checkpoint store
|
|
142
|
+
agent-replay record --db demo.sqlite --session run1 \
|
|
143
|
+
--agent myproject.agents:support_agent \
|
|
144
|
+
--verifier myproject.agents:answered_correctly --seed 1
|
|
145
|
+
|
|
146
|
+
# 2. deterministically replay it (fast-forward through the recorded decisions)
|
|
147
|
+
agent-replay replay --db demo.sqlite --session run1 \
|
|
148
|
+
--agent myproject.agents:support_agent \
|
|
149
|
+
--verifier myproject.agents:answered_correctly
|
|
150
|
+
|
|
151
|
+
# 3. attribute the failure + generate reports + propose a repair
|
|
152
|
+
agent-replay attribute --db demo.sqlite --session run1 \
|
|
153
|
+
--agent myproject.agents:support_agent \
|
|
154
|
+
--verifier myproject.agents:answered_correctly \
|
|
155
|
+
--rollouts 60 --method both --repair --out report
|
|
156
|
+
|
|
157
|
+
# 4. regenerate the HTML report from a saved JSON report
|
|
158
|
+
agent-replay report --json report.json --out report.html
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Sample output:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
============================================================
|
|
165
|
+
TRAJECTORY CAUSAL ATTRIBUTION REPORT
|
|
166
|
+
============================================================
|
|
167
|
+
Session: run1
|
|
168
|
+
Total steps: 6
|
|
169
|
+
Outcome: FAILED (verifier score: 0.000)
|
|
170
|
+
Method: both (60 rollouts/step)
|
|
171
|
+
------------------------------------------------------------
|
|
172
|
+
Point-of-Commitment: step 3
|
|
173
|
+
[RESULT] Failure attributed to step 3 (tool tool_step_3) with score 0.700.
|
|
174
|
+
CI [0.560, 0.820]
|
|
175
|
+
[REPAIR] step 3: 'BAD' -> 'OK' (valid, minimality 0.400, P(fail)->0.000)
|
|
176
|
+
============================================================
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Architecture
|
|
182
|
+
|
|
183
|
+
The end-to-end pipeline: record a factual run once, then re-run it many times
|
|
184
|
+
under counterfactual interventions to localize — and repair — the failure.
|
|
185
|
+
|
|
186
|
+
```mermaid
|
|
187
|
+
flowchart TD
|
|
188
|
+
A["Your agent<br/>ctx.llm / ctx.tool / ctx.memory"]
|
|
189
|
+
A -->|record| R["RecordContext<br/>runs the policy, captures steps"]
|
|
190
|
+
R --> T["Trajectory<br/>SCM: state → action → obs → outcome<br/>Merkle-linked step chain"]
|
|
191
|
+
T -->|"save / load"| S[("CheckpointStore<br/>SQLite + CAS blobs, WAL")]
|
|
192
|
+
T -->|"replay, per rollout"| RP["ReplayContext<br/>serve cassette by idempotency key<br/>OR resample per ReplayPlan"]
|
|
193
|
+
RP --> E["AblationEngine<br/>N stochastic rollouts / intervention<br/>serial or parallel (max_workers)"]
|
|
194
|
+
E --> SC["Attribution scorer"]
|
|
195
|
+
SC --> P1["Phase 1 — contrastive estimator<br/>+ Point-of-Commitment Rule"]
|
|
196
|
+
SC --> P2["Phase 2 — Shapley<br/>antithetic, adaptive stopping"]
|
|
197
|
+
P1 --> RES["AttributionResult<br/>Wilson + bootstrap CIs"]
|
|
198
|
+
P2 --> RES
|
|
199
|
+
RES --> REP["Repair search<br/>minimal counterfactual fix"]
|
|
200
|
+
RES --> OUT["HTML + JSON report<br/>+ plain-language explanation"]
|
|
201
|
+
REP --> OUT
|
|
202
|
+
|
|
203
|
+
classDef store fill:#123020,stroke:#1e4a33,color:#e6e9ef;
|
|
204
|
+
classDef out fill:#3a1618,stroke:#5a2226,color:#ffd7d7;
|
|
205
|
+
class S store;
|
|
206
|
+
class OUT,REP out;
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**The replay decision — how each live call binds to the cassette.** This is the
|
|
210
|
+
branch-safe core: a held step is served from the recording only when the *same*
|
|
211
|
+
operation actually recurs; otherwise the timeline has diverged and the call is
|
|
212
|
+
resampled live.
|
|
213
|
+
|
|
214
|
+
```mermaid
|
|
215
|
+
flowchart LR
|
|
216
|
+
Call["live replay call<br/>kind + name + inputs"] --> Match{"idempotency key<br/>matches an unconsumed<br/>recorded step?"}
|
|
217
|
+
Match -->|no| Diverge["timeline diverged<br/>→ resample live"]
|
|
218
|
+
Match -->|yes| Plan{"ReplayPlan.decision(i)"}
|
|
219
|
+
Plan -->|hold| Serve["serve recorded output<br/>(cassette)"]
|
|
220
|
+
Plan -->|"force / do()"| Force["inject forced action"]
|
|
221
|
+
Plan -->|remove| Rm["REMOVED sentinel"]
|
|
222
|
+
Plan -->|resample| Res["re-run policy<br/>fresh stochastic draw"]
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### How the causal attribution works
|
|
226
|
+
|
|
227
|
+
1. **Record** the factual run. Each step's inputs/outputs are stored (content-addressed, deduplicated) and chained into a Merkle-style hash sequence.
|
|
228
|
+
2. **Phase 1 — single-step contrastive estimation.** For every step `i`, hold steps `< i` at their factual recorded actions, **resample** step `i` and everything downstream, and run forward `N` times. Compute `attribution(i) = P(fail|kept) − P(fail|ablated)` with a Wilson interval on the ablated failure rate and a bootstrap interval on the difference.
|
|
229
|
+
3. **Point-of-Commitment Rule.** Because resampling an early step re-rolls the fatal late step too (a butterfly-effect confound), *magnitude alone blames early, irrelevant steps*. Instead we take the **latest** step whose interval still strictly excludes zero — the final juncture at which re-deciding can still rescue the run. That is the true causal locus.
|
|
230
|
+
4. **Phase 2 — Shapley attribution.** For interacting (AND/OR) failures, single-step ablation double-counts or zeroes-out credit. Shapley values split responsibility fairly by averaging each step's marginal contribution over sampled permutations, using **antithetic reverse-permutation pairing** for variance reduction. Coalition values are deliberately **never cached** (that would collapse marginal variance and yield falsely narrow intervals) and **no truncation** is used (it would skip pivotal late steps).
|
|
231
|
+
5. **Repair.** The culprit step's action is swapped for candidate repairs via a `do()` intervention; a candidate that flips the failure rate below threshold and has maximum **minimality** (least behavioural drift) is reported as the validated counterfactual repair.
|
|
232
|
+
|
|
233
|
+
### Scope note
|
|
234
|
+
|
|
235
|
+
The source research (*The Chronos Protocol* / *Agent Time-Travel Debugger*)
|
|
236
|
+
describes OS-level substrates — DeltaFS/DeltaCR millisecond checkpoints, CRIU,
|
|
237
|
+
Firecracker microVMs, WASI-Virt — for capturing full process/filesystem state.
|
|
238
|
+
`agent-replay` implements the **framework-agnostic, application-level** essence
|
|
239
|
+
of that vision: deterministic record/replay via recorded cassettes plus the full
|
|
240
|
+
causal-attribution mathematics, with zero external dependencies. The exotic
|
|
241
|
+
kernel primitives are intentionally out of scope; the attribution algorithm does
|
|
242
|
+
not depend on them.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Public API
|
|
247
|
+
|
|
248
|
+
| Symbol | Purpose |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `Session(db_path)` | Record and persist agent runs to a SQLite store. |
|
|
251
|
+
| `session.record(agent_fn, task, seed, verifier)` | Capture one factual `Trajectory`. |
|
|
252
|
+
| `attribute(traj, agent_fn, verifier, rollouts, method, repair)` | Run the attribution pipeline → `AttributionResult`. |
|
|
253
|
+
| `AttributionResult.to_html(path)` / `.to_json(path)` | Emit the failure-attribution report. |
|
|
254
|
+
| `record(...)` | Low-level one-shot recording (no store). |
|
|
255
|
+
| `replay(agent_fn, traj, plan, seed)` | Deterministic replay under a `ReplayPlan`. |
|
|
256
|
+
| `ReplayPlan.factual / .ablate_from / .coalition` | Build intervention plans. |
|
|
257
|
+
| `AblationEngine` | Run stochastic rollouts for a plan. |
|
|
258
|
+
| `find_minimal_repair(engine, step)` | Search for a minimal counterfactual repair. |
|
|
259
|
+
| `interop.from_otel_spans / from_jsonl / from_steps` | Import a `Trajectory` from an external trace. |
|
|
260
|
+
| `interop.replayable_agent(traj, resample_fns)` | Make an imported trace attributable. |
|
|
261
|
+
| `aggregate_runs(trajectories, agent, verifier)` | Pool attribution across runs → systematic weak step. |
|
|
262
|
+
| `CheckpointStore` | The SQLite checkpoint / content-addressable store. |
|
|
263
|
+
|
|
264
|
+
`method` is `"contrastive"` (Phase 1), `"shapley"` (Phase 2), or `"both"`.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Connect any framework
|
|
269
|
+
|
|
270
|
+
Three ways to feed steps in, from explicit to fully automatic — all produce the
|
|
271
|
+
same `Trajectory`. Full guide: [`docs/frameworks.md`](docs/frameworks.md).
|
|
272
|
+
|
|
273
|
+
**Decorate any callable** (no `ctx` threading — uses an ambient context):
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
from agent_replay import instrument
|
|
277
|
+
|
|
278
|
+
@instrument.tool
|
|
279
|
+
def search(q): ...
|
|
280
|
+
@instrument.llm
|
|
281
|
+
def answer(ctx): ...
|
|
282
|
+
|
|
283
|
+
def agent(question): # no ctx parameter
|
|
284
|
+
return {"answer": answer(search(question))}
|
|
285
|
+
|
|
286
|
+
traj = instrument.record_agent(agent, {"question": "..."}, session_id="s", verifier=v)
|
|
287
|
+
result = attribute(traj, agent, v, pass_context=False)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Auto-instrument an unmodified SDK** via the data-only recipe registry
|
|
291
|
+
(OpenAI, Anthropic, Cohere, Google GenAI, Mistral, LiteLLM, LangChain,
|
|
292
|
+
LlamaIndex, CrewAI, AutoGen — best-effort, absent SDKs skipped):
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
from agent_replay import instrument
|
|
296
|
+
instrument.available_frameworks() # -> the list above
|
|
297
|
+
with instrument.installed("openai", "langchain"):
|
|
298
|
+
traj = instrument.record_agent(run_my_crew, {...}, session_id="s", verifier=v)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Not in the registry? Patch any dotted callable — this is how the recipes work,
|
|
302
|
+
so it covers every framework: `instrument.patch("my_fw.LLM.complete", kind="llm")`.
|
|
303
|
+
|
|
304
|
+
## Import a trace recorded anywhere
|
|
305
|
+
|
|
306
|
+
Already have traces in LangSmith / Langfuse / AgentOps, an OpenTelemetry export,
|
|
307
|
+
or a JSONL log? `agent_replay.interop` turns them into a first-class `Trajectory`
|
|
308
|
+
you can diff, serve, hash — and attribute. Attribution needs to *re-run* a step's
|
|
309
|
+
policy, which a recorded trace doesn't contain, so you supply a resample policy
|
|
310
|
+
per step kind (or name); steps without one stay observation-only.
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from agent_replay import attribute, interop
|
|
314
|
+
|
|
315
|
+
traj = interop.from_otel_spans(otel_spans, session_id="prod-run-42") # or from_jsonl(...)
|
|
316
|
+
|
|
317
|
+
# Make it attributable: give the steps you want perturbed a resample policy.
|
|
318
|
+
def llm_policy(ctx, inputs): # re-draw this step's output from its recorded inputs
|
|
319
|
+
return call_your_model(inputs["messages"], temperature=0.7)
|
|
320
|
+
|
|
321
|
+
agent = interop.replayable_agent(traj, resample_fns={"llm": llm_policy})
|
|
322
|
+
result = attribute(traj, agent, my_verifier, rollouts=60)
|
|
323
|
+
print(result.culprit_index) # which step caused the failure — on an imported trace
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`from_otel_spans` follows the OpenTelemetry **GenAI** semantic conventions
|
|
327
|
+
(`gen_ai.*` attributes → `llm` steps, `execute_tool` / `gen_ai.tool.name` →
|
|
328
|
+
`tool` steps) and is deliberately tolerant — unknown spans import as opaque tool
|
|
329
|
+
steps rather than being dropped.
|
|
330
|
+
|
|
331
|
+
## Proof: the Who&When benchmark
|
|
332
|
+
|
|
333
|
+
Localizing the *responsible step* in a failed trajectory is the *Who&When* task
|
|
334
|
+
(arXiv:2505.00212), where the strongest LLM-as-judge attributor reaches only
|
|
335
|
+
**~14.2%** step accuracy. `benchmarks/whowhen.py` measures this tool on
|
|
336
|
+
ground-truth-labelled trajectories:
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
python benchmarks/whowhen.py
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
causal attribution (this tool) : 100.0% (8/8)
|
|
344
|
+
max-magnitude (no PoC rule) : 12.5% (1/8)
|
|
345
|
+
last-step baseline : 37.5% (3/8)
|
|
346
|
+
LLM-as-judge (Who&When lit.) : ~14.2% (arXiv:2505.00212)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The `max-magnitude` row — highest-|attribution| step *without* the
|
|
350
|
+
Point-of-Commitment rule — lands near the judge baseline, quantifying exactly
|
|
351
|
+
what the PoC rule buys. The harness ships a deterministic synthetic generator so
|
|
352
|
+
it runs offline; point `evaluate()` at `interop`-imported trajectories to run the
|
|
353
|
+
real dataset. See [`benchmarks/README.md`](benchmarks/README.md).
|
|
354
|
+
|
|
355
|
+
## Find your agent's *systematic* weak step
|
|
356
|
+
|
|
357
|
+
Attributing one failure tells you which step broke *that* run. `aggregate` pools
|
|
358
|
+
attribution across many failing runs of the same task and ranks steps by **name**
|
|
359
|
+
(the stable identity of an operation) — so you can tell bad luck (culprit in 1 of
|
|
360
|
+
20 runs) from a design flaw (culprit in 15 of 20):
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
from agent_replay import aggregate_runs
|
|
364
|
+
|
|
365
|
+
agg = aggregate_runs(failing_trajectories, my_agent, my_verifier, rollouts=50)
|
|
366
|
+
print(agg.systematic_culprit) # e.g. "tool:web_search" — the step most consistently to blame
|
|
367
|
+
print(agg.to_text())
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
Aggregate attribution for 'support-agent': 18 failing runs
|
|
372
|
+
Systematic weak step: tool:web_search
|
|
373
|
+
step culprit mean attr [95% CI] poc
|
|
374
|
+
tool:web_search 15/18 83% 0.71 [ 0.55, 0.86] 15/18
|
|
375
|
+
llm:plan 2/18 11% 0.14 [ 0.02, 0.29] 2/18
|
|
376
|
+
llm:write 1/18 6% 0.05 [-0.03, 0.14] 1/18
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Or over a whole checkpoint store from the CLI:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
agent-replay aggregate --db runs.sqlite --agent myproj:agent --verifier myproj:ok --rollouts 60
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Explainable output
|
|
386
|
+
|
|
387
|
+
Every attribution can be rendered as a traceable, plain-language explanation —
|
|
388
|
+
**what** went wrong, **where**, **why**, and **how to fix** it — with a per-step
|
|
389
|
+
causal trace from the first action to the point of no return. The estimators are
|
|
390
|
+
unchanged; this is a presentation layer.
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
explanation = result.explain(traj)
|
|
394
|
+
print(explanation.to_text()) # ASCII-safe narrative
|
|
395
|
+
result.to_html("report.html", explanation=explanation) # adds an Explanation panel
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
WHAT: The run failed (score 0.00). The decisive error is step 3. Its action was 'BAD'.
|
|
400
|
+
WHERE: Step 3 - tool:tool_step_3.
|
|
401
|
+
WHY: Keeping step 3 fails 1.00 of the time; re-deciding it drops failure to 0.78
|
|
402
|
+
(rescue 0.22). It is the latest step where re-deciding still changes the
|
|
403
|
+
outcome; the 2 steps after it stay failing, so the run is locked in beyond here.
|
|
404
|
+
FIX: Constrain step 3 from 'BAD' toward '' (validated repair, P(fail)->0.00).
|
|
405
|
+
|
|
406
|
+
Causal trace (first action -> point of no return):
|
|
407
|
+
+ step 0 [llm:reason_step_0] contributing (butterfly effect; blame resolves later)
|
|
408
|
+
>> step 3 [tool:tool_step_3] decisive (the point of commitment)
|
|
409
|
+
x step 4 [llm:reason_step_4] locked-in (outcome already committed)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The CLI prints this automatically (`--no-explain` to suppress) and embeds it in
|
|
413
|
+
the HTML/JSON reports.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Test your agent (pytest)
|
|
418
|
+
|
|
419
|
+
Gate CI on agent reliability. `assert_agent_passes` runs the agent many times
|
|
420
|
+
(agents are stochastic — one green run is not a pass), fails the test if the
|
|
421
|
+
failure rate exceeds budget, and on failure puts a counterfactual attribution —
|
|
422
|
+
*which step broke, why, and the minimal fix* — into the test output.
|
|
423
|
+
|
|
424
|
+
```python
|
|
425
|
+
from agent_replay.pytest_plugin import assert_agent_passes
|
|
426
|
+
|
|
427
|
+
def test_my_agent():
|
|
428
|
+
assert_agent_passes(my_agent, {"q": "..."}, my_verifier,
|
|
429
|
+
rollouts=40, p_fail_max=0.05,
|
|
430
|
+
report_path="agent_failure.html") # HTML artifact on failure
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## Faster, cheaper attribution
|
|
434
|
+
|
|
435
|
+
`adaptive=True` uses sequential stopping — rollouts accrue only until each step's
|
|
436
|
+
interval is tight enough (decisive steps stop early), typically several-fold
|
|
437
|
+
fewer rollouts with the same verdict:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
result = attribute(traj, agent, verifier, rollouts=200, adaptive=True, target_ci_width=0.2)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Analyze once, pay once
|
|
444
|
+
|
|
445
|
+
`attribute`, `drift` and `faithfulness` re-run the same prefix-hold rollouts on a
|
|
446
|
+
run. Share a `RolloutCache` so the second and third analysis reuse the first's —
|
|
447
|
+
or use the `analyze` wrapper, which does it for you:
|
|
448
|
+
|
|
449
|
+
```python
|
|
450
|
+
from agent_replay import analyze
|
|
451
|
+
|
|
452
|
+
out = analyze(traj, agent, verifier, rollouts=50, state_scorer=my_health_fn)
|
|
453
|
+
out["attribution"].culprit_index
|
|
454
|
+
out["drift"].commitment_index
|
|
455
|
+
out["cache"].hits # rollouts reused instead of recomputed
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Shapley coalition rollouts are never cached (they must stay independent draws for
|
|
459
|
+
valid variance); only the prefix-hold / factual plans are shared. Results are
|
|
460
|
+
byte-identical to running each analysis on its own.
|
|
461
|
+
|
|
462
|
+
## Async agents
|
|
463
|
+
|
|
464
|
+
`async def` agents just work — `record` / `replay` / `attribute` detect coroutine
|
|
465
|
+
agents and run them through the same pipeline (Shapley, repair, branch-safety
|
|
466
|
+
included):
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
async def agent(ctx, q):
|
|
470
|
+
plan = await ctx.llm("plan", produce=lambda: call_model(q))
|
|
471
|
+
...
|
|
472
|
+
traj = record(agent, {"q": "..."}, session_id="s", verifier=v) # auto-detected
|
|
473
|
+
result = attribute(traj, agent, v) # sync API, async agent
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## The intervention algebra
|
|
477
|
+
|
|
478
|
+
Attribution rests on `do()`-calculus interventions on one step. The full set the
|
|
479
|
+
research describes is available, both as `ReplayPlan`s and through `fork`:
|
|
480
|
+
|
|
481
|
+
| Intervention | What it changes | How |
|
|
482
|
+
|---|---|---|
|
|
483
|
+
| **resample** | re-draw the step's action from its policy | default |
|
|
484
|
+
| **do / force** | override the step's **action** | `fork(..., do=value)` |
|
|
485
|
+
| **remove** | drop the step entirely | `fork(..., remove=True)` |
|
|
486
|
+
| **mock-observe** | override the **observation**, keep the recorded action | `fork(..., observe=value)` |
|
|
487
|
+
| **swap-model** | run the step under a different model | `fork(..., model="gpt-5")` |
|
|
488
|
+
|
|
489
|
+
The SCM separates the policy's *action* from the *observation* it yields. Record
|
|
490
|
+
both when they differ — the agent's chosen call is the action, the environment's
|
|
491
|
+
return is the observation:
|
|
492
|
+
|
|
493
|
+
```python
|
|
494
|
+
def agent(ctx, q):
|
|
495
|
+
result = ctx.tool("search",
|
|
496
|
+
produce=lambda: choose_query(q), # the action
|
|
497
|
+
observe=lambda call: run_search(call)) # the observation
|
|
498
|
+
return {"answer": result}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Then `mock-observe` tests memory/context reliance (keep the action, inject a
|
|
502
|
+
different observation), and `swap-model` asks whether a different model would have
|
|
503
|
+
recovered — a policy reads `ctx.model_hint` to switch:
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
from agent_replay import fork
|
|
507
|
+
mocked = fork(agent, traj, at_step=1, observe="empty search results")
|
|
508
|
+
upgraded = fork(agent, traj, at_step=1, model="gpt-5") # policy reads ctx.model_hint
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
## The Multiverse: fork, resume, diff, faithfulness, drift
|
|
512
|
+
|
|
513
|
+
Beyond attributing a single failure, agent-replay realizes the *Agent Multiverse*
|
|
514
|
+
vision at the application level (see [`docs/MULTIVERSE_GAPS.md`](docs/MULTIVERSE_GAPS.md)):
|
|
515
|
+
|
|
516
|
+
```mermaid
|
|
517
|
+
flowchart TD
|
|
518
|
+
Base["Recorded trajectory<br/>factual, failing"]
|
|
519
|
+
Base -->|"fork(at_step=i, do= / remove=)"| Child["Child branch<br/>held prefix + intervention<br/>+ live continuation"]
|
|
520
|
+
Base -->|"resume()"| Live["Continue live<br/>past the recorded horizon"]
|
|
521
|
+
Base --> DIFF{{"diff(base, child)"}}
|
|
522
|
+
Child --> DIFF
|
|
523
|
+
DIFF --> Div["first divergence<br/>+ per-step state diff"]
|
|
524
|
+
Base -. "parent_session / fork_step (CAS-deduped prefix)" .-> Child
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
```python
|
|
529
|
+
from agent_replay import fork, resume, diff, faithfulness, drift
|
|
530
|
+
|
|
531
|
+
# Fork an alternate timeline at step 3 (force or remove the step's action),
|
|
532
|
+
# recorded as a first-class child branch (shared prefix deduped via the CAS store).
|
|
533
|
+
alt = fork(agent, traj, at_step=3, do="OK", session_id="fixed")
|
|
534
|
+
print(diff(traj, alt)["first_divergence"]) # where the timelines split
|
|
535
|
+
|
|
536
|
+
# Durable resume: fast-forward the recorded prefix, continue live past the horizon.
|
|
537
|
+
live = resume(agent, traj)
|
|
538
|
+
|
|
539
|
+
# Step-level faithfulness: does the reasoning actually drive the answer?
|
|
540
|
+
fr = faithfulness(traj, agent, verifier)
|
|
541
|
+
print(fr.quadrant) # correct/wrong x faithful/unfaithful; flags post-hoc rationalization
|
|
542
|
+
|
|
543
|
+
# Drift / entropy-of-autonomy curve: chart a run's health as it unfolds.
|
|
544
|
+
dr = drift(traj, agent, verifier, state_scorer=my_health_fn) # state_scorer optional
|
|
545
|
+
print(dr.commitment_index) # step where the outcome's fate seals (entropy collapses)
|
|
546
|
+
dr.to_html("drift.html") # standalone SVG curve
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`drift` needs only the outcome verifier for the entropy curve (how *open* the run's
|
|
550
|
+
fate still is at each step); an optional `state_scorer(step) -> [0,1]` adds an
|
|
551
|
+
alignment-health overlay and flags **silent decay** — internal health degrading
|
|
552
|
+
before the outcome reflects it. Browse it all in the zero-dependency console:
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
agent-replay serve --db demo.sqlite # sessions, frozen per-step state, branch graph
|
|
556
|
+
agent-replay fork --db demo.sqlite --session run1 --agent … --at-step 3 --do '"OK"'
|
|
557
|
+
agent-replay drift --db demo.sqlite --session run1 --agent … --verifier … \
|
|
558
|
+
--state-scorer mypkg:health --out drift.html
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## Deployable step-wise fixes
|
|
562
|
+
|
|
563
|
+
The validated repair becomes a runtime guard and training data:
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
result = attribute(traj, agent, verifier, repair=True,
|
|
567
|
+
repair_propose_fn=my_llm_proposer) # optional: LLM-proposed candidates
|
|
568
|
+
print(result.repair.to_guard()) # deploy-time recovery snippet
|
|
569
|
+
|
|
570
|
+
from agent_replay import export_contrastive_pairs
|
|
571
|
+
export_contrastive_pairs([result], "pairs.jsonl") # (wrong -> fix) pairs for DPO
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Development
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
pip install -e ".[dev]"
|
|
580
|
+
pytest -q # full suite (mock agent with a known-culprit step)
|
|
581
|
+
ruff check . && ruff format --check .
|
|
582
|
+
python -m build # sdist + wheel
|
|
583
|
+
python examples/quickstart.py # generates report.html / report.json
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
CI (GitHub Actions) runs ruff lint + format checks, the pytest suite on Python
|
|
587
|
+
3.9–3.12 with coverage, and a distribution build.
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## License
|
|
592
|
+
|
|
593
|
+
MIT © agent-replay contributors. See [LICENSE](LICENSE).
|