Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1019 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
让原生 Codex CLI 操作同一假世界,模型循环与独立评分器不共用状态。
"""
import asyncio
import copy
import hashlib
import json
import os
import shutil
import signal
import subprocess
import tempfile
import time
from contextlib import contextmanager, nullcontext
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
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 NATIVE_BROWSER_TOOLS, NATIVE_SHELL_TOOLS, EvaluationModelProxy
from scripts.evaluation.score import evaluate
from scripts.evaluation.server import EvaluationMcpServer
from scripts.evaluation.world import EvaluationWorld
SUPPORTED_CLI_VERSION = "codex-cli 0.153.4"
MAX_PROCESS_OUTPUT_BYTES = 8 * 1024 * 1024
NATIVE_TERMINAL_SCENARIOS = frozenset(
{"terminal_session", "terminal_pty_session", "subagent_terminal_share"}
)
NATIVE_BROWSER_SCENARIOS = frozenset({"browser_navigation"})
NATIVE_STEERING_SCENARIOS = frozenset({"steering_long_context", "steering_multi_message"})
NATIVE_STEERING_MESSAGE_ID = "moviepilot-evaluation-steering"
def _native_steering_message_id(index: int, count: int) -> str:
"""
为原生多条 steering 事件生成稳定且可审计的消息标识。
"""
return NATIVE_STEERING_MESSAGE_ID if count == 1 else f"{NATIVE_STEERING_MESSAGE_ID}-{index + 1}"
def _browser_runtime_configuration(executable: str, instruction_dir: Path) -> dict[str, Any] | None:
"""
发现本机已安装的浏览器插件运行时,缺少任一受信文件就保持原生边界关闭。
"""
codex_home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
plugin_root = Path(os.environ.get(
"MOVIEPILOT_EVAL_BROWSER_PLUGIN_ROOT",
str(codex_home / ".tmp" / "bundled-marketplaces" / "openai-bundled" / "plugins" / "browser"),
))
node_repl = Path(os.environ.get(
"MOVIEPILOT_EVAL_NODE_REPL",
"/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node_repl",
))
node_path = Path(os.environ.get("MOVIEPILOT_EVAL_NODE_PATH", str(node_repl.parent / "node")))
node_modules = Path(os.environ.get(
"MOVIEPILOT_EVAL_NODE_MODULES",
str(node_repl.parent.parent / "lib" / "node_modules"),
))
browser_service = os.environ.get("MOVIEPILOT_EVAL_BROWSER_SERVICE")
service_candidates = ([Path(browser_service)] if browser_service else []) + sorted(
(codex_home / "plugins" / "cache" / "openai-bundled" / "browser").glob(
"*/scripts/browser-service.mjs"
),
reverse=True,
)
browser_service_path = next((path for path in service_candidates if path.is_file()), None)
browser_client = plugin_root / "scripts" / "browser-client.mjs"
if (not node_repl.is_file() or not node_path.is_file() or not node_modules.is_dir()
or not browser_client.is_file() or browser_service_path is None):
return None
marketplace = plugin_root.parent.parent
client_hashes = {
hashlib.sha256(browser_client.read_bytes()).hexdigest(),
}
chrome_client = codex_home / "plugins" / "cache" / "openai-bundled" / "chrome" / "latest" / "scripts" / "browser-client.mjs"
if chrome_client.is_file():
client_hashes.add(hashlib.sha256(chrome_client.read_bytes()).hexdigest())
trusted_paths = ":".join(str(path) for path in (codex_home, node_modules, plugin_root))
node_environment = {
"NODE_REPL_NATIVE_PIPE_CONNECT_TIMEOUT_MS": "1000",
"NODE_REPL_NODE_MODULE_DIRS": str(node_modules),
"NODE_REPL_NODE_PATH": str(node_path),
"NODE_REPL_TRUSTED_CODE_PATHS": trusted_paths,
"NODE_REPL_TRUSTED_BROWSER_CLIENT_SHA256S": ",".join(sorted(client_hashes)),
"NODE_REPL_TRUSTED_SERVICES": json.dumps(
{"browser": str(browser_service_path), "sky": "@oai/sky/service"}, ensure_ascii=False
),
"CODEX_HOME": str(codex_home),
"BROWSER_USE_AVAILABLE_BACKENDS": "chrome,iab",
"BROWSER_USE_TINYSKY_ENABLED": "1",
"BROWSER_USE_CODEX_APP_BUILD_FLAVOR": "prod",
"BROWSER_USE_CODEX_APP_VERSION": browser_service_path.parent.parent.name,
"BROWSER_USE_BROWSER_CLIENT_BUILD": browser_service_path.parent.parent.name,
"CODEX_CLI_PATH": executable,
}
# 安装后的 Skill 使用可移植的 <plugin root> 标记;独立命令行没有桌面插件上下文,
# 因此复制一份相同指令,并用已验证的绝对路径展开该标记。
skill = plugin_root / "skills" / "control-in-app-browser" / "SKILL.md"
expanded_skill = instruction_dir / "browser-control-skill.md"
expanded_skill.write_text(skill.read_text(encoding="utf-8").replace("<plugin root>", str(plugin_root)), encoding="utf-8")
return {
"node_repl": {
"command": str(node_repl), "args": [], "enabled": True, "required": False,
"startup_timeout_sec": 120, "tool_timeout_sec": 120,
"default_tools_approval_mode": "approve", "env": node_environment,
},
"marketplace": {"source_type": "local", "source": str(marketplace)},
"plugins": {"browser@openai-bundled": {"enabled": True}, "chrome@openai-bundled": {"enabled": True}},
"skill": str(expanded_skill),
}
@contextmanager
def _native_browser_fixture() -> Any:
"""
启动只绑定回环地址的动态测试页,并记录真实浏览器的点击回调。
"""
state = {"clicked": False}
class _Handler(BaseHTTPRequestHandler):
"""
提供最小页面和点击回调,不记录请求正文或客户端环境。
"""
def do_GET(self) -> None: # noqa: N802 - 标准库处理器方法名
"""
返回测试页面或确认按钮回调,其他路径返回 404。
"""
if self.path == "/fixture":
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', async () => {"
"await fetch('/clicked', {cache:'no-store'});"
"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)
return
if self.path == "/clicked":
state["clicked"] = True
body = b"ok"
self.send_response(204)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
return
self.send_error(404)
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-native-browser", daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}/fixture", state
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _catalog(payload: Any, model: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""
仅修改实际二进制自带模型的工具模式,原生提示词及其他模型字段逐字保留。
"""
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
raise ValueError("原生模型目录结构无效")
matches = [row for row in payload["models"] if isinstance(row, dict) and row.get("slug") == model]
if len(matches) != 1:
raise ValueError("原生二进制没有唯一的指定模型元数据,不能替换为其他模型")
original = matches[0]
projected = copy.deepcopy(original)
projected["tool_mode"] = "direct"
def digest(value: Any) -> str:
"""
对相同序列化规则取指纹,便于复验单字段投影。
"""
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
metadata = {"source": "codex_debug_models_bundled", "model": model,
"original_tool_mode": original.get("tool_mode"), "projected_tool_mode": "direct",
"original_model_sha256": digest(original), "projected_model_sha256": digest(projected),
"base_instructions_sha256": digest(original.get("base_instructions")),
"model_messages_sha256": digest(original.get("model_messages"))}
return {"models": [projected]}, metadata
def _toml(value: Any) -> str:
"""
按 TOML 表达配置值,直接传 argv,不经过 shell 或其变量展开。
"""
if isinstance(value, dict):
return "{ " + ", ".join(f"{json.dumps(key)} = {_toml(item)}" for key, item in value.items()) + " }"
return json.dumps(value, ensure_ascii=False)
def _redact(value: Any, tokens: tuple[str, ...]) -> Any:
"""
在全部事件和最终 JSON 解码后移除局部令牌,Unicode 转义不能绕过。
"""
if isinstance(value, str):
for token in tokens:
value = value.replace(token, "[local evaluation token]")
return value
if isinstance(value, dict):
return {_redact(key, tokens): _redact(item, tokens) for key, item in value.items()}
if isinstance(value, list):
return [_redact(item, tokens) for item in value]
return value
def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server: EvaluationMcpServer,
control_dir: Path, *, scenario_id: str = "",
browser_runtime: dict[str, Any] | None = None) -> dict[str, Any]:
"""
显式隔离原生客户端的外部能力,仍保留其默认提示词、计划及子代理循环。
"""
disabled = [
"apps", "browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use",
"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",
]
if scenario_id not in {
"command_execution",
"terminal_session",
"terminal_pty_session",
"subagent_terminal_share",
}:
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,
"project_doc_max_bytes": 0, "skills.bundled.enabled": False, "skills.include_instructions": False,
"sqlite_home": str(control_dir / "state"), "log_dir": str(control_dir / "logs"),
"model_catalog_json": str(control_dir / "models.json"),
"features.skip_host_skill_discovery": True, **{f"features.{name}": False for name in disabled},
"features.code_mode_only": False,
"features.code_mode_host": {"enabled": False, "disable_in_process_fallback": False},
"model_providers.evaluation": {
"name": "Controlled evaluation proxy", "base_url": proxy.endpoint,
"env_key": "MOVIEPILOT_EVAL_MODEL_TOKEN", "wire_api": "responses", "requires_openai_auth": False,
"request_max_retries": 0, "stream_max_retries": 0,
"stream_idle_timeout_ms": settings.timeout_seconds * 1000,
},
"mcp_servers": {"evaluation": {
"url": server.endpoint, "bearer_token_env_var": "MOVIEPILOT_EVAL_MCP_TOKEN",
"enabled": True, "required": True, "startup_timeout_sec": 10, "tool_timeout_sec": 15,
"enabled_tools": ["moviepilot_api", "read_skill", "read_tool_result"],
"tools": {name: {"approval_mode": "approve"} for name in ("moviepilot_api", "read_skill", "read_tool_result")},
}},
}
if scenario_id in {
"command_execution",
"terminal_session",
"terminal_pty_session",
"subagent_terminal_share",
}:
# 命令场景只开放 CLI 已核对的两个终端动作;仍使用只读沙箱和 never 审批。
configuration.update({f"features.{name}": True for name in ("shell_tool", "unified_exec", "shell_snapshot")})
elif scenario_id == "browser_navigation":
# 浏览器插件只能在本机受信运行时完整存在时开启,缺失时保留 blocked 证据。
configuration.update({f"features.{name}": True for name in (
"browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use", "in_app_browser",
)})
if browser_runtime is not None:
configuration.update({
"features.plugins": True,
"skills.bundled.enabled": True,
"skills.include_instructions": False,
"mcp_servers": {**configuration["mcp_servers"], "node_repl": browser_runtime["node_repl"]},
"marketplaces.openai-bundled": browser_runtime["marketplace"],
"plugins.browser@openai-bundled": browser_runtime["plugins"]["browser@openai-bundled"],
"plugins.chrome@openai-bundled": browser_runtime["plugins"]["chrome@openai-bundled"],
# 独立执行不继承桌面端已选插件上下文,需通过原生指令入口加载浏览器 Skill。
"model_instructions_file": browser_runtime["skill"],
})
return configuration
async def _read_output(stream: asyncio.StreamReader, result: bytearray) -> None:
"""
持续排空子进程管道,输出超限时让控制器终止整组进程。
"""
while chunk := await stream.read(65536):
remaining = MAX_PROCESS_OUTPUT_BYTES - len(result)
result.extend(chunk[:remaining])
if len(chunk) > remaining:
raise RuntimeError("原生评测进程输出超过 8 MiB")
async def _stop_process(process: asyncio.subprocess.Process) -> None:
"""
父任务取消或超时时回收原生客户端及继承进程组,不遗留后台评测。
"""
if os.name == "posix":
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
elif process.returncode is None:
process.kill()
await asyncio.wait_for(process.wait(), timeout=10)
async def _execute(command: list[str], prompt: str, environment: dict[str, str],
work_dir: Path, timeout: int) -> dict[str, Any]:
"""
只向原生 stdin 发送公开任务,结构化事件和退出状态由控制器采集。
"""
process = await asyncio.create_subprocess_exec(
*command, cwd=work_dir, env=environment, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=os.name == "posix",
limit=MAX_PROCESS_OUTPUT_BYTES,
)
readers: list[asyncio.Task[None]] = []
stdout, stderr = bytearray(), bytearray()
failure = None
try:
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
readers = [asyncio.create_task(_read_output(process.stdout, stdout)),
asyncio.create_task(_read_output(process.stderr, stderr))]
process.stdin.write(prompt.encode("utf-8"))
await process.stdin.drain()
process.stdin.close()
async with asyncio.timeout(timeout):
await asyncio.gather(*readers)
await process.wait()
except (RuntimeError, TimeoutError, OSError) as error:
failure = type(error).__name__
finally:
try:
await _stop_process(process)
finally:
for task in readers:
if not task.done():
task.cancel()
await asyncio.gather(*readers, return_exceptions=True)
return {"returncode": process.returncode, "stdout": stdout.decode("utf-8", errors="replace"),
"stderr": stderr.decode("utf-8", errors="replace"), "error_type": failure}
def _normalize_app_server_item(item: Any) -> dict[str, Any]:
"""
把 app-server 的 camelCase item 转成评测器共用的安全事件字段。
"""
if not isinstance(item, dict):
return {"type": "unknown"}
item_type = item.get("type")
normalized_type = {
"agentMessage": "agent_message",
"commandExecution": "command_execution",
"collabAgentToolCall": "collab_tool_call",
"fileChange": "file_change",
"userMessage": "user_message",
}.get(item_type, item_type if isinstance(item_type, str) else "unknown")
result: dict[str, Any] = {"type": normalized_type, "id": item.get("id")}
if normalized_type == "agent_message":
result.update(text=item.get("text", ""), phase=item.get("phase"))
elif normalized_type == "mcpToolCall":
result.update(
server=item.get("server"), tool=item.get("tool"), status=item.get("status"),
arguments=item.get("arguments"), result=item.get("result"),
)
elif normalized_type == "user_message":
result.update(text=item.get("text", ""), content=item.get("content"))
elif normalized_type == "command_execution":
result.update(
command=item.get("command", ""), cwd=item.get("cwd"), process_id=item.get("processId"),
status=item.get("status"), aggregated_output=item.get("aggregatedOutput"),
exit_code=item.get("exitCode"), command_actions=item.get("commandActions"),
)
elif normalized_type == "collab_tool_call":
# 保留协作调用的可审计合同;如果只留 type/id,无法区分真实派发、取消和等待。
collab_tools = {
"spawnAgent": "spawn_agent",
"sendInput": "send_input",
"resumeAgent": "resume_agent",
"wait": "wait_agent",
"closeAgent": "close_agent",
"sendMessage": "send_message",
"followupTask": "followup_task",
"interruptAgent": "interrupt_agent",
"listAgents": "list_agents",
}
raw_states = item.get("agentsStates")
agents_states = {}
if isinstance(raw_states, dict):
for agent_id, state in raw_states.items():
if not isinstance(state, dict):
continue
agents_states[str(agent_id)] = {
"status": state.get("status"),
"message": state.get("message"),
}
result.update(
tool=collab_tools.get(item.get("tool"), item.get("tool")),
status=item.get("status"),
prompt=item.get("prompt"),
model=item.get("model"),
reasoning_effort=item.get("reasoningEffort"),
sender_thread_id=item.get("senderThreadId"),
receiver_thread_ids=item.get("receiverThreadIds", []),
agents_states=agents_states,
)
return result
def _normalize_app_server_notification(method: str, params: Any) -> dict[str, Any] | None:
"""
保留终端、消息和轮次终态,把其他 app-server 通知降为可审计摘要。
"""
payload = params if isinstance(params, dict) else {}
if method == "turn/started":
turn = payload.get("turn") if isinstance(payload.get("turn"), dict) else {}
return {"type": "turn.started", "thread_id": payload.get("threadId"), "turn_id": turn.get("id")}
if method == "turn/completed":
turn = payload.get("turn") if isinstance(payload.get("turn"), dict) else {}
return {"type": "turn.completed", "thread_id": payload.get("threadId"), "turn_id": turn.get("id")}
if method == "item/started":
return {
"type": "item.started", "thread_id": payload.get("threadId"),
"turn_id": payload.get("turnId"), "item": _normalize_app_server_item(payload.get("item")),
}
if method == "item/completed":
return {
"type": "item.completed", "thread_id": payload.get("threadId"),
"turn_id": payload.get("turnId"), "item": _normalize_app_server_item(payload.get("item")),
}
if method == "item/agentMessage/delta":
return {
"type": "item.agent_message.delta", "thread_id": payload.get("threadId"),
"turn_id": payload.get("turnId"), "item_id": payload.get("itemId"), "delta": payload.get("delta", ""),
}
if method == "item/commandExecution/outputDelta":
return {
"type": "item.command_execution.output_delta", "thread_id": payload.get("threadId"),
"turn_id": payload.get("turnId"), "item_id": payload.get("itemId"), "delta": payload.get("delta", ""),
}
if method == "item/commandExecution/terminalInteraction":
return {
"type": "item.command_execution.terminal_interaction", "thread_id": payload.get("threadId"),
"turn_id": payload.get("turnId"), "item_id": payload.get("itemId"),
"process_id": payload.get("processId"), "stdin": payload.get("stdin", ""),
}
if method == "error":
return {"type": "error", "error": payload}
if method == "warning":
return {"type": "warning", "warning": payload}
return {"type": "native.notification", "method": method} if method else None
def _append_thread_history_events(events: list[dict[str, Any]], thread: Any) -> None:
"""
把 thread/read 返回的子代理历史转换为带 scope 的完成事件。
"""
if not isinstance(thread, dict):
return
thread_id = thread.get("id")
if not isinstance(thread_id, str) or not thread_id:
return
turns = thread.get("turns")
if not isinstance(turns, list):
return
for turn in turns:
if not isinstance(turn, dict):
continue
turn_id = turn.get("id")
if not isinstance(turn_id, str) or not turn_id:
continue
items = turn.get("items")
if not isinstance(items, list):
continue
for item in items:
normalized = _normalize_app_server_item(item)
if normalized.get("type") == "unknown":
continue
events.append({
"type": "item.completed",
"thread_id": thread_id,
"turn_id": turn_id,
"item": normalized,
"thread_history": True,
})
async def _execute_app_server(
command: list[str], prompt: str, environment: dict[str, str], work_dir: Path, timeout: int,
*, model: str, reasoning_effort: str, steering_message: str = "",
steering_plan: tuple[tuple[int, str], ...] = (),
) -> dict[str, Any]:
"""
通过 Codex app-server 的 JSON-RPC 驱动终端和多条中途补充消息。
"""
process = await asyncio.create_subprocess_exec(
*command, cwd=work_dir, env=environment, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=os.name == "posix",
limit=MAX_PROCESS_OUTPUT_BYTES,
)
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
stderr = bytearray()
stderr_reader = asyncio.create_task(_read_output(process.stderr, stderr))
events: list[dict[str, Any]] = []
stdout_bytes = 0
pending: dict[int, str] = {}
next_request_id = 1
thread_id: str | None = None
active_turn_id: str | None = None
turn_completed = False
failure: str | None = None
schedule = steering_plan
if not schedule and steering_message:
schedule = ((1, steering_message),)
steering_index = 0
business_tool_count = 0
steering_requests: dict[int, tuple[int, str]] = {}
steering_request_pending = False
child_thread_ids: set[str] = set()
child_thread_read_requests: dict[int, str] = {}
child_thread_read_requested: set[str] = set()
async def send_request(method: str, params: dict[str, Any]) -> int:
"""
发送带序号的 JSON-RPC 请求,并记录响应所属方法。
"""
nonlocal next_request_id
request_id = next_request_id
next_request_id += 1
pending[request_id] = method
process.stdin.write(
(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n").encode()
)
await process.stdin.drain()
return request_id
async def send_notification(method: str) -> None:
"""
发送不等待响应的 JSON-RPC 通知。
"""
process.stdin.write((json.dumps({"jsonrpc": "2.0", "method": method}) + "\n").encode())
await process.stdin.drain()
async def send_server_error(request_id: Any) -> None:
"""
拒绝意外的原生服务端请求,避免评测控制器代替用户授权。
"""
process.stdin.write(
(json.dumps({
"jsonrpc": "2.0", "id": request_id,
"error": {"code": -32000, "message": "评测适配器不接受原生交互授权请求"},
}) + "\n").encode()
)
await process.stdin.drain()
async def send_child_thread_reads_if_ready() -> None:
"""
在父轮次结束后读取已完成子代理历史,补齐可能遗漏的终端 item。
"""
if not turn_completed:
return
for child_id in sorted(child_thread_ids - child_thread_read_requested):
request_id = await send_request("thread/read", {"threadId": child_id, "includeTurns": True})
child_thread_read_requests[request_id] = child_id
child_thread_read_requested.add(child_id)
async def send_next_steering_if_ready() -> None:
"""
在达到计划回执边界后逐条发送 steering,并保留请求身份。
"""
nonlocal failure, steering_index, steering_request_pending
if steering_request_pending or steering_index >= len(schedule):
return
trigger_count, text = schedule[steering_index]
if business_tool_count < trigger_count:
return
if not thread_id or not active_turn_id:
events.append({
"type": "evaluation.steering.boundary_missing",
"thread_id_present": bool(thread_id),
"turn_id_present": bool(active_turn_id),
})
failure = "app_server_steering_turn_id_missing"
return
message_id = _native_steering_message_id(steering_index, len(schedule))
events.append({"type": "evaluation.steering.queued", "status": "queued", "message_id": message_id})
request_id = await send_request(
"turn/steer",
{
"threadId": thread_id,
"expectedTurnId": active_turn_id,
"input": [{"type": "text", "text": text}],
"clientUserMessageId": message_id,
},
)
steering_requests[request_id] = (steering_index, message_id)
steering_index += 1
steering_request_pending = True
async def handle_response(payload: dict[str, Any]) -> None:
"""
按 initialize、thread/start、turn/start 的顺序推进原生轮次。
"""
nonlocal thread_id, failure, steering_request_pending
request_id = payload.get("id")
method = pending.pop(request_id, None) if type(request_id) is int else None
steering_request = steering_requests.pop(request_id, None) if type(request_id) is int else None
child_thread_id = child_thread_read_requests.pop(request_id, None) if type(request_id) is int else None
if method is None:
await send_server_error(request_id)
failure = failure or "unexpected_server_request"
return
if "error" in payload:
failure = failure or f"app_server_{method.replace('/', '_')}_error"
if method == "turn/steer":
steering_request_pending = False
events.append({"type": "evaluation.steering.failed", "status": "failed",
"message_id": steering_request[1] if steering_request else NATIVE_STEERING_MESSAGE_ID})
elif method == "thread/read":
events.append({"type": "evaluation.child_thread_read.failed", "thread_id": child_thread_id})
return
if method == "initialize":
await send_notification("initialized")
await send_request(
"thread/start",
{"cwd": str(work_dir), "model": model, "sandbox": "read-only", "approvalPolicy": "never", "ephemeral": True},
)
elif method == "thread/start":
result = payload.get("result")
thread = result.get("thread") if isinstance(result, dict) else None
thread_id = thread.get("id") if isinstance(thread, dict) else None
if not isinstance(thread_id, str) or not thread_id:
failure = failure or "app_server_thread_id_missing"
return
await send_request(
"turn/start",
{
"threadId": thread_id,
"input": [{"type": "text", "text": prompt}],
"model": model,
"effort": reasoning_effort,
},
)
elif method == "turn/steer":
steering_request_pending = False
events.append({"type": "evaluation.steering.applied", "status": "applied",
"message_id": steering_request[1] if steering_request else NATIVE_STEERING_MESSAGE_ID})
await send_next_steering_if_ready()
elif method == "thread/read":
result = payload.get("result")
thread = result.get("thread") if isinstance(result, dict) else None
_append_thread_history_events(events, thread)
try:
async with asyncio.timeout(timeout):
await send_request(
"initialize",
{"clientInfo": {"name": "moviepilot-evaluation", "version": "1"},
"capabilities": {"experimentalApi": True}},
)
while True:
line = await process.stdout.readline()
if not line:
break
stdout_bytes += len(line)
if stdout_bytes + len(stderr) > MAX_PROCESS_OUTPUT_BYTES:
raise RuntimeError("原生 app-server 输出超过 8 MiB")
try:
payload = json.loads(line)
except (TypeError, ValueError):
events.append({"type": "evaluation.invalid_event", "sha256": hashlib.sha256(line).hexdigest()})
failure = failure or "invalid_native_events"
continue
if not isinstance(payload, dict):
failure = failure or "invalid_native_events"
continue
if "id" in payload and "method" not in payload:
await handle_response(payload)
if turn_completed and pending == {}:
break
continue
method = payload.get("method")
if not isinstance(method, str):
failure = failure or "invalid_native_events"
continue
event = _normalize_app_server_notification(method, payload.get("params"))
if event is not None:
events.append(event)
item = event.get("item")
if (
isinstance(item, dict)
and item.get("type") == "collab_tool_call"
and item.get("tool") == "spawn_agent"
):
child_thread_ids.update(
child_id for child_id in item.get("receiver_thread_ids", [])
if isinstance(child_id, str) and child_id
)
if (
event.get("type") == "item.completed"
and isinstance(item, dict)
and item.get("type") == "mcpToolCall"
and str(item.get("tool") or "").rsplit(".", 1)[-1] == "moviepilot_api"
):
business_tool_count += 1
await send_next_steering_if_ready()
if method == "turn/started":
active_turn_id = event.get("turn_id") if isinstance(event.get("turn_id"), str) else None
if method == "turn/completed":
turn_completed = True
if schedule and any(value == "turn/steer" for value in pending.values()):
continue
await send_child_thread_reads_if_ready()
if not child_thread_read_requests:
break
continue
if "id" in payload:
await send_server_error(payload["id"])
failure = failure or "unexpected_server_request"
except (RuntimeError, TimeoutError, OSError) as error:
failure = failure or type(error).__name__
finally:
if process.returncode is None:
if turn_completed and failure is None:
process.stdin.close()
try:
await asyncio.wait_for(process.wait(), timeout=10)
except TimeoutError:
failure = failure or "app_server_shutdown_timeout"
await _stop_process(process)
else:
await _stop_process(process)
if not stderr_reader.done():
stderr_reader.cancel()
reader_result = (await asyncio.gather(stderr_reader, return_exceptions=True))[0]
if isinstance(reader_result, BaseException) and not isinstance(reader_result, asyncio.CancelledError):
failure = failure or type(reader_result).__name__
return {
"returncode": process.returncode,
"stdout": "\n".join(json.dumps(event, ensure_ascii=False) for event in events) + ("\n" if events else ""),
"stderr": stderr.decode("utf-8", errors="replace"), "error_type": failure,
}
def _events(text: str) -> tuple[list[dict[str, Any]], str, bool]:
"""
保留 JSONL 原生事件;只有完成事件之后的有效最终报告才由 oracle 判断。
"""
events, final_text, completed, malformed = [], "", False, False
for line in text.splitlines():
try:
event = json.loads(line)
if not isinstance(event, dict):
raise ValueError("原生客户端输出非事件对象")
except (ValueError, TypeError):
malformed = True
events.append({"type": "evaluation.invalid_event", "sha256": hashlib.sha256(line.encode()).hexdigest()})
continue
events.append(event)
if event.get("type") == "turn.started":
completed, final_text = False, ""
item = event.get("item")
if event.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message":
final_text = str(item.get("text") or "")
if event.get("type") == "turn.completed":
completed = True
elif event.get("type") in {"turn.failed", "error"}:
completed = False
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()
streamed_output: dict[str, str] = {}
streamed_input: set[str] = set()
child_thread_ids: set[str] = set()
for event in events:
if event.get("type") not in {"item.started", "item.completed"}:
continue
item = event.get("item")
if not isinstance(item, dict) or item.get("type") != "collab_tool_call" or item.get("tool") != "spawn_agent":
continue
child_thread_ids.update(
receiver_thread_id
for receiver_thread_id in item.get("receiver_thread_ids", [])
if isinstance(receiver_thread_id, str)
)
for event in events:
event_type = event.get("type")
if event_type == "item.command_execution.output_delta":
item_id = event.get("item_id")
delta = event.get("delta")
if isinstance(item_id, str) and isinstance(delta, str):
streamed_output[item_id] = streamed_output.get(item_id, "") + delta
continue
if event_type == "item.command_execution.terminal_interaction":
item_id = event.get("item_id")
stdin = event.get("stdin")
if isinstance(item_id, str) and isinstance(stdin, str) and "MOVIEPILOT_TERMINAL_OK" in stdin:
streamed_input.add(item_id)
continue
if event_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 "")
if not output:
output = streamed_output.get(identity, "")
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")
# 终端输入必须有 app-server 的 terminalInteraction 事件作为证据。
# 聚合输出可能只是命令自身打印了相同字符串,不能据此冒充 stdin 写入。
terminal_input_observed = world.scenario.kind == "terminal" and identity in streamed_input
thread_id = event.get("thread_id")
scope_kind = "subagent" if thread_id in child_thread_ids else "conversation"
world.record_command(command.strip(), {
"action": "start" if world.scenario.kind == "terminal" else "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, "terminal_input_observed": terminal_input_observed,
}, action="start" if world.scenario.kind == "terminal" else "run",
scope_kind=scope_kind, scope_task_id=thread_id if isinstance(thread_id, str) else None)
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", [])}
fixture = {f"mcp__evaluation.{name}" for name in ("moviepilot_api", "read_skill", "read_tool_result")}
return bool(usage.get("probe_requests") == 1 and not usage.get("model_calls")
and not usage.get("rejected_requests") and not server_stats.get("world_calls")
and ("tool_search" in retained or fixture <= retained))
async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str, *, probe_only: bool) -> dict[str, Any]:
"""
每次运行创建新目录、新模型预算和新世界,源码与 oracle 从不进入模型工作目录。
"""
from scripts.evaluation.__main__ import _provenance
world = EvaluationWorld(scenario_id)
fixture_context = _native_browser_fixture() if world.scenario.kind == "browser" else nullcontext(None)
with fixture_context as fixture:
if fixture is not None:
world.configure_browser_url(fixture[0])
provenance = _provenance(world)
started = time.monotonic()
result: dict[str, Any] = {}
failure = None
with tempfile.TemporaryDirectory(prefix="moviepilot-native-evaluation-") as directory:
control_dir = Path(directory)
work_dir = control_dir / "workspace"
work_dir.mkdir()
catalog_result = await _execute([executable, "debug", "models", "--bundled"], "", _worker_environment(), work_dir, 10)
if catalog_result.get("error_type") or catalog_result.get("returncode") != 0:
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")
browser_runtime = (
_browser_runtime_configuration(executable, control_dir)
if scenario_id in NATIVE_BROWSER_SCENARIOS else None
)
extra_native_tools = NATIVE_SHELL_TOOLS if scenario_id in {
"command_execution",
"terminal_session",
"terminal_pty_session",
"subagent_terminal_share",
} else frozenset()
if browser_runtime is not None:
extra_native_tools |= NATIVE_BROWSER_TOOLS
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, browser_runtime=browser_runtime,
)
use_app_server = scenario_id in (NATIVE_TERMINAL_SCENARIOS | NATIVE_STEERING_SCENARIOS)
command = (
[executable, "app-server", "--listen", "stdio://", "--strict-config"]
if use_app_server
else [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():
command.extend(["-c", f"{key}={_toml(value)}"])
environment = _worker_environment()
environment.update(MOVIEPILOT_EVAL_MODEL_TOKEN=proxy.bearer_token,
MOVIEPILOT_EVAL_MCP_TOKEN=server.bearer_token)
if use_app_server:
# app-server 必须使用临时状态根,避免读取或写入调用方的桌面会话和插件配置。
environment["CODEX_HOME"] = str(control_dir)
else:
command.append("-")
try:
result = await (
_execute_app_server(
command, world.model_input(), environment, work_dir, settings.timeout_seconds,
model=settings.model, reasoning_effort=settings.reasoning_effort,
steering_plan=(world.scenario.steering_schedule() if scenario_id in NATIVE_STEERING_SCENARIOS else ()),
)
if use_app_server
else _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__
local_tokens = tuple(
value for value in (proxy.bearer_token, server.bearer_token, settings.account_id)
if isinstance(value, str) and value
)
usage = proxy.snapshot()
server_stats = server.stats
skill_sha256 = server.skill_sha256
if fixture is not None and fixture[1].get("clicked"):
# 原生浏览器插件绕过评测 MCP 服务;回环回调独立证明按钮点击已执行。
world.record_browser("native_browser", {
"success": True, "execution_outcome": "succeeded", "rendered": "BROWSER_OK",
})
events, final_text, completed = _events(result.get("stdout", ""))
if scenario_id in {
"command_execution",
"terminal_session",
"terminal_pty_session",
"subagent_terminal_share",
}:
_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)
source_unchanged = _provenance(world) == provenance
if not source_unchanged:
failure = failure or "source_changed_during_run"
stderr = result.get("stderr", "")
report = {
**evaluate(
world,
final_report,
events,
[event for event in events if str(event.get("type", "")).startswith("evaluation.steering.")],
).to_dict(),
**provenance, **usage, "source_unchanged": source_unchanged,
"evidence_kind": "codex_native_probe" if probe_only else "codex_native_controlled",
"intelligence_evaluated": usage["completed_model_calls"] > 0, "codex_comparison": False,
"model": settings.public_metadata(), "elapsed_seconds": round(time.monotonic() - started, 3),
"cli_version": SUPPORTED_CLI_VERSION, "native_exit_code": result.get("returncode"),
"native_model_catalog": catalog_metadata,
"native_turn_completed": completed, "runner_error_type": failure,
"native_events": events, "native_stderr": stderr,
"native_stderr_sha256": hashlib.sha256(stderr.encode()).hexdigest(),
"final_text": final_text, "final_report": final_report, "ledger": world.ledger,
"fixture_server": server_stats, "exposed_skill_sha256": skill_sha256,
"tool_catalog_scope": (
"native_control_tools_shared_fixture_mcp_and_browser_plugin"
if scenario_id in NATIVE_BROWSER_SCENARIOS and browser_runtime is not None
else "native_control_tools_and_shared_fixture_mcp"
),
"browser_runtime_enabled": bool(scenario_id in NATIVE_BROWSER_SCENARIOS and browser_runtime is not None),
"native_transport": "app_server" if use_app_server else "exec",
}
report["task_passed"] = report["passed"]
report["passed"] = bool(report["passed"] and completed and result.get("returncode") == 0 and not failure)
report["probe_ready"] = bool(probe_only and not failure and _probe_ready(usage, server_stats))
checked: dict[str, Any] = _validate_report(
_redact(report, local_tokens), settings.api_key, settings.account_id,
)
checked["native_stderr_sha256"] = hashlib.sha256(checked["native_stderr"].encode()).hexdigest()
return checked
def run_codex(scenario_id: str, settings: ModelSettings, *, executable: str = "codex", probe_only: bool = False) -> dict[str, Any]:
"""
仅运行已核对的原生版本,探针不会向真实供应商发送请求。
"""
if settings.wire_api != "responses":
raise RuntimeError("原生 Codex 对照目前要求 Responses provider;当前配置是 Chat Completions")
binary = shutil.which(executable)
if binary is None:
raise RuntimeError("未找到原生 Codex CLI")
version = subprocess.run([binary, "--version"], capture_output=True, text=True, check=False,
env=_worker_environment(), timeout=10)
if version.returncode != 0 or version.stdout.strip() != SUPPORTED_CLI_VERSION:
raise RuntimeError(f"此适配器只核对过 {SUPPORTED_CLI_VERSION},请先验证当前 CLI 合同")
return asyncio.run(_run_codex(scenario_id, settings, binary, probe_only=probe_only))