agent-trajectory-diff 0.2.2__tar.gz → 0.3.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.
Files changed (47) hide show
  1. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/.gitignore +3 -0
  2. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/PKG-INFO +62 -2
  3. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/README.md +61 -1
  4. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/pyproject.toml +1 -0
  5. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/__init__.py +28 -1
  6. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/__init__.py +16 -0
  7. agent_trajectory_diff-0.3.0/src/agentdiff/adapters/_messages.py +255 -0
  8. agent_trajectory_diff-0.3.0/src/agentdiff/adapters/crewai.py +160 -0
  9. agent_trajectory_diff-0.3.0/src/agentdiff/adapters/langgraph.py +125 -0
  10. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/openinference.py +4 -3
  11. agent_trajectory_diff-0.3.0/src/agentdiff/adapters/registry.py +234 -0
  12. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/cli.py +14 -1
  13. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/config.py +4 -0
  14. agent_trajectory_diff-0.3.0/src/agentdiff/engine/benchmark.py +226 -0
  15. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/comparator.py +15 -2
  16. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/explanations.py +29 -1
  17. agent_trajectory_diff-0.3.0/src/agentdiff/engine/metrics.py +104 -0
  18. agent_trajectory_diff-0.3.0/src/agentdiff/engine/suite.py +225 -0
  19. agent_trajectory_diff-0.3.0/src/agentdiff/loader.py +122 -0
  20. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/report.py +8 -0
  21. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/pr.py +9 -1
  22. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/terminal.py +4 -1
  23. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/testing/assertions.py +55 -12
  24. agent_trajectory_diff-0.2.2/src/agentdiff/engine/metrics.py +0 -37
  25. agent_trajectory_diff-0.2.2/src/agentdiff/loader.py +0 -97
  26. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/LICENSE +0 -0
  27. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/__main__.py +0 -0
  28. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/_iso.py +0 -0
  29. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/base.py +0 -0
  30. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/generic.py +0 -0
  31. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/langfuse.py +0 -0
  32. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/langsmith.py +0 -0
  33. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/openai_agents.py +0 -0
  34. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/ci/baseline.py +0 -0
  35. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/ci/github.py +0 -0
  36. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/__init__.py +0 -0
  37. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/aligner.py +0 -0
  38. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/loop_detector.py +0 -0
  39. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/tree.py +0 -0
  40. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/__init__.py +0 -0
  41. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/step.py +0 -0
  42. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/trace.py +0 -0
  43. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/py.typed +0 -0
  44. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/pytest_plugin.py +0 -0
  45. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/__init__.py +0 -0
  46. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/markdown.py +0 -0
  47. {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/testing/__init__.py +0 -0
@@ -79,3 +79,6 @@ website/.env.local
79
79
  website/.env.development.local
80
80
  website/.env.test.local
81
81
  website/.env.production.local
82
+
83
+ # pytest-benchmark local results
84
+ .benchmarks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-trajectory-diff
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: A developer-first package to evaluate and find regressions in agent trajectories
5
5
  Project-URL: Homepage, https://github.com/lostmartian/agentdiff
6
6
  Project-URL: Repository, https://github.com/lostmartian/agentdiff
@@ -31,6 +31,23 @@ Description-Content-Type: text/markdown
31
31
  * **A Local-First CI/CD Gate:** Runs locally in your terminal or inside `pytest` and GitHub Actions, raising errors or exit codes on regression violations.
32
32
  * **A Universal Comparator:** Ingests telemetry run files from **OpenInference/OTel**, **Langfuse**, **LangSmith**, **OpenAI Agents SDK**, or raw/custom JSON.
33
33
 
34
+ ### What AgentDiff Is Not
35
+
36
+ * **Not an observability backend.** No hosted tracing, no APM, no log storage — AgentDiff works on trace files you already have, at test time.
37
+ * **Not an LLM-as-a-judge scorer.** Semantic answer quality is DeepEval/Ragas territory; AgentDiff measures *how* your agent got there — structurally and deterministically.
38
+ * **Not an agent framework.** It doesn't orchestrate or run agents; it evaluates the trajectories your existing agents (LangGraph, CrewAI, OpenAI Agents SDK, custom loops) already produce.
39
+
40
+ ### Local-First Privacy
41
+
42
+ Agent trajectories contain your prompts, your tool outputs, and often your customers' data. AgentDiff is architected so that **nothing ever leaves your machine:**
43
+
44
+ * **No network calls at diff time.** Parsing, DAG alignment, and scoring are pure local computation — run a diff on a plane, in a bank's air-gapped CI, or behind a strict egress firewall.
45
+ * **No account, no telemetry.** AgentDiff doesn't phone home, has no API to sign up for, and collects nothing.
46
+ * **Your baselines live in your repo.** Baseline traces are ordinary committed files (`--baseline` / `--update-baseline`), versioned with the code they gate — no external service holds them.
47
+ * **CI stays inside your perimeter.** The GitHub Action reads traces from your checkout and posts reports with your own `GITHUB_TOKEN`; traces are never uploaded anywhere by us.
48
+
49
+ Hosted eval platforms require shipping production traces to a third party before you can diff them. With AgentDiff, the diff is a file operation.
50
+
34
51
  ## Installation
35
52
 
36
53
  Install the PyPI package:
@@ -145,6 +162,10 @@ name: AgentDiff Gate
145
162
  on:
146
163
  pull_request:
147
164
 
165
+ permissions:
166
+ contents: read
167
+ pull-requests: write # lets the action post the PR comment
168
+
148
169
  jobs:
149
170
  agentdiff:
150
171
  runs-on: ubuntu-latest
@@ -160,11 +181,18 @@ jobs:
160
181
  update-baseline: "false"
161
182
  max-divergence: "0.3"
162
183
  max-cost-delta: "10.0"
184
+ # Optional: auto-post the report onto the triggering PR.
185
+ pr: ${{ github.event.pull_request.number }}
186
+ github-token: ${{ secrets.GITHUB_TOKEN }}
163
187
  ```
164
188
 
165
189
  The action installs the package (default `agent-trajectory-diff` from PyPI),
166
190
  runs `agentdiff --fail-on-regression`, and fails the job when divergence,
167
- loops, or cost spikes exceed the thresholds.
191
+ loops, or cost spikes exceed the thresholds. When `pr` is set it also posts the
192
+ PR-ready report (status, gate table, root-cause culprit, collapsed divergence
193
+ tree, loops) as a comment on that PR — even when the gate blocks. See the
194
+ [`agentdiff-demo`](https://github.com/lostmartian/agentdiff-demo) repository
195
+ for a working, live example (real Gemini agent + auto PR comments).
168
196
 
169
197
  **Available inputs:**
170
198
 
@@ -178,6 +206,11 @@ loops, or cost spikes exceed the thresholds.
178
206
  | `max-loops` | `0` | Maximum loop count before regression. |
179
207
  | `max-cost-delta` | `10.0` | Maximum cost increase percentage before regression. |
180
208
  | `update-baseline` | `false` | Overwrite the stored baseline with the candidate when the run is clean. |
209
+ | `pr` | *(empty)* | GitHub PR number to post the report comment to (e.g. `github.event.pull_request.number`). |
210
+ | `github-token` | *(empty)* | GitHub token used to post the comment (e.g. `secrets.GITHUB_TOKEN`). Required when `pr` is set. |
211
+
212
+ > **Permission:** to post the PR comment the workflow needs `pull-requests: write`
213
+ > (the built-in `GITHUB_TOKEN` is otherwise read-only). No manual token required.
181
214
 
182
215
  ## Core Metrics
183
216
 
@@ -186,8 +219,35 @@ loops, or cost spikes exceed the thresholds.
186
219
  | **Trajectory Divergence Index (TDI)** | `0.0` (Identical) to `1.0` (Divergent) | $$1.0 - \frac{2 \times \vert{}\text{LCS}(\text{Steps}_A, \text{Steps}_B)\vert{}}{\vert{}\text{Steps}_A\vert{} + \vert{}\text{Steps}_B\vert{}}$$ |
187
220
  | **Wasted Effort Index (WEI)** | `0.0` (Optimal) to `1.0` (Total Waste) | $$\frac{\text{Count}(\text{Steps with status} \in \{\text{ERROR, RETRY, ABANDONED}\})}{\text{Total Execution Steps}}$$ |
188
221
  | **Loop Buster Index (LBI)** | Integer ($\ge 0$) | Detects consecutive repeating sequences of tools with stagnant state changes. |
222
+ | **Recovery Step Ratio (RSR)** | `1.0` = parity; $> 1.0$ = slower recovery than baseline | Successful steps spent after ERROR/RETRY/ABANDONED clusters until re-aligning with the baseline path: $\text{RSR} = \frac{\text{Recovery}_{\text{candidate}}}{\text{Recovery}_{\text{baseline}}}$ (falls back to the raw candidate count when the baseline is clean). Gate via `--max-recovery-ratio` / `max_recovery_step_ratio`. |
189
223
  | **Resource Deltas ($\Delta\text{Res}$)** | Percentage ($\pm\%$) | Standard deltas for $\Delta\text{Tokens}$, $\Delta\text{Cost}$, and $\Delta\text{Latency}$. |
190
224
 
225
+ ## FAQ
226
+
227
+ **How is AgentDiff different from DeepEval or Ragas?**
228
+ They score *what* the agent said (semantic quality, via LLM judges). AgentDiff measures *how* the agent got there — step order, tool loops, wasted effort, cost/latency deltas — using deterministic graph algorithms. They complement each other; AgentDiff adds no LLM calls and is fully deterministic.
229
+
230
+ **Do I need API keys to run a diff?**
231
+ No. AgentDiff is pure math over trace files you already have. Keys are only needed by your own agent when it produces traces, or by the optional live cookbooks that generate them.
232
+
233
+ **Where do trace files come from?**
234
+ Export them from whatever already records your runs: Langfuse or LangSmith exports, OpenTelemetry/OpenInference span dumps, the OpenAI Agents SDK tracing processor, or hand-rolled JSON matching the generic schema. See [`cookbooks/`](cookbooks/) for working recipes per source.
235
+
236
+ **Can I compare runs from different frameworks?**
237
+ Yes. Traces are normalized to one canonical `AgentTrace` schema before comparison, so an OpenInference baseline can be diffed against a Langfuse candidate (or any other pairing).
238
+
239
+ **What do TDI / WEI / LBI mean in one line each?**
240
+ TDI: fraction of trajectory structure that changed (0 = identical). WEI: share of steps that were errors/retries/abandonments. LBI: count of repeating tool sequences with no state progress. Definitions above.
241
+
242
+ **How does the pytest plugin know which baseline belongs to a test?**
243
+ Mark tests with the `agentdiff` marker and use the `agentdiff_trace` fixture; a committed baseline file per test is compared automatically (`--agentdiff-update-baselines` advances baselines on clean runs). See the docs for setup.
244
+
245
+ **Which Python versions are supported?**
246
+ Python 3.10 through 3.13, tested in CI on every PR.
247
+
248
+ **Is it production-safe to gate merges on this?**
249
+ That's the point — exit codes 0/1 make it a drop-in CI gate, and the GitHub Action posts the culprit + divergence tree right onto the PR so reviewers see *why* a gate blocked.
250
+
191
251
  ## Development & Operations
192
252
 
193
253
  This project utilizes `uv` to manage environments and dependencies. Automation tasks are defined in the **[`Makefile`](file:///Users/lostmartian/Desktop/interview/agentdiff/Makefile)**:
@@ -11,6 +11,23 @@
11
11
  * **A Local-First CI/CD Gate:** Runs locally in your terminal or inside `pytest` and GitHub Actions, raising errors or exit codes on regression violations.
12
12
  * **A Universal Comparator:** Ingests telemetry run files from **OpenInference/OTel**, **Langfuse**, **LangSmith**, **OpenAI Agents SDK**, or raw/custom JSON.
13
13
 
14
+ ### What AgentDiff Is Not
15
+
16
+ * **Not an observability backend.** No hosted tracing, no APM, no log storage — AgentDiff works on trace files you already have, at test time.
17
+ * **Not an LLM-as-a-judge scorer.** Semantic answer quality is DeepEval/Ragas territory; AgentDiff measures *how* your agent got there — structurally and deterministically.
18
+ * **Not an agent framework.** It doesn't orchestrate or run agents; it evaluates the trajectories your existing agents (LangGraph, CrewAI, OpenAI Agents SDK, custom loops) already produce.
19
+
20
+ ### Local-First Privacy
21
+
22
+ Agent trajectories contain your prompts, your tool outputs, and often your customers' data. AgentDiff is architected so that **nothing ever leaves your machine:**
23
+
24
+ * **No network calls at diff time.** Parsing, DAG alignment, and scoring are pure local computation — run a diff on a plane, in a bank's air-gapped CI, or behind a strict egress firewall.
25
+ * **No account, no telemetry.** AgentDiff doesn't phone home, has no API to sign up for, and collects nothing.
26
+ * **Your baselines live in your repo.** Baseline traces are ordinary committed files (`--baseline` / `--update-baseline`), versioned with the code they gate — no external service holds them.
27
+ * **CI stays inside your perimeter.** The GitHub Action reads traces from your checkout and posts reports with your own `GITHUB_TOKEN`; traces are never uploaded anywhere by us.
28
+
29
+ Hosted eval platforms require shipping production traces to a third party before you can diff them. With AgentDiff, the diff is a file operation.
30
+
14
31
  ## Installation
15
32
 
16
33
  Install the PyPI package:
@@ -125,6 +142,10 @@ name: AgentDiff Gate
125
142
  on:
126
143
  pull_request:
127
144
 
145
+ permissions:
146
+ contents: read
147
+ pull-requests: write # lets the action post the PR comment
148
+
128
149
  jobs:
129
150
  agentdiff:
130
151
  runs-on: ubuntu-latest
@@ -140,11 +161,18 @@ jobs:
140
161
  update-baseline: "false"
141
162
  max-divergence: "0.3"
142
163
  max-cost-delta: "10.0"
164
+ # Optional: auto-post the report onto the triggering PR.
165
+ pr: ${{ github.event.pull_request.number }}
166
+ github-token: ${{ secrets.GITHUB_TOKEN }}
143
167
  ```
144
168
 
145
169
  The action installs the package (default `agent-trajectory-diff` from PyPI),
146
170
  runs `agentdiff --fail-on-regression`, and fails the job when divergence,
147
- loops, or cost spikes exceed the thresholds.
171
+ loops, or cost spikes exceed the thresholds. When `pr` is set it also posts the
172
+ PR-ready report (status, gate table, root-cause culprit, collapsed divergence
173
+ tree, loops) as a comment on that PR — even when the gate blocks. See the
174
+ [`agentdiff-demo`](https://github.com/lostmartian/agentdiff-demo) repository
175
+ for a working, live example (real Gemini agent + auto PR comments).
148
176
 
149
177
  **Available inputs:**
150
178
 
@@ -158,6 +186,11 @@ loops, or cost spikes exceed the thresholds.
158
186
  | `max-loops` | `0` | Maximum loop count before regression. |
159
187
  | `max-cost-delta` | `10.0` | Maximum cost increase percentage before regression. |
160
188
  | `update-baseline` | `false` | Overwrite the stored baseline with the candidate when the run is clean. |
189
+ | `pr` | *(empty)* | GitHub PR number to post the report comment to (e.g. `github.event.pull_request.number`). |
190
+ | `github-token` | *(empty)* | GitHub token used to post the comment (e.g. `secrets.GITHUB_TOKEN`). Required when `pr` is set. |
191
+
192
+ > **Permission:** to post the PR comment the workflow needs `pull-requests: write`
193
+ > (the built-in `GITHUB_TOKEN` is otherwise read-only). No manual token required.
161
194
 
162
195
  ## Core Metrics
163
196
 
@@ -166,8 +199,35 @@ loops, or cost spikes exceed the thresholds.
166
199
  | **Trajectory Divergence Index (TDI)** | `0.0` (Identical) to `1.0` (Divergent) | $$1.0 - \frac{2 \times \vert{}\text{LCS}(\text{Steps}_A, \text{Steps}_B)\vert{}}{\vert{}\text{Steps}_A\vert{} + \vert{}\text{Steps}_B\vert{}}$$ |
167
200
  | **Wasted Effort Index (WEI)** | `0.0` (Optimal) to `1.0` (Total Waste) | $$\frac{\text{Count}(\text{Steps with status} \in \{\text{ERROR, RETRY, ABANDONED}\})}{\text{Total Execution Steps}}$$ |
168
201
  | **Loop Buster Index (LBI)** | Integer ($\ge 0$) | Detects consecutive repeating sequences of tools with stagnant state changes. |
202
+ | **Recovery Step Ratio (RSR)** | `1.0` = parity; $> 1.0$ = slower recovery than baseline | Successful steps spent after ERROR/RETRY/ABANDONED clusters until re-aligning with the baseline path: $\text{RSR} = \frac{\text{Recovery}_{\text{candidate}}}{\text{Recovery}_{\text{baseline}}}$ (falls back to the raw candidate count when the baseline is clean). Gate via `--max-recovery-ratio` / `max_recovery_step_ratio`. |
169
203
  | **Resource Deltas ($\Delta\text{Res}$)** | Percentage ($\pm\%$) | Standard deltas for $\Delta\text{Tokens}$, $\Delta\text{Cost}$, and $\Delta\text{Latency}$. |
170
204
 
205
+ ## FAQ
206
+
207
+ **How is AgentDiff different from DeepEval or Ragas?**
208
+ They score *what* the agent said (semantic quality, via LLM judges). AgentDiff measures *how* the agent got there — step order, tool loops, wasted effort, cost/latency deltas — using deterministic graph algorithms. They complement each other; AgentDiff adds no LLM calls and is fully deterministic.
209
+
210
+ **Do I need API keys to run a diff?**
211
+ No. AgentDiff is pure math over trace files you already have. Keys are only needed by your own agent when it produces traces, or by the optional live cookbooks that generate them.
212
+
213
+ **Where do trace files come from?**
214
+ Export them from whatever already records your runs: Langfuse or LangSmith exports, OpenTelemetry/OpenInference span dumps, the OpenAI Agents SDK tracing processor, or hand-rolled JSON matching the generic schema. See [`cookbooks/`](cookbooks/) for working recipes per source.
215
+
216
+ **Can I compare runs from different frameworks?**
217
+ Yes. Traces are normalized to one canonical `AgentTrace` schema before comparison, so an OpenInference baseline can be diffed against a Langfuse candidate (or any other pairing).
218
+
219
+ **What do TDI / WEI / LBI mean in one line each?**
220
+ TDI: fraction of trajectory structure that changed (0 = identical). WEI: share of steps that were errors/retries/abandonments. LBI: count of repeating tool sequences with no state progress. Definitions above.
221
+
222
+ **How does the pytest plugin know which baseline belongs to a test?**
223
+ Mark tests with the `agentdiff` marker and use the `agentdiff_trace` fixture; a committed baseline file per test is compared automatically (`--agentdiff-update-baselines` advances baselines on clean runs). See the docs for setup.
224
+
225
+ **Which Python versions are supported?**
226
+ Python 3.10 through 3.13, tested in CI on every PR.
227
+
228
+ **Is it production-safe to gate merges on this?**
229
+ That's the point — exit codes 0/1 make it a drop-in CI gate, and the GitHub Action posts the culprit + divergence tree right onto the PR so reviewers see *why* a gate blocked.
230
+
171
231
  ## Development & Operations
172
232
 
173
233
  This project utilizes `uv` to manage environments and dependencies. Automation tasks are defined in the **[`Makefile`](file:///Users/lostmartian/Desktop/interview/agentdiff/Makefile)**:
@@ -35,6 +35,7 @@ Repository = "https://github.com/lostmartian/agentdiff"
35
35
  dev = [
36
36
  "hypothesis>=6.165.10",
37
37
  "pytest>=8.0",
38
+ "pytest-benchmark>=4.0",
38
39
  "pytest-cov>=7.1.0",
39
40
  "ruff>=0.16.3",
40
41
  ]
@@ -16,6 +16,9 @@ Public API
16
16
  - Adapters: ``GenericAdapter``, ``LangfuseAdapter``, ``LangSmithAdapter``,
17
17
  ``OpenInferenceAdapter``, ``OpenAIAgentsAdapter`` (all expose
18
18
  ``from_dict`` / ``from_file``).
19
+ - ``register_adapter(name, cls)`` / ``available_adapters()``
20
+ Extend ingestion with custom or entry-point plugin adapters
21
+ (entry-point group ``agentdiff.adapters``).
19
22
  - ``load_config(path=None)`` -> ``AgentDiffConfig``
20
23
  Load defaults from ``agentdiff.toml`` (thresholds, adapter, baseline).
21
24
  - Models: ``AgentTrace`` (canonical, ``schema_version``-ed), ``DiffReport``,
@@ -29,7 +32,7 @@ Typical flow::
29
32
  assert_no_regressions(report)
30
33
  """
31
34
 
32
- __version__ = "0.2.2"
35
+ __version__ = "0.3.0"
33
36
 
34
37
  from agentdiff.adapters import (
35
38
  BaseAdapter,
@@ -39,14 +42,27 @@ from agentdiff.adapters import (
39
42
  OpenAIAgentsAdapter,
40
43
  OpenInferenceAdapter,
41
44
  )
45
+ from agentdiff.adapters.registry import (
46
+ available_adapters,
47
+ register_adapter,
48
+ )
42
49
  from agentdiff.ci.baseline import decide_rotation
43
50
  from agentdiff.config import AgentDiffConfig, load_config
44
51
  from agentdiff.engine import compare
52
+ from agentdiff.engine.benchmark import BenchmarkCase, BenchmarkReport, run_benchmark
45
53
  from agentdiff.engine.explanations import (
46
54
  format_explanations,
47
55
  generate_explanations,
48
56
  locate_culprit,
49
57
  )
58
+ from agentdiff.engine.suite import (
59
+ GateThresholds,
60
+ Scenario,
61
+ ScenarioResult,
62
+ SuiteReport,
63
+ run_scenario,
64
+ run_scenarios,
65
+ )
50
66
  from agentdiff.engine.tree import render_tree
51
67
  from agentdiff.loader import load_trace, parse_trace_data
52
68
  from agentdiff.models import (
@@ -66,20 +82,27 @@ __all__ = [
66
82
  "AgentDiffConfig",
67
83
  "AgentTrace",
68
84
  "BaseAdapter",
85
+ "BenchmarkCase",
86
+ "BenchmarkReport",
69
87
  "DiffReport",
88
+ "GateThresholds",
70
89
  "GenericAdapter",
71
90
  "LangSmithAdapter",
72
91
  "LangfuseAdapter",
73
92
  "OpenAIAgentsAdapter",
74
93
  "OpenInferenceAdapter",
94
+ "Scenario",
95
+ "ScenarioResult",
75
96
  "StepDiff",
76
97
  "StepDiffStatus",
77
98
  "StepStatus",
78
99
  "StepType",
100
+ "SuiteReport",
79
101
  "TokenUsage",
80
102
  "TraceStep",
81
103
  "__version__",
82
104
  "assert_no_regressions",
105
+ "available_adapters",
83
106
  "compare",
84
107
  "decide_rotation",
85
108
  "format_explanations",
@@ -89,5 +112,9 @@ __all__ = [
89
112
  "load_trace",
90
113
  "locate_culprit",
91
114
  "parse_trace_data",
115
+ "register_adapter",
92
116
  "render_tree",
117
+ "run_benchmark",
118
+ "run_scenario",
119
+ "run_scenarios",
93
120
  ]
@@ -1,15 +1,31 @@
1
1
  from agentdiff.adapters.base import BaseAdapter
2
+ from agentdiff.adapters.crewai import CrewAIAdapter
2
3
  from agentdiff.adapters.generic import GenericAdapter
3
4
  from agentdiff.adapters.langfuse import LangfuseAdapter
5
+ from agentdiff.adapters.langgraph import LangGraphAdapter
4
6
  from agentdiff.adapters.langsmith import LangSmithAdapter
5
7
  from agentdiff.adapters.openai_agents import OpenAIAgentsAdapter
6
8
  from agentdiff.adapters.openinference import OpenInferenceAdapter
9
+ from agentdiff.adapters.registry import (
10
+ available_adapters,
11
+ get_adapter,
12
+ register_adapter,
13
+ reset_registry,
14
+ unregister_adapter,
15
+ )
7
16
 
8
17
  __all__ = [
9
18
  "BaseAdapter",
19
+ "CrewAIAdapter",
10
20
  "GenericAdapter",
21
+ "LangGraphAdapter",
11
22
  "LangSmithAdapter",
12
23
  "LangfuseAdapter",
13
24
  "OpenAIAgentsAdapter",
14
25
  "OpenInferenceAdapter",
26
+ "available_adapters",
27
+ "get_adapter",
28
+ "register_adapter",
29
+ "reset_registry",
30
+ "unregister_adapter",
15
31
  ]
@@ -0,0 +1,255 @@
1
+ """Shared role-message parsing used by direct framework adapters (A5).
2
+
3
+ LangGraph states and CrewAI task logs both reduce to OpenAI-style role
4
+ messages - system/user/human, assistant/ai with ``tool_calls``, and tool
5
+ results - serialized in one of several shapes. This module owns that
6
+ interpretation once so every direct-ingestion adapter maps identically:
7
+
8
+ - ``message_kind`` -> canonical role for any known message shape
9
+ - ``tool_calls_of`` -> normalized [{name, args, id}] from an AI payload
10
+ - ``usage_of`` -> best-effort TokenUsage across metadata layouts
11
+ - ``RoleStepBuilder`` -> accumulates TraceSteps with collision-safe ids
12
+
13
+ Mapping contract (shared by all consumers):
14
+
15
+ - human/system messages -> task-input context / skipped, never steps
16
+ - assistant tool calls -> one ROUTING step per call, named after the tool
17
+ - matching tool result -> one TOOL_CALL step, status honored when present
18
+ - final assistant text -> one LLM_CALL step named ``response``
19
+ """
20
+
21
+ from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep
22
+
23
+ WASTED_STATUS = {
24
+ "error": StepStatus.ERROR,
25
+ "retry": StepStatus.RETRY,
26
+ "abandoned": StepStatus.ABANDONED,
27
+ }
28
+
29
+
30
+ def message_kind(msg):
31
+ """Returns ``(kind, payload)`` for any known message shape.
32
+
33
+ kind is one of ``human`` | ``ai`` | ``tool`` | ``system`` | ``unknown``;
34
+ payload is the dict carrying content/tool_calls/metadata fields.
35
+ """
36
+ if not isinstance(msg, dict):
37
+ return "unknown", {}
38
+
39
+ # Shape 1: message_to_dict dump - {"type": "ai", "data": {...}}
40
+ if isinstance(msg.get("data"), dict) and msg.get("type"):
41
+ return str(msg["type"]).lower(), msg["data"]
42
+
43
+ kwargs = msg.get("kwargs") if isinstance(msg.get("kwargs"), dict) else None
44
+
45
+ # Shape 2: LC constructor dump - {"lc": 1, "id": [.., "AIMessage"], ...}
46
+ if kwargs is not None:
47
+ ident = msg.get("id")
48
+ tail = ident[-1] if isinstance(ident, list) and ident else ""
49
+ tail = str(tail)
50
+ if "AIMessage" in tail:
51
+ return "ai", kwargs
52
+ if "HumanMessage" in tail:
53
+ return "human", kwargs
54
+ if "ToolMessage" in tail:
55
+ return "tool", kwargs
56
+ if "SystemMessage" in tail:
57
+ return "system", kwargs
58
+
59
+ # Shape 3: plain role dicts
60
+ role = msg.get("role")
61
+ if role in ("user", "human"):
62
+ return "human", msg
63
+ if role in ("assistant", "ai"):
64
+ return "ai", msg
65
+ if role == "tool":
66
+ return "tool", msg
67
+ if role == "system":
68
+ return "system", msg
69
+
70
+ # Fallback for partial shapes (e.g. type field without data wrapper)
71
+ t = msg.get("type")
72
+ if t in ("ai", "human", "tool", "system"):
73
+ return str(t), msg
74
+ return "unknown", {}
75
+
76
+
77
+ def tool_calls_of(payload):
78
+ """Extracts normalized tool calls from an AI-message payload."""
79
+ calls = payload.get("tool_calls")
80
+ out = []
81
+ if isinstance(calls, list):
82
+ for call in calls:
83
+ if not isinstance(call, dict):
84
+ continue
85
+ name = call.get("name") or call.get("function", {}).get("name")
86
+ args = call.get("args")
87
+ if args is None and isinstance(call.get("function"), dict):
88
+ raw_args = call["function"].get("arguments")
89
+ try:
90
+ import json
91
+
92
+ args = json.loads(raw_args) if isinstance(raw_args, str) else None
93
+ except Exception:
94
+ args = None
95
+ call_id = call.get("id") or call.get("tool_call_id")
96
+ if name:
97
+ out.append({"name": str(name), "args": args or {}, "id": call_id})
98
+ return out
99
+
100
+
101
+ def usage_of(payload):
102
+ """Best-effort token extraction across known metadata layouts."""
103
+ usage = payload.get("usage_metadata")
104
+ if isinstance(usage, dict):
105
+ prompt = usage.get("input_tokens") or 0
106
+ completion = usage.get("output_tokens") or 0
107
+ total = usage.get("total_tokens") or (prompt + completion)
108
+ return TokenUsage(
109
+ prompt_tokens=int(prompt),
110
+ completion_tokens=int(completion),
111
+ total_tokens=int(total),
112
+ )
113
+ for holder in ("response_metadata", "additional_kwargs"):
114
+ meta = payload.get(holder)
115
+ if isinstance(meta, dict):
116
+ tu = meta.get("token_usage")
117
+ if isinstance(tu, dict):
118
+ prompt = int(tu.get("prompt_tokens") or 0)
119
+ completion = int(tu.get("completion_tokens") or 0)
120
+ return TokenUsage(
121
+ prompt_tokens=prompt,
122
+ completion_tokens=completion,
123
+ total_tokens=int(tu.get("total_tokens") or (prompt + completion)),
124
+ )
125
+ return TokenUsage()
126
+
127
+
128
+ class RoleStepBuilder:
129
+ """Accumulates canonical steps from a role-message stream.
130
+
131
+ Ids are collision-safe by construction: explicit call ids get role-scoped
132
+ suffixes (``<id>#decision`` / ``<id>#result``), anything else falls back to
133
+ sequential ids, and duplicates are disambiguated deterministically.
134
+ """
135
+
136
+ def __init__(self):
137
+ self.steps = []
138
+ self.task_input = {}
139
+ self.pending_calls = {} # tool_call_id -> tool name
140
+ self._seen_ids = set()
141
+
142
+ def _unique(self, raw, idx):
143
+ candidate = raw or f"role_step_{idx}"
144
+ if candidate not in self._seen_ids:
145
+ self._seen_ids.add(candidate)
146
+ return candidate
147
+ n = 2
148
+ while f"{candidate}#{n}" in self._seen_ids:
149
+ n += 1
150
+ final = f"{candidate}#{n}"
151
+ self._seen_ids.add(final)
152
+ return final
153
+
154
+ def add_step(
155
+ self,
156
+ name,
157
+ step_type,
158
+ payload_in,
159
+ payload_out,
160
+ status=StepStatus.SUCCESS,
161
+ error_message=None,
162
+ tokens=None,
163
+ call_id=None,
164
+ ):
165
+ idx = len(self.steps)
166
+ self.steps.append(
167
+ TraceStep(
168
+ step_id=self._unique(call_id, idx),
169
+ parent_id=None,
170
+ step_index=idx,
171
+ step_type=step_type,
172
+ name=name,
173
+ input_payload=payload_in,
174
+ output_payload=payload_out,
175
+ status=status,
176
+ error_message=error_message,
177
+ latency_ms=0.0,
178
+ tokens=tokens or TokenUsage(),
179
+ metadata={},
180
+ )
181
+ )
182
+
183
+ def feed(self, message, *, task_prefix=""):
184
+ """Consumes one message dict into the builder.
185
+
186
+ Returns ``"step"`` when a step was appended, ``"input"`` when the
187
+ message was absorbed as task input, ``"skip"`` otherwise.
188
+ """
189
+ kind, payload = message_kind(message)
190
+
191
+ if kind == "human":
192
+ content = payload.get("content")
193
+ if not self.task_input and content:
194
+ self.task_input = {"input": content}
195
+ return "input"
196
+ if kind == "system":
197
+ return "skip"
198
+
199
+ prefix = f"{task_prefix}/" if task_prefix else ""
200
+
201
+ if kind == "ai":
202
+ tokens = usage_of(payload)
203
+ calls = tool_calls_of(payload)
204
+ content = payload.get("content")
205
+ if calls:
206
+ for call in calls:
207
+ if call["id"]:
208
+ self.pending_calls[call["id"]] = call["name"]
209
+ self.add_step(
210
+ name=f"{prefix}{call['name']}",
211
+ step_type=StepType.ROUTING,
212
+ payload_in={"arguments": call["args"]},
213
+ payload_out={"decision": call["name"]},
214
+ tokens=tokens,
215
+ call_id=f"{call['id']}#decision" if call["id"] else None,
216
+ )
217
+ return "step"
218
+ if content:
219
+ self.add_step(
220
+ name=f"{prefix}response",
221
+ step_type=StepType.LLM_CALL,
222
+ payload_in={"prompt": ""},
223
+ payload_out={"result": content},
224
+ tokens=tokens,
225
+ )
226
+ return "step"
227
+ return "skip"
228
+
229
+ if kind == "tool":
230
+ call_id = payload.get("tool_call_id")
231
+ name = payload.get("name") or self.pending_calls.get(
232
+ call_id or "", "tool_response"
233
+ )
234
+ status = WASTED_STATUS.get(
235
+ str(payload.get("status")).lower(), StepStatus.SUCCESS
236
+ )
237
+ content = payload.get("content")
238
+ self.add_step(
239
+ name=f"{prefix}{name}",
240
+ step_type=StepType.TOOL_CALL,
241
+ payload_in={"tool_call_id": call_id} if call_id else {},
242
+ payload_out={"result": content},
243
+ status=status,
244
+ error_message=(
245
+ content if status is StepStatus.ERROR and content else None
246
+ ),
247
+ call_id=f"{call_id}#result" if call_id else None,
248
+ )
249
+ return "step"
250
+
251
+ return "skip"
252
+
253
+ @property
254
+ def last_step_name(self):
255
+ return self.steps[-1].name if self.steps else None