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,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)
@@ -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,7 @@
1
+ from agentdiff.reporters.markdown import generate_markdown
2
+ from agentdiff.reporters.terminal import print_report
3
+
4
+ __all__ = [
5
+ "generate_markdown",
6
+ "print_report",
7
+ ]
@@ -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)