|
|
@@ -11,7 +11,9 @@ import re
|
|
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
|
from dataclasses import asdict, dataclass
|
|
|
from datetime import UTC, datetime
|
|
|
+from hashlib import sha256
|
|
|
from typing import Any, Protocol, cast
|
|
|
+from unicodedata import normalize
|
|
|
from uuid import uuid4
|
|
|
|
|
|
from agent.orchestration import (
|
|
|
@@ -165,6 +167,7 @@ class LegacyScriptToolGateway:
|
|
|
validation_id: str | None,
|
|
|
replacement_contract: Mapping[str, Any] | None,
|
|
|
child_contracts: Sequence[Mapping[str, Any]],
|
|
|
+ selected_decision_ids: Sequence[str],
|
|
|
context: Mapping[str, Any],
|
|
|
) -> Mapping[str, Any]:
|
|
|
return cast(
|
|
|
@@ -178,6 +181,7 @@ class LegacyScriptToolGateway:
|
|
|
validation_id=validation_id,
|
|
|
replacement_contract=replacement_contract,
|
|
|
child_contracts=child_contracts,
|
|
|
+ selected_decision_ids=selected_decision_ids,
|
|
|
context=context,
|
|
|
),
|
|
|
),
|
|
|
@@ -617,9 +621,23 @@ class LegacyScriptToolGateway:
|
|
|
if task is None:
|
|
|
raise ProtocolViolation("validation task is outside the protected mission")
|
|
|
snapshot = await self.coordinator.artifact_store.get(root_trace_id, snapshot_id)
|
|
|
+ allowed_values = [*snapshot.artifact_refs, *snapshot.evidence_refs]
|
|
|
+ kind = task_kind(task.current_spec.context_refs)
|
|
|
+ if kind in PHASE_TWO_KINDS:
|
|
|
+ evidence_reader = getattr(
|
|
|
+ self._candidates(), "validation_evidence_refs", None
|
|
|
+ )
|
|
|
+ if callable(evidence_reader):
|
|
|
+ allowed_values.extend(await evidence_reader(context=context))
|
|
|
+ elif kind == ROOT_DELIVERY_KIND:
|
|
|
+ evidence_reader = getattr(
|
|
|
+ self._root_tools(), "validation_evidence_refs", None
|
|
|
+ )
|
|
|
+ if callable(evidence_reader):
|
|
|
+ allowed_values.extend(await evidence_reader(context=context))
|
|
|
allowed_refs = {
|
|
|
(ref.uri, ref.version, ref.digest, ref.kind): ref
|
|
|
- for ref in (*snapshot.artifact_refs, *snapshot.evidence_refs)
|
|
|
+ for ref in allowed_values
|
|
|
}
|
|
|
normalized_defects = _normalize_defects(defects, allowed_refs)
|
|
|
expected_ids = {item.criterion_id for item in task.current_spec.acceptance_criteria}
|
|
|
@@ -639,12 +657,13 @@ class LegacyScriptToolGateway:
|
|
|
)
|
|
|
if defect_codes:
|
|
|
reason = f"{reason}; defect_codes={','.join(defect_codes)}"[:500]
|
|
|
- evidence = _dedupe_refs(
|
|
|
- ref
|
|
|
- for item in normalized_defects
|
|
|
- if item["criterion_id"] == criterion_id
|
|
|
- for ref in item["evidence_refs"]
|
|
|
+ evidence = _normalize_evidence_refs(
|
|
|
+ raw.get("evidence_refs"),
|
|
|
+ allowed_refs,
|
|
|
+ label="criterion evidence_refs",
|
|
|
)
|
|
|
+ if criterion_verdict is ValidationVerdict.PASSED and not evidence:
|
|
|
+ raise ProtocolViolation("passed criterion requires frozen evidence_refs")
|
|
|
result_by_id[criterion_id] = CriterionResult(
|
|
|
criterion_id=criterion_id,
|
|
|
verdict=criterion_verdict,
|
|
|
@@ -657,6 +676,12 @@ class LegacyScriptToolGateway:
|
|
|
blocking = [item for item in normalized_defects if item["severity"] in {"hard", "critical"}]
|
|
|
if overall is ValidationVerdict.PASSED and blocking:
|
|
|
raise ProtocolViolation("passed validation cannot contain hard or critical defects")
|
|
|
+ criterion_verdicts = {item.verdict for item in result_by_id.values()}
|
|
|
+ every_criterion_passed = criterion_verdicts == {ValidationVerdict.PASSED}
|
|
|
+ if (overall is ValidationVerdict.PASSED) != every_criterion_passed:
|
|
|
+ raise ProtocolViolation(
|
|
|
+ "overall validation verdict must match all criterion verdicts"
|
|
|
+ )
|
|
|
from agent.orchestration.validation_policy import ValidationContext
|
|
|
|
|
|
attempt = ledger.attempts.get(_required(context, "attempt_id"))
|
|
|
@@ -767,23 +792,10 @@ class LegacyScriptToolGateway:
|
|
|
raise ProtocolViolation(
|
|
|
"direction references evidence outside frozen child ACCEPT decisions"
|
|
|
)
|
|
|
+ task_id = _required(context, "task_id")
|
|
|
+ direction_goals, goal_ids_by_client_key = _direction_goals(task_id, goals)
|
|
|
artifact = DirectionArtifact(
|
|
|
- goals=tuple(
|
|
|
- DirectionGoal(
|
|
|
- goal_id=redact_text(str(item["goal_id"])),
|
|
|
- statement=redact_text(str(item["statement"])),
|
|
|
- rationale=redact_text(str(item["rationale"])),
|
|
|
- parent_goal_id=(
|
|
|
- redact_text(str(item["parent_goal_id"]))
|
|
|
- if item.get("parent_goal_id") is not None
|
|
|
- else None
|
|
|
- ),
|
|
|
- success_criteria=tuple(
|
|
|
- redact_text(str(value)) for value in item["success_criteria"]
|
|
|
- ),
|
|
|
- )
|
|
|
- for item in goals
|
|
|
- ),
|
|
|
+ goals=direction_goals,
|
|
|
constraints=tuple(
|
|
|
DirectionConstraint(
|
|
|
constraint_id=redact_text(str(item["constraint_id"])),
|
|
|
@@ -817,6 +829,7 @@ class LegacyScriptToolGateway:
|
|
|
"artifact_ref": _ref_dict(ref),
|
|
|
"artifact_version_id": version.artifact_version_id,
|
|
|
"direction": _json_values(version.artifact.content_payload()),
|
|
|
+ "goal_ids_by_client_key": goal_ids_by_client_key,
|
|
|
}
|
|
|
|
|
|
async def query_validation_evidence(
|
|
|
@@ -1001,6 +1014,68 @@ def _bounded_text(value: Any, label: str, maximum: int) -> str:
|
|
|
return redact_text(value.strip())
|
|
|
|
|
|
|
|
|
+def _normalized_client_key(value: Any, label: str) -> str:
|
|
|
+ text = _bounded_text(value, label, 128)
|
|
|
+ normalized = " ".join(normalize("NFKC", text).split()).casefold()
|
|
|
+ if not normalized or len(normalized) > 128:
|
|
|
+ raise ProtocolViolation(f"{label} must contain between 1 and 128 characters")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _direction_goals(
|
|
|
+ task_id: str, goals: Sequence[Mapping[str, Any]]
|
|
|
+) -> tuple[tuple[DirectionGoal, ...], dict[str, str]]:
|
|
|
+ if not 1 <= len(goals) <= 30:
|
|
|
+ raise ValueError("direction requires between one and thirty Goal nodes")
|
|
|
+ normalized: list[tuple[str, str | None, Mapping[str, Any]]] = []
|
|
|
+ for item in goals:
|
|
|
+ client_key = _normalized_client_key(item.get("client_key"), "goal client_key")
|
|
|
+ parent_key = (
|
|
|
+ _normalized_client_key(
|
|
|
+ item.get("parent_client_key"), "goal parent_client_key"
|
|
|
+ )
|
|
|
+ if item.get("parent_client_key") is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ normalized.append((client_key, parent_key, item))
|
|
|
+ keys = [item[0] for item in normalized]
|
|
|
+ if len(set(keys)) != len(keys):
|
|
|
+ raise ValueError("goal client_key values must be unique")
|
|
|
+ key_set = set(keys)
|
|
|
+ for client_key, parent_key, _ in normalized:
|
|
|
+ if parent_key == client_key:
|
|
|
+ raise ValueError("a goal cannot parent itself")
|
|
|
+ if parent_key is not None and parent_key not in key_set:
|
|
|
+ raise ValueError(f"goal parent_client_key does not exist: {parent_key}")
|
|
|
+ parent_by_key = {key: parent for key, parent, _ in normalized}
|
|
|
+ if any(
|
|
|
+ parent is not None and parent_by_key.get(parent) is not None
|
|
|
+ for _, parent, _ in normalized
|
|
|
+ ):
|
|
|
+ raise ValueError("direction goal hierarchy may contain only two levels")
|
|
|
+ goal_ids = {
|
|
|
+ key: "goal-" + sha256(f"{task_id}\0{key}".encode()).hexdigest()[:16]
|
|
|
+ for key in keys
|
|
|
+ }
|
|
|
+ if len(set(goal_ids.values())) != len(goal_ids):
|
|
|
+ raise ValueError("generated goal identity collision")
|
|
|
+ return (
|
|
|
+ tuple(
|
|
|
+ DirectionGoal(
|
|
|
+ goal_id=goal_ids[client_key],
|
|
|
+ statement=redact_text(str(item["statement"])),
|
|
|
+ rationale=redact_text(str(item["rationale"])),
|
|
|
+ parent_goal_id=goal_ids[parent_key] if parent_key is not None else None,
|
|
|
+ success_criteria=tuple(
|
|
|
+ redact_text(str(value)) for value in item["success_criteria"]
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ for client_key, parent_key, item in normalized
|
|
|
+ ),
|
|
|
+ goal_ids,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def _attempt_summary(ref: ArtifactRef, scope_ref: str) -> str:
|
|
|
scope = _bounded_text(scope_ref, "scope_ref", 300)
|
|
|
if not scope.startswith("script-build://"):
|
|
|
@@ -1040,28 +1115,16 @@ def _normalize_defects(
|
|
|
or any(not isinstance(item, str) or not item.strip() for item in invalidated)
|
|
|
):
|
|
|
raise ProtocolViolation("invalidated_inputs must be a bounded string array")
|
|
|
- raw_refs = raw.get("evidence_refs", [])
|
|
|
- if not isinstance(raw_refs, list) or len(raw_refs) > 64:
|
|
|
- raise ProtocolViolation("defect evidence_refs must be a bounded array")
|
|
|
- refs: list[ArtifactRef] = []
|
|
|
- for item in raw_refs:
|
|
|
- if not isinstance(item, Mapping):
|
|
|
- raise ProtocolViolation("defect evidence reference must be an object")
|
|
|
- candidate = ArtifactRef.from_dict(dict(item))
|
|
|
- key = (candidate.uri, candidate.version, candidate.digest, candidate.kind)
|
|
|
- frozen = allowed_refs.get(key)
|
|
|
- if frozen is None:
|
|
|
- raise ProtocolViolation(
|
|
|
- "defect evidence must come from the fixed validation snapshot"
|
|
|
- )
|
|
|
- refs.append(frozen)
|
|
|
+ refs = _normalize_evidence_refs(
|
|
|
+ raw.get("evidence_refs"), allowed_refs, label="defect evidence_refs"
|
|
|
+ )
|
|
|
values.append(
|
|
|
{
|
|
|
"defect_code": code,
|
|
|
"criterion_id": criterion_id,
|
|
|
"scope_ref": scope_ref,
|
|
|
"observed_excerpt": excerpt,
|
|
|
- "evidence_refs": tuple(refs),
|
|
|
+ "evidence_refs": refs,
|
|
|
"severity": severity,
|
|
|
"invalidated_inputs": tuple(str(item) for item in invalidated),
|
|
|
"recommended_action_class": action,
|
|
|
@@ -1070,6 +1133,27 @@ def _normalize_defects(
|
|
|
return tuple(values)
|
|
|
|
|
|
|
|
|
+def _normalize_evidence_refs(
|
|
|
+ value: Any,
|
|
|
+ allowed_refs: Mapping[tuple[Any, Any, Any, Any], ArtifactRef],
|
|
|
+ *,
|
|
|
+ label: str,
|
|
|
+) -> tuple[ArtifactRef, ...]:
|
|
|
+ if not isinstance(value, list) or len(value) > 64:
|
|
|
+ raise ProtocolViolation(f"{label} must be a bounded array")
|
|
|
+ refs: list[ArtifactRef] = []
|
|
|
+ for item in value:
|
|
|
+ if not isinstance(item, Mapping):
|
|
|
+ raise ProtocolViolation(f"{label} entries must be objects")
|
|
|
+ candidate = ArtifactRef.from_dict(dict(item))
|
|
|
+ key = (candidate.uri, candidate.version, candidate.digest, candidate.kind)
|
|
|
+ frozen = allowed_refs.get(key)
|
|
|
+ if frozen is None:
|
|
|
+ raise ProtocolViolation(f"{label} must come from the protected validation closure")
|
|
|
+ refs.append(frozen)
|
|
|
+ return _dedupe_refs(refs)
|
|
|
+
|
|
|
+
|
|
|
def _dedupe_refs(values: Any) -> tuple[ArtifactRef, ...]:
|
|
|
result: list[ArtifactRef] = []
|
|
|
seen: set[tuple[Any, Any, Any, Any]] = set()
|