levain-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.
@@ -0,0 +1,66 @@
1
+ r"""Blessed reducers for recipe state fields.
2
+
3
+ LangGraph applies a reducer when a node returns an update for a
4
+ state field. The default merges by overwrite ("last write wins"),
5
+ which is wrong for accumulating lists — messages, logs, any
6
+ append-only channel. Recipes opt into a reducer by name via
7
+ ``\* @reducer <name>`` above a variable declaration; validation
8
+ rejects anything not in this registry.
9
+
10
+ Keeping the set small and named (rather than accepting arbitrary
11
+ dotted paths) keeps the DSL's surface area bounded: a customer
12
+ recipe can't reach into the platform by declaring
13
+ ``@reducer some.module:func``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ReducerSpec:
23
+ """Codegen info for a blessed reducer.
24
+
25
+ Deliberately metadata only — names and import lines, no
26
+ callables. Binding a name to the function LangGraph invokes is
27
+ the runtime's job (``levain_sdk.reducers.RUNTIME_REDUCERS``),
28
+ and keeping it there is what lets this package validate a
29
+ recipe without installing the agent runtime.
30
+
31
+ ``element_type_name`` is the type each list item takes in the
32
+ generated ``_state.py``; ``import_line`` is the single import
33
+ codegen writes when this reducer is used.
34
+ """
35
+
36
+ name: str
37
+ element_type_name: str
38
+ import_line: str
39
+
40
+
41
+ REDUCERS: dict[str, ReducerSpec] = {
42
+ "add_messages": ReducerSpec(
43
+ name="add_messages",
44
+ element_type_name="Message",
45
+ import_line="from levain_sdk.messages import Message, add_messages",
46
+ ),
47
+ "add_str": ReducerSpec(
48
+ name="add_str",
49
+ element_type_name="str",
50
+ import_line="from levain_sdk.reducers import add_str",
51
+ ),
52
+ "extend_list": ReducerSpec(
53
+ name="extend_list",
54
+ # ``dict[str, Any]`` rather than bare ``dict``: the latter
55
+ # trips mypy's ``type-arg`` rule on the generated
56
+ # ``_state.py``.
57
+ element_type_name="dict[str, Any]",
58
+ import_line="from levain_sdk.reducers import extend_list",
59
+ ),
60
+ }
61
+
62
+ if not all(k == v.name for k, v in REDUCERS.items()):
63
+ raise ValueError("REDUCERS dict key must equal ReducerSpec.name")
64
+
65
+
66
+ __all__ = ["REDUCERS", "ReducerSpec"]
@@ -0,0 +1,279 @@
1
+ """Python source validation for recipe packages.
2
+
3
+ Checks that @node function references in graph.tla resolve
4
+ to actual Python functions in the recipe's src/ directory.
5
+ Uses ast (no imports) for safe static analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ import re
13
+ import tomllib
14
+ from pathlib import Path
15
+
16
+ from .models import NodeAnnotation, ValidatedSpec, ValidationError
17
+
18
+ _SDK_PACKAGE = "levain-sdk"
19
+ _NORMALIZE_RE = re.compile(r"[-_.]+")
20
+
21
+
22
+ def check_python_sources(
23
+ spec: ValidatedSpec,
24
+ recipe_dir: str,
25
+ ) -> list[ValidationError]:
26
+ """Validate Python source references in a recipe package.
27
+
28
+ Checks:
29
+ 1. module:function refs resolve to files + functions
30
+ 2. Functions are async def with 2 parameters
31
+ 3. pyproject.toml exists and is valid
32
+ 4. pyproject.toml declares SDK dependency
33
+ 5. No orphan public ``async def`` functions in node-hosting
34
+ files — every top-level async function in a file referenced
35
+ by a ``@node`` annotation must itself be bound to a
36
+ ``@node``, or be marked private with a leading underscore.
37
+ Catches recipes that add Python functions for new behaviour
38
+ but forget to wire them into ``graph.tla``.
39
+ """
40
+ errors: list[ValidationError] = []
41
+ src_dir = os.path.join(recipe_dir, "src")
42
+
43
+ errors.extend(_check_pyproject(recipe_dir))
44
+ errors.extend(_check_function_refs(spec.nodes, src_dir))
45
+ errors.extend(_check_orphan_node_functions(spec.nodes, src_dir))
46
+
47
+ return errors
48
+
49
+
50
+ def _check_pyproject(recipe_dir: str) -> list[ValidationError]:
51
+ """Check pyproject.toml exists and declares SDK dependency."""
52
+ path = os.path.join(recipe_dir, "pyproject.toml")
53
+ if not os.path.isfile(path):
54
+ return [
55
+ ValidationError(
56
+ stage="source",
57
+ message="pyproject.toml not found in recipe directory.",
58
+ )
59
+ ]
60
+
61
+ try:
62
+ with open(path, "rb") as f:
63
+ data = tomllib.load(f)
64
+ except tomllib.TOMLDecodeError as exc:
65
+ return [
66
+ ValidationError(
67
+ stage="source",
68
+ message=f"pyproject.toml is not valid TOML: {exc}",
69
+ )
70
+ ]
71
+
72
+ deps = data.get("project", {}).get("dependencies", [])
73
+ has_sdk = any(
74
+ _NORMALIZE_RE.sub("-", re.split(r"[~><!=;\[ ]", d)[0].strip()).lower()
75
+ == _SDK_PACKAGE
76
+ for d in deps
77
+ )
78
+ if not has_sdk:
79
+ return [
80
+ ValidationError(
81
+ stage="source",
82
+ message=(
83
+ f"pyproject.toml does not declare "
84
+ f"'{_SDK_PACKAGE}' as a dependency."
85
+ ),
86
+ )
87
+ ]
88
+
89
+ return []
90
+
91
+
92
+ def _check_function_refs(
93
+ nodes: list[NodeAnnotation],
94
+ src_dir: str,
95
+ ) -> list[ValidationError]:
96
+ """Check that module:function refs resolve to Python code."""
97
+ errors: list[ValidationError] = []
98
+
99
+ for node in nodes:
100
+ func = node.function
101
+
102
+ # Bare names (no colon) are dispatcher-handled node types
103
+ # (e.g. claude_agent, shell, recipe) — not Python refs.
104
+ if ":" not in func:
105
+ continue
106
+
107
+ module_path, func_name = func.rsplit(":", 1)
108
+
109
+ # Resolve module path to file.
110
+ parts = module_path.split(".")
111
+ py_file = Path(src_dir, *parts).with_suffix(".py")
112
+
113
+ # Guard against path traversal escaping src/.
114
+ try:
115
+ py_file.resolve().relative_to(Path(src_dir).resolve())
116
+ except ValueError:
117
+ errors.append(
118
+ ValidationError(
119
+ stage="source",
120
+ message=(
121
+ f"Module path '{module_path}' "
122
+ f"escapes the src/ directory."
123
+ ),
124
+ )
125
+ )
126
+ continue
127
+
128
+ if not py_file.is_file():
129
+ errors.append(
130
+ ValidationError(
131
+ stage="source",
132
+ message=(
133
+ f"Module '{module_path}' not found "
134
+ f"at {py_file.relative_to(Path(src_dir).parent)}."
135
+ ),
136
+ )
137
+ )
138
+ continue
139
+
140
+ # Parse and check function exists.
141
+ try:
142
+ source = py_file.read_text(encoding="utf-8")
143
+ tree = ast.parse(source, filename=str(py_file))
144
+ except (SyntaxError, OSError) as exc:
145
+ errors.append(
146
+ ValidationError(
147
+ stage="source",
148
+ message=(f"Syntax error in {py_file.name}: {exc}"),
149
+ )
150
+ )
151
+ continue
152
+
153
+ func_node = _find_async_def(tree, func_name)
154
+ if func_node is None:
155
+ errors.append(
156
+ ValidationError(
157
+ stage="source",
158
+ message=(
159
+ f"Function '{func_name}' not found as "
160
+ f"a top-level async def in "
161
+ f"{py_file.name}."
162
+ ),
163
+ )
164
+ )
165
+ continue
166
+
167
+ # Check parameter count (state, config).
168
+ n_params = (
169
+ len(func_node.args.posonlyargs)
170
+ + len(func_node.args.args)
171
+ + len(func_node.args.kwonlyargs)
172
+ )
173
+ if n_params != 2:
174
+ errors.append(
175
+ ValidationError(
176
+ stage="source",
177
+ message=(
178
+ f"Function '{func_name}' in "
179
+ f"{py_file.name} has {n_params} "
180
+ f"parameter(s), expected 2 "
181
+ f"(state, config)."
182
+ ),
183
+ )
184
+ )
185
+
186
+ return errors
187
+
188
+
189
+ def _find_async_def(
190
+ tree: ast.Module,
191
+ name: str,
192
+ ) -> ast.AsyncFunctionDef | None:
193
+ """Find a top-level async def by name."""
194
+ for node in ast.iter_child_nodes(tree):
195
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == name:
196
+ return node
197
+ return None
198
+
199
+
200
+ def _check_orphan_node_functions(
201
+ nodes: list[NodeAnnotation],
202
+ src_dir: str,
203
+ ) -> list[ValidationError]:
204
+ """Flag public ``async def`` functions not bound to a ``@node``.
205
+
206
+ Recipes that add behaviour (new "nodes" in the agent sense)
207
+ must wire it through ``graph.tla`` — adding the function to
208
+ ``nodes.py`` alone leaves the recipe topology unchanged and
209
+ the new function as dead code.
210
+
211
+ The check is per-file: for each ``.py`` file referenced by
212
+ any ``@node`` annotation, collect the bound function names,
213
+ then flag any other top-level ``async def`` that doesn't
214
+ start with ``_`` (private convention). Files not hosting
215
+ any node binding are ignored — utility modules can have
216
+ arbitrary public async helpers.
217
+
218
+ The error message stays neutral: it reports the
219
+ inconsistency and lets the recipe author / build agent
220
+ decide whether the right fix is to wire the function in or
221
+ to remove it as dead code. We surface the gap; we don't
222
+ push a direction.
223
+ """
224
+ errors: list[ValidationError] = []
225
+
226
+ # file path -> set of bound function names from @node refs
227
+ bindings_per_file: dict[Path, set[str]] = {}
228
+ for node in nodes:
229
+ func = node.function
230
+ if ":" not in func:
231
+ continue
232
+ module_path, func_name = func.rsplit(":", 1)
233
+ py_file = Path(src_dir, *module_path.split(".")).with_suffix(".py")
234
+ try:
235
+ py_file.resolve().relative_to(Path(src_dir).resolve())
236
+ except ValueError:
237
+ continue
238
+ if not py_file.is_file():
239
+ continue
240
+ bindings_per_file.setdefault(py_file, set()).add(func_name)
241
+
242
+ for py_file, bound_names in bindings_per_file.items():
243
+ try:
244
+ tree = ast.parse(py_file.read_text(encoding="utf-8"))
245
+ except (SyntaxError, OSError):
246
+ # ``_check_function_refs`` already surfaced this; don't
247
+ # double-report.
248
+ continue
249
+ async_defs = {
250
+ n.name
251
+ for n in ast.iter_child_nodes(tree)
252
+ if isinstance(n, ast.AsyncFunctionDef)
253
+ }
254
+ # If any bound name is missing from the file,
255
+ # _check_function_refs already reported it — skip
256
+ # the orphan scan to avoid double-reporting every
257
+ # other function in the file as unbound.
258
+ if not bound_names.issubset(async_defs):
259
+ continue
260
+ for child in ast.iter_child_nodes(tree):
261
+ if not isinstance(child, ast.AsyncFunctionDef):
262
+ continue
263
+ if child.name.startswith("_"):
264
+ continue
265
+ if child.name in bound_names:
266
+ continue
267
+ errors.append(
268
+ ValidationError(
269
+ stage="source",
270
+ message=(
271
+ f"Async function '{child.name}' in "
272
+ f"{py_file.name} isn't bound to a @node "
273
+ f"annotation in graph.tla. Either wire it "
274
+ f"into the graph or remove it as dead code."
275
+ ),
276
+ )
277
+ )
278
+
279
+ return errors
levain_recipe/tlc.py ADDED
@@ -0,0 +1,236 @@
1
+ """Stage 5: TLC model checking via tla2tools.jar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ from typing import Any
10
+
11
+ from ._java import RE_MODULE_NAME, require_java, resolve_jar
12
+ from .models import ValidationError
13
+
14
+ _TLC_TIMEOUT_S = 60
15
+
16
+
17
+ def model_check(
18
+ translated_source: str,
19
+ module_name: str,
20
+ invariants: list[str],
21
+ ) -> list[ValidationError] | None:
22
+ """Run TLC on the translated TLA+ spec.
23
+
24
+ Parameters
25
+ ----------
26
+ translated_source
27
+ TLA+ source after PlusCal translation.
28
+ module_name
29
+ Module name for temp file naming.
30
+ invariants
31
+ Invariant names to check.
32
+
33
+ Returns
34
+ -------
35
+ None | list[ValidationError]
36
+ None on success, errors on failure.
37
+ """
38
+ if not RE_MODULE_NAME.match(module_name):
39
+ return [
40
+ ValidationError(
41
+ stage="tlc",
42
+ message="Invalid module name.",
43
+ )
44
+ ]
45
+
46
+ jar = resolve_jar()
47
+ if jar is None:
48
+ env_jar = os.environ.get("TLA2TOOLS_JAR")
49
+ if env_jar:
50
+ msg = f"TLA2TOOLS_JAR={env_jar!r} does not exist."
51
+ else:
52
+ msg = "tla2tools.jar not found. Set TLA2TOOLS_JAR env var."
53
+ return [ValidationError(stage="tlc", message=msg)]
54
+
55
+ java_or_err = require_java("tlc")
56
+ if isinstance(java_or_err, list):
57
+ return java_or_err
58
+ java = java_or_err
59
+
60
+ with tempfile.TemporaryDirectory() as tmpdir:
61
+ spec_path = os.path.join(tmpdir, f"{module_name}.tla")
62
+ cfg_path = os.path.join(tmpdir, f"{module_name}.cfg")
63
+
64
+ with open(spec_path, "w", encoding="utf-8") as f:
65
+ f.write(translated_source)
66
+
67
+ cfg = _generate_cfg(invariants)
68
+ with open(cfg_path, "w", encoding="utf-8") as f:
69
+ f.write(cfg)
70
+
71
+ try:
72
+ result = subprocess.run(
73
+ [
74
+ java,
75
+ "-Xmx512m",
76
+ "-jar",
77
+ jar,
78
+ "-config",
79
+ cfg_path,
80
+ "-deadlock",
81
+ spec_path,
82
+ ],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=_TLC_TIMEOUT_S,
86
+ cwd=tmpdir,
87
+ check=False,
88
+ )
89
+ except subprocess.TimeoutExpired:
90
+ return [
91
+ ValidationError(
92
+ stage="tlc",
93
+ message=(
94
+ "TLC model check timed out after "
95
+ f"{_TLC_TIMEOUT_S}s. The state "
96
+ "space may be too large."
97
+ ),
98
+ )
99
+ ]
100
+
101
+ return _parse_tlc_output(
102
+ result.stdout + result.stderr,
103
+ result.returncode,
104
+ )
105
+
106
+
107
+ def _generate_cfg(invariants: list[str]) -> str:
108
+ """Generate a TLC .cfg file.
109
+
110
+ Safety only — emits ``INVARIANT`` lines for each ``define``
111
+ block operator. Liveness (``PROPERTY Termination``) is
112
+ intentionally NOT checked here: under PlusCal's restricted
113
+ profile, TLA+ can't cleanly express "the agent eventually
114
+ converges", and a mechanical bound (``retry_count``) would
115
+ limit agents arbitrarily. Termination is instead enforced
116
+ at runtime by the orchestrator's budget/timeout guardrails.
117
+ """
118
+ lines = ["SPECIFICATION Spec", ""]
119
+
120
+ if invariants:
121
+ for inv in invariants:
122
+ lines.append(f"INVARIANT {inv}")
123
+ lines.append("")
124
+
125
+ return "\n".join(lines)
126
+
127
+
128
+ # ── TLC output parsing ──
129
+
130
+ _RE_ERROR = re.compile(r"Error:\s*(.*)")
131
+ _RE_INVARIANT = re.compile(r"Invariant\s+(\w+)\s+is violated")
132
+ _RE_STATE = re.compile(r"State\s+(\d+):\s*<(\w+)\s+line\s+(\d+)")
133
+ _RE_VAR_VAL = re.compile(r"/\\\s*(\w+)\s*=\s*(.*)")
134
+
135
+
136
+ def _parse_tlc_output(
137
+ output: str,
138
+ returncode: int,
139
+ ) -> list[ValidationError] | None:
140
+ """Parse TLC stdout/stderr for pass or failure."""
141
+ if returncode == 0 and "Model checking completed" in output:
142
+ return None
143
+
144
+ if returncode == 0 and "Model checking completed" not in output:
145
+ return [
146
+ ValidationError(
147
+ stage="tlc",
148
+ message=(
149
+ "TLC exited successfully but did not report completion."
150
+ ),
151
+ )
152
+ ]
153
+
154
+ errors: list[ValidationError] = []
155
+
156
+ # Check for invariant violations.
157
+ trace = _extract_trace(output) if _RE_INVARIANT.search(output) else None
158
+ for inv_match in _RE_INVARIANT.finditer(output):
159
+ errors.append(
160
+ ValidationError(
161
+ stage="tlc",
162
+ message=(f"Invariant '{inv_match.group(1)}' is violated."),
163
+ counterexample=trace or None,
164
+ )
165
+ )
166
+ if errors:
167
+ return errors
168
+
169
+ # Generic errors.
170
+ for m in _RE_ERROR.finditer(output):
171
+ errors.append(
172
+ ValidationError(
173
+ stage="tlc",
174
+ message=m.group(1).strip(),
175
+ )
176
+ )
177
+
178
+ if not errors and returncode != 0:
179
+ errors.append(
180
+ ValidationError(
181
+ stage="tlc",
182
+ message=(
183
+ f"TLC exited with code {returncode}: " + output[:200]
184
+ ),
185
+ )
186
+ )
187
+
188
+ return errors or None
189
+
190
+
191
+ def _extract_trace(
192
+ output: str,
193
+ ) -> list[dict[str, Any]]:
194
+ """Extract the counterexample trace from TLC output."""
195
+ states: list[dict[str, Any]] = []
196
+ current_state: dict[str, Any] = {}
197
+
198
+ for line in output.splitlines():
199
+ state_m = _RE_STATE.match(line.strip())
200
+ if state_m:
201
+ if current_state:
202
+ states.append(current_state)
203
+ current_state = {
204
+ "state_num": int(state_m.group(1)),
205
+ "action": state_m.group(2),
206
+ "line": int(state_m.group(3)),
207
+ "vars": {},
208
+ }
209
+ continue
210
+
211
+ var_m = _RE_VAR_VAL.match(line.strip())
212
+ if var_m and current_state:
213
+ name = var_m.group(1)
214
+ value = _parse_tlc_value(var_m.group(2).strip())
215
+ current_state["vars"][name] = value
216
+
217
+ if current_state:
218
+ states.append(current_state)
219
+
220
+ return states
221
+
222
+
223
+ def _parse_tlc_value(raw: str) -> Any:
224
+ """Best-effort parse of a TLC value."""
225
+ if raw == "TRUE":
226
+ return True
227
+ if raw == "FALSE":
228
+ return False
229
+ try:
230
+ return int(raw)
231
+ except ValueError:
232
+ pass
233
+ # Strip TLA+ string quotes.
234
+ if raw.startswith('"') and raw.endswith('"'):
235
+ return raw[1:-1]
236
+ return raw