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
@@ -0,0 +1,928 @@
1
+ """Deterministic lowering from the experimental v3 preview to FlowSpec2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Mapping, cast
9
+
10
+ from ..checker import check_flow
11
+ from ..diagnostics import FlowCheckReport, FlowDiagnostic
12
+ from ..profiles import FlowProfile
13
+ from .v3_preview import (
14
+ V2_SCHEMA_IDENTIFIER,
15
+ V3PreviewDocumentMode,
16
+ V3PreviewLossCategory,
17
+ V3PreviewLossHandling,
18
+ migrate_v2_to_v3_preview,
19
+ preview_loss_policy,
20
+ validate_v3_preview,
21
+ )
22
+
23
+ _SLOT_CONTRACT_KEYS = (
24
+ "persist",
25
+ "required",
26
+ "nullable",
27
+ "prefill_sources",
28
+ "max_attempts",
29
+ "on_exhaust",
30
+ "default",
31
+ "fill_only_when_asked",
32
+ )
33
+ _PREDICATE_REFERENCE_NAMESPACES = {
34
+ "$config": "config",
35
+ "$payload": "payload",
36
+ "$internal": "internal",
37
+ "$address": "address",
38
+ }
39
+
40
+
41
+ class V3PreviewLoweringError(ValueError):
42
+ """A valid preview cannot produce an executable FlowSpec2 document."""
43
+
44
+ def __init__(self, diagnostics: tuple[FlowDiagnostic, ...]) -> None:
45
+ self.diagnostics = diagnostics
46
+ summary = "; ".join(
47
+ f"{diagnostic.code} at {diagnostic.path or '/'}: {diagnostic.message}"
48
+ for diagnostic in diagnostics
49
+ )
50
+ super().__init__(summary)
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class V3PreviewLoweringReport:
55
+ """Immutable executable lowering result with fixed-point evidence."""
56
+
57
+ _source_json: str = field(repr=False)
58
+ rehydrated_paths: tuple[str, ...]
59
+ flow_check: FlowCheckReport
60
+
61
+ @property
62
+ def source_document(self) -> dict[str, Any]:
63
+ """Return a fresh lowered document so callers cannot mutate the report."""
64
+ return cast(dict[str, Any], json.loads(self._source_json))
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class _LoweringContext:
69
+ source_document: dict[str, Any]
70
+ reserved_domain_names: set[str]
71
+ domains: dict[str, dict[str, Any]] = field(default_factory=dict)
72
+ slots: dict[str, dict[str, Any]] = field(default_factory=dict)
73
+ path: list[dict[str, Any]] = field(default_factory=list)
74
+ uses: list[dict[str, Any]] = field(default_factory=list)
75
+ derives: list[dict[str, Any]] = field(default_factory=list)
76
+ override_gates: dict[str, dict[str, Any]] = field(default_factory=dict)
77
+ confirmation: dict[str, Any] | None = None
78
+ terminal: dict[str, Any] | None = None
79
+ await_capability: dict[str, Any] | None = None
80
+
81
+
82
+ def lower_v3_preview_to_v2(
83
+ preview_document: Mapping[str, Any],
84
+ *,
85
+ profile: FlowProfile | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Lower a preview and return a fresh executable FlowSpec2 document."""
88
+ return lower_v3_preview_to_v2_report(preview_document, profile=profile).source_document
89
+
90
+
91
+ def lower_v3_preview_to_v2_report(
92
+ preview_document: Mapping[str, Any],
93
+ *,
94
+ profile: FlowProfile | None = None,
95
+ ) -> V3PreviewLoweringReport:
96
+ """Lower, compile-check, and prove the exact v3-to-v2-to-v3 fixed point."""
97
+ private_preview = _canonical_clone(preview_document)
98
+ if not isinstance(private_preview, dict):
99
+ _raise_lowering(
100
+ "FLOWSPEC3_LOWERING_INVALID_DOCUMENT",
101
+ "",
102
+ "preview source must be a JSON object",
103
+ )
104
+ validation_mode = (
105
+ V3PreviewDocumentMode.V2_MIGRATION
106
+ if "v2_passthrough" in private_preview
107
+ else V3PreviewDocumentMode.AUTHORING
108
+ )
109
+ validate_v3_preview(private_preview, mode=validation_mode)
110
+ loss_entries = _loss_entries(private_preview)
111
+ _reject_excluded_loss_entries(loss_entries)
112
+ _validate_lowerable_surface(private_preview)
113
+
114
+ source_document = _lower_native_preview(private_preview, loss_entries)
115
+ rehydrated_paths = _rehydrate_loss_entries(source_document, loss_entries)
116
+ if not source_document.get("domains"):
117
+ _raise_lowering(
118
+ "FLOWSPEC3_LOWERING_DOMAINS_REQUIRED",
119
+ "/steps",
120
+ "FlowSpec2 requires at least one local or rehydrated domain declaration",
121
+ )
122
+ flow_check = check_flow(source_document, profile=profile)
123
+ if not flow_check.is_valid or flow_check.compilation != "succeeded":
124
+ raise V3PreviewLoweringError(flow_check.diagnostics)
125
+
126
+ round_trip_preview = migrate_v2_to_v3_preview(source_document)
127
+ if round_trip_preview != private_preview:
128
+ mismatch_path = _first_difference_path(private_preview, round_trip_preview)
129
+ _raise_lowering(
130
+ "FLOWSPEC3_LOWERING_FIXED_POINT_MISMATCH",
131
+ mismatch_path,
132
+ "lowered FlowSpec2 does not migrate back to the exact preview source",
133
+ )
134
+
135
+ return V3PreviewLoweringReport(
136
+ _source_json=_canonical_json(source_document),
137
+ rehydrated_paths=rehydrated_paths,
138
+ flow_check=flow_check,
139
+ )
140
+
141
+
142
+ def _loss_entries(preview_document: dict[str, Any]) -> list[dict[str, Any]]:
143
+ passthrough = cast(dict[str, Any] | None, preview_document.get("v2_passthrough"))
144
+ return [] if passthrough is None else cast(list[dict[str, Any]], passthrough["entries"])
145
+
146
+
147
+ def _reject_excluded_loss_entries(loss_entries: list[dict[str, Any]]) -> None:
148
+ policy_categories = cast(dict[str, dict[str, str]], preview_loss_policy()["categories"])
149
+ diagnostics = tuple(
150
+ FlowDiagnostic(
151
+ code="FLOWSPEC3_LOWERING_EXCLUDED_LOSS",
152
+ severity="error",
153
+ path=f"/v2_passthrough/entries/{loss_index}",
154
+ message=(
155
+ f"loss category {loss_entry['category']!r} requires rejection and cannot be "
156
+ "rehydrated"
157
+ ),
158
+ )
159
+ for loss_index, loss_entry in enumerate(loss_entries)
160
+ if V3PreviewLossHandling(policy_categories[cast(str, loss_entry["category"])]["handling"])
161
+ is V3PreviewLossHandling.REJECT
162
+ )
163
+ if diagnostics:
164
+ raise V3PreviewLoweringError(diagnostics)
165
+
166
+
167
+ def _validate_lowerable_surface(preview_document: dict[str, Any]) -> None:
168
+ for step_index, preview_step in enumerate(
169
+ cast(list[dict[str, Any]], preview_document["steps"])
170
+ ):
171
+ step_path = f"/steps/{step_index}"
172
+ if domain := preview_step.get("domain"):
173
+ _validate_lowerable_domain(cast(dict[str, Any], domain), f"{step_path}/domain")
174
+ preview_ui = preview_step.get("ui")
175
+ if isinstance(preview_ui, dict) and preview_ui.get("kind") == "flow":
176
+ _raise_lowering(
177
+ "FLOWSPEC3_LOWERING_UNSUPPORTED_FLOW_UI",
178
+ f"{step_path}/ui",
179
+ "FlowSpec2 path steps have no equivalent Meta Flow UI contract",
180
+ )
181
+ if "derive" in preview_step:
182
+ lookup = cast(dict[str, Any], preview_step["lookup"])
183
+ if any(not isinstance(lookup_value, str) for lookup_value in lookup.values()):
184
+ _raise_lowering(
185
+ "FLOWSPEC3_LOWERING_NON_STRING_DERIVE_VALUE",
186
+ f"{step_path}/lookup",
187
+ "FlowSpec2 derive lookup values must be strings",
188
+ )
189
+ derive_default = preview_step.get("default")
190
+ if derive_default is not None and (
191
+ not isinstance(derive_default, (str, dict))
192
+ or isinstance(derive_default, dict)
193
+ and set(derive_default) not in ({"$slot"}, {"$derive"})
194
+ ):
195
+ _raise_lowering(
196
+ "FLOWSPEC3_LOWERING_NON_STRING_DERIVE_DEFAULT",
197
+ f"{step_path}/default",
198
+ "FlowSpec2 derive defaults must be strings or source references",
199
+ )
200
+ if isinstance(derive_default, str) and re.fullmatch(
201
+ r"\$from\[[0-9]+\]", derive_default
202
+ ):
203
+ _raise_lowering(
204
+ "FLOWSPEC3_LOWERING_AMBIGUOUS_DERIVE_DEFAULT",
205
+ f"{step_path}/default",
206
+ "FlowSpec2 interprets this string as a derive source reference",
207
+ )
208
+ for predicate_key in ("ask_when", "skip_when", "gate"):
209
+ if predicate_key in preview_step:
210
+ _validate_lowerable_predicate_references(
211
+ cast(dict[str, Any], preview_step[predicate_key]),
212
+ f"{step_path}/{predicate_key}",
213
+ )
214
+ preview_ui = cast(dict[str, Any] | None, preview_step.get("ui"))
215
+ if isinstance(preview_ui, dict):
216
+ for option_index, option_gate in enumerate(
217
+ cast(list[dict[str, Any]], preview_ui.get("options_when", []))
218
+ ):
219
+ _validate_lowerable_predicate_references(
220
+ cast(dict[str, Any], option_gate["gate"]),
221
+ f"{step_path}/ui/options_when/{option_index}/gate",
222
+ )
223
+ if on_resume := preview_step.get("on_resume"):
224
+ _validate_lowerable_bindings(
225
+ cast(dict[str, Any], on_resume).get("set"),
226
+ "$token.",
227
+ f"{step_path}/on_resume/set",
228
+ )
229
+ enrichment = cast(dict[str, Any], on_resume).get("enrich")
230
+ if isinstance(enrichment, dict):
231
+ _validate_lowerable_bindings(
232
+ enrichment.get("input"), "$token.", f"{step_path}/on_resume/enrich/input"
233
+ )
234
+ _validate_lowerable_bindings(
235
+ enrichment.get("set"), "$result.", f"{step_path}/on_resume/enrich/set"
236
+ )
237
+
238
+
239
+ def _validate_lowerable_domain(domain: dict[str, Any], domain_path: str) -> None:
240
+ domain_type = cast(str, domain["type"])
241
+ if domain_type not in {"categorical", "bool"}:
242
+ return
243
+ if (
244
+ domain_type == "categorical"
245
+ and domain.get("accepts_null") is True
246
+ and cast(dict[str, Any], domain.get("normalize", {})).get("number_words") is True
247
+ ):
248
+ _raise_lowering(
249
+ "FLOWSPEC3_LOWERING_NULLABLE_NUMBER_WORDS",
250
+ f"{domain_path}/normalize/number_words",
251
+ "nullable categorical number-word normalization has no stable FlowSpec2 round-trip",
252
+ )
253
+ for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
254
+ option_value = option["value"]
255
+ expected_label = (
256
+ "Yes" if option_value is True else "No" if option_value is False else str(option_value)
257
+ )
258
+ if option["label"] != expected_label:
259
+ _raise_lowering(
260
+ "FLOWSPEC3_LOWERING_CUSTOM_OPTION_LABEL",
261
+ f"{domain_path}/options/{option_index}/label",
262
+ f"FlowSpec2 derives label {expected_label!r} from the canonical option value",
263
+ )
264
+ if domain_type == "bool" and "description" in option:
265
+ _raise_lowering(
266
+ "FLOWSPEC3_LOWERING_BOOLEAN_OPTION_DESCRIPTION",
267
+ f"{domain_path}/options/{option_index}/description",
268
+ "FlowSpec2 boolean domains cannot represent per-option descriptions",
269
+ )
270
+ aliases = cast(list[str], option.get("aliases", []))
271
+ if aliases != sorted(aliases):
272
+ _raise_lowering(
273
+ "FLOWSPEC3_LOWERING_UNORDERED_ALIASES",
274
+ f"{domain_path}/options/{option_index}/aliases",
275
+ "aliases must use canonical lexical order for an exact round-trip",
276
+ )
277
+ null_aliases = cast(list[str], domain.get("null_aliases", []))
278
+ if null_aliases != sorted(null_aliases):
279
+ _raise_lowering(
280
+ "FLOWSPEC3_LOWERING_UNORDERED_ALIASES",
281
+ f"{domain_path}/null_aliases",
282
+ "null aliases must use canonical lexical order for an exact round-trip",
283
+ )
284
+
285
+
286
+ def _validate_lowerable_bindings(bindings: Any, reserved_prefix: str, path: str) -> None:
287
+ if not isinstance(bindings, dict):
288
+ return
289
+ for binding_name, binding_value in bindings.items():
290
+ reference_key = reserved_prefix.removesuffix(".")
291
+ if isinstance(binding_value, (dict, list)) and not (
292
+ isinstance(binding_value, dict) and set(binding_value) == {reference_key}
293
+ ):
294
+ _raise_lowering(
295
+ "FLOWSPEC3_LOWERING_COMPOSITE_BINDING_LITERAL",
296
+ f"{path}/{_encode_pointer_segment(binding_name)}",
297
+ "FlowSpec2 resume and enrichment binding literals must be JSON scalars",
298
+ )
299
+ if isinstance(binding_value, str) and binding_value.startswith(reserved_prefix):
300
+ _raise_lowering(
301
+ "FLOWSPEC3_LOWERING_AMBIGUOUS_BINDING_LITERAL",
302
+ f"{path}/{_encode_pointer_segment(binding_name)}",
303
+ f"FlowSpec2 interprets strings beginning with {reserved_prefix!r} as references",
304
+ )
305
+
306
+
307
+ def _validate_lowerable_predicate_references(predicate: dict[str, Any], path: str) -> None:
308
+ operator, operand = next(iter(predicate.items()))
309
+ if operator in {"and", "or"}:
310
+ for child_index, child_predicate in enumerate(cast(list[dict[str, Any]], operand)):
311
+ _validate_lowerable_predicate_references(
312
+ child_predicate, f"{path}/{operator}/{child_index}"
313
+ )
314
+ return
315
+ if operator == "not":
316
+ _validate_lowerable_predicate_references(cast(dict[str, Any], operand), f"{path}/not")
317
+ return
318
+ operands = [operand] if operator == "is_present" else cast(list[Any], operand)
319
+ for operand_index, predicate_operand in enumerate(operands):
320
+ if not isinstance(predicate_operand, dict):
321
+ continue
322
+ reference_key, reference_path = next(iter(predicate_operand.items()))
323
+ if reference_key in {"$config", "$payload", "$internal"} and "." in reference_path:
324
+ _raise_lowering(
325
+ "FLOWSPEC3_LOWERING_NESTED_STATE_REFERENCE",
326
+ f"{path}/{operator}/{operand_index}",
327
+ f"FlowSpec2 supports only one path segment for {reference_key} references",
328
+ )
329
+
330
+
331
+ def _lower_native_preview(
332
+ preview_document: dict[str, Any], loss_entries: list[dict[str, Any]]
333
+ ) -> dict[str, Any]:
334
+ source_document: dict[str, Any] = {
335
+ "schema": V2_SCHEMA_IDENTIFIER,
336
+ "flow": preview_document["flow"],
337
+ "version": preview_document["version"],
338
+ "route": _canonical_clone(preview_document["route"]),
339
+ }
340
+ for top_level_key in ("service", "config"):
341
+ if top_level_key in preview_document:
342
+ source_document[top_level_key] = _canonical_clone(preview_document[top_level_key])
343
+ lowering_context = _LoweringContext(
344
+ source_document=source_document,
345
+ reserved_domain_names={
346
+ _decode_pointer(cast(str, loss_entry["source_path"]))[-1]
347
+ for loss_entry in loss_entries
348
+ if loss_entry["category"] == V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION.value
349
+ },
350
+ )
351
+ for step_index, preview_step in enumerate(
352
+ cast(list[dict[str, Any]], preview_document["steps"])
353
+ ):
354
+ _lower_step(preview_step, step_index, lowering_context)
355
+ source_document["path"] = lowering_context.path
356
+ for key, section in (
357
+ ("domains", lowering_context.domains),
358
+ ("slots", lowering_context.slots),
359
+ ("uses", lowering_context.uses),
360
+ ("derive", lowering_context.derives),
361
+ ):
362
+ if section:
363
+ source_document[key] = section
364
+ if lowering_context.confirmation is not None:
365
+ source_document["confirm"] = lowering_context.confirmation
366
+ if lowering_context.terminal is not None:
367
+ source_document["terminal"] = lowering_context.terminal
368
+ if lowering_context.override_gates:
369
+ source_document["overrides"] = {"gates": lowering_context.override_gates}
370
+ if lowering_context.await_capability is not None:
371
+ source_document["capabilities"] = {"await_external": lowering_context.await_capability}
372
+ return cast(dict[str, Any], _canonical_clone(source_document))
373
+
374
+
375
+ def _lower_step(
376
+ preview_step: dict[str, Any], step_index: int, lowering_context: _LoweringContext
377
+ ) -> None:
378
+ if "collect" in preview_step:
379
+ _lower_slot_step("collect", "slot", preview_step, step_index, lowering_context)
380
+ return
381
+ if "confirm" in preview_step:
382
+ _lower_slot_step("confirm", "confirm", preview_step, step_index, lowering_context)
383
+ return
384
+ if "use" in preview_step:
385
+ use_reference = cast(str, preview_step["use"])
386
+ lowering_context.path.append({"use": use_reference})
387
+ use_declaration: dict[str, Any] = {"ref": use_reference}
388
+ if "with" in preview_step:
389
+ use_declaration["with"] = _canonical_clone(preview_step["with"])
390
+ lowering_context.uses.append(use_declaration)
391
+ return
392
+ if "derive" in preview_step:
393
+ _lower_derive_step(preview_step, step_index, lowering_context)
394
+ return
395
+ if "submit" in preview_step:
396
+ _lower_submit_step(preview_step, lowering_context)
397
+ return
398
+ if "await" in preview_step:
399
+ _lower_await_step(preview_step, lowering_context)
400
+ return
401
+ _raise_lowering(
402
+ "FLOWSPEC3_LOWERING_UNKNOWN_STEP",
403
+ f"/steps/{step_index}",
404
+ "preview step has no lowerable kind",
405
+ )
406
+
407
+
408
+ def _lower_slot_step(
409
+ preview_kind: str,
410
+ v2_kind: str,
411
+ preview_step: dict[str, Any],
412
+ step_index: int,
413
+ lowering_context: _LoweringContext,
414
+ ) -> None:
415
+ slot_name = cast(str, preview_step[preview_kind])
416
+ domain_name = _available_domain_name(slot_name, lowering_context)
417
+ lowering_context.domains[domain_name] = _lower_domain(
418
+ cast(dict[str, Any], preview_step["domain"])
419
+ )
420
+ slot_definition = {
421
+ "domain": domain_name,
422
+ **{
423
+ contract_key: _canonical_clone(preview_step[contract_key])
424
+ for contract_key in _SLOT_CONTRACT_KEYS
425
+ if contract_key in preview_step
426
+ },
427
+ }
428
+ if "requires" in preview_step:
429
+ slot_definition["requires"] = [
430
+ _reference_target(slot_reference, "$slot")
431
+ for slot_reference in cast(list[dict[str, str]], preview_step["requires"])
432
+ ]
433
+ lowering_context.slots[slot_name] = slot_definition
434
+
435
+ path_step: dict[str, Any] = {v2_kind: slot_name}
436
+ if "id" in preview_step:
437
+ path_step["step"] = preview_step["id"]
438
+ if "prompt" in preview_step:
439
+ path_step["prompt"] = _canonical_clone(preview_step["prompt"])
440
+ if "ui" in preview_step:
441
+ path_step["interactive"] = _lower_ui(cast(dict[str, Any], preview_step["ui"]), domain_name)
442
+ for predicate_key in ("ask_when", "skip_when"):
443
+ if predicate_key in preview_step:
444
+ path_step[predicate_key] = _lower_predicate(
445
+ cast(dict[str, Any], preview_step[predicate_key])
446
+ )
447
+ if "gate" in preview_step:
448
+ step_identifier = cast(str, preview_step.get("id", slot_name))
449
+ lowering_context.override_gates[step_identifier] = _lower_predicate(
450
+ cast(dict[str, Any], preview_step["gate"])
451
+ )
452
+ if "on_reject" in preview_step:
453
+ path_step["on_reject"] = _canonical_clone(preview_step["on_reject"])
454
+ if preview_step.get("correction_hub") is True:
455
+ path_step["correctable"] = True
456
+ step_identifier = cast(str, preview_step.get("id", slot_name))
457
+ lowering_context.confirmation = {
458
+ "step": step_identifier,
459
+ "slot": slot_name,
460
+ "correctable": [
461
+ _reference_target(reference, "$slot")
462
+ for reference in cast(list[dict[str, str]], preview_step["correctable"])
463
+ ],
464
+ "on_confirm": _reference_target(
465
+ cast(dict[str, str], preview_step["on_confirm"]), "$step"
466
+ ),
467
+ }
468
+ lowering_context.path.append(path_step)
469
+
470
+
471
+ def _available_domain_name(slot_name: str, lowering_context: _LoweringContext) -> str:
472
+ domain_name_base = f"domain_{slot_name}"
473
+ domain_name = domain_name_base
474
+ suffix = 2
475
+ unavailable_names = {
476
+ *lowering_context.reserved_domain_names,
477
+ *lowering_context.domains,
478
+ }
479
+ while domain_name in unavailable_names:
480
+ domain_name = f"{domain_name_base}_{suffix}"
481
+ suffix += 1
482
+ return domain_name
483
+
484
+
485
+ def _lower_domain(preview_domain: dict[str, Any]) -> dict[str, Any]:
486
+ domain_type = cast(str, preview_domain["type"])
487
+ domain_definition: dict[str, Any] = {"type": domain_type}
488
+ for domain_key in ("optional", "minimum", "maximum"):
489
+ if domain_key in preview_domain:
490
+ domain_definition[domain_key] = preview_domain[domain_key]
491
+ normalization = cast(dict[str, Any], _canonical_clone(preview_domain.get("normalize", {})))
492
+ if domain_type in {"categorical", "bool"}:
493
+ options = cast(list[dict[str, Any]], preview_domain["options"])
494
+ if domain_type == "categorical":
495
+ domain_definition["values"] = [option["value"] for option in options]
496
+ if preview_domain.get("accepts_null") is True:
497
+ cast(list[Any], domain_definition["values"]).append(None)
498
+ synonyms = {
499
+ alias: option["value"]
500
+ for option in options
501
+ for alias in cast(list[str], option.get("aliases", []))
502
+ }
503
+ if domain_type == "categorical" and preview_domain.get("accepts_null") is True:
504
+ synonyms.update(
505
+ {alias: None for alias in cast(list[str], preview_domain.get("null_aliases", []))}
506
+ )
507
+ if synonyms:
508
+ normalization["synonyms"] = synonyms
509
+ if domain_type == "categorical":
510
+ rows = [
511
+ {"value": option["value"], "description": option["description"]}
512
+ for option in options
513
+ if "description" in option
514
+ ]
515
+ if rows:
516
+ domain_definition["rows"] = rows
517
+ if normalization:
518
+ domain_definition["normalize"] = normalization
519
+ return domain_definition
520
+
521
+
522
+ def _lower_ui(preview_ui: dict[str, Any], domain_name: str | None) -> dict[str, Any]:
523
+ interactive = {
524
+ interactive_key: _canonical_clone(interactive_value)
525
+ for interactive_key, interactive_value in preview_ui.items()
526
+ if interactive_key not in {"next_step", "prefill_from", "options_when"}
527
+ }
528
+ if preview_ui["kind"] in {"buttons", "list"}:
529
+ if domain_name is None:
530
+ _raise_lowering(
531
+ "FLOWSPEC3_LOWERING_MISSING_UI_DOMAIN",
532
+ "",
533
+ "choice UI requires a lowered domain",
534
+ )
535
+ interactive["from_domain"] = domain_name
536
+ if "next_step" in preview_ui:
537
+ interactive["next_step"] = _reference_target(
538
+ cast(dict[str, str], preview_ui["next_step"]), "$step"
539
+ )
540
+ if "prefill_from" in preview_ui:
541
+ interactive["prefill_from"] = [
542
+ _reference_target(reference, "$slot")
543
+ for reference in cast(list[dict[str, str]], preview_ui["prefill_from"])
544
+ ]
545
+ if "options_when" in preview_ui:
546
+ interactive["options_when"] = [
547
+ {
548
+ "value": option_gate["value"],
549
+ "gate": _lower_predicate(cast(dict[str, Any], option_gate["gate"])),
550
+ }
551
+ for option_gate in cast(list[dict[str, Any]], preview_ui["options_when"])
552
+ ]
553
+ return interactive
554
+
555
+
556
+ def _lower_derive_step(
557
+ preview_step: dict[str, Any], step_index: int, lowering_context: _LoweringContext
558
+ ) -> None:
559
+ source_references = cast(list[dict[str, str]], preview_step["from"])
560
+ source_names = [_source_reference_target(reference) for reference in source_references]
561
+ derive_definition: dict[str, Any] = {
562
+ "writes": preview_step["derive"],
563
+ "from": source_names,
564
+ "lookup": _canonical_clone(preview_step["lookup"]),
565
+ }
566
+ if "default" in preview_step:
567
+ derive_default = preview_step["default"]
568
+ if isinstance(derive_default, dict):
569
+ source_index = next(
570
+ (
571
+ candidate_index
572
+ for candidate_index, source_reference in enumerate(source_references)
573
+ if source_reference == cast(dict[str, str], derive_default)
574
+ ),
575
+ None,
576
+ )
577
+ if source_index is None:
578
+ _raise_lowering(
579
+ "FLOWSPEC3_LOWERING_DERIVE_DEFAULT_OUTSIDE_SOURCES",
580
+ f"/steps/{step_index}/default",
581
+ "derive default reference must also appear in the ordered from list",
582
+ )
583
+ derive_definition["default"] = f"$from[{source_index}]"
584
+ else:
585
+ derive_definition["default"] = derive_default
586
+ lowering_context.derives.append(derive_definition)
587
+ path_step: dict[str, Any] = {"derive": preview_step["derive"]}
588
+ if "id" in preview_step:
589
+ path_step["step"] = preview_step["id"]
590
+ lowering_context.path.append(path_step)
591
+
592
+
593
+ def _lower_submit_step(preview_step: dict[str, Any], lowering_context: _LoweringContext) -> None:
594
+ terminal: dict[str, Any] = {
595
+ "step": preview_step["id"],
596
+ "tool": preview_step["submit"],
597
+ "idempotent": preview_step["idempotent"],
598
+ "outcomes": _canonical_clone(preview_step["outcomes"]),
599
+ }
600
+ if "input" in preview_step:
601
+ terminal["input"] = [
602
+ {"param": parameter_name, "slot": _source_reference_target(source_reference)}
603
+ for parameter_name, source_reference in cast(
604
+ dict[str, dict[str, str]], preview_step["input"]
605
+ ).items()
606
+ ]
607
+ if "outputs" in preview_step:
608
+ terminal["outputs"] = {
609
+ state_key: f"result.{_reference_target(result_reference, '$result')}"
610
+ for state_key, result_reference in cast(
611
+ dict[str, dict[str, str]], preview_step["outputs"]
612
+ ).items()
613
+ }
614
+ if "empty_payload" in preview_step:
615
+ terminal["empty_payload"] = _canonical_clone(preview_step["empty_payload"])
616
+ lowering_context.terminal = terminal
617
+ lowering_context.path.append({"terminal": True})
618
+
619
+
620
+ def _lower_await_step(preview_step: dict[str, Any], lowering_context: _LoweringContext) -> None:
621
+ await_capability: dict[str, Any] = {
622
+ "kind": preview_step["await"],
623
+ "step": preview_step["id"],
624
+ "resume_on": preview_step["resume_on"],
625
+ }
626
+ if "on_resume" in preview_step:
627
+ await_capability["on_resume"] = _lower_on_resume(
628
+ cast(dict[str, Any], preview_step["on_resume"])
629
+ )
630
+ if "timeout" in preview_step:
631
+ await_capability["timeout"] = _lower_transition(
632
+ cast(dict[str, Any], preview_step["timeout"])
633
+ )
634
+ if "recovery" in preview_step:
635
+ await_capability["recovery"] = {
636
+ recovery_event: _lower_transition(transition)
637
+ for recovery_event, transition in cast(
638
+ dict[str, dict[str, Any]], preview_step["recovery"]
639
+ ).items()
640
+ }
641
+ path_step: dict[str, Any] = {
642
+ "step": preview_step["id"],
643
+ "await_external": True,
644
+ "interactive": _lower_ui(cast(dict[str, Any], preview_step["ui"]), None),
645
+ }
646
+ if "prompt" in preview_step:
647
+ path_step["prompt"] = _canonical_clone(preview_step["prompt"])
648
+ lowering_context.await_capability = await_capability
649
+ lowering_context.path.append(path_step)
650
+
651
+
652
+ def _lower_on_resume(preview_on_resume: dict[str, Any]) -> dict[str, Any]:
653
+ on_resume: dict[str, Any] = {}
654
+ if "set" in preview_on_resume:
655
+ on_resume["set"] = _lower_binding_map(
656
+ cast(dict[str, Any], preview_on_resume["set"]), "$token"
657
+ )
658
+ if "enrich" in preview_on_resume:
659
+ preview_enrichment = cast(dict[str, Any], preview_on_resume["enrich"])
660
+ enrichment: dict[str, Any] = {"tool": preview_enrichment["tool"]}
661
+ if "optional" in preview_enrichment:
662
+ enrichment["optional"] = preview_enrichment["optional"]
663
+ if "input" in preview_enrichment:
664
+ enrichment["input"] = _lower_binding_map(
665
+ cast(dict[str, Any], preview_enrichment["input"]), "$token"
666
+ )
667
+ if "set" in preview_enrichment:
668
+ enrichment["set"] = _lower_binding_map(
669
+ cast(dict[str, Any], preview_enrichment["set"]), "$result"
670
+ )
671
+ on_resume["enrich"] = enrichment
672
+ return on_resume
673
+
674
+
675
+ def _lower_binding_map(bindings: dict[str, Any], reference_key: str) -> dict[str, Any]:
676
+ return {
677
+ binding_name: (
678
+ f"{reference_key}.{_reference_target(binding_value, reference_key)}"
679
+ if isinstance(binding_value, dict) and reference_key in binding_value
680
+ else _canonical_clone(binding_value)
681
+ )
682
+ for binding_name, binding_value in bindings.items()
683
+ }
684
+
685
+
686
+ def _lower_transition(preview_transition: dict[str, Any]) -> dict[str, Any]:
687
+ transition_target = preview_transition["goto"]
688
+ transition: dict[str, Any] = {
689
+ "goto": transition_target
690
+ if transition_target == "END"
691
+ else _reference_target(cast(dict[str, str], transition_target), "$step")
692
+ }
693
+ if "set" in preview_transition:
694
+ transition["set"] = _canonical_clone(preview_transition["set"])
695
+ return transition
696
+
697
+
698
+ def _lower_predicate(preview_predicate: dict[str, Any]) -> dict[str, Any]:
699
+ operator, operand = next(iter(preview_predicate.items()))
700
+ if operator in {"and", "or"}:
701
+ return {
702
+ operator: [
703
+ _lower_predicate(child_predicate)
704
+ for child_predicate in cast(list[dict[str, Any]], operand)
705
+ ]
706
+ }
707
+ if operator == "not":
708
+ return {operator: _lower_predicate(cast(dict[str, Any], operand))}
709
+ if operator == "is_present":
710
+ return {operator: _lower_predicate_reference(cast(dict[str, str], operand))}
711
+ if operator == "in":
712
+ membership_operands = cast(list[Any], operand)
713
+ return {
714
+ operator: [
715
+ _lower_predicate_reference(cast(dict[str, str], membership_operands[0])),
716
+ _canonical_clone(membership_operands[1]),
717
+ ]
718
+ }
719
+ return {
720
+ operator: [
721
+ _lower_predicate_operand(predicate_operand)
722
+ for predicate_operand in cast(list[Any], operand)
723
+ ]
724
+ }
725
+
726
+
727
+ def _lower_predicate_operand(predicate_operand: Any) -> Any:
728
+ if isinstance(predicate_operand, dict):
729
+ return _lower_predicate_reference(cast(dict[str, str], predicate_operand))
730
+ if isinstance(predicate_operand, str) and _looks_like_v2_reference(predicate_operand):
731
+ return {"literal": predicate_operand}
732
+ return _canonical_clone(predicate_operand)
733
+
734
+
735
+ def _lower_predicate_reference(reference: dict[str, str]) -> str:
736
+ reference_key, reference_path = next(iter(reference.items()))
737
+ if reference_key in {"$slot", "$derive"}:
738
+ return f"slots.{reference_path}"
739
+ return f"{_PREDICATE_REFERENCE_NAMESPACES[reference_key]}.{reference_path}"
740
+
741
+
742
+ def _looks_like_v2_reference(value: str) -> bool:
743
+ namespace, separator, reference_path = value.partition(".")
744
+ return bool(
745
+ separator
746
+ and reference_path
747
+ and namespace in {"slots", "internal", "payload", "config", "address"}
748
+ )
749
+
750
+
751
+ def _source_reference_target(reference: dict[str, str]) -> str:
752
+ reference_key, reference_target = next(iter(reference.items()))
753
+ if reference_key not in {"$slot", "$derive"}:
754
+ _raise_lowering(
755
+ "FLOWSPEC3_LOWERING_INVALID_SOURCE_REFERENCE",
756
+ "",
757
+ f"source reference key {reference_key!r} is not lowerable",
758
+ )
759
+ return reference_target
760
+
761
+
762
+ def _reference_target(reference: dict[str, str], expected_key: str) -> str:
763
+ return reference[expected_key]
764
+
765
+
766
+ def _rehydrate_loss_entries(
767
+ source_document: dict[str, Any], loss_entries: list[dict[str, Any]]
768
+ ) -> tuple[str, ...]:
769
+ rehydratable_entries = [
770
+ loss_entry
771
+ for loss_entry in loss_entries
772
+ if loss_entry["disposition"] == "compatibility_only"
773
+ ]
774
+ use_entries = sorted(
775
+ (
776
+ loss_entry
777
+ for loss_entry in rehydratable_entries
778
+ if loss_entry["category"] == V3PreviewLossCategory.UNUSED_SUBFLOW_DECLARATION.value
779
+ ),
780
+ key=lambda loss_entry: int(cast(str, loss_entry["source_path"]).rsplit("/", 1)[1]),
781
+ )
782
+ for loss_entry in rehydratable_entries:
783
+ if loss_entry in use_entries:
784
+ continue
785
+ _prepare_rehydration_parent(source_document, loss_entry)
786
+ _set_pointer(
787
+ source_document,
788
+ cast(str, loss_entry["source_path"]),
789
+ _canonical_clone(loss_entry["source_fragment"]),
790
+ )
791
+ for loss_entry in use_entries:
792
+ _insert_array_pointer(
793
+ source_document,
794
+ cast(str, loss_entry["source_path"]),
795
+ _canonical_clone(loss_entry["source_fragment"]),
796
+ )
797
+ return tuple(cast(str, loss_entry["source_path"]) for loss_entry in rehydratable_entries)
798
+
799
+
800
+ def _prepare_rehydration_parent(
801
+ source_document: dict[str, Any], loss_entry: dict[str, Any]
802
+ ) -> None:
803
+ category = V3PreviewLossCategory(cast(str, loss_entry["category"]))
804
+ if category in {
805
+ V3PreviewLossCategory.AGENT_CAPABILITY,
806
+ V3PreviewLossCategory.IMPLICIT_AWAIT_CAPABILITY,
807
+ }:
808
+ source_document.setdefault("capabilities", {})
809
+ elif category is V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION:
810
+ source_document.setdefault("domains", {})
811
+ elif category is V3PreviewLossCategory.UNUSED_SLOT_DECLARATION:
812
+ source_document.setdefault("slots", {})
813
+ elif category is V3PreviewLossCategory.UNUSED_OVERRIDE_GATE:
814
+ overrides = source_document.setdefault("overrides", {})
815
+ if isinstance(overrides, dict):
816
+ overrides.setdefault("gates", {})
817
+
818
+
819
+ def _set_pointer(document: dict[str, Any], pointer: str, fragment: Any) -> None:
820
+ segments = _decode_pointer(pointer)
821
+ parent: Any = document
822
+ for segment in segments[:-1]:
823
+ if not isinstance(parent, dict) or segment not in parent:
824
+ _raise_lowering(
825
+ "FLOWSPEC3_LOWERING_REHYDRATION_PARENT_MISSING",
826
+ pointer,
827
+ "loss entry parent does not exist in the native lowered document",
828
+ )
829
+ parent = parent[segment]
830
+ final_segment = segments[-1]
831
+ if not isinstance(parent, dict):
832
+ _raise_lowering(
833
+ "FLOWSPEC3_LOWERING_REHYDRATION_PARENT_INVALID",
834
+ pointer,
835
+ "loss entry parent is not an object",
836
+ )
837
+ if final_segment in parent:
838
+ _raise_lowering(
839
+ "FLOWSPEC3_LOWERING_REHYDRATION_CONFLICT",
840
+ pointer,
841
+ "loss entry would overwrite native lowered content",
842
+ )
843
+ parent[final_segment] = fragment
844
+
845
+
846
+ def _insert_array_pointer(document: dict[str, Any], pointer: str, fragment: Any) -> None:
847
+ segments = _decode_pointer(pointer)
848
+ if len(segments) != 2 or segments[0] != "uses":
849
+ _raise_lowering(
850
+ "FLOWSPEC3_LOWERING_REHYDRATION_ARRAY_PATH_INVALID",
851
+ pointer,
852
+ "only unused subflow declarations may rehydrate into arrays",
853
+ )
854
+ uses = document.setdefault("uses", [])
855
+ if not isinstance(uses, list):
856
+ _raise_lowering(
857
+ "FLOWSPEC3_LOWERING_REHYDRATION_PARENT_INVALID",
858
+ pointer,
859
+ "uses rehydration parent is not an array",
860
+ )
861
+ insertion_index = int(segments[1])
862
+ if insertion_index > len(uses):
863
+ _raise_lowering(
864
+ "FLOWSPEC3_LOWERING_REHYDRATION_INDEX_INVALID",
865
+ pointer,
866
+ "unused subflow declaration index is beyond the reconstructed uses array",
867
+ )
868
+ uses.insert(insertion_index, fragment)
869
+
870
+
871
+ def _decode_pointer(pointer: str) -> list[str]:
872
+ return [segment.replace("~1", "/").replace("~0", "~") for segment in pointer[1:].split("/")]
873
+
874
+
875
+ def _first_difference_path(expected: Any, actual: Any, segments: tuple[str | int, ...] = ()) -> str:
876
+ if type(expected) is not type(actual):
877
+ return _pointer(segments)
878
+ if isinstance(expected, dict):
879
+ expected_keys = set(expected)
880
+ actual_keys = set(cast(dict[str, Any], actual))
881
+ if expected_keys != actual_keys:
882
+ differing_key = sorted(expected_keys ^ actual_keys)[0]
883
+ return _pointer((*segments, differing_key))
884
+ for key in sorted(expected):
885
+ difference = _first_difference_path(expected[key], actual[key], (*segments, key))
886
+ if difference:
887
+ return difference
888
+ return ""
889
+ if isinstance(expected, list):
890
+ actual_list = cast(list[Any], actual)
891
+ if len(expected) != len(actual_list):
892
+ return _pointer((*segments, min(len(expected), len(actual_list))))
893
+ for index, expected_item in enumerate(expected):
894
+ difference = _first_difference_path(
895
+ expected_item, actual_list[index], (*segments, index)
896
+ )
897
+ if difference:
898
+ return difference
899
+ return ""
900
+ return "" if expected == actual else _pointer(segments)
901
+
902
+
903
+ def _pointer(segments: tuple[str | int, ...]) -> str:
904
+ return "".join(f"/{_encode_pointer_segment(str(segment))}" for segment in segments)
905
+
906
+
907
+ def _encode_pointer_segment(segment: str) -> str:
908
+ return segment.replace("~", "~0").replace("/", "~1")
909
+
910
+
911
+ def _raise_lowering(code: str, path: str, message: str) -> None:
912
+ raise V3PreviewLoweringError(
913
+ (FlowDiagnostic(code=code, severity="error", path=path, message=message),)
914
+ )
915
+
916
+
917
+ def _canonical_clone(value: Any) -> Any:
918
+ return json.loads(_canonical_json(value))
919
+
920
+
921
+ def _canonical_json(value: Any) -> str:
922
+ return json.dumps(
923
+ value,
924
+ ensure_ascii=False,
925
+ allow_nan=False,
926
+ separators=(",", ":"),
927
+ sort_keys=True,
928
+ )