|
|
@@ -0,0 +1,384 @@
|
|
|
+"""Application gateway used by all script-build Agent tools.
|
|
|
+
|
|
|
+Agent-visible functions never receive a build, root, task, attempt, or
|
|
|
+validation identity from model arguments. Those values are resolved from the
|
|
|
+framework's protected context and then checked against durable bindings.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from collections.abc import Mapping, Sequence
|
|
|
+from dataclasses import asdict, dataclass
|
|
|
+from datetime import UTC, datetime
|
|
|
+from typing import Any, Protocol, cast
|
|
|
+from uuid import uuid4
|
|
|
+
|
|
|
+from agent.orchestration import ArtifactRef, EvidenceQuery
|
|
|
+
|
|
|
+from script_build_host.agents.validation import RETRIEVAL_KINDS, task_kind
|
|
|
+from script_build_host.domain.artifacts import (
|
|
|
+ ArtifactKind,
|
|
|
+ Criterion,
|
|
|
+ DirectionGoal,
|
|
|
+ EvidenceRecordV1,
|
|
|
+ ScriptDirectionArtifactV1,
|
|
|
+)
|
|
|
+from script_build_host.domain.errors import ProtocolViolation
|
|
|
+from script_build_host.domain.ports import (
|
|
|
+ InputSnapshotRepository,
|
|
|
+ MissionBindingRepository,
|
|
|
+ ScriptBusinessArtifactRepository,
|
|
|
+)
|
|
|
+from script_build_host.infrastructure.redaction import redact, redact_text
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class RetrievalResult:
|
|
|
+ source_refs: tuple[str, ...]
|
|
|
+ summary: str
|
|
|
+ supports: tuple[str, ...] = ()
|
|
|
+ confidence: str = "unknown"
|
|
|
+ limitations: tuple[str, ...] = ()
|
|
|
+ raw_artifact_ref: str | None = None
|
|
|
+ metadata: Mapping[str, Any] | None = None
|
|
|
+
|
|
|
+
|
|
|
+class RetrievalAdapter(Protocol):
|
|
|
+ async def retrieve(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ query: Mapping[str, Any],
|
|
|
+ snapshot: Any,
|
|
|
+ ) -> RetrievalResult: ...
|
|
|
+
|
|
|
+
|
|
|
+class ImageAdapter(Protocol):
|
|
|
+ async def load(self, *, urls: Sequence[str], snapshot: Any) -> tuple[dict[str, Any], ...]: ...
|
|
|
+
|
|
|
+
|
|
|
+class DispatchGuard(Protocol):
|
|
|
+ async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
|
|
|
+
|
|
|
+
|
|
|
+class LegacyScriptToolGateway:
|
|
|
+ """Translate protected Agent tool calls to typed domain operations."""
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ bindings: MissionBindingRepository,
|
|
|
+ snapshots: InputSnapshotRepository,
|
|
|
+ artifacts: ScriptBusinessArtifactRepository,
|
|
|
+ coordinator: Any,
|
|
|
+ retrieval_adapters: Mapping[str, RetrievalAdapter] | None = None,
|
|
|
+ image_adapter: ImageAdapter | None = None,
|
|
|
+ dispatch_guard: DispatchGuard | None = None,
|
|
|
+ ) -> None:
|
|
|
+ self.bindings = bindings
|
|
|
+ self.snapshots = snapshots
|
|
|
+ self.artifacts = artifacts
|
|
|
+ self.coordinator = coordinator
|
|
|
+ self.retrieval_adapters = dict(retrieval_adapters or {})
|
|
|
+ self.image_adapter = image_adapter
|
|
|
+ self.dispatch_guard = dispatch_guard
|
|
|
+
|
|
|
+ async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
|
|
|
+ binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
|
|
|
+ if self.dispatch_guard is not None:
|
|
|
+ await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
|
|
|
+
|
|
|
+ async def read_input_snapshot(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
+ binding, snapshot = await self._scope(context)
|
|
|
+ payload = asdict(snapshot)
|
|
|
+ payload["script_build_id"] = binding.script_build_id
|
|
|
+ return cast(dict[str, Any], _json_values(payload))
|
|
|
+
|
|
|
+ async def read_accepted_artifacts(self, context: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
|
+ binding, _ = await self._scope(context)
|
|
|
+ ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
|
+ attempt_id = _required(context, "attempt_id")
|
|
|
+ attempt = ledger.attempts.get(attempt_id)
|
|
|
+ if attempt is None or attempt.task_id != _required(context, "task_id"):
|
|
|
+ raise ProtocolViolation("attempt is outside the protected task context")
|
|
|
+ decision_ids = attempt.accepted_child_decision_ids
|
|
|
+ if decision_ids is None:
|
|
|
+ raise ProtocolViolation("attempt has no frozen child decision binding")
|
|
|
+ values: list[dict[str, Any]] = []
|
|
|
+ for decision_id in decision_ids:
|
|
|
+ decision = ledger.decisions.get(decision_id)
|
|
|
+ if (
|
|
|
+ decision is None
|
|
|
+ or decision.action.value != "accept"
|
|
|
+ or not decision.attempt_id
|
|
|
+ or not decision.validation_id
|
|
|
+ ):
|
|
|
+ raise ProtocolViolation("accepted child decision binding is invalid")
|
|
|
+ child_attempt = ledger.attempts[decision.attempt_id]
|
|
|
+ child_task = ledger.tasks[child_attempt.task_id]
|
|
|
+ validation = ledger.validations.get(decision.validation_id)
|
|
|
+ child_kind = task_kind(child_task.current_spec.context_refs)
|
|
|
+ if (
|
|
|
+ child_task.parent_task_id != attempt.task_id
|
|
|
+ or child_kind not in RETRIEVAL_KINDS
|
|
|
+ or decision.task_id != child_task.task_id
|
|
|
+ or validation is None
|
|
|
+ or validation.attempt_id != child_attempt.attempt_id
|
|
|
+ or validation.verdict is None
|
|
|
+ or validation.verdict.value != "passed"
|
|
|
+ or child_attempt.snapshot_id != validation.snapshot_id
|
|
|
+ ):
|
|
|
+ raise ProtocolViolation(
|
|
|
+ "accepted decision is not closed over a passed direct-child snapshot"
|
|
|
+ )
|
|
|
+ if child_attempt.submission is None:
|
|
|
+ raise ProtocolViolation("accepted child attempt has no submission")
|
|
|
+ refs = [
|
|
|
+ *child_attempt.submission.artifact_refs,
|
|
|
+ *child_attempt.submission.evidence_refs,
|
|
|
+ ]
|
|
|
+ for ref in refs:
|
|
|
+ if ref.kind != ArtifactKind.EVIDENCE.value:
|
|
|
+ raise ProtocolViolation(
|
|
|
+ "direction child decisions may expose only evidence artifacts"
|
|
|
+ )
|
|
|
+ version = await self.artifacts.read_by_ref(
|
|
|
+ ref,
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ task_id=child_attempt.task_id,
|
|
|
+ attempt_id=child_attempt.attempt_id,
|
|
|
+ )
|
|
|
+ values.append(
|
|
|
+ {
|
|
|
+ "decision_id": decision_id,
|
|
|
+ "task_id": child_attempt.task_id,
|
|
|
+ "attempt_id": child_attempt.attempt_id,
|
|
|
+ "artifact_ref": _ref_dict(ref),
|
|
|
+ "artifact": _json_values(asdict(version.artifact)),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return values
|
|
|
+
|
|
|
+ async def retrieve(
|
|
|
+ self,
|
|
|
+ source_type: str,
|
|
|
+ tool_name: str,
|
|
|
+ query: Mapping[str, Any],
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ binding, snapshot = await self._scope(context)
|
|
|
+ if source_type == "decode" and query.get("account_name"):
|
|
|
+ frozen_accounts = {
|
|
|
+ str(snapshot.account.get(key) or "")
|
|
|
+ for key in ("account_name", "resolved_account_name")
|
|
|
+ }
|
|
|
+ if query["account_name"] not in frozen_accounts:
|
|
|
+ raise ProtocolViolation("decode account_name is outside the frozen account scope")
|
|
|
+ adapter = self.retrieval_adapters.get(source_type)
|
|
|
+ if adapter is None:
|
|
|
+ raise ProtocolViolation(f"retrieval adapter is not configured: {source_type}")
|
|
|
+ result = await adapter.retrieve(query=dict(query), snapshot=snapshot)
|
|
|
+ frozen_query = redact(dict(query))
|
|
|
+ if not isinstance(frozen_query, dict):
|
|
|
+ raise ProtocolViolation("redacted retrieval query must remain an object")
|
|
|
+ if result.metadata:
|
|
|
+ safe_metadata = redact(dict(result.metadata))
|
|
|
+ if not isinstance(safe_metadata, dict):
|
|
|
+ raise ProtocolViolation("redacted retrieval metadata must remain an object")
|
|
|
+ frozen_query["_retrieval_metadata"] = safe_metadata
|
|
|
+ record = EvidenceRecordV1(
|
|
|
+ evidence_id=str(uuid4()),
|
|
|
+ source_type=source_type,
|
|
|
+ tool_name=tool_name,
|
|
|
+ query=frozen_query,
|
|
|
+ source_refs=_safe_text_tuple(result.source_refs),
|
|
|
+ raw_artifact_ref=(
|
|
|
+ redact_text(result.raw_artifact_ref) if result.raw_artifact_ref else None
|
|
|
+ ),
|
|
|
+ summary=redact_text(result.summary),
|
|
|
+ supports=_safe_text_tuple(result.supports),
|
|
|
+ confidence=redact_text(result.confidence),
|
|
|
+ limitations=_safe_text_tuple(result.limitations),
|
|
|
+ content_sha256="",
|
|
|
+ created_at=datetime.now(UTC),
|
|
|
+ )
|
|
|
+ version, ref = await self.artifacts.freeze(
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
+ attempt_id=_required(context, "attempt_id"),
|
|
|
+ spec_version=_required_int(context, "spec_version"),
|
|
|
+ artifact=record,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "artifact_ref": _ref_dict(ref),
|
|
|
+ "artifact_version_id": version.artifact_version_id,
|
|
|
+ "evidence": _json_values(asdict(version.artifact)),
|
|
|
+ "metadata": _json_values(redact(dict(result.metadata or {}))),
|
|
|
+ }
|
|
|
+
|
|
|
+ async def load_images(
|
|
|
+ self, urls: Sequence[str], context: Mapping[str, Any]
|
|
|
+ ) -> tuple[dict[str, Any], ...]:
|
|
|
+ if self.image_adapter is None:
|
|
|
+ raise ProtocolViolation("image adapter is not configured")
|
|
|
+ _, snapshot = await self._scope(context)
|
|
|
+ return await self.image_adapter.load(urls=urls, snapshot=snapshot)
|
|
|
+
|
|
|
+ async def save_direction_candidate(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ goals: Sequence[Mapping[str, Any]],
|
|
|
+ criteria: Sequence[Mapping[str, Any]],
|
|
|
+ domain_criteria: Sequence[Mapping[str, Any]],
|
|
|
+ topic_refs: Sequence[str],
|
|
|
+ persona_refs: Sequence[str],
|
|
|
+ strategy_refs: Sequence[str],
|
|
|
+ evidence_refs: Sequence[str],
|
|
|
+ legacy_markdown: str,
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ binding, _ = await self._scope(context)
|
|
|
+ accepted = await self.read_accepted_artifacts(context)
|
|
|
+ allowed_evidence = {item["artifact_ref"]["uri"] for item in accepted}
|
|
|
+ if any(ref not in allowed_evidence for ref in evidence_refs):
|
|
|
+ raise ProtocolViolation(
|
|
|
+ "direction references evidence outside frozen child ACCEPT decisions"
|
|
|
+ )
|
|
|
+ artifact = ScriptDirectionArtifactV1(
|
|
|
+ goals=tuple(
|
|
|
+ DirectionGoal(
|
|
|
+ goal_id=redact_text(str(item["goal_id"])),
|
|
|
+ statement=redact_text(str(item["statement"])),
|
|
|
+ rationale=redact_text(str(item.get("rationale", ""))),
|
|
|
+ )
|
|
|
+ for item in goals
|
|
|
+ ),
|
|
|
+ criteria=tuple(
|
|
|
+ Criterion(
|
|
|
+ redact_text(str(item["criterion_id"])),
|
|
|
+ redact_text(str(item["description"])),
|
|
|
+ )
|
|
|
+ for item in criteria
|
|
|
+ ),
|
|
|
+ domain_criteria=tuple(
|
|
|
+ Criterion(
|
|
|
+ redact_text(str(item["criterion_id"])),
|
|
|
+ redact_text(str(item["description"])),
|
|
|
+ )
|
|
|
+ for item in domain_criteria
|
|
|
+ ),
|
|
|
+ topic_refs=_safe_text_tuple(topic_refs),
|
|
|
+ persona_refs=_safe_text_tuple(persona_refs),
|
|
|
+ strategy_refs=_safe_text_tuple(strategy_refs),
|
|
|
+ evidence_refs=tuple(evidence_refs),
|
|
|
+ legacy_markdown=redact_text(legacy_markdown),
|
|
|
+ )
|
|
|
+ version, ref = await self.artifacts.freeze(
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
+ attempt_id=_required(context, "attempt_id"),
|
|
|
+ spec_version=_required_int(context, "spec_version"),
|
|
|
+ artifact=artifact,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "artifact_ref": _ref_dict(ref),
|
|
|
+ "artifact_version_id": version.artifact_version_id,
|
|
|
+ "direction": _json_values(version.artifact.content_payload()),
|
|
|
+ }
|
|
|
+
|
|
|
+ async def query_validation_evidence(
|
|
|
+ self,
|
|
|
+ query: str,
|
|
|
+ limit: int,
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ request = EvidenceQuery(
|
|
|
+ root_trace_id=_required(context, "root_trace_id"),
|
|
|
+ task_id=_required(context, "task_id"),
|
|
|
+ attempt_id=_required(context, "attempt_id"),
|
|
|
+ snapshot_id=_required(context, "snapshot_id"),
|
|
|
+ validation_id=_required(context, "validation_id"),
|
|
|
+ query=query,
|
|
|
+ limit=limit,
|
|
|
+ )
|
|
|
+ response = await self.coordinator.query_evidence(dict(context), request)
|
|
|
+ return cast(dict[str, Any], _json_values(asdict(response)))
|
|
|
+
|
|
|
+ async def deterministic_precheck(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
+ from agent.orchestration.validation_policy import ValidationContext
|
|
|
+
|
|
|
+ from script_build_host.agents.validation import precheck_snapshot
|
|
|
+
|
|
|
+ root_trace_id = _required(context, "root_trace_id")
|
|
|
+ ledger = await self.coordinator.task_store.load(root_trace_id)
|
|
|
+ task = ledger.tasks[_required(context, "task_id")]
|
|
|
+ attempt = ledger.attempts[_required(context, "attempt_id")]
|
|
|
+ snapshot = await self.coordinator.artifact_store.get(
|
|
|
+ root_trace_id, _required(context, "snapshot_id")
|
|
|
+ )
|
|
|
+ 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),
|
|
|
+ )
|
|
|
+ return {"rule_results": [item.to_dict() for item in precheck_snapshot(validation_context)]}
|
|
|
+
|
|
|
+ async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
|
|
|
+ root_trace_id = _required(context, "root_trace_id")
|
|
|
+ binding = await self.bindings.get_by_root(root_trace_id)
|
|
|
+ snapshot = await self.snapshots.get(
|
|
|
+ str(binding.input_snapshot_id),
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ )
|
|
|
+ return binding, snapshot
|
|
|
+
|
|
|
+
|
|
|
+def _required(context: Mapping[str, Any], key: str) -> str:
|
|
|
+ value = context.get(key)
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
+ raise ProtocolViolation(f"protected context is missing {key}")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _required_int(context: Mapping[str, Any], key: str) -> int:
|
|
|
+ value = context.get(key)
|
|
|
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
|
+ raise ProtocolViolation(f"protected context is missing {key}")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _ref_dict(ref: ArtifactRef) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "uri": ref.uri,
|
|
|
+ "kind": ref.kind,
|
|
|
+ "version": ref.version,
|
|
|
+ "digest": ref.digest,
|
|
|
+ "summary": ref.summary,
|
|
|
+ "metadata": ref.metadata,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _json_values(value: Any) -> Any:
|
|
|
+ if isinstance(value, datetime):
|
|
|
+ return value.isoformat()
|
|
|
+ if isinstance(value, Mapping):
|
|
|
+ return {str(key): _json_values(item) for key, item in value.items()}
|
|
|
+ if isinstance(value, (list, tuple)):
|
|
|
+ return [_json_values(item) for item in value]
|
|
|
+ if hasattr(value, "value"):
|
|
|
+ return value.value
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _safe_text_tuple(values: Sequence[str]) -> tuple[str, ...]:
|
|
|
+ return tuple(redact_text(str(value)) for value in values)
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "DispatchGuard",
|
|
|
+ "ImageAdapter",
|
|
|
+ "LegacyScriptToolGateway",
|
|
|
+ "RetrievalAdapter",
|
|
|
+ "RetrievalResult",
|
|
|
+]
|