flowspec2 1.0.0__py3-none-any.whl

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