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/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
+ }