levain-cli 0.1.18__tar.gz → 0.1.20__tar.gz

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.
Files changed (27) hide show
  1. {levain_cli-0.1.18 → levain_cli-0.1.20}/.gitignore +1 -0
  2. {levain_cli-0.1.18 → levain_cli-0.1.20}/PKG-INFO +26 -12
  3. {levain_cli-0.1.18 → levain_cli-0.1.20}/README.md +25 -11
  4. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/__init__.py +1 -1
  5. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/_format.py +10 -2
  6. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/cli.py +34 -21
  7. levain_cli-0.1.20/levain_recipe/harness.py +346 -0
  8. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/mcp.py +8 -4
  9. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/models.py +72 -0
  10. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/user_input.py +13 -4
  11. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/validate.py +23 -0
  12. {levain_cli-0.1.18 → levain_cli-0.1.20}/pyproject.toml +1 -1
  13. {levain_cli-0.1.18 → levain_cli-0.1.20}/LICENSE +0 -0
  14. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/__main__.py +0 -0
  15. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/_java.py +0 -0
  16. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/auth.py +0 -0
  17. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/codegen.py +0 -0
  18. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/graph.py +0 -0
  19. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/integrations_check.py +0 -0
  20. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/known_recipes.py +0 -0
  21. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/parser.py +0 -0
  22. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/profile.py +0 -0
  23. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/py.typed +0 -0
  24. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/reducers.py +0 -0
  25. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/source_check.py +0 -0
  26. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/tlc.py +0 -0
  27. {levain_cli-0.1.18 → levain_cli-0.1.20}/levain_recipe/translate.py +0 -0
