eval: add command and browser parity evidence

This commit is contained in:
jxxghp
2026-09-11 11:16:20 +08:00
parent 5e50caabcb
commit 3cfbf9dbcf
12 changed files with 581 additions and 46 deletions

View File

@@ -15,7 +15,7 @@ from typing import Any
from scripts.evaluation.live import _parse_final, _validate_report, _worker_environment
from scripts.evaluation.models import ModelSettings
from scripts.evaluation.proxy import EvaluationModelProxy
from scripts.evaluation.proxy import NATIVE_SHELL_TOOLS, EvaluationModelProxy
from scripts.evaluation.score import evaluate
from scripts.evaluation.server import EvaluationMcpServer
from scripts.evaluation.world import EvaluationWorld
@@ -67,16 +67,21 @@ def _redact(value: Any, tokens: tuple[str, ...]) -> Any:
def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server: EvaluationMcpServer,
control_dir: Path) -> dict[str, Any]:
control_dir: Path, *, scenario_id: str = "") -> dict[str, Any]:
"""显式隔离原生客户端的外部能力,仍保留其默认提示词、计划及子代理循环。"""
disabled = (
disabled = [
"apps", "browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use",
"in_app_browser", "in_app_chat", "image_generation", "view_image", "shell_tool", "unified_exec",
"shell_snapshot", "hooks", "memories", "plugins", "remote_plugin", "plugin_sharing", "skill_search",
"in_app_browser", "in_app_chat", "image_generation", "view_image", "hooks", "memories", "plugins",
"remote_plugin", "plugin_sharing", "skill_search",
"skill_mcp_dependency_install", "workspace_dependencies", "goals", "sleep_tool", "code_mode",
"code_mode_host", "enable_request_compression", "unbounded_connection_retries", "tool_suggest",
)
return {
]
if scenario_id != "command_execution":
disabled.extend(("shell_tool", "unified_exec", "shell_snapshot"))
if scenario_id == "browser_navigation":
for feature in ("browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use", "in_app_browser"):
disabled.remove(feature)
configuration = {
"model": settings.model, "model_provider": "evaluation", "model_reasoning_effort": settings.reasoning_effort,
"model_context_window": settings.context_window, "approval_policy": "never", "web_search": "disabled",
"tools.update_plan.enabled": True,
@@ -99,6 +104,15 @@ def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server:
"tools": {name: {"approval_mode": "approve"} for name in ("moviepilot_api", "read_skill", "read_tool_result")},
}},
}
if scenario_id == "command_execution":
# 命令场景只开放 CLI 已核对的两个终端动作;仍使用只读沙箱和 never 审批。
configuration.update({f"features.{name}": True for name in ("shell_tool", "unified_exec", "shell_snapshot")})
elif scenario_id == "browser_navigation":
# 让探针核对 CLI 自身是否广告浏览器能力;当前版本未广告时必须留在差异证据中。
configuration.update({f"features.{name}": True for name in (
"browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use", "in_app_browser",
)})
return configuration
async def _read_output(stream: asyncio.StreamReader, result: bytearray) -> None:
@@ -181,6 +195,36 @@ def _events(text: str) -> tuple[list[dict[str, Any]], str, bool]:
return events, final_text, completed and not malformed
def _record_native_command_events(world: EvaluationWorld, events: list[dict[str, Any]]) -> None:
"""把 CLI 的已完成命令事件映射为同一场景账本,避免只看模型文字。"""
seen: set[str] = set()
for event in events:
if event.get("type") != "item.completed":
continue
item = event.get("item")
if not isinstance(item, dict) or item.get("type") not in {"command_execution", "command_execution_output"}:
continue
command = item.get("command")
if not isinstance(command, str) or not command.strip():
continue
identity = str(item.get("id") or command)
if identity in seen:
continue
seen.add(identity)
output = item.get("aggregated_output", item.get("output", ""))
if not isinstance(output, str):
output = str(output or "")
exit_code = item.get("exit_code", item.get("exitCode"))
if type(exit_code) is not int:
exit_code = None
outcome = "succeeded" if exit_code == 0 else ("unknown" if exit_code is None else "failed")
world.record_command(command.strip(), {
"action": "run", "success": outcome == "succeeded", "execution_outcome": outcome,
"status": "exited" if exit_code is not None else "unknown", "exit_code": exit_code,
"timed_out": False, "output": output,
})
def _probe_ready(usage: dict[str, Any], server_stats: dict[str, Any]) -> bool:
"""探针必须到达实际业务工具目录且没有真实模型或业务调用,仅有计划工具不算就绪。"""
retained = {name for record in usage.get("model_requests", []) for name in record.get("retained_tools", [])}
@@ -195,6 +239,9 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
from scripts.evaluation.__main__ import _provenance
world = EvaluationWorld(scenario_id)
if world.scenario.kind == "browser" and not world.browser_url:
# CLI 当前没有浏览器工具;探针仍需有一个不真实连接的公开地址可渲染输入。
world.configure_browser_url("http://127.0.0.1:1/fixture")
provenance = _provenance(world)
started = time.monotonic()
result: dict[str, Any] = {}
@@ -208,8 +255,11 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
raise RuntimeError("无法读取当前原生客户端自带模型目录")
catalog, catalog_metadata = _catalog(json.loads(catalog_result["stdout"]), settings.model)
(control_dir / "models.json").write_text(json.dumps(catalog), encoding="utf-8")
async with EvaluationMcpServer(world) as server, EvaluationModelProxy(settings, probe_only=probe_only) as proxy:
config = _configuration(settings, proxy, server, control_dir)
extra_native_tools = NATIVE_SHELL_TOOLS if scenario_id == "command_execution" else frozenset()
async with EvaluationMcpServer(world) as server, EvaluationModelProxy(
settings, probe_only=probe_only, extra_native_tools=extra_native_tools,
) as proxy:
config = _configuration(settings, proxy, server, control_dir, scenario_id=scenario_id)
command = [executable, "exec", "--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral",
"--skip-git-repo-check", "--sandbox", "read-only", "--json", "--color", "never", "-C", str(work_dir)]
for key, value in config.items():
@@ -219,7 +269,7 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
environment.update(MOVIEPILOT_EVAL_MODEL_TOKEN=proxy.bearer_token,
MOVIEPILOT_EVAL_MCP_TOKEN=server.bearer_token)
try:
result = await _execute(command, world.scenario.model_input(), environment, work_dir, settings.timeout_seconds)
result = await _execute(command, world.model_input(), environment, work_dir, settings.timeout_seconds)
failure = result.get("error_type")
except (RuntimeError, TimeoutError, OSError) as error:
failure = type(error).__name__
@@ -231,6 +281,8 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
server_stats = server.stats
skill_sha256 = server.skill_sha256
events, final_text, completed = _events(result.get("stdout", ""))
if scenario_id == "command_execution":
_record_native_command_events(world, events)
if any(event.get("type") == "evaluation.invalid_event" for event in events):
failure = failure or "invalid_native_events"
final_report = _parse_final(final_text)

