flowspec2 1.0.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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/compiler.py
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
"""Compile a flowspec/2 document into an executable LangGraph ``StateGraph``.
|
|
2
|
+
|
|
3
|
+
The compiler:
|
|
4
|
+
|
|
5
|
+
1. builds a :class:`~flowspec2.nodes.FlowContext` (domains, slots, config, tools),
|
|
6
|
+
discovers subflow-exposed slots, validates every state reference, and builds
|
|
7
|
+
the dependency indexes (transitive ``requires`` → ``dependents``,
|
|
8
|
+
``derive.from`` → ``derive_readers``);
|
|
9
|
+
2. walks ``path`` into a flat ordered list of nodes, expanding each ``use`` into
|
|
10
|
+
its subflow's spliced nodes and inserting ``derive`` nodes at their ``after``
|
|
11
|
+
anchor;
|
|
12
|
+
3. registers every node, then synthesizes the routers — a node returns ``END``
|
|
13
|
+
(pause), a literal target (correction back-edge / subflow branch), or the
|
|
14
|
+
``NEXT`` sentinel which the compiler resolves to the following node;
|
|
15
|
+
4. returns a :class:`CompiledFlow` the runtime invokes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import copy
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Callable, Final, Optional, cast
|
|
26
|
+
|
|
27
|
+
from langgraph.graph import END as LANGGRAPH_END
|
|
28
|
+
from langgraph.graph import StateGraph
|
|
29
|
+
|
|
30
|
+
from .clock import UtcClock, system_utc_now
|
|
31
|
+
from .compiler_contracts import (
|
|
32
|
+
bind_await_external,
|
|
33
|
+
validate_await_external_definition,
|
|
34
|
+
validate_await_external_targets,
|
|
35
|
+
validate_entry_args_schema,
|
|
36
|
+
validate_flow_tool_contracts,
|
|
37
|
+
)
|
|
38
|
+
from .domains import interactive_options_for_domain
|
|
39
|
+
from .interactive import (
|
|
40
|
+
BUTTON_ID_MAX,
|
|
41
|
+
BUTTON_TITLE_MAX,
|
|
42
|
+
LIST_ROWS_TOTAL_MAX,
|
|
43
|
+
MAX_BUTTONS,
|
|
44
|
+
ROW_DESC_MAX,
|
|
45
|
+
ROW_ID_MAX,
|
|
46
|
+
ROW_TITLE_MAX,
|
|
47
|
+
interactive_option_identifier,
|
|
48
|
+
)
|
|
49
|
+
from .models import ServiceState
|
|
50
|
+
from .nodes import (
|
|
51
|
+
NEXT,
|
|
52
|
+
FlowContext,
|
|
53
|
+
NodeDesc,
|
|
54
|
+
make_await_external_node,
|
|
55
|
+
make_bool_confirm_node,
|
|
56
|
+
make_collect_node,
|
|
57
|
+
make_derive_node,
|
|
58
|
+
make_hub_confirm_node,
|
|
59
|
+
make_init_node,
|
|
60
|
+
make_summary_confirm_node,
|
|
61
|
+
make_terminal_node,
|
|
62
|
+
)
|
|
63
|
+
from .observability import SnowflakeIdGenerator, default_log_id_generator
|
|
64
|
+
from .subflows import SubflowDefinition, SubflowRegistry, default_subflows
|
|
65
|
+
from .tools import ToolRegistry, default_tool_registry
|
|
66
|
+
|
|
67
|
+
END: Final[str] = LANGGRAPH_END
|
|
68
|
+
_CONTRACT_INVALID: Final[int] = -1
|
|
69
|
+
_CONTRACT_UNKNOWN: Final[int] = 0
|
|
70
|
+
_CONTRACT_VALID: Final[int] = 1
|
|
71
|
+
_RESUME_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
72
|
+
r"^\$token(?:\.[A-Za-z][A-Za-z0-9_]*)+$"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CompiledFlow:
|
|
78
|
+
graph: Any # compiled langgraph
|
|
79
|
+
ctx: FlowContext
|
|
80
|
+
_doc: dict[str, Any]
|
|
81
|
+
terminal_id: Optional[str]
|
|
82
|
+
entry_node_id: str
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def doc(self) -> dict[str, Any]:
|
|
86
|
+
"""Return an owned copy of the immutable compiler source snapshot."""
|
|
87
|
+
|
|
88
|
+
return copy.deepcopy(self._doc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _transitive_dependents(slots: dict[str, Any]) -> dict[str, set[str]]:
|
|
92
|
+
"""slot -> every slot that (transitively) lists it in ``requires``."""
|
|
93
|
+
direct: dict[str, set[str]] = {}
|
|
94
|
+
for slot, cfg in slots.items():
|
|
95
|
+
for req in cfg.get("requires", []) or []:
|
|
96
|
+
direct.setdefault(req, set()).add(slot)
|
|
97
|
+
dependents: dict[str, set[str]] = {}
|
|
98
|
+
# Iterate the union of declared slots and every slot named as a `requires`
|
|
99
|
+
# target: subflow-contributed slots (e.g. `address`) are required by top-level
|
|
100
|
+
# slots but are not keys of the document's `slots` block.
|
|
101
|
+
for slot in set(slots) | set(direct):
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
stack = list(direct.get(slot, set()))
|
|
104
|
+
while stack:
|
|
105
|
+
cur = stack.pop()
|
|
106
|
+
if cur in seen:
|
|
107
|
+
continue
|
|
108
|
+
seen.add(cur)
|
|
109
|
+
stack.extend(direct.get(cur, set()))
|
|
110
|
+
dependents[slot] = seen
|
|
111
|
+
return dependents
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _derive_readers(derives: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
115
|
+
direct_readers: dict[str, list[str]] = {}
|
|
116
|
+
for derive_definition in derives:
|
|
117
|
+
for source_slot in derive_definition.get("from", []):
|
|
118
|
+
direct_readers.setdefault(source_slot, []).append(derive_definition["writes"])
|
|
119
|
+
|
|
120
|
+
transitive_readers: dict[str, list[str]] = {}
|
|
121
|
+
for source_slot in direct_readers:
|
|
122
|
+
pending_targets = list(direct_readers[source_slot])
|
|
123
|
+
seen_targets: set[str] = set()
|
|
124
|
+
ordered_targets: list[str] = []
|
|
125
|
+
while pending_targets:
|
|
126
|
+
derived_target = pending_targets.pop(0)
|
|
127
|
+
if derived_target in seen_targets:
|
|
128
|
+
continue
|
|
129
|
+
seen_targets.add(derived_target)
|
|
130
|
+
ordered_targets.append(derived_target)
|
|
131
|
+
pending_targets.extend(direct_readers.get(derived_target, ()))
|
|
132
|
+
transitive_readers[source_slot] = ordered_targets
|
|
133
|
+
return transitive_readers
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _derive_node_identifiers(doc: dict[str, Any]) -> dict[str, str]:
|
|
137
|
+
path_identifiers = {
|
|
138
|
+
cast(str, path_step["derive"]): cast(str, path_step["id"])
|
|
139
|
+
for path_step in doc["path"]
|
|
140
|
+
if "derive" in path_step
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
cast(str, derive_definition["writes"]): path_identifiers.get(
|
|
144
|
+
cast(str, derive_definition["writes"]),
|
|
145
|
+
f"derive_{derive_definition['writes']}",
|
|
146
|
+
)
|
|
147
|
+
for derive_definition in doc.get("derive", [])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_derive_execution_order(
|
|
152
|
+
doc: dict[str, Any],
|
|
153
|
+
ctx: FlowContext,
|
|
154
|
+
sequence: list[NodeDesc],
|
|
155
|
+
) -> None:
|
|
156
|
+
node_positions = {descriptor.id: node_index for node_index, descriptor in enumerate(sequence)}
|
|
157
|
+
derive_node_identifiers = _derive_node_identifiers(doc)
|
|
158
|
+
for derive_index, derive_definition in enumerate(doc.get("derive", [])):
|
|
159
|
+
target = cast(str, derive_definition["writes"])
|
|
160
|
+
target_node = derive_node_identifiers[target]
|
|
161
|
+
target_position = node_positions[target_node]
|
|
162
|
+
for source_index, source_slot in enumerate(derive_definition.get("from", [])):
|
|
163
|
+
producer_node = derive_node_identifiers.get(source_slot) or ctx.node_for_slot.get(
|
|
164
|
+
source_slot
|
|
165
|
+
)
|
|
166
|
+
if producer_node is None:
|
|
167
|
+
continue
|
|
168
|
+
producer_position = node_positions.get(producer_node)
|
|
169
|
+
if producer_position is None or producer_position >= target_position:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"$.derive[{derive_index}].from[{source_index}] is produced by "
|
|
172
|
+
f"{producer_node!r} after derive node {target_node!r} would execute"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validate_terminal_derive_execution_order(
|
|
177
|
+
doc: dict[str, Any],
|
|
178
|
+
sequence: list[NodeDesc],
|
|
179
|
+
) -> None:
|
|
180
|
+
terminal_definition = cast(dict[str, Any] | None, doc.get("terminal"))
|
|
181
|
+
if terminal_definition is None:
|
|
182
|
+
return
|
|
183
|
+
node_positions = {descriptor.id: node_index for node_index, descriptor in enumerate(sequence)}
|
|
184
|
+
terminal_position = node_positions.get(cast(str, terminal_definition["step"]))
|
|
185
|
+
if terminal_position is None:
|
|
186
|
+
return
|
|
187
|
+
derive_nodes = _derive_node_identifiers(doc)
|
|
188
|
+
for input_index, input_binding in enumerate(terminal_definition.get("input", [])):
|
|
189
|
+
source_name = cast(str, input_binding["slot"])
|
|
190
|
+
derive_node = derive_nodes.get(source_name)
|
|
191
|
+
if derive_node is None:
|
|
192
|
+
continue
|
|
193
|
+
derive_position = node_positions.get(derive_node)
|
|
194
|
+
if derive_position is None or derive_position >= terminal_position:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"$.terminal.input[{input_index}] reads derived value {source_name!r} before "
|
|
197
|
+
f"its producer node {derive_node!r} executes"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _gate_for(step: dict[str, Any], gates: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
202
|
+
if "ask_when" in step:
|
|
203
|
+
return cast(dict[str, Any], step["ask_when"])
|
|
204
|
+
return cast(Optional[dict[str, Any]], gates.get(step["id"]))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _compiled_path_step_identifier(
|
|
208
|
+
path_step: dict[str, Any],
|
|
209
|
+
terminal_identifier: str,
|
|
210
|
+
) -> str | None:
|
|
211
|
+
if "terminal" in path_step:
|
|
212
|
+
return terminal_identifier
|
|
213
|
+
if any(step_kind in path_step for step_kind in ("slot", "confirm", "derive", "await_external")):
|
|
214
|
+
return cast(str, path_step["id"])
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_auto_flow_target(
|
|
219
|
+
doc: dict[str, Any],
|
|
220
|
+
*,
|
|
221
|
+
field_path: str,
|
|
222
|
+
target: str,
|
|
223
|
+
submission_slots: frozenset[str],
|
|
224
|
+
) -> None:
|
|
225
|
+
matching_indexes = [
|
|
226
|
+
path_index
|
|
227
|
+
for path_index, path_step in enumerate(doc["path"])
|
|
228
|
+
if path_step.get("id") == target
|
|
229
|
+
]
|
|
230
|
+
if len(matching_indexes) != 1:
|
|
231
|
+
raise ValueError(f"{field_path} must reference exactly one native path step: {target!r}")
|
|
232
|
+
prefix = doc["path"][: matching_indexes[0]]
|
|
233
|
+
native_slot_definitions = cast(dict[str, dict[str, Any]], doc.get("slots", {}))
|
|
234
|
+
override_gates = cast(
|
|
235
|
+
dict[str, Any],
|
|
236
|
+
cast(dict[str, Any], doc.get("overrides", {})).get("gates", {}),
|
|
237
|
+
)
|
|
238
|
+
is_submission_target = field_path == "auto_flow.resume_at"
|
|
239
|
+
for path_index, path_step in enumerate(prefix):
|
|
240
|
+
if any(
|
|
241
|
+
step_kind in path_step for step_kind in ("use", "derive", "await_external", "terminal")
|
|
242
|
+
):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"{field_path} cannot bypass a subflow, derivation, external wait, "
|
|
245
|
+
f"or terminal step at $.path[{path_index}]"
|
|
246
|
+
)
|
|
247
|
+
if "confirm" in path_step and path_step.get("correctable") is True:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"{field_path} cannot bypass a correction-hub confirmation at $.path[{path_index}]"
|
|
250
|
+
)
|
|
251
|
+
conditional_contract = (
|
|
252
|
+
path_step.get("ask_when")
|
|
253
|
+
or path_step.get("skip_when")
|
|
254
|
+
or override_gates.get(path_step.get("id"))
|
|
255
|
+
)
|
|
256
|
+
if (slot_name := path_step.get("slot")) is not None:
|
|
257
|
+
slot_definition = native_slot_definitions[slot_name]
|
|
258
|
+
if not slot_definition.get("required"):
|
|
259
|
+
continue
|
|
260
|
+
if is_submission_target and (
|
|
261
|
+
slot_name in submission_slots or conditional_contract is not None
|
|
262
|
+
):
|
|
263
|
+
continue
|
|
264
|
+
raise ValueError(f"{field_path} cannot bypass unsatisfied required slot {slot_name!r}")
|
|
265
|
+
if (confirmation_slot := path_step.get("confirm")) is None:
|
|
266
|
+
continue
|
|
267
|
+
if is_submission_target and (
|
|
268
|
+
confirmation_slot in submission_slots or conditional_contract is not None
|
|
269
|
+
):
|
|
270
|
+
continue
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"{field_path} cannot bypass confirmation {confirmation_slot!r} while it is unanswered"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _validate_auto_flow_resume(doc: dict[str, Any]) -> None:
|
|
277
|
+
auto_flow = cast(dict[str, Any], doc.get("auto_flow") or {})
|
|
278
|
+
native_slot_definitions = cast(dict[str, dict[str, Any]], doc.get("slots", {}))
|
|
279
|
+
native_slots = set(native_slot_definitions)
|
|
280
|
+
prefill_slot_list = cast(list[str], auto_flow.get("prefill_from", []))
|
|
281
|
+
prefill_slots = frozenset(prefill_slot_list)
|
|
282
|
+
for prefill_index, prefill_slot in enumerate(prefill_slot_list):
|
|
283
|
+
if prefill_slot not in native_slots:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"auto_flow.prefill_from[{prefill_index}] must reference a top-level slot"
|
|
286
|
+
)
|
|
287
|
+
if native_slot_definitions[prefill_slot].get("persist", "data") == "internal":
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"auto_flow.prefill_from[{prefill_index}] cannot expose internal slot "
|
|
290
|
+
f"{prefill_slot!r}"
|
|
291
|
+
)
|
|
292
|
+
alias_destinations: set[str] = set()
|
|
293
|
+
for flow_field, mapping in auto_flow.get("alias_map", {}).items():
|
|
294
|
+
candidate_mappings = (
|
|
295
|
+
[mapping]
|
|
296
|
+
if all(not isinstance(value, dict) for value in mapping.values())
|
|
297
|
+
else [value for value in mapping.values() if isinstance(value, dict)]
|
|
298
|
+
)
|
|
299
|
+
for candidate_mapping in candidate_mappings:
|
|
300
|
+
for destination_slot in candidate_mapping:
|
|
301
|
+
if destination_slot not in native_slots:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"auto_flow alias destinations must be top-level slots: "
|
|
304
|
+
f"{flow_field!r} -> {destination_slot!r}"
|
|
305
|
+
)
|
|
306
|
+
alias_destinations.add(destination_slot)
|
|
307
|
+
resume_target = auto_flow.get("resume_at")
|
|
308
|
+
if resume_target is not None:
|
|
309
|
+
_validate_auto_flow_target(
|
|
310
|
+
doc,
|
|
311
|
+
field_path="auto_flow.resume_at",
|
|
312
|
+
target=cast(str, resume_target),
|
|
313
|
+
submission_slots=prefill_slots | alias_destinations,
|
|
314
|
+
)
|
|
315
|
+
recovery = cast(dict[str, Any], auto_flow.get("recovery") or {})
|
|
316
|
+
if (fallback_target := recovery.get("fallback_at")) is not None:
|
|
317
|
+
_validate_auto_flow_target(
|
|
318
|
+
doc,
|
|
319
|
+
field_path="auto_flow.recovery.fallback_at",
|
|
320
|
+
target=cast(str, fallback_target),
|
|
321
|
+
submission_slots=frozenset(),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_gate_bindings(doc: dict[str, Any], gates: dict[str, Any]) -> None:
|
|
326
|
+
gateable_steps = {
|
|
327
|
+
step["id"]: step for step in doc["path"] if "slot" in step or "confirm" in step
|
|
328
|
+
}
|
|
329
|
+
for gate_node_id in gates:
|
|
330
|
+
if gate_node_id not in gateable_steps:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"$.overrides.gates[{gate_node_id!r}] does not resolve to a collect or confirm step"
|
|
333
|
+
)
|
|
334
|
+
path_step = gateable_steps[gate_node_id]
|
|
335
|
+
if "ask_when" in path_step:
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"$.overrides.gates[{gate_node_id!r}] duplicates the inline ask_when gate"
|
|
338
|
+
)
|
|
339
|
+
if "confirm" in path_step and (
|
|
340
|
+
path_step.get("correctable") is True or "on_reject" in path_step
|
|
341
|
+
):
|
|
342
|
+
raise ValueError(
|
|
343
|
+
f"$.overrides.gates[{gate_node_id!r}] targets a confirmation variant "
|
|
344
|
+
"that does not support gating"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _validate_confirmation_domains(doc: dict[str, Any], ctx: FlowContext) -> None:
|
|
349
|
+
for path_index, path_step in enumerate(doc["path"]):
|
|
350
|
+
confirmation_slot = path_step.get("confirm")
|
|
351
|
+
if confirmation_slot is None or confirmation_slot not in ctx.slots:
|
|
352
|
+
continue
|
|
353
|
+
domain_name = ctx.slots[confirmation_slot]["domain"]
|
|
354
|
+
domain = ctx.domains.get(domain_name)
|
|
355
|
+
if domain is not None and domain.get("type", "categorical") != "bool":
|
|
356
|
+
raise ValueError(
|
|
357
|
+
f"$.path[{path_index}].confirm slot {confirmation_slot!r} must use a bool domain"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _validate_native_path_slot_references(doc: dict[str, Any], ctx: FlowContext) -> None:
|
|
362
|
+
declared_slot_names = set(ctx.slots)
|
|
363
|
+
for step_index, step in enumerate(doc["path"]):
|
|
364
|
+
reference_kind = "slot" if "slot" in step else "confirm" if "confirm" in step else None
|
|
365
|
+
if reference_kind is None:
|
|
366
|
+
continue
|
|
367
|
+
slot_name = cast(str, step[reference_kind])
|
|
368
|
+
if slot_name not in declared_slot_names:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"$.path[{step_index}].{reference_kind} does not resolve to a declared slot: "
|
|
371
|
+
f"{slot_name!r}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _validate_subflow_bindings(
|
|
376
|
+
doc: dict[str, Any],
|
|
377
|
+
subflows: SubflowRegistry,
|
|
378
|
+
) -> dict[str, dict[str, Any]]:
|
|
379
|
+
path_locations: dict[str, str] = {}
|
|
380
|
+
for path_index, path_step in enumerate(doc["path"]):
|
|
381
|
+
subflow_reference = path_step.get("use")
|
|
382
|
+
if subflow_reference is None:
|
|
383
|
+
continue
|
|
384
|
+
path_location = f"$.path[{path_index}].use"
|
|
385
|
+
if subflow_reference in path_locations:
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"{path_location} repeats subflow anchor {subflow_reference!r}; "
|
|
388
|
+
f"first anchored at {path_locations[subflow_reference]}"
|
|
389
|
+
)
|
|
390
|
+
path_locations[subflow_reference] = path_location
|
|
391
|
+
|
|
392
|
+
declarations: dict[str, tuple[int, dict[str, Any]]] = {}
|
|
393
|
+
for declaration_index, declaration in enumerate(doc.get("uses", []) or []):
|
|
394
|
+
subflow_reference = cast(str, declaration["ref"])
|
|
395
|
+
declaration_location = f"$.uses[{declaration_index}].ref"
|
|
396
|
+
if subflow_reference in declarations:
|
|
397
|
+
first_declaration_index = declarations[subflow_reference][0]
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"{declaration_location} duplicates subflow declaration {subflow_reference!r}; "
|
|
400
|
+
f"first declared at $.uses[{first_declaration_index}].ref"
|
|
401
|
+
)
|
|
402
|
+
if not subflows.has(subflow_reference):
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"{declaration_location} subflow is not registered: {subflow_reference!r}"
|
|
405
|
+
)
|
|
406
|
+
exposed_slots = subflows.definition(subflow_reference).exposed_slots
|
|
407
|
+
if exposed_slots is not None and (collisions := set(doc.get("slots", {})) & exposed_slots):
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"{declaration_location} subflow-owned slots collide with top-level "
|
|
410
|
+
f"declarations: {', '.join(sorted(collisions))}"
|
|
411
|
+
)
|
|
412
|
+
declarations[subflow_reference] = (declaration_index, declaration)
|
|
413
|
+
|
|
414
|
+
for subflow_reference, path_location in path_locations.items():
|
|
415
|
+
if not subflows.has(subflow_reference):
|
|
416
|
+
raise ValueError(f"{path_location} subflow is not registered: {subflow_reference!r}")
|
|
417
|
+
if subflow_reference not in declarations:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"{path_location} has no matching declaration in $.uses: {subflow_reference!r}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
configurations: dict[str, dict[str, Any]] = {}
|
|
423
|
+
for subflow_reference, (declaration_index, declaration) in declarations.items():
|
|
424
|
+
if subflow_reference not in path_locations:
|
|
425
|
+
raise ValueError(
|
|
426
|
+
f"$.uses[{declaration_index}].ref is an orphan declaration without a path "
|
|
427
|
+
f"anchor: {subflow_reference!r}"
|
|
428
|
+
)
|
|
429
|
+
configuration = cast(dict[str, Any], declaration.get("with") or {})
|
|
430
|
+
subflows.validate_configuration(
|
|
431
|
+
subflow_reference,
|
|
432
|
+
configuration,
|
|
433
|
+
location=f"$.uses[{declaration_index}].with",
|
|
434
|
+
)
|
|
435
|
+
configurations[subflow_reference] = configuration
|
|
436
|
+
return configurations
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _register_subflow_exposures(
|
|
440
|
+
ctx: FlowContext,
|
|
441
|
+
subflow_reference: str,
|
|
442
|
+
definition: SubflowDefinition,
|
|
443
|
+
entry_id: str,
|
|
444
|
+
first_descriptor_id: str,
|
|
445
|
+
exposed_slots: dict[str, str],
|
|
446
|
+
subflow_node_ids: set[str],
|
|
447
|
+
) -> None:
|
|
448
|
+
if entry_id not in subflow_node_ids:
|
|
449
|
+
raise ValueError(f"subflow {subflow_reference!r} declares unknown entry node {entry_id!r}")
|
|
450
|
+
if entry_id != first_descriptor_id:
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"subflow {subflow_reference!r} entry node {entry_id!r} must be its first "
|
|
453
|
+
f"descriptor {first_descriptor_id!r}"
|
|
454
|
+
)
|
|
455
|
+
if definition.exposed_slots is not None and set(exposed_slots) != definition.exposed_slots:
|
|
456
|
+
declared_slots = ", ".join(sorted(definition.exposed_slots)) or "<none>"
|
|
457
|
+
built_slots = ", ".join(sorted(exposed_slots)) or "<none>"
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"subflow {subflow_reference!r} build exposes slots [{built_slots}], but its "
|
|
460
|
+
f"manifest declares [{declared_slots}]"
|
|
461
|
+
)
|
|
462
|
+
for slot_name, node_id in exposed_slots.items():
|
|
463
|
+
if node_id not in subflow_node_ids:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"subflow {subflow_reference!r} exposes slot {slot_name!r} through unknown "
|
|
466
|
+
f"node {node_id!r}"
|
|
467
|
+
)
|
|
468
|
+
existing_node_id = ctx.node_for_slot.get(slot_name)
|
|
469
|
+
if existing_node_id is not None and existing_node_id != node_id:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
f"subflow {subflow_reference!r} exposes slot {slot_name!r} through {node_id!r}, "
|
|
472
|
+
f"but it is already bound to {existing_node_id!r}"
|
|
473
|
+
)
|
|
474
|
+
ctx.node_for_slot[slot_name] = node_id
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _validate_compiled_slot_references(doc: dict[str, Any], ctx: FlowContext) -> None:
|
|
478
|
+
collectable_slot_names = set(ctx.node_for_slot)
|
|
479
|
+
derived_slot_names = {
|
|
480
|
+
derive_definition["writes"] for derive_definition in doc.get("derive", [])
|
|
481
|
+
}
|
|
482
|
+
readable_slot_names = set(ctx.slots) | collectable_slot_names | derived_slot_names
|
|
483
|
+
|
|
484
|
+
for slot_name, slot_declaration in ctx.slots.items():
|
|
485
|
+
for requirement_index, required_slot_name in enumerate(
|
|
486
|
+
slot_declaration.get("requires", []) or []
|
|
487
|
+
):
|
|
488
|
+
if required_slot_name not in collectable_slot_names:
|
|
489
|
+
raise ValueError(
|
|
490
|
+
f"$.slots.{slot_name}.requires[{requirement_index}] does not resolve to a "
|
|
491
|
+
f"collectable slot: {required_slot_name!r}"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
for derive_index, derive_definition in enumerate(doc.get("derive", [])):
|
|
495
|
+
for source_index, source_slot_name in enumerate(derive_definition.get("from", [])):
|
|
496
|
+
if source_slot_name not in readable_slot_names:
|
|
497
|
+
raise ValueError(
|
|
498
|
+
f"$.derive[{derive_index}].from[{source_index}] does not resolve to a slot: "
|
|
499
|
+
f"{source_slot_name!r}"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
confirm_definition = doc.get("confirm")
|
|
503
|
+
if confirm_definition:
|
|
504
|
+
confirmation_slot = confirm_definition["slot"]
|
|
505
|
+
if confirmation_slot not in ctx.slots:
|
|
506
|
+
raise ValueError(
|
|
507
|
+
f"$.confirm.slot does not resolve to a declared slot: {confirmation_slot!r}"
|
|
508
|
+
)
|
|
509
|
+
for correctable_index, correctable_slot_name in enumerate(
|
|
510
|
+
confirm_definition["correctable"]
|
|
511
|
+
):
|
|
512
|
+
if correctable_slot_name not in collectable_slot_names:
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"$.confirm.correctable[{correctable_index}] does not resolve to a "
|
|
515
|
+
f"collectable slot: {correctable_slot_name!r}"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
terminal_definition = doc.get("terminal")
|
|
519
|
+
if terminal_definition:
|
|
520
|
+
for input_index, input_binding in enumerate(terminal_definition.get("input", [])):
|
|
521
|
+
input_slot_name = input_binding["slot"]
|
|
522
|
+
if input_slot_name not in readable_slot_names:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
f"$.terminal.input[{input_index}].slot does not resolve to a slot or "
|
|
525
|
+
f"derived value: {input_slot_name!r}"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _register_interactive_binding(
|
|
530
|
+
field_bindings: dict[str, tuple[str, str]],
|
|
531
|
+
field_name: str,
|
|
532
|
+
slot_name: str,
|
|
533
|
+
source_location: str,
|
|
534
|
+
) -> None:
|
|
535
|
+
existing_binding = field_bindings.get(field_name)
|
|
536
|
+
if existing_binding is not None and existing_binding[0] != slot_name:
|
|
537
|
+
raise ValueError(
|
|
538
|
+
f"{source_location}.field binds payload field {field_name!r} to slot {slot_name!r}, "
|
|
539
|
+
f"but {existing_binding[1]}.field already binds it to {existing_binding[0]!r}"
|
|
540
|
+
)
|
|
541
|
+
field_bindings[field_name] = (slot_name, source_location)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _validate_interactive_domain_binding(
|
|
545
|
+
ctx: FlowContext,
|
|
546
|
+
interactive: dict[str, Any],
|
|
547
|
+
slot_name: str,
|
|
548
|
+
source_location: str,
|
|
549
|
+
) -> None:
|
|
550
|
+
if slot_name not in ctx.slots:
|
|
551
|
+
raise ValueError(f"{source_location} targets an undeclared slot: {slot_name!r}")
|
|
552
|
+
interactive_domain_name = interactive.get("from_domain")
|
|
553
|
+
if interactive_domain_name is None:
|
|
554
|
+
if interactive.get("options_when"):
|
|
555
|
+
raise ValueError(f"{source_location}.options_when requires from_domain")
|
|
556
|
+
return
|
|
557
|
+
if interactive_domain_name not in ctx.domains:
|
|
558
|
+
raise ValueError(
|
|
559
|
+
f"{source_location}.from_domain does not resolve to a domain: "
|
|
560
|
+
f"{interactive_domain_name!r}"
|
|
561
|
+
)
|
|
562
|
+
slot_domain_name = ctx.slots[slot_name]["domain"]
|
|
563
|
+
if interactive_domain_name != slot_domain_name:
|
|
564
|
+
raise ValueError(
|
|
565
|
+
f"{source_location}.from_domain must match $.slots.{slot_name}.domain: "
|
|
566
|
+
f"{interactive_domain_name!r} != {slot_domain_name!r}"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
domain_specification = ctx.domains[interactive_domain_name]
|
|
570
|
+
domain_kind = domain_specification.get("type", "categorical")
|
|
571
|
+
if domain_kind not in {"categorical", "bool"}:
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"{source_location}.from_domain must reference a categorical or bool domain"
|
|
574
|
+
)
|
|
575
|
+
domain_values: set[Any] = {
|
|
576
|
+
domain_option.value
|
|
577
|
+
for domain_option in interactive_options_for_domain(domain_specification)
|
|
578
|
+
}
|
|
579
|
+
if None in domain_specification.get("values", []):
|
|
580
|
+
domain_values.add(None)
|
|
581
|
+
renderable_options = interactive_options_for_domain(domain_specification)
|
|
582
|
+
if not renderable_options:
|
|
583
|
+
raise ValueError(f"{source_location}.from_domain has no renderable options")
|
|
584
|
+
interactive_kind = interactive["kind"]
|
|
585
|
+
option_identifiers = [
|
|
586
|
+
interactive_option_identifier(domain_option.value) for domain_option in renderable_options
|
|
587
|
+
]
|
|
588
|
+
if len(option_identifiers) != len(set(option_identifiers)):
|
|
589
|
+
raise ValueError(f"{source_location} contains duplicate rendered option identifiers")
|
|
590
|
+
identifier_maximum = BUTTON_ID_MAX if interactive_kind == "buttons" else ROW_ID_MAX
|
|
591
|
+
if any(
|
|
592
|
+
not option_identifier.strip() or len(option_identifier) > identifier_maximum
|
|
593
|
+
for option_identifier in option_identifiers
|
|
594
|
+
):
|
|
595
|
+
raise ValueError(f"{source_location} contains an invalid rendered option identifier")
|
|
596
|
+
if interactive_kind == "buttons":
|
|
597
|
+
if len(renderable_options) > MAX_BUTTONS:
|
|
598
|
+
raise ValueError(f"{source_location} exceeds the buttons option limit")
|
|
599
|
+
if any(len(domain_option.title) > BUTTON_TITLE_MAX for domain_option in renderable_options):
|
|
600
|
+
raise ValueError(f"{source_location} contains a button title that is too long")
|
|
601
|
+
else:
|
|
602
|
+
if len(renderable_options) > LIST_ROWS_TOTAL_MAX:
|
|
603
|
+
raise ValueError(f"{source_location} exceeds the list option limit")
|
|
604
|
+
if any(len(domain_option.title) > ROW_TITLE_MAX for domain_option in renderable_options):
|
|
605
|
+
raise ValueError(f"{source_location} contains a list row title that is too long")
|
|
606
|
+
if any(
|
|
607
|
+
len(domain_option.description) > ROW_DESC_MAX for domain_option in renderable_options
|
|
608
|
+
):
|
|
609
|
+
raise ValueError(f"{source_location} contains a list row description that is too long")
|
|
610
|
+
configured_values: set[Any] = set()
|
|
611
|
+
for option_index, conditional_option in enumerate(interactive.get("options_when", [])):
|
|
612
|
+
option_value = conditional_option["value"]
|
|
613
|
+
if option_value not in domain_values:
|
|
614
|
+
raise ValueError(
|
|
615
|
+
f"{source_location}.options_when[{option_index}].value is not in domain "
|
|
616
|
+
f"{interactive_domain_name!r}: {option_value!r}"
|
|
617
|
+
)
|
|
618
|
+
if option_value in configured_values:
|
|
619
|
+
raise ValueError(
|
|
620
|
+
f"{source_location}.options_when repeats domain value {option_value!r}"
|
|
621
|
+
)
|
|
622
|
+
configured_values.add(option_value)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _validate_interactive_bindings(doc: dict[str, Any], ctx: FlowContext) -> None:
|
|
626
|
+
field_bindings: dict[str, tuple[str, str]] = {}
|
|
627
|
+
|
|
628
|
+
for step_index, step in enumerate(doc["path"]):
|
|
629
|
+
interactive = step.get("interactive")
|
|
630
|
+
if not interactive:
|
|
631
|
+
continue
|
|
632
|
+
slot_name = step.get("slot", step.get("confirm"))
|
|
633
|
+
if slot_name is not None:
|
|
634
|
+
source_location = f"$.path[{step_index}].interactive"
|
|
635
|
+
_validate_interactive_domain_binding(ctx, interactive, slot_name, source_location)
|
|
636
|
+
_register_interactive_binding(
|
|
637
|
+
field_bindings,
|
|
638
|
+
interactive["field"],
|
|
639
|
+
slot_name,
|
|
640
|
+
source_location,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
confirm_definition = doc.get("confirm")
|
|
644
|
+
if confirm_definition and confirm_definition.get("interactive"):
|
|
645
|
+
_validate_interactive_domain_binding(
|
|
646
|
+
ctx,
|
|
647
|
+
confirm_definition["interactive"],
|
|
648
|
+
confirm_definition["slot"],
|
|
649
|
+
"$.confirm.interactive",
|
|
650
|
+
)
|
|
651
|
+
_register_interactive_binding(
|
|
652
|
+
field_bindings,
|
|
653
|
+
confirm_definition["interactive"]["field"],
|
|
654
|
+
confirm_definition["slot"],
|
|
655
|
+
"$.confirm.interactive",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _validate_confirmation_hub_binding(doc: dict[str, Any]) -> None:
|
|
660
|
+
correctable_steps = [step for step in doc["path"] if step.get("correctable") is True]
|
|
661
|
+
confirm_definition = doc.get("confirm")
|
|
662
|
+
if confirm_definition is None and correctable_steps:
|
|
663
|
+
raise ValueError("a correctable path confirmation requires the top-level confirm block")
|
|
664
|
+
if len(correctable_steps) > 1:
|
|
665
|
+
raise ValueError("the top-level confirm block cannot bind multiple correctable path steps")
|
|
666
|
+
if confirm_definition is None or confirm_definition.get("on_confirm") is None:
|
|
667
|
+
return
|
|
668
|
+
terminal_identifier = cast(
|
|
669
|
+
str,
|
|
670
|
+
cast(dict[str, Any], doc.get("terminal", {})).get("step", "terminal"),
|
|
671
|
+
)
|
|
672
|
+
path_positions = {
|
|
673
|
+
step_identifier: path_index
|
|
674
|
+
for path_index, path_step in enumerate(doc["path"])
|
|
675
|
+
if (
|
|
676
|
+
step_identifier := _compiled_path_step_identifier(
|
|
677
|
+
path_step,
|
|
678
|
+
terminal_identifier,
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
is not None
|
|
682
|
+
}
|
|
683
|
+
confirmation_step = cast(str, confirm_definition["step"])
|
|
684
|
+
confirmation_target = cast(str, confirm_definition["on_confirm"])
|
|
685
|
+
if (
|
|
686
|
+
confirmation_step in path_positions
|
|
687
|
+
and confirmation_target in path_positions
|
|
688
|
+
and path_positions[confirmation_target] <= path_positions[confirmation_step]
|
|
689
|
+
):
|
|
690
|
+
raise ValueError("$.confirm.on_confirm must target a later path step")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _effective_hub_confirm_definition(
|
|
694
|
+
step: dict[str, Any],
|
|
695
|
+
confirm_definition: dict[str, Any],
|
|
696
|
+
) -> dict[str, Any]:
|
|
697
|
+
if step["id"] != confirm_definition["step"]:
|
|
698
|
+
raise ValueError(
|
|
699
|
+
"correctable path confirmation step must match $.confirm.step: "
|
|
700
|
+
f"{step['id']!r} != {confirm_definition['step']!r}"
|
|
701
|
+
)
|
|
702
|
+
if step["confirm"] != confirm_definition["slot"]:
|
|
703
|
+
raise ValueError(
|
|
704
|
+
"correctable path confirmation slot must match $.confirm.slot: "
|
|
705
|
+
f"{step['confirm']!r} != {confirm_definition['slot']!r}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
effective_definition = copy.deepcopy(confirm_definition)
|
|
709
|
+
for presentation_key in ("prompt", "interactive"):
|
|
710
|
+
path_presentation = step.get(presentation_key)
|
|
711
|
+
confirm_presentation = confirm_definition.get(presentation_key)
|
|
712
|
+
if (
|
|
713
|
+
path_presentation is not None
|
|
714
|
+
and confirm_presentation is not None
|
|
715
|
+
and path_presentation != confirm_presentation
|
|
716
|
+
):
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"correctable path {presentation_key} conflicts with $.confirm.{presentation_key}"
|
|
719
|
+
)
|
|
720
|
+
if path_presentation is not None:
|
|
721
|
+
effective_definition[presentation_key] = copy.deepcopy(path_presentation)
|
|
722
|
+
return effective_definition
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def compile_flow(
|
|
726
|
+
doc: dict[str, Any],
|
|
727
|
+
*,
|
|
728
|
+
tools: Optional[ToolRegistry] = None,
|
|
729
|
+
subflows: Optional[SubflowRegistry] = None,
|
|
730
|
+
log_id_generator: Optional[SnowflakeIdGenerator] = None,
|
|
731
|
+
clock: Optional[UtcClock] = None,
|
|
732
|
+
validate_semantics: bool = True,
|
|
733
|
+
) -> CompiledFlow:
|
|
734
|
+
tools = tools or default_tool_registry()
|
|
735
|
+
subflows = subflows or default_subflows()
|
|
736
|
+
log_id_generator = log_id_generator or default_log_id_generator()
|
|
737
|
+
clock = clock or system_utc_now
|
|
738
|
+
|
|
739
|
+
# Work on a private copy: the compiler annotates path steps with synthesized
|
|
740
|
+
# node ids, and must never mutate the caller's document (which is re-validated
|
|
741
|
+
# against an additionalProperties:false schema).
|
|
742
|
+
doc = copy.deepcopy(doc)
|
|
743
|
+
|
|
744
|
+
if validate_semantics:
|
|
745
|
+
from .profiles import reference_profile
|
|
746
|
+
from .semantics import FlowLinkError, semantic_diagnostics
|
|
747
|
+
|
|
748
|
+
linking_diagnostics = semantic_diagnostics(
|
|
749
|
+
doc,
|
|
750
|
+
profile=reference_profile(tools=tools, subflows=subflows),
|
|
751
|
+
)
|
|
752
|
+
if linking_diagnostics:
|
|
753
|
+
raise FlowLinkError(linking_diagnostics)
|
|
754
|
+
|
|
755
|
+
subflow_configurations = _validate_subflow_bindings(doc, subflows)
|
|
756
|
+
validate_flow_tool_contracts(doc, tools, subflows)
|
|
757
|
+
await_external = bind_await_external(doc)
|
|
758
|
+
|
|
759
|
+
slots = dict(doc.get("slots", {}))
|
|
760
|
+
ctx = FlowContext(
|
|
761
|
+
domains=dict(doc.get("domains", {})),
|
|
762
|
+
slots=slots,
|
|
763
|
+
config=dict(doc.get("config", {})),
|
|
764
|
+
tools=tools,
|
|
765
|
+
log_id_generator=log_id_generator,
|
|
766
|
+
clock=clock,
|
|
767
|
+
flow_name=cast(str, doc["flow"]),
|
|
768
|
+
flow_revision=(
|
|
769
|
+
f"{doc['version']}:"
|
|
770
|
+
+ hashlib.sha256(
|
|
771
|
+
json.dumps(
|
|
772
|
+
doc,
|
|
773
|
+
ensure_ascii=False,
|
|
774
|
+
allow_nan=False,
|
|
775
|
+
separators=(",", ":"),
|
|
776
|
+
sort_keys=True,
|
|
777
|
+
).encode("utf-8")
|
|
778
|
+
).hexdigest()
|
|
779
|
+
),
|
|
780
|
+
await_external=await_external,
|
|
781
|
+
)
|
|
782
|
+
await_external_path_step = next(
|
|
783
|
+
(step for step in doc["path"] if step.get("await_external") is True),
|
|
784
|
+
None,
|
|
785
|
+
)
|
|
786
|
+
validate_await_external_definition(
|
|
787
|
+
doc,
|
|
788
|
+
await_external,
|
|
789
|
+
tools,
|
|
790
|
+
subflows,
|
|
791
|
+
await_external_path_step,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
terminal = doc.get("terminal")
|
|
795
|
+
terminal_id = terminal["step"] if terminal else None
|
|
796
|
+
gates = (doc.get("overrides", {}) or {}).get("gates", {}) or {}
|
|
797
|
+
confirm_block = doc.get("confirm")
|
|
798
|
+
|
|
799
|
+
seq: list[NodeDesc] = []
|
|
800
|
+
|
|
801
|
+
# init node (service seed + best-effort entry tool) is always the entry point
|
|
802
|
+
auto_flow = cast(dict[str, Any], doc.get("auto_flow") or {})
|
|
803
|
+
auto_flow_resume_targets = frozenset(
|
|
804
|
+
target
|
|
805
|
+
for target in (
|
|
806
|
+
auto_flow.get("resume_at"),
|
|
807
|
+
cast(dict[str, Any], auto_flow.get("recovery") or {}).get("fallback_at"),
|
|
808
|
+
)
|
|
809
|
+
if isinstance(target, str)
|
|
810
|
+
)
|
|
811
|
+
seq.append(
|
|
812
|
+
make_init_node(
|
|
813
|
+
ctx,
|
|
814
|
+
doc.get("entry"),
|
|
815
|
+
dict(doc.get("service", {})),
|
|
816
|
+
auto_flow_resume_targets=auto_flow_resume_targets,
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# Pre-pass: assign step ids and register node_for_slot so the correction hub
|
|
821
|
+
# (built later in path order) can resolve every correctable target.
|
|
822
|
+
for step in doc["path"]:
|
|
823
|
+
if "slot" in step:
|
|
824
|
+
step["id"] = step.get("step", step["slot"])
|
|
825
|
+
ctx.node_for_slot.setdefault(step["slot"], step["id"])
|
|
826
|
+
elif "confirm" in step:
|
|
827
|
+
step["id"] = step.get("step", step["confirm"])
|
|
828
|
+
ctx.node_for_slot.setdefault(step["confirm"], step["id"])
|
|
829
|
+
elif "terminal" in step:
|
|
830
|
+
step["id"] = terminal_id or "terminal"
|
|
831
|
+
elif "derive" in step:
|
|
832
|
+
step["id"] = step.get("step", f"derive_{step['derive']}")
|
|
833
|
+
elif "await_external" in step:
|
|
834
|
+
step["id"] = step["step"]
|
|
835
|
+
# `use` ids come from the subflow
|
|
836
|
+
|
|
837
|
+
_validate_auto_flow_resume(doc)
|
|
838
|
+
_validate_gate_bindings(doc, gates)
|
|
839
|
+
_validate_native_path_slot_references(doc, ctx)
|
|
840
|
+
_validate_confirmation_domains(doc, ctx)
|
|
841
|
+
_validate_confirmation_hub_binding(doc)
|
|
842
|
+
_validate_interactive_bindings(doc, ctx)
|
|
843
|
+
|
|
844
|
+
# Main pass: build nodes in path order.
|
|
845
|
+
for step in doc["path"]:
|
|
846
|
+
if "use" in step:
|
|
847
|
+
ref = step["use"]
|
|
848
|
+
with_cfg = subflow_configurations[ref]
|
|
849
|
+
build = subflows.get(ref).build(ctx, with_cfg)
|
|
850
|
+
if not build.descriptors:
|
|
851
|
+
raise ValueError(f"subflow {ref!r} build must contain at least one descriptor")
|
|
852
|
+
_register_subflow_exposures(
|
|
853
|
+
ctx,
|
|
854
|
+
ref,
|
|
855
|
+
subflows.definition(ref),
|
|
856
|
+
build.entry_id,
|
|
857
|
+
build.descriptors[0].id,
|
|
858
|
+
build.node_for_slot,
|
|
859
|
+
{descriptor.id for descriptor in build.descriptors},
|
|
860
|
+
)
|
|
861
|
+
seq.extend(build.descriptors)
|
|
862
|
+
continue
|
|
863
|
+
if "slot" in step:
|
|
864
|
+
seq.append(make_collect_node(ctx, step, _gate_for(step, gates)))
|
|
865
|
+
elif "confirm" in step:
|
|
866
|
+
if step.get("correctable") and confirm_block:
|
|
867
|
+
effective_confirm = _effective_hub_confirm_definition(step, confirm_block)
|
|
868
|
+
seq.append(make_hub_confirm_node(ctx, effective_confirm))
|
|
869
|
+
elif step.get("on_reject"):
|
|
870
|
+
seq.append(make_summary_confirm_node(ctx, step))
|
|
871
|
+
else:
|
|
872
|
+
seq.append(make_bool_confirm_node(ctx, step, _gate_for(step, gates)))
|
|
873
|
+
elif "derive" in step:
|
|
874
|
+
derive_def = next(d for d in doc.get("derive", []) if d["writes"] == step["derive"])
|
|
875
|
+
seq.append(make_derive_node(ctx, derive_def, node_id=step["id"]))
|
|
876
|
+
elif "await_external" in step:
|
|
877
|
+
if await_external is None: # defended by bind_await_external
|
|
878
|
+
raise ValueError("path await_external requires capabilities.await_external")
|
|
879
|
+
seq.append(make_await_external_node(ctx, await_external, step))
|
|
880
|
+
elif "terminal" in step:
|
|
881
|
+
if terminal:
|
|
882
|
+
seq.append(make_terminal_node(ctx, terminal))
|
|
883
|
+
|
|
884
|
+
_validate_compiled_slot_references(doc, ctx)
|
|
885
|
+
validate_entry_args_schema(doc, ctx, subflows)
|
|
886
|
+
|
|
887
|
+
# Compute dependency indexes now that subflow slots are registered.
|
|
888
|
+
ctx.dependents = _transitive_dependents(ctx.slots)
|
|
889
|
+
ctx.derive_readers = _derive_readers(doc.get("derive", []))
|
|
890
|
+
|
|
891
|
+
# Insert derive[] nodes at their `after` anchor (those not already placed as path steps).
|
|
892
|
+
placed = {s["derive"] for s in doc["path"] if "derive" in s}
|
|
893
|
+
last_inserted_for_anchor: dict[str, str] = {}
|
|
894
|
+
for derive_index, derive_def in enumerate(doc.get("derive", [])):
|
|
895
|
+
if derive_def["writes"] in placed:
|
|
896
|
+
continue
|
|
897
|
+
after = derive_def.get("after")
|
|
898
|
+
node = make_derive_node(ctx, derive_def)
|
|
899
|
+
if after is None:
|
|
900
|
+
raise ValueError(
|
|
901
|
+
f"$.derive[{derive_index}] must be placed in path or declare an after anchor"
|
|
902
|
+
)
|
|
903
|
+
effective_anchor = last_inserted_for_anchor.get(after, after)
|
|
904
|
+
idx = next((i for i, d in enumerate(seq) if d.id == effective_anchor), None)
|
|
905
|
+
if idx is None:
|
|
906
|
+
raise ValueError(
|
|
907
|
+
f"$.derive[{derive_index}].after does not resolve to a compiled node: {after!r}"
|
|
908
|
+
)
|
|
909
|
+
seq.insert(idx + 1, node)
|
|
910
|
+
last_inserted_for_anchor[after] = node.id
|
|
911
|
+
|
|
912
|
+
_validate_derive_execution_order(doc, ctx, seq)
|
|
913
|
+
_validate_terminal_derive_execution_order(doc, seq)
|
|
914
|
+
node_ids = [descriptor.id for descriptor in seq]
|
|
915
|
+
if len(node_ids) != len(set(node_ids)):
|
|
916
|
+
duplicate_node_ids = sorted(
|
|
917
|
+
node_id for node_id in set(node_ids) if node_ids.count(node_id) > 1
|
|
918
|
+
)
|
|
919
|
+
raise ValueError(
|
|
920
|
+
f"compiled flow contains duplicate node ids: {', '.join(duplicate_node_ids)}"
|
|
921
|
+
)
|
|
922
|
+
validate_await_external_targets(
|
|
923
|
+
await_external,
|
|
924
|
+
set(node_ids),
|
|
925
|
+
ctx.await_external_nodes,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
# ── wire the StateGraph ──────────────────────────────────────────────
|
|
929
|
+
graph = StateGraph(ServiceState)
|
|
930
|
+
for desc in seq:
|
|
931
|
+
graph.add_node(desc.id, cast(Any, desc.fn))
|
|
932
|
+
graph.set_entry_point(seq[0].id)
|
|
933
|
+
|
|
934
|
+
for i, desc in enumerate(seq):
|
|
935
|
+
next_id = seq[i + 1].id if i + 1 < len(seq) else END
|
|
936
|
+
path_map = list(dict.fromkeys([*desc.targets, next_id, END]))
|
|
937
|
+
|
|
938
|
+
def make_router(
|
|
939
|
+
router: Callable[[ServiceState], str],
|
|
940
|
+
resolved_next: str,
|
|
941
|
+
) -> Callable[[ServiceState], str]:
|
|
942
|
+
def routed(state: ServiceState) -> str:
|
|
943
|
+
target = router(state)
|
|
944
|
+
return resolved_next if target == NEXT else target
|
|
945
|
+
|
|
946
|
+
return routed
|
|
947
|
+
|
|
948
|
+
graph.add_conditional_edges(desc.id, make_router(desc.router, next_id), path_map)
|
|
949
|
+
|
|
950
|
+
compiled = graph.compile()
|
|
951
|
+
return CompiledFlow(
|
|
952
|
+
graph=compiled,
|
|
953
|
+
ctx=ctx,
|
|
954
|
+
_doc=copy.deepcopy(doc),
|
|
955
|
+
terminal_id=terminal_id,
|
|
956
|
+
entry_node_id=seq[0].id,
|
|
957
|
+
)
|