infini-cli 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.
infini/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """INFINI — the open standard for agent loops.
2
+
3
+ Write a Loopfile, run it on any engine.
4
+ """
5
+
6
+ __version__ = "0.1.0"
infini/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from infini.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
infini/adapters.py ADDED
@@ -0,0 +1,68 @@
1
+ """Adapter discovery and capability metadata.
2
+
3
+ Scans the adapters/ directory for adapter.yaml manifests and reports
4
+ actual capabilities (parse, run, verify, inspect, replay, diff) instead
5
+ of just directory names.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import yaml
13
+
14
+
15
+ def find_adapters_dir(start: Optional[Path] = None) -> Optional[Path]:
16
+ """Search upwards from start (or cwd) for an adapters/ directory."""
17
+ p = Path(start or Path.cwd()).resolve()
18
+ root = p.anchor
19
+ while True:
20
+ candidate = p / "adapters"
21
+ if candidate.is_dir():
22
+ return candidate
23
+ if str(p) == root:
24
+ return None
25
+ p = p.parent
26
+
27
+
28
+ def load_adapter_manifest(adapter_dir: Path) -> dict | None:
29
+ """Load an adapter's adapter.yaml manifest. Returns None if not found."""
30
+ manifest_path = adapter_dir / "adapter.yaml"
31
+ if not manifest_path.exists():
32
+ return None
33
+ try:
34
+ with manifest_path.open("r", encoding="utf-8") as fh:
35
+ data = yaml.safe_load(fh)
36
+ return data if isinstance(data, dict) else None
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ def detect_adapters(base: Optional[Path] = None) -> list[dict]:
42
+ """Scan for adapters and return their manifests.
43
+
44
+ Returns a list of dicts, each with at minimum:
45
+ - name: adapter directory name
46
+ - path: absolute path to the adapter directory
47
+ - manifest: the parsed adapter.yaml (or None if no manifest)
48
+ - capabilities: list of supported capability strings (from manifest, or [])
49
+ """
50
+ adapters_dir = find_adapters_dir(base)
51
+ if not adapters_dir:
52
+ return []
53
+
54
+ results = []
55
+ for p in sorted(adapters_dir.iterdir()):
56
+ if not p.is_dir():
57
+ continue
58
+ manifest = load_adapter_manifest(p)
59
+ caps = []
60
+ if manifest and isinstance(manifest.get("capabilities"), dict):
61
+ caps = [k for k, v in manifest["capabilities"].items() if v is True]
62
+ results.append({
63
+ "name": p.name,
64
+ "path": str(p),
65
+ "manifest": manifest,
66
+ "capabilities": caps,
67
+ })
68
+ return results
infini/cli.py ADDED
@@ -0,0 +1,287 @@
1
+ """INFINI CLI — the entry point.
2
+
3
+ Usage:
4
+ infini validate <loopfile>
5
+ infini run <loopfile> [--mock] [--output <dir>]
6
+ infini inspect <run_dir> [--web]
7
+ infini replay <run_dir> [--step <id>] [--freeze-model-calls]
8
+ infini diff <v1> <v2>
9
+ infini ui [<trace>]
10
+ infini engines
11
+ infini init [--target <dir>] [--filename <name>]
12
+ infini new <name>
13
+ infini graph <loopfile>
14
+ infini benchmark <loopfile>
15
+ infini --version
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import click
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+ from rich.table import Table
26
+ from rich import box
27
+
28
+ from . import __version__
29
+ from .parse import parse_file, ParseError
30
+ from .engine import run as run_loop
31
+ from .inspect import inspect as inspect_trace
32
+ from .replay import replay as replay_trace
33
+ from .diff import diff as diff_cmd
34
+ from .ui import launch_ui
35
+ from .adapters import detect_adapters
36
+
37
+ console = Console()
38
+
39
+
40
+ @click.group(invoke_without_command=True)
41
+ @click.version_option(__version__, prog_name="infini")
42
+ def cli():
43
+ """INFINI — the open standard for agent loops.
44
+
45
+ Write a Loopfile, run it on any engine.
46
+
47
+ \b
48
+ Quickstart:
49
+ infini validate loop.yaml
50
+ infini run loop.yaml --mock
51
+ infini inspect runs/latest/
52
+ infini replay runs/latest/ --step s3
53
+ infini ui runs/latest/run.json
54
+ """
55
+
56
+
57
+ @cli.command()
58
+ @click.argument("loopfile", type=click.Path(exists=True))
59
+ def validate(loopfile: str):
60
+ """Validate a Loopfile against the spec."""
61
+ try:
62
+ lf = parse_file(loopfile)
63
+ console.print(f"[green]✓ valid[/green] {lf.name}@{lf.version} (LOOPFILE-{lf.spec_version})")
64
+ console.print(f" [dim]objective: {lf.objective}[/dim]")
65
+ console.print(f" [dim]agents: {len(lf.agents)} steps: {len(lf.steps)} verify: {len(lf.verify.syntactic)} syntactic + {len(lf.verify.semantic)} semantic[/dim]")
66
+ console.print(f" [dim]budget: ${lf.budget.dollars} / {lf.budget.minutes}m[/dim]")
67
+ except ParseError as e:
68
+ console.print(f"[red]✗ invalid[/red]")
69
+ console.print(str(e))
70
+ sys.exit(1)
71
+
72
+
73
+ @cli.command()
74
+ @click.argument("loopfile", type=click.Path(exists=True))
75
+ @click.option("--mock/--live", default=True, help="Use mock LLM (default) or live execution.")
76
+ @click.option("-o", "--output", default="runs/latest", help="Output directory for the trace.")
77
+ @click.option("--max-iterations", default=5, help="Hard cap on iterations.")
78
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress progress output.")
79
+ def run(loopfile: str, mock: bool, output: str, max_iterations: int, quiet: bool):
80
+ """Run a Loopfile."""
81
+ try:
82
+ lf = parse_file(loopfile)
83
+ except ParseError as e:
84
+ console.print(f"[red]✗ invalid Loopfile[/red]")
85
+ console.print(str(e))
86
+ sys.exit(1)
87
+
88
+ trace = run_loop(
89
+ lf,
90
+ output_dir=output,
91
+ mock=mock,
92
+ max_iterations=max_iterations,
93
+ verbose=not quiet,
94
+ )
95
+
96
+ if trace.outcome == "verified":
97
+ console.print(f"\n[green]✓ shipped.[/green] trace: {Path(output) / 'run.json'}")
98
+ elif trace.outcome == "budget_exceeded":
99
+ console.print(f"\n[red]✗ budget exceeded.[/red] trace: {Path(output) / 'run.json'}")
100
+ sys.exit(1)
101
+ else:
102
+ console.print(f"\n[yellow]⚠ unverified.[/yellow] trace: {Path(output) / 'run.json'}")
103
+ sys.exit(1)
104
+
105
+
106
+ @cli.command()
107
+ @click.argument("run_dir", type=click.Path(exists=True))
108
+ @click.option("--web", is_flag=True, help="Open the Observatory UI in the browser (coming soon).")
109
+ def inspect(run_dir: str, web: bool):
110
+ """Inspect a run trace. Use --web to open the Observatory UI (coming soon)."""
111
+ inspect_trace(run_dir)
112
+ if web:
113
+ console.print("[blue]Observatory UI support is coming soon.[/blue]")
114
+ console.print("[dim]For now, run `infini ui` separately to launch the Next.js dashboard.[/dim]")
115
+
116
+
117
+ @cli.command()
118
+ @click.argument("run_dir", type=click.Path(exists=True))
119
+ @click.option("--step", default=None, help="Step ID to replay from.")
120
+ @click.option("--freeze-model-calls", is_flag=True, help="Reuse original model responses (bit-exact).")
121
+ @click.option("-o", "--output", default=None, help="Output directory for the replay trace.")
122
+ def replay(run_dir: str, step: str | None, freeze_model_calls: bool, output: str | None):
123
+ """Replay a run from a specific step."""
124
+ replay_trace(run_dir, from_step=step, freeze_model_calls=freeze_model_calls, output_dir=output)
125
+
126
+
127
+ @cli.command()
128
+ @click.argument("v1", type=click.Path(exists=True))
129
+ @click.argument("v2", type=click.Path(exists=True))
130
+ def diff(v1: str, v2: str):
131
+ """Semantic diff between two Loopfiles or two traces."""
132
+ diff_cmd(v1, v2)
133
+
134
+
135
+ @cli.command()
136
+ @click.argument("trace", required=False, type=click.Path(exists=True))
137
+ @click.option("--port", default=3000, help="Port for the Observatory UI.")
138
+ def ui(trace: str | None, port: int):
139
+ """Launch the Loop Observatory UI."""
140
+ launch_ui(trace_path=trace, port=port)
141
+
142
+
143
+ @cli.command()
144
+ def engines():
145
+ """List installed adapters and their capabilities (from adapter.yaml manifests)."""
146
+ console.rule("[bold]Engines / Adapters (detected)")
147
+ adapters = detect_adapters()
148
+ if not adapters:
149
+ console.print("[yellow]No adapters found under adapters/.[/yellow]")
150
+ console.print("[dim]Adapters are discovered by scanning for adapter.yaml manifests.[/dim]")
151
+ return
152
+ table = Table(box=box.SIMPLE)
153
+ table.add_column("Adapter", style="cyan")
154
+ table.add_column("Version")
155
+ table.add_column("Type")
156
+ table.add_column("Capabilities", style="green")
157
+ for a in adapters:
158
+ m = a.get("manifest") or {}
159
+ adapter_info = m.get("adapter", {}) if isinstance(m, dict) else {}
160
+ name = adapter_info.get("name", a["name"])
161
+ version = adapter_info.get("version", "?")
162
+ atype = adapter_info.get("type", "?")
163
+ caps = a.get("capabilities", [])
164
+ caps_str = ", ".join(caps) if caps else "(none declared)"
165
+ table.add_row(name, version, atype, caps_str)
166
+ console.print(table)
167
+ console.print("\n[dim]Full compatibility matrix: spec/compatibility.md[/dim]")
168
+ console.print("[dim]Adapters without adapter.yaml are not listed.[/dim]")
169
+
170
+
171
+ @cli.command()
172
+ @click.option("--target", "-t", default=".", help="Directory to initialize.")
173
+ @click.option("--filename", "-f", default="Loopfile", help="Starter Loopfile name.")
174
+ def init(target: str, filename: str):
175
+ """Scaffold a minimal loop project: Loopfile, loops/, state/, runs/."""
176
+ target_path = Path(target).resolve()
177
+ for d in ("loops", "state", "runs"):
178
+ (target_path / d).mkdir(parents=True, exist_ok=True)
179
+ lf = target_path / filename
180
+ if not lf.exists():
181
+ lf.write_text(
182
+ 'LOOPFILE: "1.0"\n'
183
+ 'name: my-loop\n'
184
+ 'version: 1.0.0\n'
185
+ 'OBJECTIVE: "Describe the objective here."\n'
186
+ 'AGENTS:\n'
187
+ ' - { name: builder, role: builder, model_tier: sonnet }\n'
188
+ 'STEPS: []\n'
189
+ 'VERIFY:\n'
190
+ ' syntactic: []\n'
191
+ ' semantic: []\n'
192
+ ' confidence_threshold: 80\n'
193
+ 'BUDGET: { dollars: 5, minutes: 15 }\n'
194
+ 'STOP_WHEN: ["all_verify_passed"]\n'
195
+ )
196
+ console.print(Panel.fit(
197
+ f"Initialized at [cyan]{target_path}[/cyan]\nLoopfile: [green]{lf}[/green]",
198
+ title="infini init",
199
+ ))
200
+
201
+
202
+ @cli.command()
203
+ @click.argument("name")
204
+ def new(name: str):
205
+ """Create a new loop project scaffold: <name>/Loopfile, state/, runs/, artifacts/.
206
+
207
+ NAME is used both as the directory name and the Loopfile's name field.
208
+ Use a simple slug (e.g. 'my-loop'), not a full path.
209
+ """
210
+ # Derive a clean slug from the name (last path component, lowercased)
211
+ slug = Path(name).name.lower().replace(" ", "-")
212
+ base = Path.cwd() / name
213
+ base.mkdir(parents=True, exist_ok=True)
214
+ for d in ("state", "runs", "artifacts"):
215
+ (base / d).mkdir(exist_ok=True)
216
+ lf = base / "Loopfile"
217
+ if not lf.exists():
218
+ lf.write_text(
219
+ f'LOOPFILE: "1.0"\n'
220
+ f'name: {slug}\n'
221
+ f'version: 1.0.0\n'
222
+ f'OBJECTIVE: "Describe the objective."\n'
223
+ f'AGENTS:\n'
224
+ f' - {{ name: builder, role: builder, model_tier: sonnet }}\n'
225
+ f'STEPS: []\n'
226
+ f'VERIFY:\n'
227
+ f' syntactic: []\n'
228
+ f' semantic: []\n'
229
+ f' confidence_threshold: 80\n'
230
+ f'BUDGET: {{ dollars: 5, minutes: 15 }}\n'
231
+ f'STOP_WHEN: ["all_verify_passed"]\n'
232
+ )
233
+ console.print(Panel.fit(
234
+ f"Created new project at [cyan]{base}[/cyan]\nLoopfile: [green]{lf}[/green]",
235
+ title="infini new",
236
+ ))
237
+
238
+
239
+ @cli.command()
240
+ @click.argument("loopfile", type=click.Path(exists=True))
241
+ def graph(loopfile: str):
242
+ """Render a simple ASCII graph of the Loopfile's steps."""
243
+ try:
244
+ lf = parse_file(loopfile)
245
+ except ParseError as e:
246
+ console.print(f"[red]✗ invalid Loopfile[/red]")
247
+ console.print(str(e))
248
+ sys.exit(1)
249
+ console.rule("[bold]Loop Graph")
250
+ if not lf.steps:
251
+ console.print("[yellow]No steps found to graph.[/yellow]")
252
+ return
253
+ for idx, s in enumerate(lf.steps):
254
+ console.print(f" [cyan]{s.id}[/cyan] [bold]{s.name}[/bold]")
255
+ if idx < len(lf.steps) - 1:
256
+ console.print(" [dim]│[/dim]")
257
+ console.print(" [dim]▼[/dim]")
258
+
259
+
260
+ @cli.command()
261
+ @click.argument("loopfile", type=click.Path(exists=True))
262
+ def benchmark(loopfile: str):
263
+ """Produce a benchmark estimate (preview — real profiling coming later)."""
264
+ try:
265
+ lf = parse_file(loopfile)
266
+ except ParseError as e:
267
+ console.print(f"[red]✗ invalid Loopfile[/red]")
268
+ console.print(str(e))
269
+ sys.exit(1)
270
+ n = len(lf.steps)
271
+ console.rule("[bold]Benchmark Estimate — Preview")
272
+ console.print("[dim]This is a preview. Real profiling requires running the loop.[/dim]\n")
273
+ console.print(f" Steps detected: [bold]{n}[/bold]")
274
+ console.print(f" Runtime: [yellow]Unknown[/yellow] [dim](run `infini run` to measure)[/dim]")
275
+ console.print(f" Cost: [yellow]Unknown[/yellow] [dim](run `infini run` to measure)[/dim]")
276
+ console.print(f" Iterations: [yellow]Unknown[/yellow] [dim](depends on verification)[/dim]")
277
+ console.print(f" Confidence: [yellow]Unknown[/yellow] [dim](depends on semantic checks)[/dim]")
278
+ console.print(f"\n To get real numbers:\n infini run {loopfile} --mock")
279
+
280
+
281
+ def main():
282
+ """Entry point."""
283
+ cli()
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
infini/diff.py ADDED
@@ -0,0 +1,167 @@
1
+ """Semantic diff — `infini diff`.
2
+
3
+ Produces a semantic diff between two Loopfiles or two traces, not a
4
+ line diff. Highlights changes to OBJECTIVE, AGENTS, STEPS, VERIFY,
5
+ BUDGET, STOP_WHEN.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from .parse import parse_file
16
+ from .trace import load_trace
17
+
18
+ console = Console()
19
+
20
+
21
+ def diff(v1: str | Path, v2: str | Path) -> None:
22
+ """Diff two Loopfiles or two traces. Auto-detects which."""
23
+ p1, p2 = Path(v1), Path(v2)
24
+
25
+ # Auto-detect: is this a Loopfile (YAML) or a trace (JSON)?
26
+ is_trace_1 = _is_trace(p1)
27
+ is_trace_2 = _is_trace(p2)
28
+
29
+ if is_trace_1 and is_trace_2:
30
+ _diff_traces(p1, p2)
31
+ elif not is_trace_1 and not is_trace_2:
32
+ _diff_loopfiles(p1, p2)
33
+ else:
34
+ console.print("[red]Cannot diff a Loopfile against a trace. Both arguments must be the same type.[/red]")
35
+
36
+
37
+ def _is_trace(path: Path) -> bool:
38
+ """Heuristic: is this file a trace (JSON with 'loopfile' key) or a Loopfile (YAML)?"""
39
+ if not path.exists():
40
+ return False
41
+ text = path.read_text()
42
+ try:
43
+ data = json.loads(text)
44
+ return isinstance(data, dict) and "loopfile" in data
45
+ except json.JSONDecodeError:
46
+ return False
47
+
48
+
49
+ def _diff_loopfiles(v1: Path, v2: Path) -> None:
50
+ """Diff two Loopfiles."""
51
+ lf1 = parse_file(v1)
52
+ lf2 = parse_file(v2)
53
+
54
+ console.print(f"\n[bold]INFINI diff[/bold] — Loopfiles")
55
+ console.print(f" [dim]v1: {v1}[/dim]")
56
+ console.print(f" [dim]v2: {v2}[/dim]\n")
57
+
58
+ changes: list[str] = []
59
+
60
+ # Objective
61
+ if lf1.objective != lf2.objective:
62
+ changes.append(f"[red]OBJECTIVE changed[/red]")
63
+ console.print(f" [red]- OBJECTIVE: {lf1.objective}[/red]")
64
+ console.print(f" [green]+ OBJECTIVE: {lf2.objective}[/green]")
65
+ else:
66
+ console.print(f" [dim]OBJECTIVE: unchanged[/dim]")
67
+
68
+ # Agents
69
+ agents1 = {a.name: a for a in lf1.agents}
70
+ agents2 = {a.name: a for a in lf2.agents}
71
+ added = set(agents2) - set(agents1)
72
+ removed = set(agents1) - set(agents2)
73
+ if added:
74
+ changes.append(f"[green]+{len(added)} agent(s) added[/green]")
75
+ console.print(f" [green]+ AGENTS: {', '.join(added)}[/green]")
76
+ if removed:
77
+ changes.append(f"[red]-{len(removed)} agent(s) removed[/red]")
78
+ console.print(f" [red]- AGENTS: {', '.join(removed)}[/red]")
79
+ if not added and not removed:
80
+ console.print(f" [dim]AGENTS: unchanged[/dim]")
81
+
82
+ # Steps
83
+ steps1 = {s.id: s for s in lf1.steps}
84
+ steps2 = {s.id: s for s in lf2.steps}
85
+ added_s = set(steps2) - set(steps1)
86
+ removed_s = set(steps1) - set(steps2)
87
+ if added_s:
88
+ changes.append(f"[green]+{len(added_s)} step(s) added[/green]")
89
+ console.print(f" [green]+ STEPS: {', '.join(added_s)}[/green]")
90
+ if removed_s:
91
+ changes.append(f"[red]-{len(removed_s)} step(s) removed[/red]")
92
+ console.print(f" [red]- STEPS: {', '.join(removed_s)}[/red]")
93
+ if not added_s and not removed_s:
94
+ console.print(f" [dim]STEPS: unchanged[/dim]")
95
+
96
+ # Verify
97
+ v1_checks = set(lf1.verify.syntactic + lf1.verify.semantic)
98
+ v2_checks = set(lf2.verify.syntactic + lf2.verify.semantic)
99
+ added_v = v2_checks - v1_checks
100
+ removed_v = v1_checks - v2_checks
101
+ if added_v:
102
+ changes.append(f"[green]+{len(added_v)} verification check(s) added[/green]")
103
+ console.print(f" [green]+ VERIFY: {', '.join(added_v)}[/green]")
104
+ if removed_v:
105
+ changes.append(f"[red]-{len(removed_v)} verification check(s) removed[/red]")
106
+ console.print(f" [red]- VERIFY: {', '.join(removed_v)}[/red]")
107
+ if lf1.verify.confidence_threshold != lf2.verify.confidence_threshold:
108
+ changes.append(f"[yellow]~ confidence_threshold: {lf1.verify.confidence_threshold} → {lf2.verify.confidence_threshold}[/yellow]")
109
+ console.print(f" [yellow]~ confidence_threshold: {lf1.verify.confidence_threshold} → {lf2.verify.confidence_threshold}[/yellow]")
110
+ if not added_v and not removed_v and lf1.verify.confidence_threshold == lf2.verify.confidence_threshold:
111
+ console.print(f" [dim]VERIFY: unchanged[/dim]")
112
+
113
+ # Budget
114
+ if lf1.budget.dollars != lf2.budget.dollars:
115
+ changes.append(f"[yellow]~ BUDGET dollars: ${lf1.budget.dollars} → ${lf2.budget.dollars}[/yellow]")
116
+ console.print(f" [yellow]~ BUDGET dollars: ${lf1.budget.dollars} → ${lf2.budget.dollars}[/yellow]")
117
+ if lf1.budget.minutes != lf2.budget.minutes:
118
+ changes.append(f"[yellow]~ BUDGET minutes: {lf1.budget.minutes} → {lf2.budget.minutes}[/yellow]")
119
+ console.print(f" [yellow]~ BUDGET minutes: {lf1.budget.minutes}m → {lf2.budget.minutes}m[/yellow]")
120
+ if lf1.budget.dollars == lf2.budget.dollars and lf1.budget.minutes == lf2.budget.minutes:
121
+ console.print(f" [dim]BUDGET: unchanged[/dim]")
122
+
123
+ # Summary
124
+ console.print(f"\n[bold]Summary[/bold]")
125
+ if not changes:
126
+ console.print(f" [green]No semantic changes. The two Loopfiles are equivalent.[/green]")
127
+ else:
128
+ for c in changes:
129
+ console.print(f" {c}")
130
+
131
+ # Compatibility classification
132
+ if any("[red]" in c for c in changes):
133
+ console.print(f"\n [red]This is a BREAKING change.[/red]")
134
+ elif any("[yellow]" in c for c in changes):
135
+ console.print(f"\n [yellow]This is a COMPATIBLE change.[/yellow]")
136
+ elif added or added_s or added_v:
137
+ console.print(f"\n [green]This is an ADDITIVE change.[/green]")
138
+
139
+
140
+ def _diff_traces(v1: Path, v2: Path) -> None:
141
+ """Diff two traces."""
142
+ t1 = load_trace(v1)
143
+ t2 = load_trace(v2)
144
+
145
+ console.print(f"\n[bold]INFINI diff[/bold] — Traces")
146
+ console.print(f" [dim]v1: {v1}[/dim]")
147
+ console.print(f" [dim]v2: {v2}[/dim]\n")
148
+
149
+ table = Table(show_header=True, header_style="bold")
150
+ table.add_column("Metric")
151
+ table.add_column("v1", justify="right")
152
+ table.add_column("v2", justify="right")
153
+ table.add_column("Delta", justify="right")
154
+
155
+ b1, b2 = t1.get("budget", {}), t2.get("budget", {})
156
+ d_cost = b2.get("spent_dollars", 0) - b1.get("spent_dollars", 0)
157
+ d_time = b2.get("spent_minutes", 0) - b1.get("spent_minutes", 0)
158
+
159
+ table.add_row("Outcome", t1.get("outcome", "?"), t2.get("outcome", "?"), "")
160
+ table.add_row("Iterations", str(t1.get("iterations", 0)), str(t2.get("iterations", 0)), "")
161
+ table.add_row("Steps", str(len(t1.get("steps", []))), str(len(t2.get("steps", []))), "")
162
+ table.add_row("Cost", f"${b1.get('spent_dollars', 0):.2f}", f"${b2.get('spent_dollars', 0):.2f}",
163
+ f"[{'red' if d_cost > 0 else 'green'}]{'+' if d_cost > 0 else ''}${d_cost:.2f}[/]")
164
+ table.add_row("Time", f"{b1.get('spent_minutes', 0):.1f}m", f"{b2.get('spent_minutes', 0):.1f}m",
165
+ f"[{'red' if d_time > 0 else 'green'}]{'+' if d_time > 0 else ''}{d_time:.1f}m[/]")
166
+
167
+ console.print(table)