levain-cli 0.1.18__tar.gz → 0.1.19__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.
- {levain_cli-0.1.18 → levain_cli-0.1.19}/.gitignore +1 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/PKG-INFO +26 -12
- {levain_cli-0.1.18 → levain_cli-0.1.19}/README.md +25 -11
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/__init__.py +1 -1
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/_format.py +10 -2
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/cli.py +34 -21
- levain_cli-0.1.19/levain_recipe/harness.py +346 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/mcp.py +8 -4
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/models.py +56 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/user_input.py +13 -4
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/validate.py +23 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/pyproject.toml +1 -1
- {levain_cli-0.1.18 → levain_cli-0.1.19}/LICENSE +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/__main__.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/_java.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/auth.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/codegen.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/graph.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/integrations_check.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/known_recipes.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/parser.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/profile.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/py.typed +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/reducers.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/source_check.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/tlc.py +0 -0
- {levain_cli-0.1.18 → levain_cli-0.1.19}/levain_recipe/translate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: levain-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.19
|
|
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
|
|
21
|
-
|
|
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
|
|
24
|
-
|
|
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
|
-
- **
|
|
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** —
|
|
31
|
-
|
|
32
|
-
`pyproject.toml`
|
|
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
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
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
|
-
- **
|
|
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** —
|
|
15
|
-
|
|
16
|
-
`pyproject.toml`
|
|
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.
|
|
@@ -8,7 +8,7 @@ language-version bump.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from .models import
|
|
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:
|
|
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 .
|
|
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
|
|
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 =
|
|
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
|
-
|
|
134
|
-
|
|
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 =
|
|
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 .
|
|
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
|
|
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 =
|
|
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 =
|
|
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():
|
|
@@ -144,3 +144,59 @@ class ValidationError:
|
|
|
144
144
|
message: str
|
|
145
145
|
line: int | None = None
|
|
146
146
|
counterexample: list[dict[str, Any]] | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Recipe kinds. A ``graph`` package is a PlusCal spec plus node
|
|
150
|
+
# functions; a ``harness`` package is one engine session with no
|
|
151
|
+
# graph. Read from ``[tool.levain] kind`` in ``pyproject.toml``.
|
|
152
|
+
RECIPE_KINDS = ("graph", "harness")
|
|
153
|
+
DEFAULT_RECIPE_KIND = "graph"
|
|
154
|
+
|
|
155
|
+
HARNESS_ENGINES = ("claude_code", "hermes")
|
|
156
|
+
EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
|
|
157
|
+
PERMISSION_MODES = ("default", "acceptEdits", "plan", "bypassPermissions")
|
|
158
|
+
DEFAULT_PERMISSION_MODE = "bypassPermissions"
|
|
159
|
+
|
|
160
|
+
# The conversation field every harness recipe carries; declared
|
|
161
|
+
# implicitly so the platform's message projection, A2A output and
|
|
162
|
+
# post-run hooks see the same shape as a graph recipe's state.
|
|
163
|
+
MESSAGES_FIELD = "messages"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class HarnessSpec:
|
|
168
|
+
"""A validated harness recipe: one engine session per platform session.
|
|
169
|
+
|
|
170
|
+
``variables`` holds the declared state fields plus the implicit
|
|
171
|
+
``messages`` field. ``prompt`` is a path relative to the package
|
|
172
|
+
root; ``tools`` is the engine's permission allowlist (``None``
|
|
173
|
+
keeps the engine's default toolset).
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
engine: str
|
|
177
|
+
model: str
|
|
178
|
+
variables: list[StateField]
|
|
179
|
+
effort: str | None = None
|
|
180
|
+
prompt: str | None = None
|
|
181
|
+
tools: list[str] | None = None
|
|
182
|
+
mcp_servers: list[str] = field(default_factory=list)
|
|
183
|
+
permission_mode: str = DEFAULT_PERMISSION_MODE
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
RecipeSpec = ValidatedSpec | HarnessSpec
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def recipe_kind_of(spec: RecipeSpec) -> str:
|
|
190
|
+
return "harness" if isinstance(spec, HarnessSpec) else "graph"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def mcp_server_names(spec: RecipeSpec) -> set[str]:
|
|
194
|
+
"""Every MCP server name the recipe declares, whichever its kind."""
|
|
195
|
+
if isinstance(spec, HarnessSpec):
|
|
196
|
+
return set(spec.mcp_servers)
|
|
197
|
+
names: set[str] = set()
|
|
198
|
+
for node in spec.nodes:
|
|
199
|
+
servers = node.args.get("mcp_servers")
|
|
200
|
+
if isinstance(servers, list):
|
|
201
|
+
names.update(s for s in servers if isinstance(s, str))
|
|
202
|
+
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
|
|
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:
|
|
79
|
-
"""Project a
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|