Explorar el Código

主机:由工具网关生成 Goal ID 与创作闭包

Direction 使用 client_key 批量校验并生成稳定 Goal ID;Paragraph/Element 写入采用批量接口;Compose 由 Host 派生 Artifact ref、digest、scope、Goal Coverage 和完整 source closure。
SamLee hace 11 horas
padre
commit
f3c0522695

+ 122 - 38
script_build_host/src/script_build_host/tools/gateway.py

@@ -11,7 +11,9 @@ import re
 from collections.abc import Awaitable, Callable, Mapping, Sequence
 from collections.abc import Awaitable, Callable, Mapping, Sequence
 from dataclasses import asdict, dataclass
 from dataclasses import asdict, dataclass
 from datetime import UTC, datetime
 from datetime import UTC, datetime
+from hashlib import sha256
 from typing import Any, Protocol, cast
 from typing import Any, Protocol, cast
+from unicodedata import normalize
 from uuid import uuid4
 from uuid import uuid4
 
 
 from agent.orchestration import (
 from agent.orchestration import (
@@ -165,6 +167,7 @@ class LegacyScriptToolGateway:
         validation_id: str | None,
         validation_id: str | None,
         replacement_contract: Mapping[str, Any] | None,
         replacement_contract: Mapping[str, Any] | None,
         child_contracts: Sequence[Mapping[str, Any]],
         child_contracts: Sequence[Mapping[str, Any]],
+        selected_decision_ids: Sequence[str],
         context: Mapping[str, Any],
         context: Mapping[str, Any],
     ) -> Mapping[str, Any]:
     ) -> Mapping[str, Any]:
         return cast(
         return cast(
@@ -178,6 +181,7 @@ class LegacyScriptToolGateway:
                     validation_id=validation_id,
                     validation_id=validation_id,
                     replacement_contract=replacement_contract,
                     replacement_contract=replacement_contract,
                     child_contracts=child_contracts,
                     child_contracts=child_contracts,
+                    selected_decision_ids=selected_decision_ids,
                     context=context,
                     context=context,
                 ),
                 ),
             ),
             ),
