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.
- levain_cli-0.1.0.dist-info/METADATA +77 -0
- levain_cli-0.1.0.dist-info/RECORD +26 -0
- levain_cli-0.1.0.dist-info/WHEEL +4 -0
- levain_cli-0.1.0.dist-info/entry_points.txt +2 -0
- levain_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- levain_recipe/__init__.py +20 -0
- levain_recipe/__main__.py +6 -0
- levain_recipe/_format.py +45 -0
- levain_recipe/_java.py +95 -0
- levain_recipe/auth.py +322 -0
- levain_recipe/cli.py +344 -0
- levain_recipe/codegen.py +114 -0
- levain_recipe/graph.py +813 -0
- levain_recipe/integrations_check.py +218 -0
- levain_recipe/known_recipes.py +21 -0
- levain_recipe/mcp.py +85 -0
- levain_recipe/models.py +146 -0
- levain_recipe/parser.py +299 -0
- levain_recipe/profile.py +389 -0
- levain_recipe/py.typed +0 -0
- levain_recipe/reducers.py +66 -0
- levain_recipe/source_check.py +279 -0
- levain_recipe/tlc.py +236 -0
- levain_recipe/translate.py +142 -0
- levain_recipe/user_input.py +334 -0
- levain_recipe/validate.py +554 -0
|
@@ -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 ""
|