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/engine.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""INFINI Reference Engine.
|
|
2
|
+
|
|
3
|
+
Executes a Loopfile: runs the STEPS DAG, enforces BUDGET, runs VERIFY,
|
|
4
|
+
and emits a trace. In mock mode (default for V1), uses the mock LLM
|
|
5
|
+
engine so no API key is required.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .parse import Loopfile, Step
|
|
12
|
+
from .trace import Trace, add_step, add_verification, finalize_trace, save_trace, new_trace
|
|
13
|
+
from .mock import mock_execute, mock_verify
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BudgetExceeded(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
loopfile: Loopfile,
|
|
22
|
+
output_dir: str | Path = "runs/latest",
|
|
23
|
+
mock: bool = True,
|
|
24
|
+
max_iterations: int = 5,
|
|
25
|
+
verbose: bool = True,
|
|
26
|
+
) -> Trace:
|
|
27
|
+
"""Execute a Loopfile. Returns the completed Trace.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
loopfile: Parsed Loopfile to run.
|
|
31
|
+
output_dir: Where to save the trace (run.json).
|
|
32
|
+
mock: If True, use mock LLM (no API key needed).
|
|
33
|
+
max_iterations: Hard cap on iterations (overrides STOP_WHEN if lower).
|
|
34
|
+
verbose: Print progress to stdout.
|
|
35
|
+
"""
|
|
36
|
+
trace = new_trace(loopfile.name, _serialize(loopfile))
|
|
37
|
+
|
|
38
|
+
if verbose:
|
|
39
|
+
_log(f"▶ engine: infini-reference {'(mock)' if mock else '(live)'}")
|
|
40
|
+
_log(f"▶ reading state... none found, starting fresh")
|
|
41
|
+
_log(f"▶ objective: {loopfile.objective}")
|
|
42
|
+
_log(f"▶ budget: ${loopfile.budget.dollars} / {loopfile.budget.minutes}m")
|
|
43
|
+
|
|
44
|
+
outcome = "unverified"
|
|
45
|
+
lessons: list[str] = []
|
|
46
|
+
|
|
47
|
+
for iteration in range(1, max_iterations + 1):
|
|
48
|
+
trace.iterations = iteration
|
|
49
|
+
if verbose:
|
|
50
|
+
_log(f"▶ iteration {iteration}")
|
|
51
|
+
|
|
52
|
+
# Execute all steps
|
|
53
|
+
for step in loopfile.steps:
|
|
54
|
+
result = _execute_step(loopfile, step, iteration, mock)
|
|
55
|
+
|
|
56
|
+
if result.status == "failed" and step.retry:
|
|
57
|
+
# Retry
|
|
58
|
+
for attempt in range(1, step.retry.get("max", 3) + 1):
|
|
59
|
+
if verbose:
|
|
60
|
+
_log(f" ⚠ {step.id} {step.name} failed, retry {attempt}/{step.retry.get('max', 3)}")
|
|
61
|
+
result = _execute_step(loopfile, step, iteration, mock, retry_attempt=attempt)
|
|
62
|
+
if result.status == "ok":
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
add_step(
|
|
66
|
+
trace, step.id, step.name,
|
|
67
|
+
agent=step.uses, action=step.action,
|
|
68
|
+
artifacts=result.artifacts,
|
|
69
|
+
cost_dollars=result.cost_dollars,
|
|
70
|
+
cost_minutes=result.cost_minutes,
|
|
71
|
+
tokens_in=result.tokens_in,
|
|
72
|
+
tokens_out=result.tokens_out,
|
|
73
|
+
status=result.status,
|
|
74
|
+
retry_attempt=result.retry_attempt,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if verbose:
|
|
78
|
+
status_icon = "✓" if result.status == "ok" else "⚠"
|
|
79
|
+
_log(f" {status_icon} {step.id} {step.name} ${result.cost_dollars:.2f} · {result.cost_minutes:.1f}m")
|
|
80
|
+
|
|
81
|
+
# Check budget
|
|
82
|
+
if trace.budget["spent_dollars"] >= loopfile.budget.dollars:
|
|
83
|
+
outcome = "budget_exceeded"
|
|
84
|
+
if verbose:
|
|
85
|
+
_log(f" ✗ budget exceeded (${trace.budget['spent_dollars']:.2f} / ${loopfile.budget.dollars})")
|
|
86
|
+
finalize_trace(trace, outcome, lessons)
|
|
87
|
+
save_trace(trace, Path(output_dir) / "run.json")
|
|
88
|
+
return trace
|
|
89
|
+
|
|
90
|
+
if trace.budget["spent_minutes"] >= loopfile.budget.minutes:
|
|
91
|
+
outcome = "budget_exceeded"
|
|
92
|
+
if verbose:
|
|
93
|
+
_log(f" ✗ budget exceeded ({trace.budget['spent_minutes']:.1f}m / {loopfile.budget.minutes}m)")
|
|
94
|
+
finalize_trace(trace, outcome, lessons)
|
|
95
|
+
save_trace(trace, Path(output_dir) / "run.json")
|
|
96
|
+
return trace
|
|
97
|
+
|
|
98
|
+
# Run verification
|
|
99
|
+
if verbose:
|
|
100
|
+
_log(f"▶ verification:")
|
|
101
|
+
all_passed = True
|
|
102
|
+
confidences: list[float] = []
|
|
103
|
+
|
|
104
|
+
for check in loopfile.verify.syntactic:
|
|
105
|
+
passed, _ = mock_verify(check, loopfile.name, iteration, loopfile.verify.confidence_threshold) if mock else (True, None)
|
|
106
|
+
add_verification(trace, check, passed, confidence=None)
|
|
107
|
+
if verbose:
|
|
108
|
+
_log(f" {'✓' if passed else '✗'} {check}")
|
|
109
|
+
if not passed:
|
|
110
|
+
all_passed = False
|
|
111
|
+
|
|
112
|
+
for check in loopfile.verify.semantic:
|
|
113
|
+
passed, conf = mock_verify(check, loopfile.name, iteration, loopfile.verify.confidence_threshold) if mock else (True, 90.0)
|
|
114
|
+
add_verification(trace, check, passed, confidence=conf)
|
|
115
|
+
if conf is not None:
|
|
116
|
+
confidences.append(conf)
|
|
117
|
+
if verbose:
|
|
118
|
+
_log(f" {'✓' if passed else '✗'} {check} (conf {conf})" if conf else f" {'✓' if passed else '✗'} {check}")
|
|
119
|
+
if not passed:
|
|
120
|
+
all_passed = False
|
|
121
|
+
|
|
122
|
+
# Check confidence threshold
|
|
123
|
+
if confidences:
|
|
124
|
+
mean_conf = sum(confidences) / len(confidences)
|
|
125
|
+
if mean_conf < loopfile.verify.confidence_threshold:
|
|
126
|
+
all_passed = False
|
|
127
|
+
if verbose:
|
|
128
|
+
_log(f" ✗ mean confidence {mean_conf:.1f} < threshold {loopfile.verify.confidence_threshold}")
|
|
129
|
+
|
|
130
|
+
if all_passed:
|
|
131
|
+
outcome = "verified"
|
|
132
|
+
lesson = f"{loopfile.name} shipped at iteration {iteration} with mean confidence {sum(confidences)/len(confidences):.1f}." if confidences else f"{loopfile.name} shipped at iteration {iteration}."
|
|
133
|
+
lessons.append(lesson)
|
|
134
|
+
if verbose:
|
|
135
|
+
_log(f"✓ shipped. state saved. lessons appended.")
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
# Check stop conditions
|
|
139
|
+
if "iterations>=" + str(max_iterations) in loopfile.stop_when:
|
|
140
|
+
break
|
|
141
|
+
if iteration >= max_iterations:
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
finalize_trace(trace, outcome, lessons)
|
|
145
|
+
save_trace(trace, Path(output_dir) / "run.json")
|
|
146
|
+
|
|
147
|
+
if verbose:
|
|
148
|
+
_log(f"▶ cost: ${trace.budget['spent_dollars']:.2f} / ${loopfile.budget.dollars} · {trace.budget['spent_minutes']:.1f}m / {loopfile.budget.minutes}m")
|
|
149
|
+
_log(f"▶ outcome: {outcome}")
|
|
150
|
+
_log(f"▶ trace: {Path(output_dir) / 'run.json'}")
|
|
151
|
+
|
|
152
|
+
return trace
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _execute_step(
|
|
156
|
+
loopfile: Loopfile,
|
|
157
|
+
step: Step,
|
|
158
|
+
iteration: int,
|
|
159
|
+
mock: bool,
|
|
160
|
+
retry_attempt: int = 0,
|
|
161
|
+
):
|
|
162
|
+
"""Execute a single step. Returns a MockResult (or equivalent)."""
|
|
163
|
+
if mock:
|
|
164
|
+
# Find the agent
|
|
165
|
+
agent = next((a for a in loopfile.agents if a.name == step.uses), None)
|
|
166
|
+
model_tier = agent.model_tier if agent else "sonnet"
|
|
167
|
+
role = agent.role if agent else "builder"
|
|
168
|
+
return mock_execute(
|
|
169
|
+
step_id=step.id, step_name=step.name, action=step.action,
|
|
170
|
+
agent_role=role, model_tier=model_tier, produces=step.produces,
|
|
171
|
+
loopfile_name=loopfile.name, iteration=iteration,
|
|
172
|
+
retry_attempt=retry_attempt,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
# Live mode: not implemented in V1 (requires adapter)
|
|
176
|
+
raise NotImplementedError(
|
|
177
|
+
"Live execution requires an engine adapter. Use --mock for now, "
|
|
178
|
+
"or install infini-cli[hermes] / infini-cli[openclaw] when adapters ship."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _serialize(loopfile: Loopfile) -> str:
|
|
183
|
+
"""Serialize a Loopfile back to YAML for hashing."""
|
|
184
|
+
import yaml
|
|
185
|
+
from .parse import to_dict
|
|
186
|
+
return yaml.dump(to_dict(loopfile), sort_keys=False, default_flow_style=False)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _log(msg: str) -> None:
|
|
190
|
+
"""Print to stdout."""
|
|
191
|
+
print(msg)
|
infini/inspect.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Trace inspector — `infini inspect`.
|
|
2
|
+
|
|
3
|
+
Prints a human-readable summary of a trace to the terminal.
|
|
4
|
+
The Observatory UI (infini ui) provides the full visual experience.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .trace import load_trace
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def inspect(run_dir: str | Path) -> None:
|
|
21
|
+
"""Inspect a run trace. Prints a summary to stdout."""
|
|
22
|
+
run_dir = Path(run_dir)
|
|
23
|
+
trace_path = run_dir / "run.json" if (run_dir / "run.json").exists() else run_dir
|
|
24
|
+
trace = load_trace(trace_path)
|
|
25
|
+
|
|
26
|
+
# Header
|
|
27
|
+
console.print(Panel.fit(
|
|
28
|
+
f"[bold]{trace.get('loopfile', 'unknown')}[/bold]\n"
|
|
29
|
+
f"engine: {trace.get('engine', {}).get('type', '?')} · "
|
|
30
|
+
f"outcome: {_outcome_color(trace.get('outcome', '?'))} · "
|
|
31
|
+
f"iterations: {trace.get('iterations', 0)}",
|
|
32
|
+
border_style="bright_blue",
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
# Budget
|
|
36
|
+
budget = trace.get("budget", {})
|
|
37
|
+
console.print(f"\n[bold]Budget[/bold]")
|
|
38
|
+
console.print(f" dollars: ${budget.get('spent_dollars', 0):.2f}")
|
|
39
|
+
console.print(f" minutes: {budget.get('spent_minutes', 0):.1f}m")
|
|
40
|
+
|
|
41
|
+
# Steps
|
|
42
|
+
steps = trace.get("steps", [])
|
|
43
|
+
if steps:
|
|
44
|
+
table = Table(title="\nSteps", show_lines=False)
|
|
45
|
+
table.add_column("ID", style="dim")
|
|
46
|
+
table.add_column("Name", style="bold")
|
|
47
|
+
table.add_column("Status")
|
|
48
|
+
table.add_column("Cost", justify="right")
|
|
49
|
+
table.add_column("Time", justify="right")
|
|
50
|
+
table.add_column("Artifacts", style="dim")
|
|
51
|
+
|
|
52
|
+
for s in steps:
|
|
53
|
+
status = s.get("status", "?")
|
|
54
|
+
status_str = f"[green]✓ {status}[/green]" if status == "ok" else f"[yellow]⚠ {status}[/yellow]"
|
|
55
|
+
cost = s.get("cost", {})
|
|
56
|
+
table.add_row(
|
|
57
|
+
s.get("id", ""),
|
|
58
|
+
s.get("name", ""),
|
|
59
|
+
status_str,
|
|
60
|
+
f"${cost.get('dollars', 0):.2f}",
|
|
61
|
+
f"{cost.get('minutes', 0):.1f}m",
|
|
62
|
+
", ".join(s.get("artifacts", [])) or "—",
|
|
63
|
+
)
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
# Verifications
|
|
67
|
+
verifs = trace.get("verifications", [])
|
|
68
|
+
if verifs:
|
|
69
|
+
vtable = Table(title="\nVerification")
|
|
70
|
+
vtable.add_column("Check", style="bold")
|
|
71
|
+
vtable.add_column("Status")
|
|
72
|
+
vtable.add_column("Confidence", justify="right")
|
|
73
|
+
for v in verifs:
|
|
74
|
+
status = v.get("status", "?")
|
|
75
|
+
status_str = f"[green]PASS[/green]" if status == "pass" else f"[red]FAIL[/red]"
|
|
76
|
+
conf = v.get("confidence")
|
|
77
|
+
conf_str = f"{conf:.0f}" if conf is not None else "—"
|
|
78
|
+
vtable.add_row(v.get("check", ""), status_str, conf_str)
|
|
79
|
+
console.print(vtable)
|
|
80
|
+
|
|
81
|
+
# Lessons
|
|
82
|
+
lessons = trace.get("lessons", [])
|
|
83
|
+
if lessons:
|
|
84
|
+
console.print(f"\n[bold]Lessons[/bold]")
|
|
85
|
+
for l in lessons:
|
|
86
|
+
console.print(f" • {l}")
|
|
87
|
+
|
|
88
|
+
# Footer
|
|
89
|
+
console.print(f"\n[dim]Trace: {trace_path}[/dim]")
|
|
90
|
+
console.print(f"[dim]Open in Observatory: infini ui {trace_path}[/dim]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _outcome_color(outcome: str) -> str:
|
|
94
|
+
colors = {
|
|
95
|
+
"verified": "[green]verified[/green]",
|
|
96
|
+
"unverified": "[red]unverified[/red]",
|
|
97
|
+
"budget_exceeded": "[red]budget_exceeded[/red]",
|
|
98
|
+
"escalated": "[yellow]escalated[/yellow]",
|
|
99
|
+
"error": "[red]error[/red]",
|
|
100
|
+
"running": "[blue]running[/blue]",
|
|
101
|
+
}
|
|
102
|
+
return colors.get(outcome, outcome)
|
infini/mock.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Mock LLM engine.
|
|
2
|
+
|
|
3
|
+
Simulates agent execution without calling any real model. This is what
|
|
4
|
+
makes `infini run --mock` work without an API key — users can see the
|
|
5
|
+
full loop execute, produce artifacts, emit traces, and pass/fail
|
|
6
|
+
verification, all deterministically.
|
|
7
|
+
|
|
8
|
+
The mock is deterministic: same Loopfile + same seed = same output.
|
|
9
|
+
This is essential for replay fidelity and for the conformance suite.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import random
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MockResult:
|
|
20
|
+
"""The result of a mocked step execution."""
|
|
21
|
+
artifacts: list[str]
|
|
22
|
+
cost_dollars: float
|
|
23
|
+
cost_minutes: float
|
|
24
|
+
tokens_in: int
|
|
25
|
+
tokens_out: int
|
|
26
|
+
status: str = "ok"
|
|
27
|
+
retry_attempt: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _seed_from(loopfile_name: str, step_id: str, iteration: int) -> int:
|
|
31
|
+
"""Deterministic seed from loop name + step + iteration."""
|
|
32
|
+
h = hashlib.sha256(f"{loopfile_name}:{step_id}:{iteration}".encode()).hexdigest()
|
|
33
|
+
return int(h[:8], 16)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mock_execute(
|
|
37
|
+
step_id: str,
|
|
38
|
+
step_name: str,
|
|
39
|
+
action: str,
|
|
40
|
+
agent_role: str,
|
|
41
|
+
model_tier: str,
|
|
42
|
+
produces: list[str],
|
|
43
|
+
loopfile_name: str,
|
|
44
|
+
iteration: int,
|
|
45
|
+
retry_attempt: int = 0,
|
|
46
|
+
) -> MockResult:
|
|
47
|
+
"""Execute a step in mock mode. Returns a MockResult."""
|
|
48
|
+
rng = random.Random(_seed_from(loopfile_name, step_id, iteration + retry_attempt))
|
|
49
|
+
|
|
50
|
+
# Cost model (rough, model-tier-based)
|
|
51
|
+
tier_multipliers = {"haiku": 0.5, "sonnet": 1.0, "opus": 3.0, "gpt-4o": 1.2}
|
|
52
|
+
mult = tier_multipliers.get(model_tier, 1.0)
|
|
53
|
+
|
|
54
|
+
tokens_in = rng.randint(800, 3000)
|
|
55
|
+
tokens_out = rng.randint(200, 1200)
|
|
56
|
+
cost_dollars = round((tokens_in * 0.000003 + tokens_out * 0.000015) * mult, 4)
|
|
57
|
+
cost_minutes = round(rng.uniform(0.3, 1.8), 2)
|
|
58
|
+
|
|
59
|
+
# Artifacts: produce what the step declares
|
|
60
|
+
artifacts = list(produces)
|
|
61
|
+
|
|
62
|
+
# Status: mostly ok, occasionally retried
|
|
63
|
+
status = "ok"
|
|
64
|
+
if retry_attempt == 0 and rng.random() < 0.15:
|
|
65
|
+
# 15% chance of a transient failure on first attempt
|
|
66
|
+
status = "failed"
|
|
67
|
+
elif retry_attempt > 0:
|
|
68
|
+
status = "ok" # retries usually succeed
|
|
69
|
+
|
|
70
|
+
return MockResult(
|
|
71
|
+
artifacts=artifacts,
|
|
72
|
+
cost_dollars=cost_dollars,
|
|
73
|
+
cost_minutes=cost_minutes,
|
|
74
|
+
tokens_in=tokens_in,
|
|
75
|
+
tokens_out=tokens_out,
|
|
76
|
+
status=status,
|
|
77
|
+
retry_attempt=retry_attempt if retry_attempt > 0 else None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def mock_verify(
|
|
82
|
+
check: str,
|
|
83
|
+
loopfile_name: str,
|
|
84
|
+
iteration: int,
|
|
85
|
+
confidence_threshold: int,
|
|
86
|
+
) -> tuple[bool, float | None]:
|
|
87
|
+
"""Mock a verification check. Returns (passed, confidence).
|
|
88
|
+
|
|
89
|
+
Syntactic checks (no 'judge:' prefix) mostly pass.
|
|
90
|
+
Semantic checks return a confidence score; iteration 1 is usually
|
|
91
|
+
below threshold, iteration 2+ usually passes.
|
|
92
|
+
"""
|
|
93
|
+
rng = random.Random(_seed_from(loopfile_name, check, iteration))
|
|
94
|
+
|
|
95
|
+
if check.startswith("judge:") or check.startswith("rubric:"):
|
|
96
|
+
# Semantic check
|
|
97
|
+
if iteration == 1:
|
|
98
|
+
# First iteration: likely below threshold
|
|
99
|
+
conf = rng.randint(max(0, confidence_threshold - 15), confidence_threshold - 1)
|
|
100
|
+
else:
|
|
101
|
+
# Subsequent iterations: likely above threshold
|
|
102
|
+
conf = rng.randint(confidence_threshold, min(100, confidence_threshold + 12))
|
|
103
|
+
passed = conf >= confidence_threshold
|
|
104
|
+
return passed, float(conf)
|
|
105
|
+
else:
|
|
106
|
+
# Syntactic check: 95% pass rate
|
|
107
|
+
passed = rng.random() < 0.95
|
|
108
|
+
return passed, None
|
infini/parse.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Loopfile parser and validator.
|
|
2
|
+
|
|
3
|
+
Reads a YAML Loopfile, validates it against the INFINI JSON Schema,
|
|
4
|
+
and returns a structured Loopfile object.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from jsonschema import Draft202012Validator
|
|
15
|
+
|
|
16
|
+
_SCHEMA_PATH = Path(__file__).parent / "schema.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Agent:
|
|
21
|
+
name: str
|
|
22
|
+
role: str
|
|
23
|
+
model_tier: str
|
|
24
|
+
tools: list[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Step:
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
action: str
|
|
32
|
+
uses: str
|
|
33
|
+
produces: list[str] = field(default_factory=list)
|
|
34
|
+
depends_on: list[str] = field(default_factory=list)
|
|
35
|
+
retry: dict | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Verify:
|
|
40
|
+
syntactic: list[str]
|
|
41
|
+
semantic: list[str]
|
|
42
|
+
confidence_threshold: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Budget:
|
|
47
|
+
dollars: float
|
|
48
|
+
minutes: float
|
|
49
|
+
tokens: int | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Loopfile:
|
|
54
|
+
spec_version: str
|
|
55
|
+
name: str
|
|
56
|
+
version: str
|
|
57
|
+
description: str | None
|
|
58
|
+
objective: str
|
|
59
|
+
agents: list[Agent]
|
|
60
|
+
steps: list[Step]
|
|
61
|
+
verify: Verify
|
|
62
|
+
budget: Budget
|
|
63
|
+
stop_when: list[str]
|
|
64
|
+
lessons: dict | None = None
|
|
65
|
+
state: dict | None = None
|
|
66
|
+
engine: dict | None = None
|
|
67
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ParseError(Exception):
|
|
71
|
+
"""Raised when a Loopfile is invalid."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message: str, errors: list[dict] | None = None):
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
self.errors = errors or []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_schema() -> dict:
|
|
79
|
+
with open(_SCHEMA_PATH) as f:
|
|
80
|
+
return json.load(f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse(yaml_str: str) -> Loopfile:
|
|
84
|
+
"""Parse a YAML string into a Loopfile. Raises ParseError on invalid input."""
|
|
85
|
+
try:
|
|
86
|
+
raw = yaml.safe_load(yaml_str)
|
|
87
|
+
except yaml.YAMLError as e:
|
|
88
|
+
raise ParseError(f"YAML parse error: {e}")
|
|
89
|
+
|
|
90
|
+
if not isinstance(raw, dict):
|
|
91
|
+
raise ParseError("Loopfile must be a YAML mapping at the top level.")
|
|
92
|
+
|
|
93
|
+
# Validate against JSON Schema
|
|
94
|
+
schema = _load_schema()
|
|
95
|
+
validator = Draft202012Validator(schema)
|
|
96
|
+
errors = sorted(validator.iter_errors(raw), key=lambda e: list(e.absolute_path))
|
|
97
|
+
if errors:
|
|
98
|
+
msgs = []
|
|
99
|
+
for err in errors:
|
|
100
|
+
path = ".".join(str(p) for p in err.absolute_path) or "<root>"
|
|
101
|
+
msgs.append(f" {path}: {err.message}")
|
|
102
|
+
raise ParseError(
|
|
103
|
+
f"Loopfile failed schema validation ({len(errors)} error(s)):\n" + "\n".join(msgs),
|
|
104
|
+
errors=[{"path": list(e.absolute_path), "message": e.message} for e in errors],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Build structured object
|
|
108
|
+
return _build_loopfile(raw)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_file(path: str | Path) -> Loopfile:
|
|
112
|
+
"""Parse a Loopfile from a file path."""
|
|
113
|
+
path = Path(path)
|
|
114
|
+
if not path.exists():
|
|
115
|
+
raise ParseError(f"File not found: {path}")
|
|
116
|
+
return parse(path.read_text())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_loopfile(raw: dict) -> Loopfile:
|
|
120
|
+
agents = [
|
|
121
|
+
Agent(
|
|
122
|
+
name=a["name"],
|
|
123
|
+
role=a["role"],
|
|
124
|
+
model_tier=a["model_tier"],
|
|
125
|
+
tools=a.get("tools", []),
|
|
126
|
+
)
|
|
127
|
+
for a in raw["AGENTS"]
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
steps = []
|
|
131
|
+
for s in raw["STEPS"]:
|
|
132
|
+
steps.append(
|
|
133
|
+
Step(
|
|
134
|
+
id=s["id"],
|
|
135
|
+
name=s["name"],
|
|
136
|
+
action=s["action"],
|
|
137
|
+
uses=s["uses"],
|
|
138
|
+
produces=s.get("produces", []),
|
|
139
|
+
depends_on=s.get("depends_on", []),
|
|
140
|
+
retry=s.get("retry"),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
verify = Verify(
|
|
145
|
+
syntactic=raw["VERIFY"]["syntactic"],
|
|
146
|
+
semantic=raw["VERIFY"]["semantic"],
|
|
147
|
+
confidence_threshold=raw["VERIFY"]["confidence_threshold"],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
budget = Budget(
|
|
151
|
+
dollars=raw["BUDGET"]["dollars"],
|
|
152
|
+
minutes=raw["BUDGET"]["minutes"],
|
|
153
|
+
tokens=raw["BUDGET"].get("tokens"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return Loopfile(
|
|
157
|
+
spec_version=raw["LOOPFILE"],
|
|
158
|
+
name=raw["name"],
|
|
159
|
+
version=raw["version"],
|
|
160
|
+
description=raw.get("description"),
|
|
161
|
+
objective=raw["OBJECTIVE"],
|
|
162
|
+
agents=agents,
|
|
163
|
+
steps=steps,
|
|
164
|
+
verify=verify,
|
|
165
|
+
budget=budget,
|
|
166
|
+
stop_when=raw["STOP_WHEN"],
|
|
167
|
+
lessons=raw.get("LESSONS"),
|
|
168
|
+
state=raw.get("STATE"),
|
|
169
|
+
engine=raw.get("ENGINE"),
|
|
170
|
+
raw=raw,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def to_dict(loopfile: Loopfile) -> dict:
|
|
175
|
+
"""Serialize a Loopfile back to a dict (for trace emission)."""
|
|
176
|
+
return {
|
|
177
|
+
"LOOPFILE": loopfile.spec_version,
|
|
178
|
+
"name": loopfile.name,
|
|
179
|
+
"version": loopfile.version,
|
|
180
|
+
"description": loopfile.description,
|
|
181
|
+
"OBJECTIVE": loopfile.objective,
|
|
182
|
+
"AGENTS": [
|
|
183
|
+
{"name": a.name, "role": a.role, "model_tier": a.model_tier, "tools": a.tools}
|
|
184
|
+
for a in loopfile.agents
|
|
185
|
+
],
|
|
186
|
+
"STEPS": [
|
|
187
|
+
{
|
|
188
|
+
"id": s.id, "name": s.name, "action": s.action, "uses": s.uses,
|
|
189
|
+
"produces": s.produces, "depends_on": s.depends_on, "retry": s.retry,
|
|
190
|
+
}
|
|
191
|
+
for s in loopfile.steps
|
|
192
|
+
],
|
|
193
|
+
"VERIFY": {
|
|
194
|
+
"syntactic": loopfile.verify.syntactic,
|
|
195
|
+
"semantic": loopfile.verify.semantic,
|
|
196
|
+
"confidence_threshold": loopfile.verify.confidence_threshold,
|
|
197
|
+
},
|
|
198
|
+
"BUDGET": {
|
|
199
|
+
"dollars": loopfile.budget.dollars,
|
|
200
|
+
"minutes": loopfile.budget.minutes,
|
|
201
|
+
"tokens": loopfile.budget.tokens,
|
|
202
|
+
},
|
|
203
|
+
"STOP_WHEN": loopfile.stop_when,
|
|
204
|
+
"LESSONS": loopfile.lessons,
|
|
205
|
+
"STATE": loopfile.state,
|
|
206
|
+
"ENGINE": loopfile.engine,
|
|
207
|
+
}
|