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.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. 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
+ )