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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Stable compiler-contract entry points grouped by validation domain."""
|
|
2
|
+
|
|
3
|
+
from .compiler_resume_contracts import (
|
|
4
|
+
bind_await_external,
|
|
5
|
+
validate_await_external_definition,
|
|
6
|
+
validate_await_external_targets,
|
|
7
|
+
)
|
|
8
|
+
from .compiler_tool_contracts import validate_flow_tool_contracts
|
|
9
|
+
from .compiler_value_contracts import validate_entry_args_schema
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"bind_await_external",
|
|
13
|
+
"validate_await_external_definition",
|
|
14
|
+
"validate_await_external_targets",
|
|
15
|
+
"validate_entry_args_schema",
|
|
16
|
+
"validate_flow_tool_contracts",
|
|
17
|
+
]
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""External suspension, resume-token, and recovery compiler contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Final, Optional, cast
|
|
9
|
+
|
|
10
|
+
from .compiler_schema_relations import (
|
|
11
|
+
CONTRACT_VALID,
|
|
12
|
+
combined_contract,
|
|
13
|
+
mutable_contract_json,
|
|
14
|
+
required_schema_properties,
|
|
15
|
+
schema_accepts_known_instance,
|
|
16
|
+
schema_is_proven_subset,
|
|
17
|
+
schema_path_status,
|
|
18
|
+
schema_property_contract,
|
|
19
|
+
schema_requires_path_in_every_alternative,
|
|
20
|
+
schema_type_atoms,
|
|
21
|
+
)
|
|
22
|
+
from .compiler_tool_contracts import (
|
|
23
|
+
registered_tool_definition,
|
|
24
|
+
validate_read_only_tool_effects,
|
|
25
|
+
validate_tool_inputs,
|
|
26
|
+
validate_tool_result_path,
|
|
27
|
+
)
|
|
28
|
+
from .compiler_value_contracts import FlowValueContract, declared_slot_value_contract
|
|
29
|
+
from .schema_contracts import schema_reference_target, validate_resume_token_schema
|
|
30
|
+
from .subflows import SubflowRegistry
|
|
31
|
+
from .tools import ToolRegistry
|
|
32
|
+
|
|
33
|
+
_RESUME_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
34
|
+
r"^\$token(?:\.[A-Za-z][A-Za-z0-9_]*)+$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _uses_identification_v2(doc: dict[str, Any]) -> bool:
|
|
39
|
+
return any(use.get("ref") == "identification@2" for use in doc.get("uses", []))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bind_await_external(doc: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
43
|
+
capability = cast(
|
|
44
|
+
Optional[dict[str, Any]],
|
|
45
|
+
(doc.get("capabilities") or {}).get("await_external"),
|
|
46
|
+
)
|
|
47
|
+
path_steps = [step for step in doc["path"] if step.get("await_external") is True]
|
|
48
|
+
if len(path_steps) > 1:
|
|
49
|
+
raise ValueError("flowspec/2 supports one capabilities.await_external binding")
|
|
50
|
+
if path_steps and capability is None:
|
|
51
|
+
raise ValueError("path await_external requires capabilities.await_external")
|
|
52
|
+
if capability is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if path_steps:
|
|
56
|
+
path_step = path_steps[0]
|
|
57
|
+
unsupported_fields = {
|
|
58
|
+
"ask_when",
|
|
59
|
+
"skip_when",
|
|
60
|
+
"on_reject",
|
|
61
|
+
"correctable",
|
|
62
|
+
} & path_step.keys()
|
|
63
|
+
if unsupported_fields:
|
|
64
|
+
unsupported = ", ".join(sorted(unsupported_fields))
|
|
65
|
+
raise ValueError(f"path await_external does not support these fields: {unsupported}")
|
|
66
|
+
path_node_id = path_step.get("step")
|
|
67
|
+
capability_node_id = capability.get("step")
|
|
68
|
+
if path_node_id and capability_node_id and path_node_id != capability_node_id:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"path await_external step conflicts with capabilities.await_external.step"
|
|
71
|
+
)
|
|
72
|
+
bound_node_id = path_node_id or capability_node_id or "await_external"
|
|
73
|
+
path_step["step"] = bound_node_id
|
|
74
|
+
capability["step"] = bound_node_id
|
|
75
|
+
return capability
|
|
76
|
+
|
|
77
|
+
if not capability.get("step") and _uses_identification_v2(doc):
|
|
78
|
+
# Backward compatibility for documents authored before the explicit
|
|
79
|
+
# subflow binding existed.
|
|
80
|
+
capability["step"] = "authenticate_govbr"
|
|
81
|
+
if not capability.get("step"):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"capabilities.await_external must declare step or have a path await_external anchor"
|
|
84
|
+
)
|
|
85
|
+
return capability
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_binding_map(
|
|
89
|
+
bindings: dict[str, Any],
|
|
90
|
+
*,
|
|
91
|
+
namespace: str,
|
|
92
|
+
location: str,
|
|
93
|
+
) -> None:
|
|
94
|
+
if not isinstance(bindings, dict):
|
|
95
|
+
raise ValueError(f"{location} must be an object")
|
|
96
|
+
prefix = f"${namespace}."
|
|
97
|
+
for target, binding in bindings.items():
|
|
98
|
+
if not isinstance(target, str) or not target:
|
|
99
|
+
raise ValueError(f"{location} contains an empty destination")
|
|
100
|
+
if binding is not None and not isinstance(binding, (str, int, float, bool)):
|
|
101
|
+
raise ValueError(f"{location}.{target} must be a JSON scalar")
|
|
102
|
+
if isinstance(binding, str) and binding.startswith("$"):
|
|
103
|
+
reference_path = binding.removeprefix(prefix)
|
|
104
|
+
if (
|
|
105
|
+
not binding.startswith(prefix)
|
|
106
|
+
or not reference_path
|
|
107
|
+
or any(not segment for segment in reference_path.split("."))
|
|
108
|
+
):
|
|
109
|
+
raise ValueError(f"{location}.{target} must use {prefix}path")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resume_reference_segments(reference: str, *, location: str) -> tuple[str, ...]:
|
|
113
|
+
if _RESUME_REFERENCE_PATTERN.fullmatch(reference) is None:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"{location} must be an exact $token property path with identifier segments"
|
|
116
|
+
)
|
|
117
|
+
return tuple(reference.removeprefix("$token.").split("."))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _schema_array_index_contract(
|
|
121
|
+
schema: Any,
|
|
122
|
+
array_index: int,
|
|
123
|
+
root_schema: Any,
|
|
124
|
+
reference_stack: tuple[str, ...] = (),
|
|
125
|
+
) -> Any:
|
|
126
|
+
if schema is False:
|
|
127
|
+
return False
|
|
128
|
+
if schema is True or not isinstance(schema, Mapping):
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
contract_fragments: list[Any] = []
|
|
132
|
+
prefix_items = schema.get("prefixItems")
|
|
133
|
+
if isinstance(prefix_items, (list, tuple)) and array_index < len(prefix_items):
|
|
134
|
+
contract_fragments.append(prefix_items[array_index])
|
|
135
|
+
elif "items" in schema:
|
|
136
|
+
contract_fragments.append(schema["items"])
|
|
137
|
+
|
|
138
|
+
if (reference := schema.get("$ref")) is not None:
|
|
139
|
+
reference_text = cast(str, reference)
|
|
140
|
+
contract_fragments.append(
|
|
141
|
+
True
|
|
142
|
+
if reference_text in reference_stack
|
|
143
|
+
else _schema_array_index_contract(
|
|
144
|
+
schema_reference_target(root_schema, reference_text),
|
|
145
|
+
array_index,
|
|
146
|
+
root_schema,
|
|
147
|
+
(*reference_stack, reference_text),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
if isinstance((all_of := schema.get("allOf")), (list, tuple)):
|
|
151
|
+
contract_fragments.extend(
|
|
152
|
+
_schema_array_index_contract(branch, array_index, root_schema, reference_stack)
|
|
153
|
+
for branch in all_of
|
|
154
|
+
)
|
|
155
|
+
for union_keyword in ("anyOf", "oneOf"):
|
|
156
|
+
branches = schema.get(union_keyword)
|
|
157
|
+
if isinstance(branches, (list, tuple)):
|
|
158
|
+
contract_fragments.append(
|
|
159
|
+
combined_contract(
|
|
160
|
+
[
|
|
161
|
+
_schema_array_index_contract(
|
|
162
|
+
branch,
|
|
163
|
+
array_index,
|
|
164
|
+
root_schema,
|
|
165
|
+
reference_stack,
|
|
166
|
+
)
|
|
167
|
+
for branch in branches
|
|
168
|
+
],
|
|
169
|
+
"anyOf",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return combined_contract(contract_fragments)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _schema_path_value_contract(
|
|
176
|
+
schema: Any,
|
|
177
|
+
path_segments: tuple[str, ...],
|
|
178
|
+
root_schema: Any,
|
|
179
|
+
) -> Any:
|
|
180
|
+
current_contract = schema
|
|
181
|
+
for path_segment in path_segments:
|
|
182
|
+
if path_segment.isdigit():
|
|
183
|
+
current_contract = _schema_array_index_contract(
|
|
184
|
+
current_contract,
|
|
185
|
+
int(path_segment),
|
|
186
|
+
root_schema,
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
current_contract = schema_property_contract(
|
|
190
|
+
current_contract,
|
|
191
|
+
path_segment,
|
|
192
|
+
root_schema,
|
|
193
|
+
frozenset(required_schema_properties(current_contract, root_schema)),
|
|
194
|
+
)
|
|
195
|
+
return current_contract
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resume_token_path_contract(
|
|
199
|
+
token_schema: dict[str, Any],
|
|
200
|
+
reference: str,
|
|
201
|
+
*,
|
|
202
|
+
location: str,
|
|
203
|
+
require_presence: bool,
|
|
204
|
+
) -> FlowValueContract:
|
|
205
|
+
path_segments = _resume_reference_segments(reference, location=location)
|
|
206
|
+
path_status = schema_path_status(token_schema, path_segments)
|
|
207
|
+
if path_status != CONTRACT_VALID:
|
|
208
|
+
raise ValueError(f"{location} does not resolve exactly in resume.schema: {reference!r}")
|
|
209
|
+
if require_presence and not schema_requires_path_in_every_alternative(
|
|
210
|
+
token_schema,
|
|
211
|
+
path_segments,
|
|
212
|
+
token_schema,
|
|
213
|
+
):
|
|
214
|
+
raise ValueError(f"{location} must be required by every resume.schema alternative")
|
|
215
|
+
path_contract = _schema_path_value_contract(token_schema, path_segments, token_schema)
|
|
216
|
+
return FlowValueContract(
|
|
217
|
+
schema=path_contract,
|
|
218
|
+
root_schema=token_schema,
|
|
219
|
+
origin=f"resume token path {reference!r}",
|
|
220
|
+
total=require_presence,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class _AwaitStateTargetContract:
|
|
226
|
+
value_contract: FlowValueContract
|
|
227
|
+
partition: str
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _await_state_target_contract(
|
|
231
|
+
document: dict[str, Any],
|
|
232
|
+
subflows: SubflowRegistry,
|
|
233
|
+
state_key: str,
|
|
234
|
+
) -> _AwaitStateTargetContract | None:
|
|
235
|
+
if declared_contract := declared_slot_value_contract(
|
|
236
|
+
document,
|
|
237
|
+
state_key,
|
|
238
|
+
include_absence=False,
|
|
239
|
+
):
|
|
240
|
+
slot_definition = cast(dict[str, Any], document["slots"][state_key])
|
|
241
|
+
return _AwaitStateTargetContract(
|
|
242
|
+
value_contract=declared_contract,
|
|
243
|
+
partition=cast(str, slot_definition.get("persist", "data")),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for use_definition in document.get("uses", []):
|
|
247
|
+
subflow_reference = cast(str, use_definition["ref"])
|
|
248
|
+
if not subflows.has(subflow_reference):
|
|
249
|
+
continue
|
|
250
|
+
subflow_definition = subflows.definition(subflow_reference)
|
|
251
|
+
state_contract = subflow_definition.owned_state_keys.get(state_key)
|
|
252
|
+
if state_contract is None:
|
|
253
|
+
continue
|
|
254
|
+
state_schema = mutable_contract_json(state_contract.schema)
|
|
255
|
+
return _AwaitStateTargetContract(
|
|
256
|
+
value_contract=FlowValueContract(
|
|
257
|
+
schema=state_schema,
|
|
258
|
+
root_schema=state_schema,
|
|
259
|
+
origin=f"state key {state_key!r} owned by subflow {subflow_reference!r}",
|
|
260
|
+
total=True,
|
|
261
|
+
),
|
|
262
|
+
partition=state_contract.partition,
|
|
263
|
+
)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _validate_source_contract_for_await_state(
|
|
268
|
+
document: dict[str, Any],
|
|
269
|
+
subflows: SubflowRegistry,
|
|
270
|
+
state_key: str,
|
|
271
|
+
source_contract: FlowValueContract,
|
|
272
|
+
*,
|
|
273
|
+
location: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
target_contract = _await_state_target_contract(document, subflows, state_key)
|
|
276
|
+
if target_contract is None:
|
|
277
|
+
return
|
|
278
|
+
if target_contract.partition == "internal" and state_key not in document.get("slots", {}):
|
|
279
|
+
raise ValueError(f"{location} cannot write subflow-owned internal state key {state_key!r}")
|
|
280
|
+
if schema_is_proven_subset(
|
|
281
|
+
source_contract.schema,
|
|
282
|
+
source_contract.root_schema,
|
|
283
|
+
target_contract.value_contract.schema,
|
|
284
|
+
target_contract.value_contract.root_schema,
|
|
285
|
+
):
|
|
286
|
+
return
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"{location} {source_contract.origin} is not proven to satisfy "
|
|
289
|
+
f"{target_contract.value_contract.origin}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _validate_literal_for_await_state(
|
|
294
|
+
document: dict[str, Any],
|
|
295
|
+
subflows: SubflowRegistry,
|
|
296
|
+
state_key: str,
|
|
297
|
+
literal_value: Any,
|
|
298
|
+
*,
|
|
299
|
+
location: str,
|
|
300
|
+
) -> None:
|
|
301
|
+
target_contract = _await_state_target_contract(document, subflows, state_key)
|
|
302
|
+
if target_contract is None:
|
|
303
|
+
return
|
|
304
|
+
if target_contract.partition == "internal" and state_key not in document.get("slots", {}):
|
|
305
|
+
raise ValueError(f"{location} cannot write subflow-owned internal state key {state_key!r}")
|
|
306
|
+
if not schema_accepts_known_instance(
|
|
307
|
+
target_contract.value_contract.schema,
|
|
308
|
+
target_contract.value_contract.root_schema,
|
|
309
|
+
literal_value,
|
|
310
|
+
):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"{location} literal does not satisfy {target_contract.value_contract.origin}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_typed_resume_contract(
|
|
317
|
+
document: dict[str, Any],
|
|
318
|
+
capability: dict[str, Any],
|
|
319
|
+
tools: ToolRegistry,
|
|
320
|
+
subflows: SubflowRegistry,
|
|
321
|
+
) -> None:
|
|
322
|
+
resume_contract = cast(dict[str, Any] | None, capability.get("resume"))
|
|
323
|
+
if resume_contract is None:
|
|
324
|
+
return
|
|
325
|
+
token_schema = cast(dict[str, Any], resume_contract["schema"])
|
|
326
|
+
validate_resume_token_schema(token_schema)
|
|
327
|
+
correlation_reference = cast(str, resume_contract["correlation"])
|
|
328
|
+
correlation_contract = _resume_token_path_contract(
|
|
329
|
+
token_schema,
|
|
330
|
+
correlation_reference,
|
|
331
|
+
location="$.capabilities.await_external.resume.correlation",
|
|
332
|
+
require_presence=True,
|
|
333
|
+
)
|
|
334
|
+
scalar_correlation_types = frozenset({"boolean", "integer", "non_integer_number", "string"})
|
|
335
|
+
correlation_types = schema_type_atoms(
|
|
336
|
+
correlation_contract.schema,
|
|
337
|
+
correlation_contract.root_schema,
|
|
338
|
+
)
|
|
339
|
+
if not correlation_types or not correlation_types <= scalar_correlation_types:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
"$.capabilities.await_external.resume.correlation must resolve to a "
|
|
342
|
+
"non-null JSON scalar"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
on_resume = cast(dict[str, Any], capability.get("on_resume") or {})
|
|
346
|
+
for state_key, binding in cast(dict[str, Any], on_resume.get("set") or {}).items():
|
|
347
|
+
binding_location = f"$.capabilities.await_external.on_resume.set[{state_key!r}]"
|
|
348
|
+
if isinstance(binding, str) and binding.startswith("$"):
|
|
349
|
+
source_contract = _resume_token_path_contract(
|
|
350
|
+
token_schema,
|
|
351
|
+
binding,
|
|
352
|
+
location=binding_location,
|
|
353
|
+
require_presence=False,
|
|
354
|
+
)
|
|
355
|
+
_validate_source_contract_for_await_state(
|
|
356
|
+
document,
|
|
357
|
+
subflows,
|
|
358
|
+
state_key,
|
|
359
|
+
source_contract,
|
|
360
|
+
location=binding_location,
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
_validate_literal_for_await_state(
|
|
364
|
+
document,
|
|
365
|
+
subflows,
|
|
366
|
+
state_key,
|
|
367
|
+
binding,
|
|
368
|
+
location=binding_location,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
enrichment = on_resume.get("enrich")
|
|
372
|
+
for transition_name, transition in {
|
|
373
|
+
"timeout": capability.get("timeout"),
|
|
374
|
+
**cast(dict[str, Any], capability.get("recovery") or {}),
|
|
375
|
+
}.items():
|
|
376
|
+
if not isinstance(transition, dict):
|
|
377
|
+
continue
|
|
378
|
+
for state_key, literal_value in cast(dict[str, Any], transition.get("set") or {}).items():
|
|
379
|
+
_validate_literal_for_await_state(
|
|
380
|
+
document,
|
|
381
|
+
subflows,
|
|
382
|
+
state_key,
|
|
383
|
+
literal_value,
|
|
384
|
+
location=(f"$.capabilities.await_external.{transition_name}.set[{state_key!r}]"),
|
|
385
|
+
)
|
|
386
|
+
if not isinstance(enrichment, dict):
|
|
387
|
+
return
|
|
388
|
+
tool_definition = tools.definition(cast(str, enrichment["tool"]))
|
|
389
|
+
enrichment_input = cast(dict[str, Any], enrichment.get("input") or {})
|
|
390
|
+
required_parameters = required_schema_properties(tool_definition.input_schema)
|
|
391
|
+
bound_parameters = frozenset(enrichment_input)
|
|
392
|
+
for parameter_name, binding in enrichment_input.items():
|
|
393
|
+
binding_location = (
|
|
394
|
+
f"$.capabilities.await_external.on_resume.enrich.input[{parameter_name!r}]"
|
|
395
|
+
)
|
|
396
|
+
parameter_contract = schema_property_contract(
|
|
397
|
+
tool_definition.input_schema,
|
|
398
|
+
parameter_name,
|
|
399
|
+
tool_definition.input_schema,
|
|
400
|
+
bound_parameters,
|
|
401
|
+
)
|
|
402
|
+
if isinstance(binding, str) and binding.startswith("$"):
|
|
403
|
+
source_contract = _resume_token_path_contract(
|
|
404
|
+
token_schema,
|
|
405
|
+
binding,
|
|
406
|
+
location=binding_location,
|
|
407
|
+
require_presence=parameter_name in required_parameters,
|
|
408
|
+
)
|
|
409
|
+
if not schema_is_proven_subset(
|
|
410
|
+
source_contract.schema,
|
|
411
|
+
source_contract.root_schema,
|
|
412
|
+
parameter_contract,
|
|
413
|
+
tool_definition.input_schema,
|
|
414
|
+
):
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"{binding_location} {source_contract.origin} is not proven to satisfy "
|
|
417
|
+
f"tool parameter {parameter_name!r} in {tool_definition.identifier!r}"
|
|
418
|
+
)
|
|
419
|
+
elif not schema_accepts_known_instance(
|
|
420
|
+
parameter_contract,
|
|
421
|
+
tool_definition.input_schema,
|
|
422
|
+
binding,
|
|
423
|
+
):
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"{binding_location} literal does not satisfy tool parameter "
|
|
426
|
+
f"{parameter_name!r} in {tool_definition.identifier!r}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
for state_key, binding in cast(dict[str, Any], enrichment.get("set") or {}).items():
|
|
430
|
+
binding_location = f"$.capabilities.await_external.on_resume.enrich.set[{state_key!r}]"
|
|
431
|
+
if isinstance(binding, str) and binding.startswith("$result."):
|
|
432
|
+
result_path = tuple(binding.removeprefix("$result.").split("."))
|
|
433
|
+
source_schema = _schema_path_value_contract(
|
|
434
|
+
tool_definition.output_schema,
|
|
435
|
+
result_path,
|
|
436
|
+
tool_definition.output_schema,
|
|
437
|
+
)
|
|
438
|
+
_validate_source_contract_for_await_state(
|
|
439
|
+
document,
|
|
440
|
+
subflows,
|
|
441
|
+
state_key,
|
|
442
|
+
FlowValueContract(
|
|
443
|
+
schema=source_schema,
|
|
444
|
+
root_schema=tool_definition.output_schema,
|
|
445
|
+
origin=f"tool result path {binding!r}",
|
|
446
|
+
total=False,
|
|
447
|
+
),
|
|
448
|
+
location=binding_location,
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
_validate_literal_for_await_state(
|
|
452
|
+
document,
|
|
453
|
+
subflows,
|
|
454
|
+
state_key,
|
|
455
|
+
binding,
|
|
456
|
+
location=binding_location,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def validate_await_external_definition(
|
|
461
|
+
document: dict[str, Any],
|
|
462
|
+
capability: Optional[dict[str, Any]],
|
|
463
|
+
tools: ToolRegistry,
|
|
464
|
+
subflows: SubflowRegistry,
|
|
465
|
+
path_step: Optional[dict[str, Any]],
|
|
466
|
+
) -> None:
|
|
467
|
+
if capability is None:
|
|
468
|
+
return
|
|
469
|
+
if not isinstance(capability.get("step"), str) or not capability["step"]:
|
|
470
|
+
raise ValueError("capabilities.await_external.step must be a non-empty string")
|
|
471
|
+
if not isinstance(capability.get("resume_on"), str) or not capability["resume_on"]:
|
|
472
|
+
raise ValueError("capabilities.await_external.resume_on must be a non-empty string")
|
|
473
|
+
effective_interactive = (path_step or {}).get("interactive") or capability.get("interactive")
|
|
474
|
+
if effective_interactive:
|
|
475
|
+
if effective_interactive.get("kind") != "cta_url":
|
|
476
|
+
raise ValueError("await_external interactive.kind must be 'cta_url'")
|
|
477
|
+
if effective_interactive.get("field") != capability["resume_on"]:
|
|
478
|
+
raise ValueError("await_external interactive.field must match resume_on")
|
|
479
|
+
if effective_interactive.get("out_of_band", True) is not True:
|
|
480
|
+
raise ValueError("await_external interactive.out_of_band cannot be false")
|
|
481
|
+
next_step = effective_interactive.get("next_step")
|
|
482
|
+
if next_step is not None and next_step != capability["step"]:
|
|
483
|
+
raise ValueError("await_external interactive.next_step must match step")
|
|
484
|
+
on_resume = capability.get("on_resume") or {}
|
|
485
|
+
token_bindings = on_resume.get("set") or {}
|
|
486
|
+
_validate_binding_map(
|
|
487
|
+
token_bindings,
|
|
488
|
+
namespace="token",
|
|
489
|
+
location="capabilities.await_external.on_resume.set",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
enrichment = on_resume.get("enrich")
|
|
493
|
+
if enrichment is None:
|
|
494
|
+
_validate_typed_resume_contract(document, capability, tools, subflows)
|
|
495
|
+
return
|
|
496
|
+
enrichment_definition: dict[str, Any] | None = None
|
|
497
|
+
if isinstance(enrichment, str):
|
|
498
|
+
if path_step is not None:
|
|
499
|
+
raise ValueError("path await_external requires object-form on_resume.enrich")
|
|
500
|
+
tool_name = enrichment
|
|
501
|
+
else:
|
|
502
|
+
if not isinstance(enrichment, dict):
|
|
503
|
+
raise ValueError("await_external enrichment must be a tool name or object")
|
|
504
|
+
enrichment_definition = enrichment
|
|
505
|
+
tool_name_value = enrichment.get("tool")
|
|
506
|
+
if not isinstance(tool_name_value, str) or not tool_name_value:
|
|
507
|
+
raise ValueError("await_external enrichment.tool must be a non-empty string")
|
|
508
|
+
tool_name = tool_name_value
|
|
509
|
+
_validate_binding_map(
|
|
510
|
+
enrichment.get("input") or {},
|
|
511
|
+
namespace="token",
|
|
512
|
+
location="capabilities.await_external.on_resume.enrich.input",
|
|
513
|
+
)
|
|
514
|
+
enrichment_bindings = enrichment.get("set") or {}
|
|
515
|
+
_validate_binding_map(
|
|
516
|
+
enrichment_bindings,
|
|
517
|
+
namespace="result",
|
|
518
|
+
location="capabilities.await_external.on_resume.enrich.set",
|
|
519
|
+
)
|
|
520
|
+
duplicate_writes = set(token_bindings) & set(enrichment_bindings)
|
|
521
|
+
if duplicate_writes:
|
|
522
|
+
duplicates = ", ".join(sorted(duplicate_writes))
|
|
523
|
+
raise ValueError(f"await_external mappings write the same keys twice: {duplicates}")
|
|
524
|
+
tool_definition = registered_tool_definition(
|
|
525
|
+
tools,
|
|
526
|
+
tool_name,
|
|
527
|
+
location="$.capabilities.await_external.on_resume.enrich.tool",
|
|
528
|
+
)
|
|
529
|
+
validate_read_only_tool_effects(
|
|
530
|
+
tool_definition,
|
|
531
|
+
location="$.capabilities.await_external.on_resume.enrich.tool",
|
|
532
|
+
)
|
|
533
|
+
if enrichment_definition is None:
|
|
534
|
+
_validate_typed_resume_contract(document, capability, tools, subflows)
|
|
535
|
+
return
|
|
536
|
+
enrichment_input = cast(dict[str, Any], enrichment_definition.get("input") or {})
|
|
537
|
+
validate_tool_inputs(
|
|
538
|
+
tool_definition,
|
|
539
|
+
[
|
|
540
|
+
(
|
|
541
|
+
parameter_name,
|
|
542
|
+
f"$.capabilities.await_external.on_resume.enrich.input[{parameter_name!r}]",
|
|
543
|
+
)
|
|
544
|
+
for parameter_name in enrichment_input
|
|
545
|
+
],
|
|
546
|
+
call_location="$.capabilities.await_external.on_resume.enrich.input",
|
|
547
|
+
)
|
|
548
|
+
for state_key, result_binding in cast(
|
|
549
|
+
dict[str, Any], enrichment_definition.get("set") or {}
|
|
550
|
+
).items():
|
|
551
|
+
if isinstance(result_binding, str) and result_binding.startswith("$result."):
|
|
552
|
+
validate_tool_result_path(
|
|
553
|
+
tool_definition,
|
|
554
|
+
result_binding,
|
|
555
|
+
location=(f"$.capabilities.await_external.on_resume.enrich.set[{state_key!r}]"),
|
|
556
|
+
allow_array_indices=True,
|
|
557
|
+
)
|
|
558
|
+
_validate_typed_resume_contract(document, capability, tools, subflows)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def validate_await_external_targets(
|
|
562
|
+
capability: Optional[dict[str, Any]],
|
|
563
|
+
node_ids: set[str],
|
|
564
|
+
await_external_node_ids: set[str],
|
|
565
|
+
) -> None:
|
|
566
|
+
if capability is None:
|
|
567
|
+
return
|
|
568
|
+
bound_node_id = capability["step"]
|
|
569
|
+
if bound_node_id not in node_ids:
|
|
570
|
+
raise ValueError(
|
|
571
|
+
f"capabilities.await_external.step does not resolve to a node: {bound_node_id!r}"
|
|
572
|
+
)
|
|
573
|
+
if bound_node_id not in await_external_node_ids:
|
|
574
|
+
raise ValueError(
|
|
575
|
+
"capabilities.await_external.step resolves to a node that does not implement "
|
|
576
|
+
f"await_external: {bound_node_id!r}"
|
|
577
|
+
)
|
|
578
|
+
transitions = {
|
|
579
|
+
"timeout": capability.get("timeout"),
|
|
580
|
+
**(capability.get("recovery") or {}),
|
|
581
|
+
}
|
|
582
|
+
for event, transition in transitions.items():
|
|
583
|
+
if transition is None:
|
|
584
|
+
continue
|
|
585
|
+
target = transition["goto"]
|
|
586
|
+
if event == "resend" and target != bound_node_id:
|
|
587
|
+
raise ValueError(
|
|
588
|
+
"capabilities.await_external resend target must match its bound step: "
|
|
589
|
+
f"{bound_node_id!r}"
|
|
590
|
+
)
|
|
591
|
+
if target != "END" and target not in node_ids:
|
|
592
|
+
raise ValueError(
|
|
593
|
+
f"capabilities.await_external {event} target does not resolve to a node: {target!r}"
|
|
594
|
+
)
|