|
@@ -7,15 +7,28 @@ framework's protected context and then checked against durable bindings.
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
+import re
|
|
|
from collections.abc import Mapping, Sequence
|
|
from collections.abc import Mapping, Sequence
|
|
|
from dataclasses import asdict, dataclass
|
|
from dataclasses import asdict, dataclass
|
|
|
from datetime import UTC, datetime
|
|
from datetime import UTC, datetime
|
|
|
from typing import Any, Protocol, cast
|
|
from typing import Any, Protocol, cast
|
|
|
from uuid import uuid4
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
-from agent.orchestration import ArtifactRef, EvidenceQuery
|
|
|
|
|
|
|
+from agent.orchestration import (
|
|
|
|
|
+ ArtifactRef,
|
|
|
|
|
+ AttemptSubmission,
|
|
|
|
|
+ CriterionResult,
|
|
|
|
|
+ EvidenceQuery,
|
|
|
|
|
+ ValidationVerdict,
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
-from script_build_host.agents.validation import RETRIEVAL_KINDS, task_kind
|
|
|
|
|
|
|
+from script_build_host.agents.validation import (
|
|
|
|
|
+ PHASE_TWO_KINDS,
|
|
|
|
|
+ RETRIEVAL_KINDS,
|
|
|
|
|
+ precheck_business_artifact,
|
|
|
|
|
+ precheck_snapshot,
|
|
|
|
|
+ task_kind,
|
|
|
|
|
+)
|
|
|
from script_build_host.domain.artifacts import (
|
|
from script_build_host.domain.artifacts import (
|
|
|
ArtifactKind,
|
|
ArtifactKind,
|
|
|
Criterion,
|
|
Criterion,
|
|
@@ -23,7 +36,8 @@ from script_build_host.domain.artifacts import (
|
|
|
EvidenceRecordV1,
|
|
EvidenceRecordV1,
|
|
|
ScriptDirectionArtifactV1,
|
|
ScriptDirectionArtifactV1,
|
|
|
)
|
|
)
|
|
|
-from script_build_host.domain.errors import ProtocolViolation
|
|
|
|
|
|
|
+from script_build_host.domain.canonical_json import canonical_sha256
|
|
|
|
|
+from script_build_host.domain.errors import ArtifactNotFound, ProtocolViolation
|
|
|
from script_build_host.domain.ports import (
|
|
from script_build_host.domain.ports import (
|
|
|
InputSnapshotRepository,
|
|
InputSnapshotRepository,
|
|
|
MissionBindingRepository,
|
|
MissionBindingRepository,
|
|
@@ -31,6 +45,15 @@ from script_build_host.domain.ports import (
|
|
|
)
|
|
)
|
|
|
from script_build_host.infrastructure.redaction import redact, redact_text
|
|
from script_build_host.infrastructure.redaction import redact, redact_text
|
|
|
|
|
|
|
|
|
|
+from .contracts import ScriptCandidateToolPort, ScriptPlannerToolPort
|
|
|
|
|
+
|
|
|
|
|
+_DEFECT_SEVERITIES = frozenset({"warning", "hard", "critical"})
|
|
|
|
|
+_DEFECT_ACTIONS = frozenset(
|
|
|
|
|
+ {"repair", "retry", "revise", "split", "retrieve", "replace", "compare", "block"}
|
|
|
|
|
+)
|
|
|
|
|
+_BUSINESS_ARTIFACT_KINDS = frozenset(item.value for item in ArtifactKind)
|
|
|
|
|
+_DEFECT_CODE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$")
|
|
|
|
|
+
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
@dataclass(frozen=True, slots=True)
|
|
|
class RetrievalResult:
|
|
class RetrievalResult:
|
|
@@ -60,6 +83,10 @@ class DispatchGuard(Protocol):
|
|
|
async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
|
|
async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class PhaseTwoBudgetGuard(Protocol):
|
|
|
|
|
+ async def ensure_tool_budget(self, *, root_trace_id: str, task_id: str, entry: str) -> None: ...
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class LegacyScriptToolGateway:
|
|
class LegacyScriptToolGateway:
|
|
|
"""Translate protected Agent tool calls to typed domain operations."""
|
|
"""Translate protected Agent tool calls to typed domain operations."""
|
|
|
|
|
|
|
@@ -73,6 +100,9 @@ class LegacyScriptToolGateway:
|
|
|
retrieval_adapters: Mapping[str, RetrievalAdapter] | None = None,
|
|
retrieval_adapters: Mapping[str, RetrievalAdapter] | None = None,
|
|
|
image_adapter: ImageAdapter | None = None,
|
|
image_adapter: ImageAdapter | None = None,
|
|
|
dispatch_guard: DispatchGuard | None = None,
|
|
dispatch_guard: DispatchGuard | None = None,
|
|
|
|
|
+ planner_tools: ScriptPlannerToolPort | None = None,
|
|
|
|
|
+ candidate_tools: ScriptCandidateToolPort | None = None,
|
|
|
|
|
+ budget_guard: PhaseTwoBudgetGuard | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
self.bindings = bindings
|
|
self.bindings = bindings
|
|
|
self.snapshots = snapshots
|
|
self.snapshots = snapshots
|
|
@@ -81,18 +111,109 @@ class LegacyScriptToolGateway:
|
|
|
self.retrieval_adapters = dict(retrieval_adapters or {})
|
|
self.retrieval_adapters = dict(retrieval_adapters or {})
|
|
|
self.image_adapter = image_adapter
|
|
self.image_adapter = image_adapter
|
|
|
self.dispatch_guard = dispatch_guard
|
|
self.dispatch_guard = dispatch_guard
|
|
|
|
|
+ self.planner_tools = planner_tools
|
|
|
|
|
+ self.candidate_tools = candidate_tools
|
|
|
|
|
+ self.budget_guard = budget_guard
|
|
|
|
|
+ self._active_retrieval_attempts: set[tuple[str, str, str]] = set()
|
|
|
|
|
|
|
|
async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
|
|
async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
|
|
|
binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
|
|
binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
|
|
|
if self.dispatch_guard is not None:
|
|
if self.dispatch_guard is not None:
|
|
|
await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
|
|
await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
|
|
|
|
|
|
|
|
|
|
+ async def plan_script_tasks(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ contracts: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ parent_task_id: str | None,
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._planner().plan_script_tasks(
|
|
|
|
|
+ contract_payloads=contracts,
|
|
|
|
|
+ parent_task_id=parent_task_id,
|
|
|
|
|
+ context=context,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def decide_script_task(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ task_id: str,
|
|
|
|
|
+ action: str,
|
|
|
|
|
+ reason: str,
|
|
|
|
|
+ validation_id: str | None,
|
|
|
|
|
+ replacement_contract: Mapping[str, Any] | None,
|
|
|
|
|
+ child_contracts: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._planner().decide_script_task(
|
|
|
|
|
+ task_id=task_id,
|
|
|
|
|
+ action=action,
|
|
|
|
|
+ reason=reason,
|
|
|
|
|
+ validation_id=validation_id,
|
|
|
|
|
+ replacement_contract=replacement_contract,
|
|
|
|
|
+ child_contracts=child_contracts,
|
|
|
|
|
+ context=context,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def inspect_script_plan(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._planner().inspect_script_plan(context=context)
|
|
|
|
|
+
|
|
|
|
|
+ async def dispatch_script_tasks(
|
|
|
|
|
+ self, *, task_ids: Sequence[str], context: Mapping[str, Any]
|
|
|
|
|
+ ) -> Sequence[Mapping[str, Any]]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
|
|
+ return await self._planner().dispatch_script_tasks(task_ids=task_ids, context=context)
|
|
|
|
|
+
|
|
|
async def read_input_snapshot(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
async def read_input_snapshot(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
binding, snapshot = await self._scope(context)
|
|
binding, snapshot = await self._scope(context)
|
|
|
payload = asdict(snapshot)
|
|
payload = asdict(snapshot)
|
|
|
payload["script_build_id"] = binding.script_build_id
|
|
payload["script_build_id"] = binding.script_build_id
|
|
|
|
|
+ payload["strategies"] = [
|
|
|
|
|
+ (
|
|
|
|
|
+ item
|
|
|
|
|
+ if item.get("mode") == "always_on"
|
|
|
|
|
+ else {key: value for key, value in item.items() if key != "content"}
|
|
|
|
|
+ )
|
|
|
|
|
+ for item in payload.get("strategies", [])
|
|
|
|
|
+ ]
|
|
|
|
|
+ payload["prompt_manifest"] = [
|
|
|
|
|
+ {key: value for key, value in item.items() if key != "content"}
|
|
|
|
|
+ for item in payload.get("prompt_manifest", [])
|
|
|
|
|
+ ]
|
|
|
return cast(dict[str, Any], _json_values(payload))
|
|
return cast(dict[str, Any], _json_values(payload))
|
|
|
|
|
|
|
|
|
|
+ async def load_frozen_strategy(
|
|
|
|
|
+ self, strategy_ref: str, context: Mapping[str, Any]
|
|
|
|
|
+ ) -> dict[str, Any]:
|
|
|
|
|
+ """Explicitly load one digest-verified on-demand strategy from the bound snapshot."""
|
|
|
|
|
+
|
|
|
|
|
+ binding, snapshot = await self._scope(context)
|
|
|
|
|
+ ledger = await self.coordinator.task_store.load(_required(context, "root_trace_id"))
|
|
|
|
|
+ task = ledger.tasks.get(_required(context, "task_id"))
|
|
|
|
|
+ expected_input_ref = f"script-build://inputs/{binding.input_snapshot_id}"
|
|
|
|
|
+ if task is None or {
|
|
|
|
|
+ item
|
|
|
|
|
+ for item in task.current_spec.context_refs
|
|
|
|
|
+ if item.startswith("script-build://inputs/")
|
|
|
|
|
+ } != {expected_input_ref}:
|
|
|
|
|
+ raise ProtocolViolation("current task is not bound to the frozen InputSnapshot")
|
|
|
|
|
+ matches: list[dict[str, Any]] = []
|
|
|
|
|
+ for item in snapshot.strategies:
|
|
|
|
|
+ expected = (
|
|
|
|
|
+ f"script-build://strategies/{item.get('strategy_id')}"
|
|
|
|
|
+ f"/versions/{item.get('version')}"
|
|
|
|
|
+ )
|
|
|
|
|
+ if expected == strategy_ref and item.get("mode") == "on_demand":
|
|
|
|
|
+ matches.append(item)
|
|
|
|
|
+ if len(matches) != 1:
|
|
|
|
|
+ raise ProtocolViolation("strategy_ref is outside the frozen on-demand strategies")
|
|
|
|
|
+ item = matches[0]
|
|
|
|
|
+ content = item.get("content")
|
|
|
|
|
+ digest = item.get("content_sha256")
|
|
|
|
|
+ if not isinstance(content, str) or canonical_sha256(content).wire != digest:
|
|
|
|
|
+ raise ProtocolViolation("frozen on-demand strategy digest does not match")
|
|
|
|
|
+ return cast(dict[str, Any], _json_values(item))
|
|
|
|
|
+
|
|
|
async def read_accepted_artifacts(self, context: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
async def read_accepted_artifacts(self, context: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
|
binding, _ = await self._scope(context)
|
|
binding, _ = await self._scope(context)
|
|
|
ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
@@ -164,6 +285,43 @@ class LegacyScriptToolGateway:
|
|
|
tool_name: str,
|
|
tool_name: str,
|
|
|
query: Mapping[str, Any],
|
|
query: Mapping[str, Any],
|
|
|
context: Mapping[str, Any],
|
|
context: Mapping[str, Any],
|
|
|
|
|
+ ) -> dict[str, Any]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
|
|
+ if self.budget_guard is not None:
|
|
|
|
|
+ await self.budget_guard.ensure_tool_budget(
|
|
|
|
|
+ root_trace_id=_required(context, "root_trace_id"),
|
|
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
|
|
+ entry="retrieval",
|
|
|
|
|
+ )
|
|
|
|
|
+ root_trace_id = _required(context, "root_trace_id")
|
|
|
|
|
+ task_id = _required(context, "task_id")
|
|
|
|
|
+ attempt_id = _required(context, "attempt_id")
|
|
|
|
|
+ retrieval_key = (root_trace_id, task_id, attempt_id)
|
|
|
|
|
+ if retrieval_key in self._active_retrieval_attempts:
|
|
|
|
|
+ raise ProtocolViolation("one Retrieval Attempt may execute only one external query")
|
|
|
|
|
+ binding = await self.bindings.get_by_root(root_trace_id)
|
|
|
|
|
+ try:
|
|
|
|
|
+ await self.artifacts.get_by_attempt(
|
|
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
|
|
+ task_id=task_id,
|
|
|
|
|
+ attempt_id=attempt_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ except ArtifactNotFound:
|
|
|
|
|
+ pass
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise ProtocolViolation("one Retrieval Attempt may freeze only one Evidence artifact")
|
|
|
|
|
+ self._active_retrieval_attempts.add(retrieval_key)
|
|
|
|
|
+ try:
|
|
|
|
|
+ return await self._retrieve_once(source_type, tool_name, query, context)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self._active_retrieval_attempts.discard(retrieval_key)
|
|
|
|
|
+
|
|
|
|
|
+ async def _retrieve_once(
|
|
|
|
|
+ self,
|
|
|
|
|
+ source_type: str,
|
|
|
|
|
+ tool_name: str,
|
|
|
|
|
+ query: Mapping[str, Any],
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
) -> dict[str, Any]:
|
|
) -> dict[str, Any]:
|
|
|
binding, snapshot = await self._scope(context)
|
|
binding, snapshot = await self._scope(context)
|
|
|
if source_type == "decode" and query.get("account_name"):
|
|
if source_type == "decode" and query.get("account_name"):
|
|
@@ -223,6 +381,267 @@ class LegacyScriptToolGateway:
|
|
|
_, snapshot = await self._scope(context)
|
|
_, snapshot = await self._scope(context)
|
|
|
return await self.image_adapter.load(urls=urls, snapshot=snapshot)
|
|
return await self.image_adapter.load(urls=urls, snapshot=snapshot)
|
|
|
|
|
|
|
|
|
|
+ async def view_frozen_images(
|
|
|
|
|
+ self, raw_artifact_refs: Sequence[str], context: Mapping[str, Any]
|
|
|
|
|
+ ) -> Sequence[Mapping[str, Any]]:
|
|
|
|
|
+ return await self._candidates().view_frozen_images(
|
|
|
|
|
+ raw_artifact_refs=raw_artifact_refs,
|
|
|
|
|
+ context=context,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def read_accepted_input_bundle(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._candidates().read_accepted_input_bundle(context=context)
|
|
|
|
|
+
|
|
|
|
|
+ async def read_active_frontier(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._candidates().read_active_frontier(context=context)
|
|
|
|
|
+
|
|
|
|
|
+ async def read_pinned_candidates(
|
|
|
|
|
+ self, context: Mapping[str, Any]
|
|
|
|
|
+ ) -> Sequence[Mapping[str, Any]]:
|
|
|
|
|
+ return await self._candidates().read_pinned_candidates(context=context)
|
|
|
|
|
+
|
|
|
|
|
+ async def read_attempt_workspace(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
+ return await self._candidates().read_attempt_workspace(context=context)
|
|
|
|
|
+
|
|
|
|
|
+ async def candidate_command(
|
|
|
|
|
+ self,
|
|
|
|
|
+ operation: str,
|
|
|
|
|
+ payload: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
|
|
+ tools = self._candidates()
|
|
|
|
|
+ if operation == "create_script_paragraph":
|
|
|
|
|
+ return await tools.create_script_paragraph(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "append_paragraph_atoms":
|
|
|
|
|
+ return await tools.append_paragraph_atoms(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "delete_paragraph_atom":
|
|
|
|
|
+ return await tools.delete_paragraph_atom(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "batch_update_script_paragraphs":
|
|
|
|
|
+ return await tools.batch_update_script_paragraphs(
|
|
|
|
|
+ updates=_sequence_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "create_script_element":
|
|
|
|
|
+ return await tools.create_script_element(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "update_script_element":
|
|
|
|
|
+ return await tools.update_script_element(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "batch_link_paragraph_elements":
|
|
|
|
|
+ return await tools.batch_link_paragraph_elements(
|
|
|
|
|
+ links=_sequence_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "delete_paragraph_element_links":
|
|
|
|
|
+ return await tools.delete_paragraph_element_links(
|
|
|
|
|
+ links=_sequence_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "save_comparison_candidate":
|
|
|
|
|
+ return await tools.save_comparison_candidate(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ if operation == "save_candidate_portfolio":
|
|
|
|
|
+ return await tools.save_candidate_portfolio(
|
|
|
|
|
+ payload=_mapping_payload(payload), context=context
|
|
|
|
|
+ )
|
|
|
|
|
+ raise ProtocolViolation(f"unsupported candidate operation: {operation}")
|
|
|
|
|
+
|
|
|
|
|
+ async def save_structured_script_candidate(
|
|
|
|
|
+ self, acceptance_notes: Sequence[str], context: Mapping[str, Any]
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
|
|
+ notes = tuple(_bounded_text(item, "acceptance note", 500) for item in acceptance_notes)
|
|
|
|
|
+ if len(notes) > 16:
|
|
|
|
|
+ raise ProtocolViolation("a StructuredScript may contain at most 16 acceptance notes")
|
|
|
|
|
+ return await self._candidates().save_structured_script_candidate(
|
|
|
|
|
+ acceptance_notes=notes,
|
|
|
|
|
+ context=context,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def submit_current_attempt(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
|
|
+ if self.budget_guard is not None:
|
|
|
|
|
+ await self.budget_guard.ensure_tool_budget(
|
|
|
|
|
+ root_trace_id=_required(context, "root_trace_id"),
|
|
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
|
|
+ entry="submit",
|
|
|
|
|
+ )
|
|
|
|
|
+ manifest = await self._candidates().resolve_attempt_manifest(context=context)
|
|
|
|
|
+ ref = manifest.artifact_ref
|
|
|
|
|
+ if ref.kind not in _BUSINESS_ARTIFACT_KINDS or not ref.digest:
|
|
|
|
|
+ raise ProtocolViolation("attempt manifest must contain one frozen business artifact")
|
|
|
|
|
+ binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
|
|
|
|
|
+ version = await self.artifacts.read_by_ref(
|
|
|
|
|
+ ref,
|
|
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
|
|
+ attempt_id=_required(context, "attempt_id"),
|
|
|
|
|
+ )
|
|
|
|
|
+ if (
|
|
|
|
|
+ version.artifact_type.value != ref.kind
|
|
|
|
|
+ or version.canonical_sha256 != ref.digest
|
|
|
|
|
+ or version.state.value != "frozen"
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ProtocolViolation("attempt artifact ownership or frozen digest is invalid")
|
|
|
|
|
+ safe_ref = ArtifactRef(
|
|
|
|
|
+ uri=ref.uri,
|
|
|
|
|
+ kind=ref.kind,
|
|
|
|
|
+ version=ref.version,
|
|
|
|
|
+ digest=ref.digest,
|
|
|
|
|
+ )
|
|
|
|
|
+ summary = _attempt_summary(safe_ref, manifest.scope_ref)
|
|
|
|
|
+ submission = AttemptSubmission(
|
|
|
|
|
+ summary=summary,
|
|
|
|
|
+ artifact_refs=([] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [safe_ref]),
|
|
|
|
|
+ evidence_refs=([safe_ref] if safe_ref.kind == ArtifactKind.EVIDENCE.value else []),
|
|
|
|
|
+ )
|
|
|
|
|
+ return cast(
|
|
|
|
|
+ dict[str, Any],
|
|
|
|
|
+ await self.coordinator.submit_attempt(dict(context), submission),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def submit_structured_validation(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ verdict: str,
|
|
|
|
|
+ criterion_results: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ defects: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ recommendation: str,
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ root_trace_id = _required(context, "root_trace_id")
|
|
|
|
|
+ task_id = _required(context, "task_id")
|
|
|
|
|
+ snapshot_id = _required(context, "snapshot_id")
|
|
|
|
|
+ ledger = await self.coordinator.task_store.load(root_trace_id)
|
|
|
|
|
+ task = ledger.tasks.get(task_id)
|
|
|
|
|
+ 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_refs = {
|
|
|
|
|
+ (ref.uri, ref.version, ref.digest, ref.kind): ref
|
|
|
|
|
+ for ref in (*snapshot.artifact_refs, *snapshot.evidence_refs)
|
|
|
|
|
+ }
|
|
|
|
|
+ normalized_defects = _normalize_defects(defects, allowed_refs)
|
|
|
|
|
+ expected_ids = {item.criterion_id for item in task.current_spec.acceptance_criteria}
|
|
|
|
|
+ if any(item["criterion_id"] not in expected_ids for item in normalized_defects):
|
|
|
|
|
+ raise ProtocolViolation("validation defect references an unknown Task criterion")
|
|
|
|
|
+ result_by_id: dict[str, CriterionResult] = {}
|
|
|
|
|
+ for raw in criterion_results:
|
|
|
|
|
+ criterion_id = _bounded_text(raw.get("criterion_id"), "criterion_id", 128)
|
|
|
|
|
+ if criterion_id not in expected_ids or criterion_id in result_by_id:
|
|
|
|
|
+ raise ProtocolViolation("validation criterion identity is unknown or duplicated")
|
|
|
|
|
+ criterion_verdict = ValidationVerdict(str(raw.get("verdict", "")))
|
|
|
|
|
+ reason = _bounded_text(raw.get("reason"), "criterion reason", 300)
|
|
|
|
|
+ defect_codes = tuple(
|
|
|
|
|
+ item["defect_code"]
|
|
|
|
|
+ for item in normalized_defects
|
|
|
|
|
+ if item["criterion_id"] == criterion_id
|
|
|
|
|
+ )
|
|
|
|
|
+ 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"]
|
|
|
|
|
+ )
|
|
|
|
|
+ result_by_id[criterion_id] = CriterionResult(
|
|
|
|
|
+ criterion_id=criterion_id,
|
|
|
|
|
+ verdict=criterion_verdict,
|
|
|
|
|
+ reason=reason,
|
|
|
|
|
+ evidence_refs=list(evidence),
|
|
|
|
|
+ )
|
|
|
|
|
+ if set(result_by_id) != expected_ids:
|
|
|
|
|
+ raise ProtocolViolation("validation must report every Task criterion exactly once")
|
|
|
|
|
+ overall = ValidationVerdict(verdict)
|
|
|
|
|
+ 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")
|
|
|
|
|
+ from agent.orchestration.validation_policy import ValidationContext
|
|
|
|
|
+
|
|
|
|
|
+ attempt = ledger.attempts.get(_required(context, "attempt_id"))
|
|
|
|
|
+ if attempt is None or attempt.task_id != task_id:
|
|
|
|
|
+ raise ProtocolViolation("validation Attempt is outside the protected Task")
|
|
|
|
|
+ validation_context = ValidationContext(
|
|
|
|
|
+ root_trace_id=root_trace_id,
|
|
|
|
|
+ task=task,
|
|
|
|
|
+ attempt=attempt,
|
|
|
|
|
+ snapshot=snapshot,
|
|
|
|
|
+ prior_validation_count=max(0, len(task.validation_ids) - 1),
|
|
|
|
|
+ )
|
|
|
|
|
+ deterministic = precheck_snapshot(validation_context)
|
|
|
|
|
+ if overall is ValidationVerdict.PASSED and any(
|
|
|
|
|
+ item.verdict is not ValidationVerdict.PASSED for item in deterministic
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ProtocolViolation("passed validation conflicts with deterministic precheck")
|
|
|
|
|
+ if (
|
|
|
|
|
+ overall is ValidationVerdict.PASSED
|
|
|
|
|
+ and task_kind(task.current_spec.context_refs) in PHASE_TWO_KINDS
|
|
|
|
|
+ ):
|
|
|
|
|
+ binding = await self.bindings.get_by_root(root_trace_id)
|
|
|
|
|
+ for ref in snapshot.artifact_refs:
|
|
|
|
|
+ version = await self.artifacts.read_by_ref(
|
|
|
|
|
+ ref,
|
|
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
|
|
+ task_id=task_id,
|
|
|
|
|
+ attempt_id=attempt.attempt_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ if precheck_business_artifact(version.artifact):
|
|
|
|
|
+ raise ProtocolViolation(
|
|
|
|
|
+ "passed validation conflicts with unrealized placeholder content"
|
|
|
|
|
+ )
|
|
|
|
|
+ business_rules = await self._candidates().deterministic_precheck(context=context)
|
|
|
|
|
+ if any(item.get("verdict") != "passed" for item in business_rules):
|
|
|
|
|
+ raise ProtocolViolation(
|
|
|
|
|
+ "passed validation conflicts with phase-two deterministic precheck"
|
|
|
|
|
+ )
|
|
|
|
|
+ if overall is not ValidationVerdict.PASSED and not normalized_defects:
|
|
|
|
|
+ raise ProtocolViolation("failed or inconclusive validation must report a defect")
|
|
|
|
|
+ if any(
|
|
|
|
|
+ result_by_id[item["criterion_id"]].verdict is ValidationVerdict.PASSED
|
|
|
|
|
+ for item in blocking
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ProtocolViolation("blocking defect conflicts with a passed criterion")
|
|
|
|
|
+ codes = tuple(dict.fromkeys(item["defect_code"] for item in normalized_defects))
|
|
|
|
|
+ scopes = tuple(dict.fromkeys(item["scope_ref"] for item in normalized_defects))
|
|
|
|
|
+ action_classes = tuple(
|
|
|
|
|
+ dict.fromkeys(item["recommended_action_class"] for item in normalized_defects)
|
|
|
|
|
+ )
|
|
|
|
|
+ summary = (
|
|
|
|
|
+ f"validation={overall.value};criteria={len(result_by_id)};"
|
|
|
|
|
+ f"defects={len(normalized_defects)};codes={','.join(codes) or 'none'};"
|
|
|
|
|
+ f"scopes={','.join(scopes) or 'none'};"
|
|
|
|
|
+ f"actions={','.join(action_classes) or 'none'}"
|
|
|
|
|
+ )[:500]
|
|
|
|
|
+ evidence_refs = _dedupe_refs(
|
|
|
|
|
+ ref for item in normalized_defects for ref in item["evidence_refs"]
|
|
|
|
|
+ )
|
|
|
|
|
+ bounded_recommendation = _bounded_text(
|
|
|
|
|
+ recommendation or (action_classes[0] if action_classes else "accept"),
|
|
|
|
|
+ "recommendation",
|
|
|
|
|
+ 200,
|
|
|
|
|
+ )
|
|
|
|
|
+ return cast(
|
|
|
|
|
+ dict[str, Any],
|
|
|
|
|
+ await self.coordinator.submit_validation(
|
|
|
|
|
+ dict(context),
|
|
|
|
|
+ overall,
|
|
|
|
|
+ tuple(result_by_id.values()),
|
|
|
|
|
+ summary,
|
|
|
|
|
+ evidence_refs,
|
|
|
|
|
+ (),
|
|
|
|
|
+ codes[:50],
|
|
|
|
|
+ bounded_recommendation,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
async def save_direction_candidate(
|
|
async def save_direction_candidate(
|
|
|
self,
|
|
self,
|
|
|
*,
|
|
*,
|
|
@@ -236,6 +655,7 @@ class LegacyScriptToolGateway:
|
|
|
legacy_markdown: str,
|
|
legacy_markdown: str,
|
|
|
context: Mapping[str, Any],
|
|
context: Mapping[str, Any],
|
|
|
) -> dict[str, Any]:
|
|
) -> dict[str, Any]:
|
|
|
|
|
+ await self.ensure_dispatch_allowed(context)
|
|
|
binding, _ = await self._scope(context)
|
|
binding, _ = await self._scope(context)
|
|
|
accepted = await self.read_accepted_artifacts(context)
|
|
accepted = await self.read_accepted_artifacts(context)
|
|
|
allowed_evidence = {item["artifact_ref"]["uri"] for item in accepted}
|
|
allowed_evidence = {item["artifact_ref"]["uri"] for item in accepted}
|
|
@@ -306,7 +726,11 @@ class LegacyScriptToolGateway:
|
|
|
async def deterministic_precheck(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
async def deterministic_precheck(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
from agent.orchestration.validation_policy import ValidationContext
|
|
from agent.orchestration.validation_policy import ValidationContext
|
|
|
|
|
|
|
|
- from script_build_host.agents.validation import precheck_snapshot
|
|
|
|
|
|
|
+ from script_build_host.agents.validation import (
|
|
|
|
|
+ PHASE_TWO_KINDS,
|
|
|
|
|
+ precheck_snapshot,
|
|
|
|
|
+ task_kind,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
root_trace_id = _required(context, "root_trace_id")
|
|
root_trace_id = _required(context, "root_trace_id")
|
|
|
ledger = await self.coordinator.task_store.load(root_trace_id)
|
|
ledger = await self.coordinator.task_store.load(root_trace_id)
|
|
@@ -322,7 +746,11 @@ class LegacyScriptToolGateway:
|
|
|
snapshot=snapshot,
|
|
snapshot=snapshot,
|
|
|
prior_validation_count=max(0, len(task.validation_ids) - 1),
|
|
prior_validation_count=max(0, len(task.validation_ids) - 1),
|
|
|
)
|
|
)
|
|
|
- return {"rule_results": [item.to_dict() for item in precheck_snapshot(validation_context)]}
|
|
|
|
|
|
|
+ rules = [item.to_dict() for item in precheck_snapshot(validation_context)]
|
|
|
|
|
+ kind = task_kind(task.current_spec.context_refs)
|
|
|
|
|
+ if kind in PHASE_TWO_KINDS:
|
|
|
|
|
+ rules.extend(await self._candidates().deterministic_precheck(context=context))
|
|
|
|
|
+ return {"rule_results": rules}
|
|
|
|
|
|
|
|
async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
|
|
async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
|
|
|
root_trace_id = _required(context, "root_trace_id")
|
|
root_trace_id = _required(context, "root_trace_id")
|
|
@@ -333,6 +761,16 @@ class LegacyScriptToolGateway:
|
|
|
)
|
|
)
|
|
|
return binding, snapshot
|
|
return binding, snapshot
|
|
|
|
|
|
|
|
|
|
+ def _planner(self) -> ScriptPlannerToolPort:
|
|
|
|
|
+ if self.planner_tools is None:
|
|
|
|
|
+ raise ProtocolViolation("script planner tools are not configured")
|
|
|
|
|
+ return self.planner_tools
|
|
|
|
|
+
|
|
|
|
|
+ def _candidates(self) -> ScriptCandidateToolPort:
|
|
|
|
|
+ if self.candidate_tools is None:
|
|
|
|
|
+ raise ProtocolViolation("script candidate tools are not configured")
|
|
|
|
|
+ return self.candidate_tools
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def _required(context: Mapping[str, Any], key: str) -> str:
|
|
def _required(context: Mapping[str, Any], key: str) -> str:
|
|
|
value = context.get(key)
|
|
value = context.get(key)
|
|
@@ -375,10 +813,124 @@ def _safe_text_tuple(values: Sequence[str]) -> tuple[str, ...]:
|
|
|
return tuple(redact_text(str(value)) for value in values)
|
|
return tuple(redact_text(str(value)) for value in values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _mapping_payload(
|
|
|
|
|
+ value: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
|
|
|
+) -> Mapping[str, Any]:
|
|
|
|
|
+ if not isinstance(value, Mapping):
|
|
|
|
|
+ raise ProtocolViolation("candidate command expects an object payload")
|
|
|
|
|
+ return value
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _sequence_payload(
|
|
|
|
|
+ value: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
|
|
|
+) -> Sequence[Mapping[str, Any]]:
|
|
|
|
|
+ if isinstance(value, Mapping) or isinstance(value, (str, bytes)):
|
|
|
|
|
+ raise ProtocolViolation("candidate batch command expects an array payload")
|
|
|
|
|
+ if any(not isinstance(item, Mapping) for item in value):
|
|
|
|
|
+ raise ProtocolViolation("candidate batch entries must be objects")
|
|
|
|
|
+ return value
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _bounded_text(value: Any, label: str, maximum: int) -> str:
|
|
|
|
|
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
|
|
|
+ raise ProtocolViolation(f"{label} must contain between 1 and {maximum} characters")
|
|
|
|
|
+ return redact_text(value.strip())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _attempt_summary(ref: ArtifactRef, scope_ref: str) -> str:
|
|
|
|
|
+ scope = _bounded_text(scope_ref, "scope_ref", 300)
|
|
|
|
|
+ if not scope.startswith("script-build://"):
|
|
|
|
|
+ raise ProtocolViolation("scope_ref must use the script-build namespace")
|
|
|
|
|
+ digest = _bounded_text(ref.digest, "artifact digest", 71)
|
|
|
|
|
+ return f"kind={ref.kind};scope={scope};digest={digest};status=frozen"[:500]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _normalize_defects(
|
|
|
|
|
+ defects: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ allowed_refs: Mapping[tuple[Any, Any, Any, Any], ArtifactRef],
|
|
|
|
|
+) -> tuple[dict[str, Any], ...]:
|
|
|
|
|
+ if len(defects) > 50:
|
|
|
|
|
+ raise ProtocolViolation("a validation may report at most 50 defects")
|
|
|
|
|
+ values: list[dict[str, Any]] = []
|
|
|
|
|
+ for raw in defects:
|
|
|
|
|
+ if not isinstance(raw, Mapping):
|
|
|
|
|
+ raise ProtocolViolation("each validation defect must be an object")
|
|
|
|
|
+ code = _bounded_text(raw.get("defect_code"), "defect_code", 64)
|
|
|
|
|
+ if not _DEFECT_CODE.fullmatch(code):
|
|
|
|
|
+ raise ProtocolViolation("defect_code must be an uppercase stable identifier")
|
|
|
|
|
+ criterion_id = _bounded_text(raw.get("criterion_id"), "criterion_id", 128)
|
|
|
|
|
+ scope_ref = _bounded_text(raw.get("scope_ref"), "scope_ref", 500)
|
|
|
|
|
+ if not scope_ref.startswith("script-build://"):
|
|
|
|
|
+ raise ProtocolViolation("defect scope_ref must use script-build://")
|
|
|
|
|
+ excerpt = _bounded_text(raw.get("observed_excerpt"), "observed_excerpt", 500)
|
|
|
|
|
+ severity = str(raw.get("severity", ""))
|
|
|
|
|
+ if severity not in _DEFECT_SEVERITIES:
|
|
|
|
|
+ raise ProtocolViolation("defect severity must be warning, hard, or critical")
|
|
|
|
|
+ action = str(raw.get("recommended_action_class", ""))
|
|
|
|
|
+ if action not in _DEFECT_ACTIONS:
|
|
|
|
|
+ raise ProtocolViolation("defect recommended action class is unsupported")
|
|
|
|
|
+ invalidated = raw.get("invalidated_inputs", [])
|
|
|
|
|
+ if (
|
|
|
|
|
+ not isinstance(invalidated, list)
|
|
|
|
|
+ or len(invalidated) > 64
|
|
|
|
|
+ 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)
|
|
|
|
|
+ values.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "defect_code": code,
|
|
|
|
|
+ "criterion_id": criterion_id,
|
|
|
|
|
+ "scope_ref": scope_ref,
|
|
|
|
|
+ "observed_excerpt": excerpt,
|
|
|
|
|
+ "evidence_refs": tuple(refs),
|
|
|
|
|
+ "severity": severity,
|
|
|
|
|
+ "invalidated_inputs": tuple(str(item) for item in invalidated),
|
|
|
|
|
+ "recommended_action_class": action,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ return tuple(values)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _dedupe_refs(values: Any) -> tuple[ArtifactRef, ...]:
|
|
|
|
|
+ result: list[ArtifactRef] = []
|
|
|
|
|
+ seen: set[tuple[Any, Any, Any, Any]] = set()
|
|
|
|
|
+ for ref in values:
|
|
|
|
|
+ key = (ref.uri, ref.version, ref.digest, ref.kind)
|
|
|
|
|
+ if key in seen:
|
|
|
|
|
+ continue
|
|
|
|
|
+ seen.add(key)
|
|
|
|
|
+ result.append(
|
|
|
|
|
+ ArtifactRef(
|
|
|
|
|
+ uri=ref.uri,
|
|
|
|
|
+ kind=ref.kind,
|
|
|
|
|
+ version=ref.version,
|
|
|
|
|
+ digest=ref.digest,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ return tuple(result)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
__all__ = [
|
|
__all__ = [
|
|
|
"DispatchGuard",
|
|
"DispatchGuard",
|
|
|
"ImageAdapter",
|
|
"ImageAdapter",
|
|
|
"LegacyScriptToolGateway",
|
|
"LegacyScriptToolGateway",
|
|
|
"RetrievalAdapter",
|
|
"RetrievalAdapter",
|
|
|
"RetrievalResult",
|
|
"RetrievalResult",
|
|
|
|
|
+ "ScriptCandidateToolPort",
|
|
|
|
|
+ "ScriptPlannerToolPort",
|
|
|
]
|
|
]
|