agent-trajectory-diff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_trajectory_diff-0.1.0.dist-info/METADATA +112 -0
- agent_trajectory_diff-0.1.0.dist-info/RECORD +30 -0
- agent_trajectory_diff-0.1.0.dist-info/WHEEL +4 -0
- agent_trajectory_diff-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trajectory_diff-0.1.0.dist-info/licenses/LICENSE +622 -0
- agentdiff/__init__.py +40 -0
- agentdiff/__main__.py +4 -0
- agentdiff/adapters/__init__.py +13 -0
- agentdiff/adapters/base.py +20 -0
- agentdiff/adapters/deepeval.py +134 -0
- agentdiff/adapters/generic.py +11 -0
- agentdiff/adapters/langfuse.py +145 -0
- agentdiff/adapters/openinference.py +211 -0
- agentdiff/cli.py +120 -0
- agentdiff/engine/__init__.py +17 -0
- agentdiff/engine/aligner.py +146 -0
- agentdiff/engine/comparator.py +68 -0
- agentdiff/engine/loop_detector.py +110 -0
- agentdiff/engine/metrics.py +31 -0
- agentdiff/loader.py +65 -0
- agentdiff/models/__init__.py +14 -0
- agentdiff/models/report.py +74 -0
- agentdiff/models/step.py +40 -0
- agentdiff/models/trace.py +33 -0
- agentdiff/py.typed +0 -0
- agentdiff/reporters/__init__.py +7 -0
- agentdiff/reporters/markdown.py +78 -0
- agentdiff/reporters/terminal.py +107 -0
- agentdiff/testing/__init__.py +5 -0
- agentdiff/testing/assertions.py +50 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
from agentdiff.models.report import StepDiff, StepDiffStatus
|
|
6
|
+
from agentdiff.models.step import TraceStep
|
|
7
|
+
from agentdiff.models.trace import AgentTrace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def step_signature(step: TraceStep) -> tuple:
|
|
11
|
+
"""Computes a unique equivalence signature for a TraceStep.
|
|
12
|
+
Signature(N) = (step_type, name, tuple(sorted(input_payload.keys())))
|
|
13
|
+
"""
|
|
14
|
+
keys = tuple(sorted(step.input_payload.keys())) if step.input_payload else ()
|
|
15
|
+
return (step.step_type, step.name, keys)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dict_diff(dict_a: dict[str, Any], dict_b: dict[str, Any]) -> dict[str, Any] | None:
|
|
19
|
+
"""Calculates the difference between two dictionaries."""
|
|
20
|
+
diff = {}
|
|
21
|
+
all_keys = set(dict_a.keys()).union(dict_b.keys())
|
|
22
|
+
|
|
23
|
+
for k in all_keys:
|
|
24
|
+
if k not in dict_a:
|
|
25
|
+
diff[k] = {"status": "added", "new_value": dict_b[k]}
|
|
26
|
+
elif k not in dict_b:
|
|
27
|
+
diff[k] = {"status": "removed", "old_value": dict_a[k]}
|
|
28
|
+
elif dict_a[k] != dict_b[k]:
|
|
29
|
+
diff[k] = {
|
|
30
|
+
"status": "changed",
|
|
31
|
+
"old_value": dict_a[k],
|
|
32
|
+
"new_value": dict_b[k],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diff if diff else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def align_traces(
|
|
39
|
+
baseline: AgentTrace, candidate: AgentTrace, strict_tool_signatures: bool = False
|
|
40
|
+
) -> list[StepDiff]:
|
|
41
|
+
"""Aligns two AgentTrace runs using modified topological LCS alignment."""
|
|
42
|
+
# Convert to networkx to get topological ordering
|
|
43
|
+
g_a = baseline.to_networkx()
|
|
44
|
+
g_b = candidate.to_networkx()
|
|
45
|
+
|
|
46
|
+
# Get topological sort of baseline
|
|
47
|
+
try:
|
|
48
|
+
topo_a = list(nx.topological_sort(g_a))
|
|
49
|
+
seq_a = [g_a.nodes[node_id]["step"] for node_id in topo_a]
|
|
50
|
+
except nx.NetworkXUnfeasible:
|
|
51
|
+
# Fall back to step_index sorting if cycles are present
|
|
52
|
+
seq_a = sorted(baseline.steps, key=lambda s: s.step_index)
|
|
53
|
+
|
|
54
|
+
# Get topological sort of candidate
|
|
55
|
+
try:
|
|
56
|
+
topo_b = list(nx.topological_sort(g_b))
|
|
57
|
+
seq_b = [g_b.nodes[node_id]["step"] for node_id in topo_b]
|
|
58
|
+
except nx.NetworkXUnfeasible:
|
|
59
|
+
# Fall back to step_index sorting
|
|
60
|
+
seq_b = sorted(candidate.steps, key=lambda s: s.step_index)
|
|
61
|
+
|
|
62
|
+
m, n = len(seq_a), len(seq_b)
|
|
63
|
+
|
|
64
|
+
# DP table for LCS length
|
|
65
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
66
|
+
|
|
67
|
+
def is_equivalent(sa: TraceStep, sb: TraceStep) -> bool:
|
|
68
|
+
if strict_tool_signatures:
|
|
69
|
+
return (
|
|
70
|
+
step_signature(sa) == step_signature(sb)
|
|
71
|
+
and sa.input_payload == sb.input_payload
|
|
72
|
+
)
|
|
73
|
+
return step_signature(sa) == step_signature(sb)
|
|
74
|
+
|
|
75
|
+
# Compute DP table
|
|
76
|
+
for i in range(1, m + 1):
|
|
77
|
+
for j in range(1, n + 1):
|
|
78
|
+
if is_equivalent(seq_a[i - 1], seq_b[j - 1]):
|
|
79
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
80
|
+
else:
|
|
81
|
+
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
|
|
82
|
+
|
|
83
|
+
# Backtrack alignment
|
|
84
|
+
step_diffs = []
|
|
85
|
+
i, j = m, n
|
|
86
|
+
|
|
87
|
+
while i > 0 or j > 0:
|
|
88
|
+
if i > 0 and j > 0 and is_equivalent(seq_a[i - 1], seq_b[j - 1]):
|
|
89
|
+
sa = seq_a[i - 1]
|
|
90
|
+
sb = seq_b[j - 1]
|
|
91
|
+
|
|
92
|
+
arg_diff = dict_diff(sa.input_payload or {}, sb.input_payload or {})
|
|
93
|
+
out_diff = dict_diff(sa.output_payload or {}, sb.output_payload or {})
|
|
94
|
+
|
|
95
|
+
has_diff = (
|
|
96
|
+
arg_diff is not None
|
|
97
|
+
or out_diff is not None
|
|
98
|
+
or sa.status != sb.status
|
|
99
|
+
or sa.error_message != sb.error_message
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
diff_status = (
|
|
103
|
+
StepDiffStatus.MODIFIED if has_diff else StepDiffStatus.MATCHED
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
step_diffs.append(
|
|
107
|
+
StepDiff(
|
|
108
|
+
step_name=sa.name,
|
|
109
|
+
diff_status=diff_status,
|
|
110
|
+
baseline_step=sa,
|
|
111
|
+
candidate_step=sb,
|
|
112
|
+
argument_diff=arg_diff,
|
|
113
|
+
output_diff=out_diff,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
i -= 1
|
|
117
|
+
j -= 1
|
|
118
|
+
elif j > 0 and (i == 0 or dp[i][j - 1] >= dp[i - 1][j]):
|
|
119
|
+
sb = seq_b[j - 1]
|
|
120
|
+
step_diffs.append(
|
|
121
|
+
StepDiff(
|
|
122
|
+
step_name=sb.name,
|
|
123
|
+
diff_status=StepDiffStatus.ADDED,
|
|
124
|
+
baseline_step=None,
|
|
125
|
+
candidate_step=sb,
|
|
126
|
+
argument_diff=None,
|
|
127
|
+
output_diff=None,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
j -= 1
|
|
131
|
+
else:
|
|
132
|
+
sa = seq_a[i - 1]
|
|
133
|
+
step_diffs.append(
|
|
134
|
+
StepDiff(
|
|
135
|
+
step_name=sa.name,
|
|
136
|
+
diff_status=StepDiffStatus.REMOVED,
|
|
137
|
+
baseline_step=sa,
|
|
138
|
+
candidate_step=None,
|
|
139
|
+
argument_diff=None,
|
|
140
|
+
output_diff=None,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
i -= 1
|
|
144
|
+
|
|
145
|
+
step_diffs.reverse()
|
|
146
|
+
return step_diffs
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from agentdiff.engine.aligner import align_traces
|
|
4
|
+
from agentdiff.engine.loop_detector import detect_all_loops
|
|
5
|
+
from agentdiff.engine.metrics import (
|
|
6
|
+
calculate_delta_percentage,
|
|
7
|
+
calculate_tdi,
|
|
8
|
+
calculate_wei,
|
|
9
|
+
)
|
|
10
|
+
from agentdiff.models.report import DiffReport, StepDiffStatus
|
|
11
|
+
from agentdiff.models.trace import AgentTrace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compare(
|
|
15
|
+
baseline: AgentTrace,
|
|
16
|
+
candidate: AgentTrace,
|
|
17
|
+
detect_loops: bool = True,
|
|
18
|
+
strict_tool_signatures: bool = False,
|
|
19
|
+
) -> DiffReport:
|
|
20
|
+
"""Compares baseline and candidate AgentTrace runs, returning a DiffReport."""
|
|
21
|
+
# 1. Align execution traces
|
|
22
|
+
step_diffs = align_traces(baseline, candidate, strict_tool_signatures)
|
|
23
|
+
|
|
24
|
+
# 2. Compute LCS length for TDI
|
|
25
|
+
lcs_len = sum(
|
|
26
|
+
1
|
|
27
|
+
for sd in step_diffs
|
|
28
|
+
if sd.diff_status in (StepDiffStatus.MATCHED, StepDiffStatus.MODIFIED)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 3. Calculate metrics
|
|
32
|
+
tdi = calculate_tdi(len(baseline.steps), len(candidate.steps), lcs_len)
|
|
33
|
+
baseline_wei = calculate_wei(baseline.steps)
|
|
34
|
+
candidate_wei = calculate_wei(candidate.steps)
|
|
35
|
+
|
|
36
|
+
# 4. Calculate Resource Deltas
|
|
37
|
+
cost_delta = calculate_delta_percentage(
|
|
38
|
+
baseline.total_tokens.estimated_cost_usd,
|
|
39
|
+
candidate.total_tokens.estimated_cost_usd,
|
|
40
|
+
)
|
|
41
|
+
latency_delta = calculate_delta_percentage(
|
|
42
|
+
baseline.total_latency_ms, candidate.total_latency_ms
|
|
43
|
+
)
|
|
44
|
+
token_delta = calculate_delta_percentage(
|
|
45
|
+
baseline.total_tokens.total_tokens, candidate.total_tokens.total_tokens
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 5. Detect loops in the candidate run
|
|
49
|
+
loops: list[dict[str, Any]] = []
|
|
50
|
+
if detect_loops:
|
|
51
|
+
loops = detect_all_loops(candidate)
|
|
52
|
+
|
|
53
|
+
# 6. Build the DiffReport
|
|
54
|
+
report = DiffReport(
|
|
55
|
+
baseline_id=baseline.trace_id,
|
|
56
|
+
candidate_id=candidate.trace_id,
|
|
57
|
+
trajectory_divergence_index=tdi,
|
|
58
|
+
baseline_wei=baseline_wei,
|
|
59
|
+
candidate_wei=candidate_wei,
|
|
60
|
+
loops_detected=loops,
|
|
61
|
+
cost_delta_percentage=cost_delta,
|
|
62
|
+
latency_delta_percentage=latency_delta,
|
|
63
|
+
token_delta_percentage=token_delta,
|
|
64
|
+
step_diffs=step_diffs,
|
|
65
|
+
passed=True, # Default to True, assertions/plugins override this
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return report
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
from agentdiff.models.trace import AgentTrace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_graph_cycles(trace: AgentTrace) -> list[list[str]]:
|
|
9
|
+
"""Detects cycles in the parent-id dependency graph of the trace."""
|
|
10
|
+
graph = trace.to_networkx()
|
|
11
|
+
try:
|
|
12
|
+
cycles = list(nx.simple_cycles(graph))
|
|
13
|
+
return cycles
|
|
14
|
+
except Exception:
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_sequence_loops(trace: AgentTrace) -> list[dict[str, Any]]:
|
|
19
|
+
"""Detects consecutive repeating sub-sequences of steps (e.g., A -> B -> A -> B).
|
|
20
|
+
|
|
21
|
+
A loop is defined as a sequence of step names of length k repeating consecutively
|
|
22
|
+
2 or more times.
|
|
23
|
+
"""
|
|
24
|
+
steps = sorted(trace.steps, key=lambda s: s.step_index)
|
|
25
|
+
names = [s.name for s in steps]
|
|
26
|
+
n = len(names)
|
|
27
|
+
loops = []
|
|
28
|
+
|
|
29
|
+
i = 0
|
|
30
|
+
while i < n:
|
|
31
|
+
found_loop = False
|
|
32
|
+
# Try different pattern lengths up to half the remaining sequence length
|
|
33
|
+
for k in range(1, (n - i) // 2 + 1):
|
|
34
|
+
pattern = names[i : i + k]
|
|
35
|
+
|
|
36
|
+
# Count consecutive repetitions of the pattern
|
|
37
|
+
count = 1
|
|
38
|
+
while i + (count + 1) * k <= n:
|
|
39
|
+
next_segment = names[i + count * k : i + (count + 1) * k]
|
|
40
|
+
if next_segment == pattern:
|
|
41
|
+
count += 1
|
|
42
|
+
else:
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
if count >= 2:
|
|
46
|
+
# Loop detected!
|
|
47
|
+
loop_step_ids = [s.step_id for s in steps[i : i + k]]
|
|
48
|
+
|
|
49
|
+
# Check for stagnant state (whether input payloads or outputs are unchanged)
|
|
50
|
+
# Let's compare first iteration payloads with subsequent ones
|
|
51
|
+
stagnant = True
|
|
52
|
+
for step_idx in range(k):
|
|
53
|
+
base_step = steps[i + step_idx]
|
|
54
|
+
for iter_idx in range(1, count):
|
|
55
|
+
compare_step = steps[i + iter_idx * k + step_idx]
|
|
56
|
+
if (
|
|
57
|
+
base_step.input_payload != compare_step.input_payload
|
|
58
|
+
or base_step.output_payload != compare_step.output_payload
|
|
59
|
+
):
|
|
60
|
+
stagnant = False
|
|
61
|
+
break
|
|
62
|
+
if not stagnant:
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
loops.append(
|
|
66
|
+
{
|
|
67
|
+
"steps": pattern,
|
|
68
|
+
"step_ids": loop_step_ids,
|
|
69
|
+
"iterations": count,
|
|
70
|
+
"start_index": i,
|
|
71
|
+
"length": k,
|
|
72
|
+
"stagnant": stagnant,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Advance pointer past the repeated patterns
|
|
77
|
+
i += count * k
|
|
78
|
+
found_loop = True
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if not found_loop:
|
|
82
|
+
i += 1
|
|
83
|
+
|
|
84
|
+
return loops
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_all_loops(trace: AgentTrace) -> list[dict[str, Any]]:
|
|
88
|
+
"""Runs all loop detection algorithms on the trace and returns detected loops."""
|
|
89
|
+
loops = detect_sequence_loops(trace)
|
|
90
|
+
|
|
91
|
+
# Add graph cycles if any
|
|
92
|
+
cycles = detect_graph_cycles(trace)
|
|
93
|
+
for cycle in cycles:
|
|
94
|
+
loops.append(
|
|
95
|
+
{
|
|
96
|
+
"steps": [
|
|
97
|
+
trace.steps[idx].name
|
|
98
|
+
for idx in range(len(trace.steps))
|
|
99
|
+
if trace.steps[idx].step_id in cycle
|
|
100
|
+
],
|
|
101
|
+
"step_ids": cycle,
|
|
102
|
+
"iterations": 2, # cycle implies a recurring path
|
|
103
|
+
"start_index": -1,
|
|
104
|
+
"length": len(cycle),
|
|
105
|
+
"stagnant": True,
|
|
106
|
+
"type": "graph_cycle",
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return loops
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from agentdiff.models.step import StepStatus, TraceStep
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def calculate_tdi(steps_a_len: int, steps_b_len: int, lcs_len: int) -> float:
|
|
5
|
+
"""Calculates Trajectory Divergence Index (TDI).
|
|
6
|
+
TDI = 1.0 - (2 * |LCS(A, B)|) / (|Steps_A| + |Steps_B|)
|
|
7
|
+
"""
|
|
8
|
+
total_steps = steps_a_len + steps_b_len
|
|
9
|
+
if total_steps == 0:
|
|
10
|
+
return 0.0
|
|
11
|
+
return 1.0 - (2.0 * lcs_len) / total_steps
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def calculate_wei(steps: list[TraceStep]) -> float:
|
|
15
|
+
"""Calculates Wasted Effort Index (WEI).
|
|
16
|
+
WEI = Count(Steps with status in {ERROR, RETRY, ABANDONED}) / Total Steps
|
|
17
|
+
"""
|
|
18
|
+
if not steps:
|
|
19
|
+
return 0.0
|
|
20
|
+
wasted_statuses = {StepStatus.ERROR, StepStatus.RETRY, StepStatus.ABANDONED}
|
|
21
|
+
wasted_count = sum(1 for s in steps if s.status in wasted_statuses)
|
|
22
|
+
return wasted_count / len(steps)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_delta_percentage(baseline_val: float, candidate_val: float) -> float:
|
|
26
|
+
"""Calculates percentage delta between candidate and baseline values."""
|
|
27
|
+
if baseline_val == 0.0:
|
|
28
|
+
if candidate_val == 0.0:
|
|
29
|
+
return 0.0
|
|
30
|
+
return 100.0 # Standard representation for positive spike from zero
|
|
31
|
+
return ((candidate_val - baseline_val) / baseline_val) * 100.0
|
agentdiff/loader.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from agentdiff.adapters import (
|
|
5
|
+
DeepEvalAdapter,
|
|
6
|
+
GenericAdapter,
|
|
7
|
+
LangfuseAdapter,
|
|
8
|
+
OpenInferenceAdapter,
|
|
9
|
+
)
|
|
10
|
+
from agentdiff.models.trace import AgentTrace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_trace_data(data: Any, adapter_name: str = "auto") -> AgentTrace:
|
|
14
|
+
"""Parses a dictionary/list into an AgentTrace, auto-detecting the adapter if specified."""
|
|
15
|
+
name = adapter_name.lower().strip()
|
|
16
|
+
|
|
17
|
+
if name == "generic":
|
|
18
|
+
return GenericAdapter.from_dict(data)
|
|
19
|
+
elif name == "deepeval":
|
|
20
|
+
return DeepEvalAdapter.from_dict(data)
|
|
21
|
+
elif name in ("openinference", "open_inference"):
|
|
22
|
+
return OpenInferenceAdapter.from_dict(data)
|
|
23
|
+
elif name == "langfuse":
|
|
24
|
+
return LangfuseAdapter.from_dict(data)
|
|
25
|
+
elif name == "auto":
|
|
26
|
+
# Auto-detect format based on structure
|
|
27
|
+
if isinstance(data, list):
|
|
28
|
+
if not data:
|
|
29
|
+
raise ValueError("Cannot auto-detect from an empty list")
|
|
30
|
+
elem = data[0]
|
|
31
|
+
if isinstance(elem, dict):
|
|
32
|
+
if "context" in elem or "attributes" in elem:
|
|
33
|
+
return OpenInferenceAdapter.from_dict(data)
|
|
34
|
+
if "type" in elem and "input" in elem:
|
|
35
|
+
return DeepEvalAdapter.from_dict(data)
|
|
36
|
+
return GenericAdapter.from_dict(data)
|
|
37
|
+
|
|
38
|
+
elif isinstance(data, dict):
|
|
39
|
+
# Langfuse check
|
|
40
|
+
if "observations" in data:
|
|
41
|
+
return LangfuseAdapter.from_dict(data)
|
|
42
|
+
|
|
43
|
+
# OpenInference check
|
|
44
|
+
if "spans" in data and isinstance(data["spans"], list) and data["spans"]:
|
|
45
|
+
first_span = data["spans"][0]
|
|
46
|
+
if "context" in first_span or "attributes" in first_span:
|
|
47
|
+
return OpenInferenceAdapter.from_dict(data)
|
|
48
|
+
|
|
49
|
+
# DeepEval check
|
|
50
|
+
if ("input" in data and "output" in data) and (
|
|
51
|
+
"spans" in data or "nodes" in data
|
|
52
|
+
):
|
|
53
|
+
return DeepEvalAdapter.from_dict(data)
|
|
54
|
+
|
|
55
|
+
# Default fallback
|
|
56
|
+
return GenericAdapter.from_dict(data)
|
|
57
|
+
|
|
58
|
+
raise ValueError(f"Unsupported raw trace data type: {type(data)}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_trace(filepath: str, adapter_name: str = "auto") -> AgentTrace:
|
|
62
|
+
"""Loads and parses a trace file from disk."""
|
|
63
|
+
with open(filepath, encoding="utf-8") as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
return parse_trace_data(data, adapter_name)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from agentdiff.models.report import DiffReport, StepDiff, StepDiffStatus
|
|
2
|
+
from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep
|
|
3
|
+
from agentdiff.models.trace import AgentTrace
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AgentTrace",
|
|
7
|
+
"DiffReport",
|
|
8
|
+
"StepDiff",
|
|
9
|
+
"StepDiffStatus",
|
|
10
|
+
"StepStatus",
|
|
11
|
+
"StepType",
|
|
12
|
+
"TokenUsage",
|
|
13
|
+
"TraceStep",
|
|
14
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from agentdiff.models.step import TraceStep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StepDiffStatus(str, Enum):
|
|
10
|
+
MATCHED = "matched"
|
|
11
|
+
ADDED = "added"
|
|
12
|
+
REMOVED = "removed"
|
|
13
|
+
MODIFIED = "modified"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StepDiff(BaseModel):
|
|
17
|
+
step_name: str
|
|
18
|
+
diff_status: StepDiffStatus
|
|
19
|
+
baseline_step: TraceStep | None = None
|
|
20
|
+
candidate_step: TraceStep | None = None
|
|
21
|
+
argument_diff: dict[str, Any] | None = None
|
|
22
|
+
output_diff: dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DiffReport(BaseModel):
|
|
26
|
+
baseline_id: str
|
|
27
|
+
candidate_id: str
|
|
28
|
+
trajectory_divergence_index: float
|
|
29
|
+
baseline_wei: float
|
|
30
|
+
candidate_wei: float
|
|
31
|
+
loops_detected: list[dict[str, Any]] = Field(default_factory=list)
|
|
32
|
+
cost_delta_percentage: float
|
|
33
|
+
latency_delta_percentage: float
|
|
34
|
+
token_delta_percentage: float
|
|
35
|
+
step_diffs: list[StepDiff] = Field(default_factory=list)
|
|
36
|
+
passed: bool = True
|
|
37
|
+
|
|
38
|
+
def summary(self) -> str:
|
|
39
|
+
"""Returns a string summarizing the comparison report."""
|
|
40
|
+
lines = [
|
|
41
|
+
"=========================================",
|
|
42
|
+
" AGENTDIFF REPORT SUMMARY ",
|
|
43
|
+
"=========================================",
|
|
44
|
+
f"Baseline ID: {self.baseline_id}",
|
|
45
|
+
f"Candidate ID: {self.candidate_id}",
|
|
46
|
+
f"Status: {'PASSED' if self.passed else 'FAILED'}",
|
|
47
|
+
"-----------------------------------------",
|
|
48
|
+
f"Trajectory Divergence Index (TDI): {self.trajectory_divergence_index:.4f}",
|
|
49
|
+
f"Baseline Wasted Effort Index (WEI): {self.baseline_wei:.4f}",
|
|
50
|
+
f"Candidate Wasted Effort Index (WEI): {self.candidate_wei:.4f}",
|
|
51
|
+
"-----------------------------------------",
|
|
52
|
+
"Resource Deltas:",
|
|
53
|
+
f" Cost Delta: {self.cost_delta_percentage:+.2f}%",
|
|
54
|
+
f" Latency Delta: {self.latency_delta_percentage:+.2f}%",
|
|
55
|
+
f" Token Delta: {self.token_delta_percentage:+.2f}%",
|
|
56
|
+
"-----------------------------------------",
|
|
57
|
+
f"Loops Detected: {len(self.loops_detected)}",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for idx, loop in enumerate(self.loops_detected):
|
|
61
|
+
lines.append(
|
|
62
|
+
f" - Loop #{idx + 1}: Repeated steps {loop.get('steps', [])} (Count: {loop.get('iterations', 0)})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
lines.append("Step Diff Summary:")
|
|
66
|
+
counts = {status: 0 for status in StepDiffStatus}
|
|
67
|
+
for sd in self.step_diffs:
|
|
68
|
+
counts[sd.diff_status] += 1
|
|
69
|
+
|
|
70
|
+
for status, count in counts.items():
|
|
71
|
+
lines.append(f" - {status.value.capitalize()}: {count}")
|
|
72
|
+
|
|
73
|
+
lines.append("=========================================")
|
|
74
|
+
return "\n".join(lines)
|
agentdiff/models/step.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StepType(str, Enum):
|
|
8
|
+
TOOL_CALL = "tool_call"
|
|
9
|
+
LLM_CALL = "llm_call"
|
|
10
|
+
ROUTING = "routing"
|
|
11
|
+
THOUGHT = "thought"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StepStatus(str, Enum):
|
|
15
|
+
SUCCESS = "success"
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
RETRY = "retry"
|
|
18
|
+
ABANDONED = "abandoned"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenUsage(BaseModel):
|
|
22
|
+
prompt_tokens: int = 0
|
|
23
|
+
completion_tokens: int = 0
|
|
24
|
+
total_tokens: int = 0
|
|
25
|
+
estimated_cost_usd: float = 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TraceStep(BaseModel):
|
|
29
|
+
step_id: str
|
|
30
|
+
parent_id: str | None = None
|
|
31
|
+
step_index: int
|
|
32
|
+
step_type: StepType
|
|
33
|
+
name: str # e.g. "sql_executor", "web_search", "synthesize"
|
|
34
|
+
input_payload: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
output_payload: dict[str, Any] | None = None
|
|
36
|
+
status: StepStatus = StepStatus.SUCCESS
|
|
37
|
+
error_message: str | None = None
|
|
38
|
+
latency_ms: float = 0.0
|
|
39
|
+
tokens: TokenUsage = Field(default_factory=TokenUsage)
|
|
40
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from agentdiff.models.step import TokenUsage, TraceStep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentTrace(BaseModel):
|
|
10
|
+
trace_id: str
|
|
11
|
+
agent_name: str
|
|
12
|
+
agent_version: str | None = None
|
|
13
|
+
task_input: dict[str, Any]
|
|
14
|
+
final_output: dict[str, Any] | None = None
|
|
15
|
+
steps: list[TraceStep] = Field(default_factory=list)
|
|
16
|
+
total_latency_ms: float = 0.0
|
|
17
|
+
total_tokens: TokenUsage = Field(default_factory=TokenUsage)
|
|
18
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def to_networkx(self) -> nx.DiGraph:
|
|
21
|
+
"""Converts step sequence/parent_ids into a networkx.DiGraph."""
|
|
22
|
+
graph = nx.DiGraph()
|
|
23
|
+
|
|
24
|
+
# Add all nodes first
|
|
25
|
+
for step in self.steps:
|
|
26
|
+
graph.add_node(step.step_id, step=step)
|
|
27
|
+
|
|
28
|
+
# Add edges based on parent_id relationships
|
|
29
|
+
for step in self.steps:
|
|
30
|
+
if step.parent_id and step.parent_id in graph:
|
|
31
|
+
graph.add_edge(step.parent_id, step.step_id)
|
|
32
|
+
|
|
33
|
+
return graph
|
agentdiff/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from agentdiff.models.report import DiffReport, StepDiffStatus
|
|
2
|
+
from agentdiff.models.step import StepStatus
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_markdown(report: DiffReport) -> str:
|
|
6
|
+
"""Generates a markdown formatted summary of the comparison report."""
|
|
7
|
+
status_emoji = "✅ PASSED" if report.passed else "❌ FAILED"
|
|
8
|
+
|
|
9
|
+
lines = [
|
|
10
|
+
f"# AgentDiff Comparison Report: {status_emoji}",
|
|
11
|
+
"",
|
|
12
|
+
"## Summary Metrics",
|
|
13
|
+
"",
|
|
14
|
+
"| Metric | Baseline | Candidate | Delta |",
|
|
15
|
+
"| :--- | :--- | :--- | :--- |",
|
|
16
|
+
f"| **Trajectory Divergence (TDI)** | - | - | `{report.trajectory_divergence_index:.4f}` |",
|
|
17
|
+
f"| **Wasted Effort Index (WEI)** | `{report.baseline_wei:.4f}` | `{report.candidate_wei:.4f}` | - |",
|
|
18
|
+
f"| **Total Latency** | - | - | `{report.latency_delta_percentage:+.2f}%` |",
|
|
19
|
+
f"| **Total Tokens** | - | - | `{report.token_delta_percentage:+.2f}%` |",
|
|
20
|
+
f"| **Estimated Cost** | - | - | `{report.cost_delta_percentage:+.2f}%` |",
|
|
21
|
+
"",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
if report.loops_detected:
|
|
25
|
+
lines.extend(
|
|
26
|
+
[
|
|
27
|
+
"## ⚠️ Warnings: Loops Detected",
|
|
28
|
+
"",
|
|
29
|
+
"The candidate trace contains repeating step sequences:",
|
|
30
|
+
"",
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
for idx, loop in enumerate(report.loops_detected):
|
|
34
|
+
stagnant_str = " (Stagnant state changes)" if loop.get("stagnant") else ""
|
|
35
|
+
lines.append(
|
|
36
|
+
f"- **Loop #{idx + 1}:** Repeated {loop['steps']} `{loop['iterations']}` times{stagnant_str}"
|
|
37
|
+
)
|
|
38
|
+
lines.append("")
|
|
39
|
+
|
|
40
|
+
lines.extend(
|
|
41
|
+
[
|
|
42
|
+
"## Step-by-Step Trajectory Diff",
|
|
43
|
+
"",
|
|
44
|
+
"| # | Step Name | Status | Baseline (Latency / Tokens / Cost) | Candidate (Latency / Tokens / Cost) |",
|
|
45
|
+
"| :--- | :--- | :--- | :--- | :--- |",
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for idx, sd in enumerate(report.step_diffs):
|
|
50
|
+
status_emoji_map = {
|
|
51
|
+
StepDiffStatus.MATCHED: "🟢 MATCHED",
|
|
52
|
+
StepDiffStatus.ADDED: "🔵 ADDED",
|
|
53
|
+
StepDiffStatus.REMOVED: "🔴 REMOVED",
|
|
54
|
+
StepDiffStatus.MODIFIED: "🟡 MODIFIED",
|
|
55
|
+
}
|
|
56
|
+
status_str = status_emoji_map.get(sd.diff_status, sd.diff_status.value.upper())
|
|
57
|
+
|
|
58
|
+
base_info = "-"
|
|
59
|
+
if sd.baseline_step:
|
|
60
|
+
s = sd.baseline_step
|
|
61
|
+
err_str = (
|
|
62
|
+
f" ({s.status.value.upper()})" if s.status != StepStatus.SUCCESS else ""
|
|
63
|
+
)
|
|
64
|
+
base_info = f"{s.latency_ms:.0f}ms / {s.tokens.total_tokens}t / ${s.tokens.estimated_cost_usd:.4f}{err_str}"
|
|
65
|
+
|
|
66
|
+
cand_info = "-"
|
|
67
|
+
if sd.candidate_step:
|
|
68
|
+
s = sd.candidate_step
|
|
69
|
+
err_str = (
|
|
70
|
+
f" ({s.status.value.upper()})" if s.status != StepStatus.SUCCESS else ""
|
|
71
|
+
)
|
|
72
|
+
cand_info = f"{s.latency_ms:.0f}ms / {s.tokens.total_tokens}t / ${s.tokens.estimated_cost_usd:.4f}{err_str}"
|
|
73
|
+
|
|
74
|
+
lines.append(
|
|
75
|
+
f"| {idx + 1} | {sd.step_name} | {status_str} | {base_info} | {cand_info} |"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return "\n".join(lines)
|