agent-lifecycle-kit 1.50.1__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.
- agent_lifecycle/__init__.py +5 -0
- agent_lifecycle/__main__.py +5 -0
- agent_lifecycle/_version.py +3 -0
- agent_lifecycle/adapter_sessions/__init__.py +41 -0
- agent_lifecycle/adapter_sessions/contracts.py +133 -0
- agent_lifecycle/adapter_sessions/env.py +44 -0
- agent_lifecycle/adapter_sessions/launcher.py +101 -0
- agent_lifecycle/adapter_sessions/process.py +39 -0
- agent_lifecycle/adapter_sessions/redaction.py +30 -0
- agent_lifecycle/adapter_sessions/session_store.py +64 -0
- agent_lifecycle/adapter_sessions/task_intake.py +450 -0
- agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
- agent_lifecycle/audit/__init__.py +67 -0
- agent_lifecycle/audit/bug_forensics.py +94 -0
- agent_lifecycle/audit/implementation.py +516 -0
- agent_lifecycle/audit/ownership.py +119 -0
- agent_lifecycle/audit/proof_integrity.py +814 -0
- agent_lifecycle/audit/review_verdict.py +116 -0
- agent_lifecycle/changesets/__init__.py +5 -0
- agent_lifecycle/changesets/git.py +35 -0
- agent_lifecycle/cli/__init__.py +5 -0
- agent_lifecycle/cli/adapter.py +360 -0
- agent_lifecycle/cli/dispatch.py +1220 -0
- agent_lifecycle/cli/followup.py +89 -0
- agent_lifecycle/cli/main.py +41 -0
- agent_lifecycle/cli/metrics_parser.py +49 -0
- agent_lifecycle/cli/parsers.py +618 -0
- agent_lifecycle/cli/policy.py +101 -0
- agent_lifecycle/cli/progress_hooks.py +117 -0
- agent_lifecycle/cli/worktree.py +74 -0
- agent_lifecycle/compiler/__init__.py +17 -0
- agent_lifecycle/compiler/small_model_packets.py +322 -0
- agent_lifecycle/compiler/task_packets.py +229 -0
- agent_lifecycle/context/__init__.py +20 -0
- agent_lifecycle/context/episode_retrieval.py +30 -0
- agent_lifecycle/context/external_memory.py +71 -0
- agent_lifecycle/context/profiles.py +104 -0
- agent_lifecycle/context/rendering.py +226 -0
- agent_lifecycle/contracts/__init__.py +25 -0
- agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
- agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
- agent_lifecycle/contracts/audit_schemas.py +133 -0
- agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
- agent_lifecycle/contracts/canonical.py +55 -0
- agent_lifecycle/contracts/compatibility.py +242 -0
- agent_lifecycle/contracts/context_model_schemas.py +265 -0
- agent_lifecycle/contracts/core_schemas.py +252 -0
- agent_lifecycle/contracts/cross_check_schemas.py +121 -0
- agent_lifecycle/contracts/errors.py +26 -0
- agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
- agent_lifecycle/contracts/host_capability_schemas.py +64 -0
- agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
- agent_lifecycle/contracts/metric_schemas.py +315 -0
- agent_lifecycle/contracts/paths.py +31 -0
- agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
- agent_lifecycle/contracts/policy_schemas.py +350 -0
- agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
- agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
- agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
- agent_lifecycle/contracts/release_contract_schemas.py +476 -0
- agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
- agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
- agent_lifecycle/contracts/review_quality_schemas.py +109 -0
- agent_lifecycle/contracts/runner_schemas.py +91 -0
- agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
- agent_lifecycle/contracts/sandbox_schemas.py +159 -0
- agent_lifecycle/contracts/schema_builders.py +23 -0
- agent_lifecycle/contracts/schemas.py +93 -0
- agent_lifecycle/contracts/status_goal_schemas.py +469 -0
- agent_lifecycle/contracts/task_template_schemas.py +96 -0
- agent_lifecycle/contracts/usage_export_schemas.py +68 -0
- agent_lifecycle/diagnostics/__init__.py +17 -0
- agent_lifecycle/diagnostics/bundles.py +124 -0
- agent_lifecycle/diagnostics/readiness.py +733 -0
- agent_lifecycle/evidence_index/__init__.py +39 -0
- agent_lifecycle/evidence_index/core.py +272 -0
- agent_lifecycle/evidence_index/episode_index.py +329 -0
- agent_lifecycle/evidence_index/external_context.py +276 -0
- agent_lifecycle/followup/__init__.py +23 -0
- agent_lifecycle/followup/records.py +404 -0
- agent_lifecycle/freeze/__init__.py +5 -0
- agent_lifecycle/freeze/locks.py +23 -0
- agent_lifecycle/goal/__init__.py +15 -0
- agent_lifecycle/goal/records.py +244 -0
- agent_lifecycle/goal/view.py +109 -0
- agent_lifecycle/host_protocol/__init__.py +65 -0
- agent_lifecycle/host_protocol/acp_capability.py +195 -0
- agent_lifecycle/host_protocol/capabilities.py +268 -0
- agent_lifecycle/host_protocol/contracts.py +214 -0
- agent_lifecycle/host_protocol/event_capture.py +390 -0
- agent_lifecycle/host_protocol/events.py +196 -0
- agent_lifecycle/host_protocol/inspection.py +194 -0
- agent_lifecycle/host_protocol/inspection_common.py +310 -0
- agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
- agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
- agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
- agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
- agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
- agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
- agent_lifecycle/host_protocol/receipts.py +58 -0
- agent_lifecycle/host_protocol/scaffold.py +329 -0
- agent_lifecycle/host_protocol/validation.py +229 -0
- agent_lifecycle/imports/__init__.py +76 -0
- agent_lifecycle/imports/agentskills_profile.py +54 -0
- agent_lifecycle/imports/bmad_profile.py +49 -0
- agent_lifecycle/imports/constitution_adr.py +91 -0
- agent_lifecycle/imports/dialect_profiles.py +121 -0
- agent_lifecycle/imports/external_agent.py +28 -0
- agent_lifecycle/imports/external_dialects.py +486 -0
- agent_lifecycle/imports/external_workflow.py +28 -0
- agent_lifecycle/imports/multi_markdown.py +111 -0
- agent_lifecycle/imports/openspec_profile.py +49 -0
- agent_lifecycle/imports/planning.py +536 -0
- agent_lifecycle/imports/spec_kit_profile.py +49 -0
- agent_lifecycle/imports/spec_kitty_profile.py +49 -0
- agent_lifecycle/metrics/__init__.py +60 -0
- agent_lifecycle/metrics/cost_collection.py +315 -0
- agent_lifecycle/metrics/costs.py +203 -0
- agent_lifecycle/metrics/outcome_index.py +330 -0
- agent_lifecycle/metrics/phase_resources.py +208 -0
- agent_lifecycle/metrics/recommendations.py +507 -0
- agent_lifecycle/metrics/regression_signals.py +64 -0
- agent_lifecycle/metrics/usage_export.py +390 -0
- agent_lifecycle/model_routing/__init__.py +19 -0
- agent_lifecycle/model_routing/profiles.py +323 -0
- agent_lifecycle/model_routing/receipts.py +140 -0
- agent_lifecycle/model_routing/resolver.py +566 -0
- agent_lifecycle/neutrality/__init__.py +12 -0
- agent_lifecycle/neutrality/__main__.py +6 -0
- agent_lifecycle/neutrality/authority.py +265 -0
- agent_lifecycle/neutrality/canonical.py +28 -0
- agent_lifecycle/neutrality/cli.py +294 -0
- agent_lifecycle/neutrality/ed25519.py +124 -0
- agent_lifecycle/neutrality/errors.py +89 -0
- agent_lifecycle/neutrality/gate.py +269 -0
- agent_lifecycle/neutrality/paths.py +92 -0
- agent_lifecycle/neutrality/policy.py +76 -0
- agent_lifecycle/neutrality/receipt.py +135 -0
- agent_lifecycle/neutrality/scanner.py +310 -0
- agent_lifecycle/neutrality/test_runner.py +116 -0
- agent_lifecycle/planning/__init__.py +47 -0
- agent_lifecycle/planning/acceptance_markdown.py +159 -0
- agent_lifecycle/planning/completeness.py +362 -0
- agent_lifecycle/planning/continuity.py +255 -0
- agent_lifecycle/planning/templates.py +294 -0
- agent_lifecycle/planning/tiering.py +87 -0
- agent_lifecycle/planning/validation.py +72 -0
- agent_lifecycle/policy/__init__.py +37 -0
- agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
- agent_lifecycle/policy/apply.py +44 -0
- agent_lifecycle/policy/proposals.py +189 -0
- agent_lifecycle/policy/quality_floor.py +144 -0
- agent_lifecycle/policy/runtime_receipts.py +160 -0
- agent_lifecycle/py.typed +1 -0
- agent_lifecycle/quality/__init__.py +89 -0
- agent_lifecycle/quality/bug_forensics.py +727 -0
- agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
- agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
- agent_lifecycle/quality/cross_check.py +437 -0
- agent_lifecycle/quality/failure_classification.py +318 -0
- agent_lifecycle/quality/packs.py +265 -0
- agent_lifecycle/reporting/__init__.py +37 -0
- agent_lifecycle/reporting/change_summary.py +156 -0
- agent_lifecycle/reporting/event_feed.py +101 -0
- agent_lifecycle/reporting/progress_bridge.py +153 -0
- agent_lifecycle/reporting/progress_hooks.py +161 -0
- agent_lifecycle/reporting/progress_terminal.py +95 -0
- agent_lifecycle/reporting/progress_view.py +284 -0
- agent_lifecycle/reporting/status_view.py +125 -0
- agent_lifecycle/reporting/usage_export.py +59 -0
- agent_lifecycle/review/__init__.py +5 -0
- agent_lifecycle/review/validation.py +29 -0
- agent_lifecycle/review_mesh/__init__.py +83 -0
- agent_lifecycle/review_mesh/assignments.py +160 -0
- agent_lifecycle/review_mesh/contracts.py +685 -0
- agent_lifecycle/review_mesh/operator_templates.py +320 -0
- agent_lifecycle/review_mesh/quorum.py +38 -0
- agent_lifecycle/review_mesh/recommendation.py +487 -0
- agent_lifecycle/review_mesh/results.py +98 -0
- agent_lifecycle/review_mesh/synthesis.py +102 -0
- agent_lifecycle/runner/__init__.py +47 -0
- agent_lifecycle/runner/attempt_snapshots.py +244 -0
- agent_lifecycle/runner/core.py +653 -0
- agent_lifecycle/runner/sandbox_receipts.py +628 -0
- agent_lifecycle/specification/__init__.py +23 -0
- agent_lifecycle/specification/completion_check.py +135 -0
- agent_lifecycle/specification/completion_gate.py +501 -0
- agent_lifecycle/specification/completion_signal.py +75 -0
- agent_lifecycle/specification/validation.py +48 -0
- agent_lifecycle/workflow/__init__.py +79 -0
- agent_lifecycle/workflow/artifacts.py +74 -0
- agent_lifecycle/workflow/budget_decisions.py +370 -0
- agent_lifecycle/workflow/budget_policy.py +140 -0
- agent_lifecycle/workflow/budget_receipts.py +70 -0
- agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
- agent_lifecycle/workflow/controller.py +45 -0
- agent_lifecycle/workflow/events.py +50 -0
- agent_lifecycle/workflow/final_proof_integrity.py +74 -0
- agent_lifecycle/workflow/finalization.py +422 -0
- agent_lifecycle/workflow/gates.py +185 -0
- agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
- agent_lifecycle/workflow/leases.py +220 -0
- agent_lifecycle/workflow/lineage.py +108 -0
- agent_lifecycle/workflow/managed_runner.py +167 -0
- agent_lifecycle/workflow/model_usage.py +106 -0
- agent_lifecycle/workflow/next_action.py +86 -0
- agent_lifecycle/workflow/operation_kernel.py +98 -0
- agent_lifecycle/workflow/plan_adoption.py +439 -0
- agent_lifecycle/workflow/query.py +52 -0
- agent_lifecycle/workflow/review_mesh_gate.py +93 -0
- agent_lifecycle/workflow/reviews.py +168 -0
- agent_lifecycle/workflow/run_transitions.py +163 -0
- agent_lifecycle/workflow/sandbox_policy.py +214 -0
- agent_lifecycle/workflow/selectors.py +57 -0
- agent_lifecycle/workflow/state.py +130 -0
- agent_lifecycle/workflow/task_transitions.py +421 -0
- agent_lifecycle/worktree/__init__.py +17 -0
- agent_lifecycle/worktree/isolation.py +407 -0
- agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
- agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Validation helpers for provider-neutral model routing profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agent_lifecycle.context.profiles import WINDOWS
|
|
9
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object
|
|
10
|
+
|
|
11
|
+
ALLOWED_MODEL_CLASSES = {
|
|
12
|
+
"no-model",
|
|
13
|
+
"budget",
|
|
14
|
+
"local-compact",
|
|
15
|
+
"standard-code",
|
|
16
|
+
"local-standard-code",
|
|
17
|
+
"strong-reasoning",
|
|
18
|
+
"local-strong-review",
|
|
19
|
+
"specialist-review",
|
|
20
|
+
}
|
|
21
|
+
LOCAL_MODEL_CLASSES = {"local-compact", "local-standard-code", "local-strong-review"}
|
|
22
|
+
CRITICAL_REVIEW_CLASSES = {"strong-reasoning", "local-strong-review", "specialist-review"}
|
|
23
|
+
ROUTING_POLICIES = {"cost-optimized", "balanced", "quality-first", "local-only", "manual"}
|
|
24
|
+
SDD_TIERS = {"S0", "S1", "S2"}
|
|
25
|
+
JSON_RELIABILITY = {"strict", "best-effort", "unsupported"}
|
|
26
|
+
TOOL_USE = {"supported", "unsupported"}
|
|
27
|
+
USAGE_ACCOUNTING = {"host-attested", "required", "unavailable"}
|
|
28
|
+
DATA_POLICIES = {"cloud-allowed", "local-only", "restricted"}
|
|
29
|
+
CALIBRATION_STATUSES = {"PASSED", "PENDING", "FAILED", "UNKNOWN"}
|
|
30
|
+
ESCALATION_LADDER_STEPS = {
|
|
31
|
+
"no-model",
|
|
32
|
+
"local-small-packet",
|
|
33
|
+
"standard-implementation",
|
|
34
|
+
"stronger-review",
|
|
35
|
+
"optional-cross-check",
|
|
36
|
+
}
|
|
37
|
+
FAILURE_CLASSES = {
|
|
38
|
+
"edge-case",
|
|
39
|
+
"api-contract",
|
|
40
|
+
"serialization",
|
|
41
|
+
"race",
|
|
42
|
+
"permission",
|
|
43
|
+
"migration",
|
|
44
|
+
"performance",
|
|
45
|
+
"flaky-test",
|
|
46
|
+
"security-bug",
|
|
47
|
+
"unknown",
|
|
48
|
+
}
|
|
49
|
+
HOST_MODEL_PROFILE_SCHEMAS = {
|
|
50
|
+
"agent-lifecycle-host-model-profile.v1",
|
|
51
|
+
"agent-host-model-selection-profile.v1",
|
|
52
|
+
}
|
|
53
|
+
BUDGET_MODES = {"metered", "subscription", "local"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_model_routing_profile(path: Path) -> dict[str, Any]:
|
|
57
|
+
return read_json_object(path, label="model routing profile")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_host_model_profile(path: Path) -> dict[str, Any]:
|
|
61
|
+
return read_json_object(path, label="host model profile")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_model_routing_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
if profile.get("schemaVersion") != "agent-lifecycle-model-routing-profile.v1":
|
|
66
|
+
raise LifecycleError("invalid-model-routing-profile", "unsupported model routing profile schema")
|
|
67
|
+
profile_id = _required_str(profile, "profileId", "model routing profile")
|
|
68
|
+
classes = _required_string_list(profile, "classes", "model routing profile")
|
|
69
|
+
unknown = sorted(set(classes) - ALLOWED_MODEL_CLASSES)
|
|
70
|
+
if unknown:
|
|
71
|
+
raise LifecycleError("invalid-model-routing-profile", "unknown model classes", {"classes": unknown})
|
|
72
|
+
if "no-model" not in classes:
|
|
73
|
+
raise LifecycleError("invalid-model-routing-profile", "no-model class is required")
|
|
74
|
+
phase_rules = _required_dict(profile, "phaseRules", "model routing profile")
|
|
75
|
+
invalid_phase_rules = sorted(
|
|
76
|
+
phase
|
|
77
|
+
for phase, model_class in phase_rules.items()
|
|
78
|
+
if not isinstance(phase, str) or not phase or model_class not in classes
|
|
79
|
+
)
|
|
80
|
+
if invalid_phase_rules:
|
|
81
|
+
raise LifecycleError(
|
|
82
|
+
"invalid-model-routing-profile",
|
|
83
|
+
"phaseRules must map phases to declared model classes",
|
|
84
|
+
{"phases": invalid_phase_rules},
|
|
85
|
+
)
|
|
86
|
+
token_budgets = profile.get("tokenBudgets", {})
|
|
87
|
+
if token_budgets is not None:
|
|
88
|
+
_validate_token_budgets(token_budgets)
|
|
89
|
+
critical_phases = profile.get("criticalReviewPhases", [])
|
|
90
|
+
if critical_phases is not None:
|
|
91
|
+
_required_string_list(profile, "criticalReviewPhases", "model routing profile")
|
|
92
|
+
policy_presets = profile.get("policyPresets", {})
|
|
93
|
+
if policy_presets is not None:
|
|
94
|
+
_validate_policy_presets(policy_presets)
|
|
95
|
+
progressive = profile.get("progressiveEscalation", {})
|
|
96
|
+
if progressive is not None:
|
|
97
|
+
_validate_progressive_escalation(progressive)
|
|
98
|
+
return {
|
|
99
|
+
"schemaVersion": "agent-lifecycle-model-routing-profile-validation.v1",
|
|
100
|
+
"profileId": profile_id,
|
|
101
|
+
"classes": classes,
|
|
102
|
+
"phaseRuleCount": len(phase_rules),
|
|
103
|
+
"criticalReviewPhases": sorted(critical_phases),
|
|
104
|
+
"policyPresets": sorted(policy_presets),
|
|
105
|
+
"progressiveEscalation": bool(progressive.get("enabled")) if isinstance(progressive, dict) else False,
|
|
106
|
+
"profileDigest": canonical_digest(profile),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_host_model_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
111
|
+
schema_version = profile.get("schemaVersion")
|
|
112
|
+
if schema_version not in HOST_MODEL_PROFILE_SCHEMAS:
|
|
113
|
+
raise LifecycleError("invalid-host-model-profile", "unsupported host model profile schema")
|
|
114
|
+
profile_id = _required_str(profile, "profileId", "host model profile")
|
|
115
|
+
host = _required_str(profile, "host", "host model profile")
|
|
116
|
+
bindings = _required_dict(profile, "bindings", "host model profile")
|
|
117
|
+
if not bindings:
|
|
118
|
+
raise LifecycleError("invalid-host-model-profile", "bindings are required")
|
|
119
|
+
budget_mode_default: str | None = None
|
|
120
|
+
fallback_policies: dict[str, Any] | None = None
|
|
121
|
+
redaction_policy: dict[str, Any] | None = None
|
|
122
|
+
if schema_version == "agent-host-model-selection-profile.v1":
|
|
123
|
+
budget_mode_default = _required_str(profile, "budgetModeDefault", "host model profile")
|
|
124
|
+
if budget_mode_default not in BUDGET_MODES:
|
|
125
|
+
raise LifecycleError(
|
|
126
|
+
"invalid-host-model-profile",
|
|
127
|
+
"budgetModeDefault is unsupported",
|
|
128
|
+
{"budgetModeDefault": budget_mode_default},
|
|
129
|
+
)
|
|
130
|
+
fallback_policies = _required_dict(profile, "fallbackPolicies", "host model profile")
|
|
131
|
+
redaction_policy = _required_dict(profile, "redactionPolicy", "host model profile")
|
|
132
|
+
_validate_fallback_policies(fallback_policies)
|
|
133
|
+
_validate_redaction_policy(redaction_policy)
|
|
134
|
+
checked: dict[str, Any] = {}
|
|
135
|
+
for model_class, binding in bindings.items():
|
|
136
|
+
if model_class not in ALLOWED_MODEL_CLASSES:
|
|
137
|
+
raise LifecycleError(
|
|
138
|
+
"invalid-host-model-profile",
|
|
139
|
+
"unknown model class binding",
|
|
140
|
+
{"modelClass": model_class},
|
|
141
|
+
)
|
|
142
|
+
if model_class == "no-model":
|
|
143
|
+
raise LifecycleError("invalid-host-model-profile", "no-model must not have a host binding")
|
|
144
|
+
if not isinstance(binding, dict):
|
|
145
|
+
raise LifecycleError("invalid-host-model-profile", "binding must be an object", {"modelClass": model_class})
|
|
146
|
+
checked[model_class] = _validate_binding(model_class, binding)
|
|
147
|
+
return {
|
|
148
|
+
"schemaVersion": "agent-lifecycle-host-model-profile-validation.v1",
|
|
149
|
+
"host": host,
|
|
150
|
+
"profileId": profile_id,
|
|
151
|
+
"profileSchemaVersion": schema_version,
|
|
152
|
+
"budgetModeDefault": budget_mode_default,
|
|
153
|
+
"classes": sorted(checked),
|
|
154
|
+
"bindings": checked,
|
|
155
|
+
"fallbackPolicies": sorted(fallback_policies) if fallback_policies is not None else [],
|
|
156
|
+
"redactionPolicyDigest": canonical_digest(redaction_policy) if redaction_policy is not None else None,
|
|
157
|
+
"profileDigest": canonical_digest(profile),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_binding(model_class: str, binding: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
provider_model = _required_str(binding, "providerModel", f"{model_class} binding")
|
|
163
|
+
context_window = _required_str(binding, "contextWindow", f"{model_class} binding")
|
|
164
|
+
if context_window not in WINDOWS:
|
|
165
|
+
raise LifecycleError(
|
|
166
|
+
"invalid-host-model-profile",
|
|
167
|
+
"binding contextWindow is unsupported",
|
|
168
|
+
{"modelClass": model_class, "contextWindow": context_window},
|
|
169
|
+
)
|
|
170
|
+
capabilities = _required_string_list(binding, "capabilities", f"{model_class} binding")
|
|
171
|
+
json_reliability = _enum(binding, "jsonReliability", JSON_RELIABILITY, default="best-effort")
|
|
172
|
+
tool_use = _enum(binding, "toolUse", TOOL_USE, default="unsupported")
|
|
173
|
+
usage_accounting = _enum(binding, "usageAccounting", USAGE_ACCOUNTING, default="unavailable")
|
|
174
|
+
data_policy = _enum(binding, "dataPolicy", DATA_POLICIES, default="restricted")
|
|
175
|
+
calibration_status = _enum(binding, "calibrationStatus", CALIBRATION_STATUSES, default="UNKNOWN")
|
|
176
|
+
allowed_tiers = _optional_limited_string_list(binding, "allowedForTiers", SDD_TIERS)
|
|
177
|
+
allowed_phases = _optional_string_list(binding, "allowedForPhases")
|
|
178
|
+
disallowed_risks = _optional_string_list(binding, "disallowedForRisks")
|
|
179
|
+
review_strategy = binding.get("reviewStrategy")
|
|
180
|
+
if review_strategy is not None and not isinstance(review_strategy, str):
|
|
181
|
+
raise LifecycleError("invalid-host-model-profile", "reviewStrategy must be a string", {"modelClass": model_class})
|
|
182
|
+
return {
|
|
183
|
+
"contextWindow": context_window,
|
|
184
|
+
"capabilities": capabilities,
|
|
185
|
+
"jsonReliability": json_reliability,
|
|
186
|
+
"toolUse": tool_use,
|
|
187
|
+
"allowedForTiers": allowed_tiers,
|
|
188
|
+
"allowedForPhases": allowed_phases,
|
|
189
|
+
"disallowedForRisks": disallowed_risks,
|
|
190
|
+
"usageAccounting": usage_accounting,
|
|
191
|
+
"dataPolicy": data_policy,
|
|
192
|
+
"calibrationStatus": calibration_status,
|
|
193
|
+
"reviewStrategy": review_strategy,
|
|
194
|
+
"providerModelHash": canonical_digest({"providerModel": provider_model}),
|
|
195
|
+
"bindingDigest": canonical_digest(binding),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_fallback_policies(value: dict[str, Any]) -> None:
|
|
200
|
+
for model_class, fallbacks in value.items():
|
|
201
|
+
if model_class not in ALLOWED_MODEL_CLASSES or model_class == "no-model":
|
|
202
|
+
raise LifecycleError(
|
|
203
|
+
"invalid-host-model-profile",
|
|
204
|
+
"fallbackPolicies must use routable model classes",
|
|
205
|
+
{"modelClass": model_class},
|
|
206
|
+
)
|
|
207
|
+
if not isinstance(fallbacks, list) or any(not isinstance(item, str) or not item for item in fallbacks):
|
|
208
|
+
raise LifecycleError(
|
|
209
|
+
"invalid-host-model-profile",
|
|
210
|
+
"fallbackPolicies values must be lists of model classes",
|
|
211
|
+
{"modelClass": model_class},
|
|
212
|
+
)
|
|
213
|
+
invalid = sorted(set(fallbacks) - (ALLOWED_MODEL_CLASSES - {"no-model"}))
|
|
214
|
+
if invalid:
|
|
215
|
+
raise LifecycleError(
|
|
216
|
+
"invalid-host-model-profile",
|
|
217
|
+
"fallbackPolicies contain unsupported model classes",
|
|
218
|
+
{"modelClass": model_class, "fallbacks": invalid},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate_redaction_policy(value: dict[str, Any]) -> None:
|
|
223
|
+
provider_model_policy = value.get("providerModel")
|
|
224
|
+
if provider_model_policy != "hash":
|
|
225
|
+
raise LifecycleError(
|
|
226
|
+
"invalid-host-model-profile",
|
|
227
|
+
"redactionPolicy.providerModel must be hash",
|
|
228
|
+
{"providerModel": provider_model_policy},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate_token_budgets(value: Any) -> None:
|
|
233
|
+
if not isinstance(value, dict):
|
|
234
|
+
raise LifecycleError("invalid-model-routing-profile", "tokenBudgets must be an object")
|
|
235
|
+
for tier, budgets in value.items():
|
|
236
|
+
if tier not in SDD_TIERS or not isinstance(budgets, dict):
|
|
237
|
+
raise LifecycleError("invalid-model-routing-profile", "tokenBudgets keys must be SDD tiers")
|
|
238
|
+
for budget_class, amount in budgets.items():
|
|
239
|
+
if not isinstance(budget_class, str) or not isinstance(amount, int) or isinstance(amount, bool) or amount < 0:
|
|
240
|
+
raise LifecycleError("invalid-model-routing-profile", "token budget values must be non-negative integers")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_policy_presets(value: Any) -> None:
|
|
244
|
+
if not isinstance(value, dict):
|
|
245
|
+
raise LifecycleError("invalid-model-routing-profile", "policyPresets must be an object")
|
|
246
|
+
unknown = sorted(set(value) - ROUTING_POLICIES)
|
|
247
|
+
if unknown:
|
|
248
|
+
raise LifecycleError("invalid-model-routing-profile", "unknown policy presets", {"policies": unknown})
|
|
249
|
+
for policy, payload in value.items():
|
|
250
|
+
if not isinstance(payload, dict):
|
|
251
|
+
raise LifecycleError("invalid-model-routing-profile", "policy preset must be an object", {"policy": policy})
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validate_progressive_escalation(value: Any) -> None:
|
|
255
|
+
if not isinstance(value, dict):
|
|
256
|
+
raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation must be an object")
|
|
257
|
+
enabled = value.get("enabled", False)
|
|
258
|
+
if not isinstance(enabled, bool):
|
|
259
|
+
raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.enabled must be boolean")
|
|
260
|
+
ladder = value.get("ladder", [])
|
|
261
|
+
if not isinstance(ladder, list) or ladder != [
|
|
262
|
+
"no-model",
|
|
263
|
+
"local-small-packet",
|
|
264
|
+
"standard-implementation",
|
|
265
|
+
"stronger-review",
|
|
266
|
+
"optional-cross-check",
|
|
267
|
+
]:
|
|
268
|
+
raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.ladder must use the canonical order")
|
|
269
|
+
classes = value.get("failureClasses", [])
|
|
270
|
+
if not isinstance(classes, list) or sorted(classes) != sorted(FAILURE_CLASSES):
|
|
271
|
+
raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.failureClasses must list neutral failure classes")
|
|
272
|
+
triggers = value.get("evidenceTriggers", [])
|
|
273
|
+
if not isinstance(triggers, list) or any(not isinstance(item, str) or not item for item in triggers):
|
|
274
|
+
raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.evidenceTriggers must be strings")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _required_str(payload: dict[str, Any], key: str, label: str) -> str:
|
|
278
|
+
value = payload.get(key)
|
|
279
|
+
if not isinstance(value, str) or not value:
|
|
280
|
+
raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} is required")
|
|
281
|
+
return value
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _required_dict(payload: dict[str, Any], key: str, label: str) -> dict[str, Any]:
|
|
285
|
+
value = payload.get(key)
|
|
286
|
+
if not isinstance(value, dict):
|
|
287
|
+
raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} object is required")
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _required_string_list(payload: dict[str, Any], key: str, label: str) -> list[str]:
|
|
292
|
+
if key not in payload:
|
|
293
|
+
raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} is required")
|
|
294
|
+
return _string_list(payload[key], key=key)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _optional_string_list(payload: dict[str, Any], key: str) -> list[str]:
|
|
298
|
+
if key not in payload:
|
|
299
|
+
return []
|
|
300
|
+
return _string_list(payload[key], key=key)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _optional_limited_string_list(payload: dict[str, Any], key: str, allowed: set[str]) -> list[str]:
|
|
304
|
+
values = _optional_string_list(payload, key)
|
|
305
|
+
invalid = sorted(set(values) - allowed)
|
|
306
|
+
if invalid:
|
|
307
|
+
raise LifecycleError("invalid-host-model-profile", f"{key} contains unsupported values", {"values": invalid})
|
|
308
|
+
return values
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _string_list(value: Any, *, key: str) -> list[str]:
|
|
312
|
+
if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
|
|
313
|
+
raise LifecycleError("invalid-model-routing-profile", f"{key} must be a list of non-empty strings")
|
|
314
|
+
if len(value) != len(set(value)):
|
|
315
|
+
raise LifecycleError("invalid-model-routing-profile", f"{key} contains duplicates")
|
|
316
|
+
return list(value)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _enum(payload: dict[str, Any], key: str, allowed: set[str], *, default: str) -> str:
|
|
320
|
+
value = payload.get(key, default)
|
|
321
|
+
if value not in allowed:
|
|
322
|
+
raise LifecycleError("invalid-host-model-profile", f"{key} is unsupported", {"value": value})
|
|
323
|
+
return value
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Usage receipt validation for model-backed route decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
8
|
+
from agent_lifecycle.model_routing.profiles import ALLOWED_MODEL_CLASSES, SDD_TIERS
|
|
9
|
+
|
|
10
|
+
USAGE_METRICS = {
|
|
11
|
+
"inputTokens",
|
|
12
|
+
"outputTokens",
|
|
13
|
+
"billableTokens",
|
|
14
|
+
"cumulativeContextBytes",
|
|
15
|
+
"toolCalls",
|
|
16
|
+
"wallSeconds",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_usage_receipt(
|
|
21
|
+
receipt: dict[str, Any],
|
|
22
|
+
*,
|
|
23
|
+
budget_targets: dict[str, Any] | None = None,
|
|
24
|
+
route_decision: dict[str, Any] | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
_validate_shape(receipt)
|
|
27
|
+
checks = [_attestation_check(receipt)]
|
|
28
|
+
if route_decision is not None:
|
|
29
|
+
checks.extend(_route_binding_checks(receipt, route_decision))
|
|
30
|
+
checks.extend(_budget_checks(receipt, budget_targets=budget_targets, route_decision=route_decision))
|
|
31
|
+
status = "PASS" if all(item["status"] == "PASS" for item in checks) else "FAIL"
|
|
32
|
+
payload = {
|
|
33
|
+
"schemaVersion": "agent-lifecycle-model-usage-validation.v1",
|
|
34
|
+
"status": status,
|
|
35
|
+
"operationId": receipt["operationId"],
|
|
36
|
+
"host": receipt["host"],
|
|
37
|
+
"modelClass": receipt["modelClass"],
|
|
38
|
+
"receiptDigest": canonical_digest(receipt),
|
|
39
|
+
"checks": checks,
|
|
40
|
+
}
|
|
41
|
+
if route_decision is not None:
|
|
42
|
+
payload["routeDecisionDigest"] = canonical_digest(route_decision)
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_shape(receipt: dict[str, Any]) -> None:
|
|
47
|
+
if receipt.get("schemaVersion") != "agent-lifecycle-model-usage-receipt.v1":
|
|
48
|
+
raise LifecycleError("invalid-model-usage-receipt", "unsupported model usage receipt schema")
|
|
49
|
+
for key in ("operationId", "host", "modelClass", "providerModelHash"):
|
|
50
|
+
if not isinstance(receipt.get(key), str) or not receipt.get(key):
|
|
51
|
+
raise LifecycleError("invalid-model-usage-receipt", f"{key} is required")
|
|
52
|
+
if receipt["modelClass"] not in ALLOWED_MODEL_CLASSES - {"no-model"}:
|
|
53
|
+
raise LifecycleError("invalid-model-usage-receipt", "modelClass is unsupported")
|
|
54
|
+
usage = receipt.get("usage")
|
|
55
|
+
if not isinstance(usage, dict):
|
|
56
|
+
raise LifecycleError("invalid-model-usage-receipt", "usage object is required")
|
|
57
|
+
missing = sorted(metric for metric in USAGE_METRICS if not isinstance(usage.get(metric), int) or isinstance(usage.get(metric), bool))
|
|
58
|
+
if missing:
|
|
59
|
+
raise LifecycleError("invalid-model-usage-receipt", "usage metrics must be integers", {"missing": missing})
|
|
60
|
+
negative = sorted(metric for metric in USAGE_METRICS if usage[metric] < 0)
|
|
61
|
+
if negative:
|
|
62
|
+
raise LifecycleError("invalid-model-usage-receipt", "usage metrics must be non-negative", {"metrics": negative})
|
|
63
|
+
attestation = receipt.get("attestation")
|
|
64
|
+
if not isinstance(attestation, dict):
|
|
65
|
+
raise LifecycleError("invalid-model-usage-receipt", "attestation object is required")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _attestation_check(receipt: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
attestation = receipt["attestation"]
|
|
70
|
+
status = "PASS" if attestation.get("source") == "host" and attestation.get("status") == "ATTESTED" else "FAIL"
|
|
71
|
+
return {
|
|
72
|
+
"id": "host-usage-attestation",
|
|
73
|
+
"status": status,
|
|
74
|
+
"source": attestation.get("source"),
|
|
75
|
+
"attestationStatus": attestation.get("status"),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _route_binding_checks(receipt: dict[str, Any], decision: dict[str, Any]) -> list[dict[str, Any]]:
|
|
80
|
+
expected_digest = decision.get("decisionDigest", canonical_digest(decision))
|
|
81
|
+
receipt_digest = receipt.get("routeDecisionDigest")
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
"id": "operation-id-binding",
|
|
85
|
+
"status": "PASS" if receipt.get("operationId") == decision.get("operationId") else "FAIL",
|
|
86
|
+
"expected": decision.get("operationId"),
|
|
87
|
+
"actual": receipt.get("operationId"),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "model-class-binding",
|
|
91
|
+
"status": "PASS" if receipt.get("modelClass") == decision.get("modelClass") else "FAIL",
|
|
92
|
+
"expected": decision.get("modelClass"),
|
|
93
|
+
"actual": receipt.get("modelClass"),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "route-decision-digest-binding",
|
|
97
|
+
"status": "PASS" if receipt_digest in (None, expected_digest) else "FAIL",
|
|
98
|
+
"expected": expected_digest,
|
|
99
|
+
"actual": receipt_digest,
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _budget_checks(
|
|
105
|
+
receipt: dict[str, Any],
|
|
106
|
+
*,
|
|
107
|
+
budget_targets: dict[str, Any] | None,
|
|
108
|
+
route_decision: dict[str, Any] | None,
|
|
109
|
+
) -> list[dict[str, Any]]:
|
|
110
|
+
checks: list[dict[str, Any]] = []
|
|
111
|
+
usage = receipt["usage"]
|
|
112
|
+
if route_decision is not None and isinstance(route_decision.get("maxBillableTokens"), int):
|
|
113
|
+
limit = route_decision["maxBillableTokens"]
|
|
114
|
+
checks.append({
|
|
115
|
+
"id": "route-max-billable-tokens",
|
|
116
|
+
"status": "PASS" if usage["billableTokens"] <= limit else "FAIL",
|
|
117
|
+
"value": usage["billableTokens"],
|
|
118
|
+
"limit": limit,
|
|
119
|
+
})
|
|
120
|
+
if budget_targets is None:
|
|
121
|
+
return checks
|
|
122
|
+
tier = receipt.get("sddTier") or (route_decision or {}).get("sddTier")
|
|
123
|
+
if tier not in SDD_TIERS:
|
|
124
|
+
checks.append({"id": "budget-tier-binding", "status": "FAIL", "tier": tier})
|
|
125
|
+
return checks
|
|
126
|
+
hard = budget_targets.get("hardCeilings", {}).get(tier)
|
|
127
|
+
if not isinstance(hard, dict):
|
|
128
|
+
checks.append({"id": "budget-target-present", "status": "FAIL", "tier": tier})
|
|
129
|
+
return checks
|
|
130
|
+
for metric in ("billableTokens", "cumulativeContextBytes", "toolCalls", "wallSeconds"):
|
|
131
|
+
limit = hard.get(metric)
|
|
132
|
+
if isinstance(limit, int):
|
|
133
|
+
checks.append({
|
|
134
|
+
"id": f"hard-ceiling-{metric}",
|
|
135
|
+
"status": "PASS" if usage[metric] <= limit else "FAIL",
|
|
136
|
+
"value": usage[metric],
|
|
137
|
+
"limit": limit,
|
|
138
|
+
"tier": tier,
|
|
139
|
+
})
|
|
140
|
+
return checks
|