@@ -69,6 +69,7 @@ skills-lock.json
69
69
  .claude/skills/*
70
70
  !.claude/skills/check-pr/
71
71
  !.claude/skills/weekly-update/
72
+ !.claude/skills/cost-review/
72
73
 
73
74
  # Knead orchestrator state
74
75
  .knead/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: levain-cli
3
- Version: 0.1.18
3
+ Version: 0.1.20
4
4
  Summary: Levain recipe toolchain: PlusCal validator, TLC model checking, and GraphState codegen for agent recipes
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -17,21 +17,31 @@ Description-Content-Type: text/markdown
17
17
  # levain-cli
18
18
 
19
19
  The open toolchain for authoring [Levain](https://levainlabs.com) agent
20
- recipes: a validator, model checker, and code generator for the PlusCal
21
- recipe format that Levain agents are defined in.
20
+ recipes: a validator, model checker, and code generator for the recipe
21
+ formats that Levain agents are defined in.
22
22
 
23
- A recipe package is a `graph.tla` PlusCal spec (the agent's node graph
24
- and state) plus a Python package with one function per node. This tool
25
- checks both halves and keeps them coherent:
23
+ A recipe package is one of two kinds, set by `kind` under `[tool.levain]`
24
+ in its `pyproject.toml`:
26
25
 
27
- - **validate** — parse the spec, enforce the recipe grammar profile,
26
+ - **graph** (the default): a `graph.tla` PlusCal spec (the agent's node
27
+ graph and state) plus a Python package with one function per node.
28
+ - **harness**: one engine session (Claude Code or Hermes) declared in
29
+ `[tool.levain.harness]`: engine, model, effort, system prompt, tools,
30
+ MCP servers and state fields. No graph and no Python.
31
+
32
+ This tool checks both and keeps a graph's two halves coherent:
33
+
34
+ - **validate** — parse a spec, enforce the recipe grammar profile,
28
35
  check graph topology, translate PlusCal to TLA+, and model-check the
29
36
  result with TLC (deadlock and termination).
30
- - **validate-package** — all of the above, plus Python source
31
- cross-checks (every `@node` function exists and is importable) and
32
- `pyproject.toml` checks.
37
+ - **validate-package** — validate a package of either kind. For a
38
+ graph: all of the above, plus Python source cross-checks (every
39
+ `@node` function exists and is importable) and `pyproject.toml`
40
+ checks. For a harness: the `[tool.levain.harness]` keys and values,
41
+ the prompt file, the state fields and the MCP server names.
33
42
  - **generate-state** — regenerate `_state.py`, the typed `GraphState`
34
- TypedDict node functions program against.
43
+ TypedDict a graph's node functions program against. A harness has
44
+ no state module.
35
45
 
36
46
  ## Install
37
47
 
@@ -58,9 +68,13 @@ The import package is `levain_recipe` — use it to lint recipes from
58
68
  your own CI or scripts:
59
69
 
60
70
  ```python
61
- from levain_recipe.validate import validate_fast
71
+ from levain_recipe.validate import validate_fast, validate_recipe
62
72
  ```
63
73
 
74
+ `validate_recipe(dir)` reads the package's kind and returns a
75
+ `ValidatedSpec` for a graph or a `HarnessSpec` for a harness, or a list
76
+ of errors.
77
+
64
78
  Validation errors are prescriptive (`[stage] line N: message`, with
65
79
  TLC counterexample traces) — they are the recipe format's primary
66
80
  teaching interface, for humans and agents alike.
@@ -1,21 +1,31 @@
1
1
  # levain-cli
2
2
 
3
3
  The open toolchain for authoring [Levain](https://levainlabs.com) agent
4
- recipes: a validator, model checker, and code generator for the PlusCal
5
- recipe format that Levain agents are defined in.
4
+ recipes: a validator, model checker, and code generator for the recipe
5
+ formats that Levain agents are defined in.
6
6
 
7
- A recipe package is a `graph.tla` PlusCal spec (the agent's node graph
8
- and state) plus a Python package with one function per node. This tool
9
- checks both halves and keeps them coherent:
7
+ A recipe package is one of two kinds, set by `kind` under `[tool.levain]`
8
+ in its `pyproject.toml`:
10
9
 
11
- - **validate** — parse the spec, enforce the recipe grammar profile,
10
+ - **graph** (the default): a `graph.tla` PlusCal spec (the agent's node
11
+ graph and state) plus a Python package with one function per node.
12
+ - **harness**: one engine session (Claude Code or Hermes) declared in
13
+ `[tool.levain.harness]`: engine, model, effort, system prompt, tools,
14
+ MCP servers and state fields. No graph and no Python.
15
+
16
+ This tool checks both and keeps a graph's two halves coherent:
17
+
18
+ - **validate** — parse a spec, enforce the recipe grammar profile,
12
19
  check graph topology, translate PlusCal to TLA+, and model-check the
13
20
  result with TLC (deadlock and termination).
14
- - **validate-package** — all of the above, plus Python source
15
- cross-checks (every `@node` function exists and is importable) and
16
- `pyproject.toml` checks.
21
+ - **validate-package** — validate a package of either kind. For a
22
+ graph: all of the above, plus Python source cross-checks (every
23
+ `@node` function exists and is importable) and `pyproject.toml`
24
+ checks. For a harness: the `[tool.levain.harness]` keys and values,
25
+ the prompt file, the state fields and the MCP server names.
17
26
  - **generate-state** — regenerate `_state.py`, the typed `GraphState`
18
- TypedDict node functions program against.
27
+ TypedDict a graph's node functions program against. A harness has
28
+ no state module.
19
29
 
20
30
  ## Install
21
31
 
@@ -42,9 +52,13 @@ The import package is `levain_recipe` — use it to lint recipes from
42
52
  your own CI or scripts:
43
53
 
44
54
  ```python
45
- from levain_recipe.validate import validate_fast
55
+ from levain_recipe.validate import validate_fast, validate_recipe
46
56
  ```
47
57
 
58
+ `validate_recipe(dir)` reads the package's kind and returns a
59
+ `ValidatedSpec` for a graph or a `HarnessSpec` for a harness, or a list
60
+ of errors.
61
+
48
62
  Validation errors are prescriptive (`[stage] line N: message`, with
49
63
  TLC counterexample traces) — they are the recipe format's primary
50
64
  teaching interface, for humans and agents alike.
@@ -17,4 +17,4 @@ avoid them.
17
17
  # or annotation vocabulary changes, independently of tool
18
18
  # releases. The API's toolchain handshake compares this against
19
19
  # the server's copy.
20
- LANGUAGE_VERSION = "1.0"
20
+ LANGUAGE_VERSION = "1.1"
@@ -8,7 +8,7 @@ language-version bump.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from .models import ValidatedSpec, ValidationError
11
+ from .models import HarnessSpec, RecipeSpec, ValidationError
12
12
 
13
13
 
14
14
  def format_error(err: ValidationError) -> str:
@@ -31,8 +31,16 @@ def format_errors(errors: list[ValidationError]) -> str:
31
31
  return f"{header}\n{body}\n\nFix these errors and regenerate the spec."
32
32
 
33
33
 
34
- def format_success(spec: ValidatedSpec) -> str:
34
+ def format_success(spec: RecipeSpec) -> str:
35
35
  """Format the success summary."""
36
+ if isinstance(spec, HarnessSpec):
37
+ fields = ", ".join(v.name for v in spec.variables)
38
+ return (
39
+ f"VALIDATION PASSED\n\n"
40
+ f"Kind: harness\n"
41
+ f"Engine: {spec.engine} ({spec.model})\n"
42
+ f"State: {fields}"
43
+ )
36
44
  nodes = ", ".join(n.label for n in spec.nodes)
37
45
  transitions = sum(len(r.routes) for r in spec.routing)
38
46
  invariants = ", ".join(spec.invariants) if spec.invariants else "(none)"
@@ -23,7 +23,8 @@ from . import LANGUAGE_VERSION
23
23
  from ._format import format_errors, format_success
24
24
  from ._java import ensure_jar
25
25
  from .codegen import generate_state_module, state_module_signature
26
- from .validate import validate, validate_fast, validate_package
26
+ from .models import ValidatedSpec
27
+ from .validate import validate, validate_fast, validate_recipe
27
28
 
28
29
  app = typer.Typer(
29
30
  name="levain",
@@ -111,7 +112,7 @@ def validate_package_cmd(
111
112
  typer.Argument(help="Path to recipe package directory"),
112
113
  ],
113
114
  ) -> None:
114
- """Validate a full recipe package (graph.tla + Python source)."""
115
+ """Validate a recipe package (a graph with its Python source, or a harness)."""
115
116
  if not recipe_dir.is_dir():
116
117
  print(
117
118
  f"Error: not a directory: {recipe_dir}",
@@ -120,7 +121,7 @@ def validate_package_cmd(
120
121
  raise typer.Exit(2)
121
122
 
122
123
  _prepare_tlc()
123
- result = validate_package(str(recipe_dir))
124
+ result = validate_recipe(str(recipe_dir))
124
125
  if isinstance(result, list):
125
126
  print(format_errors(result))
126
127
  raise typer.Exit(1)
@@ -130,27 +131,33 @@ def validate_package_cmd(
130
131
  # matches. The comparison is structural (field names, types,
131
132
  # reducers) so formatter rewraps and doc metadata don't trip
132
133
  # it. Read-only on purpose — ``generate-state`` writes.
133
- state_path = recipe_dir / "src" / result.module_name / "_state.py"
134
- if state_path.parent.is_dir():
135
- expected = state_module_signature(generate_state_module(result))
136
- current = (
137
- state_module_signature(state_path.read_text(encoding="utf-8"))
138
- if state_path.is_file()
139
- else None
140
- )
141
- if current != expected:
142
- what = "missing" if current is None else "stale"
143
- print(
144
- f"Error: {state_path.relative_to(recipe_dir)} is {what} — "
145
- f"run `levain generate-state {recipe_dir}` and commit "
146
- f"the result.",
147
- file=sys.stderr,
148
- )
149
- raise typer.Exit(1)
134
+ if isinstance(result, ValidatedSpec):
135
+ _check_state_module(recipe_dir, result)
150
136
 
151
137
  print(format_success(result))
152
138
 
153
139
 
140
+ def _check_state_module(recipe_dir: Path, result: ValidatedSpec) -> None:
141
+ state_path = recipe_dir / "src" / result.module_name / "_state.py"
142
+ if not state_path.parent.is_dir():
143
+ return
144
+ expected = state_module_signature(generate_state_module(result))
145
+ current = (
146
+ state_module_signature(state_path.read_text(encoding="utf-8"))
147
+ if state_path.is_file()
148
+ else None
149
+ )
150
+ if current != expected:
151
+ what = "missing" if current is None else "stale"
152
+ print(
153
+ f"Error: {state_path.relative_to(recipe_dir)} is {what} — "
154
+ f"run `levain generate-state {recipe_dir}` and commit "
155
+ f"the result.",
156
+ file=sys.stderr,
157
+ )
158
+ raise typer.Exit(1)
159
+
160
+
154
161
  @app.command("generate-state")
155
162
  def generate_state_cmd(
156
163
  recipe_dir: Annotated[
@@ -171,10 +178,16 @@ def generate_state_cmd(
171
178
  raise typer.Exit(2)
172
179
 
173
180
  _prepare_tlc()
174
- result = validate_package(str(recipe_dir))
181
+ result = validate_recipe(str(recipe_dir))
175
182
  if isinstance(result, list):
176
183
  print(format_errors(result))
177
184
  raise typer.Exit(1)
185
+ if not isinstance(result, ValidatedSpec):
186
+ print(
187
+ "Error: a harness recipe has no Python state module.",
188
+ file=sys.stderr,
189
+ )
190
+ raise typer.Exit(2)
178
191
 
179
192
  src_dir = recipe_dir / "src" / result.module_name
180
193
  if not src_dir.is_dir():
@@ -0,0 +1,346 @@
1
+ """Harness recipe packages: one engine session, declared in ``pyproject.toml``.
2
+
3
+ A harness recipe has no ``graph.tla`` and no node functions. Its
4
+ ``pyproject.toml`` carries::
5
+
6
+ [tool.levain]
7
+ kind = "harness"
8
+
9
+ [tool.levain.harness]
10
+ engine = "claude_code" # or "hermes"
11
+ model = "claude-sonnet-4-6"
12
+ effort = "high" # optional
13
+ prompt = "prompts/system.md" # optional system-prompt append
14
+ tools = ["Read", "Edit", "Bash"] # optional permission allowlist
15
+ mcp_servers = ["levain-wiki"] # optional, platform or attached names
16
+ permission_mode = "bypassPermissions"
17
+
18
+ [tool.levain.harness.state]
19
+ task = { type = "string", description = "What to work on" }
20
+ summary = "string" # shorthand: type only
21
+
22
+ Validation errors are prescriptive in the same ``[stage] message``
23
+ shape as the PlusCal pipeline, so the build agent and the CLI treat
24
+ both kinds alike.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import tomllib
31
+ from typing import Any
32
+
33
+ from .integrations_check import check_mcp_names
34
+ from .models import (
35
+ DEFAULT_PERMISSION_MODE,
36
+ DEFAULT_RECIPE_KIND,
37
+ EFFORT_LEVELS,
38
+ HARNESS_ENGINES,
39
+ MESSAGES_FIELD,
40
+ PERMISSION_MODES,
41
+ RECIPE_KINDS,
42
+ SPEC_TYPES,
43
+ HarnessSpec,
44
+ NodeAnnotation,
45
+ StateField,
46
+ ValidationError,
47
+ )
48
+ from .reducers import REDUCERS
49
+
50
+ _STAGE = "harness"
51
+ _KEYS = (
52
+ "engine",
53
+ "model",
54
+ "effort",
55
+ "prompt",
56
+ "tools",
57
+ "mcp_servers",
58
+ "permission_mode",
59
+ "state",
60
+ )
61
+ _FIELD_KEYS = ("type", "default", "description", "reducer")
62
+ _DEFAULTS: dict[str, Any] = {
63
+ "bool": False,
64
+ "string": "",
65
+ "int": 0,
66
+ "list": [],
67
+ }
68
+
69
+
70
+ def _err(message: str) -> ValidationError:
71
+ return ValidationError(stage=_STAGE, message=message)
72
+
73
+
74
+ def read_pyproject(recipe_dir: str) -> dict[str, Any] | list[ValidationError]:
75
+ path = os.path.join(recipe_dir, "pyproject.toml")
76
+ if not os.path.isfile(path):
77
+ return [
78
+ ValidationError(
79
+ stage="source",
80
+ message="pyproject.toml not found in recipe directory.",
81
+ )
82
+ ]
83
+ try:
84
+ with open(path, "rb") as f:
85
+ return tomllib.load(f)
86
+ except tomllib.TOMLDecodeError as exc:
87
+ return [
88
+ ValidationError(
89
+ stage="source",
90
+ message=f"pyproject.toml is not valid TOML: {exc}",
91
+ )
92
+ ]
93
+
94
+
95
+ def _levain_table(data: dict[str, Any]) -> dict[str, Any]:
96
+ tool = data.get("tool")
97
+ levain = tool.get("levain") if isinstance(tool, dict) else None
98
+ return levain if isinstance(levain, dict) else {}
99
+
100
+
101
+ def recipe_kind(recipe_dir: str) -> str | list[ValidationError]:
102
+ """The package's declared kind; ``graph`` when nothing says otherwise.
103
+
104
+ A missing ``pyproject.toml`` reads as ``graph`` so the PlusCal
105
+ pipeline reports it with its own, more specific error.
106
+ """
107
+ data = read_pyproject(recipe_dir)
108
+ if isinstance(data, list):
109
+ return DEFAULT_RECIPE_KIND
110
+ kind = _levain_table(data).get("kind", DEFAULT_RECIPE_KIND)
111
+ if kind not in RECIPE_KINDS:
112
+ return [
113
+ _err(
114
+ f"[tool.levain] kind = {kind!r} is not a recipe kind."
115
+ f" Known: {', '.join(RECIPE_KINDS)}."
116
+ )
117
+ ]
118
+ return str(kind)
119
+
120
+
121
+ def load_harness_spec(
122
+ recipe_dir: str,
123
+ *,
124
+ attached_mcp_names: list[str] | None = None,
125
+ ) -> HarnessSpec | list[ValidationError]:
126
+ """Read and validate ``[tool.levain.harness]``.
127
+
128
+ ``attached_mcp_names`` has the same contract as the PlusCal
129
+ pipeline's MCP-name check (see ``integrations_check``).
130
+ """
131
+ data = read_pyproject(recipe_dir)
132
+ if isinstance(data, list):
133
+ return data
134
+ table = _levain_table(data).get("harness")
135
+ if not isinstance(table, dict):
136
+ return [
137
+ _err(
138
+ "[tool.levain.harness] table is missing. A harness recipe"
139
+ " declares at least `engine` and `model` there."
140
+ )
141
+ ]
142
+
143
+ errors: list[ValidationError] = []
144
+ for key in table:
145
+ if key not in _KEYS:
146
+ errors.append(
147
+ _err(
148
+ f"[tool.levain.harness] has no key {key!r}."
149
+ f" Legal keys: {', '.join(_KEYS)}."
150
+ )
151
+ )
152
+
153
+ engine = table.get("engine")
154
+ if engine not in HARNESS_ENGINES:
155
+ errors.append(
156
+ _err(
157
+ f"engine = {engine!r} is not an engine."
158
+ f" Known: {', '.join(HARNESS_ENGINES)}."
159
+ )
160
+ )
161
+
162
+ model = table.get("model")
163
+ if not isinstance(model, str) or not model.strip():
164
+ errors.append(_err("model must be a non-empty string."))
165
+
166
+ effort = table.get("effort")
167
+ if effort is not None and effort not in EFFORT_LEVELS:
168
+ errors.append(
169
+ _err(
170
+ f"effort = {effort!r} is not an effort level."
171
+ f" Known: {', '.join(EFFORT_LEVELS)}."
172
+ )
173
+ )
174
+
175
+ prompt = table.get("prompt")
176
+ if prompt is not None:
177
+ if not isinstance(prompt, str) or not prompt.strip():
178
+ errors.append(
179
+ _err("prompt must be a path relative to the package.")
180
+ )
181
+ elif os.path.isabs(prompt) or ".." in prompt.split(os.sep):
182
+ errors.append(
183
+ _err(f"prompt = {prompt!r} must stay inside the package.")
184
+ )
185
+ elif not os.path.isfile(os.path.join(recipe_dir, prompt)):
186
+ errors.append(_err(f"prompt file {prompt!r} not found."))
187
+
188
+ tools = table.get("tools")
189
+ if tools is not None and not _is_str_list(tools):
190
+ errors.append(_err("tools must be a list of tool names."))
191
+
192
+ mcp_servers = table.get("mcp_servers", [])
193
+ if not _is_str_list(mcp_servers):
194
+ errors.append(_err("mcp_servers must be a list of server names."))
195
+ mcp_servers = []
196
+
197
+ permission_mode = table.get("permission_mode", DEFAULT_PERMISSION_MODE)
198
+ if permission_mode not in PERMISSION_MODES:
199
+ errors.append(
200
+ _err(
201
+ f"permission_mode = {permission_mode!r} is not a mode."
202
+ f" Known: {', '.join(PERMISSION_MODES)}."
203
+ )
204
+ )
205
+
206
+ variables, field_errors = _state_fields(table.get("state", {}))
207
+ errors.extend(field_errors)
208
+
209
+ errors.extend(
210
+ check_mcp_names(
211
+ [
212
+ NodeAnnotation(
213
+ label="Session",
214
+ function=str(engine),
215
+ args={"mcp_servers": list(mcp_servers)},
216
+ )
217
+ ],
218
+ attached_mcp_names=attached_mcp_names,
219
+ )
220
+ )
221
+ if errors:
222
+ return errors
223
+ return HarnessSpec(
224
+ engine=str(engine),
225
+ model=str(model).strip(),
226
+ variables=variables,
227
+ effort=effort,
228
+ prompt=prompt,
229
+ tools=list(tools) if tools is not None else None,
230
+ mcp_servers=list(mcp_servers),
231
+ permission_mode=str(permission_mode),
232
+ )
233
+
234
+
235
+ def _is_str_list(value: Any) -> bool:
236
+ return isinstance(value, list) and all(
237
+ isinstance(v, str) and v.strip() for v in value
238
+ )
239
+
240
+
241
+ def _state_fields(
242
+ raw: Any,
243
+ ) -> tuple[list[StateField], list[ValidationError]]:
244
+ fields = [
245
+ StateField(
246
+ name=MESSAGES_FIELD,
247
+ type="list",
248
+ default=[],
249
+ reducer="add_messages",
250
+ description="The conversation.",
251
+ )
252
+ ]
253
+ if not isinstance(raw, dict):
254
+ return fields, [_err("[tool.levain.harness.state] must be a table.")]
255
+ errors: list[ValidationError] = []
256
+ for name, entry in raw.items():
257
+ spec = {"type": entry} if isinstance(entry, str) else entry
258
+ where = f"state.{name}"
259
+ if name == MESSAGES_FIELD:
260
+ errors.append(
261
+ _err(
262
+ f"{where}: `{MESSAGES_FIELD}` is implicit; do not declare it."
263
+ )
264
+ )
265
+ continue
266
+ if name.startswith("_"):
267
+ errors.append(
268
+ _err(
269
+ f"{where}: names prefixed with '_' are reserved"
270
+ " for platform metadata."
271
+ )
272
+ )
273
+ continue
274
+ if not isinstance(spec, dict):
275
+ errors.append(
276
+ _err(
277
+ f"{where}: declare a field as a type name or a table"
278
+ f" with keys {', '.join(_FIELD_KEYS)}."
279
+ )
280
+ )
281
+ continue
282
+ unknown = [k for k in spec if k not in _FIELD_KEYS]
283
+ if unknown:
284
+ errors.append(
285
+ _err(
286
+ f"{where}: unknown key(s) {', '.join(unknown)}."
287
+ f" Legal: {', '.join(_FIELD_KEYS)}."
288
+ )
289
+ )
290
+ continue
291
+ vtype = spec.get("type")
292
+ if vtype not in SPEC_TYPES:
293
+ errors.append(
294
+ _err(
295
+ f"{where}: type = {vtype!r} is not a state type."
296
+ f" Known: {', '.join(SPEC_TYPES)}."
297
+ )
298
+ )
299
+ continue
300
+ reducer = spec.get("reducer")
301
+ if reducer is not None and reducer not in REDUCERS:
302
+ errors.append(
303
+ _err(
304
+ f"{where}: unknown reducer {reducer!r}."
305
+ f" Known: {', '.join(sorted(REDUCERS))}."
306
+ )
307
+ )
308
+ continue
309
+ if vtype == "list" and reducer is None:
310
+ errors.append(
311
+ _err(
312
+ f"{where}: a list field must declare a reducer"
313
+ " (the default last-write-wins drops accumulated items)."
314
+ )
315
+ )
316
+ continue
317
+ if vtype != "list" and reducer is not None:
318
+ errors.append(
319
+ _err(f"{where}: reducer is only valid on list fields.")
320
+ )
321
+ continue
322
+ default = spec.get("default", _DEFAULTS[vtype])
323
+ if not _default_fits(default, vtype):
324
+ errors.append(
325
+ _err(f"{where}: default {default!r} is not a {vtype}.")
326
+ )
327
+ continue
328
+ description = spec.get("description")
329
+ fields.append(
330
+ StateField(
331
+ name=str(name),
332
+ type=str(vtype),
333
+ default=default,
334
+ reducer=reducer,
335
+ description=(
336
+ str(description) if isinstance(description, str) else None
337
+ ),
338
+ )
339
+ )
340
+ return fields, errors
341
+
342
+
343
+ def _default_fits(value: Any, vtype: str) -> bool:
344
+ if vtype == "int":
345
+ return isinstance(value, int) and not isinstance(value, bool)
346
+ return isinstance(value, SPEC_TYPES[vtype])
@@ -15,7 +15,9 @@ from fastmcp import FastMCP
15
15
  from ._format import format_errors, format_success
16
16
  from ._java import ensure_jar
17
17
  from .codegen import generate_state_module
18
- from .validate import validate, validate_fast, validate_package
18
+ from .models import ValidatedSpec
19
+ from .validate import validate, validate_fast
20
+ from .validate import validate_recipe as validate_recipe_dir
19
21
 
20
22
  server = FastMCP("levain-recipe")
21
23
 
@@ -48,7 +50,7 @@ def validate_recipe(path: str) -> str:
48
50
 
49
51
  @server.tool()
50
52
  def validate_recipe_package(recipe_dir: str) -> str:
51
- """Validate a full recipe package (graph.tla + Python source)."""
53
+ """Validate a recipe package (a graph with its Python source, or a harness)."""
52
54
  if not Path(recipe_dir).is_dir():
53
55
  return f"Error: not a directory: {recipe_dir}"
54
56
 
@@ -57,7 +59,7 @@ def validate_recipe_package(recipe_dir: str) -> str:
57
59
  ensure_jar()
58
60
  else:
59
61
  note = _NO_JAVA_NOTE
60
- result = validate_package(recipe_dir)
62
+ result = validate_recipe_dir(recipe_dir)
61
63
  if isinstance(result, list):
62
64
  return note + format_errors(result)
63
65
  return note + format_success(result)
@@ -72,9 +74,11 @@ def generate_state(recipe_dir: str) -> str:
72
74
 
73
75
  if shutil.which("java"):
74
76
  ensure_jar()
75
- result = validate_package(recipe_dir)
77
+ result = validate_recipe_dir(recipe_dir)
76
78
  if isinstance(result, list):
77
79
  return format_errors(result)
80
+ if not isinstance(result, ValidatedSpec):
81
+ return "Error: a harness recipe has no Python state module."
78
82
 
79
83
  src_dir = root / "src" / result.module_name
80
84
  if not src_dir.is_dir():
@@ -5,6 +5,7 @@ and sandbox modules. Other modules should import from here
5
5
  rather than defining their own copies.
6
6
  """
7
7
 
8
+ import dataclasses
8
9
  from dataclasses import dataclass, field
9
10
  from typing import Any
10
11
 
@@ -144,3 +145,74 @@ class ValidationError:
144
145
  message: str
145
146
  line: int | None = None
146
147
  counterexample: list[dict[str, Any]] | None = None
148
+
149
+
150
+ # Recipe kinds. A ``graph`` package is a PlusCal spec plus node
151
+ # functions; a ``harness`` package is one engine session with no
152
+ # graph. Read from ``[tool.levain] kind`` in ``pyproject.toml``.
153
+ RECIPE_KINDS = ("graph", "harness")
154
+ DEFAULT_RECIPE_KIND = "graph"
155
+
156
+ HARNESS_ENGINES = ("claude_code", "hermes")
157
+ EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
158
+ PERMISSION_MODES = ("default", "acceptEdits", "plan", "bypassPermissions")
159
+ DEFAULT_PERMISSION_MODE = "bypassPermissions"
160
+
161
+ # The conversation field every harness recipe carries; declared
162
+ # implicitly so the platform's message projection, A2A output and
163
+ # post-run hooks see the same shape as a graph recipe's state.
164
+ MESSAGES_FIELD = "messages"
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class HarnessSpec:
169
+ """A validated harness recipe: one engine session per platform session.
170
+
171
+ ``variables`` holds the declared state fields plus the implicit
172
+ ``messages`` field. ``prompt`` is a path relative to the package
173
+ root; ``tools`` is the engine's permission allowlist (``None``
174
+ keeps the engine's default toolset).
175
+ """
176
+
177
+ engine: str
178
+ model: str
179
+ variables: list[StateField]
180
+ effort: str | None = None
181
+ prompt: str | None = None
182
+ tools: list[str] | None = None
183
+ mcp_servers: list[str] = field(default_factory=list)
184
+ permission_mode: str = DEFAULT_PERMISSION_MODE
185
+
186
+
187
+ RecipeSpec = ValidatedSpec | HarnessSpec
188
+
189
+
190
+ def recipe_kind_of(spec: RecipeSpec) -> str:
191
+ return "harness" if isinstance(spec, HarnessSpec) else "graph"
192
+
193
+
194
+ def graph_payload(spec: ValidatedSpec) -> dict[str, Any]:
195
+ """The spec in its runtime shape: what the sandbox graph engine builds.
196
+
197
+ Plain JSON (the turn params carry it), so the sandbox needs none of
198
+ this package to run a graph; the spec's source and invariants stay
199
+ behind, they are the validator's.
200
+ """
201
+ return {
202
+ "variables": [dataclasses.asdict(v) for v in spec.variables],
203
+ "nodes": [dataclasses.asdict(n) for n in spec.nodes],
204
+ "routing": [dataclasses.asdict(t) for t in spec.routing],
205
+ "constants": dict(spec.constants),
206
+ }
207
+
208
+
209
+ def mcp_server_names(spec: RecipeSpec) -> set[str]:
210
+ """Every MCP server name the recipe declares, whichever its kind."""
211
+ if isinstance(spec, HarnessSpec):
212
+ return set(spec.mcp_servers)
213
+ names: set[str] = set()
214
+ for node in spec.nodes:
215
+ servers = node.args.get("mcp_servers")
216
+ if isinstance(servers, list):
217
+ names.update(s for s in servers if isinstance(s, str))
218
+ return names
@@ -25,7 +25,12 @@ from __future__ import annotations
25
25
  from collections.abc import Callable
26
26
  from typing import Any, Literal, NotRequired, TypedDict
27
27
 
28
- from .models import INTERRUPT_BUILTIN, StateField, ValidatedSpec
28
+ from .models import (
29
+ INTERRUPT_BUILTIN,
30
+ HarnessSpec,
31
+ RecipeSpec,
32
+ StateField,
33
+ )
29
34
  from .reducers import REDUCERS
30
35
 
31
36
  # Each kind names a JSON-shape the API knows how to validate and
@@ -75,8 +80,12 @@ class UserInputSchema(TypedDict):
75
80
  interrupting: bool
76
81
 
77
82
 
78
- def user_input_schema(spec: ValidatedSpec) -> UserInputSchema:
79
- """Project a ``ValidatedSpec`` to its user-input schema."""
83
+ def user_input_schema(spec: RecipeSpec) -> UserInputSchema:
84
+ """Project a recipe spec to its user-input schema.
85
+
86
+ A harness recipe is a conversation: every turn ends waiting for
87
+ the next message, so it is always ``interrupting``.
88
+ """
80
89
  fields: dict[str, FieldSchema] = {}
81
90
  for var in spec.variables:
82
91
  if var.name.startswith("_"):
@@ -85,7 +94,7 @@ def user_input_schema(spec: ValidatedSpec) -> UserInputSchema:
85
94
  if descr is None:
86
95
  continue
87
96
  fields[var.name] = descr
88
- interrupting = any(
97
+ interrupting = isinstance(spec, HarnessSpec) or any(
89
98
  node.function == INTERRUPT_BUILTIN for node in spec.nodes
90
99
  )
91
100
  return {"fields": fields, "interrupting": interrupting}
@@ -14,6 +14,7 @@ from typing import cast
14
14
 
15
15
  from ._java import resolve_jar
16
16
  from .graph import check_graph
17
+ from .harness import load_harness_spec, recipe_kind
17
18
  from .integrations_check import check_mcp_names
18
19
  from .models import (
19
20
  CALL_AGENT_BUILTIN,
@@ -21,6 +22,7 @@ from .models import (
21
22
  INTERRUPTIBLE_CLASSES,
22
23
  INTERRUPTIBLE_KEY,
23
24
  NodeAnnotation,
25
+ RecipeSpec,
24
26
  RoutingTable,
25
27
  StateField,
26
28
  ValidatedSpec,
@@ -383,6 +385,27 @@ def validate_package(
383
385
  return result
384
386
 
385
387
 
388
+ def validate_recipe(
389
+ recipe_dir: str,
390
+ *,
391
+ attached_mcp_names: list[str] | None = None,
392
+ ) -> RecipeSpec | list[ValidationError]:
393
+ """Validate a recipe package of either kind.
394
+
395
+ The one entry point callers should use: it reads the package's
396
+ ``[tool.levain] kind`` and runs the PlusCal pipeline for a graph
397
+ or the harness loader for a harness recipe.
398
+ """
399
+ kind = recipe_kind(recipe_dir)
400
+ if isinstance(kind, list):
401
+ return kind
402
+ if kind == "harness":
403
+ return load_harness_spec(
404
+ recipe_dir, attached_mcp_names=attached_mcp_names
405
+ )
406
+ return validate_package(recipe_dir, attached_mcp_names=attached_mcp_names)
407
+
408
+
386
409
  def _require_tlc() -> bool:
387
410
  """Whether deployed services must run full TLC validation.
388
411
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "levain-cli"
3
- version = "0.1.18"
3
+ version = "0.1.20"
4
4
  description = "Levain recipe toolchain: PlusCal validator, TLC model checking, and GraphState codegen for agent recipes"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes