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 +6 -0
- infini/__main__.py +4 -0
- infini/adapters.py +68 -0
- infini/cli.py +287 -0
- infini/diff.py +167 -0
- infini/engine.py +191 -0
- infini/inspect.py +102 -0
- infini/mock.py +108 -0
- infini/parse.py +207 -0
- infini/replay.py +137 -0
- infini/schema.json +181 -0
- infini/trace.py +178 -0
- infini/ui.py +70 -0
- infini_cli-0.1.0.dist-info/METADATA +159 -0
- infini_cli-0.1.0.dist-info/RECORD +18 -0
- infini_cli-0.1.0.dist-info/WHEEL +5 -0
- infini_cli-0.1.0.dist-info/entry_points.txt +2 -0
- infini_cli-0.1.0.dist-info/top_level.txt +1 -0
infini/__init__.py
ADDED
infini/__main__.py
ADDED
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)
|