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,142 @@
1
+ """Stage 4: PlusCal-to-TLA+ translation via tla2tools.jar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+
10
+ from ._java import RE_MODULE_NAME, require_java, resolve_jar
11
+ from .models import ValidationError
12
+
13
+ _TRANSLATE_TIMEOUT_S = 30
14
+
15
+
16
+ def translate(
17
+ source: str,
18
+ module_name: str,
19
+ ) -> str | list[ValidationError]:
20
+ """Translate PlusCal to TLA+ using pcal.trans.
21
+
22
+ Parameters
23
+ ----------
24
+ source
25
+ Full TLA+ module text with PlusCal algorithm.
26
+ module_name
27
+ The module name (used for the temp filename).
28
+
29
+ Returns
30
+ -------
31
+ str | list[ValidationError]
32
+ The translated TLA+ source, or errors.
33
+ """
34
+ if not RE_MODULE_NAME.match(module_name):
35
+ return [
36
+ ValidationError(
37
+ stage="translate",
38
+ message="Invalid module name.",
39
+ )
40
+ ]
41
+ jar = resolve_jar()
42
+ if jar is None:
43
+ env_jar = os.environ.get("TLA2TOOLS_JAR")
44
+ if env_jar:
45
+ msg = f"TLA2TOOLS_JAR={env_jar!r} does not exist."
46
+ else:
47
+ msg = (
48
+ "tla2tools.jar not found. Set "
49
+ "TLA2TOOLS_JAR env var or place it "
50
+ "in tools/tla2tools.jar."
51
+ )
52
+ return [ValidationError(stage="translate", message=msg)]
53
+
54
+ java_or_err = require_java("translate")
55
+ if isinstance(java_or_err, list):
56
+ return java_or_err
57
+ java = java_or_err
58
+
59
+ with tempfile.TemporaryDirectory() as tmpdir:
60
+ spec_path = os.path.join(tmpdir, f"{module_name}.tla")
61
+ with open(spec_path, "w", encoding="utf-8") as f:
62
+ f.write(source)
63
+
64
+ try:
65
+ result = subprocess.run(
66
+ [
67
+ java,
68
+ "-cp",
69
+ jar,
70
+ "pcal.trans",
71
+ spec_path,
72
+ ],
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=_TRANSLATE_TIMEOUT_S,
76
+ cwd=tmpdir,
77
+ check=False,
78
+ )
79
+ except subprocess.TimeoutExpired:
80
+ return [
81
+ ValidationError(
82
+ stage="translate",
83
+ message=(
84
+ "PlusCal translation timed out "
85
+ f"after {_TRANSLATE_TIMEOUT_S}s."
86
+ ),
87
+ )
88
+ ]
89
+
90
+ if result.returncode != 0:
91
+ errors = _parse_translation_errors(result.stderr + result.stdout)
92
+ return errors or [
93
+ ValidationError(
94
+ stage="translate",
95
+ message=(
96
+ "PlusCal translation failed: "
97
+ + (result.stderr or result.stdout)[:200]
98
+ ),
99
+ )
100
+ ]
101
+
102
+ # Read back the translated file.
103
+ with open(spec_path, encoding="utf-8") as f:
104
+ translated = f.read()
105
+
106
+ return translated
107
+
108
+
109
+ _RE_PCAL_ERROR = re.compile(
110
+ r"(?:line (\d+),?\s*column \d+)?\s*:?\s*(.*)",
111
+ re.IGNORECASE,
112
+ )
113
+
114
+
115
+ def _parse_translation_errors(
116
+ output: str,
117
+ ) -> list[ValidationError]:
118
+ """Parse pcal.trans error messages."""
119
+ errors: list[ValidationError] = []
120
+
121
+ for raw_line in output.splitlines():
122
+ line = raw_line.strip()
123
+ if not line:
124
+ continue
125
+ if "error" in line.lower() or "Unrecoverable" in line:
126
+ m = _RE_PCAL_ERROR.search(line)
127
+ line_num = None
128
+ msg = line
129
+ if m:
130
+ if m.group(1):
131
+ line_num = int(m.group(1))
132
+ if m.group(2):
133
+ msg = m.group(2).strip()
134
+ errors.append(
135
+ ValidationError(
136
+ stage="translate",
137
+ message=msg,
138
+ line=line_num,
139
+ )
140
+ )
141
+
142
+ return errors
@@ -0,0 +1,334 @@
1
+ """Projector: ``ValidatedSpec`` → user-input schema + validator.
2
+
3
+ Surfaces the subset of a recipe's state that a customer can
4
+ populate from outside the recipe — used by the API to (a) tell
5
+ the FE what shape of input the recipe accepts and (b) validate
6
+ ``initial_state`` overlays on dispatch.
7
+
8
+ Intentionally lossy: fields whose names start with ``_`` (the
9
+ convention for harness-injected internals) and fields whose
10
+ ``reducer`` isn't in :data:`REDUCERS` are excluded. The harness
11
+ already overlays user-supplied state then overwrites with its own
12
+ values in ``_build_initial_state``, so an excluded field is
13
+ defence-in-depth: even a smuggled value would get clobbered
14
+ before any node runs.
15
+
16
+ Safe by construction: ``ValidatedSpec`` comes from tree-sitter
17
+ parsing of ``graph.tla``; ``REDUCERS`` is a hardcoded registry of
18
+ platform-blessed reducers. No customer Python is imported, so a
19
+ malicious recipe cannot influence the schema beyond declaring
20
+ nameable variables and reducer names.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+ from typing import Any, Literal, NotRequired, TypedDict
27
+
28
+ from .models import INTERRUPT_BUILTIN, StateField, ValidatedSpec
29
+ from .reducers import REDUCERS
30
+
31
+ # Each kind names a JSON-shape the API knows how to validate and
32
+ # the FE knows how to render. The set is deliberately small —
33
+ # adding a kind means teaching both sides.
34
+ FieldKind = Literal[
35
+ "string",
36
+ "int",
37
+ "bool",
38
+ "messages",
39
+ "string_list",
40
+ "dict_list",
41
+ ]
42
+
43
+
44
+ class FieldSchema(TypedDict):
45
+ """Per-field descriptor in :class:`UserInputSchema`."""
46
+
47
+ kind: FieldKind
48
+ reducer: NotRequired[str] # only for list-typed fields
49
+ # Spec-declared default for the field (the RHS of the
50
+ # ``variables`` declaration in ``graph.tla``). Surfaced so
51
+ # the FE can pre-fill the state editor on first paint, before
52
+ # any live checkpoint exists. Omitted for list-typed fields
53
+ # — their PlusCal default is always ``<<>>`` which carries no
54
+ # useful pre-fill information for the chat shape we render.
55
+ default: NotRequired[Any]
56
+ # Free-form description authored as ``\* ...`` comments above
57
+ # the variable in graph.tla. Surfaced so the FE can render a
58
+ # per-field helper in the State tab; omitted when the variable
59
+ # has no descriptive comments.
60
+ description: NotRequired[str]
61
+
62
+
63
+ class UserInputSchema(TypedDict):
64
+ """User-settable subset of a recipe's state schema.
65
+
66
+ ``interrupting`` flags whether the recipe expects multiple
67
+ turns: a graph that contains an ``interrupt`` node loops back
68
+ to wait for the next user message between turns, while a
69
+ graph without one runs end-to-end on each dispatch and starts
70
+ fresh on the next. The FE uses this to decide whether to
71
+ show a "single-shot" hint above the composer.
72
+ """
73
+
74
+ fields: dict[str, FieldSchema]
75
+ interrupting: bool
76
+
77
+
78
+ def user_input_schema(spec: ValidatedSpec) -> UserInputSchema:
79
+ """Project a ``ValidatedSpec`` to its user-input schema."""
80
+ fields: dict[str, FieldSchema] = {}
81
+ for var in spec.variables:
82
+ if var.name.startswith("_"):
83
+ continue
84
+ descr = _field_schema(var)
85
+ if descr is None:
86
+ continue
87
+ fields[var.name] = descr
88
+ interrupting = any(
89
+ node.function == INTERRUPT_BUILTIN for node in spec.nodes
90
+ )
91
+ return {"fields": fields, "interrupting": interrupting}
92
+
93
+
94
+ # Maps a spec scalar type to the FieldKind exposed externally.
95
+ # Mirrored by ``_LIST_KIND_BY_REDUCER`` for list-typed fields.
96
+ _SCALAR_KIND: dict[str, FieldKind] = {
97
+ "string": "string",
98
+ "int": "int",
99
+ "bool": "bool",
100
+ }
101
+
102
+ # Every entry maps a blessed reducer name to the FieldKind the
103
+ # API + FE expose. Add a row here only when both sides know how
104
+ # to handle the new kind.
105
+ _LIST_KIND_BY_REDUCER: dict[str, FieldKind] = {
106
+ "add_messages": "messages",
107
+ "add_str": "string_list",
108
+ "extend_list": "dict_list",
109
+ }
110
+
111
+
112
+ def _field_schema(var: StateField) -> FieldSchema | None:
113
+ scalar = _SCALAR_KIND.get(var.type)
114
+ if scalar is not None:
115
+ descr: FieldSchema = {"kind": scalar}
116
+ if var.default is not None:
117
+ descr["default"] = var.default
118
+ if var.description:
119
+ descr["description"] = var.description
120
+ return descr
121
+ if var.type != "list":
122
+ return None
123
+ if var.reducer is None or var.reducer not in REDUCERS:
124
+ return None
125
+ kind = _LIST_KIND_BY_REDUCER.get(var.reducer)
126
+ if kind is None:
127
+ return None
128
+ descr_list: FieldSchema = {"kind": kind, "reducer": var.reducer}
129
+ if var.description:
130
+ descr_list["description"] = var.description
131
+ return descr_list
132
+
133
+
134
+ # ── Validation ──────────────────────────────────────────────
135
+
136
+
137
+ # "system" is excluded intentionally: the recipe's system prompt is
138
+ # set by the spec, not by the caller. Allowing system-role messages
139
+ # in user-supplied initial_state would be a prompt-injection vector.
140
+ _VALID_MESSAGE_ROLES = ("user",)
141
+
142
+
143
+ def validate_initial_state(
144
+ payload: Any,
145
+ schema: UserInputSchema,
146
+ ) -> list[str]:
147
+ """Return a list of human-readable error strings. Empty = valid.
148
+
149
+ Validation is shallow but strict: unknown keys are rejected
150
+ rather than silently dropped, scalar types are checked exactly
151
+ (no truthy/falsy coercion, no int-from-bool), and list shapes
152
+ are walked one level deep. Anything beyond the declared
153
+ :class:`FieldKind` set surfaces as "unknown field".
154
+ """
155
+ if not isinstance(payload, dict):
156
+ return ["initial_state must be a JSON object"]
157
+ fields = schema["fields"]
158
+ errors: list[str] = []
159
+ allowed = ", ".join(sorted(fields)) or "(none)"
160
+ for key, value in payload.items():
161
+ descr = fields.get(key)
162
+ if descr is None:
163
+ errors.append(f"Unknown field {key!r} (allowed: {allowed})")
164
+ continue
165
+ err = _validate_value(key, value, descr["kind"])
166
+ if err is not None:
167
+ errors.append(err)
168
+ return errors
169
+
170
+
171
+ _KIND_VALIDATORS: dict[FieldKind, Callable[[str, Any], str | None]] = {}
172
+
173
+
174
+ def _validate_value(key: str, value: Any, kind: FieldKind) -> str | None:
175
+ validator = _KIND_VALIDATORS.get(kind)
176
+ if validator is None:
177
+ return None
178
+ return validator(key, value)
179
+
180
+
181
+ def _check_string(key: str, value: Any) -> str | None:
182
+ if not isinstance(value, str):
183
+ return _type_err(key, "string", value)
184
+ if not value:
185
+ return f"{key}: expected non-empty string"
186
+ return None
187
+
188
+
189
+ def _check_int(key: str, value: Any) -> str | None:
190
+ # ``bool`` is a subclass of ``int`` in Python — exclude it
191
+ # explicitly so ``True`` doesn't sneak past an ``int`` field.
192
+ if isinstance(value, bool) or not isinstance(value, int):
193
+ return _type_err(key, "int", value)
194
+ return None
195
+
196
+
197
+ def _check_bool(key: str, value: Any) -> str | None:
198
+ return None if isinstance(value, bool) else _type_err(key, "bool", value)
199
+
200
+
201
+ def _check_string_list(key: str, value: Any) -> str | None:
202
+ if not isinstance(value, list) or not all(
203
+ isinstance(x, str) for x in value
204
+ ):
205
+ return f"{key}: expected list of strings"
206
+ return None
207
+
208
+
209
+ def _check_dict_list(key: str, value: Any) -> str | None:
210
+ if not isinstance(value, list) or not all(
211
+ isinstance(x, dict) for x in value
212
+ ):
213
+ return f"{key}: expected list of objects"
214
+ return None
215
+
216
+
217
+ def _type_err(key: str, expected: str, value: Any) -> str:
218
+ return f"{key}: expected {expected}, got {type(value).__name__}"
219
+
220
+
221
+ def _validate_messages(key: str, value: Any) -> str | None:
222
+ if not isinstance(value, list) or not value:
223
+ return f"{key}: expected non-empty list of messages"
224
+ for i, m in enumerate(value):
225
+ if not isinstance(m, dict):
226
+ return f"{key}[{i}]: expected message object"
227
+ role = m.get("role")
228
+ content = m.get("content")
229
+ if role not in _VALID_MESSAGE_ROLES:
230
+ return (
231
+ f"{key}[{i}].role: must be one of {list(_VALID_MESSAGE_ROLES)}"
232
+ )
233
+ if not isinstance(content, str) or not content:
234
+ return f"{key}[{i}].content: expected non-empty string"
235
+ extra = set(m) - {"role", "content"}
236
+ if extra:
237
+ return f"{key}[{i}]: unknown keys {sorted(extra)}"
238
+ return None
239
+
240
+
241
+ # Populated after the per-kind helpers are defined so the lookup
242
+ # in ``_validate_value`` resolves to a real Callable at import.
243
+ _KIND_VALIDATORS.update(
244
+ {
245
+ "string": _check_string,
246
+ "int": _check_int,
247
+ "bool": _check_bool,
248
+ "messages": _validate_messages,
249
+ "string_list": _check_string_list,
250
+ "dict_list": _check_dict_list,
251
+ }
252
+ )
253
+
254
+
255
+ # ── Bare-prompt convenience ─────────────────────────────────
256
+
257
+
258
+ def primary_input_field(
259
+ schema: UserInputSchema,
260
+ ) -> tuple[str, FieldKind] | None:
261
+ """Pick the canonical user-input field for a bare-prompt dispatch.
262
+
263
+ Heuristic, in order:
264
+
265
+ * ``messages`` (the chat shape — most customer recipes).
266
+ * ``plan_text`` (the platform task-recipe convention —
267
+ build / plan / review).
268
+ * The first scalar string field in declaration order.
269
+
270
+ Returns ``None`` when the recipe declares no obviously usable
271
+ field; the caller should refuse the dispatch with a 422.
272
+ """
273
+ fields = schema["fields"]
274
+ msgs = fields.get("messages")
275
+ if msgs is not None and msgs["kind"] == "messages":
276
+ return "messages", "messages"
277
+ plan = fields.get("plan_text")
278
+ if plan is not None and plan["kind"] == "string":
279
+ return "plan_text", "string"
280
+ for name, descr in fields.items():
281
+ if descr["kind"] == "string":
282
+ return name, "string"
283
+ return None
284
+
285
+
286
+ def wrap_prompt(prompt: str, field: str, kind: FieldKind) -> dict[str, Any]:
287
+ """Build the ``initial_state`` overlay for a bare-prompt dispatch.
288
+
289
+ Mirrors what the FE would have constructed itself if it knew
290
+ the recipe shape; centralised here so the choice of "user"
291
+ role / single-message wrapping lives in one spot.
292
+ """
293
+ if kind == "messages":
294
+ return {field: [{"role": "user", "content": prompt}]}
295
+ if kind == "string":
296
+ return {field: prompt}
297
+ raise ValueError(
298
+ f"cannot wrap a bare prompt as kind {kind!r}; supply initial_state"
299
+ )
300
+
301
+
302
+ # ── Inverse: pluck human text out of an existing overlay ────
303
+
304
+
305
+ def user_text_from_overlay(overlay: dict[str, Any]) -> str:
306
+ """Best-effort extraction of human-readable text from a state overlay.
307
+
308
+ The inverse of :func:`wrap_prompt`: given an ``initial_state``
309
+ dict, recover the user's textual input (``messages[-1].content``
310
+ for chat shapes, ``plan_text`` for task shapes). Returns
311
+ ``""`` when nothing looks like a user message — callers fall
312
+ back gracefully (event log row still renders; interrupt-resume
313
+ treats it as an empty reply).
314
+
315
+ Used by:
316
+
317
+ * The dispatch path to populate the ``user_message`` event log.
318
+ * The run / round read surfaces so a chat-shape recipe shows
319
+ the prompt the user actually sent.
320
+ * The harness's interrupt-resume callback so a chat-shape
321
+ recipe's ``interrupt`` node gets the new turn's message back
322
+ from ``langgraph.types.interrupt``.
323
+ """
324
+ msgs = overlay.get("messages")
325
+ if isinstance(msgs, list) and msgs:
326
+ last = msgs[-1]
327
+ if isinstance(last, dict) and last.get("role") == "user":
328
+ content = last.get("content")
329
+ if isinstance(content, str):
330
+ return content
331
+ plan = overlay.get("plan_text")
332
+ if isinstance(plan, str):
333
+ return plan
334
+ return ""