millforge 0.1.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 (116) hide show
  1. millforge/__init__.py +1174 -0
  2. millforge/_forge/LICENSE +21 -0
  3. millforge/_forge/PROVENANCE.json +295 -0
  4. millforge/_forge/UPDATE_POLICY.md +24 -0
  5. millforge/_forge/__init__.py +14 -0
  6. millforge/_forge/adapter.py +2232 -0
  7. millforge/_forge/base_runner.py +121 -0
  8. millforge/_forge/clients/__init__.py +10 -0
  9. millforge/_forge/clients/base.py +200 -0
  10. millforge/_forge/context/__init__.py +23 -0
  11. millforge/_forge/context/manager.py +178 -0
  12. millforge/_forge/context/strategies.py +335 -0
  13. millforge/_forge/core/__init__.py +16 -0
  14. millforge/_forge/core/inference.py +433 -0
  15. millforge/_forge/core/messages.py +119 -0
  16. millforge/_forge/core/runner.py +479 -0
  17. millforge/_forge/core/steps.py +108 -0
  18. millforge/_forge/core/workflow.py +400 -0
  19. millforge/_forge/errors.py +222 -0
  20. millforge/_forge/guardrails/__init__.py +21 -0
  21. millforge/_forge/guardrails/error_tracker.py +71 -0
  22. millforge/_forge/guardrails/guardrails.py +194 -0
  23. millforge/_forge/guardrails/nudge.py +47 -0
  24. millforge/_forge/guardrails/response_validator.py +119 -0
  25. millforge/_forge/guardrails/step_enforcer.py +183 -0
  26. millforge/_forge/prompts/__init__.py +16 -0
  27. millforge/_forge/prompts/nudges.py +95 -0
  28. millforge/_forge/prompts/templates.py +285 -0
  29. millforge/_version.py +3 -0
  30. millforge/artifacts.py +570 -0
  31. millforge/base/__init__.py +97 -0
  32. millforge/base/composition.py +402 -0
  33. millforge/base/context.py +285 -0
  34. millforge/base/harness.py +138 -0
  35. millforge/base/identity.py +465 -0
  36. millforge/base/options.py +34 -0
  37. millforge/base/platform.py +17 -0
  38. millforge/base/prompt.py +317 -0
  39. millforge/base/runner.py +546 -0
  40. millforge/compiled_plan.py +970 -0
  41. millforge/compiler/__init__.py +231 -0
  42. millforge/compiler/artifact_validation.py +257 -0
  43. millforge/compiler/canonicalization.py +169 -0
  44. millforge/compiler/capabilities.py +66 -0
  45. millforge/compiler/catalogs.py +500 -0
  46. millforge/compiler/diagnostics.py +491 -0
  47. millforge/compiler/graph.py +678 -0
  48. millforge/compiler/lowering.py +198 -0
  49. millforge/compiler/output.py +692 -0
  50. millforge/compiler/parsing.py +1424 -0
  51. millforge/compiler/requests.py +1180 -0
  52. millforge/compiler/schema_validation.py +272 -0
  53. millforge/compiler/semantic.py +490 -0
  54. millforge/compiler/service.py +448 -0
  55. millforge/compiler/source.py +375 -0
  56. millforge/compiler/validators.py +184 -0
  57. millforge/connectors/__init__.py +95 -0
  58. millforge/connectors/admission.py +801 -0
  59. millforge/connectors/broker.py +202 -0
  60. millforge/connectors/contracts.py +1159 -0
  61. millforge/connectors/diagnostics.py +189 -0
  62. millforge/connectors/fake.py +66 -0
  63. millforge/connectors/runtime.py +236 -0
  64. millforge/contracts.py +2860 -0
  65. millforge/custom_tools/__init__.py +67 -0
  66. millforge/custom_tools/compiler.py +724 -0
  67. millforge/custom_tools/contracts.py +1093 -0
  68. millforge/custom_tools/diagnostics.py +205 -0
  69. millforge/eval_artifacts.py +952 -0
  70. millforge/eval_boundary.py +2435 -0
  71. millforge/eval_fixtures/__init__.py +1 -0
  72. millforge/eval_fixtures/default_pack/__init__.py +1 -0
  73. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
  74. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
  75. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
  76. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
  77. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
  78. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
  79. millforge/eval_fixtures/default_pack/manifest.json +12 -0
  80. millforge/eval_modes.py +1282 -0
  81. millforge/eval_presets.py +1398 -0
  82. millforge/eval_reports.py +2517 -0
  83. millforge/eval_suite.py +2429 -0
  84. millforge/eval_trials.py +2632 -0
  85. millforge/eval_workflow.py +794 -0
  86. millforge/exceptions.py +122 -0
  87. millforge/model_backend.py +2098 -0
  88. millforge/protocols.py +340 -0
  89. millforge/py.typed +0 -0
  90. millforge/runtime.py +1791 -0
  91. millforge/testing/__init__.py +1089 -0
  92. millforge/tools/__init__.py +83 -0
  93. millforge/tools/builtin_runtime.py +1339 -0
  94. millforge/tools/builtins.py +773 -0
  95. millforge/tools/execution.py +1545 -0
  96. millforge/tools/path_policy.py +155 -0
  97. millforge/tools/pi_compat/PI_LICENSE +21 -0
  98. millforge/tools/pi_compat/PROVENANCE.json +55 -0
  99. millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
  100. millforge/tools/pi_compat/__init__.py +34 -0
  101. millforge/tools/pi_compat/contracts.py +49 -0
  102. millforge/tools/pi_compat/editing.py +390 -0
  103. millforge/tools/pi_compat/mutations.py +57 -0
  104. millforge/tools/pi_compat/operations.py +401 -0
  105. millforge/tools/pi_compat/paths.py +155 -0
  106. millforge/tools/pi_compat/process.py +1375 -0
  107. millforge/tools/pi_compat/search.py +738 -0
  108. millforge/tools/pi_compat/truncation.py +267 -0
  109. millforge/tools/pi_compat_catalog.py +396 -0
  110. millforge/tools/pi_compat_runtime.py +460 -0
  111. millforge/tools/registry.py +553 -0
  112. millforge/tools/results.py +533 -0
  113. millforge-0.1.0.dist-info/METADATA +844 -0
  114. millforge-0.1.0.dist-info/RECORD +116 -0
  115. millforge-0.1.0.dist-info/WHEEL +4 -0
  116. millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,553 @@
