flowspec2 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/tools.py
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
"""Tool registry + injectable backends.
|
|
2
|
+
|
|
3
|
+
A *tool* is the unit a flow's ``entry.tool`` / ``terminal.tool`` /
|
|
4
|
+
``capabilities.await_external.on_resume.enrich`` binds to. Each is an async
|
|
5
|
+
callable ``(**kwargs) -> dict``. Backends default to in-memory fakes so the
|
|
6
|
+
suite runs offline; swap any of them for a real integration by passing a custom
|
|
7
|
+
``ToolRegistry`` to :class:`~flowspec2.runtime.FlowRuntime`.
|
|
8
|
+
|
|
9
|
+
Terminal idempotency lives here: ``replay`` caches a successful result under a
|
|
10
|
+
sha256 of the operation namespace and call inputs, keyed per user. The default
|
|
11
|
+
cache is process- and registry-local; cross-process guarantees require a host
|
|
12
|
+
adapter backed by durable replay storage.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import copy
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from types import MappingProxyType
|
|
24
|
+
from typing import Any, Awaitable, Callable, TypeAlias, cast
|
|
25
|
+
from urllib.parse import unquote
|
|
26
|
+
|
|
27
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
28
|
+
from jsonschema.exceptions import SchemaError
|
|
29
|
+
|
|
30
|
+
from .json_codec import validate_json_value
|
|
31
|
+
|
|
32
|
+
Tool = Callable[..., Awaitable[dict[str, Any]]]
|
|
33
|
+
|
|
34
|
+
_CategoryMatch: TypeAlias = tuple[bool, bool]
|
|
35
|
+
_BoundaryMatch: TypeAlias = tuple[_CategoryMatch, ...]
|
|
36
|
+
_ALWAYS_MATCHES: _CategoryMatch = (True, True)
|
|
37
|
+
_NEVER_MATCHES: _CategoryMatch = (False, False)
|
|
38
|
+
_SOMETIMES_MATCHES: _CategoryMatch = (True, False)
|
|
39
|
+
_JSON_VALUE_CATEGORIES: tuple[str, ...] = (
|
|
40
|
+
"object",
|
|
41
|
+
"array",
|
|
42
|
+
"boolean",
|
|
43
|
+
"null",
|
|
44
|
+
"number",
|
|
45
|
+
"string",
|
|
46
|
+
)
|
|
47
|
+
_OBJECT_CATEGORY_INDEX = _JSON_VALUE_CATEGORIES.index("object")
|
|
48
|
+
_DRAFT_2020_12_URIS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
51
|
+
"https://json-schema.org/draft/2020-12/schema#",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mutable_json(value: Any) -> Any:
|
|
57
|
+
if isinstance(value, Mapping):
|
|
58
|
+
return {key: _mutable_json(nested_value) for key, nested_value in value.items()}
|
|
59
|
+
if isinstance(value, tuple):
|
|
60
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _immutable_json(value: Any) -> Any:
|
|
67
|
+
if isinstance(value, Mapping):
|
|
68
|
+
return MappingProxyType(
|
|
69
|
+
{key: _immutable_json(nested_value) for key, nested_value in value.items()}
|
|
70
|
+
)
|
|
71
|
+
if isinstance(value, (list, tuple)):
|
|
72
|
+
return tuple(_immutable_json(nested_value) for nested_value in value)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _local_anchor_target(root_schema: Any, anchor: str) -> Any:
|
|
77
|
+
matching_targets: list[Any] = []
|
|
78
|
+
|
|
79
|
+
def visit(schema_fragment: Any) -> None:
|
|
80
|
+
if isinstance(schema_fragment, Mapping):
|
|
81
|
+
if schema_fragment.get("$anchor") == anchor:
|
|
82
|
+
matching_targets.append(schema_fragment)
|
|
83
|
+
for nested_schema in schema_fragment.values():
|
|
84
|
+
visit(nested_schema)
|
|
85
|
+
elif isinstance(schema_fragment, (list, tuple)):
|
|
86
|
+
for nested_schema in schema_fragment:
|
|
87
|
+
visit(nested_schema)
|
|
88
|
+
|
|
89
|
+
visit(root_schema)
|
|
90
|
+
if len(matching_targets) != 1:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"local schema anchor {anchor!r} must resolve exactly once; "
|
|
93
|
+
f"resolved {len(matching_targets)} times"
|
|
94
|
+
)
|
|
95
|
+
return matching_targets[0]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _local_reference_target(root_schema: Any, reference: str) -> Any:
|
|
99
|
+
if not reference.startswith("#"):
|
|
100
|
+
raise ValueError(f"tool schemas only support local references, got {reference!r}")
|
|
101
|
+
fragment = unquote(reference[1:])
|
|
102
|
+
if not fragment:
|
|
103
|
+
return root_schema
|
|
104
|
+
if not fragment.startswith("/"):
|
|
105
|
+
return _local_anchor_target(root_schema, fragment)
|
|
106
|
+
|
|
107
|
+
referenced_schema = root_schema
|
|
108
|
+
for encoded_segment in fragment[1:].split("/"):
|
|
109
|
+
reference_segment = encoded_segment.replace("~1", "/").replace("~0", "~")
|
|
110
|
+
if isinstance(referenced_schema, Mapping) and reference_segment in referenced_schema:
|
|
111
|
+
referenced_schema = referenced_schema[reference_segment]
|
|
112
|
+
continue
|
|
113
|
+
if isinstance(referenced_schema, (list, tuple)) and reference_segment.isdigit():
|
|
114
|
+
reference_index = int(reference_segment)
|
|
115
|
+
if reference_index < len(referenced_schema):
|
|
116
|
+
referenced_schema = referenced_schema[reference_index]
|
|
117
|
+
continue
|
|
118
|
+
raise ValueError(f"local schema reference does not resolve: {reference!r}")
|
|
119
|
+
return referenced_schema
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_local_references(
|
|
123
|
+
schema: Any,
|
|
124
|
+
root_schema: Any,
|
|
125
|
+
*,
|
|
126
|
+
is_root: bool = True,
|
|
127
|
+
) -> None:
|
|
128
|
+
if isinstance(schema, Mapping):
|
|
129
|
+
if "$dynamicRef" in schema:
|
|
130
|
+
raise ValueError("tool schemas do not support $dynamicRef")
|
|
131
|
+
if "unevaluatedProperties" in schema:
|
|
132
|
+
raise ValueError("tool schemas do not support unevaluatedProperties")
|
|
133
|
+
if not is_root and "$id" in schema:
|
|
134
|
+
raise ValueError("tool schemas do not support nested $id resources")
|
|
135
|
+
if (reference := schema.get("$ref")) is not None:
|
|
136
|
+
if not isinstance(reference, str):
|
|
137
|
+
raise ValueError("tool schema $ref must be a string")
|
|
138
|
+
_local_reference_target(root_schema, reference)
|
|
139
|
+
for nested_schema in schema.values():
|
|
140
|
+
_validate_local_references(nested_schema, root_schema, is_root=False)
|
|
141
|
+
elif isinstance(schema, (list, tuple)):
|
|
142
|
+
for nested_schema in schema:
|
|
143
|
+
_validate_local_references(nested_schema, root_schema, is_root=False)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_reference_cycles(root_schema: Any) -> None:
|
|
147
|
+
reference_edges: dict[int, int] = {}
|
|
148
|
+
schema_nodes: dict[int, Any] = {}
|
|
149
|
+
|
|
150
|
+
def collect(schema_fragment: Any) -> None:
|
|
151
|
+
if isinstance(schema_fragment, Mapping):
|
|
152
|
+
fragment_identifier = id(schema_fragment)
|
|
153
|
+
schema_nodes[fragment_identifier] = schema_fragment
|
|
154
|
+
if isinstance((reference := schema_fragment.get("$ref")), str):
|
|
155
|
+
reference_target = _local_reference_target(root_schema, reference)
|
|
156
|
+
if isinstance(reference_target, Mapping):
|
|
157
|
+
reference_edges[fragment_identifier] = id(reference_target)
|
|
158
|
+
schema_nodes[id(reference_target)] = reference_target
|
|
159
|
+
for nested_fragment in schema_fragment.values():
|
|
160
|
+
collect(nested_fragment)
|
|
161
|
+
elif isinstance(schema_fragment, (list, tuple)):
|
|
162
|
+
for nested_fragment in schema_fragment:
|
|
163
|
+
collect(nested_fragment)
|
|
164
|
+
|
|
165
|
+
collect(root_schema)
|
|
166
|
+
visiting: set[int] = set()
|
|
167
|
+
visited: set[int] = set()
|
|
168
|
+
|
|
169
|
+
def visit(fragment_identifier: int) -> None:
|
|
170
|
+
if fragment_identifier in visited:
|
|
171
|
+
return
|
|
172
|
+
if fragment_identifier in visiting:
|
|
173
|
+
raise ValueError("tool schemas do not support cyclic local references")
|
|
174
|
+
visiting.add(fragment_identifier)
|
|
175
|
+
if (target_identifier := reference_edges.get(fragment_identifier)) is not None:
|
|
176
|
+
visit(target_identifier)
|
|
177
|
+
visiting.remove(fragment_identifier)
|
|
178
|
+
visited.add(fragment_identifier)
|
|
179
|
+
|
|
180
|
+
for schema_identifier in schema_nodes:
|
|
181
|
+
visit(schema_identifier)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _category_all(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
185
|
+
return (
|
|
186
|
+
all(category_match[0] for category_match in category_matches),
|
|
187
|
+
all(category_match[1] for category_match in category_matches),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _category_any(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
192
|
+
return (
|
|
193
|
+
any(category_match[0] for category_match in category_matches),
|
|
194
|
+
any(category_match[1] for category_match in category_matches),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _category_not(category_match: _CategoryMatch) -> _CategoryMatch:
|
|
199
|
+
can_match, must_match = category_match
|
|
200
|
+
return not must_match, not can_match
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _category_one_of(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
204
|
+
possible_matches = [category_match for category_match in category_matches if category_match[0]]
|
|
205
|
+
guaranteed_matches = sum(category_match[1] for category_match in possible_matches)
|
|
206
|
+
return (
|
|
207
|
+
bool(possible_matches) and guaranteed_matches < 2,
|
|
208
|
+
guaranteed_matches == 1 and len(possible_matches) == 1,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _boundary_all(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
213
|
+
return tuple(
|
|
214
|
+
_category_all([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
215
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _boundary_any(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
220
|
+
return tuple(
|
|
221
|
+
_category_any([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
222
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _boundary_not(boundary_match: _BoundaryMatch) -> _BoundaryMatch:
|
|
227
|
+
return tuple(_category_not(category_match) for category_match in boundary_match)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _boundary_one_of(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
231
|
+
return tuple(
|
|
232
|
+
_category_one_of([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
233
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _type_boundary_match(schema_type: Any) -> _BoundaryMatch:
|
|
238
|
+
schema_types = {schema_type} if isinstance(schema_type, str) else set(schema_type)
|
|
239
|
+
return tuple(
|
|
240
|
+
(
|
|
241
|
+
_ALWAYS_MATCHES
|
|
242
|
+
if category in schema_types
|
|
243
|
+
else _SOMETIMES_MATCHES
|
|
244
|
+
if category == "number" and "integer" in schema_types
|
|
245
|
+
else _NEVER_MATCHES
|
|
246
|
+
)
|
|
247
|
+
for category in _JSON_VALUE_CATEGORIES
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _instance_category(instance: Any) -> str:
|
|
252
|
+
if isinstance(instance, Mapping):
|
|
253
|
+
return "object"
|
|
254
|
+
if isinstance(instance, list):
|
|
255
|
+
return "array"
|
|
256
|
+
if isinstance(instance, bool):
|
|
257
|
+
return "boolean"
|
|
258
|
+
if instance is None:
|
|
259
|
+
return "null"
|
|
260
|
+
if isinstance(instance, (int, float)):
|
|
261
|
+
return "number"
|
|
262
|
+
return "string"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _finite_boundary_match(instances: list[Any]) -> _BoundaryMatch:
|
|
266
|
+
return tuple(
|
|
267
|
+
(
|
|
268
|
+
any(_instance_category(instance) == category for instance in instances),
|
|
269
|
+
category == "null"
|
|
270
|
+
and any(instance is None for instance in instances)
|
|
271
|
+
or category == "boolean"
|
|
272
|
+
and any(instance is True for instance in instances)
|
|
273
|
+
and any(instance is False for instance in instances),
|
|
274
|
+
)
|
|
275
|
+
for category in _JSON_VALUE_CATEGORIES
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _category_restriction_boundary(
|
|
280
|
+
category: str,
|
|
281
|
+
category_match: _CategoryMatch,
|
|
282
|
+
) -> _BoundaryMatch:
|
|
283
|
+
return tuple(
|
|
284
|
+
category_match if candidate_category == category else _ALWAYS_MATCHES
|
|
285
|
+
for candidate_category in _JSON_VALUE_CATEGORIES
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _conjunctive_schema_fragments(schema: Any, root_schema: Any) -> list[Any]:
|
|
290
|
+
pending_fragments = [schema]
|
|
291
|
+
visited_fragments: set[int] = set()
|
|
292
|
+
schema_fragments: list[Any] = []
|
|
293
|
+
while pending_fragments:
|
|
294
|
+
schema_fragment = pending_fragments.pop()
|
|
295
|
+
if not isinstance(schema_fragment, Mapping):
|
|
296
|
+
schema_fragments.append(schema_fragment)
|
|
297
|
+
continue
|
|
298
|
+
fragment_identifier = id(schema_fragment)
|
|
299
|
+
if fragment_identifier in visited_fragments:
|
|
300
|
+
continue
|
|
301
|
+
visited_fragments.add(fragment_identifier)
|
|
302
|
+
schema_fragments.append(schema_fragment)
|
|
303
|
+
if (reference := schema_fragment.get("$ref")) is not None:
|
|
304
|
+
pending_fragments.append(_local_reference_target(root_schema, cast(str, reference)))
|
|
305
|
+
if isinstance((all_of := schema_fragment.get("allOf")), (list, tuple)):
|
|
306
|
+
pending_fragments.extend(all_of)
|
|
307
|
+
return schema_fragments
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _canonical_json(value: Any) -> str:
|
|
311
|
+
return json.dumps(
|
|
312
|
+
value,
|
|
313
|
+
allow_nan=False,
|
|
314
|
+
ensure_ascii=False,
|
|
315
|
+
separators=(",", ":"),
|
|
316
|
+
sort_keys=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _finite_object_candidates(schema_fragments: list[Any]) -> dict[str, Any] | None:
|
|
321
|
+
candidate_objects: dict[str, Any] | None = None
|
|
322
|
+
for schema_fragment in schema_fragments:
|
|
323
|
+
if not isinstance(schema_fragment, Mapping):
|
|
324
|
+
continue
|
|
325
|
+
restricted_instances: list[Any] | None = None
|
|
326
|
+
if "const" in schema_fragment:
|
|
327
|
+
restricted_instances = [schema_fragment["const"]]
|
|
328
|
+
if isinstance((enum_members := schema_fragment.get("enum")), (list, tuple)):
|
|
329
|
+
restricted_instances = (
|
|
330
|
+
list(enum_members)
|
|
331
|
+
if restricted_instances is None
|
|
332
|
+
else [
|
|
333
|
+
instance
|
|
334
|
+
for instance in restricted_instances
|
|
335
|
+
if any(instance == enum_member for enum_member in enum_members)
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
if restricted_instances is None:
|
|
339
|
+
continue
|
|
340
|
+
restricted_objects = {
|
|
341
|
+
_canonical_json(instance): instance
|
|
342
|
+
for instance in restricted_instances
|
|
343
|
+
if isinstance(instance, Mapping)
|
|
344
|
+
}
|
|
345
|
+
candidate_objects = (
|
|
346
|
+
restricted_objects
|
|
347
|
+
if candidate_objects is None
|
|
348
|
+
else {
|
|
349
|
+
serialized_object: candidate_object
|
|
350
|
+
for serialized_object, candidate_object in candidate_objects.items()
|
|
351
|
+
if serialized_object in restricted_objects
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
return candidate_objects
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _schema_accepts_instance(schema: Any, root_schema: Any, instance: Any) -> bool:
|
|
358
|
+
validator = Draft202012Validator(root_schema, format_checker=FormatChecker())
|
|
359
|
+
return validator.evolve(schema=schema).is_valid(instance)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _object_constraints_may_match(schema: Any, root_schema: Any) -> bool:
|
|
363
|
+
schema_fragments = _conjunctive_schema_fragments(schema, root_schema)
|
|
364
|
+
if any(schema_fragment is False for schema_fragment in schema_fragments):
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
required_properties: set[str] = set()
|
|
368
|
+
minimum_properties = 0
|
|
369
|
+
maximum_properties: int | None = None
|
|
370
|
+
closed_property_sets: list[set[str]] = []
|
|
371
|
+
property_name_schemas: list[Any] = []
|
|
372
|
+
property_value_schemas: dict[str, list[Any]] = {}
|
|
373
|
+
for schema_fragment in schema_fragments:
|
|
374
|
+
if not isinstance(schema_fragment, Mapping):
|
|
375
|
+
continue
|
|
376
|
+
required_properties.update(
|
|
377
|
+
property_name
|
|
378
|
+
for property_name in schema_fragment.get("required", ())
|
|
379
|
+
if isinstance(property_name, str)
|
|
380
|
+
)
|
|
381
|
+
if isinstance((fragment_minimum := schema_fragment.get("minProperties")), int):
|
|
382
|
+
minimum_properties = max(minimum_properties, fragment_minimum)
|
|
383
|
+
if isinstance((fragment_maximum := schema_fragment.get("maxProperties")), int):
|
|
384
|
+
maximum_properties = (
|
|
385
|
+
fragment_maximum
|
|
386
|
+
if maximum_properties is None
|
|
387
|
+
else min(maximum_properties, fragment_maximum)
|
|
388
|
+
)
|
|
389
|
+
if (property_names := schema_fragment.get("propertyNames")) is not None:
|
|
390
|
+
property_name_schemas.append(property_names)
|
|
391
|
+
properties = schema_fragment.get("properties")
|
|
392
|
+
if isinstance(properties, Mapping):
|
|
393
|
+
for property_name, property_schema in properties.items():
|
|
394
|
+
property_value_schemas.setdefault(property_name, []).append(property_schema)
|
|
395
|
+
pattern_properties = schema_fragment.get("patternProperties")
|
|
396
|
+
rejects_every_pattern_property = isinstance(pattern_properties, Mapping) and any(
|
|
397
|
+
pattern in {".*", "^.*$"} and pattern_schema is False
|
|
398
|
+
for pattern, pattern_schema in pattern_properties.items()
|
|
399
|
+
)
|
|
400
|
+
if schema_fragment.get("additionalProperties") is False and (
|
|
401
|
+
not pattern_properties or rejects_every_pattern_property
|
|
402
|
+
):
|
|
403
|
+
properties = schema_fragment.get("properties")
|
|
404
|
+
permitted_properties = set(properties) if isinstance(properties, Mapping) else set()
|
|
405
|
+
if rejects_every_pattern_property:
|
|
406
|
+
permitted_properties.clear()
|
|
407
|
+
closed_property_sets.append(permitted_properties)
|
|
408
|
+
|
|
409
|
+
dependencies_changed = True
|
|
410
|
+
while dependencies_changed:
|
|
411
|
+
dependencies_changed = False
|
|
412
|
+
for schema_fragment in schema_fragments:
|
|
413
|
+
if not isinstance(schema_fragment, Mapping):
|
|
414
|
+
continue
|
|
415
|
+
dependent_required = schema_fragment.get("dependentRequired")
|
|
416
|
+
if not isinstance(dependent_required, Mapping):
|
|
417
|
+
continue
|
|
418
|
+
for trigger_property, dependent_properties in dependent_required.items():
|
|
419
|
+
if trigger_property not in required_properties:
|
|
420
|
+
continue
|
|
421
|
+
previous_size = len(required_properties)
|
|
422
|
+
required_properties.update(
|
|
423
|
+
property_name
|
|
424
|
+
for property_name in dependent_properties
|
|
425
|
+
if isinstance(property_name, str)
|
|
426
|
+
)
|
|
427
|
+
dependencies_changed = (
|
|
428
|
+
dependencies_changed or len(required_properties) > previous_size
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
for schema_fragment in schema_fragments:
|
|
432
|
+
if not isinstance(schema_fragment, Mapping):
|
|
433
|
+
continue
|
|
434
|
+
dependent_schemas = schema_fragment.get("dependentSchemas")
|
|
435
|
+
if not isinstance(dependent_schemas, Mapping):
|
|
436
|
+
continue
|
|
437
|
+
if any(
|
|
438
|
+
trigger_property in required_properties and dependent_schema is False
|
|
439
|
+
for trigger_property, dependent_schema in dependent_schemas.items()
|
|
440
|
+
):
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
minimum_properties = max(minimum_properties, len(required_properties))
|
|
444
|
+
if maximum_properties is not None and minimum_properties > maximum_properties:
|
|
445
|
+
return False
|
|
446
|
+
if closed_property_sets:
|
|
447
|
+
permitted_properties = set.intersection(*closed_property_sets)
|
|
448
|
+
if not required_properties <= permitted_properties:
|
|
449
|
+
return False
|
|
450
|
+
if minimum_properties > len(permitted_properties):
|
|
451
|
+
return False
|
|
452
|
+
if any(
|
|
453
|
+
not _schema_accepts_instance(property_name_schema, root_schema, required_property)
|
|
454
|
+
for property_name_schema in property_name_schemas
|
|
455
|
+
for required_property in required_properties
|
|
456
|
+
):
|
|
457
|
+
return False
|
|
458
|
+
if minimum_properties > 0 and any(
|
|
459
|
+
property_name_schema is False for property_name_schema in property_name_schemas
|
|
460
|
+
):
|
|
461
|
+
return False
|
|
462
|
+
for required_property in required_properties:
|
|
463
|
+
required_property_schemas = property_value_schemas.get(required_property, [])
|
|
464
|
+
if not required_property_schemas:
|
|
465
|
+
continue
|
|
466
|
+
combined_property_boundary = _boundary_all(
|
|
467
|
+
[
|
|
468
|
+
_object_boundary_match(property_schema, root_schema)
|
|
469
|
+
for property_schema in required_property_schemas
|
|
470
|
+
]
|
|
471
|
+
)
|
|
472
|
+
if not any(category_match[0] for category_match in combined_property_boundary):
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
finite_candidates = _finite_object_candidates(schema_fragments)
|
|
476
|
+
return finite_candidates is None or any(
|
|
477
|
+
_schema_accepts_instance(schema, root_schema, candidate_object)
|
|
478
|
+
for candidate_object in finite_candidates.values()
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _object_boundary_match(
|
|
483
|
+
schema: Any,
|
|
484
|
+
root_schema: Any,
|
|
485
|
+
reference_stack: tuple[str, ...] = (),
|
|
486
|
+
) -> _BoundaryMatch:
|
|
487
|
+
if schema is False:
|
|
488
|
+
return tuple(_NEVER_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
489
|
+
if schema is True or not isinstance(schema, Mapping):
|
|
490
|
+
return tuple(_ALWAYS_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
491
|
+
|
|
492
|
+
boundary_matches: list[_BoundaryMatch] = []
|
|
493
|
+
if not _object_constraints_may_match(schema, root_schema):
|
|
494
|
+
boundary_matches.append(_category_restriction_boundary("object", _NEVER_MATCHES))
|
|
495
|
+
if (schema_type := schema.get("type")) is not None:
|
|
496
|
+
boundary_matches.append(_type_boundary_match(schema_type))
|
|
497
|
+
|
|
498
|
+
if "const" in schema:
|
|
499
|
+
boundary_matches.append(_finite_boundary_match([schema["const"]]))
|
|
500
|
+
if isinstance((enum_members := schema.get("enum")), (list, tuple)):
|
|
501
|
+
boundary_matches.append(_finite_boundary_match(list(enum_members)))
|
|
502
|
+
|
|
503
|
+
object_shape_keywords = {
|
|
504
|
+
"additionalProperties",
|
|
505
|
+
"dependentRequired",
|
|
506
|
+
"dependentSchemas",
|
|
507
|
+
"patternProperties",
|
|
508
|
+
"properties",
|
|
509
|
+
"propertyNames",
|
|
510
|
+
}
|
|
511
|
+
if object_shape_keywords & set(schema) or any(
|
|
512
|
+
keyword in schema for keyword in ("maxProperties", "minProperties", "required")
|
|
513
|
+
):
|
|
514
|
+
boundary_matches.append(_category_restriction_boundary("object", _SOMETIMES_MATCHES))
|
|
515
|
+
|
|
516
|
+
if (reference := schema.get("$ref")) is not None:
|
|
517
|
+
if reference in reference_stack:
|
|
518
|
+
boundary_matches.append(tuple(_SOMETIMES_MATCHES for _ in _JSON_VALUE_CATEGORIES))
|
|
519
|
+
else:
|
|
520
|
+
boundary_matches.append(
|
|
521
|
+
_object_boundary_match(
|
|
522
|
+
_local_reference_target(root_schema, cast(str, reference)),
|
|
523
|
+
root_schema,
|
|
524
|
+
(*reference_stack, cast(str, reference)),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
all_of = schema.get("allOf")
|
|
529
|
+
if isinstance(all_of, (list, tuple)):
|
|
530
|
+
boundary_matches.append(
|
|
531
|
+
_boundary_all(
|
|
532
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in all_of]
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
if isinstance((any_of := schema.get("anyOf")), (list, tuple)):
|
|
537
|
+
boundary_matches.append(
|
|
538
|
+
_boundary_any(
|
|
539
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in any_of]
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
if isinstance((one_of := schema.get("oneOf")), (list, tuple)):
|
|
543
|
+
boundary_matches.append(
|
|
544
|
+
_boundary_one_of(
|
|
545
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in one_of]
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
if "not" in schema:
|
|
549
|
+
boundary_matches.append(
|
|
550
|
+
_boundary_not(_object_boundary_match(schema["not"], root_schema, reference_stack))
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if "if" in schema:
|
|
554
|
+
condition_match = _object_boundary_match(schema["if"], root_schema, reference_stack)
|
|
555
|
+
boundary_matches.append(
|
|
556
|
+
_boundary_any(
|
|
557
|
+
[
|
|
558
|
+
_boundary_all(
|
|
559
|
+
[
|
|
560
|
+
condition_match,
|
|
561
|
+
_object_boundary_match(
|
|
562
|
+
schema.get("then", True), root_schema, reference_stack
|
|
563
|
+
),
|
|
564
|
+
]
|
|
565
|
+
),
|
|
566
|
+
_boundary_all(
|
|
567
|
+
[
|
|
568
|
+
_boundary_not(condition_match),
|
|
569
|
+
_object_boundary_match(
|
|
570
|
+
schema.get("else", True), root_schema, reference_stack
|
|
571
|
+
),
|
|
572
|
+
]
|
|
573
|
+
),
|
|
574
|
+
]
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
_boundary_all(boundary_matches)
|
|
580
|
+
if boundary_matches
|
|
581
|
+
else tuple(_ALWAYS_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _validated_schema(schema: Mapping[str, Any], location: str) -> Mapping[str, Any]:
|
|
586
|
+
mutable_schema = _mutable_json(schema)
|
|
587
|
+
try:
|
|
588
|
+
json.dumps(mutable_schema, allow_nan=False)
|
|
589
|
+
declared_dialect = mutable_schema.get("$schema")
|
|
590
|
+
if declared_dialect is not None and declared_dialect not in _DRAFT_2020_12_URIS:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"tool schemas must use JSON Schema Draft 2020-12, got {declared_dialect!r}"
|
|
593
|
+
)
|
|
594
|
+
Draft202012Validator.check_schema(mutable_schema)
|
|
595
|
+
_validate_local_references(mutable_schema, mutable_schema)
|
|
596
|
+
_validate_reference_cycles(mutable_schema)
|
|
597
|
+
except (SchemaError, TypeError, ValueError) as error:
|
|
598
|
+
detail = error.message if isinstance(error, SchemaError) else str(error)
|
|
599
|
+
raise ValueError(f"{location} is not a valid JSON Schema: {detail}") from error
|
|
600
|
+
boundary_match = _object_boundary_match(mutable_schema, mutable_schema)
|
|
601
|
+
object_match = boundary_match[_OBJECT_CATEGORY_INDEX]
|
|
602
|
+
if not object_match[0]:
|
|
603
|
+
raise ValueError(f"{location} cannot validate any object-shaped callable boundary")
|
|
604
|
+
if any(
|
|
605
|
+
category_match[0]
|
|
606
|
+
for category_index, category_match in enumerate(boundary_match)
|
|
607
|
+
if category_index != _OBJECT_CATEGORY_INDEX
|
|
608
|
+
):
|
|
609
|
+
raise ValueError(f"{location} must only validate object-shaped callable boundaries")
|
|
610
|
+
return cast(Mapping[str, Any], _immutable_json(mutable_schema))
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _contract_validation_path(validation_error: Any) -> str:
|
|
614
|
+
return "".join(
|
|
615
|
+
"/" + str(path_segment).replace("~", "~0").replace("/", "~1")
|
|
616
|
+
for path_segment in validation_error.absolute_path
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _validate_contract_instance(
|
|
621
|
+
definition: ToolDefinition,
|
|
622
|
+
instance: object,
|
|
623
|
+
*,
|
|
624
|
+
boundary: str,
|
|
625
|
+
schema: Mapping[str, Any],
|
|
626
|
+
) -> None:
|
|
627
|
+
mutable_schema = _mutable_json(schema)
|
|
628
|
+
validator = Draft202012Validator(mutable_schema, format_checker=FormatChecker())
|
|
629
|
+
validation_errors = sorted(
|
|
630
|
+
validator.iter_errors(cast(Any, instance)),
|
|
631
|
+
key=lambda error: (
|
|
632
|
+
tuple(str(path_segment) for path_segment in error.absolute_path),
|
|
633
|
+
tuple(str(path_segment) for path_segment in error.absolute_schema_path),
|
|
634
|
+
str(error.validator),
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
if not validation_errors:
|
|
638
|
+
return
|
|
639
|
+
validation_error = validation_errors[0]
|
|
640
|
+
instance_path = _contract_validation_path(validation_error) or "/"
|
|
641
|
+
raise ValueError(
|
|
642
|
+
f"tool contract {definition.identifier!r} rejected {boundary} at "
|
|
643
|
+
f"{instance_path} ({validation_error.validator})"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@dataclass(frozen=True)
|
|
648
|
+
class ToolEffects:
|
|
649
|
+
"""Static effect hints for orchestration, review, and policy enforcement."""
|
|
650
|
+
|
|
651
|
+
read_only: bool = False
|
|
652
|
+
destructive: bool = False
|
|
653
|
+
idempotent: bool = False
|
|
654
|
+
open_world: bool = True
|
|
655
|
+
|
|
656
|
+
def __post_init__(self) -> None:
|
|
657
|
+
if self.read_only and self.destructive:
|
|
658
|
+
raise ValueError("a read-only tool cannot be destructive")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@dataclass(frozen=True)
|
|
662
|
+
class ToolDefinition:
|
|
663
|
+
"""Immutable, versioned contract for a registered tool callable."""
|
|
664
|
+
|
|
665
|
+
name: str
|
|
666
|
+
version: str
|
|
667
|
+
description: str
|
|
668
|
+
input_schema: Mapping[str, Any]
|
|
669
|
+
output_schema: Mapping[str, Any]
|
|
670
|
+
effects: ToolEffects = field(default_factory=ToolEffects)
|
|
671
|
+
legacy_contract: bool = False
|
|
672
|
+
|
|
673
|
+
def __post_init__(self) -> None:
|
|
674
|
+
if not self.name.strip():
|
|
675
|
+
raise ValueError("tool definition name must be non-empty")
|
|
676
|
+
if not self.version.strip():
|
|
677
|
+
raise ValueError(f"tool {self.name!r} version must be non-empty")
|
|
678
|
+
if not self.description.strip():
|
|
679
|
+
raise ValueError(f"tool {self.name!r} description must be non-empty")
|
|
680
|
+
object.__setattr__(
|
|
681
|
+
self,
|
|
682
|
+
"input_schema",
|
|
683
|
+
_validated_schema(self.input_schema, f"tool {self.name!r} input_schema"),
|
|
684
|
+
)
|
|
685
|
+
object.__setattr__(
|
|
686
|
+
self,
|
|
687
|
+
"output_schema",
|
|
688
|
+
_validated_schema(self.output_schema, f"tool {self.name!r} output_schema"),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
@property
|
|
692
|
+
def identifier(self) -> str:
|
|
693
|
+
return f"{self.name}@{self.version}"
|
|
694
|
+
|
|
695
|
+
def as_dict(self) -> dict[str, Any]:
|
|
696
|
+
"""Return a JSON-serializable catalog entry without exposing mutable state."""
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
"name": self.name,
|
|
700
|
+
"version": self.version,
|
|
701
|
+
"identifier": self.identifier,
|
|
702
|
+
"description": self.description,
|
|
703
|
+
"input_schema": _mutable_json(self.input_schema),
|
|
704
|
+
"output_schema": _mutable_json(self.output_schema),
|
|
705
|
+
"effects": {
|
|
706
|
+
"read_only": self.effects.read_only,
|
|
707
|
+
"destructive": self.effects.destructive,
|
|
708
|
+
"idempotent": self.effects.idempotent,
|
|
709
|
+
"open_world": self.effects.open_world,
|
|
710
|
+
},
|
|
711
|
+
"legacy_contract": self.legacy_contract,
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
@classmethod
|
|
715
|
+
def legacy(cls, name: str) -> ToolDefinition:
|
|
716
|
+
open_object_schema = {"type": "object", "additionalProperties": True}
|
|
717
|
+
return cls(
|
|
718
|
+
name=name,
|
|
719
|
+
version="unversioned",
|
|
720
|
+
description="Legacy callable registered without a typed contract.",
|
|
721
|
+
input_schema=open_object_schema,
|
|
722
|
+
output_schema=open_object_schema,
|
|
723
|
+
effects=ToolEffects(open_world=True),
|
|
724
|
+
legacy_contract=True,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@dataclass
|
|
729
|
+
class _ReplayFlight:
|
|
730
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
731
|
+
participants: int = 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class ToolRegistry:
|
|
735
|
+
def __init__(self) -> None:
|
|
736
|
+
self._tools: dict[str, Tool] = {}
|
|
737
|
+
self._definitions: dict[str, ToolDefinition] = {}
|
|
738
|
+
self._binding_revisions: dict[str, int] = {}
|
|
739
|
+
self._next_binding_revision = 0
|
|
740
|
+
self._replay: dict[str, dict[str, Any]] = {}
|
|
741
|
+
self._replay_flights: dict[str, _ReplayFlight] = {}
|
|
742
|
+
|
|
743
|
+
def register(
|
|
744
|
+
self,
|
|
745
|
+
name: str,
|
|
746
|
+
tool: Tool,
|
|
747
|
+
*,
|
|
748
|
+
definition: ToolDefinition | None = None,
|
|
749
|
+
) -> None:
|
|
750
|
+
if not name.strip():
|
|
751
|
+
raise ValueError("tool registry name must be non-empty")
|
|
752
|
+
if definition is not None and definition.name != name:
|
|
753
|
+
raise ValueError(
|
|
754
|
+
f"tool definition name {definition.name!r} does not match registry name {name!r}"
|
|
755
|
+
)
|
|
756
|
+
self._next_binding_revision += 1
|
|
757
|
+
self._binding_revisions[name] = self._next_binding_revision
|
|
758
|
+
self._tools[name] = tool
|
|
759
|
+
if definition is not None:
|
|
760
|
+
self._definitions[name] = definition
|
|
761
|
+
elif name not in self._definitions:
|
|
762
|
+
self._definitions[name] = ToolDefinition.legacy(name)
|
|
763
|
+
|
|
764
|
+
def has(self, name: str) -> bool:
|
|
765
|
+
return name in self._tools
|
|
766
|
+
|
|
767
|
+
def definition(self, name: str) -> ToolDefinition:
|
|
768
|
+
if name not in self._definitions:
|
|
769
|
+
raise KeyError(f"tool definition not registered: {name!r}")
|
|
770
|
+
return self._definitions[name]
|
|
771
|
+
|
|
772
|
+
def binding_revision(self, name: str) -> int:
|
|
773
|
+
"""Return the process-local revision of one callable/contract binding."""
|
|
774
|
+
|
|
775
|
+
if name not in self._binding_revisions:
|
|
776
|
+
raise KeyError(f"tool not registered: {name!r}")
|
|
777
|
+
return self._binding_revisions[name]
|
|
778
|
+
|
|
779
|
+
@property
|
|
780
|
+
def definitions(self) -> Mapping[str, ToolDefinition]:
|
|
781
|
+
return MappingProxyType(dict(self._definitions))
|
|
782
|
+
|
|
783
|
+
async def call(self, name: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
784
|
+
if name not in self._tools:
|
|
785
|
+
raise KeyError(f"tool not registered: {name!r}")
|
|
786
|
+
definition = self.definition(name)
|
|
787
|
+
validate_json_value(
|
|
788
|
+
kwargs,
|
|
789
|
+
boundary=f"tool contract {definition.identifier!r} rejected input",
|
|
790
|
+
)
|
|
791
|
+
_validate_contract_instance(
|
|
792
|
+
definition,
|
|
793
|
+
kwargs,
|
|
794
|
+
boundary="input",
|
|
795
|
+
schema=definition.input_schema,
|
|
796
|
+
)
|
|
797
|
+
tool_result = await self._tools[name](**kwargs)
|
|
798
|
+
validate_json_value(
|
|
799
|
+
tool_result,
|
|
800
|
+
boundary=f"tool contract {definition.identifier!r} rejected output",
|
|
801
|
+
)
|
|
802
|
+
_validate_contract_instance(
|
|
803
|
+
definition,
|
|
804
|
+
tool_result,
|
|
805
|
+
boundary="output",
|
|
806
|
+
schema=definition.output_schema,
|
|
807
|
+
)
|
|
808
|
+
return tool_result
|
|
809
|
+
|
|
810
|
+
# ── idempotency replay cache ─────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
@staticmethod
|
|
813
|
+
def operation_namespace(
|
|
814
|
+
flow_name: str,
|
|
815
|
+
flow_revision: str,
|
|
816
|
+
terminal_step: str,
|
|
817
|
+
tool_identifier: str,
|
|
818
|
+
) -> str:
|
|
819
|
+
"""Return a stable namespace for one versioned terminal operation."""
|
|
820
|
+
|
|
821
|
+
namespace_components = {
|
|
822
|
+
"flow": flow_name,
|
|
823
|
+
"revision": flow_revision,
|
|
824
|
+
"terminal": terminal_step,
|
|
825
|
+
"tool": tool_identifier,
|
|
826
|
+
}
|
|
827
|
+
if empty_components := [
|
|
828
|
+
component_name
|
|
829
|
+
for component_name, component_content in namespace_components.items()
|
|
830
|
+
if not component_content
|
|
831
|
+
]:
|
|
832
|
+
rendered_components = ", ".join(empty_components)
|
|
833
|
+
raise ValueError(
|
|
834
|
+
f"operation namespace components must be non-empty: {rendered_components}"
|
|
835
|
+
)
|
|
836
|
+
return _canonical_json(namespace_components)
|
|
837
|
+
|
|
838
|
+
@staticmethod
|
|
839
|
+
def idempotency_key(
|
|
840
|
+
user_id: str,
|
|
841
|
+
operation_namespace: str,
|
|
842
|
+
inputs: dict[str, Any],
|
|
843
|
+
) -> str:
|
|
844
|
+
"""Hash strict JSON inputs within a stable, versioned operation namespace."""
|
|
845
|
+
|
|
846
|
+
if not operation_namespace:
|
|
847
|
+
raise ValueError("operation namespace must be non-empty")
|
|
848
|
+
validate_json_value(inputs, boundary="idempotency inputs")
|
|
849
|
+
blob = _canonical_json(
|
|
850
|
+
{
|
|
851
|
+
"inputs": inputs,
|
|
852
|
+
"operation_namespace": operation_namespace,
|
|
853
|
+
"user_id": user_id,
|
|
854
|
+
}
|
|
855
|
+
)
|
|
856
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
857
|
+
|
|
858
|
+
def replay_get(self, key: str) -> dict[str, Any] | None:
|
|
859
|
+
replayed_result = self._replay.get(key)
|
|
860
|
+
return copy.deepcopy(replayed_result) if replayed_result is not None else None
|
|
861
|
+
|
|
862
|
+
def replay_put(self, key: str, result: dict[str, Any]) -> None:
|
|
863
|
+
self._replay[key] = copy.deepcopy(result)
|
|
864
|
+
|
|
865
|
+
async def call_idempotent(
|
|
866
|
+
self,
|
|
867
|
+
name: str,
|
|
868
|
+
idempotency_key: str,
|
|
869
|
+
/,
|
|
870
|
+
**kwargs: Any,
|
|
871
|
+
) -> dict[str, Any]:
|
|
872
|
+
"""Coalesce one process-local call per key and replay successful results."""
|
|
873
|
+
|
|
874
|
+
if not idempotency_key:
|
|
875
|
+
raise ValueError("idempotency key must be non-empty")
|
|
876
|
+
replay_flight = self._replay_flights.get(idempotency_key)
|
|
877
|
+
if replay_flight is None:
|
|
878
|
+
replay_flight = _ReplayFlight()
|
|
879
|
+
self._replay_flights[idempotency_key] = replay_flight
|
|
880
|
+
replay_flight.participants += 1
|
|
881
|
+
try:
|
|
882
|
+
async with replay_flight.lock:
|
|
883
|
+
if (replayed_result := self.replay_get(idempotency_key)) is not None:
|
|
884
|
+
return replayed_result
|
|
885
|
+
tool_result = await self.call(name, **kwargs)
|
|
886
|
+
if tool_result.get("status", "success") == "success":
|
|
887
|
+
self.replay_put(idempotency_key, tool_result)
|
|
888
|
+
return tool_result
|
|
889
|
+
finally:
|
|
890
|
+
replay_flight.participants -= 1
|
|
891
|
+
if (
|
|
892
|
+
replay_flight.participants == 0
|
|
893
|
+
and self._replay_flights.get(idempotency_key) is replay_flight
|
|
894
|
+
):
|
|
895
|
+
self._replay_flights.pop(idempotency_key, None)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# ── default fake backends ────────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
async def _fake_hub_search(**kwargs: Any) -> dict[str, Any]:
|
|
902
|
+
"""Best-effort knowledge load (entry.tool). Never blocks the flow."""
|
|
903
|
+
return {
|
|
904
|
+
"status": "ok",
|
|
905
|
+
"name": "Streetlight repair",
|
|
906
|
+
"summary": "Repair public lighting infrastructure.",
|
|
907
|
+
"estimated_resolution": "Three business days",
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
async def _fake_geocode(address: str = "", **_: Any) -> dict[str, Any]:
|
|
912
|
+
"""Geocode/validate an address string (address subflow backend)."""
|
|
913
|
+
text = (address or "").strip()
|
|
914
|
+
if not text:
|
|
915
|
+
return {"status": "not_found", "error": "empty address"}
|
|
916
|
+
folded = text.lower()
|
|
917
|
+
kind = "square" if "square" in folded else "street"
|
|
918
|
+
return {
|
|
919
|
+
"status": "ok",
|
|
920
|
+
"needs_confirmation": True,
|
|
921
|
+
"address": {
|
|
922
|
+
"street": text,
|
|
923
|
+
"kind": kind,
|
|
924
|
+
"district": "Downtown",
|
|
925
|
+
"city": "Example City",
|
|
926
|
+
},
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
async def _fake_brazilian_tax_id_lookup(brazilian_tax_id: str = "", **_: Any) -> dict[str, Any]:
|
|
931
|
+
"""Look up a citizen's registry by Brazilian tax ID (identification backend)."""
|
|
932
|
+
return {"status": "ok", "name": "", "email": "", "phones": []}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
async def _fake_govbr_enrich(brazilian_tax_id: str = "", **_: Any) -> dict[str, Any]:
|
|
936
|
+
"""Enrich gov.br data with internal registry (await_external.on_resume.enrich)."""
|
|
937
|
+
return {"status": "ok", "phones": ["5521999999999"]}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
async def _fake_open_service_request(**inputs: Any) -> dict[str, Any]:
|
|
941
|
+
"""Open a ticketing-system request (terminal.tool). Return its protocol ID."""
|
|
942
|
+
digest = hashlib.sha256(_canonical_json(inputs).encode()).hexdigest()
|
|
943
|
+
return {
|
|
944
|
+
"status": "success",
|
|
945
|
+
"protocol_id": f"REQ-{digest[:10].upper()}",
|
|
946
|
+
"message": "Service request opened successfully.",
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _status_schema(*statuses: str) -> dict[str, Any]:
|
|
951
|
+
return {"type": "string", "enum": list(statuses)}
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _default_tool_definitions() -> dict[str, ToolDefinition]:
|
|
955
|
+
read_only_external = ToolEffects(read_only=True, idempotent=True, open_world=True)
|
|
956
|
+
return {
|
|
957
|
+
"hub_search": ToolDefinition(
|
|
958
|
+
name="hub_search",
|
|
959
|
+
version="1",
|
|
960
|
+
description="Load public service knowledge for flow initialization.",
|
|
961
|
+
input_schema={"type": "object", "additionalProperties": False},
|
|
962
|
+
output_schema={
|
|
963
|
+
"type": "object",
|
|
964
|
+
"additionalProperties": False,
|
|
965
|
+
"properties": {
|
|
966
|
+
"status": {"const": "ok"},
|
|
967
|
+
"name": {"type": "string"},
|
|
968
|
+
"summary": {"type": "string"},
|
|
969
|
+
"estimated_resolution": {"type": "string"},
|
|
970
|
+
},
|
|
971
|
+
"required": ["status"],
|
|
972
|
+
},
|
|
973
|
+
effects=read_only_external,
|
|
974
|
+
),
|
|
975
|
+
"geocode": ToolDefinition(
|
|
976
|
+
name="geocode",
|
|
977
|
+
version="1",
|
|
978
|
+
description="Resolve and classify a citizen-provided address.",
|
|
979
|
+
input_schema={
|
|
980
|
+
"type": "object",
|
|
981
|
+
"additionalProperties": False,
|
|
982
|
+
"properties": {"address": {"type": "string"}},
|
|
983
|
+
"required": ["address"],
|
|
984
|
+
},
|
|
985
|
+
output_schema={
|
|
986
|
+
"type": "object",
|
|
987
|
+
"additionalProperties": False,
|
|
988
|
+
"properties": {
|
|
989
|
+
"status": _status_schema("ok", "not_found", "error"),
|
|
990
|
+
"needs_confirmation": {"type": "boolean"},
|
|
991
|
+
"error": {"type": "string"},
|
|
992
|
+
"address": {
|
|
993
|
+
"type": "object",
|
|
994
|
+
"additionalProperties": False,
|
|
995
|
+
"properties": {
|
|
996
|
+
"street": {"type": "string", "minLength": 1},
|
|
997
|
+
"kind": {"type": "string", "minLength": 1},
|
|
998
|
+
"district": {"type": "string"},
|
|
999
|
+
"city": {"type": "string"},
|
|
1000
|
+
},
|
|
1001
|
+
"required": ["street"],
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
"required": ["status"],
|
|
1005
|
+
"allOf": [
|
|
1006
|
+
{
|
|
1007
|
+
"if": {
|
|
1008
|
+
"properties": {"status": {"const": "ok"}},
|
|
1009
|
+
"required": ["status"],
|
|
1010
|
+
},
|
|
1011
|
+
"then": {"required": ["address", "needs_confirmation"]},
|
|
1012
|
+
"else": {"required": ["error"]},
|
|
1013
|
+
}
|
|
1014
|
+
],
|
|
1015
|
+
},
|
|
1016
|
+
effects=read_only_external,
|
|
1017
|
+
),
|
|
1018
|
+
"brazilian_tax_id_lookup": ToolDefinition(
|
|
1019
|
+
name="brazilian_tax_id_lookup",
|
|
1020
|
+
version="1",
|
|
1021
|
+
description="Look up optional citizen contact data by Brazilian tax ID.",
|
|
1022
|
+
input_schema={
|
|
1023
|
+
"type": "object",
|
|
1024
|
+
"additionalProperties": False,
|
|
1025
|
+
"properties": {"brazilian_tax_id": {"type": "string", "pattern": "^[0-9]{11}$"}},
|
|
1026
|
+
"required": ["brazilian_tax_id"],
|
|
1027
|
+
},
|
|
1028
|
+
output_schema={
|
|
1029
|
+
"type": "object",
|
|
1030
|
+
"additionalProperties": False,
|
|
1031
|
+
"properties": {
|
|
1032
|
+
"status": _status_schema("ok", "error"),
|
|
1033
|
+
"name": {"type": "string"},
|
|
1034
|
+
"email": {"anyOf": [{"const": ""}, {"type": "string", "format": "email"}]},
|
|
1035
|
+
"phones": {
|
|
1036
|
+
"type": "array",
|
|
1037
|
+
"items": {"type": "string", "pattern": "^\\+?[1-9][0-9]{7,14}$"},
|
|
1038
|
+
},
|
|
1039
|
+
"error": {"type": "string", "minLength": 1},
|
|
1040
|
+
},
|
|
1041
|
+
"required": ["status"],
|
|
1042
|
+
"allOf": [
|
|
1043
|
+
{
|
|
1044
|
+
"if": {
|
|
1045
|
+
"properties": {"status": {"const": "error"}},
|
|
1046
|
+
"required": ["status"],
|
|
1047
|
+
},
|
|
1048
|
+
"then": {"required": ["error"]},
|
|
1049
|
+
}
|
|
1050
|
+
],
|
|
1051
|
+
},
|
|
1052
|
+
effects=read_only_external,
|
|
1053
|
+
),
|
|
1054
|
+
"get_user_info": ToolDefinition(
|
|
1055
|
+
name="get_user_info",
|
|
1056
|
+
version="1",
|
|
1057
|
+
description="Enrich a gov.br identity with internal citizen contact data.",
|
|
1058
|
+
input_schema={
|
|
1059
|
+
"type": "object",
|
|
1060
|
+
"additionalProperties": False,
|
|
1061
|
+
"properties": {"brazilian_tax_id": {"type": "string", "pattern": "^[0-9]{11}$"}},
|
|
1062
|
+
"required": ["brazilian_tax_id"],
|
|
1063
|
+
},
|
|
1064
|
+
output_schema={
|
|
1065
|
+
"type": "object",
|
|
1066
|
+
"additionalProperties": False,
|
|
1067
|
+
"properties": {
|
|
1068
|
+
"status": _status_schema("ok", "error"),
|
|
1069
|
+
"phones": {
|
|
1070
|
+
"type": "array",
|
|
1071
|
+
"items": {"type": "string", "pattern": "^\\+?[1-9][0-9]{7,14}$"},
|
|
1072
|
+
},
|
|
1073
|
+
"email": {"anyOf": [{"const": ""}, {"type": "string", "format": "email"}]},
|
|
1074
|
+
"name": {"type": "string"},
|
|
1075
|
+
"error": {"type": "string", "minLength": 1},
|
|
1076
|
+
},
|
|
1077
|
+
"required": ["status"],
|
|
1078
|
+
"allOf": [
|
|
1079
|
+
{
|
|
1080
|
+
"if": {
|
|
1081
|
+
"properties": {"status": {"const": "error"}},
|
|
1082
|
+
"required": ["status"],
|
|
1083
|
+
},
|
|
1084
|
+
"then": {"required": ["error"]},
|
|
1085
|
+
}
|
|
1086
|
+
],
|
|
1087
|
+
},
|
|
1088
|
+
effects=read_only_external,
|
|
1089
|
+
),
|
|
1090
|
+
"open_service_request": ToolDefinition(
|
|
1091
|
+
name="open_service_request",
|
|
1092
|
+
version="2",
|
|
1093
|
+
description="Create a service request in the ticketing system and return its protocol.",
|
|
1094
|
+
input_schema={
|
|
1095
|
+
"type": "object",
|
|
1096
|
+
"additionalProperties": False,
|
|
1097
|
+
"properties": {
|
|
1098
|
+
"streetlightIssue": {"type": "string", "minLength": 1},
|
|
1099
|
+
"address": {"type": "object", "minProperties": 1},
|
|
1100
|
+
"referencePoint": {"type": ["string", "null"]},
|
|
1101
|
+
"nearSportsCourt": {"type": ["boolean", "null"]},
|
|
1102
|
+
"requester": {"type": ["string", "null"]},
|
|
1103
|
+
"potholeType": {"type": "string", "minLength": 1},
|
|
1104
|
+
"size": {"type": "string", "minLength": 1},
|
|
1105
|
+
"problem": {"type": "string", "minLength": 1},
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
output_schema={
|
|
1109
|
+
"type": "object",
|
|
1110
|
+
"additionalProperties": False,
|
|
1111
|
+
"properties": {
|
|
1112
|
+
"status": _status_schema("success", "retryable", "fatal"),
|
|
1113
|
+
"protocol_id": {"type": "string", "minLength": 1},
|
|
1114
|
+
"message": {"type": "string", "minLength": 1},
|
|
1115
|
+
"error": {"type": "string", "minLength": 1},
|
|
1116
|
+
},
|
|
1117
|
+
"required": ["status"],
|
|
1118
|
+
"allOf": [
|
|
1119
|
+
{
|
|
1120
|
+
"if": {
|
|
1121
|
+
"properties": {"status": {"const": "success"}},
|
|
1122
|
+
"required": ["status"],
|
|
1123
|
+
},
|
|
1124
|
+
"then": {"required": ["protocol_id"]},
|
|
1125
|
+
}
|
|
1126
|
+
],
|
|
1127
|
+
},
|
|
1128
|
+
effects=ToolEffects(
|
|
1129
|
+
read_only=False,
|
|
1130
|
+
destructive=False,
|
|
1131
|
+
idempotent=True,
|
|
1132
|
+
open_world=True,
|
|
1133
|
+
),
|
|
1134
|
+
),
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def default_tool_registry() -> ToolRegistry:
|
|
1139
|
+
reg = ToolRegistry()
|
|
1140
|
+
definitions = _default_tool_definitions()
|
|
1141
|
+
reg.register("hub_search", _fake_hub_search, definition=definitions["hub_search"])
|
|
1142
|
+
reg.register("geocode", _fake_geocode, definition=definitions["geocode"])
|
|
1143
|
+
reg.register(
|
|
1144
|
+
"brazilian_tax_id_lookup",
|
|
1145
|
+
_fake_brazilian_tax_id_lookup,
|
|
1146
|
+
definition=definitions["brazilian_tax_id_lookup"],
|
|
1147
|
+
)
|
|
1148
|
+
reg.register("get_user_info", _fake_govbr_enrich, definition=definitions["get_user_info"])
|
|
1149
|
+
reg.register(
|
|
1150
|
+
"open_service_request",
|
|
1151
|
+
_fake_open_service_request,
|
|
1152
|
+
definition=definitions["open_service_request"],
|
|
1153
|
+
)
|
|
1154
|
+
return reg
|