فهرست منبع

本地化并封印远程正式 Artifact

Executor 允许模型在 Candidate 中引用真实远程产物,但必须通过工具证据唯一解析到当前 Run 的本地副本;正式 Delivery 只保存本地 Artifact.uri,并把原始地址降为 source_uri。

统一完整性复验只接受本地封印路径,继续校验 SHA-256、文件大小和 MIME;新增远程 URI 拒绝、本地映射和同 URL 内容替换对抗测试,避免终态验收依赖可变网络位置。
SamLee 4 روز پیش
والد
کامیت
ae7abf4932

+ 48 - 17
production_build_agents/agents/executor/agent.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import json
 from pathlib import Path
 from typing import Any
+from urllib.parse import urlparse
 
 from langchain.agents import create_agent
 from langchain_core.language_models import BaseChatModel
@@ -50,7 +51,8 @@ from ...run.records import (
 )
 from ...run.artifacts import (
     content_identity_for_bytes,
-    content_identity_for_uri,
+    content_identity_for_path,
+    resolve_candidate_artifact_path,
     verify_delivery_artifacts,
 )
 from ...tools.registry import (
@@ -523,19 +525,36 @@ def _build_artifacts(
     records: list[ToolCallRecord],
 ) -> list[Artifact]:
     if candidate.artifacts:
-        return [
-            Artifact(
-                artifact_id=(
-                    f"{task.task_id}-v{task.plan_version}-artifact-{index}"
-                ),
-                artifact_type=item.artifact_type,
-                uri=item.uri,
-                source_uri=item.source_uri,
-                description=item.description,
-                **content_identity_for_uri(item.uri, records),
+        artifacts: list[Artifact] = []
+        for index, item in enumerate(candidate.artifacts, start=1):
+            local_path = resolve_candidate_artifact_path(item.uri, records)
+            remote_uri = (
+                item.uri
+                if urlparse(item.uri).scheme in {"http", "https"}
+                else None
             )
-            for index, item in enumerate(candidate.artifacts, start=1)
-        ]
+            if (
+                remote_uri is not None
+                and item.source_uri is not None
+                and item.source_uri != remote_uri
+            ):
+                raise ExecutorOutputError(
+                    "远程候选 Artifact 的 source_uri 不得声明另一份内容;"
+                    "参考来源应保存在 Finding 或工具记录中"
+                )
+            artifacts.append(
+                Artifact(
+                    artifact_id=(
+                        f"{task.task_id}-v{task.plan_version}-artifact-{index}"
+                    ),
+                    artifact_type=item.artifact_type,
+                    uri=str(local_path),
+                    source_uri=remote_uri or item.source_uri,
+                    description=item.description,
+                    **content_identity_for_path(local_path),
+                )
+            )
+        return artifacts
     artifact_type = {
         "structured_data": "structured_data",
         "document": "document",
@@ -572,9 +591,21 @@ def _build_artifact_expectation_bindings(
     candidate: ExecutorCandidate,
     artifacts: list[Artifact],
 ) -> list[ArtifactExpectationBinding]:
-    artifact_ids_by_uri = {
-        item.uri: item.artifact_id for item in artifacts
-    }
+    artifact_ids_by_uri = (
+        {
+            candidate_item.uri: artifact.artifact_id
+            for candidate_item, artifact in zip(
+                candidate.artifacts,
+                artifacts,
+                strict=True,
+            )
+        }
+        if candidate.artifacts
+        else {}
+    )
+    artifact_ids_by_uri.update(
+        {item.uri: item.artifact_id for item in artifacts}
+    )
     if not candidate.artifacts and artifacts:
         artifact_ids_by_uri[
             Path(artifacts[0].uri).name
@@ -738,5 +769,5 @@ def run_executor_agent(
         )
         if artifact_path.resolve() != expected_artifact_path.resolve():
             raise ExecutorOutputError("结构化 Artifact 落盘路径与 Delivery 不一致")
-    verify_delivery_artifacts(delivery.artifacts, delivery.tool_calls)
+    verify_delivery_artifacts(delivery.artifacts)
     return delivery

+ 3 - 1
production_build_agents/agents/executor/skills/image-production/SKILL.md

@@ -10,7 +10,9 @@ Task 决定,本 Skill 不绑定 Global Data 或正式生产阶段。
 1. 需要远程能力时用 search_tool 发现当前授权的图片工具;
 2. 用 inspect_tool 获取真实入参,再调用 run_tool;
 3. 按任务需要使用 crop_image、grid_collage 或 overlay_text 加工已有图片;
-4. artifacts 只能填写本次输入、依赖或工具真实产生的图片 URI;
+4. artifacts 只能填写本次输入、依赖或工具真实产生的图片 URI;远程 URI 只存在于
+   Candidate,Runtime 会把工具记录中的本地副本封印为正式 Artifact.uri,并保留
+   原地址为 source_uri;
 5. 用 view_images 查看每个最终图片 Artifact;
 6. 对照 selected_expectations 自检后,只输出 ExecutorCandidate JSON。
 

+ 3 - 1
production_build_agents/agents/executor/skills/video-production/SKILL.md

@@ -10,7 +10,9 @@ Skill 不绑定 Global Data 或正式生产阶段。
 1. 需要远程生成时用 search_tool 发现当前授权的视频工具;
 2. 用 inspect_tool 获取真实入参,再调用 run_tool;
 3. 按任务需要使用 video_trim、video_concat 或 video_mux_audio 加工视频;
-4. artifacts 只能填写本次输入、依赖或工具真实产生的视频 URI;
+4. artifacts 只能填写本次输入、依赖或工具真实产生的视频 URI;远程 URI 只存在于
+   Candidate,Runtime 会把工具记录中的本地副本封印为正式 Artifact.uri,并保留
+   原地址为 source_uri;
 5. 用 extract_frames 对每个最终视频 Artifact 抽帧;
 6. 用 view_images 查看全部抽取帧,再对照 selected_expectations 自检;
 7. 只输出 ExecutorCandidate JSON。

+ 1 - 1
production_build_agents/agents/validator/task_context.py

@@ -249,7 +249,7 @@ def build_validator_user_message(
     """只传最终产物和显式证据,不传 Executor 的对话或自评。"""
 
     try:
-        verify_delivery_artifacts(delivery.artifacts, delivery.tool_calls)
+        verify_delivery_artifacts(delivery.artifacts)
     except ArtifactIntegrityError as exc:
         raise ValidatorContextError(str(exc)) from exc
     evidence, image_blocks = _media_evidence(

+ 1 - 4
production_build_agents/global_data_stage_assembly.py

@@ -136,10 +136,7 @@ def _read_task_evidence(
                 + ";".join(item.message for item in delivery_issues)
             )
         try:
-            verify_delivery_artifacts(
-                delivery.artifacts,
-                delivery.tool_calls,
-            )
+            verify_delivery_artifacts(delivery.artifacts)
         except ArtifactIntegrityError as exc:
             raise GlobalDataStageError(str(exc)) from exc
         report_issues = evaluate_validation_report(

+ 11 - 24
production_build_agents/run/artifacts.py

@@ -15,10 +15,12 @@ class ArtifactIntegrityError(ValueError):
     """正式 Artifact 缺失、无法定位或内容与 Delivery 不一致。"""
 
 
-def _local_path_for_uri(
+def resolve_candidate_artifact_path(
     uri: str,
     tool_calls: Iterable[ToolCallRecord],
-) -> Path | None:
+) -> Path:
+    """把候选 URI 解析为正式、可封印的本地文件路径。"""
+
     if urlparse(uri).scheme not in {"http", "https"}:
         return Path(uri).resolve()
     mapped = {
@@ -28,7 +30,9 @@ def _local_path_for_uri(
         if mapping.source == uri
     }
     if not mapped:
-        return None
+        raise ArtifactIntegrityError(
+            f"远程 Artifact 缺少可封印的本地内容:{uri}"
+        )
     if len(mapped) != 1:
         raise ArtifactIntegrityError(
             f"远程 Artifact 对应多个本地内容:{uri}"
@@ -71,31 +75,16 @@ def content_identity_for_path(path: Path) -> dict[str, str | int | None]:
     }
 
 
-def content_identity_for_uri(
-    uri: str,
-    tool_calls: Iterable[ToolCallRecord],
-) -> dict[str, str | int | None]:
-    """封印正式 Artifact;远程 URI 必须有本地工具映射。"""
-
-    path = _local_path_for_uri(uri, tool_calls)
-    if path is None:
-        raise ArtifactIntegrityError(
-            f"远程 Artifact 缺少可封印的本地内容:{uri}"
-        )
-    return content_identity_for_path(path)
-
-
 def verify_artifact(
     artifact: Artifact,
-    tool_calls: Iterable[ToolCallRecord],
 ) -> None:
     """根据 Delivery 中保存的身份复验同一份 Artifact 内容。"""
 
-    path = _local_path_for_uri(artifact.uri, tool_calls)
-    if path is None:
+    if urlparse(artifact.uri).scheme in {"http", "https"}:
         raise ArtifactIntegrityError(
-            f"远程 Artifact 缺少可复验的本地映射:{artifact.artifact_id}"
+            f"正式 Artifact.uri 必须是本地封印路径:{artifact.artifact_id}"
         )
+    path = Path(artifact.uri).resolve()
     actual = content_identity_for_path(path)
     if (
         artifact.content_sha256 != actual["content_sha256"]
@@ -115,10 +104,8 @@ def verify_artifact(
 
 def verify_delivery_artifacts(
     artifacts: Iterable[Artifact],
-    tool_calls: Iterable[ToolCallRecord],
 ) -> None:
     """在各运行边界复用同一套正式 Artifact 完整性规则。"""
 
-    records = tuple(tool_calls)
     for artifact in artifacts:
-        verify_artifact(artifact, records)
+        verify_artifact(artifact)

+ 13 - 2
tests/agents/test_executor.py

@@ -1057,7 +1057,14 @@ class GeneralExecutorTest(unittest.TestCase):
                 filename="agent_checkpoints.sqlite",
             ) as checkpointer:
                 messages = checkpoint_messages(checkpointer, config)
-        self.assertEqual(delivery.artifacts[0].uri, IMAGE_URL)
+        self.assertEqual(
+            Path(delivery.artifacts[0].uri),
+            _fake_media_path(IMAGE_URL, ".png").resolve(),
+        )
+        self.assertEqual(delivery.artifacts[0].source_uri, IMAGE_URL)
+        self.assertFalse(
+            delivery.artifacts[0].uri.startswith(("http://", "https://"))
+        )
         self.assertEqual(delivery.tool_calls[-1].tool_name, "view_images")
         self.assertTrue(
             any(
@@ -1241,7 +1248,11 @@ class GeneralExecutorTest(unittest.TestCase):
                 filename="agent_checkpoints.sqlite",
             ) as checkpointer:
                 messages = checkpoint_messages(checkpointer, config)
-        self.assertEqual(delivery.artifacts[0].uri, VIDEO_URL)
+        self.assertEqual(
+            Path(delivery.artifacts[0].uri),
+            _fake_media_path(VIDEO_URL, ".mp4").resolve(),
+        )
+        self.assertEqual(delivery.artifacts[0].source_uri, VIDEO_URL)
         self.assertEqual(delivery.tool_calls[-1].tool_name, "view_images")
         self.assertTrue(
             any(

+ 129 - 0
tests/run/test_artifacts.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from io import BytesIO
+import tempfile
+import threading
+import unittest
+from pathlib import Path
+from urllib.request import urlopen
+
+from PIL import Image
+
+from production_build_agents.contracts.models import (
+    Artifact,
+    ToolArtifactMapping,
+    ToolCallRecord,
+)
+from production_build_agents.run.artifacts import (
+    ArtifactIntegrityError,
+    content_identity_for_path,
+    resolve_candidate_artifact_path,
+    verify_artifact,
+)
+from production_build_agents.tools.media import probe_media
+
+
+def _png_bytes(color: str) -> bytes:
+    output = BytesIO()
+    Image.new("RGB", (2, 2), color=color).save(output, format="PNG")
+    return output.getvalue()
+
+
+class _MutableImageHandler(BaseHTTPRequestHandler):
+    body = _png_bytes("red")
+
+    def do_GET(self) -> None:
+        self.send_response(200)
+        self.send_header("Content-Type", "image/png")
+        self.send_header("Content-Length", str(len(self.body)))
+        self.end_headers()
+        self.wfile.write(self.body)
+
+    def log_message(self, format: str, *args: object) -> None:
+        del format, args
+
+
+class ArtifactIntegrityTest(unittest.TestCase):
+    def test_remote_candidate_resolves_to_single_local_copy(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            local = Path(temp_dir) / "sealed.png"
+            local.write_bytes(b"sealed")
+            remote = "https://example.test/mutable.png"
+            record = ToolCallRecord(
+                tool_call_id="view-1",
+                tool_name="view_images",
+                success=True,
+                artifact_mappings=[
+                    ToolArtifactMapping(
+                        source=remote,
+                        local_path=str(local),
+                    )
+                ],
+            )
+            self.assertEqual(
+                resolve_candidate_artifact_path(remote, [record]),
+                local.resolve(),
+            )
+
+    def test_formal_remote_uri_is_rejected(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            cached = Path(temp_dir) / "old.png"
+            cached.write_bytes(b"old-sealed-content")
+            remote = "https://example.test/mutable.png"
+            artifact = Artifact(
+                artifact_id="Task1-v1-artifact-1",
+                artifact_type="image",
+                uri=remote,
+                source_uri=remote,
+                description="不得作为正式远程 URI",
+                **content_identity_for_path(cached),
+            )
+            with self.assertRaisesRegex(
+                ArtifactIntegrityError,
+                "必须是本地封印路径",
+            ):
+                verify_artifact(artifact)
+
+    def test_localized_artifact_survives_remote_url_content_replacement(
+        self,
+    ) -> None:
+        server = ThreadingHTTPServer(("127.0.0.1", 0), _MutableImageHandler)
+        thread = threading.Thread(target=server.serve_forever, daemon=True)
+        thread.start()
+        try:
+            with tempfile.TemporaryDirectory() as temp_dir:
+                remote = (
+                    f"http://127.0.0.1:{server.server_port}/mutable.png"
+                )
+                original_remote = _png_bytes("red")
+                replacement_remote = _png_bytes("blue")
+                _MutableImageHandler.body = original_remote
+                result = probe_media(
+                    remote,
+                    output_dir=Path(temp_dir),
+                )
+                localized = Path(result["local_path"])
+                artifact = Artifact(
+                    artifact_id="Task1-v1-artifact-1",
+                    artifact_type="image",
+                    uri=str(localized),
+                    source_uri=remote,
+                    description="正式产物使用首次本地化并封印的内容",
+                    **content_identity_for_path(localized),
+                )
+
+                _MutableImageHandler.body = replacement_remote
+                with urlopen(remote, timeout=2) as response:
+                    self.assertEqual(response.read(), replacement_remote)
+
+                self.assertEqual(localized.read_bytes(), original_remote)
+                verify_artifact(artifact)
+        finally:
+            server.shutdown()
+            server.server_close()
+            thread.join(timeout=2)
+
+
+if __name__ == "__main__":
+    unittest.main()