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.
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/.gitignore +3 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/PKG-INFO +62 -2
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/README.md +61 -1
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/pyproject.toml +1 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/__init__.py +28 -1
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/__init__.py +16 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/adapters/_messages.py +255 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/adapters/crewai.py +160 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/adapters/langgraph.py +125 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/openinference.py +4 -3
- agent_trajectory_diff-0.3.0/src/agentdiff/adapters/registry.py +234 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/cli.py +14 -1
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/config.py +4 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/engine/benchmark.py +226 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/comparator.py +15 -2
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/explanations.py +29 -1
- agent_trajectory_diff-0.3.0/src/agentdiff/engine/metrics.py +104 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/engine/suite.py +225 -0
- agent_trajectory_diff-0.3.0/src/agentdiff/loader.py +122 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/report.py +8 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/pr.py +9 -1
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/terminal.py +4 -1
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/testing/assertions.py +55 -12
- agent_trajectory_diff-0.2.2/src/agentdiff/engine/metrics.py +0 -37
- agent_trajectory_diff-0.2.2/src/agentdiff/loader.py +0 -97
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/LICENSE +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/__main__.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/_iso.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/base.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/generic.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/langfuse.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/langsmith.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/openai_agents.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/ci/baseline.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/ci/github.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/__init__.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/aligner.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/loop_detector.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/engine/tree.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/__init__.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/step.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/models/trace.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/py.typed +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/pytest_plugin.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/__init__.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/reporters/markdown.py +0 -0
- {agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/testing/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-trajectory-diff
|
|
3
|
-
Version: 0.
|
|
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)**:
|
|
@@ -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.
|
|
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
|
]
|
{agent_trajectory_diff-0.2.2 → agent_trajectory_diff-0.3.0}/src/agentdiff/adapters/__init__.py
RENAMED
|
@@ -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
|