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.
@@ -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
+ ]