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,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agentdiff.models.trace import AgentTrace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseAdapter(ABC):
|
|
9
|
+
@classmethod
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentTrace:
|
|
12
|
+
"""Parses a raw dictionary input into a canonical AgentTrace."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_file(cls, filepath: str) -> AgentTrace:
|
|
17
|
+
"""Loads a JSON file and parses it into a canonical AgentTrace."""
|
|
18
|
+
with open(filepath, encoding="utf-8") as f:
|
|
19
|
+
data = json.load(f)
|
|
20
|
+
return cls.from_dict(data)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from agentdiff.adapters.base import BaseAdapter
|
|
4
|
+
from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep
|
|
5
|
+
from agentdiff.models.trace import AgentTrace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeepEvalAdapter(BaseAdapter):
|
|
9
|
+
@classmethod
|
|
10
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentTrace:
|
|
11
|
+
"""Parses a DeepEval trace dictionary into a canonical AgentTrace."""
|
|
12
|
+
if isinstance(data, list):
|
|
13
|
+
if not data:
|
|
14
|
+
raise ValueError("DeepEval trace list is empty")
|
|
15
|
+
data = data[0]
|
|
16
|
+
|
|
17
|
+
trace_id = data.get("id") or data.get("trace_id") or "deepeval_trace"
|
|
18
|
+
agent_name = data.get("name") or data.get("agent_name") or "deepeval_agent"
|
|
19
|
+
|
|
20
|
+
task_input = data.get("input")
|
|
21
|
+
if not isinstance(task_input, dict):
|
|
22
|
+
task_input = {"input": task_input}
|
|
23
|
+
|
|
24
|
+
final_output = data.get("output")
|
|
25
|
+
if not isinstance(final_output, dict):
|
|
26
|
+
final_output = {"output": final_output}
|
|
27
|
+
|
|
28
|
+
steps: list[TraceStep] = []
|
|
29
|
+
|
|
30
|
+
# Traverse child spans/nodes
|
|
31
|
+
spans = data.get("spans") or data.get("children") or data.get("nodes") or []
|
|
32
|
+
for span in spans:
|
|
33
|
+
cls._parse_span(span, None, steps)
|
|
34
|
+
|
|
35
|
+
total_latency_ms = data.get("latency") or data.get("executionTime") or 0.0
|
|
36
|
+
if total_latency_ms < 100.0 and total_latency_ms > 0:
|
|
37
|
+
total_latency_ms *= 1000.0
|
|
38
|
+
|
|
39
|
+
prompt_tokens = sum(s.tokens.prompt_tokens for s in steps)
|
|
40
|
+
completion_tokens = sum(s.tokens.completion_tokens for s in steps)
|
|
41
|
+
total_tokens = sum(s.tokens.total_tokens for s in steps)
|
|
42
|
+
cost = sum(s.tokens.estimated_cost_usd for s in steps)
|
|
43
|
+
|
|
44
|
+
total_tokens_obj = TokenUsage(
|
|
45
|
+
prompt_tokens=prompt_tokens,
|
|
46
|
+
completion_tokens=completion_tokens,
|
|
47
|
+
total_tokens=total_tokens,
|
|
48
|
+
estimated_cost_usd=cost,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return AgentTrace(
|
|
52
|
+
trace_id=trace_id,
|
|
53
|
+
agent_name=agent_name,
|
|
54
|
+
task_input=task_input,
|
|
55
|
+
final_output=final_output,
|
|
56
|
+
steps=steps,
|
|
57
|
+
total_latency_ms=total_latency_ms,
|
|
58
|
+
total_tokens=total_tokens_obj,
|
|
59
|
+
metadata=data.get("metadata") or {},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _parse_span(
|
|
64
|
+
cls, span: dict[str, Any], parent_id: str | None, steps_list: list[TraceStep]
|
|
65
|
+
):
|
|
66
|
+
"""Recursively parses DeepEval spans and adds them to steps_list."""
|
|
67
|
+
span_id = span.get("id") or span.get("span_id") or f"step_{len(steps_list)}"
|
|
68
|
+
span_type_str = str(span.get("type", "")).lower()
|
|
69
|
+
|
|
70
|
+
# Map span type to StepType
|
|
71
|
+
if "tool" in span_type_str or "retriever" in span_type_str:
|
|
72
|
+
step_type = StepType.TOOL_CALL
|
|
73
|
+
elif "llm" in span_type_str:
|
|
74
|
+
step_type = StepType.LLM_CALL
|
|
75
|
+
elif "agent" in span_type_str:
|
|
76
|
+
step_type = StepType.ROUTING
|
|
77
|
+
else:
|
|
78
|
+
step_type = StepType.THOUGHT
|
|
79
|
+
|
|
80
|
+
input_payload = span.get("input")
|
|
81
|
+
if not isinstance(input_payload, dict):
|
|
82
|
+
input_payload = {"input": input_payload}
|
|
83
|
+
|
|
84
|
+
output_payload = span.get("output")
|
|
85
|
+
if not isinstance(output_payload, dict):
|
|
86
|
+
output_payload = {"output": output_payload}
|
|
87
|
+
|
|
88
|
+
# Tokens & Cost
|
|
89
|
+
prompt_tokens = span.get("input_token_count") or span.get("prompt_tokens") or 0
|
|
90
|
+
completion_tokens = (
|
|
91
|
+
span.get("output_token_count") or span.get("completion_tokens") or 0
|
|
92
|
+
)
|
|
93
|
+
total_tokens = (
|
|
94
|
+
span.get("total_token_count")
|
|
95
|
+
or span.get("total_tokens")
|
|
96
|
+
or (prompt_tokens + completion_tokens)
|
|
97
|
+
)
|
|
98
|
+
cost = span.get("cost") or span.get("estimated_cost_usd") or 0.0
|
|
99
|
+
|
|
100
|
+
tokens = TokenUsage(
|
|
101
|
+
prompt_tokens=prompt_tokens,
|
|
102
|
+
completion_tokens=completion_tokens,
|
|
103
|
+
total_tokens=total_tokens,
|
|
104
|
+
estimated_cost_usd=cost,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
latency_ms = span.get("latency") or span.get("executionTime") or 0.0
|
|
108
|
+
if latency_ms < 100.0 and latency_ms > 0:
|
|
109
|
+
latency_ms *= 1000.0
|
|
110
|
+
|
|
111
|
+
step = TraceStep(
|
|
112
|
+
step_id=span_id,
|
|
113
|
+
parent_id=parent_id,
|
|
114
|
+
step_index=len(steps_list),
|
|
115
|
+
step_type=step_type,
|
|
116
|
+
name=span.get("name") or span.get("displayName") or f"step_{span_type_str}",
|
|
117
|
+
input_payload=input_payload,
|
|
118
|
+
output_payload=output_payload,
|
|
119
|
+
status=StepStatus.SUCCESS,
|
|
120
|
+
error_message=span.get("error") or span.get("errorMessage"),
|
|
121
|
+
latency_ms=latency_ms,
|
|
122
|
+
tokens=tokens,
|
|
123
|
+
metadata=span.get("metadata") or {},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if step.error_message:
|
|
127
|
+
step.status = StepStatus.ERROR
|
|
128
|
+
|
|
129
|
+
steps_list.append(step)
|
|
130
|
+
|
|
131
|
+
# Recurse children
|
|
132
|
+
children = span.get("spans") or span.get("children") or span.get("nodes") or []
|
|
133
|
+
for child in children:
|
|
134
|
+
cls._parse_span(child, span_id, steps_list)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from agentdiff.adapters.base import BaseAdapter
|
|
4
|
+
from agentdiff.models.trace import AgentTrace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GenericAdapter(BaseAdapter):
|
|
8
|
+
@classmethod
|
|
9
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentTrace:
|
|
10
|
+
"""Parses a dictionary matching the canonical schema into AgentTrace."""
|
|
11
|
+
return AgentTrace.model_validate(data)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from agentdiff.adapters.base import BaseAdapter
|
|
5
|
+
from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep
|
|
6
|
+
from agentdiff.models.trace import AgentTrace
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LangfuseAdapter(BaseAdapter):
|
|
10
|
+
@classmethod
|
|
11
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentTrace:
|
|
12
|
+
"""Parses exported Langfuse trace JSON into a canonical AgentTrace."""
|
|
13
|
+
trace_id = data.get("id") or "langfuse_trace"
|
|
14
|
+
agent_name = data.get("name") or "langfuse_agent"
|
|
15
|
+
|
|
16
|
+
task_input = data.get("input")
|
|
17
|
+
if not isinstance(task_input, dict):
|
|
18
|
+
task_input = {"input": task_input}
|
|
19
|
+
|
|
20
|
+
final_output = data.get("output")
|
|
21
|
+
if not isinstance(final_output, dict):
|
|
22
|
+
final_output = {"output": final_output}
|
|
23
|
+
|
|
24
|
+
observations = data.get("observations") or []
|
|
25
|
+
steps: list[TraceStep] = []
|
|
26
|
+
|
|
27
|
+
for idx, obs in enumerate(observations):
|
|
28
|
+
obs_id = obs.get("id") or f"obs_{idx}"
|
|
29
|
+
parent_id = obs.get("parentObservationId")
|
|
30
|
+
|
|
31
|
+
# Map Langfuse type to StepType
|
|
32
|
+
obs_type = str(obs.get("type", "")).upper()
|
|
33
|
+
if obs_type == "GENERATION":
|
|
34
|
+
step_type = StepType.LLM_CALL
|
|
35
|
+
elif obs_type == "SPAN":
|
|
36
|
+
name_lower = str(obs.get("name", "")).lower()
|
|
37
|
+
if "tool" in name_lower or "call" in name_lower:
|
|
38
|
+
step_type = StepType.TOOL_CALL
|
|
39
|
+
else:
|
|
40
|
+
step_type = StepType.ROUTING
|
|
41
|
+
else:
|
|
42
|
+
step_type = StepType.THOUGHT
|
|
43
|
+
|
|
44
|
+
input_payload = obs.get("input")
|
|
45
|
+
if not isinstance(input_payload, dict):
|
|
46
|
+
input_payload = {"input": input_payload}
|
|
47
|
+
|
|
48
|
+
output_payload = obs.get("output")
|
|
49
|
+
if not isinstance(output_payload, dict):
|
|
50
|
+
output_payload = {"output": output_payload}
|
|
51
|
+
|
|
52
|
+
# Parse Usage
|
|
53
|
+
usage = obs.get("usage") or {}
|
|
54
|
+
prompt_tokens = usage.get("promptTokens") or usage.get("input_tokens") or 0
|
|
55
|
+
completion_tokens = (
|
|
56
|
+
usage.get("completionTokens") or usage.get("output_tokens") or 0
|
|
57
|
+
)
|
|
58
|
+
total_tokens = (
|
|
59
|
+
usage.get("totalTokens")
|
|
60
|
+
or usage.get("total_tokens")
|
|
61
|
+
or (prompt_tokens + completion_tokens)
|
|
62
|
+
)
|
|
63
|
+
cost = usage.get("cost") or obs.get("cost") or 0.0
|
|
64
|
+
|
|
65
|
+
tokens = TokenUsage(
|
|
66
|
+
prompt_tokens=prompt_tokens,
|
|
67
|
+
completion_tokens=completion_tokens,
|
|
68
|
+
total_tokens=total_tokens,
|
|
69
|
+
estimated_cost_usd=cost,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Latency
|
|
73
|
+
latency_ms = 0.0
|
|
74
|
+
start_str = obs.get("startTime")
|
|
75
|
+
end_str = obs.get("endTime")
|
|
76
|
+
if start_str and end_str:
|
|
77
|
+
try:
|
|
78
|
+
t1 = datetime.fromisoformat(str(start_str).replace("Z", "+00:00"))
|
|
79
|
+
t2 = datetime.fromisoformat(str(end_str).replace("Z", "+00:00"))
|
|
80
|
+
latency_ms = (t2 - t1).total_seconds() * 1000.0
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
if latency_ms == 0.0:
|
|
84
|
+
latency_ms = obs.get("latency_ms") or (
|
|
85
|
+
obs.get("duration", 0.0) * 1000.0
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Error Levels
|
|
89
|
+
level = str(obs.get("level", "")).upper()
|
|
90
|
+
status = StepStatus.SUCCESS
|
|
91
|
+
error_message = None
|
|
92
|
+
if "ERROR" in level or obs.get("statusMessage"):
|
|
93
|
+
status = StepStatus.ERROR
|
|
94
|
+
error_message = obs.get("statusMessage")
|
|
95
|
+
|
|
96
|
+
step = TraceStep(
|
|
97
|
+
step_id=obs_id,
|
|
98
|
+
parent_id=parent_id,
|
|
99
|
+
step_index=idx,
|
|
100
|
+
step_type=step_type,
|
|
101
|
+
name=obs.get("name") or f"obs_{obs_type.lower()}",
|
|
102
|
+
input_payload=input_payload,
|
|
103
|
+
output_payload=output_payload,
|
|
104
|
+
status=status,
|
|
105
|
+
error_message=error_message,
|
|
106
|
+
latency_ms=latency_ms,
|
|
107
|
+
tokens=tokens,
|
|
108
|
+
metadata=obs.get("metadata") or {},
|
|
109
|
+
)
|
|
110
|
+
steps.append(step)
|
|
111
|
+
|
|
112
|
+
steps.sort(key=lambda s: s.step_index)
|
|
113
|
+
|
|
114
|
+
# Total latency of trace (in seconds, convert to ms)
|
|
115
|
+
total_latency_ms = data.get("duration", 0.0) * 1000.0
|
|
116
|
+
if total_latency_ms == 0.0:
|
|
117
|
+
root_spans = [s for s in steps if not s.parent_id]
|
|
118
|
+
total_latency_ms = (
|
|
119
|
+
sum(s.latency_ms for s in root_spans)
|
|
120
|
+
if root_spans
|
|
121
|
+
else sum(s.latency_ms for s in steps)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
prompt_tokens = sum(s.tokens.prompt_tokens for s in steps)
|
|
125
|
+
completion_tokens = sum(s.tokens.completion_tokens for s in steps)
|
|
126
|
+
total_tokens = sum(s.tokens.total_tokens for s in steps)
|
|
127
|
+
cost = sum(s.tokens.estimated_cost_usd for s in steps)
|
|
128
|
+
|
|
129
|
+
total_tokens_obj = TokenUsage(
|
|
130
|
+
prompt_tokens=prompt_tokens,
|
|
131
|
+
completion_tokens=completion_tokens,
|
|
132
|
+
total_tokens=total_tokens,
|
|
133
|
+
estimated_cost_usd=cost,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return AgentTrace(
|
|
137
|
+
trace_id=trace_id,
|
|
138
|
+
agent_name=agent_name,
|
|
139
|
+
task_input=task_input,
|
|
140
|
+
final_output=final_output,
|
|
141
|
+
steps=steps,
|
|
142
|
+
total_latency_ms=total_latency_ms,
|
|
143
|
+
total_tokens=total_tokens_obj,
|
|
144
|
+
metadata=data.get("metadata") or {},
|
|
145
|
+
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agentdiff.adapters.base import BaseAdapter
|
|
6
|
+
from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep
|
|
7
|
+
from agentdiff.models.trace import AgentTrace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenInferenceAdapter(BaseAdapter):
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentTrace:
|
|
13
|
+
"""Parses an OpenInference span or collection of spans into an AgentTrace."""
|
|
14
|
+
spans = []
|
|
15
|
+
if isinstance(data, dict):
|
|
16
|
+
spans = data.get("spans") or data.get("spans_list") or [data]
|
|
17
|
+
elif isinstance(data, list):
|
|
18
|
+
spans = data
|
|
19
|
+
|
|
20
|
+
if not spans:
|
|
21
|
+
raise ValueError("No spans found to parse")
|
|
22
|
+
|
|
23
|
+
steps: list[TraceStep] = []
|
|
24
|
+
for idx, span in enumerate(spans):
|
|
25
|
+
context = span.get("context") or {}
|
|
26
|
+
span_id = context.get("span_id") or span.get("span_id") or f"span_{idx}"
|
|
27
|
+
parent_id = span.get("parent_span_id") or span.get("parent_id")
|
|
28
|
+
|
|
29
|
+
attrs = span.get("attributes") or {}
|
|
30
|
+
|
|
31
|
+
# Map OpenInference span kind to StepType
|
|
32
|
+
span_kind = attrs.get("openinference.span.kind") or span.get("kind") or ""
|
|
33
|
+
span_kind_str = str(span_kind).upper()
|
|
34
|
+
|
|
35
|
+
if "TOOL" in span_kind_str or "RETRIEVER" in span_kind_str:
|
|
36
|
+
step_type = StepType.TOOL_CALL
|
|
37
|
+
elif "LLM" in span_kind_str:
|
|
38
|
+
step_type = StepType.LLM_CALL
|
|
39
|
+
elif "AGENT" in span_kind_str or "CHAIN" in span_kind_str:
|
|
40
|
+
step_type = StepType.ROUTING
|
|
41
|
+
else:
|
|
42
|
+
step_type = StepType.THOUGHT
|
|
43
|
+
|
|
44
|
+
# Parse input value
|
|
45
|
+
input_val = (
|
|
46
|
+
attrs.get("input.value")
|
|
47
|
+
or attrs.get("llm.input_messages")
|
|
48
|
+
or span.get("input")
|
|
49
|
+
or {}
|
|
50
|
+
)
|
|
51
|
+
input_payload = cls._to_payload_dict(input_val, "input")
|
|
52
|
+
|
|
53
|
+
# Parse output value
|
|
54
|
+
output_val = (
|
|
55
|
+
attrs.get("output.value")
|
|
56
|
+
or attrs.get("llm.output_messages")
|
|
57
|
+
or span.get("output")
|
|
58
|
+
or {}
|
|
59
|
+
)
|
|
60
|
+
output_payload = cls._to_payload_dict(output_val, "output")
|
|
61
|
+
|
|
62
|
+
# Tokens
|
|
63
|
+
prompt_tokens = (
|
|
64
|
+
attrs.get("llm.token_count.prompt") or attrs.get("prompt_tokens") or 0
|
|
65
|
+
)
|
|
66
|
+
completion_tokens = (
|
|
67
|
+
attrs.get("llm.token_count.completion")
|
|
68
|
+
or attrs.get("completion_tokens")
|
|
69
|
+
or 0
|
|
70
|
+
)
|
|
71
|
+
total_tokens = (
|
|
72
|
+
attrs.get("llm.token_count.total")
|
|
73
|
+
or attrs.get("total_tokens")
|
|
74
|
+
or (prompt_tokens + completion_tokens)
|
|
75
|
+
)
|
|
76
|
+
cost = attrs.get("llm.cost") or attrs.get("cost") or 0.0
|
|
77
|
+
|
|
78
|
+
tokens = TokenUsage(
|
|
79
|
+
prompt_tokens=prompt_tokens,
|
|
80
|
+
completion_tokens=completion_tokens,
|
|
81
|
+
total_tokens=total_tokens,
|
|
82
|
+
estimated_cost_usd=cost,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Compute Latency
|
|
86
|
+
latency_ms = 0.0
|
|
87
|
+
start_time = span.get("start_time")
|
|
88
|
+
end_time = span.get("end_time")
|
|
89
|
+
if start_time and end_time:
|
|
90
|
+
try:
|
|
91
|
+
if isinstance(start_time, (int, float)) and isinstance(
|
|
92
|
+
end_time, (int, float)
|
|
93
|
+
):
|
|
94
|
+
latency_ms = (end_time - start_time) * 1000.0
|
|
95
|
+
else:
|
|
96
|
+
t1 = datetime.fromisoformat(
|
|
97
|
+
str(start_time).replace("Z", "+00:00")
|
|
98
|
+
)
|
|
99
|
+
t2 = datetime.fromisoformat(
|
|
100
|
+
str(end_time).replace("Z", "+00:00")
|
|
101
|
+
)
|
|
102
|
+
latency_ms = (t2 - t1).total_seconds() * 1000.0
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
if latency_ms == 0.0:
|
|
106
|
+
latency_ms = span.get("latency_ms") or 0.0
|
|
107
|
+
|
|
108
|
+
# Status and error messages
|
|
109
|
+
status_val = span.get("status") or {}
|
|
110
|
+
status_code = (
|
|
111
|
+
status_val.get("status_code") or span.get("status_code") or "OK"
|
|
112
|
+
)
|
|
113
|
+
status_code_str = str(status_code).upper()
|
|
114
|
+
|
|
115
|
+
status = StepStatus.SUCCESS
|
|
116
|
+
error_message = None
|
|
117
|
+
if "ERROR" in status_code_str or status_val.get("message"):
|
|
118
|
+
status = StepStatus.ERROR
|
|
119
|
+
error_message = status_val.get("message")
|
|
120
|
+
|
|
121
|
+
step = TraceStep(
|
|
122
|
+
step_id=span_id,
|
|
123
|
+
parent_id=parent_id,
|
|
124
|
+
step_index=idx,
|
|
125
|
+
step_type=step_type,
|
|
126
|
+
name=span.get("name")
|
|
127
|
+
or attrs.get("openinference.span.name")
|
|
128
|
+
or f"step_{idx}",
|
|
129
|
+
input_payload=input_payload,
|
|
130
|
+
output_payload=output_payload,
|
|
131
|
+
status=status,
|
|
132
|
+
error_message=error_message,
|
|
133
|
+
latency_ms=latency_ms,
|
|
134
|
+
tokens=tokens,
|
|
135
|
+
metadata=attrs,
|
|
136
|
+
)
|
|
137
|
+
steps.append(step)
|
|
138
|
+
|
|
139
|
+
steps.sort(key=lambda s: s.step_index)
|
|
140
|
+
|
|
141
|
+
# Retrieve trace metadata
|
|
142
|
+
trace_id = "openinference_trace"
|
|
143
|
+
if spans:
|
|
144
|
+
first_span = spans[0]
|
|
145
|
+
trace_id = (
|
|
146
|
+
first_span.get("context", {}).get("trace_id")
|
|
147
|
+
or first_span.get("trace_id")
|
|
148
|
+
or trace_id
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Task input / Final output from root span if available
|
|
152
|
+
task_input = {}
|
|
153
|
+
final_output = {}
|
|
154
|
+
agent_name = "openinference_agent"
|
|
155
|
+
|
|
156
|
+
root_spans = [s for s in steps if not s.parent_id]
|
|
157
|
+
if root_spans:
|
|
158
|
+
task_input = root_spans[0].input_payload
|
|
159
|
+
final_output = root_spans[0].output_payload
|
|
160
|
+
agent_name = root_spans[0].name
|
|
161
|
+
elif steps:
|
|
162
|
+
task_input = steps[0].input_payload
|
|
163
|
+
final_output = steps[-1].output_payload
|
|
164
|
+
|
|
165
|
+
total_latency_ms = (
|
|
166
|
+
sum(s.latency_ms for s in root_spans)
|
|
167
|
+
if root_spans
|
|
168
|
+
else sum(s.latency_ms for s in steps)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
prompt_tokens = sum(s.tokens.prompt_tokens for s in steps)
|
|
172
|
+
completion_tokens = sum(s.tokens.completion_tokens for s in steps)
|
|
173
|
+
total_tokens = sum(s.tokens.total_tokens for s in steps)
|
|
174
|
+
cost = sum(s.tokens.estimated_cost_usd for s in steps)
|
|
175
|
+
|
|
176
|
+
total_tokens_obj = TokenUsage(
|
|
177
|
+
prompt_tokens=prompt_tokens,
|
|
178
|
+
completion_tokens=completion_tokens,
|
|
179
|
+
total_tokens=total_tokens,
|
|
180
|
+
estimated_cost_usd=cost,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return AgentTrace(
|
|
184
|
+
trace_id=trace_id,
|
|
185
|
+
agent_name=agent_name,
|
|
186
|
+
task_input=task_input,
|
|
187
|
+
final_output=final_output,
|
|
188
|
+
steps=steps,
|
|
189
|
+
total_latency_ms=total_latency_ms,
|
|
190
|
+
total_tokens=total_tokens_obj,
|
|
191
|
+
metadata={},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def _to_payload_dict(cls, val: Any, key_name: str) -> dict[str, Any]:
|
|
196
|
+
"""Ensures input/output values are returned as dictionary payloads."""
|
|
197
|
+
if not val:
|
|
198
|
+
return {}
|
|
199
|
+
if isinstance(val, dict):
|
|
200
|
+
return val
|
|
201
|
+
if isinstance(val, list):
|
|
202
|
+
return {f"{key_name}_list": val}
|
|
203
|
+
if isinstance(val, str):
|
|
204
|
+
try:
|
|
205
|
+
parsed = json.loads(val)
|
|
206
|
+
if isinstance(parsed, dict):
|
|
207
|
+
return parsed
|
|
208
|
+
return {key_name: parsed}
|
|
209
|
+
except Exception:
|
|
210
|
+
return {key_name: val}
|
|
211
|
+
return {key_name: val}
|
agentdiff/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from agentdiff.engine.comparator import compare
|
|
7
|
+
from agentdiff.loader import load_trace
|
|
8
|
+
from agentdiff.reporters.markdown import generate_markdown
|
|
9
|
+
from agentdiff.reporters.terminal import print_report
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
help="AgentDiff CLI - Compare multi-turn agent execution trajectories."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command(name="diff")
|
|
17
|
+
def diff(
|
|
18
|
+
baseline_path: str = typer.Argument(..., help="Path to baseline trace JSON file"),
|
|
19
|
+
candidate_path: str = typer.Argument(..., help="Path to candidate trace JSON file"),
|
|
20
|
+
adapter: str = typer.Option(
|
|
21
|
+
"auto",
|
|
22
|
+
help="Telemetry adapter: auto, generic, deepeval, openinference, langfuse",
|
|
23
|
+
),
|
|
24
|
+
format: str = typer.Option(
|
|
25
|
+
"terminal", help="Output format: terminal, json, markdown"
|
|
26
|
+
),
|
|
27
|
+
output_file: str | None = typer.Option(
|
|
28
|
+
None, help="Write output to specified file path"
|
|
29
|
+
),
|
|
30
|
+
fail_on_regression: bool = typer.Option(
|
|
31
|
+
False, help="Return exit code 1 if regressions are detected"
|
|
32
|
+
),
|
|
33
|
+
max_loops: int = typer.Option(
|
|
34
|
+
0, help="Maximum allowed loop count before regression"
|
|
35
|
+
),
|
|
36
|
+
max_divergence: float = typer.Option(
|
|
37
|
+
0.3, help="Maximum allowed Trajectory Divergence Index (TDI) before regression"
|
|
38
|
+
),
|
|
39
|
+
max_cost_delta: float = typer.Option(
|
|
40
|
+
10.0, help="Maximum allowed cost increase percentage before regression"
|
|
41
|
+
),
|
|
42
|
+
):
|
|
43
|
+
"""Compares baseline and candidate agent trajectories."""
|
|
44
|
+
try:
|
|
45
|
+
# Load and parse traces
|
|
46
|
+
baseline = load_trace(baseline_path, adapter)
|
|
47
|
+
candidate = load_trace(candidate_path, adapter)
|
|
48
|
+
except (json.JSONDecodeError, ValueError, FileNotFoundError) as e:
|
|
49
|
+
typer.echo(f"Error loading or parsing trace: {e}", err=True)
|
|
50
|
+
sys.exit(2)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
typer.echo(f"Ingestion error: {e}", err=True)
|
|
53
|
+
sys.exit(2)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Perform comparison
|
|
57
|
+
report = compare(baseline, candidate, detect_loops=True)
|
|
58
|
+
|
|
59
|
+
# Check regressions
|
|
60
|
+
loop_count = len(report.loops_detected)
|
|
61
|
+
diverged = report.trajectory_divergence_index > max_divergence
|
|
62
|
+
loop_failed = loop_count > max_loops
|
|
63
|
+
cost_failed = report.cost_delta_percentage > max_cost_delta
|
|
64
|
+
|
|
65
|
+
has_regression = diverged or loop_failed or cost_failed
|
|
66
|
+
|
|
67
|
+
if has_regression:
|
|
68
|
+
report.passed = False
|
|
69
|
+
|
|
70
|
+
# Format output
|
|
71
|
+
output_content = ""
|
|
72
|
+
if format.lower() == "terminal":
|
|
73
|
+
# For console printing we write directly, but we can capture it or format differently if output_file is active
|
|
74
|
+
if output_file:
|
|
75
|
+
# If writing terminal format to file, output the text summary representation
|
|
76
|
+
output_content = report.summary()
|
|
77
|
+
else:
|
|
78
|
+
print_report(report)
|
|
79
|
+
elif format.lower() == "json":
|
|
80
|
+
output_content = report.model_dump_json(indent=2)
|
|
81
|
+
if not output_file:
|
|
82
|
+
typer.echo(output_content)
|
|
83
|
+
elif format.lower() == "markdown":
|
|
84
|
+
output_content = generate_markdown(report)
|
|
85
|
+
if not output_file:
|
|
86
|
+
typer.echo(output_content)
|
|
87
|
+
else:
|
|
88
|
+
typer.echo(f"Unsupported format: {format}", err=True)
|
|
89
|
+
sys.exit(2)
|
|
90
|
+
|
|
91
|
+
# Write to file if requested
|
|
92
|
+
if output_file:
|
|
93
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
94
|
+
f.write(output_content)
|
|
95
|
+
|
|
96
|
+
# Enforce exit code protocol
|
|
97
|
+
if fail_on_regression and has_regression:
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
sys.exit(0)
|
|
101
|
+
|
|
102
|
+
except SystemExit:
|
|
103
|
+
raise
|
|
104
|
+
except Exception as e:
|
|
105
|
+
typer.echo(f"Internal comparison error: {e}", err=True)
|
|
106
|
+
sys.exit(3)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
try:
|
|
111
|
+
app()
|
|
112
|
+
except SystemExit as e:
|
|
113
|
+
sys.exit(e.code)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"Unhandled error: {e}", file=sys.stderr)
|
|
116
|
+
sys.exit(3)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from agentdiff.engine.aligner import align_traces
|
|
2
|
+
from agentdiff.engine.comparator import compare
|
|
3
|
+
from agentdiff.engine.loop_detector import detect_all_loops
|
|
4
|
+
from agentdiff.engine.metrics import (
|
|
5
|
+
calculate_delta_percentage,
|
|
6
|
+
calculate_tdi,
|
|
7
|
+
calculate_wei,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"align_traces",
|
|
12
|
+
"calculate_delta_percentage",
|
|
13
|
+
"calculate_tdi",
|
|
14
|
+
"calculate_wei",
|
|
15
|
+
"compare",
|
|
16
|
+
"detect_all_loops",
|
|
17
|
+
]
|