1
+ """Immutable tool descriptor contracts and deterministic registry snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Mapping
7
+ from enum import Enum
8
+ from types import MappingProxyType
9
+ from typing import Any, ClassVar, Literal
10
+
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Field,
15
+ StrictBool,
16
+ StrictInt,
17
+ StrictStr,
18
+ field_serializer,
19
+ field_validator,
20
+ model_validator,
21
+ )
22
+
23
+ from millforge import IdempotencyClass, SideEffectClass, canonical_json_serialize
24
+ from millforge.compiler.catalogs import (
25
+ CatalogSnapshotMetadata,
26
+ MAX_CAPABILITY_ID_UTF8,
27
+ MAX_IMPLEMENTATION_ID_UTF8,
28
+ MAX_MODEL_TOOL_NAME_UTF8,
29
+ MAX_TOOL_DESCRIPTION_UTF8,
30
+ RawToolDescriptor,
31
+ ToolCatalogEntry,
32
+ ToolCatalogLookup,
33
+ ToolCatalogSnapshot,
34
+ )
35
+ from millforge.compiler.diagnostics import detect_secret_candidate
36
+ from millforge.compiler.schema_validation import validate_json_schema_subset
37
+ from millforge.compiler.validators import (
38
+ validate_artifact_id,
39
+ validate_capability_id,
40
+ validate_canonical_tool_id,
41
+ validate_nonblank,
42
+ validate_tool_version,
43
+ validate_unique,
44
+ validate_utf8_size,
45
+ )
46
+ from millforge.contracts import RedactionPolicy
47
+
48
+ DESCRIPTOR_SCHEMA_VERSION = 1
49
+ DESCRIPTOR_HASH_KIND = "millforge.tool_descriptor.v1"
50
+ SNAPSHOT_KIND = "millforge.tool_registry.snapshot.v1"
51
+ SNAPSHOT_ID_KIND = "millforge.tool_registry.snapshot_id.v1"
52
+
53
+ MAX_TIMEOUT_SECONDS = 86_400
54
+ MAX_CANCELLATION_GRACE_SECONDS = 3_600
55
+ MAX_OUTPUT_BYTES = 64 * 1024 * 1024
56
+ MAX_OUTPUT_SUMMARY_UTF8 = 65_536
57
+
58
+ _SIDE_EFFECTS_REQUIRING_CAPABILITY = frozenset(
59
+ item for item in SideEffectClass if item is not SideEffectClass.READ_ONLY
60
+ )
61
+
62
+
63
+ JsonValue = Any
64
+
65
+
66
+ class ToolRegistryErrorCode(str, Enum):
67
+ """Stable registry error categories."""
68
+
69
+ DESCRIPTOR_INVALID = "descriptor_invalid"
70
+ DUPLICATE_TOOL = "duplicate_tool"
71
+ DUPLICATE_IMPLEMENTATION = "duplicate_implementation"
72
+ REGISTRY_FROZEN = "registry_frozen"
73
+ LOOKUP_INVALID = "lookup_invalid"
74
+ LOOKUP_MISSING = "lookup_missing"
75
+ PROJECTION_INVALID = "projection_invalid"
76
+
77
+
78
+ class ToolRegistryError(ValueError):
79
+ """Typed deterministic registry error with redacted evidence."""
80
+
81
+ def __init__(
82
+ self,
83
+ code: ToolRegistryErrorCode,
84
+ message: str,
85
+ *,
86
+ evidence: Mapping[str, str] | None = None,
87
+ ) -> None:
88
+ super().__init__(message)
89
+ self.code = code
90
+ self.evidence = _redacted_evidence(evidence or {})
91
+
92
+
93
+ class ToolTimeoutPolicy(BaseModel):
94
+ """Descriptor-owned timeout policy data for later execution stages."""
95
+
96
+ model_config = ConfigDict(frozen=True, extra="forbid")
97
+
98
+ timeout_seconds: StrictInt = Field(ge=1, le=MAX_TIMEOUT_SECONDS)
99
+ cancellation_grace_seconds: StrictInt = Field(
100
+ ge=1, le=MAX_CANCELLATION_GRACE_SECONDS
101
+ )
102
+
103
+
104
+ class ToolOutputPolicy(BaseModel):
105
+ """Descriptor-owned output policy data for later execution stages."""
106
+
107
+ model_config = ConfigDict(frozen=True, extra="forbid")
108
+
109
+ max_output_bytes: StrictInt = Field(ge=1, le=MAX_OUTPUT_BYTES)
110
+ max_summary_utf8: StrictInt = Field(ge=1, le=MAX_OUTPUT_SUMMARY_UTF8)
111
+ redact_secrets: StrictBool
112
+
113
+
114
+ class ToolDescriptor(BaseModel):
115
+ """Immutable registry-owned descriptor with a computed SHA-256 identity."""
116
+
117
+ model_config = ConfigDict(
118
+ frozen=True,
119
+ extra="forbid",
120
+ arbitrary_types_allowed=True,
121
+ hide_input_in_errors=True,
122
+ use_enum_values=False,
123
+ )
124
+
125
+ schema_version: Literal[1] = 1
126
+ kind: Literal["millforge.tool_descriptor.v1"] = "millforge.tool_descriptor.v1"
127
+ tool_id: StrictStr
128
+ tool_version: StrictInt = Field(ge=1)
129
+ implementation_id: StrictStr
130
+ model_tool_name: StrictStr
131
+ description: StrictStr
132
+ input_schema: MappingProxyType[str, JsonValue]
133
+ output_schema: MappingProxyType[str, JsonValue]
134
+ required_capabilities: tuple[StrictStr, ...] = Field(default_factory=tuple)
135
+ produced_artifact_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
136
+ side_effect_class: SideEffectClass
137
+ idempotency: IdempotencyClass
138
+ timeout_policy: ToolTimeoutPolicy
139
+ output_policy: ToolOutputPolicy
140
+
141
+ _secret_policy: ClassVar[RedactionPolicy] = RedactionPolicy()
142
+
143
+ @field_validator("tool_id")
144
+ @classmethod
145
+ def _tool_id_valid(cls, value: str) -> str:
146
+ return validate_canonical_tool_id(value)
147
+
148
+ @field_validator("tool_version")
149
+ @classmethod
150
+ def _tool_version_valid(cls, value: int) -> int:
151
+ return validate_tool_version(value)
152
+
153
+ @field_validator("implementation_id")
154
+ @classmethod
155
+ def _implementation_id_valid(cls, value: str) -> str:
156
+ _validate_descriptor_string(
157
+ value, "implementation_id", MAX_IMPLEMENTATION_ID_UTF8
158
+ )
159
+ return value
160
+
161
+ @field_validator("model_tool_name")
162
+ @classmethod
163
+ def _model_tool_name_valid(cls, value: str) -> str:
164
+ _validate_descriptor_string(value, "model_tool_name", MAX_MODEL_TOOL_NAME_UTF8)
165
+ return value
166
+
167
+ @field_validator("description")
168
+ @classmethod
169
+ def _description_valid(cls, value: str) -> str:
170
+ _validate_descriptor_string(value, "description", MAX_TOOL_DESCRIPTION_UTF8)
171
+ return value
172
+
173
+ @field_validator("input_schema", "output_schema", mode="before")
174
+ @classmethod
175
+ def _schema_valid(cls, value: Any, info: Any) -> MappingProxyType[str, Any]:
176
+ _reject_runtime_values(value, path=f"/{info.field_name}")
177
+ _reject_secret_values(value, path=f"/{info.field_name}")
178
+ return validate_json_schema_subset(value, field_name=info.field_name)
179
+
180
+ @field_validator("required_capabilities", mode="before")
181
+ @classmethod
182
+ def _capabilities_snapshot(cls, value: Any) -> tuple[str, ...]:
183
+ return _sorted_string_tuple(value, "required_capabilities")
184
+
185
+ @field_validator("required_capabilities")
186
+ @classmethod
187
+ def _capabilities_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
188
+ for capability in value:
189
+ _validate_descriptor_string(
190
+ capability, "required_capabilities", MAX_CAPABILITY_ID_UTF8
191
+ )
192
+ validate_capability_id(capability)
193
+ return validate_unique(value, "required_capabilities")
194
+
195
+ @field_validator("produced_artifact_ids", mode="before")
196
+ @classmethod
197
+ def _produced_artifacts_snapshot(cls, value: Any) -> tuple[str, ...]:
198
+ return _sorted_string_tuple(value, "produced_artifact_ids")
199
+
200
+ @field_validator("produced_artifact_ids")
201
+ @classmethod
202
+ def _produced_artifacts_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
203
+ for artifact_id in value:
204
+ validate_artifact_id(artifact_id)
205
+ return validate_unique(value, "produced_artifact_ids")
206
+
207
+ @model_validator(mode="after")
208
+ def _descriptor_semantics_valid(self) -> ToolDescriptor:
209
+ if (
210
+ self.side_effect_class in _SIDE_EFFECTS_REQUIRING_CAPABILITY
211
+ or self.produced_artifact_ids
212
+ ) and not self.required_capabilities:
213
+ raise ValueError(
214
+ "side-effecting or artifact-producing descriptors require capabilities"
215
+ )
216
+ return self
217
+
218
+ @property
219
+ def descriptor_sha256(self) -> str:
220
+ return hashlib.sha256(
221
+ canonical_json_serialize(descriptor_hash_payload(self)).encode("utf-8")
222
+ ).hexdigest()
223
+
224
+ @field_serializer("input_schema", "output_schema")
225
+ def _serialize_schema(self, value: MappingProxyType[str, Any]) -> Any:
226
+ return _thaw_json_value(value)
227
+
228
+ def to_raw_descriptor(self) -> RawToolDescriptor:
229
+ """Project into the accepted compiler catalog descriptor contract."""
230
+ return RawToolDescriptor(
231
+ tool_id=self.tool_id,
232
+ tool_version=self.tool_version,
233
+ implementation_id=self.implementation_id,
234
+ descriptor_sha256=self.descriptor_sha256,
235
+ model_tool_name=self.model_tool_name,
236
+ description=self.description,
237
+ input_schema=_thaw_json_value(self.input_schema),
238
+ output_schema=_thaw_json_value(self.output_schema),
239
+ side_effect_class=self.side_effect_class,
240
+ idempotency=self.idempotency,
241
+ required_capabilities=self.required_capabilities,
242
+ produced_artifact_ids=self.produced_artifact_ids,
243
+ )
244
+
245
+ def to_catalog_entry(self) -> ToolCatalogEntry:
246
+ """Project into an immutable compiler catalog entry."""
247
+ return ToolCatalogEntry.admit(
248
+ self.to_raw_descriptor(),
249
+ expected_tool_id=self.tool_id,
250
+ expected_tool_version=self.tool_version,
251
+ timeout_policy=self.timeout_policy,
252
+ output_policy=self.output_policy,
253
+ )
254
+
255
+
256
+ class FrozenDescriptorHashRecord(BaseModel):
257
+ """Immutable descriptor identity record covered by snapshot hashing."""
258
+
259
+ model_config = ConfigDict(frozen=True, extra="forbid")
260
+
261
+ tool_id: StrictStr
262
+ tool_version: StrictInt = Field(ge=1)
263
+ implementation_id: StrictStr
264
+ descriptor_sha256: StrictStr
265
+
266
+ @field_validator("tool_id")
267
+ @classmethod
268
+ def _tool_id_valid(cls, value: str) -> str:
269
+ return validate_canonical_tool_id(value)
270
+
271
+ @field_validator("tool_version")
272
+ @classmethod
273
+ def _tool_version_valid(cls, value: int) -> int:
274
+ return validate_tool_version(value)
275
+
276
+
277
+ class FrozenToolRegistrySnapshot:
278
+ """Immutable exact-version lookup snapshot for semantic compilation."""
279
+
280
+ def __init__(self, descriptors: tuple[ToolDescriptor, ...]) -> None:
281
+ entries: dict[tuple[str, int], ToolCatalogEntry] = {}
282
+ records: list[FrozenDescriptorHashRecord] = []
283
+ for descriptor in descriptors:
284
+ try:
285
+ entry = descriptor.to_catalog_entry()
286
+ except Exception as exc:
287
+ raise ToolRegistryError(
288
+ ToolRegistryErrorCode.PROJECTION_INVALID,
289
+ "descriptor projection failed",
290
+ evidence={"error_type": type(exc).__name__},
291
+ ) from exc
292
+ entries[(descriptor.tool_id, descriptor.tool_version)] = entry
293
+ records.append(
294
+ FrozenDescriptorHashRecord(
295
+ tool_id=descriptor.tool_id,
296
+ tool_version=descriptor.tool_version,
297
+ implementation_id=entry.implementation_id,
298
+ descriptor_sha256=entry.descriptor_sha256,
299
+ )
300
+ )
301
+ self._entries = MappingProxyType(dict(sorted(entries.items())))
302
+ self._descriptor_hash_records = tuple(
303
+ sorted(records, key=_descriptor_hash_record_identity_key)
304
+ )
305
+ snapshot_payload = {
306
+ "schema_version": 1,
307
+ "kind": SNAPSHOT_KIND,
308
+ "descriptors": [
309
+ record.model_dump(mode="json")
310
+ for record in self._descriptor_hash_records
311
+ ],
312
+ }
313
+ snapshot_sha256 = hashlib.sha256(
314
+ canonical_json_serialize(snapshot_payload).encode("utf-8")
315
+ ).hexdigest()
316
+ snapshot_id_payload = {
317
+ "schema_version": 1,
318
+ "kind": SNAPSHOT_ID_KIND,
319
+ "snapshot_sha256": snapshot_sha256,
320
+ }
321
+ snapshot_id = hashlib.sha256(
322
+ canonical_json_serialize(snapshot_id_payload).encode("utf-8")
323
+ ).hexdigest()
324
+ self._snapshot_sha256 = snapshot_sha256
325
+ self._snapshot_id = snapshot_id
326
+ CatalogSnapshotMetadata(
327
+ snapshot_id=self._snapshot_id,
328
+ snapshot_sha256=self._snapshot_sha256,
329
+ )
330
+
331
+ @property
332
+ def snapshot_id(self) -> str:
333
+ return self._snapshot_id
334
+
335
+ @property
336
+ def snapshot_sha256(self) -> str:
337
+ return self._snapshot_sha256
338
+
339
+ @property
340
+ def descriptor_hash_records(self) -> tuple[FrozenDescriptorHashRecord, ...]:
341
+ return self._descriptor_hash_records
342
+
343
+ def resolve_exact(self, tool_id: str, tool_version: int) -> ToolCatalogLookup:
344
+ try:
345
+ if _looks_like_alias(tool_id):
346
+ raise ValueError("tool_id aliases are not supported")
347
+ if not isinstance(tool_version, int) or isinstance(tool_version, bool):
348
+ raise ValueError("tool_version must be an integer")
349
+ valid_tool_id = validate_canonical_tool_id(tool_id)
350
+ valid_tool_version = validate_tool_version(tool_version)
351
+ except Exception:
352
+ return ToolCatalogLookup.invalid(
353
+ error_code=ToolRegistryErrorCode.LOOKUP_INVALID.value,
354
+ evidence={
355
+ "tool_id": _safe_evidence_text(tool_id),
356
+ "tool_version": _safe_evidence_text(tool_version),
357
+ },
358
+ )
359
+ entry = self._entries.get((valid_tool_id, valid_tool_version))
360
+ if entry is None:
361
+ return ToolCatalogLookup.missing(
362
+ error_code=ToolRegistryErrorCode.LOOKUP_MISSING.value,
363
+ evidence={"tool_id": valid_tool_id, "tool_version": str(tool_version)},
364
+ )
365
+ return ToolCatalogLookup.found(entry)
366
+
367
+
368
+ class ToolRegistry:
369
+ """Explicit in-process registry for immutable descriptors."""
370
+
371
+ def __init__(self) -> None:
372
+ self._descriptors: dict[tuple[str, int], ToolDescriptor] = {}
373
+ self._implementation_ids: set[str] = set()
374
+ self._snapshot: FrozenToolRegistrySnapshot | None = None
375
+ self._frozen = False
376
+
377
+ def register(self, descriptor: ToolDescriptor) -> None:
378
+ if self._frozen:
379
+ raise ToolRegistryError(
380
+ ToolRegistryErrorCode.REGISTRY_FROZEN,
381
+ "registry is frozen",
382
+ )
383
+ if not isinstance(descriptor, ToolDescriptor):
384
+ raise ToolRegistryError(
385
+ ToolRegistryErrorCode.DESCRIPTOR_INVALID,
386
+ "descriptor must be a ToolDescriptor",
387
+ )
388
+ key = (descriptor.tool_id, descriptor.tool_version)
389
+ if key in self._descriptors:
390
+ raise ToolRegistryError(
391
+ ToolRegistryErrorCode.DUPLICATE_TOOL,
392
+ "duplicate tool descriptor",
393
+ evidence={"tool_id": descriptor.tool_id},
394
+ )
395
+ if descriptor.implementation_id in self._implementation_ids:
396
+ raise ToolRegistryError(
397
+ ToolRegistryErrorCode.DUPLICATE_IMPLEMENTATION,
398
+ "duplicate implementation id",
399
+ evidence={"implementation_id": descriptor.implementation_id},
400
+ )
401
+ try:
402
+ descriptor.to_catalog_entry()
403
+ except Exception as exc:
404
+ raise ToolRegistryError(
405
+ ToolRegistryErrorCode.PROJECTION_INVALID,
406
+ "descriptor projection failed",
407
+ evidence={"error_type": type(exc).__name__},
408
+ ) from exc
409
+ self._descriptors[key] = descriptor
410
+ self._implementation_ids.add(descriptor.implementation_id)
411
+
412
+ def freeze(self) -> FrozenToolRegistrySnapshot:
413
+ if self._snapshot is None:
414
+ descriptors = tuple(
415
+ descriptor
416
+ for _, descriptor in sorted(
417
+ self._descriptors.items(),
418
+ key=lambda item: _descriptor_identity_key(item[1]),
419
+ )
420
+ )
421
+ self._snapshot = FrozenToolRegistrySnapshot(descriptors)
422
+ self._frozen = True
423
+ return self._snapshot
424
+
425
+
426
+ def descriptor_hash_payload(descriptor: ToolDescriptor) -> dict[str, Any]:
427
+ """Return the deterministic descriptor payload covered by SHA-256."""
428
+ return {
429
+ "schema_version": descriptor.schema_version,
430
+ "kind": descriptor.kind,
431
+ "tool_id": descriptor.tool_id,
432
+ "tool_version": descriptor.tool_version,
433
+ "implementation_id": descriptor.implementation_id,
434
+ "model_tool_name": descriptor.model_tool_name,
435
+ "description": descriptor.description,
436
+ "input_schema": _thaw_json_value(descriptor.input_schema),
437
+ "output_schema": _thaw_json_value(descriptor.output_schema),
438
+ "required_capabilities": list(descriptor.required_capabilities),
439
+ "produced_artifact_ids": list(descriptor.produced_artifact_ids),
440
+ "side_effect_class": descriptor.side_effect_class.value,
441
+ "idempotency": descriptor.idempotency.value,
442
+ "timeout_policy": descriptor.timeout_policy.model_dump(mode="json"),
443
+ "output_policy": descriptor.output_policy.model_dump(mode="json"),
444
+ }
445
+
446
+
447
+ def _descriptor_identity_key(
448
+ descriptor: ToolDescriptor,
449
+ ) -> tuple[str, int, str, str, str]:
450
+ return (
451
+ descriptor.tool_id,
452
+ descriptor.tool_version,
453
+ descriptor.model_tool_name,
454
+ descriptor.implementation_id,
455
+ descriptor.descriptor_sha256,
456
+ )
457
+
458
+
459
+ def _descriptor_hash_record_identity_key(
460
+ record: FrozenDescriptorHashRecord,
461
+ ) -> tuple[str, int, str, str]:
462
+ return (
463
+ record.tool_id,
464
+ record.tool_version,
465
+ record.implementation_id,
466
+ record.descriptor_sha256,
467
+ )
468
+
469
+
470
+ def _sorted_string_tuple(value: Any, field_name: str) -> tuple[str, ...]:
471
+ if value is None:
472
+ return ()
473
+ if isinstance(value, str) or not isinstance(value, (list, tuple)):
474
+ raise ValueError(f"{field_name} must be an array")
475
+ return tuple(sorted(value))
476
+
477
+
478
+ def _validate_descriptor_string(value: str, field_name: str, maximum: int) -> None:
479
+ validate_nonblank(value, field_name)
480
+ validate_utf8_size(value, field_name, maximum)
481
+ if detect_secret_candidate(
482
+ field_path=f"/{field_name}",
483
+ field_name=field_name,
484
+ value=value,
485
+ policy=RedactionPolicy(),
486
+ ):
487
+ raise ValueError(f"{field_name} contains suspected secret material")
488
+
489
+
490
+ def _reject_runtime_values(value: Any, *, path: str) -> None:
491
+ if callable(value):
492
+ raise ValueError(f"{path} must not contain runtime values")
493
+ if isinstance(value, Mapping):
494
+ for key, item in value.items():
495
+ _reject_runtime_values(item, path=f"{path}/{key}")
496
+ return
497
+ if isinstance(value, (list, tuple)):
498
+ for index, item in enumerate(value):
499
+ _reject_runtime_values(item, path=f"{path}/{index}")
500
+
501
+
502
+ def _reject_secret_values(value: Any, *, path: str) -> None:
503
+ if isinstance(value, Mapping):
504
+ for key, item in value.items():
505
+ _reject_secret_values(item, path=f"{path}/{key}")
506
+ return
507
+ if isinstance(value, (list, tuple)):
508
+ for index, item in enumerate(value):
509
+ _reject_secret_values(item, path=f"{path}/{index}")
510
+ return
511
+ if isinstance(value, str) and detect_secret_candidate(
512
+ field_path="/descriptor_schema_value",
513
+ field_name="descriptor_schema_value",
514
+ value=value,
515
+ policy=RedactionPolicy(),
516
+ ):
517
+ raise ValueError("schema contains suspected secret material")
518
+
519
+
520
+ def _looks_like_alias(tool_id: Any) -> bool:
521
+ return isinstance(tool_id, str) and (
522
+ tool_id == "latest" or tool_id.endswith(".latest")
523
+ )
524
+
525
+
526
+ def _thaw_json_value(value: Any) -> Any:
527
+ if isinstance(value, Mapping):
528
+ return {key: _thaw_json_value(item) for key, item in value.items()}
529
+ if isinstance(value, tuple):
530
+ return [_thaw_json_value(item) for item in value]
531
+ return value
532
+
533
+
534
+ def _redacted_evidence(values: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
535
+ return tuple(
536
+ sorted((key, _safe_evidence_text(value)) for key, value in values.items())
537
+ )
538
+
539
+
540
+ def _safe_evidence_text(value: Any) -> str:
541
+ text = str(value)
542
+ policy = RedactionPolicy()
543
+ if detect_secret_candidate(
544
+ field_path="/evidence",
545
+ field_name="evidence",
546
+ value=text,
547
+ policy=policy,
548
+ ):
549
+ return policy.replacement
550
+ return validate_utf8_size(text, "evidence", 512)
551
+
552
+
553
+ assert isinstance(FrozenToolRegistrySnapshot(()), ToolCatalogSnapshot)