|
|
@@ -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,
|