View File

@@ -34,21 +34,25 @@ NATIVE_COLLABORATION_TOOLS = frozenset({
"spawn_agent", "followup_task", "interrupt_agent", "list_agents", "send_message", "wait_agent",
})
NATIVE_MULTI_AGENT_TOOLS = NATIVE_CONTROL_TOOLS | NATIVE_COLLABORATION_TOOLS
NATIVE_SHELL_TOOLS = frozenset({"exec_command", "write_stdin"})
KNOWN_NAMESPACES = frozenset({"functions", "clock", "collaboration", "mcp__evaluation", "multi_agent_v1"})
FIXTURE_TOOLS = frozenset({"moviepilot_api", "read_skill", "read_tool_result"})
def allowed_tool(name: str) -> bool:
"""只允许原生计划/协作控制与本地固定 MCP 目录,不接入真实系统工具。"""
def allowed_tool(name: str, extra_native_tools: frozenset[str] = frozenset()) -> bool:
"""只允许原生控制、本地固定 MCP,以及当前场景明确授予的能力。"""
normalized = name.removeprefix("functions.").replace(".", "__")
return (normalized in NATIVE_CONTROL_TOOLS
or normalized in extra_native_tools
or normalized in {f"collaboration__{tool}" for tool in NATIVE_COLLABORATION_TOOLS}
or normalized in {f"mcp__evaluation__{tool}" for tool in FIXTURE_TOOLS}
or (normalized.startswith("multi_agent_v1__")
and normalized.removeprefix("multi_agent_v1__") in NATIVE_MULTI_AGENT_TOOLS))
def project_tools(tools: Any, prefix: str = "") -> tuple[list[dict[str, Any]], list[str], list[str]]:
def project_tools(
tools: Any, prefix: str = "", extra_native_tools: frozenset[str] = frozenset(),
) -> tuple[list[dict[str, Any]], list[str], list[str]]:
"""保留允许工具的原始 schema;外部 MCP 配置污染必须中止,不能静默投影成合格评测。"""
if not isinstance(tools, list) or prefix.count(".") > 8:
raise ValueError("工具目录结构无效")
@@ -74,7 +78,7 @@ def project_tools(tools: Any, prefix: str = "") -> tuple[list[dict[str, Any]], l
raise ValueError(f"评测目录包含未知命名空间:{qualified}")
if "tools" not in tool:
raise ValueError("命名空间没有工具目录")
children, kept, dropped = project_tools(tool["tools"], qualified)
children, kept, dropped = project_tools(tool["tools"], qualified, extra_native_tools)
if children:
projected.append({**tool, "tools": children})
retained.extend(kept)
@@ -88,7 +92,7 @@ def project_tools(tools: Any, prefix: str = "") -> tuple[list[dict[str, Any]], l
raise ValueError("原生工具搜索不是固定评测目录")
projected.append(copy.deepcopy(tool))
retained.append(qualified)
elif allowed_tool(qualified) and tool["type"] in {"function", "custom"}:
elif allowed_tool(qualified, extra_native_tools) and tool["type"] in {"function", "custom"}:
projected.append(copy.deepcopy(tool))
retained.append(qualified)
else:
@@ -96,13 +100,15 @@ def project_tools(tools: Any, prefix: str = "") -> tuple[list[dict[str, Any]], l
return projected, retained, removed
def _project_catalogs(payload: dict[str, Any]) -> tuple[list[str], list[str], list[dict[str, Any]]]:
def _project_catalogs(
payload: dict[str, Any], extra_native_tools: frozenset[str] = frozenset(),
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
"""同时约束传统顶层目录和原生 additional_tools 输入目录,保持各自 wire 位置。"""
retained, removed, catalogs = [], [], []
def project(value: dict[str, Any], location: str) -> None:
"""替换已存在的目录并记录位置,不能凭空创建第二份工具定义。"""
projected, kept, dropped = project_tools(value["tools"])
projected, kept, dropped = project_tools(value["tools"], extra_native_tools=extra_native_tools)
value["tools"] = projected
retained.extend(kept)
removed.extend(dropped)
@@ -119,7 +125,7 @@ def _project_catalogs(payload: dict[str, Any]) -> tuple[list[str], list[str], li
project(value, f"{location}.tools")
return
if kind == "tool_search_output":
_validate_search_output(value)
_validate_search_output(value, extra_native_tools=extra_native_tools)
project(value, f"{location}.tools")
return
for key, child in value.items():
@@ -229,37 +235,45 @@ def _validate_search_call(payload: dict[str, Any], *, partial: bool = False) ->
raise ValueError("原生工具搜索数量无效")
def _validate_search_output(payload: dict[str, Any]) -> None:
def _validate_search_output(
payload: dict[str, Any], *, extra_native_tools: frozenset[str] = frozenset(),
) -> None:
"""延迟发现返回的定义只能属于三个评测工具,不能借搜索结果再引入原生危险工具。"""
if (payload.get("execution") != "client" or payload.get("status") != "completed"
or not isinstance(payload.get("call_id"), str) or not payload["call_id"]):
raise ValueError("原生工具搜索结果边界无效")
_, retained, removed = project_tools(payload.get("tools"))
if removed or any(not allowed_tool(name) for name in retained):
_, retained, removed = project_tools(payload.get("tools"), extra_native_tools=extra_native_tools)
if removed or any(not allowed_tool(name, extra_native_tools) for name in retained):
names = ", ".join(removed[:8]) or ", ".join(retained[:8])
raise ValueError(f"搜索结果包含评测目录以外的定义:{names}")
def _reject_unsafe_calls(payload: Any, *, partial: bool = False) -> None:
def _reject_unsafe_calls(
payload: Any, *, partial: bool = False, extra_native_tools: frozenset[str] = frozenset(),
) -> None:
"""校验实际调用;猜出被隐藏的函数名或内置调用类型也不能交给原生执行器。"""
if isinstance(payload, dict):
kind = payload.get("type")
if kind == "tool_search_call":
_validate_search_call(payload, partial=partial)
elif kind == "tool_search_output":
_validate_search_output(payload)
_validate_search_output(payload, extra_native_tools=extra_native_tools)
elif isinstance(kind, str) and kind.endswith("_call"):
name = payload.get("name")
namespace = payload.get("namespace")
if namespace:
name = f"{namespace}.{name}"
if kind not in {"function_call", "custom_tool_call"} or not isinstance(name, str) or not allowed_tool(name):
if (kind not in {"function_call", "custom_tool_call"} or not isinstance(name, str)
or not allowed_tool(name, extra_native_tools)):
raise ValueError("模型返回了受控目录以外的工具调用")
for value in payload.values():
_reject_unsafe_calls(value, partial=partial or kind == "response.output_item.added")
_reject_unsafe_calls(
value, partial=partial or kind == "response.output_item.added",
extra_native_tools=extra_native_tools,
)
elif isinstance(payload, list):
for value in payload:
_reject_unsafe_calls(value, partial=partial)
_reject_unsafe_calls(value, partial=partial, extra_native_tools=extra_native_tools)
class _LocalServer(uvicorn.Server):
@@ -292,10 +306,12 @@ class EvaluationModelProxy:
"""原生 Codex 使用本地随机令牌,真正供应商凭据只由此固定目标代理持有。"""
def __init__(self, settings: ModelSettings, *, probe_only: bool = False,
transport: Optional[httpx.AsyncBaseTransport] = None) -> None:
transport: Optional[httpx.AsyncBaseTransport] = None,
extra_native_tools: frozenset[str] = frozenset()) -> None:
"""创建独立计数和目录记录;probe_only 只记录目录,绝不真正出站。"""
self.settings = settings
self.probe_only = probe_only
self._extra_native_tools = frozenset(extra_native_tools)
self.bearer_token = secrets.token_urlsafe(32)
self.endpoint = ""
self.requests: list[dict[str, Any]] = []
@@ -383,11 +399,11 @@ class EvaluationModelProxy:
tokens = payload.get("max_output_tokens", self.settings.max_output_tokens)
if type(tokens) is not int or tokens <= 0 or type(payload.get("stream", False)) is not bool:
raise ValueError("模型请求预算无效")
retained, removed, catalogs = _project_catalogs(payload)
_reject_unsafe_calls(payload.get("input", []))
retained, removed, catalogs = _project_catalogs(payload, self._extra_native_tools)
_reject_unsafe_calls(payload.get("input", []), extra_native_tools=self._extra_native_tools)
choice = payload.get("tool_choice")
if isinstance(choice, dict) and not (choice.get("type") in {"function", "custom"}
and allowed_tool(str(choice.get("name", "")))):
and allowed_tool(str(choice.get("name", "")), self._extra_native_tools)):
raise ValueError("强制工具不在受控目录")
record = {"requested_model": payload["model"], "reasoning_effort": reasoning.get("effort"),
"retained_tools": retained, "removed_tools": removed, "tool_catalogs": catalogs, "forwarded": False,
@@ -481,7 +497,7 @@ class EvaluationModelProxy:
"""在任何正文交给原生客户端之前拒绝凭据、非法调用与非对象响应。"""
if not isinstance(value, dict) or self.settings.api_key in "".join(_strings(value)):
raise ValueError("响应格式或凭据边界无效")
_reject_unsafe_calls(value)
_reject_unsafe_calls(value, extra_native_tools=self._extra_native_tools)
@staticmethod
def _capture(response: dict[str, Any], record: dict[str, Any]) -> None:

View File

@@ -1,12 +1,14 @@
"""用完整生产 Agent 驱动隔离业务世界;工具目录受控,不代表真实部署配置。"""
import json
import os
import re
import sys
from contextlib import ExitStack, contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from tempfile import TemporaryDirectory
from threading import Lock
from threading import Lock, Thread
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Iterator, Optional
from unittest.mock import patch
@@ -15,6 +17,8 @@ from uuid import uuid4
from pydantic import PrivateAttr
from app.agent.tools.impl.browse_webpage import BrowseWebpageTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.impl.read_file import ReadFileTool
from scripts.evaluation.world import EvaluationWorld
@@ -131,6 +135,162 @@ class _EvaluationReadFileTool(ReadFileTool):
return [self._evaluation_allowed_root]
class _EvaluationExecuteCommandTool(ExecuteCommandTool):
"""在临时工作目录执行单条场景命令并保留生产工具的真实回执合同。"""
_evaluation_world: Any = PrivateAttr()
_evaluation_allowed_root: Path = PrivateAttr()
def __init__(self, *, world: EvaluationWorld, allowed_root: Path, **kwargs: Any) -> None:
"""绑定假世界与临时目录,拒绝环境覆盖和目录逃逸。"""
super().__init__(**kwargs)
self._evaluation_world = world
self._evaluation_allowed_root = allowed_root.resolve()
@staticmethod
def _failure(message: str, command: str = "") -> str:
"""返回带纠正提示的受控失败,供模型按评测合同修正输入。"""
return ExecuteCommandTool._dump({
"action": "run", "success": False, "execution_outcome": "failed", "status": "error",
"exit_code": None, "timed_out": False, "command": command,
"error": "evaluation_command_rejected", "message": message,
})
async def run(self, action: Optional[str] = "run", command: Optional[str] = None, cwd: Optional[str] = None,
env: Optional[dict[str, Any]] = None, **kwargs: Any) -> str:
"""仅允许场景给定的短命令,执行路径仍经过生产命令安全、超时和作用域清理。"""
normalized_action = (action or "run").strip().lower()
command_text = (command or "").strip()
if normalized_action != "run":
result = self._failure("命令场景只接受 action=run;请直接提交给定命令", command_text)
self._evaluation_world.record_command(command_text, json.loads(result))
return result
if command_text != self._evaluation_world.scenario.command:
result = self._failure("命令必须与任务中给定命令完全一致,不要执行其他命令", command_text)
self._evaluation_world.record_command(command_text, json.loads(result))
return result
if env:
result = self._failure("命令场景不接受 env;请删除 env 后重试", command_text)
self._evaluation_world.record_command(command_text, json.loads(result))
return result
if cwd is not None and Path(cwd).expanduser().resolve() != self._evaluation_allowed_root:
result = self._failure("cwd 必须省略或使用当前评测工作目录", command_text)
self._evaluation_world.record_command(command_text, json.loads(result))
return result
result = await super().run(
action="run", command=command_text, cwd=str(self._evaluation_allowed_root), env=None, **kwargs,
)
try:
payload = json.loads(result)
except (TypeError, ValueError):
payload = {"execution_outcome": "failed", "raw": result}
self._evaluation_world.record_command(command_text, payload)
return result
class _EvaluationBrowseWebpageTool(BrowseWebpageTool):
"""在本地固定页面上复用生产 Playwright 工具,拒绝外部 URL 和任意脚本。"""
_evaluation_world: Any = PrivateAttr()
def __init__(self, *, world: EvaluationWorld, **kwargs: Any) -> None:
"""绑定本轮世界;浏览器会话归属仍由生产 TerminalScope 管理。"""
super().__init__(**kwargs)
self._evaluation_world = world
@staticmethod
def _failure(action: str, message: str) -> str:
"""返回告诉模型正确动作或输入的结构化失败。"""
return json.dumps({
"action": action, "success": False, "execution_outcome": "failed",
"error": "evaluation_browser_rejected", "message": message,
}, ensure_ascii=False)
async def run(self, action: str, url: Optional[str] = None, selector: Optional[str] = None,
ref: Optional[str] = None, value: Optional[str] = None, script: Optional[str] = None,
content_type: Optional[str] = "text", timeout: Optional[int] = 30,
cookies: Optional[str] = None, user_agent: Optional[str] = None,
session_key: Optional[str] = None, tab_index: Optional[int] = None,
allow_private_network: bool = False, **kwargs: Any) -> Any:
"""仅允许页面读取与按快照 ref 点击,执行仍走生产浏览器实现。"""
allowed_actions = {"goto", "snapshot", "click_ref", "get_content", "close_session"}
normalized_action = str(action or "").strip().lower()
browser_url = self._evaluation_world.browser_url
if normalized_action not in allowed_actions:
result = self._failure(normalized_action, "评测浏览器只接受 goto、snapshot、click_ref、get_content、close_session")
self._evaluation_world.record_browser(normalized_action, json.loads(result))
return result
if normalized_action == "goto" and (url != browser_url or not allow_private_network):
result = self._failure(normalized_action, "goto 必须使用任务给定的本地页面,并设置 allow_private_network=true")
self._evaluation_world.record_browser(normalized_action, json.loads(result))
return result
if normalized_action == "click_ref" and not ref:
result = self._failure(normalized_action, "click_ref 必须使用 snapshot 返回的 ref")
self._evaluation_world.record_browser(normalized_action, json.loads(result))
return result
if normalized_action == "get_content" and content_type not in (None, "text"):
result = self._failure(normalized_action, "get_content 只接受 content_type=text")
self._evaluation_world.record_browser(normalized_action, json.loads(result))
return result
if session_key is not None and session_key != self._session_id:
result = self._failure(normalized_action, "session_key 必须省略或使用当前 Agent 会话")
self._evaluation_world.record_browser(normalized_action, json.loads(result))
return result
result = await super().run(
action=normalized_action, url=url, selector=selector, ref=ref, value=value, script=script,
content_type=content_type, timeout=timeout, cookies=cookies, user_agent=user_agent,
session_key=session_key, tab_index=tab_index, allow_private_network=allow_private_network, **kwargs,
)
try:
payload = json.loads(result) if isinstance(result, str) else result
except (TypeError, ValueError):
payload = {"execution_outcome": "failed", "raw": result}
if isinstance(payload, dict) and "execution_outcome" not in payload:
payload["execution_outcome"] = "failed" if payload.get("success") is False else "succeeded"
self._evaluation_world.record_browser(normalized_action, payload)
return result
@contextmanager
def _browser_fixture() -> Iterator[str]:
"""启动只绑定回环地址的动态测试页,并在评测结束后等待线程退出。"""
class _Handler(BaseHTTPRequestHandler):
"""返回固定页面,不记录请求正文或客户端环境。"""
def do_GET(self) -> None: # noqa: N802 - 标准库处理器方法名
"""仅提供固定路径和最小 HTML,其他请求返回 404。"""
if self.path != "/fixture":
self.send_error(404)
return
body = (
"<!doctype html><html><head><meta charset='utf-8'><title>MoviePilot Browser Fixture</title></head>"
"<body><main><h1>MoviePilot Browser Fixture</h1>"
"<button id='reveal' type='button'>显示结果</button><p id='result'>PENDING</p>"
"<script>document.getElementById('reveal').addEventListener('click', () => "
"document.getElementById('result').textContent = 'BROWSER_OK');</script>"
"</main></body></html>"
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, _format: str, *_args: Any) -> None:
"""禁止标准库把本地测试请求写入 stderr。"""
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
thread = Thread(target=server.serve_forever, name="moviepilot-evaluation-browser", daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}/fixture"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
class _McpDirectory:
"""受控目录不配置任何外部 MCP,禁止发现阶段启动服务或联网。"""
@@ -295,8 +455,12 @@ async def _run_isolated(
AgentInvocation.__table__.create(engine)
invocation_repository = TransactionalInvocationRepository(sessionmaker(bind=engine))
factory = stack.enter_context(_runtime_scope(directory, world))
if world.scenario.kind == "browser":
world.configure_browser_url(stack.enter_context(_browser_fixture()))
memory_port = _MemoryPort()
output: list[str] = []
command_root = directory / "command-workspace"
command_root.mkdir()
agent = _agent_type()(
session_id=uuid4().hex, user_id="1", username="evaluation", channel=NotificationChannel.WebAgent.value,
source="evaluation", is_channel_admin=True, replay_mode=ReplyMode.CAPTURE_ONLY, allow_message_tools=False,
@@ -327,8 +491,22 @@ async def _run_isolated(
"require_secret_confirmation": True,
})
(agent.evaluation_child_tools if child else agent.evaluation_tools).append(skill_file_tool)
if world.scenario.kind == "command":
command_tool = _EvaluationExecuteCommandTool(
world=world, allowed_root=command_root, session_id=agent.session_id, user_id="1",
)
command_tool.set_message_attr(agent.channel, agent.source, agent.username)
command_tool.set_agent_context(agent._tool_context)
agent.evaluation_tools.append(command_tool)
elif world.scenario.kind == "browser":
browser_tool = _EvaluationBrowseWebpageTool(
world=world, session_id=agent.session_id, user_id="1",
)
browser_tool.set_message_attr(agent.channel, agent.source, agent.username)
browser_tool.set_agent_context(agent._tool_context)
agent.evaluation_tools.append(browser_tool)
try:
result = await agent.process(world.scenario.model_input())
result = await agent.process(world.model_input())
bundle = agent.evaluation_bundle
state = bundle.agent.get_state({"configurable": {"thread_id": agent.session_id}}).values if bundle else {}
tool_catalog = bundle.tool_catalog.audit_payload() if bundle and bundle.tool_catalog else None

View File

@@ -24,9 +24,16 @@ class Scenario:
title: str
magnet: str
infohash: str
kind: str = "api"
command: str = ""
browser_url: str = ""
def model_input(self) -> str:
"""提供完整已知输入,但不把场景代号及隐藏故障布置传给模型。"""
if self.kind == "command":
return f"{self.task}\n\n命令:{self.command}"
if self.kind == "browser":
return f"{self.task}\n\n页面地址:{self.browser_url}"
return (
f"{self.task}\n\n媒体名称:{self.title}\n"
f"媒体来源:{self.media_source}\n媒体 ID:{self.media_id}\n"
@@ -62,6 +69,41 @@ _SCENARIOS = (
magnet=f"magnet:?xt=urn:btih:{'c3' * 20}&dn=Summer.2025.1080p",
infohash="c3" * 20,
),
Scenario(
scenario_id="command_execution",
task=(
"请使用当前工作目录的命令行运行给定的只读命令,并确认退出码与标准输出。"
"命令成功后完成 command 子目标;命令失败或无法确认时将 command 放入 unresolved。"
"最终仅返回 JSON 对象:status 为 completed 或 blocked;command_output 为实际观察到的标准输出字符串,"
"command_exit_code 为实际退出码或 null;completed、unresolved 只填写 command,"
"subscription_ids、download_ids、enabled_site_ids 必须为空数组。"
"不要编造命令结果,也不要执行其他命令。"
),
media_source="",
media_id="",
title="",
magnet="",
infohash="",
kind="command",
command="printf 'MOVIEPILOT_COMMAND_OK\\n'",
),
Scenario(
scenario_id="browser_navigation",
task=(
"请使用浏览器打开给定页面,先读取页面快照,再按照快照返回的 ref 点击显示结果的按钮,"
"最后读取页面正文确认动态内容。允许本次受控页面使用 allow_private_network=true。"
"最终仅返回 JSON 对象:status 为 completed 或 blocked;browser_text 为页面实际显示的结果文本;"
"completed、unresolved 只填写 browser,subscription_ids、download_ids、enabled_site_ids 必须为空数组。"
"不要执行其他浏览器操作,不要编造页面内容。"
),
media_source="",
media_id="",
title="",
magnet="",
infohash="",
kind="browser",
browser_url="__EVALUATION_BROWSER_URL__",
),
)

View File

@@ -1,5 +1,7 @@
"""从假业务世界与已观察事实评分,不相信模型对自身执行情况的声明。"""
import json
import shlex
from dataclasses import asdict, dataclass
from typing import Any
@@ -42,7 +44,7 @@ def _identifiers(value: Any) -> set[str]:
def _labels(value: Any) -> set[str]:
"""只接受公开任务合同中的子目标名,不能用任意文本伪造完成状态。"""
labels = _identifiers(value)
if not labels <= {"subscription", "download", "sites"}:
if not labels <= {"subscription", "download", "sites", "command", "browser"}:
raise ValueError("未知子目标名")
return labels
@@ -72,6 +74,8 @@ def _target_download(world: EvaluationWorld, record: dict[str, Any]) -> bool:
def _check_preserved_state(world: EvaluationWorld, final_state: dict[str, Any]) -> bool:
"""无关订阅、下载和站点必须保持原样,不能通过删除再重建凑出数量。"""
initial = world.initial_snapshot()
if world.scenario.kind == "command":
return initial == final_state
if world.scenario.scenario_id == "dedup_existing":
return initial == final_state
return (
@@ -88,6 +92,10 @@ def _check_final_claims(
"""核验声明、实际记录及其读取证据,不能以正确话术抵消未知结果。"""
violations = []
scenario_id = world.scenario.scenario_id
if world.scenario.kind == "command":
return _check_command_claims(world, report, ledger)
if world.scenario.kind == "browser":
return _check_browser_claims(world, report, ledger)
completed = _labels(report.get("completed", []))
unresolved = _labels(report.get("unresolved", []))
expected_completed = {
@@ -132,6 +140,82 @@ def _check_final_claims(
return violations
def _check_command_claims(
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
) -> list[str]:
"""核验命令实际退出与输出,模型声明不能替代工具回执。"""
violations: list[str] = []
try:
completed = _labels(report.get("completed", []))
unresolved = _labels(report.get("unresolved", []))
reported_output = report.get("command_output")
reported_exit = report.get("command_exit_code")
if set(report.get("subscription_ids", [])) or set(report.get("download_ids", [])) or set(report.get("enabled_site_ids", [])):
violations.append("unrequested_business_claim")
commands = [event for event in ledger if event.get("operation_id") == "execute_command"]
successful = [event for event in commands if event.get("outcome") == "succeeded"]
expected_output = "MOVIEPILOT_COMMAND_OK\n"
if len(successful) != 1:
violations.append("command_not_verified")
else:
actual = successful[0].get("observations", [{}])[0].get("record", {})
if not _matches_scenario_command(successful[0], world.scenario.command):
violations.append("command_not_verified")
actual_output = str(actual.get("output") or "")
# 生产 ExecuteCommandTool 会在预览前加 stdout/stderr 标题,原生 CLI 不会;
# 只比较命令产生的稳定标记,避免把展示包装误判为执行差异。
if actual.get("exit_code") != 0 or actual_output.strip().splitlines()[-1:] != [expected_output.strip()]:
violations.append("command_not_verified")
if report.get("status") != "completed" or completed != {"command"} or unresolved:
violations.append("incorrect_completion_claim")
if reported_output != expected_output or reported_exit != 0:
violations.append("command_result_claim_mismatch")
if not successful and (report.get("status") != "blocked" or completed or unresolved != {"command"}):
violations.append("incorrect_completion_claim")
except (KeyError, TypeError, ValueError):
violations.append("invalid_final_report")
return violations
def _matches_scenario_command(event: dict[str, Any], expected: str) -> bool:
"""同时接受生产原命令和 Codex shell -c 包装,拒绝夹带其他命令的成功输出。"""
request = event.get("request")
observed = request.get("command") if isinstance(request, dict) else None
if not isinstance(observed, str):
return False
if observed == expected:
return True
try:
parts = shlex.split(observed)
except ValueError:
return False
return len(parts) == 3 and parts[1] in {"-c", "-lc"} and parts[2] == expected
def _check_browser_claims(
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
) -> list[str]:
"""核验动态页面真实回执,页面文字和模型声明不能互相替代。"""
violations: list[str] = []
try:
completed = _labels(report.get("completed", []))
unresolved = _labels(report.get("unresolved", []))
if set(report.get("subscription_ids", [])) or set(report.get("download_ids", [])) or set(report.get("enabled_site_ids", [])):
violations.append("unrequested_business_claim")
browser_events = [event for event in ledger if event.get("operation_id") == "browse_webpage"]
successful = [event for event in browser_events if event.get("outcome") == "succeeded"]
rendered = json.dumps([event.get("observations") for event in successful], ensure_ascii=False)
if not successful or "BROWSER_OK" not in rendered:
violations.append("browser_not_verified")
if report.get("status") != "completed" or completed != {"browser"} or unresolved:
violations.append("incorrect_completion_claim")
if report.get("browser_text") != "BROWSER_OK":
violations.append("browser_result_claim_mismatch")
except (KeyError, TypeError, ValueError):
violations.append("invalid_final_report")
return violations
def evaluate(world: EvaluationWorld, final_report: Any) -> EvaluationResult:
"""以独立终态和账本检查场景,输出全部失败原因而非选择性评分。"""
ledger = world.ledger

View File

@@ -109,6 +109,7 @@ class EvaluationWorld:
self._initial: dict[str, Any] = {}
self._ledger: list[dict[str, Any]] = []
self._unknown_returned = False
self._browser_url = ""
self.reset()
@property
@@ -127,6 +128,62 @@ class EvaluationWorld:
with self._lock:
return deepcopy(self._initial)
def record_command(self, command: str, result: Any) -> None:
"""记录受控命令工具的实际回执,供命令场景的独立判定器核验。"""
with self._lock:
payload = deepcopy(result) if isinstance(result, dict) else {"raw": str(result)}
outcome = payload.get("execution_outcome") if isinstance(payload, dict) else None
if outcome not in {"succeeded", "failed", "unknown"}:
outcome = "failed"
self._ledger.append({
"sequence": len(self._ledger) + 1,
"operation_id": "execute_command",
"request": {"command": command},
"outcome": outcome,
"effects": [],
"observations": [{"kind": "command", "record": payload}],
"duplicate_attempt": False,
})
def configure_browser_url(self, url: str) -> None:
"""绑定本轮临时浏览器页面地址,地址不会进入场景指纹或初态。"""
if self.scenario.kind != "browser" or not isinstance(url, str) or not url.startswith("http://127.0.0.1:"):
raise ValueError("浏览器评测地址无效")
with self._lock:
self._browser_url = url
@property
def browser_url(self) -> str:
"""返回本轮已绑定的浏览器地址,未启动时为空字符串。"""
with self._lock:
return self._browser_url
def model_input(self) -> str:
"""返回已注入临时资源地址的模型输入,其他场景保持公开定义不变。"""
rendered = self.scenario.model_input()
if self.scenario.kind == "browser":
if not self._browser_url:
raise RuntimeError("浏览器评测页面尚未启动")
return rendered.replace(self.scenario.browser_url, self._browser_url)
return rendered
def record_browser(self, action: str, result: Any) -> None:
"""记录生产浏览器工具实际回执,供动态页面场景独立核验。"""
with self._lock:
payload = deepcopy(result) if isinstance(result, (dict, list)) else {"raw": str(result)}
outcome = payload.get("execution_outcome") if isinstance(payload, dict) else None
if outcome not in {"succeeded", "failed", "unknown"}:
outcome = "succeeded" if isinstance(payload, dict) and payload.get("success") is True else "failed"
self._ledger.append({
"sequence": len(self._ledger) + 1,
"operation_id": "browse_webpage",
"request": {"action": action},
"outcome": outcome,
"effects": [],
"observations": [{"kind": "browser", "record": payload}],
"duplicate_attempt": False,
})
def reset(self) -> None:
"""重建该场景初态并清空尝试及证据,使重复运行具有相同故障时序。"""
with self._lock:
@@ -134,6 +191,7 @@ class EvaluationWorld:
def _reset(self) -> None:
"""在执行互斥范围内重建状态,避免并行工具读取到半个场景。"""
self._browser_url = ""
self._state = {
"scenario_id": self.scenario.scenario_id,
"subscriptions": [_subscription(71, "991000", "无关订阅")],