test(agent): evaluate shared parent terminals

This commit is contained in:
jxxghp
2026-09-12 06:10:06 +08:00
parent 5cd66e08df
commit da33988c50
11 changed files with 398 additions and 30 deletions

View File

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

View File

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

View File

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

View File

@@ -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]:

View File

@@ -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:
"""绑定本轮临时浏览器页面地址,地址不会进入场景指纹或初态。"""