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,1093 @@
1
+ """Frozen custom-tool source, policy, compilation, and diagnostic contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+ from types import MappingProxyType
11
+ from typing import Any, ClassVar, cast
12
+
13
+ from pydantic import (
14
+ BaseModel,
15
+ ConfigDict,
16
+ Field,
17
+ StrictBool,
18
+ StrictInt,
19
+ StrictStr,
20
+ ValidationError,
21
+ field_serializer,
22
+ field_validator,
23
+ model_validator,
24
+ )
25
+
26
+ from millforge import IdempotencyClass, SideEffectClass, canonical_json_serialize
27
+ from millforge.compiler.diagnostics import detect_secret_candidate
28
+ from millforge.compiler.schema_validation import normalize_json_schema
29
+ from millforge.compiler.validators import (
30
+ validate_artifact_id,
31
+ validate_capability_id,
32
+ validate_canonical_tool_id,
33
+ validate_nonblank,
34
+ validate_sha256,
35
+ validate_tool_version,
36
+ validate_unique,
37
+ validate_utf8_size,
38
+ )
39
+ from millforge.contracts import RedactionPolicy
40
+ from millforge.custom_tools.diagnostics import (
41
+ CustomToolDiagnostic,
42
+ CustomToolDiagnosticCode,
43
+ CustomToolDiagnosticPhase,
44
+ custom_tool_diagnostic_sort_key,
45
+ malformed_input_diagnostic,
46
+ )
47
+ from millforge.tools.registry import ToolDescriptor, ToolOutputPolicy, ToolTimeoutPolicy
48
+
49
+ CUSTOM_TOOL_DECLARATION_HASH_KIND = "millforge.custom_tool.declaration.v1"
50
+ CUSTOM_TOOL_SOURCE_HASH_KIND = "millforge.custom_tool.source.v1"
51
+ CUSTOM_TOOL_COMPILATION_RECORD_HASH_KIND = "millforge.custom_tool.compilation_record.v1"
52
+ CUSTOM_TOOL_SOURCE_SCHEMA = "millforge.custom_tool.source"
53
+ CUSTOM_TOOL_SOURCE_KIND = "custom_tool_source"
54
+ CUSTOM_TOOL_SOURCE_VERSION = "1.0"
55
+
56
+ MAX_CUSTOM_TOOL_FIELD_UTF8 = 512
57
+ MAX_CUSTOM_TOOL_PACKAGE_ID_UTF8 = 160
58
+ MAX_CUSTOM_TOOL_DESCRIPTION_UTF8 = 65_536
59
+ MAX_CUSTOM_TOOL_SCHEMA_BYTES = 65_536
60
+ MAX_CUSTOM_TOOL_COUNT = 128
61
+
62
+ JsonValue = Any
63
+
64
+ _SIDE_EFFECTS_REQUIRING_APPROVAL = frozenset(
65
+ item for item in SideEffectClass if item is not SideEffectClass.READ_ONLY
66
+ )
67
+
68
+
69
+ class CustomToolRuntimeKind(str, Enum):
70
+ """Closed custom-tool runtime kind values for 05C contracts."""
71
+
72
+ CONTRACT_ONLY = "contract_only"
73
+
74
+
75
+ class CustomToolDescriptionPolicy(str, Enum):
76
+ """Closed description provenance policy values."""
77
+
78
+ OPERATOR_SUPPLIED = "operator_supplied"
79
+ SOURCE_SUPPLIED = "source_supplied"
80
+
81
+
82
+ class CustomToolApprovalPolicy(str, Enum):
83
+ """Closed approval policy values for custom-tool declarations."""
84
+
85
+ NONE = "none"
86
+ MILLRACE_EXPLICIT = "millrace_explicit"
87
+ OPERATOR_OUT_OF_BAND = "operator_out_of_band"
88
+ FORBIDDEN = "forbidden"
89
+
90
+
91
+ class CustomToolInputPolicy(str, Enum):
92
+ """Closed input acceptance policy values for later compilation."""
93
+
94
+ JSON_SCHEMA_EXACT = "json_schema_exact"
95
+
96
+
97
+ class CustomToolOutputPolicy(str, Enum):
98
+ """Closed output acceptance policy values for later compilation."""
99
+
100
+ JSON_SCHEMA_EXACT = "json_schema_exact"
101
+
102
+
103
+ class CustomToolDeclaration(BaseModel):
104
+ """Immutable source declaration for one compile-only custom tool."""
105
+
106
+ model_config = ConfigDict(
107
+ frozen=True,
108
+ extra="forbid",
109
+ arbitrary_types_allowed=True,
110
+ hide_input_in_errors=True,
111
+ use_enum_values=False,
112
+ )
113
+
114
+ tool_id: StrictStr
115
+ tool_version: StrictInt = Field(ge=1)
116
+ implementation_id: StrictStr
117
+ runtime_kind: CustomToolRuntimeKind = CustomToolRuntimeKind.CONTRACT_ONLY
118
+ model_tool_name: StrictStr
119
+ description: StrictStr
120
+ description_policy: CustomToolDescriptionPolicy
121
+ input_schema: Mapping[str, JsonValue]
122
+ output_schema: Mapping[str, JsonValue]
123
+ required_capabilities: tuple[StrictStr, ...] = Field(default_factory=tuple)
124
+ produced_artifact_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
125
+ side_effect_class: SideEffectClass
126
+ idempotency: IdempotencyClass
127
+ timeout_policy: ToolTimeoutPolicy
128
+ output_policy: ToolOutputPolicy
129
+ approval_policy: CustomToolApprovalPolicy
130
+ input_policy: CustomToolInputPolicy = CustomToolInputPolicy.JSON_SCHEMA_EXACT
131
+ output_contract_policy: CustomToolOutputPolicy = (
132
+ CustomToolOutputPolicy.JSON_SCHEMA_EXACT
133
+ )
134
+ idempotency_key_policy: StrictStr | None = None
135
+ expected_declaration_sha256: StrictStr | None = None
136
+ expected_descriptor_sha256: StrictStr | None = None
137
+ expected_compilation_record_sha256: StrictStr | None = None
138
+
139
+ _diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
140
+ CustomToolDiagnosticPhase.DECLARATION
141
+ )
142
+
143
+ @classmethod
144
+ def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
145
+ """Validate raw input and return stable diagnostics instead of exception text."""
146
+ return _validate_contract_for_model(cls, raw)
147
+
148
+ @field_validator("tool_id")
149
+ @classmethod
150
+ def _tool_id_valid(cls, value: str) -> str:
151
+ return validate_canonical_tool_id(value)
152
+
153
+ @field_validator("tool_version")
154
+ @classmethod
155
+ def _tool_version_valid(cls, value: int) -> int:
156
+ return validate_tool_version(value)
157
+
158
+ @field_validator("implementation_id", "model_tool_name")
159
+ @classmethod
160
+ def _identity_text_valid(cls, value: str, info: Any) -> str:
161
+ return _safe_custom_tool_string(
162
+ value, info.field_name, MAX_CUSTOM_TOOL_FIELD_UTF8
163
+ )
164
+
165
+ @field_validator("description")
166
+ @classmethod
167
+ def _description_valid(cls, value: str) -> str:
168
+ return _safe_custom_tool_string(
169
+ value, "description", MAX_CUSTOM_TOOL_DESCRIPTION_UTF8
170
+ )
171
+
172
+ @field_validator("input_schema", "output_schema")
173
+ @classmethod
174
+ def _schema_frozen(cls, value: Any, info: Any) -> MappingProxyType[str, Any]:
175
+ if not isinstance(value, Mapping):
176
+ raise ValueError(f"{info.field_name} must be a JSON object")
177
+ _reject_secret_values(value, path=f"/{info.field_name}")
178
+ normalized = normalize_json_schema(value, field_name=info.field_name)
179
+ _validate_schema_size(normalized, info.field_name)
180
+ return _freeze_json_object(normalized)
181
+
182
+ @field_validator("required_capabilities", "produced_artifact_ids", mode="before")
183
+ @classmethod
184
+ def _string_tuple_snapshot(cls, value: Any, info: Any) -> tuple[str, ...]:
185
+ return _sorted_string_tuple(value, info.field_name)
186
+
187
+ @field_validator("required_capabilities")
188
+ @classmethod
189
+ def _capabilities_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
190
+ for capability_id in value:
191
+ validate_capability_id(capability_id)
192
+ return validate_unique(value, "required_capabilities")
193
+
194
+ @field_validator("produced_artifact_ids")
195
+ @classmethod
196
+ def _produced_artifacts_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
197
+ for artifact_id in value:
198
+ validate_artifact_id(artifact_id)
199
+ return validate_unique(value, "produced_artifact_ids")
200
+
201
+ @field_validator("idempotency_key_policy")
202
+ @classmethod
203
+ def _idempotency_key_policy_valid(cls, value: str | None) -> str | None:
204
+ if value is None:
205
+ return None
206
+ return _safe_custom_tool_string(
207
+ value, "idempotency_key_policy", MAX_CUSTOM_TOOL_FIELD_UTF8
208
+ )
209
+
210
+ @field_validator(
211
+ "expected_declaration_sha256",
212
+ "expected_descriptor_sha256",
213
+ "expected_compilation_record_sha256",
214
+ )
215
+ @classmethod
216
+ def _expected_hash_valid(cls, value: str | None, info: Any) -> str | None:
217
+ if value is None:
218
+ return None
219
+ return validate_sha256(value, info.field_name)
220
+
221
+ @model_validator(mode="after")
222
+ def _declaration_semantics_valid(self) -> CustomToolDeclaration:
223
+ if self.approval_policy is CustomToolApprovalPolicy.FORBIDDEN:
224
+ raise ValueError("forbidden approval policy cannot compile a tool")
225
+ if (
226
+ self.side_effect_class in _SIDE_EFFECTS_REQUIRING_APPROVAL
227
+ and self.approval_policy
228
+ not in {
229
+ CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
230
+ CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
231
+ }
232
+ ):
233
+ raise ValueError("side-effecting custom tools require explicit approval")
234
+ if (
235
+ self.side_effect_class in _SIDE_EFFECTS_REQUIRING_APPROVAL
236
+ or self.produced_artifact_ids
237
+ ) and not self.required_capabilities:
238
+ raise ValueError(
239
+ "side-effecting or artifact-producing custom tools require capabilities"
240
+ )
241
+ return self
242
+
243
+ @property
244
+ def input_schema_sha256(self) -> str:
245
+ return _sha256_hex(_thaw_json_value(self.input_schema))
246
+
247
+ @property
248
+ def output_schema_sha256(self) -> str:
249
+ return _sha256_hex(_thaw_json_value(self.output_schema))
250
+
251
+ @property
252
+ def declaration_sha256(self) -> str:
253
+ return _sha256_hex(
254
+ {
255
+ "kind": CUSTOM_TOOL_DECLARATION_HASH_KIND,
256
+ "tool_id": self.tool_id,
257
+ "tool_version": self.tool_version,
258
+ "implementation_id": self.implementation_id,
259
+ "runtime_kind": self.runtime_kind.value,
260
+ "model_tool_name": self.model_tool_name,
261
+ "description": self.description,
262
+ "description_policy": self.description_policy.value,
263
+ "input_schema": _thaw_json_value(self.input_schema),
264
+ "output_schema": _thaw_json_value(self.output_schema),
265
+ "required_capabilities": list(self.required_capabilities),
266
+ "produced_artifact_ids": list(self.produced_artifact_ids),
267
+ "side_effect_class": self.side_effect_class.value,
268
+ "idempotency": self.idempotency.value,
269
+ "timeout_policy": self.timeout_policy.model_dump(mode="json"),
270
+ "output_policy": self.output_policy.model_dump(mode="json"),
271
+ "approval_policy": self.approval_policy.value,
272
+ "input_policy": self.input_policy.value,
273
+ "output_contract_policy": self.output_contract_policy.value,
274
+ "idempotency_key_policy": self.idempotency_key_policy,
275
+ }
276
+ )
277
+
278
+ @field_serializer("input_schema", "output_schema")
279
+ def _serialize_schema(self, value: Any) -> Any:
280
+ return _thaw_json_value(value)
281
+
282
+
283
+ class CustomToolSourceManifest(BaseModel):
284
+ """Immutable custom-tool source manifest for deterministic offline compilation."""
285
+
286
+ model_config = ConfigDict(
287
+ frozen=True,
288
+ extra="forbid",
289
+ arbitrary_types_allowed=True,
290
+ hide_input_in_errors=True,
291
+ )
292
+
293
+ schema_version: StrictStr = CUSTOM_TOOL_SOURCE_SCHEMA
294
+ kind: StrictStr = CUSTOM_TOOL_SOURCE_KIND
295
+ version: StrictStr = CUSTOM_TOOL_SOURCE_VERSION
296
+ package_id: StrictStr
297
+ package_version: StrictInt = Field(ge=1)
298
+ source_name: StrictStr | None = None
299
+ created_at: StrictStr
300
+ tools: tuple[CustomToolDeclaration, ...]
301
+ policy_metadata: Mapping[str, JsonValue] = Field(
302
+ default_factory=lambda: MappingProxyType({})
303
+ )
304
+ expected_source_sha256: StrictStr | None = None
305
+
306
+ _diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
307
+ CustomToolDiagnosticPhase.SOURCE
308
+ )
309
+
310
+ @classmethod
311
+ def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
312
+ """Validate raw input and return stable diagnostics instead of exception text."""
313
+ return _validate_contract_for_model(cls, raw)
314
+
315
+ @field_validator("schema_version")
316
+ @classmethod
317
+ def _schema_version_valid(cls, value: str) -> str:
318
+ if value != CUSTOM_TOOL_SOURCE_SCHEMA:
319
+ raise ValueError(
320
+ "schema_version must identify custom-tool source manifests"
321
+ )
322
+ return value
323
+
324
+ @field_validator("kind")
325
+ @classmethod
326
+ def _kind_valid(cls, value: str) -> str:
327
+ if value != CUSTOM_TOOL_SOURCE_KIND:
328
+ raise ValueError("kind must be custom_tool_source")
329
+ return value
330
+
331
+ @field_validator("version")
332
+ @classmethod
333
+ def _version_valid(cls, value: str) -> str:
334
+ if value != CUSTOM_TOOL_SOURCE_VERSION:
335
+ raise ValueError("version must be 1.0")
336
+ return value
337
+
338
+ @field_validator("package_id", "source_name")
339
+ @classmethod
340
+ def _package_text_valid(cls, value: str | None, info: Any) -> str | None:
341
+ if value is None:
342
+ return None
343
+ maximum = (
344
+ MAX_CUSTOM_TOOL_PACKAGE_ID_UTF8
345
+ if info.field_name == "package_id"
346
+ else MAX_CUSTOM_TOOL_FIELD_UTF8
347
+ )
348
+ return _safe_custom_tool_string(value, info.field_name, maximum)
349
+
350
+ @field_validator("created_at")
351
+ @classmethod
352
+ def _created_at_valid(cls, value: str) -> str:
353
+ return _validate_utc_timestamp(value, "created_at")
354
+
355
+ @field_validator("tools", mode="before")
356
+ @classmethod
357
+ def _tools_tuple(cls, value: Any) -> tuple[Any, ...]:
358
+ if isinstance(value, str) or not isinstance(value, (list, tuple)):
359
+ raise ValueError("tools must be an array")
360
+ return tuple(value)
361
+
362
+ @field_validator("policy_metadata")
363
+ @classmethod
364
+ def _policy_metadata_frozen(cls, value: Any) -> MappingProxyType[str, Any]:
365
+ if not isinstance(value, Mapping):
366
+ raise ValueError("policy_metadata must be a JSON object")
367
+ _reject_secret_values(value, path="/policy_metadata")
368
+ return _freeze_json_object(value)
369
+
370
+ @field_validator("expected_source_sha256")
371
+ @classmethod
372
+ def _expected_source_hash_valid(cls, value: str | None) -> str | None:
373
+ if value is None:
374
+ return None
375
+ return validate_sha256(value, "expected_source_sha256")
376
+
377
+ @model_validator(mode="after")
378
+ def _source_consistent(self) -> CustomToolSourceManifest:
379
+ if not self.tools:
380
+ raise ValueError("tools must contain at least one tool")
381
+ if len(self.tools) > MAX_CUSTOM_TOOL_COUNT:
382
+ raise ValueError("tools exceeds maximum custom-tool count")
383
+ tool_keys = tuple(f"{tool.tool_id}@{tool.tool_version}" for tool in self.tools)
384
+ validate_unique(tool_keys, "custom tool identities")
385
+ validate_unique(
386
+ tuple(tool.model_tool_name for tool in self.tools),
387
+ "custom tool model_tool_name",
388
+ )
389
+ validate_unique(
390
+ tuple(tool.implementation_id for tool in self.tools),
391
+ "custom tool implementation_id",
392
+ )
393
+ return self
394
+
395
+ @property
396
+ def source_sha256(self) -> str:
397
+ return _sha256_hex(
398
+ {
399
+ "kind": CUSTOM_TOOL_SOURCE_HASH_KIND,
400
+ "schema_version": self.schema_version,
401
+ "source_kind": self.kind,
402
+ "version": self.version,
403
+ "package_id": self.package_id,
404
+ "package_version": self.package_version,
405
+ "source_name": self.source_name,
406
+ "tools": [
407
+ {
408
+ "tool_id": tool.tool_id,
409
+ "tool_version": tool.tool_version,
410
+ "declaration_sha256": tool.declaration_sha256,
411
+ }
412
+ for tool in sorted(
413
+ self.tools, key=lambda item: (item.tool_id, item.tool_version)
414
+ )
415
+ ],
416
+ "policy_metadata": _thaw_json_value(self.policy_metadata),
417
+ }
418
+ )
419
+
420
+ @field_serializer("policy_metadata")
421
+ def _serialize_policy_metadata(self, value: Any) -> Any:
422
+ return _thaw_json_value(value)
423
+
424
+
425
+ class CustomToolCompilerPolicy(BaseModel):
426
+ """Closed policy constraints for deterministic custom-tool compilation."""
427
+
428
+ model_config = ConfigDict(
429
+ frozen=True,
430
+ extra="forbid",
431
+ arbitrary_types_allowed=True,
432
+ hide_input_in_errors=True,
433
+ )
434
+
435
+ allowed_capability_ids: tuple[StrictStr, ...]
436
+ allowed_runtime_kinds: tuple[CustomToolRuntimeKind, ...] = (
437
+ CustomToolRuntimeKind.CONTRACT_ONLY,
438
+ )
439
+ max_tools: StrictInt = Field(
440
+ default=MAX_CUSTOM_TOOL_COUNT, ge=1, le=MAX_CUSTOM_TOOL_COUNT
441
+ )
442
+ max_schema_bytes: StrictInt = Field(default=MAX_CUSTOM_TOOL_SCHEMA_BYTES, ge=1)
443
+ max_description_utf8: StrictInt = Field(
444
+ default=MAX_CUSTOM_TOOL_DESCRIPTION_UTF8,
445
+ ge=1,
446
+ le=MAX_CUSTOM_TOOL_DESCRIPTION_UTF8,
447
+ )
448
+ require_expected_hashes: StrictBool = False
449
+ side_effect_approval_matrix: Mapping[
450
+ SideEffectClass, tuple[CustomToolApprovalPolicy, ...]
451
+ ] = Field(default_factory=lambda: MappingProxyType(_default_approval_matrix()))
452
+
453
+ _diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
454
+ CustomToolDiagnosticPhase.POLICY
455
+ )
456
+
457
+ @classmethod
458
+ def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
459
+ """Validate raw input and return stable diagnostics instead of exception text."""
460
+ return _validate_contract_for_model(cls, raw)
461
+
462
+ @field_validator("allowed_capability_ids", mode="before")
463
+ @classmethod
464
+ def _allowed_capabilities_snapshot(cls, value: Any) -> tuple[str, ...]:
465
+ values = _sorted_string_tuple(value, "allowed_capability_ids")
466
+ for capability_id in values:
467
+ validate_capability_id(capability_id)
468
+ return validate_unique(values, "allowed_capability_ids")
469
+
470
+ @field_validator("allowed_runtime_kinds", mode="before")
471
+ @classmethod
472
+ def _runtime_kinds_tuple(cls, value: Any) -> tuple[Any, ...]:
473
+ if isinstance(value, str) or not isinstance(value, (list, tuple)):
474
+ raise ValueError("allowed_runtime_kinds must be an array")
475
+ return tuple(value)
476
+
477
+ @field_validator("allowed_runtime_kinds")
478
+ @classmethod
479
+ def _runtime_kinds_valid(
480
+ cls, value: tuple[CustomToolRuntimeKind, ...]
481
+ ) -> tuple[CustomToolRuntimeKind, ...]:
482
+ if value != (CustomToolRuntimeKind.CONTRACT_ONLY,):
483
+ raise ValueError(
484
+ "allowed runtime kinds are initially limited to contract_only"
485
+ )
486
+ return value
487
+
488
+ @field_validator("side_effect_approval_matrix")
489
+ @classmethod
490
+ def _matrix_frozen(
491
+ cls, value: Any
492
+ ) -> MappingProxyType[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]]:
493
+ if not isinstance(value, Mapping):
494
+ raise ValueError("side_effect_approval_matrix must be an object")
495
+ matrix: dict[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]] = {}
496
+ for raw_key, raw_values in value.items():
497
+ key = (
498
+ raw_key
499
+ if isinstance(raw_key, SideEffectClass)
500
+ else SideEffectClass(raw_key)
501
+ )
502
+ if isinstance(raw_values, str) or not isinstance(raw_values, (list, tuple)):
503
+ raise ValueError("side_effect_approval_matrix values must be arrays")
504
+ policies = tuple(
505
+ item
506
+ if isinstance(item, CustomToolApprovalPolicy)
507
+ else CustomToolApprovalPolicy(item)
508
+ for item in raw_values
509
+ )
510
+ if CustomToolApprovalPolicy.FORBIDDEN in policies:
511
+ raise ValueError("forbidden approval policy is denial evidence only")
512
+ if (
513
+ key in _SIDE_EFFECTS_REQUIRING_APPROVAL
514
+ and CustomToolApprovalPolicy.NONE in policies
515
+ ):
516
+ raise ValueError("side-effecting custom tools cannot use approval none")
517
+ matrix[key] = policies
518
+ return MappingProxyType(
519
+ dict(sorted(matrix.items(), key=lambda item: item[0].value))
520
+ )
521
+
522
+ @field_serializer("side_effect_approval_matrix")
523
+ def _serialize_matrix(
524
+ self,
525
+ value: MappingProxyType[SideEffectClass, tuple[CustomToolApprovalPolicy, ...]],
526
+ ) -> dict[str, list[str]]:
527
+ return {
528
+ side_effect.value: [policy.value for policy in policies]
529
+ for side_effect, policies in sorted(
530
+ value.items(), key=lambda item: item[0].value
531
+ )
532
+ }
533
+
534
+
535
+ class CustomToolCompilationRecord(BaseModel):
536
+ """Immutable record for one contract-only compiled custom-tool declaration."""
537
+
538
+ model_config = ConfigDict(frozen=True, extra="forbid")
539
+
540
+ package_id: StrictStr
541
+ package_version: StrictInt = Field(ge=1)
542
+ source_sha256: StrictStr
543
+ tool_id: StrictStr
544
+ tool_version: StrictInt = Field(ge=1)
545
+ implementation_id: StrictStr
546
+ runtime_kind: CustomToolRuntimeKind
547
+ model_tool_name: StrictStr
548
+ declaration_sha256: StrictStr
549
+ input_schema_sha256: StrictStr
550
+ output_schema_sha256: StrictStr
551
+ descriptor_sha256: StrictStr
552
+ required_capabilities: tuple[StrictStr, ...]
553
+ produced_artifact_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
554
+ side_effect_class: SideEffectClass
555
+ idempotency: IdempotencyClass
556
+ timeout_policy: ToolTimeoutPolicy
557
+ output_policy: ToolOutputPolicy
558
+ approval_policy: CustomToolApprovalPolicy
559
+ compilation_record_sha256: StrictStr = ""
560
+
561
+ @field_validator(
562
+ "source_sha256",
563
+ "declaration_sha256",
564
+ "input_schema_sha256",
565
+ "output_schema_sha256",
566
+ "descriptor_sha256",
567
+ "compilation_record_sha256",
568
+ )
569
+ @classmethod
570
+ def _hash_valid(cls, value: str, info: Any) -> str:
571
+ if info.field_name == "compilation_record_sha256" and value == "":
572
+ return value
573
+ return validate_sha256(value, info.field_name)
574
+
575
+ @field_validator("tool_id")
576
+ @classmethod
577
+ def _tool_id_valid(cls, value: str) -> str:
578
+ return validate_canonical_tool_id(value)
579
+
580
+ @field_validator("tool_version")
581
+ @classmethod
582
+ def _tool_version_valid(cls, value: int) -> int:
583
+ return validate_tool_version(value)
584
+
585
+ @field_validator("required_capabilities", "produced_artifact_ids", mode="before")
586
+ @classmethod
587
+ def _string_tuple_snapshot(cls, value: Any, info: Any) -> tuple[str, ...]:
588
+ return _sorted_string_tuple(value, info.field_name)
589
+
590
+ @field_validator("required_capabilities")
591
+ @classmethod
592
+ def _capabilities_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
593
+ for capability_id in value:
594
+ validate_capability_id(capability_id)
595
+ return validate_unique(value, "required_capabilities")
596
+
597
+ @field_validator("produced_artifact_ids")
598
+ @classmethod
599
+ def _produced_artifacts_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
600
+ for artifact_id in value:
601
+ validate_artifact_id(artifact_id)
602
+ return validate_unique(value, "produced_artifact_ids")
603
+
604
+ @model_validator(mode="after")
605
+ def _record_hash_consistent(self) -> CustomToolCompilationRecord:
606
+ computed = _compilation_record_hash(self)
607
+ if self.compilation_record_sha256 == "":
608
+ object.__setattr__(self, "compilation_record_sha256", computed)
609
+ elif self.compilation_record_sha256 != computed:
610
+ raise ValueError("compilation_record_sha256 must match record contents")
611
+ return self
612
+
613
+
614
+ class CustomToolCompilationResult(BaseModel):
615
+ """Deterministic result container for custom-tool compilation attempts."""
616
+
617
+ model_config = ConfigDict(frozen=True, extra="forbid")
618
+
619
+ accepted: bool
620
+ source_sha256: StrictStr | None = None
621
+ descriptors: tuple[ToolDescriptor, ...] = Field(default_factory=tuple)
622
+ records: tuple[CustomToolCompilationRecord, ...] = Field(default_factory=tuple)
623
+ diagnostics: tuple[CustomToolDiagnostic, ...] = Field(default_factory=tuple)
624
+
625
+ _diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
626
+ CustomToolDiagnosticPhase.COMPILATION
627
+ )
628
+
629
+ @classmethod
630
+ def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
631
+ """Validate raw input and return stable diagnostics instead of exception text."""
632
+ return _validate_contract_for_model(cls, raw)
633
+
634
+ @field_validator("source_sha256")
635
+ @classmethod
636
+ def _source_hash_valid(cls, value: str | None) -> str | None:
637
+ if value is None:
638
+ return None
639
+ return validate_sha256(value, "source_sha256")
640
+
641
+ @model_validator(mode="after")
642
+ def _result_consistent(self) -> CustomToolCompilationResult:
643
+ if self.accepted:
644
+ if self.diagnostics:
645
+ raise ValueError(
646
+ "accepted compilation results cannot contain diagnostics"
647
+ )
648
+ if self.source_sha256 is None:
649
+ raise ValueError("accepted compilation results require source hash")
650
+ if not self.descriptors:
651
+ raise ValueError("accepted compilation results require descriptors")
652
+ if len(self.descriptors) != len(self.records):
653
+ raise ValueError(
654
+ "accepted compilation results require one record per descriptor"
655
+ )
656
+ descriptor_by_hash = {
657
+ descriptor.descriptor_sha256: descriptor
658
+ for descriptor in self.descriptors
659
+ }
660
+ record_by_hash = {
661
+ record.descriptor_sha256: record for record in self.records
662
+ }
663
+ if (
664
+ len(descriptor_by_hash) != len(self.descriptors)
665
+ or len(record_by_hash) != len(self.records)
666
+ or descriptor_by_hash.keys() != record_by_hash.keys()
667
+ ):
668
+ raise ValueError("compilation records must match descriptor hashes")
669
+ lowered = tuple(
670
+ sorted(
671
+ (
672
+ (descriptor, record_by_hash[descriptor.descriptor_sha256])
673
+ for descriptor in descriptor_by_hash.values()
674
+ ),
675
+ key=lambda item: _accepted_result_identity_key(item[0], item[1]),
676
+ )
677
+ )
678
+ object.__setattr__(
679
+ self, "descriptors", tuple(descriptor for descriptor, _ in lowered)
680
+ )
681
+ object.__setattr__(self, "records", tuple(record for _, record in lowered))
682
+ elif self.descriptors or self.records:
683
+ raise ValueError(
684
+ "rejected compilation results cannot contain descriptors or records"
685
+ )
686
+ else:
687
+ object.__setattr__(
688
+ self,
689
+ "diagnostics",
690
+ tuple(sorted(self.diagnostics, key=custom_tool_diagnostic_sort_key)),
691
+ )
692
+ return self
693
+
694
+
695
+ def _compilation_record_hash(record: CustomToolCompilationRecord) -> str:
696
+ return _sha256_hex(
697
+ {
698
+ "kind": CUSTOM_TOOL_COMPILATION_RECORD_HASH_KIND,
699
+ "package_id": record.package_id,
700
+ "package_version": record.package_version,
701
+ "source_sha256": record.source_sha256,
702
+ "tool_id": record.tool_id,
703
+ "tool_version": record.tool_version,
704
+ "implementation_id": record.implementation_id,
705
+ "runtime_kind": record.runtime_kind.value,
706
+ "model_tool_name": record.model_tool_name,
707
+ "declaration_sha256": record.declaration_sha256,
708
+ "input_schema_sha256": record.input_schema_sha256,
709
+ "output_schema_sha256": record.output_schema_sha256,
710
+ "descriptor_sha256": record.descriptor_sha256,
711
+ "required_capabilities": list(record.required_capabilities),
712
+ "produced_artifact_ids": list(record.produced_artifact_ids),
713
+ "side_effect_class": record.side_effect_class.value,
714
+ "idempotency": record.idempotency.value,
715
+ "timeout_policy": record.timeout_policy.model_dump(mode="json"),
716
+ "output_policy": record.output_policy.model_dump(mode="json"),
717
+ "approval_policy": record.approval_policy.value,
718
+ }
719
+ )
720
+
721
+
722
+ def _accepted_result_identity_key(
723
+ descriptor: ToolDescriptor,
724
+ record: CustomToolCompilationRecord,
725
+ ) -> tuple[str, str, int, str, str, str, str]:
726
+ return (
727
+ record.package_id,
728
+ descriptor.tool_id,
729
+ descriptor.tool_version,
730
+ descriptor.model_tool_name,
731
+ descriptor.implementation_id,
732
+ descriptor.descriptor_sha256,
733
+ record.compilation_record_sha256,
734
+ )
735
+
736
+
737
+ class CustomToolContractValidation(BaseModel):
738
+ """Contract validation result for malformed raw custom-tool inputs."""
739
+
740
+ model_config = ConfigDict(frozen=True, extra="forbid")
741
+
742
+ value: Any | None = None
743
+ diagnostics: tuple[CustomToolDiagnostic, ...] = Field(default_factory=tuple)
744
+
745
+ @property
746
+ def accepted(self) -> bool:
747
+ return self.value is not None and not self.diagnostics
748
+
749
+
750
+ class CustomToolContractModel(BaseModel):
751
+ """Base helper for diagnostic validation of raw custom-tool mappings."""
752
+
753
+ model_config = ConfigDict(frozen=True)
754
+ _diagnostic_phase: ClassVar[CustomToolDiagnosticPhase] = (
755
+ CustomToolDiagnosticPhase.DIAGNOSTIC
756
+ )
757
+
758
+ @classmethod
759
+ def validate_contract(cls, raw: Any) -> CustomToolContractValidation:
760
+ """Validate raw input and return stable diagnostics instead of exception text."""
761
+ try:
762
+ return CustomToolContractValidation(value=cls.model_validate(raw))
763
+ except ValidationError as exc:
764
+ missing_field = _validation_missing_field(exc)
765
+ return CustomToolContractValidation(
766
+ diagnostics=(
767
+ malformed_input_diagnostic(
768
+ phase=cls._diagnostic_phase,
769
+ model_name=cls.__name__,
770
+ path=_validation_pointer(exc),
771
+ missing_field=missing_field,
772
+ code=_validation_code(exc),
773
+ ),
774
+ )
775
+ )
776
+ except Exception:
777
+ return CustomToolContractValidation(
778
+ diagnostics=(
779
+ malformed_input_diagnostic(
780
+ phase=cls._diagnostic_phase,
781
+ model_name=cls.__name__,
782
+ code=CustomToolDiagnosticCode.SOURCE_INVALID,
783
+ ),
784
+ )
785
+ )
786
+
787
+
788
+ def _validate_contract_for_model(
789
+ cls: type[BaseModel], raw: Any
790
+ ) -> CustomToolContractValidation:
791
+ phase = getattr(cls, "_diagnostic_phase", CustomToolDiagnosticPhase.DIAGNOSTIC)
792
+ try:
793
+ return CustomToolContractValidation(value=cls.model_validate(raw))
794
+ except ValidationError as exc:
795
+ missing_field = _validation_missing_field(exc)
796
+ return CustomToolContractValidation(
797
+ diagnostics=(
798
+ malformed_input_diagnostic(
799
+ phase=phase,
800
+ model_name=cls.__name__,
801
+ path=_validation_pointer(exc),
802
+ missing_field=missing_field,
803
+ code=_validation_code(exc),
804
+ ),
805
+ )
806
+ )
807
+ except Exception:
808
+ return CustomToolContractValidation(
809
+ diagnostics=(
810
+ malformed_input_diagnostic(
811
+ phase=phase,
812
+ model_name=cls.__name__,
813
+ code=CustomToolDiagnosticCode.SOURCE_INVALID,
814
+ ),
815
+ )
816
+ )
817
+
818
+
819
+ def compilation_record_from_declaration(
820
+ source: CustomToolSourceManifest,
821
+ declaration: CustomToolDeclaration,
822
+ descriptor: ToolDescriptor | None = None,
823
+ ) -> CustomToolCompilationRecord:
824
+ """Create deterministic record metadata for a contract-only declaration."""
825
+ descriptor = descriptor or tool_descriptor_from_declaration(declaration)
826
+ _validate_descriptor_matches_declaration(declaration, descriptor)
827
+ return CustomToolCompilationRecord(
828
+ package_id=source.package_id,
829
+ package_version=source.package_version,
830
+ source_sha256=source.source_sha256,
831
+ tool_id=declaration.tool_id,
832
+ tool_version=declaration.tool_version,
833
+ implementation_id=declaration.implementation_id,
834
+ runtime_kind=declaration.runtime_kind,
835
+ model_tool_name=declaration.model_tool_name,
836
+ declaration_sha256=declaration.declaration_sha256,
837
+ input_schema_sha256=declaration.input_schema_sha256,
838
+ output_schema_sha256=declaration.output_schema_sha256,
839
+ descriptor_sha256=descriptor.descriptor_sha256,
840
+ required_capabilities=declaration.required_capabilities,
841
+ produced_artifact_ids=declaration.produced_artifact_ids,
842
+ side_effect_class=declaration.side_effect_class,
843
+ idempotency=declaration.idempotency,
844
+ timeout_policy=declaration.timeout_policy,
845
+ output_policy=declaration.output_policy,
846
+ approval_policy=declaration.approval_policy,
847
+ )
848
+
849
+
850
+ def tool_descriptor_from_declaration(
851
+ declaration: CustomToolDeclaration,
852
+ ) -> ToolDescriptor:
853
+ """Create descriptor data for a contract-only declaration without registration."""
854
+ return ToolDescriptor(
855
+ tool_id=declaration.tool_id,
856
+ tool_version=declaration.tool_version,
857
+ implementation_id=declaration.implementation_id,
858
+ model_tool_name=declaration.model_tool_name,
859
+ description=declaration.description,
860
+ input_schema=cast(MappingProxyType[str, JsonValue], declaration.input_schema),
861
+ output_schema=cast(MappingProxyType[str, JsonValue], declaration.output_schema),
862
+ required_capabilities=declaration.required_capabilities,
863
+ produced_artifact_ids=declaration.produced_artifact_ids,
864
+ side_effect_class=declaration.side_effect_class,
865
+ idempotency=declaration.idempotency,
866
+ timeout_policy=declaration.timeout_policy,
867
+ output_policy=declaration.output_policy,
868
+ )
869
+
870
+
871
+ def _validate_descriptor_matches_declaration(
872
+ declaration: CustomToolDeclaration,
873
+ descriptor: ToolDescriptor,
874
+ ) -> None:
875
+ expected = tool_descriptor_from_declaration(declaration)
876
+ expected_dump = expected.model_dump(mode="json")
877
+ supplied_dump = descriptor.model_dump(mode="json")
878
+ if supplied_dump != expected_dump:
879
+ raise ValueError("descriptor must match custom-tool declaration")
880
+
881
+
882
+ def _default_approval_matrix() -> dict[
883
+ SideEffectClass, tuple[CustomToolApprovalPolicy, ...]
884
+ ]:
885
+ explicit = (
886
+ CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
887
+ CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
888
+ )
889
+ return {
890
+ SideEffectClass.READ_ONLY: (
891
+ CustomToolApprovalPolicy.NONE,
892
+ CustomToolApprovalPolicy.MILLRACE_EXPLICIT,
893
+ CustomToolApprovalPolicy.OPERATOR_OUT_OF_BAND,
894
+ ),
895
+ SideEffectClass.ARTIFACT_WRITE: explicit,
896
+ SideEffectClass.WORKSPACE_WRITE: explicit,
897
+ SideEffectClass.PROCESS_EXECUTION: explicit,
898
+ SideEffectClass.NETWORK_READ: explicit,
899
+ SideEffectClass.NETWORK_WRITE: explicit,
900
+ SideEffectClass.TERMINAL: explicit,
901
+ }
902
+
903
+
904
+ def _safe_custom_tool_string(value: str, field_name: str, maximum: int) -> str:
905
+ validate_nonblank(value, field_name)
906
+ validate_utf8_size(value, field_name, maximum)
907
+ _reject_secret_material(value, field_name)
908
+ return value
909
+
910
+
911
+ def _validate_utc_timestamp(value: str, field_name: str) -> str:
912
+ validate_nonblank(value, field_name)
913
+ validate_utf8_size(value, field_name, MAX_CUSTOM_TOOL_FIELD_UTF8)
914
+ _reject_secret_material(value, field_name)
915
+ if not value.endswith("Z"):
916
+ raise ValueError(f"{field_name} must be an explicit UTC timestamp ending in Z")
917
+ try:
918
+ parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
919
+ except ValueError as exc:
920
+ raise ValueError(f"{field_name} must be an ISO 8601 UTC timestamp") from exc
921
+ if parsed.utcoffset() != timezone.utc.utcoffset(parsed):
922
+ raise ValueError(f"{field_name} must be an explicit UTC timestamp")
923
+ return value
924
+
925
+
926
+ def _validate_schema_size(value: Mapping[str, Any], field_name: str) -> None:
927
+ encoded = canonical_json_serialize(value).encode("utf-8")
928
+ if len(encoded) > MAX_CUSTOM_TOOL_SCHEMA_BYTES:
929
+ raise ValueError(f"{field_name} exceeds maximum schema size")
930
+
931
+
932
+ def _reject_secret_material(value: str, field_name: str) -> None:
933
+ if detect_secret_candidate(
934
+ field_path=f"/{field_name}",
935
+ field_name=field_name,
936
+ value=value,
937
+ policy=RedactionPolicy(),
938
+ ):
939
+ raise ValueError(f"{field_name} contains suspected secret material")
940
+
941
+
942
+ def _reject_secret_values(value: Any, *, path: str) -> None:
943
+ if isinstance(value, Mapping):
944
+ for key, item in value.items():
945
+ _reject_secret_values(item, path=f"{path}/{key}")
946
+ return
947
+ if isinstance(value, tuple | list):
948
+ for index, item in enumerate(value):
949
+ _reject_secret_values(item, path=f"{path}/{index}")
950
+ return
951
+ if isinstance(value, str):
952
+ _reject_secret_material(value, path)
953
+
954
+
955
+ def _sorted_string_tuple(value: Any, field_name: str) -> tuple[str, ...]:
956
+ if value is None:
957
+ return ()
958
+ if isinstance(value, str) or not isinstance(value, (list, tuple)):
959
+ raise ValueError(f"{field_name} must be an array")
960
+ values = tuple(value)
961
+ if not all(isinstance(item, str) for item in values):
962
+ raise ValueError(f"{field_name} must contain strings")
963
+ return tuple(sorted(values))
964
+
965
+
966
+ def _freeze_json_object(value: Mapping[str, Any]) -> MappingProxyType[str, Any]:
967
+ frozen = _freeze_json_value(dict(value), path="")
968
+ if not isinstance(frozen, MappingProxyType):
969
+ raise ValueError("schema must be a JSON object")
970
+ return frozen
971
+
972
+
973
+ def _freeze_json_value(value: Any, *, path: str) -> Any:
974
+ if isinstance(value, Mapping):
975
+ frozen_items: dict[str, Any] = {}
976
+ for key, item in sorted(value.items(), key=lambda item: str(item[0])):
977
+ if not isinstance(key, str):
978
+ raise ValueError(f"{path or '/'} object keys must be strings")
979
+ frozen_items[key] = _freeze_json_value(item, path=f"{path}/{key}")
980
+ return MappingProxyType(frozen_items)
981
+ if isinstance(value, tuple | list):
982
+ return tuple(
983
+ _freeze_json_value(item, path=f"{path}/{index}")
984
+ for index, item in enumerate(value)
985
+ )
986
+ return _validate_json_scalar(value, path=path)
987
+
988
+
989
+ def _thaw_json_value(value: Any, *, path: str = "") -> Any:
990
+ if isinstance(value, Mapping):
991
+ thawed: dict[str, Any] = {}
992
+ for key, item in sorted(value.items(), key=lambda item: str(item[0])):
993
+ if not isinstance(key, str):
994
+ raise ValueError(f"{path or '/'} object keys must be strings")
995
+ thawed[key] = _thaw_json_value(item, path=f"{path}/{key}")
996
+ return thawed
997
+ if isinstance(value, tuple | list):
998
+ return [
999
+ _thaw_json_value(item, path=f"{path}/{index}")
1000
+ for index, item in enumerate(value)
1001
+ ]
1002
+ return _validate_json_scalar(value, path=path)
1003
+
1004
+
1005
+ def _validate_json_scalar(value: Any, *, path: str) -> str | int | float | bool | None:
1006
+ if isinstance(value, str):
1007
+ _reject_secret_material(value, path or "value")
1008
+ json.dumps(value, allow_nan=False)
1009
+ return value
1010
+ if isinstance(value, bool):
1011
+ json.dumps(value, allow_nan=False)
1012
+ return value
1013
+ if isinstance(value, int):
1014
+ json.dumps(value, allow_nan=False)
1015
+ return value
1016
+ if isinstance(value, float):
1017
+ json.dumps(value, allow_nan=False)
1018
+ return value
1019
+ if value is None:
1020
+ json.dumps(value, allow_nan=False)
1021
+ return value
1022
+ raise ValueError(f"{path or '/'} must contain only JSON values")
1023
+
1024
+
1025
+ def _sha256_hex(payload: Any) -> str:
1026
+ return hashlib.sha256(
1027
+ canonical_json_serialize(_thaw_json_value(payload)).encode("utf-8")
1028
+ ).hexdigest()
1029
+
1030
+
1031
+ def _validation_pointer(exc: ValidationError) -> str:
1032
+ errors = exc.errors()
1033
+ if not errors:
1034
+ return "/"
1035
+ loc = errors[0].get("loc")
1036
+ if not isinstance(loc, tuple | list) or not loc:
1037
+ return "/"
1038
+ parts = [str(part).replace("~", "~0").replace("/", "~1") for part in loc]
1039
+ return "/" + "/".join(parts)
1040
+
1041
+
1042
+ def _validation_missing_field(exc: ValidationError) -> str | None:
1043
+ errors = exc.errors()
1044
+ if not errors:
1045
+ return None
1046
+ first = errors[0]
1047
+ if first.get("type") != "missing":
1048
+ return None
1049
+ loc = first.get("loc")
1050
+ if not isinstance(loc, tuple | list) or not loc:
1051
+ return None
1052
+ return str(loc[-1])
1053
+
1054
+
1055
+ def _validation_code(exc: ValidationError) -> CustomToolDiagnosticCode:
1056
+ errors = exc.errors()
1057
+ if not errors:
1058
+ return CustomToolDiagnosticCode.SOURCE_INVALID
1059
+ first = errors[0]
1060
+ loc = tuple(str(part) for part in first.get("loc", ()))
1061
+ message = str(first.get("msg", "")).lower()
1062
+ error_text = str(errors).lower()
1063
+ if "secret material" in error_text:
1064
+ return CustomToolDiagnosticCode.SECRET_MATERIAL
1065
+ if "runtime_kind" in loc:
1066
+ return CustomToolDiagnosticCode.RUNTIME_KIND_UNSUPPORTED
1067
+ if "produced_artifact_ids" in loc:
1068
+ return CustomToolDiagnosticCode.ARTIFACT_POLICY_INVALID
1069
+ if (
1070
+ "required_capabilities" in loc
1071
+ or "require capabilities" in error_text
1072
+ or "requires explicit capabilities" in error_text
1073
+ ):
1074
+ return CustomToolDiagnosticCode.CAPABILITY_MISSING
1075
+ if "forbidden approval policy" in error_text:
1076
+ return CustomToolDiagnosticCode.FORBIDDEN_TOOL_COMPILED
1077
+ if "approval_policy" in loc or "side-effecting custom tools" in error_text:
1078
+ return CustomToolDiagnosticCode.APPROVAL_POLICY_INVALID
1079
+ if "timeout_policy" in loc:
1080
+ return CustomToolDiagnosticCode.TIMEOUT_POLICY_INVALID
1081
+ if "output_policy" in loc:
1082
+ return CustomToolDiagnosticCode.OUTPUT_POLICY_INVALID
1083
+ if "input schema" in message or "input_schema" in loc:
1084
+ return CustomToolDiagnosticCode.INPUT_SCHEMA_UNSUPPORTED
1085
+ if "output schema" in message or "output_schema" in loc:
1086
+ return CustomToolDiagnosticCode.OUTPUT_SCHEMA_UNSUPPORTED
1087
+ if "custom tool identities" in error_text:
1088
+ return CustomToolDiagnosticCode.DUPLICATE_TOOL
1089
+ if "custom tool model_tool_name" in error_text:
1090
+ return CustomToolDiagnosticCode.DUPLICATE_MODEL_TOOL_NAME
1091
+ if "custom tool implementation_id" in error_text:
1092
+ return CustomToolDiagnosticCode.DUPLICATE_IMPLEMENTATION_ID
1093
+ return CustomToolDiagnosticCode.SOURCE_INVALID