Просмотр исходного кода

抽取跨 Agent 共用的工具调用证据记录

把工具消息解析、输出引用收集和 ToolCallRecord 构造从 Global Data Executor 的 provenance 模块移动到共享 tool_trace,避免 Segment Executor 复制同一套审计逻辑。

原 Executor 仅调整导入路径并保留既有来源校验语义,相关测试同步指向共享实现,确保这次归位不改变 0.3 执行行为。
SamLee 4 дней назад
Родитель
Сommit
71e0562692

+ 1 - 2
production_build_agents/agents/executor/agent.py

@@ -22,6 +22,7 @@ from ..invocation import (
     invoke_or_resume,
     recovered_tool_progress,
 )
+from ..tool_trace import build_tool_call_records, collect_references
 from ...capabilities import CAPABILITIES, validate_skill_delivery
 from ..model_config import create_configured_chat_model
 from ...run.langgraph_checkpointer import (
@@ -72,8 +73,6 @@ from .context import (
 )
 from .provenance import (
     ExecutorProvenanceError,
-    build_tool_call_records,
-    collect_references,
     validate_candidate_provenance,
 )
 from .skills.registry import ExecutorSkill, load_executor_skill

+ 1 - 120
production_build_agents/agents/executor/provenance.py

@@ -2,21 +2,17 @@
 
 from __future__ import annotations
 
-import json
-import re
 from pathlib import Path
 from typing import Any
 
-from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
-
 from ...capabilities import get_skill_capability
 from ...contracts.models import (
     ExecutorCandidate,
     PlannedTask,
     TaskPackage,
-    ToolArtifactMapping,
     ToolCallRecord,
 )
+from ..tool_trace import build_tool_call_records, collect_references
 from .skills.registry import ExecutorSkill, VerificationPolicy
 
 
@@ -32,35 +28,6 @@ _NON_PRODUCING_TOOLS = {
 }
 
 
-def _slim(value: Any) -> Any:
-    if isinstance(value, dict):
-        return {str(key): _slim(item) for key, item in value.items()}
-    if isinstance(value, list):
-        return [_slim(item) for item in value[:20]]
-    if isinstance(value, str):
-        if value.startswith("data:"):
-            return f"[data URL omitted, {len(value)} chars]"
-        if len(value) > 500:
-            return value[:500] + "…"
-    return value
-
-
-def collect_references(value: Any) -> set[str]:
-    refs: set[str] = set()
-    if isinstance(value, dict):
-        for item in value.values():
-            refs.update(collect_references(item))
-    elif isinstance(value, list):
-        for item in value:
-            refs.update(collect_references(item))
-    elif isinstance(value, str):
-        urls = re.findall(r"https?://[^\s\"'<>]+", value)
-        refs.update(url.rstrip(".,;:)]}") for url in urls)
-        if not urls and value.startswith("/") and Path(value).exists():
-            refs.add(str(Path(value).resolve()))
-    return refs
-
-
 def collect_declared_source_paths(value: Any) -> set[str]:
     """递归收集结构化产物显式声明的 source_paths。"""
 
@@ -79,92 +46,6 @@ def collect_declared_source_paths(value: Any) -> set[str]:
     return paths
 
 
-def build_tool_call_records(
-    messages: list[BaseMessage],
-) -> list[ToolCallRecord]:
-    calls: dict[str, dict[str, Any]] = {}
-    for message in messages:
-        if isinstance(message, AIMessage):
-            for call in message.tool_calls:
-                calls[call["id"]] = {
-                    "name": call["name"],
-                    "args": call.get("args") or {},
-                }
-
-    records: list[ToolCallRecord] = []
-    for message in messages:
-        if not isinstance(message, ToolMessage):
-            continue
-        call = calls.get(
-            message.tool_call_id,
-            {"name": message.name or "unknown", "args": {}},
-        )
-        try:
-            decoded = json.loads(message.content)
-        except (json.JSONDecodeError, TypeError):
-            decoded = message.content
-        success = decoded.get("success") if isinstance(decoded, dict) else None
-        arguments = call["args"] if isinstance(call["args"], dict) else {}
-        artifact_mappings: list[ToolArtifactMapping] = []
-        if isinstance(decoded, dict):
-            mapping_items = [decoded]
-            images = decoded.get("images")
-            if isinstance(images, list):
-                mapping_items.extend(
-                    item for item in images if isinstance(item, dict)
-                )
-            for item in mapping_items:
-                source = item.get("source")
-                local_path = item.get("local_path")
-                if isinstance(source, str) and isinstance(
-                    local_path,
-                    str,
-                ):
-                    artifact_mappings.append(
-                        ToolArtifactMapping(
-                            source=source,
-                            local_path=str(Path(local_path).resolve()),
-                        )
-                    )
-        records.append(
-            ToolCallRecord(
-                tool_call_id=message.tool_call_id,
-                operation_id=(
-                    decoded.get("_operation_id")
-                    if isinstance(decoded, dict)
-                    and isinstance(decoded.get("_operation_id"), str)
-                    else None
-                ),
-                tool_name=call["name"],
-                remote_tool_id=(
-                    arguments.get("tool_id")
-                    if call["name"] == "run_tool"
-                    else None
-                ),
-                arguments=_slim(arguments),
-                success=success if isinstance(success, bool) else None,
-                duration_ms=(
-                    decoded.get("_duration_ms", 0)
-                    if isinstance(decoded, dict)
-                    and isinstance(decoded.get("_duration_ms", 0), int)
-                    else 0
-                ),
-                output_refs=sorted(collect_references(decoded)),
-                artifact_mappings=artifact_mappings,
-                output_excerpt=(
-                    json.dumps(
-                        _slim(decoded),
-                        ensure_ascii=False,
-                        sort_keys=True,
-                    )[:2000]
-                    if isinstance(decoded, (dict, list))
-                    else str(_slim(decoded))[:2000]
-                ),
-            )
-        )
-    return records
-
-
 def validate_candidate_provenance(
     candidate: ExecutorCandidate,
     task: TaskPackage,

+ 126 - 0
production_build_agents/agents/tool_trace.py

@@ -0,0 +1,126 @@
+"""从 Agent 消息提取稳定、可审计的工具调用记录。"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
+
+from ..contracts.models import ToolArtifactMapping, ToolCallRecord
+
+
+def _slim(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {str(key): _slim(item) for key, item in value.items()}
+    if isinstance(value, list):
+        return [_slim(item) for item in value[:20]]
+    if isinstance(value, str):
+        if value.startswith("data:"):
+            return f"[data URL omitted, {len(value)} chars]"
+        if len(value) > 500:
+            return value[:500] + "…"
+    return value
+
+
+def collect_references(value: Any) -> set[str]:
+    refs: set[str] = set()
+    if isinstance(value, dict):
+        for item in value.values():
+            refs.update(collect_references(item))
+    elif isinstance(value, list):
+        for item in value:
+            refs.update(collect_references(item))
+    elif isinstance(value, str):
+        urls = re.findall(r"https?://[^\s\"'<>]+", value)
+        refs.update(url.rstrip(".,;:)]}") for url in urls)
+        if not urls and value.startswith("/") and Path(value).exists():
+            refs.add(str(Path(value).resolve()))
+    return refs
+
+
+def build_tool_call_records(
+    messages: list[BaseMessage],
+) -> list[ToolCallRecord]:
+    calls: dict[str, dict[str, Any]] = {}
+    for message in messages:
+        if isinstance(message, AIMessage):
+            for call in message.tool_calls:
+                calls[call["id"]] = {
+                    "name": call["name"],
+                    "args": call.get("args") or {},
+                }
+
+    records: list[ToolCallRecord] = []
+    for message in messages:
+        if not isinstance(message, ToolMessage):
+            continue
+        call = calls.get(
+            message.tool_call_id,
+            {"name": message.name or "unknown", "args": {}},
+        )
+        try:
+            decoded = json.loads(message.content)
+        except (json.JSONDecodeError, TypeError):
+            decoded = message.content
+        success = decoded.get("success") if isinstance(decoded, dict) else None
+        arguments = call["args"] if isinstance(call["args"], dict) else {}
+        artifact_mappings: list[ToolArtifactMapping] = []
+        if isinstance(decoded, dict):
+            mapping_items = [decoded]
+            images = decoded.get("images")
+            if isinstance(images, list):
+                mapping_items.extend(
+                    item for item in images if isinstance(item, dict)
+                )
+            for item in mapping_items:
+                source = item.get("source")
+                local_path = item.get("local_path")
+                if not isinstance(source, str):
+                    source = item.get("published_url")
+                if isinstance(source, str) and isinstance(local_path, str):
+                    artifact_mappings.append(
+                        ToolArtifactMapping(
+                            source=source,
+                            local_path=str(Path(local_path).resolve()),
+                        )
+                    )
+        records.append(
+            ToolCallRecord(
+                tool_call_id=message.tool_call_id,
+                operation_id=(
+                    decoded.get("_operation_id")
+                    if isinstance(decoded, dict)
+                    and isinstance(decoded.get("_operation_id"), str)
+                    else None
+                ),
+                tool_name=call["name"],
+                remote_tool_id=(
+                    arguments.get("tool_id")
+                    if call["name"] == "run_tool"
+                    else None
+                ),
+                arguments=_slim(arguments),
+                success=success if isinstance(success, bool) else None,
+                duration_ms=(
+                    decoded.get("_duration_ms", 0)
+                    if isinstance(decoded, dict)
+                    and isinstance(decoded.get("_duration_ms", 0), int)
+                    else 0
+                ),
+                output_refs=sorted(collect_references(decoded)),
+                artifact_mappings=artifact_mappings,
+                output_excerpt=(
+                    json.dumps(
+                        _slim(decoded),
+                        ensure_ascii=False,
+                        sort_keys=True,
+                    )[:2000]
+                    if isinstance(decoded, (dict, list))
+                    else str(_slim(decoded))[:2000]
+                ),
+            )
+        )
+    return records

+ 1 - 1
tests/agents/test_executor.py

@@ -35,9 +35,9 @@ from production_build_agents.agents.executor.context import (
 )
 from production_build_agents.agents.executor.provenance import (
     ExecutorProvenanceError,
-    build_tool_call_records,
     validate_candidate_provenance,
 )
+from production_build_agents.agents.tool_trace import build_tool_call_records
 from production_build_agents.agents.executor.skills.registry import (
     ExecutorSkillError,
     load_executor_skill,