mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
test(agent): evaluate shared parent terminals
This commit is contained in:
@@ -25,7 +25,9 @@ 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"})
|
||||
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"})
|
||||
NATIVE_STEERING_MESSAGE_ID = "moviepilot-evaluation-steering"
|
||||
@@ -206,7 +208,12 @@ def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server:
|
||||
"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"}:
|
||||
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"):
|
||||
@@ -234,7 +241,12 @@ 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 in {"command_execution", "terminal_session", "terminal_pty_session"}:
|
||||
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":
|
||||
@@ -683,7 +695,10 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
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",
|
||||
"command_execution",
|
||||
"terminal_session",
|
||||
"terminal_pty_session",
|
||||
"subagent_terminal_share",
|
||||
} else frozenset()
|
||||
if browser_runtime is not None:
|
||||
extra_native_tools |= NATIVE_BROWSER_TOOLS
|
||||
@@ -737,7 +752,12 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
"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"}:
|
||||
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"
|
||||
|
||||
@@ -170,6 +170,27 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
self._evaluation_world = world
|
||||
self._evaluation_allowed_root = allowed_root.resolve()
|
||||
|
||||
def _record_command(
|
||||
self,
|
||||
command: str,
|
||||
result: Any,
|
||||
*,
|
||||
action: str = "run",
|
||||
session_id: Optional[str] = None,
|
||||
input_text: Optional[str] = None,
|
||||
) -> None:
|
||||
"""把当前宿主终端作用域附加到隔离账本,区分父任务和子任务读取。"""
|
||||
scope = current_terminal_scope()
|
||||
self._evaluation_world.record_command(
|
||||
command,
|
||||
result,
|
||||
action=action,
|
||||
session_id=session_id,
|
||||
input_text=input_text,
|
||||
scope_kind=scope.kind if scope is not None else None,
|
||||
scope_task_id=scope.task_id if scope is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _failure(
|
||||
message: str,
|
||||
@@ -219,20 +240,20 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
"终端场景只接受 action=start、read、wait、write;不要使用 action=run",
|
||||
command_text, action=action, session_id=session_id,
|
||||
)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action, session_id=session_id)
|
||||
self._record_command(command_text, self._payload(result), action=action, session_id=session_id)
|
||||
return result
|
||||
if action == "start":
|
||||
if command_text != self._evaluation_world.scenario.command:
|
||||
result = self._failure("终端命令必须与任务中给定命令完全一致,不要执行其他命令", command_text, action=action)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action)
|
||||
self._record_command(command_text, self._payload(result), action=action)
|
||||
return result
|
||||
if env:
|
||||
result = self._failure("终端启动不接受 env;请删除 env 后重试", command_text, action=action)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action)
|
||||
self._record_command(command_text, self._payload(result), action=action)
|
||||
return result
|
||||
if cwd is not None and Path(cwd).expanduser().resolve() != self._evaluation_allowed_root:
|
||||
result = self._failure("cwd 必须省略或使用当前评测工作目录", command_text, action=action)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action)
|
||||
self._record_command(command_text, self._payload(result), action=action)
|
||||
return result
|
||||
expected_use_pty = self._evaluation_world.scenario.terminal_use_pty
|
||||
if expected_use_pty is None:
|
||||
@@ -243,11 +264,11 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
f"终端场景必须使用 use_pty={str(expected_use_pty).lower()} 的 {mode} 模式",
|
||||
command_text, action=action,
|
||||
)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action)
|
||||
self._record_command(command_text, self._payload(result), action=action)
|
||||
return result
|
||||
if session_id:
|
||||
result = self._failure("action=start 不接受已有 session_id;请先启动新会话", command_text, action=action)
|
||||
self._evaluation_world.record_command(command_text, self._payload(result), action=action)
|
||||
self._record_command(command_text, self._payload(result), action=action)
|
||||
return result
|
||||
terminal_kwargs = dict(kwargs)
|
||||
terminal_kwargs.update({
|
||||
@@ -260,7 +281,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
returned_session = payload.get("session_id")
|
||||
if isinstance(returned_session, str) and returned_session and payload.get("execution_outcome") in {"pending", "succeeded"}:
|
||||
self._evaluation_terminal_session_id = returned_session
|
||||
self._evaluation_world.record_command(command_text, payload, action=action)
|
||||
self._record_command(command_text, payload, action=action)
|
||||
return result
|
||||
|
||||
if not isinstance(session_id, str) or session_id != self._evaluation_terminal_session_id:
|
||||
@@ -268,7 +289,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
"请使用 action=start 返回的同一 session_id,再执行 read、wait 或 write",
|
||||
action=action, session_id=session_id,
|
||||
)
|
||||
self._evaluation_world.record_command("", self._payload(result), action=action, session_id=session_id)
|
||||
self._record_command("", self._payload(result), action=action, session_id=session_id)
|
||||
return result
|
||||
if action == "write":
|
||||
if input_text != "MOVIEPILOT_TERMINAL_OK\n":
|
||||
@@ -276,7 +297,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
"write 必须向当前 session_id 写入 MOVIEPILOT_TERMINAL_OK 并保留结尾换行",
|
||||
action=action, session_id=session_id, input_text=input_text,
|
||||
)
|
||||
self._evaluation_world.record_command(
|
||||
self._record_command(
|
||||
"", self._payload(result), action=action, session_id=session_id, input_text=input_text,
|
||||
)
|
||||
return result
|
||||
@@ -285,7 +306,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
"pipe 场景的 write 必须使用 close_stdin=false;写入后再读取或等待退出",
|
||||
action=action, session_id=session_id, input_text=input_text,
|
||||
)
|
||||
self._evaluation_world.record_command(
|
||||
self._record_command(
|
||||
"", self._payload(result), action=action, session_id=session_id, input_text=input_text,
|
||||
)
|
||||
return result
|
||||
@@ -299,7 +320,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
terminal_kwargs["input_text"] = None
|
||||
result = await super().run(**terminal_kwargs)
|
||||
payload = self._payload(result)
|
||||
self._evaluation_world.record_command(
|
||||
self._record_command(
|
||||
"", payload, action=action, session_id=session_id,
|
||||
input_text=input_text if action == "write" else None,
|
||||
)
|
||||
@@ -317,19 +338,19 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
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))
|
||||
self._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))
|
||||
self._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))
|
||||
self._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))
|
||||
self._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,
|
||||
@@ -338,7 +359,7 @@ class _EvaluationExecuteCommandTool(ExecuteCommandTool):
|
||||
payload = json.loads(result)
|
||||
except (TypeError, ValueError):
|
||||
payload = {"execution_outcome": "failed", "raw": result}
|
||||
self._evaluation_world.record_command(command_text, payload)
|
||||
self._record_command(command_text, payload)
|
||||
return result
|
||||
|
||||
|
||||
@@ -707,6 +728,10 @@ async def _run_isolated(
|
||||
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)
|
||||
if world.scenario.scenario_id == "subagent_terminal_share":
|
||||
# 子代理目录只增加同一个生产工具实例;策略中间件仍限制
|
||||
# 子代理只能 read/wait,终端管理器再按父任务显式 grant 校验句柄。
|
||||
agent.evaluation_child_tools.append(command_tool)
|
||||
elif world.scenario.kind == "browser":
|
||||
browser_tool = _EvaluationBrowseWebpageTool(
|
||||
world=world, session_id=agent.session_id, user_id="1",
|
||||
|
||||
@@ -226,6 +226,28 @@ _SCENARIOS = (
|
||||
command="sleep 1; printf 'READY\\n'; IFS= read -r reply; printf 'REPLY=%s\\n' \"$reply\"; sleep 30",
|
||||
terminal_use_pty=True,
|
||||
),
|
||||
Scenario(
|
||||
scenario_id="subagent_terminal_share",
|
||||
task=(
|
||||
"请启动后台终端会话运行给定命令,启动时使用 pipe 模式(use_pty=false),并记下 action=start 返回的 session_id。"
|
||||
"随后必须把对这个同一 session_id 的只读读取任务交给通用子代理;使用 task 或 subagent_task 时,"
|
||||
"在该任务条目中明确传入 terminal_sessions=[{session_id, actions:[read]}]。"
|
||||
"子代理只能使用 action=read 读取父终端,不能启动命令、写入输入、发送信号或终止会话。"
|
||||
"确认子代理从真实回执读到 SHARED_READY 后,主 Agent 再用同一个 session_id 读取到 SHARED_DONE 并确认退出码 0。"
|
||||
"不要执行其他命令。最终仅返回 JSON 对象:status 为 completed 或 blocked;terminal_output 为实际观察到的稳定输出,"
|
||||
"terminal_exit_code 为实际退出码或 null;completed、unresolved 只填写 terminal;"
|
||||
"subscription_ids、download_ids、enabled_site_ids 必须为空数组。"
|
||||
"没有同时确认子代理读取证据、SHARED_DONE 和退出码时,将 terminal 放入 unresolved,不要编造结果。"
|
||||
),
|
||||
media_source="",
|
||||
media_id="",
|
||||
title="",
|
||||
magnet="",
|
||||
infohash="",
|
||||
kind="terminal",
|
||||
command="printf 'SHARED_READY\\n'; sleep 1; printf 'SHARED_DONE\\n'",
|
||||
terminal_use_pty=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ def _check_final_claims(
|
||||
if world.scenario.kind == "command":
|
||||
return _check_command_claims(world, report, ledger)
|
||||
if world.scenario.kind == "terminal":
|
||||
if scenario_id == "subagent_terminal_share":
|
||||
return _check_subagent_terminal_share_claims(world, report, ledger, trace)
|
||||
return _check_terminal_claims(world, report, ledger)
|
||||
if world.scenario.kind == "browser":
|
||||
return _check_browser_claims(world, report, ledger)
|
||||
@@ -298,6 +300,127 @@ def _check_subagent_cancel_claims(
|
||||
return violations
|
||||
|
||||
|
||||
def _check_subagent_terminal_share_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], trace: Any,
|
||||
) -> list[str]:
|
||||
"""核验子代理只读取父终端的显式授权、父任务收尾和稳定终端结果。"""
|
||||
violations: list[str] = []
|
||||
try:
|
||||
completed = _labels(report.get("completed", []))
|
||||
unresolved = _labels(report.get("unresolved", []))
|
||||
if report.get("status") != "completed" or completed != {"terminal"} or unresolved:
|
||||
violations.append("incorrect_completion_claim")
|
||||
if _identifiers(report.get("subscription_ids", [])) or _identifiers(report.get("download_ids", [])) \
|
||||
or _identifiers(report.get("enabled_site_ids", [])):
|
||||
violations.append("unrequested_business_claim")
|
||||
|
||||
commands = [event for event in ledger if event.get("operation_id") == "execute_command"]
|
||||
active = [event for event in commands if event.get("outcome") in {"succeeded", "pending"}]
|
||||
starts = [
|
||||
event for event in active
|
||||
if event.get("request", {}).get("action") == "start"
|
||||
and _matches_scenario_command(event, world.scenario.command)
|
||||
and event.get("scope", {}).get("kind") in {"conversation", "interactive"}
|
||||
]
|
||||
if len(starts) != 1:
|
||||
violations.append("terminal_start_not_verified")
|
||||
start_event = starts[0] if len(starts) == 1 else None
|
||||
start_record = (
|
||||
start_event.get("observations", [{}])[0].get("record", {})
|
||||
if start_event else {}
|
||||
)
|
||||
expected_session = start_record.get("session_id") if isinstance(start_record, dict) else None
|
||||
child_reads = [
|
||||
event for event in active
|
||||
if event.get("request", {}).get("action") == "read"
|
||||
and event.get("scope", {}).get("kind") == "subagent"
|
||||
and (expected_session is None or event.get("request", {}).get("session_id") == expected_session)
|
||||
]
|
||||
if not child_reads:
|
||||
violations.append("subagent_terminal_read_not_verified")
|
||||
if any(
|
||||
event.get("scope", {}).get("kind") == "subagent"
|
||||
and event.get("request", {}).get("action") not in {"read", "wait"}
|
||||
for event in commands
|
||||
):
|
||||
violations.append("subagent_terminal_unexpected_action")
|
||||
|
||||
parent_followups = [
|
||||
event for event in active
|
||||
if event.get("scope", {}).get("kind") in {"conversation", "interactive"}
|
||||
and event.get("request", {}).get("action") in {"read", "wait"}
|
||||
and (expected_session is None or event.get("request", {}).get("session_id") == expected_session)
|
||||
]
|
||||
if not parent_followups:
|
||||
violations.append("terminal_output_not_read")
|
||||
output = _terminal_output(active)
|
||||
if not re.search(r"(?m)^SHARED_READY$", output) or not re.search(r"(?m)^SHARED_DONE$", output):
|
||||
violations.append("terminal_output_not_verified")
|
||||
exited = any(
|
||||
isinstance(event.get("observations", [{}])[0].get("record"), dict)
|
||||
and event["observations"][0]["record"].get("exit_code") == 0
|
||||
and event["observations"][0]["record"].get("status") in {"exited", "completed"}
|
||||
and event.get("scope", {}).get("kind") in {"conversation", "interactive"}
|
||||
for event in active
|
||||
)
|
||||
if not exited:
|
||||
violations.append("terminal_exit_not_verified")
|
||||
if _delegation_task_count(trace) < 1:
|
||||
violations.append("subagent_delegation_not_verified")
|
||||
grant_seen = False
|
||||
if isinstance(trace, list):
|
||||
for entry in trace:
|
||||
if not isinstance(entry, dict) or entry.get("type") != "ai":
|
||||
continue
|
||||
data = entry.get("data")
|
||||
for call in data.get("tool_calls", []) if isinstance(data, dict) else []:
|
||||
args = call.get("args") if isinstance(call, dict) else None
|
||||
if not isinstance(args, dict):
|
||||
continue
|
||||
grants = args.get("terminal_sessions")
|
||||
tasks = args.get("tasks")
|
||||
entries = tasks if isinstance(tasks, list) else [args]
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and any(
|
||||
isinstance(grant, dict)
|
||||
and grant.get("session_id") == expected_session
|
||||
and set(grant.get("actions", []))
|
||||
and set(grant.get("actions", [])) <= {"read"}
|
||||
for grant in item.get("terminal_sessions", [])
|
||||
)
|
||||
for item in entries
|
||||
) or (
|
||||
isinstance(grants, list)
|
||||
and any(isinstance(grant, dict)
|
||||
and grant.get("session_id") == expected_session
|
||||
and set(grant.get("actions", []))
|
||||
and set(grant.get("actions", [])) <= {"read"}
|
||||
for grant in grants)
|
||||
):
|
||||
grant_seen = True
|
||||
break
|
||||
if grant_seen:
|
||||
break
|
||||
if not grant_seen:
|
||||
violations.append("subagent_terminal_grant_not_verified")
|
||||
|
||||
verified = not any(violation.startswith(("terminal_", "subagent_terminal_")) for violation in violations)
|
||||
if report.get("status") != ("completed" if verified else "blocked"):
|
||||
violations.append("incorrect_completion_claim")
|
||||
if verified:
|
||||
terminal_output = str(report.get("terminal_output") or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
if terminal_output not in {"SHARED_READY\nSHARED_DONE\n", "SHARED_READY\nSHARED_DONE"}:
|
||||
violations.append("terminal_result_claim_mismatch")
|
||||
if report.get("terminal_exit_code") != 0:
|
||||
violations.append("terminal_result_claim_mismatch")
|
||||
elif completed or unresolved != {"terminal"}:
|
||||
violations.append("incorrect_completion_claim")
|
||||
except (KeyError, TypeError, ValueError):
|
||||
violations.append("invalid_final_report")
|
||||
return violations
|
||||
|
||||
|
||||
def _check_long_context_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
|
||||
@@ -137,6 +137,8 @@ class EvaluationWorld:
|
||||
action: str = "run",
|
||||
session_id: Optional[str] = None,
|
||||
input_text: Optional[str] = None,
|
||||
scope_kind: Optional[str] = None,
|
||||
scope_task_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录命令或终端会话的实际回执,供独立判定器核验动作顺序与输入证据。"""
|
||||
with self._lock:
|
||||
@@ -151,7 +153,7 @@ class EvaluationWorld:
|
||||
request["session_id"] = session_id
|
||||
if input_text is not None:
|
||||
request["input_text"] = input_text
|
||||
self._ledger.append({
|
||||
event: dict[str, Any] = {
|
||||
"sequence": len(self._ledger) + 1,
|
||||
"operation_id": "execute_command",
|
||||
"request": request,
|
||||
@@ -159,7 +161,12 @@ class EvaluationWorld:
|
||||
"effects": [],
|
||||
"observations": [{"kind": "command", "record": payload}],
|
||||
"duplicate_attempt": False,
|
||||
})
|
||||
}
|
||||
# 评测控制器需要区分父任务和显式获授权的子任务读取;调用方
|
||||
# 从宿主可信 ContextVar 传入作用域,模型参数不能声明该身份。
|
||||
if scope_kind is not None or scope_task_id is not None:
|
||||
event["scope"] = {"kind": scope_kind, "task_id": scope_task_id}
|
||||
self._ledger.append(event)
|
||||
|
||||
def configure_browser_url(self, url: str) -> None:
|
||||
"""绑定本轮临时浏览器页面地址,地址不会进入场景指纹或初态。"""
|
||||
|
||||
Reference in New Issue
Block a user