@@ -617,9 +621,23 @@ class LegacyScriptToolGateway:
         if task is None:
         if task is None:
             raise ProtocolViolation("validation task is outside the protected mission")
             raise ProtocolViolation("validation task is outside the protected mission")
         snapshot = await self.coordinator.artifact_store.get(root_trace_id, snapshot_id)
         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 = {
         allowed_refs = {
             (ref.uri, ref.version, ref.digest, ref.kind): ref
             (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)
         normalized_defects = _normalize_defects(defects, allowed_refs)
         expected_ids = {item.criterion_id for item in task.current_spec.acceptance_criteria}
         expected_ids = {item.criterion_id for item in task.current_spec.acceptance_criteria}
@@ -639,12 +657,13 @@ class LegacyScriptToolGateway:
             )
             )
             if defect_codes:
             if defect_codes:
                 reason = f"{reason}; defect_codes={','.join(defect_codes)}"[:500]
                 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(
             result_by_id[criterion_id] = CriterionResult(
                 criterion_id=criterion_id,
                 criterion_id=criterion_id,
                 verdict=criterion_verdict,
                 verdict=criterion_verdict,
@@ -657,6 +676,12 @@ class LegacyScriptToolGateway:
         blocking = [item for item in normalized_defects if item["severity"] in {"hard", "critical"}]
         blocking = [item for item in normalized_defects if item["severity"] in {"hard", "critical"}]
         if overall is ValidationVerdict.PASSED and blocking:
         if overall is ValidationVerdict.PASSED and blocking:
             raise ProtocolViolation("passed validation cannot contain hard or critical defects")
             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
         from agent.orchestration.validation_policy import ValidationContext
 
 
         attempt = ledger.attempts.get(_required(context, "attempt_id"))
         attempt = ledger.attempts.get(_required(context, "attempt_id"))
@@ -767,23 +792,10 @@ class LegacyScriptToolGateway:
             raise ProtocolViolation(
             raise ProtocolViolation(
                 "direction references evidence outside frozen child ACCEPT decisions"
                 "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(
         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(
             constraints=tuple(
                 DirectionConstraint(
                 DirectionConstraint(
                     constraint_id=redact_text(str(item["constraint_id"])),
                     constraint_id=redact_text(str(item["constraint_id"])),
@@ -817,6 +829,7 @@ class LegacyScriptToolGateway:
             "artifact_ref": _ref_dict(ref),
             "artifact_ref": _ref_dict(ref),
             "artifact_version_id": version.artifact_version_id,
             "artifact_version_id": version.artifact_version_id,
             "direction": _json_values(version.artifact.content_payload()),
             "direction": _json_values(version.artifact.content_payload()),
+            "goal_ids_by_client_key": goal_ids_by_client_key,
         }
         }
 
 
     async def query_validation_evidence(
     async def query_validation_evidence(
@@ -1001,6 +1014,68 @@ def _bounded_text(value: Any, label: str, maximum: int) -> str:
     return redact_text(value.strip())
     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:
 def _attempt_summary(ref: ArtifactRef, scope_ref: str) -> str:
     scope = _bounded_text(scope_ref, "scope_ref", 300)
     scope = _bounded_text(scope_ref, "scope_ref", 300)
     if not scope.startswith("script-build://"):
     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)
             or any(not isinstance(item, str) or not item.strip() for item in invalidated)
         ):
         ):
             raise ProtocolViolation("invalidated_inputs must be a bounded string array")
             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(
         values.append(
             {
             {
                 "defect_code": code,
                 "defect_code": code,
                 "criterion_id": criterion_id,
                 "criterion_id": criterion_id,
                 "scope_ref": scope_ref,
                 "scope_ref": scope_ref,
                 "observed_excerpt": excerpt,
                 "observed_excerpt": excerpt,
-                "evidence_refs": tuple(refs),
+                "evidence_refs": refs,
                 "severity": severity,
                 "severity": severity,
                 "invalidated_inputs": tuple(str(item) for item in invalidated),
                 "invalidated_inputs": tuple(str(item) for item in invalidated),
                 "recommended_action_class": action,
                 "recommended_action_class": action,
@@ -1070,6 +1133,27 @@ def _normalize_defects(
     return tuple(values)
     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, ...]:
 def _dedupe_refs(values: Any) -> tuple[ArtifactRef, ...]:
     result: list[ArtifactRef] = []
     result: list[ArtifactRef] = []
     seen: set[tuple[Any, Any, Any, Any]] = set()
     seen: set[tuple[Any, Any, Any, Any]] = set()

+ 49 - 0
script_build_host/tests/test_direction_artifact.py

@@ -11,6 +11,7 @@ from script_build_host.domain.artifacts import (
 )
 )
 from script_build_host.domain.errors import ProtocolViolation
 from script_build_host.domain.errors import ProtocolViolation
 from script_build_host.repositories.sqlalchemy import _hydrate_artifact
 from script_build_host.repositories.sqlalchemy import _hydrate_artifact
+from script_build_host.tools.gateway import _direction_goals
 from script_build_host.tools.registry import _save_direction_candidate_schema
 from script_build_host.tools.registry import _save_direction_candidate_schema
 
 
 EVIDENCE_REF = "script-build://artifact-versions/1"
 EVIDENCE_REF = "script-build://artifact-versions/1"
@@ -100,6 +101,54 @@ def test_direction_tool_contract_keeps_schema_name_and_removes_model_markdown()
     assert "criteria" not in parameters["properties"]
     assert "criteria" not in parameters["properties"]
 
 
 
 
+def _client_goal(key: str, parent: str | None = None) -> dict[str, object]:
+    return {
+        "client_key": key,
+        "parent_client_key": parent,
+        "statement": f"statement for {key}",
+        "rationale": f"rationale for {key}",
+        "success_criteria": [f"criterion for {key}"],
+    }
+
+
+def test_direction_client_keys_generate_stable_order_independent_goal_ids() -> None:
+    first, first_map = _direction_goals(
+        "task-a", [_client_goal("Parent"), _client_goal("Child", "Parent")]
+    )
+    second, second_map = _direction_goals(
+        "task-a", [_client_goal(" child ", " parent "), _client_goal("parent")]
+    )
+
+    assert first_map == second_map
+    assert {item.goal_id for item in first} == {item.goal_id for item in second}
+    child = next(item for item in second if item.parent_goal_id)
+    assert child.parent_goal_id == first_map["parent"]
+
+
+@pytest.mark.parametrize(
+    ("goals", "message"),
+    [
+        ([_client_goal("same"), _client_goal("SAME")], "must be unique"),
+        ([_client_goal("child", "missing")], "does not exist"),
+        ([_client_goal("self", "self")], "cannot parent itself"),
+        (
+            [
+                _client_goal("root"),
+                _client_goal("child", "root"),
+                _client_goal("grandchild", "child"),
+            ],
+            "only two levels",
+        ),
+        ([_client_goal(str(index)) for index in range(31)], "between one and thirty"),
+    ],
+)
+def test_direction_client_keys_reject_invalid_batches(
+    goals: list[dict[str, object]], message: str
+) -> None:
+    with pytest.raises(ValueError, match=message):
+        _direction_goals("task-a", goals)
+
+
 def test_direction_hydration_rejects_historical_structure_without_compatibility() -> None:
 def test_direction_hydration_rejects_historical_structure_without_compatibility() -> None:
     historical_payload = {
     historical_payload = {
         "schema_version": "script-direction/v1",
         "schema_version": "script-direction/v1",

+ 22 - 6
script_build_host/tests/test_phase_two_agent_tools.py

@@ -2,20 +2,21 @@ from __future__ import annotations
 
 
 import base64
 import base64
 from collections.abc import Mapping
 from collections.abc import Mapping
+from dataclasses import asdict
 from types import SimpleNamespace
 from types import SimpleNamespace
 from typing import Any
 from typing import Any
 
 
 import pytest
 import pytest
+from agent import ToolRegistry
 from agent.orchestration import ArtifactRef, ValidationVerdict
 from agent.orchestration import ArtifactRef, ValidationVerdict
 from agent.orchestration.evidence import EvidenceResponse
 from agent.orchestration.evidence import EvidenceResponse
+
 from script_build_host.domain.artifacts import ArtifactState
 from script_build_host.domain.artifacts import ArtifactState
 from script_build_host.domain.errors import ArtifactNotFound, ProtocolViolation
 from script_build_host.domain.errors import ArtifactNotFound, ProtocolViolation
 from script_build_host.tools.contracts import AttemptManifest
 from script_build_host.tools.contracts import AttemptManifest
 from script_build_host.tools.gateway import LegacyScriptToolGateway, RetrievalResult
 from script_build_host.tools.gateway import LegacyScriptToolGateway, RetrievalResult
 from script_build_host.tools.registry import register_script_tools
 from script_build_host.tools.registry import register_script_tools
 
 
-from agent import ToolRegistry
-
 
 
 class _CandidateTools:
 class _CandidateTools:
     def __init__(self, artifact_ref: ArtifactRef) -> None:
     def __init__(self, artifact_ref: ArtifactRef) -> None:
@@ -387,7 +388,12 @@ async def test_structured_validation_keeps_excerpt_out_of_framework_summary() ->
     await gateway.submit_structured_validation(
     await gateway.submit_structured_validation(
         verdict="failed",
         verdict="failed",
         criterion_results=[
         criterion_results=[
-            {"criterion_id": "criterion-a", "verdict": "failed", "reason": "not realized"}
+            {
+                "criterion_id": "criterion-a",
+                "verdict": "failed",
+                "reason": "not realized",
+                "evidence_refs": [],
+            }
         ],
         ],
         defects=[
         defects=[
             {
             {
@@ -455,7 +461,12 @@ async def test_passed_validation_rejects_blocking_defect_and_unfrozen_evidence()
         await gateway.submit_structured_validation(
         await gateway.submit_structured_validation(
             verdict="passed",
             verdict="passed",
             criterion_results=[
             criterion_results=[
-                {"criterion_id": "criterion-a", "verdict": "passed", "reason": "ok"}
+                {
+                    "criterion_id": "criterion-a",
+                    "verdict": "passed",
+                    "reason": "ok",
+                    "evidence_refs": [asdict(ref)],
+                }
             ],
             ],
             defects=[defect],
             defects=[defect],
             recommendation="replace",
             recommendation="replace",
@@ -471,11 +482,16 @@ async def test_passed_validation_rejects_blocking_defect_and_unfrozen_evidence()
             "digest": "sha256:" + "b" * 64,
             "digest": "sha256:" + "b" * 64,
         }
         }
     ]
     ]
-    with pytest.raises(ProtocolViolation, match="fixed validation snapshot"):
+    with pytest.raises(ProtocolViolation, match="protected validation closure"):
         await gateway.submit_structured_validation(
         await gateway.submit_structured_validation(
             verdict="passed",
             verdict="passed",
             criterion_results=[
             criterion_results=[
-                {"criterion_id": "criterion-a", "verdict": "passed", "reason": "ok"}
+                {
+                    "criterion_id": "criterion-a",
+                    "verdict": "passed",
+                    "reason": "ok",
+                    "evidence_refs": [asdict(ref)],
+                }
             ],
             ],
             defects=[defect],
             defects=[defect],
             recommendation="accept",
             recommendation="accept",

+ 6 - 0
script_build_host/tests/test_phase_two_workspace.py

@@ -622,6 +622,12 @@ async def test_run_454_shape_roundtrips_without_branch_zero(database) -> None:
         structured_script={
         structured_script={
             "direction_ref": "script-build://artifact-versions/999",
             "direction_ref": "script-build://artifact-versions/999",
             "source_artifact_refs": ["script-build://artifact-versions/888"],
             "source_artifact_refs": ["script-build://artifact-versions/888"],
+            "goal_coverage": [
+                {
+                    "goal_id": "goal-1",
+                    "source_artifact_refs": ["script-build://artifact-versions/888"],
+                }
+            ],
             "evidence_refs": ["script-build://artifact-versions/777"],
             "evidence_refs": ["script-build://artifact-versions/777"],
             "acceptance_notes": ["legacy detail shape preserved"],
             "acceptance_notes": ["legacy detail shape preserved"],
         },
         },