mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
eval: add command and browser parity evidence
This commit is contained in:
@@ -81,6 +81,12 @@ uv run --locked --no-sync python -m scripts.evaluation --live \
|
||||
|
||||
三份报告的 `reported_models` 都是 `gemini-3.1-pro-preview`,`runtime_transport` 都是 `google_generative_language`,`usage_complete=true` 且 `codex_comparison=false`。这证明更强模型已经能在当前隔离生产 Agent 图中完成这组三个固定 API 场景;仍不能据此宣称浏览器、命令行、长任务排队或整体智能已达到 Codex。后续 OAuth 配对结果见下节。
|
||||
|
||||
### 命令行与浏览器能力实测
|
||||
|
||||
`command_execution` 场景按场景显式开启原生 CLI 的 `shell_tool`、`unified_exec` 和 `shell_snapshot`,代理保留 `functions.exec_command` 与 `functions.write_stdin`;MoviePilot 侧注入生产 `ExecuteCommandTool`,仅允许任务给定的固定命令和临时工作目录。配对报告 `/tmp/moviepilot-agent-round-luna-oauth-command-pair-final.json` 的 `pair_valid=true`、`both_passed=true`,harness 指纹为 `c409ee1ca7899ccfcefd38832d6a343f024f8fc67fb65f14c3eec08552203257`:两侧均以退出码 0 得到 `MOVIEPILOT_COMMAND_OK`,没有业务副作用。
|
||||
|
||||
`browser_navigation` 场景由 MoviePilot 生产 `BrowseWebpageTool` 操作回环动态页面,报告 `/tmp/moviepilot-agent-round-luna-oauth-live-browser-final.json` 通过,3 次浏览器回执在点击后观察到 `BROWSER_OK`。`codex exec 0.153.4` 在探针 `/tmp/moviepilot-agent-round-luna-oauth-native-probe-browser-final.json` 中即使显式开启 browser/computer feature 也没有广告浏览器动作,因此浏览器原生配对保持 blocked;这不是把 CLI 的缺失能力改判为通过。
|
||||
|
||||
因此当前真实 Gemini 结果证明了工具合同读取和未知结果诚实边界已经能被实测,但不能宣称达到 Codex 的整体智能水平。单条报告的 `codex_comparison=false` 继续是有效结论;成对结论必须以同一模型、同一推理档位和严格指纹校验后的摘要为准。
|
||||
|
||||
### `thought_signature` 与真实 MoviePilot Agent
|
||||
@@ -119,7 +125,7 @@ uv run --locked --no-sync python -m scripts.evaluation \
|
||||
|
||||
只有显式 `--live` 或 `--native` 才会调用真实模型并产生费用。默认读取 `~/.codex/config.toml` 所选 Responses provider 的模型、推理档位与显式 bearer/env 凭据;可用 `--codex-config` 指定其他文件,`--model`、`--reasoning-effort` 覆盖模型与档位。不会借用其他服务的登录凭据,也不会自动降低被供应商拒绝的参数。报告同时保留请求模型与供应商返回的模型标识;本地 Codex 配置不能证明运行中的 MoviePilot 使用相同配置。
|
||||
|
||||
调用配置经私有标准输入传给 worker,凭据不进入命令行、提示词或报告。worker 只继承必要的平台环境,先创建临时 `CONFIG_DIR`,再导入后端;每轮拥有独立回执库、记忆、会话和工具实例。生产 `process/_create_agent`、Skills、计划、权限、持久回执、工具输出预算、压缩和子代理仍按真实路径执行。评测主目录包含 `moviepilot_api`、生产 `read_skill`、受临时 Agent 根约束的 `read_file`、计划、子代理和回执查询;API transport 拒绝任何外部目标及场景外 operation。插件、外部 MCP、通知、任意 shell 和浏览器工具仍不开放,此受控目录不代表默认部署工具全集。
|
||||
调用配置经私有标准输入传给 worker,凭据不进入命令行、提示词或报告。worker 只继承必要的平台环境,先创建临时 `CONFIG_DIR`,再导入后端;每轮拥有独立回执库、记忆、会话和工具实例。生产 `process/_create_agent`、Skills、计划、权限、持久回执、工具输出预算、压缩和子代理仍按真实路径执行。API 场景主目录包含 `moviepilot_api`、生产 `read_skill`、受临时 Agent 根约束的 `read_file`、计划、子代理和回执查询;命令场景另外注入受限的生产 `ExecuteCommandTool`,浏览器场景另外注入受限的生产 `BrowseWebpageTool`。API transport 拒绝任何外部目标及场景外 operation;这些受控目录不代表默认部署工具全集。
|
||||
|
||||
共享回调在请求前执行硬调用上限,覆盖主模型、选择、摘要和子代理;SDK 自动重试关闭。单请求超时最多 120 秒,全轮和独立进程另有期限。每次请求的输出 token 及运行器上下文上限被记录;上下文上限是测试参数,不表示模型真实最大窗口。当前固定为 128000 tokens。
|
||||
|
||||
@@ -144,7 +150,7 @@ uv run --locked --no-sync python -m scripts.evaluation --native \
|
||||
|
||||
适配器目前只核对 `codex-cli 0.153.4`,可用 `--codex-executable` 指定文件。其他版本或二进制目录中没有指定模型时明确拒绝,不换模型或套用其他模型的元数据。`--native-probe` 的 `probe_ready` 只代表配置和目录检查,不是任务通过;原生进程预期收到本地探针错误并退出,模型调用数仍为零。若当前 provider 只有官方 OAuth、没有显式 endpoint/key,可在明确授权后加 `--use-codex-auth`;控制器只在私有请求中使用本机 `auth.json` 的访问令牌和账户标识,默认模式不会借用登录态。
|
||||
|
||||
每次创建空白临时工作目录;场景源码、初态、账本、oracle、模型凭据留在控制器。原生客户端使用局部随机令牌连接回环模型代理和 MCP 假世界,不继承业务配置、真实模型令牌或其他服务环境。客户端忽略用户配置和规则文件,关闭宿主技能、插件、浏览器、文件查看及 shell 等能力,采用只读沙箱、never 审批与有界退出;仅对当前回环 evaluation 服务的三个假工具显式设置 `approval_mode=approve`,避免原生客户端拒绝已授权的假业务动作。不修改用户 HOME、CODEX_HOME 或现有配置。全局 AGENTS 仍可能由原生客户端加载,因此代理在发送给模型前仅移除规范的独立 AGENTS 用户块,保留原生基础说明和任务文本,并记录被移除块的长度与哈希。
|
||||
每次创建空白临时工作目录;场景源码、初态、账本、oracle、模型凭据留在控制器。原生客户端使用局部随机令牌连接回环模型代理和 MCP 假世界,不继承业务配置、真实模型令牌或其他服务环境。客户端忽略用户配置和规则文件,采用只读沙箱、never 审批与有界退出;API 场景关闭宿主浏览器和 shell,命令场景只按条件开启并保留 `functions.exec_command`/`functions.write_stdin`。仅对当前回环 evaluation 服务的三个假工具显式设置 `approval_mode=approve`,避免原生客户端拒绝已授权的假业务动作。不修改用户 HOME、CODEX_HOME 或现有配置。全局 AGENTS 仍可能由原生客户端加载,因此代理在发送给模型前仅移除规范的独立 AGENTS 用户块,保留原生基础说明和任务文本,并记录被移除块的长度与哈希。
|
||||
|
||||
原生循环、计划和协作工具仍由 Codex 执行。当前模型可固定使用 Code Mode,单独关闭 feature 无法改变它;适配器通过 `codex debug models --bundled` 读取当前二进制自带目录,只投影指定模型的 `tool_mode=direct`,保持其余字段、`base_instructions` 和 `model_messages`,记录前后指纹。随后代理限制实际广告目录为计划/协作与 evaluation MCP 工具,兼顾请求中的动态目录,并在每个完整响应事件交给客户端前再次拒绝目录以外的调用。这是明确投影过工具模式与目录的原生 Codex 对照,不代表默认部署的完整产品工具环境。
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Google 的 OpenAI-compatible 层会丢失 Gemini 3 工具回复中的 `thought_s
|
||||
| C1.4 终端工具完整闭环 | 已完成基线 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试 |
|
||||
| C1.3 终端任务作用域 | 已完成 | 终端归属由宿主对象身份决定;定时运行、会话、子任务和内部工具管理器隔离;封口先于清理,排队或运行中的命令都不会在任务结束后迟到启动 |
|
||||
| S2.3 运行中消息排队与 WebAgent 输入 | 已完成本轮实现 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 报告 queued/applied,停止后不再派发后续工具;剩余边界由真实长任务回归继续覆盖 |
|
||||
| 浏览器能力对齐 | 动作与作用域已实现,真实配对验证未完成 | 导航、页面读取、点击/输入、等待、截图和失败收口使用真实浏览器状态;工具清单、权限、超时、重试和会话生命周期与命令行能力同样可观测;仍需可用供应商和原生 harness 做同场景验证 |
|
||||
| 浏览器能力对齐 | MoviePilot 真实运行已验证;原生 CLI 配对受能力缺口阻塞 | 导航、页面读取、点击/输入、等待、截图和失败收口使用真实浏览器状态;本轮本地动态页面场景已由生产 `BrowseWebpageTool` 完成。`codex exec 0.153.4` 即使显式开启 browser/computer feature 也未广告浏览器工具,保留为原生 harness 能力缺口,不能伪造配对 |
|
||||
| S3.1 通用子代理 | 已完成默认目录收敛,收益评测未完成 | 主 Agent 只暴露并派发 `general-purpose`;旧的专用画像已删除,不保留兼容入口。仍需用 held-out 任务验证通用派发的收益、授权、工具角色、终端分享和副作用边界 |
|
||||
|
||||
## 每轮硬门禁
|
||||
@@ -49,8 +49,8 @@ uv run --locked --no-sync python -m scripts.evaluation \
|
||||
|
||||
## 工具与 Harness 对齐清单
|
||||
|
||||
- **命令行**:普通一次性命令与后台终端分别支持 pipe/PTY、共享 cwd/shell/login/环境和 UTF-8;输入写入、空写入、EOF、分页、短写、interrupt、kill、超时、取消和进程组收尾都有结构化终态。
|
||||
- **浏览器**:工具目录必须明确导航、读取、交互、等待和截图的动作与权限;浏览器会话、页面状态和失败重试由宿主持有,不能由模型字符串冒领。
|
||||
- **命令行**:普通一次性命令与后台终端分别支持 pipe/PTY、共享 cwd/shell/login/环境和 UTF-8;输入写入、空写入、EOF、分页、短写、interrupt、kill、超时、取消和进程组收尾都有结构化终态。新增 `command_execution` 场景只在原生 CLI 真实广告 `functions.exec_command`/`functions.write_stdin` 时按场景开启,并把 MoviePilot 生产 `ExecuteCommandTool` 绑定到临时目录;PTY 长会话仍需同条件原生配对。
|
||||
- **浏览器**:工具目录必须明确导航、读取、交互、等待和截图的动作与权限;浏览器会话、页面状态和失败重试由宿主持有,不能由模型字符串冒领。新增 `browser_navigation` 场景使用回环动态页面验证生产工具;原生 CLI 探针没有广告浏览器工具,原生配对保持 blocked,等待可用的 Codex browser harness。
|
||||
- **子代理**:主 Agent 自动选择通用子代理;专用画像只有在 held-out 任务上证明提高成功率、减少调用或降低副作用风险时才保留。子代理不能自行发送消息、执行高影响写操作或继承兄弟任务句柄。
|
||||
- **API Skill 与合同读取**:`skills/moviepilot-api/SKILL.md` 只负责路由索引和工作流;`api/*.md` 各自包含完整操作合同及该类别需要的 Body Models,不再依赖单独的 `api/models.md`。真实 Agent 先用 `read_skill` 得到 supporting-file 清单,再用同一个工具的 `file=api/<category>.md` 参数按需读取类别文档;评测目录必须提供同一条链路,不能只返回文件名而不给模型读取能力。operation 输入错误还要返回该 operation 的允许字段、必填字段和类型约束,帮助模型纠正后重试。
|
||||
- **长任务与新消息**:入站消息在任务运行时仍可接受并进入有界队列;应用到下一模型边界时必须保留 tool-call/tool-result 配对和取消语义,不能只把文本拼到系统提示词。
|
||||
@@ -73,4 +73,5 @@ uv run --locked --no-sync python -m scripts.evaluation \
|
||||
- 本轮:真实测评切换到 `gemini-3.1-pro-preview + high`,官方 Google 主机改用生产同源的 `langchain-google-genai` 原生工具通道并保留 `thought_signature`。`dedup_existing`、`unknown_download`、`honest_unknown` 三个固定场景均通过(报告分别为 `/tmp/moviepilot-agent-round-gemini31native-dedup.json`、`/tmp/moviepilot-agent-round-gemini31native-unknown.json`、`/tmp/moviepilot-agent-round-gemini31native-honest.json`);这只是 API 假世界的模型行为证据,仍未形成与原生 Codex 的配对比较。
|
||||
- 本轮第二阶段:用同一 `gpt-5.6-luna + max`、同一场景和预算显式启用 Codex OAuth,首次生成有效成对摘要 `/tmp/moviepilot-agent-round-luna-oauth-dedup-pair.json`。MoviePilot live `/tmp/moviepilot-agent-round-luna-oauth-live-dedup-5.json` 因最终 infohash 少两位而未通过;原生 Codex `/tmp/moviepilot-agent-round-luna-oauth-native-dedup-8.json` 通过。摘要 `pair_valid=true`、`both_passed=false`,证明真实生产图与 Codex 仍有可观测的终态可靠性差距;下一轮先针对精确最终报告和未知结果收口做多次重复,再扩展浏览器、终端和中途消息场景。
|
||||
- 本轮第三阶段:修正原生动态工具搜索对 `multi_agent_v1` 协作命名空间的投影,正向保留 `wait_agent`、`resume_agent` 等原生控制动作,负向继续拒绝 `read_file`。在同一 Harness(`71be4c436acc5dc2d5a0cd6996c3ca10688a9c4ba98fd11594e536f7aa440c22`)下,`dedup_existing` 配对 `/tmp/moviepilot-agent-round-luna-oauth-dedup-pair-13.json` 与 `unknown_download` 配对 `/tmp/moviepilot-agent-round-luna-oauth-unknown-pair-14.json` 均 `pair_valid=true`、`both_passed=true`;MoviePilot 分别使用 6/6 次模型调用,原生 Codex 使用 10/14 次。`honest_unknown` 配对 `/tmp/moviepilot-agent-round-luna-oauth-honest-pair-16.json` 仍为 `both_passed=false`:MoviePilot 在 9 次调用后如实保留下载未核验,原生 Codex 达到 16 次上限后没有最终 JSON,产生 `invalid_final_report`。这保留了真实失败边界,不能等同整体 Codex 智能已完成。
|
||||
- 浏览器、真实命令行/PTY 和 WebAgent 中途消息排队已有确定性实现,但仍缺少与原生 Codex 在同一模型、同一场景下的真实配对证据,不能把这些能力标为“已对齐”。
|
||||
- 命令行与浏览器能力阶段:`command_execution` 在同一 `gpt-5.6-luna + max` 和独立 Codex OAuth 下已形成真实配对 `/tmp/moviepilot-agent-round-luna-oauth-command-pair-final.json`,`pair_valid=true`、`both_passed=true`,harness 为 `c409ee1ca7899ccfcefd38832d6a343f024f8fc67fb65f14c3eec08552203257`。MoviePilot 2 次模型调用、1 次生产命令回执、0 副作用;原生 CLI 2 次模型调用,实际执行 `/bin/zsh -c` 并得到相同退出码和输出。生产 stdout 的 `[标准输出]` 展示包装已在独立判定器中与命令内容分离。
|
||||
- 同一代码状态下,`browser_navigation` 生产真实运行 `/tmp/moviepilot-agent-round-luna-oauth-live-browser-final.json` 通过:6 次模型调用、4 次浏览器回执,真实页面点击后观察到 `BROWSER_OK`。`codex exec 0.153.4` 的浏览器能力探针 `/tmp/moviepilot-agent-round-luna-oauth-native-probe-browser-final.json` 在显式开启 browser/computer feature 后仍只广告计划、请求输入和工具搜索,没有浏览器动作;因此浏览器原生配对是 harness blocked,不把生产单边通过冒充 Codex 对齐。PTY 长会话、WebAgent 中途消息排队和通用子代理收益仍需真实配对。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "无关订阅")],
|
||||
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
|
||||
from scripts.evaluation import codex
|
||||
from scripts.evaluation.models import ModelSettings
|
||||
from scripts.evaluation.proxy import NATIVE_SHELL_TOOLS, project_tools
|
||||
|
||||
|
||||
def _program(tmp_path: Path, code: str) -> list[str]:
|
||||
@@ -351,3 +352,21 @@ def test_toml_arguments_preserve_literals_without_shell_interpretation() -> None
|
||||
"""带引号、换行和 shell 字符的模型配置仍是单个正确 TOML 值。"""
|
||||
value = {"name": "引号\"与换行\n以及$(command)", "enabled": False, "budget": 12}
|
||||
assert tomllib.loads("value=" + codex._toml(value))["value"] == value
|
||||
|
||||
|
||||
def test_command_scenario_enables_only_native_shell_controls() -> None:
|
||||
"""命令场景显式开启原生终端开关,普通 API 场景继续关闭。"""
|
||||
settings = ModelSettings("gpt-test", "https://provider.invalid/v1", "provider-key")
|
||||
proxy = SimpleNamespace(endpoint="http://model.invalid/v1")
|
||||
server = SimpleNamespace(endpoint="http://mcp.invalid/mcp", bearer_token="token")
|
||||
control = Path("/tmp/evaluation-control")
|
||||
api_config = codex._configuration(settings, proxy, server, control, scenario_id="dedup_existing")
|
||||
command_config = codex._configuration(settings, proxy, server, control, scenario_id="command_execution")
|
||||
assert all(api_config[f"features.{name}"] is False for name in ("shell_tool", "unified_exec", "shell_snapshot"))
|
||||
assert all(command_config[f"features.{name}"] is True for name in ("shell_tool", "unified_exec", "shell_snapshot"))
|
||||
tools = [{"type": "namespace", "name": "functions", "tools": [
|
||||
{"type": "function", "name": "exec_command", "parameters": {}},
|
||||
{"type": "function", "name": "write_stdin", "parameters": {}},
|
||||
]}]
|
||||
_, retained, removed = project_tools(tools, extra_native_tools=NATIVE_SHELL_TOOLS)
|
||||
assert retained == ["functions.exec_command", "functions.write_stdin"] and removed == []
|
||||
|
||||
@@ -17,9 +17,10 @@ from app.agent.api.executor import MoviePilotApiExecutor
|
||||
from app.agent.middleware import config as config_middleware
|
||||
from app.agent.middleware import subagents
|
||||
from app.agent.middleware.summarization import FinalRequestCompactionMiddleware
|
||||
from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope, close_terminal_scope
|
||||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
from scripts.evaluation.runtime import _Transport, run_moviepilot
|
||||
from scripts.evaluation.runtime import _EvaluationExecuteCommandTool, _Transport, run_moviepilot
|
||||
from scripts.evaluation.score import evaluate
|
||||
from scripts.evaluation.world import EvaluationWorld
|
||||
|
||||
@@ -224,6 +225,21 @@ async def test_transport_rejects_external_and_unlisted_routes():
|
||||
assert world.ledger == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluation_command_tool_rejects_wrong_input_with_correction(tmp_path):
|
||||
"""评测命令工具拒绝越界输入,并把可修正的合同返回给模型。"""
|
||||
world = EvaluationWorld("command_execution")
|
||||
tool = _EvaluationExecuteCommandTool(world=world, allowed_root=tmp_path, session_id="command-test", user_id="1")
|
||||
scope = TerminalScope(user_id="1", task_id="command-test", kind="conversation")
|
||||
with bind_terminal_scope(scope):
|
||||
result = json.loads(await tool.run(action="run", command="echo outside"))
|
||||
assert result["execution_outcome"] == "failed"
|
||||
assert result["error"] == "evaluation_command_rejected"
|
||||
assert "完全一致" in result["message"]
|
||||
assert world.ledger[0]["operation_id"] == "execute_command"
|
||||
assert await close_terminal_scope(scope)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_refuses_mismatched_config(monkeypatch, tmp_path):
|
||||
"""导入后更换 CONFIG_DIR 不能被误认成已隔离后端。"""
|
||||
|
||||
@@ -148,6 +148,52 @@ def test_model_input_contains_known_resource_but_hides_scenario_kind():
|
||||
assert world.scenario.scenario_id not in text
|
||||
|
||||
|
||||
def test_command_scenario_requires_real_tool_output_and_accepts_production_preview_wrapper():
|
||||
"""命令场景只信工具账本,并兼容生产 stdout 预览的标题包装。"""
|
||||
world = EvaluationWorld("command_execution")
|
||||
world.record_command(world.scenario.command, {
|
||||
"execution_outcome": "succeeded", "exit_code": 0,
|
||||
"output": "[标准输出]\nMOVIEPILOT_COMMAND_OK",
|
||||
})
|
||||
report = {
|
||||
"status": "completed", "command_output": "MOVIEPILOT_COMMAND_OK\n", "command_exit_code": 0,
|
||||
"completed": ["command"], "unresolved": [],
|
||||
"subscription_ids": [], "download_ids": [], "enabled_site_ids": [],
|
||||
}
|
||||
grade = evaluate(world, report)
|
||||
assert grade.passed is True
|
||||
world.record_command(world.scenario.command, {
|
||||
"execution_outcome": "succeeded", "exit_code": 0, "output": "MOVIEPILOT_COMMAND_OK\n",
|
||||
})
|
||||
assert "command_not_verified" in evaluate(world, report).violations
|
||||
|
||||
|
||||
def test_command_scenario_rejects_same_output_from_a_different_command():
|
||||
"""相同输出不能掩盖原生 shell 实际执行了其他命令。"""
|
||||
world = EvaluationWorld("command_execution")
|
||||
world.record_command("printf 'MOVIEPILOT_COMMAND_OK\\n'; echo unexpected", {
|
||||
"execution_outcome": "succeeded", "exit_code": 0, "output": "MOVIEPILOT_COMMAND_OK\n",
|
||||
})
|
||||
report = {
|
||||
"status": "completed", "command_output": "MOVIEPILOT_COMMAND_OK\n", "command_exit_code": 0,
|
||||
"completed": ["command"], "unresolved": [],
|
||||
"subscription_ids": [], "download_ids": [], "enabled_site_ids": [],
|
||||
}
|
||||
assert "command_not_verified" in evaluate(world, report).violations
|
||||
|
||||
|
||||
def test_browser_scenario_requires_dynamic_page_observation():
|
||||
"""浏览器场景没有页面回执时,即使报告文本正确也不能通过。"""
|
||||
world = EvaluationWorld("browser_navigation")
|
||||
report = {
|
||||
"status": "completed", "browser_text": "BROWSER_OK", "completed": ["browser"], "unresolved": [],
|
||||
"subscription_ids": [], "download_ids": [], "enabled_site_ids": [],
|
||||
}
|
||||
assert "browser_not_verified" in evaluate(world, report).violations
|
||||
world.record_browser("get_content", {"success": True, "execution_outcome": "succeeded", "content": "BROWSER_OK"})
|
||||
assert evaluate(world, report).passed is True
|
||||
|
||||
|
||||
def test_cli_reports_failure_without_executing_any_model(tmp_path):
|
||||
"""独立命令行真实退出码与报告表达失败,且显式标注只是离线轨迹验收。"""
|
||||
trace = tmp_path / "trace.json"
|
||||
|
||||
@@ -30,13 +30,30 @@ def _download_body(world: EvaluationWorld) -> dict[str, Any]:
|
||||
def test_scenarios_expose_inputs_without_initial_state_or_oracle() -> None:
|
||||
"""公开定义只有任务、业务身份及资源,答案和故障时序不能进入模型上下文。"""
|
||||
scenarios = list_scenarios()
|
||||
assert len(scenarios) == 3
|
||||
assert len({scenario.media_id for scenario in scenarios}) == 3
|
||||
assert len(scenarios) == 5
|
||||
api_scenarios = [scenario for scenario in scenarios if scenario.kind == "api"]
|
||||
assert len(api_scenarios) == 3
|
||||
assert len({scenario.media_id for scenario in api_scenarios}) == 3
|
||||
for scenario in scenarios:
|
||||
assert set(asdict(scenario)) == {"scenario_id", "task", "media_source", "media_id", "title", "magnet", "infohash"}
|
||||
assert scenario.infohash in scenario.magnet
|
||||
assert set(asdict(scenario)) == {
|
||||
"scenario_id", "task", "media_source", "media_id", "title", "magnet", "infohash",
|
||||
"kind", "command", "browser_url",
|
||||
}
|
||||
assert "JSON" in scenario.task
|
||||
assert "默认下载器和目录已配置" in scenario.task
|
||||
assert scenario.scenario_id not in scenario.task
|
||||
if scenario.kind == "api":
|
||||
assert scenario.infohash in scenario.magnet
|
||||
assert "默认下载器和目录已配置" in scenario.task
|
||||
assert scenario.command == ""
|
||||
assert scenario.browser_url == ""
|
||||
elif scenario.kind == "command":
|
||||
assert scenario.command
|
||||
assert scenario.browser_url == ""
|
||||
assert not scenario.media_id and not scenario.infohash
|
||||
else:
|
||||
assert scenario.kind == "browser"
|
||||
assert scenario.browser_url == "__EVALUATION_BROWSER_URL__"
|
||||
assert not scenario.media_id and not scenario.infohash
|
||||
assert "旧译名" not in scenario.task
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
scenario.title = "污染输入"
|
||||
|
||||
Reference in New Issue
Block a user