mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 06:15:14 +08:00
fix(agent): verify steering boundary in evaluation
This commit is contained in:
@@ -27,6 +27,8 @@ 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_BROWSER_SCENARIOS = frozenset({"browser_navigation"})
|
||||
NATIVE_STEERING_SCENARIOS = frozenset({"steering_long_context"})
|
||||
NATIVE_STEERING_MESSAGE_ID = "moviepilot-evaluation-steering"
|
||||
|
||||
|
||||
def _browser_runtime_configuration(executable: str, instruction_dir: Path) -> dict[str, Any] | None:
|
||||
@@ -283,6 +285,7 @@ async def _execute(command: list[str], prompt: str, environment: dict[str, str],
|
||||
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()
|
||||
@@ -325,6 +328,13 @@ def _normalize_app_server_item(item: Any) -> dict[str, Any]:
|
||||
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"),
|
||||
@@ -338,9 +348,11 @@ def _normalize_app_server_notification(method: str, params: Any) -> dict[str, An
|
||||
"""保留终端、消息和轮次终态,把其他 app-server 通知降为可审计摘要。"""
|
||||
payload = params if isinstance(params, dict) else {}
|
||||
if method == "turn/started":
|
||||
return {"type": "turn.started"}
|
||||
turn = payload.get("turn") if isinstance(payload.get("turn"), dict) else {}
|
||||
return {"type": "turn.started", "turn_id": turn.get("id")}
|
||||
if method == "turn/completed":
|
||||
return {"type": "turn.completed"}
|
||||
turn = payload.get("turn") if isinstance(payload.get("turn"), dict) else {}
|
||||
return {"type": "turn.completed", "turn_id": turn.get("id")}
|
||||
if method == "item/started":
|
||||
return {"type": "item.started", "item": _normalize_app_server_item(payload.get("item"))}
|
||||
if method == "item/completed":
|
||||
@@ -369,12 +381,13 @@ def _normalize_app_server_notification(method: str, params: Any) -> dict[str, An
|
||||
|
||||
async def _execute_app_server(
|
||||
command: list[str], prompt: str, environment: dict[str, str], work_dir: Path, timeout: int,
|
||||
*, model: str, reasoning_effort: str,
|
||||
*, model: str, reasoning_effort: str, steering_message: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""通过 Codex app-server 的 JSON-RPC 驱动可写 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,
|
||||
)
|
||||
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
|
||||
stderr = bytearray()
|
||||
@@ -384,8 +397,10 @@ async def _execute_app_server(
|
||||
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
|
||||
steering_sent = False
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any]) -> int:
|
||||
"""发送带序号的 JSON-RPC 请求,并记录响应所属方法。"""
|
||||
@@ -416,7 +431,7 @@ async def _execute_app_server(
|
||||
|
||||
async def handle_response(payload: dict[str, Any]) -> None:
|
||||
"""按 initialize、thread/start、turn/start 的顺序推进原生轮次。"""
|
||||
nonlocal thread_id, failure
|
||||
nonlocal thread_id, failure, steering_sent
|
||||
request_id = payload.get("id")
|
||||
method = pending.pop(request_id, None) if type(request_id) is int else None
|
||||
if method is None:
|
||||
@@ -425,6 +440,9 @@ async def _execute_app_server(
|
||||
return
|
||||
if "error" in payload:
|
||||
failure = failure or f"app_server_{method.replace('/', '_')}_error"
|
||||
if method == "turn/steer":
|
||||
events.append({"type": "evaluation.steering.failed", "status": "failed",
|
||||
"message_id": NATIVE_STEERING_MESSAGE_ID})
|
||||
return
|
||||
if method == "initialize":
|
||||
await send_notification("initialized")
|
||||
@@ -448,6 +466,10 @@ async def _execute_app_server(
|
||||
"effort": reasoning_effort,
|
||||
},
|
||||
)
|
||||
elif method == "turn/steer":
|
||||
steering_sent = True
|
||||
events.append({"type": "evaluation.steering.applied", "status": "applied",
|
||||
"message_id": NATIVE_STEERING_MESSAGE_ID})
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
@@ -474,6 +496,8 @@ async def _execute_app_server(
|
||||
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):
|
||||
@@ -482,8 +506,42 @@ async def _execute_app_server(
|
||||
event = _normalize_app_server_notification(method, payload.get("params"))
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
if (
|
||||
steering_message
|
||||
and not steering_sent
|
||||
and event.get("type") == "item.completed"
|
||||
and isinstance(event.get("item"), dict)
|
||||
and event["item"].get("type") == "mcpToolCall"
|
||||
and str(event["item"].get("tool") or "").rsplit(".", 1)[-1] == "moviepilot_api"
|
||||
):
|
||||
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 = failure or "app_server_steering_turn_id_missing"
|
||||
else:
|
||||
events.append({"type": "evaluation.steering.queued", "status": "queued",
|
||||
"message_id": NATIVE_STEERING_MESSAGE_ID})
|
||||
await send_request(
|
||||
"turn/steer",
|
||||
{
|
||||
"threadId": thread_id,
|
||||
"expectedTurnId": active_turn_id,
|
||||
"input": [{"type": "text", "text": steering_message}],
|
||||
"clientUserMessageId": NATIVE_STEERING_MESSAGE_ID,
|
||||
},
|
||||
)
|
||||
# 防止一个模型回合包含多个业务工具时重复注入;只有成功响应
|
||||
# 才会追加 applied,错误会保留失败事件并让报告判定不通过。
|
||||
steering_sent = True
|
||||
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 steering_message and any(value == "turn/steer" for value in pending.values()):
|
||||
continue
|
||||
break
|
||||
if "id" in payload:
|
||||
await send_server_error(payload["id"])
|
||||
@@ -635,7 +693,7 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
config = _configuration(
|
||||
settings, proxy, server, control_dir, scenario_id=scenario_id, browser_runtime=browser_runtime,
|
||||
)
|
||||
use_app_server = scenario_id in NATIVE_TERMINAL_SCENARIOS
|
||||
use_app_server = scenario_id in (NATIVE_TERMINAL_SCENARIOS | NATIVE_STEERING_SCENARIOS)
|
||||
command = (
|
||||
[executable, "app-server", "--listen", "stdio://", "--strict-config"]
|
||||
if use_app_server
|
||||
@@ -657,6 +715,7 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
_execute_app_server(
|
||||
command, world.model_input(), environment, work_dir, settings.timeout_seconds,
|
||||
model=settings.model, reasoning_effort=settings.reasoning_effort,
|
||||
steering_message=(world.scenario.steering_message if scenario_id in NATIVE_STEERING_SCENARIOS else ""),
|
||||
)
|
||||
if use_app_server
|
||||
else _execute(command, world.model_input(), environment, work_dir, settings.timeout_seconds)
|
||||
@@ -688,7 +747,13 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
failure = failure or "source_changed_during_run"
|
||||
stderr = result.get("stderr", "")
|
||||
report = {
|
||||
**evaluate(world, final_report, events).to_dict(), **provenance, **usage, "source_unchanged": source_unchanged,
|
||||
**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),
|
||||
@@ -705,7 +770,7 @@ async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str,
|
||||
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 scenario_id in NATIVE_TERMINAL_SCENARIOS else "exec",
|
||||
"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)
|
||||
|
||||
@@ -227,7 +227,12 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An
|
||||
cleanup_errors.extend(await _close_model_clients(model))
|
||||
final_text = str(capture.get("final_text") or "")
|
||||
final_report = _parse_final(final_text)
|
||||
grade = evaluate(world, final_report, capture.get("raw_messages", [])).to_dict()
|
||||
grade = evaluate(
|
||||
world,
|
||||
final_report,
|
||||
capture.get("raw_messages", []),
|
||||
capture.get("steering_events"),
|
||||
).to_dict()
|
||||
usage = tracker.snapshot()
|
||||
# 首次 HTTP 被拒绝不属于模型能力证据;有响应也仍不构成 Codex 配对比较。
|
||||
return {
|
||||
@@ -247,6 +252,7 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An
|
||||
"request_budgets": capture.get("request_budgets"),
|
||||
"agent_trace": capture.get("raw_messages", []), "task_plan": capture.get("task_plan"),
|
||||
"trace_tool_metrics": _trace_metrics(capture.get("raw_messages", [])),
|
||||
"steering_events": capture.get("steering_events", []),
|
||||
"ledger": world.ledger,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from threading import Lock, Thread
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, Iterator, Optional
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, Optional
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import uuid4
|
||||
@@ -69,11 +69,18 @@ class _Response:
|
||||
class _Transport:
|
||||
"""按生产固定路由反向匹配 operation,无法向任意主机发送请求。"""
|
||||
|
||||
def __init__(self, world: EvaluationWorld, **_kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
world: EvaluationWorld,
|
||||
*,
|
||||
after_operation: Optional[Callable[[str, dict[str, Any]], Awaitable[None]]] = None,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
"""绑定当前世界;执行器传来的认证头不记录、不使用。"""
|
||||
from app.agent.policy.api import resolve_api_route
|
||||
|
||||
self._world = world
|
||||
self._after_operation = after_operation
|
||||
self._routes = []
|
||||
for operation in _OPERATIONS:
|
||||
route = resolve_api_route(operation)
|
||||
@@ -101,7 +108,10 @@ class _Transport:
|
||||
path_params["subscribe_id"] = int(path_params["subscribe_id"])
|
||||
except ValueError:
|
||||
pass
|
||||
return _Response(self._world.execute(operation, path_params=path_params, query=params, body=json))
|
||||
response = self._world.execute(operation, path_params=path_params, query=params, body=json)
|
||||
if self._after_operation is not None:
|
||||
await self._after_operation(operation, response)
|
||||
return _Response(response)
|
||||
return _Response({"success": False, "execution_outcome": "failed", "message": "该 API 不属于受控评测目录"})
|
||||
|
||||
|
||||
@@ -534,7 +544,12 @@ def _agent_type() -> type:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _runtime_scope(directory: Path, world: EvaluationWorld) -> Iterator[Any]:
|
||||
def _runtime_scope(
|
||||
directory: Path,
|
||||
world: EvaluationWorld,
|
||||
*,
|
||||
after_operation: Optional[Callable[[str, dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Iterator[Any]:
|
||||
"""在独立 worker 中临时替换明确的外部边界,退出时恢复全部全局引用。"""
|
||||
from app.agent.api.executor import MoviePilotApiExecutor
|
||||
from app.agent.runtime import AgentRuntimeManager
|
||||
@@ -549,7 +564,7 @@ def _runtime_scope(directory: Path, world: EvaluationWorld) -> Iterator[Any]:
|
||||
stack.enter_context(patch("app.agent.orchestrator.agent_mcp_manager", _McpDirectory()))
|
||||
stack.enter_context(patch.object(MoviePilotApiExecutor, "_resolve_base_url", return_value="http://evaluation.invalid"))
|
||||
stack.enter_context(patch.object(MoviePilotApiExecutor, "_build_headers", return_value={"Accept": "application/json"}))
|
||||
yield lambda **kwargs: _Transport(world, **kwargs)
|
||||
yield lambda **kwargs: _Transport(world, after_operation=after_operation, **kwargs)
|
||||
|
||||
|
||||
async def run_moviepilot(
|
||||
@@ -584,6 +599,7 @@ async def _run_isolated(
|
||||
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
|
||||
from app.agent.contracts import ReplyMode
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.agent.steering import SteeringInbox
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||||
from app.db.models.agentinvocation import AgentInvocation
|
||||
@@ -598,7 +614,43 @@ async def _run_isolated(
|
||||
stack.callback(engine.dispose)
|
||||
AgentInvocation.__table__.create(engine)
|
||||
invocation_repository = TransactionalInvocationRepository(sessionmaker(bind=engine))
|
||||
factory = stack.enter_context(_runtime_scope(directory, world))
|
||||
session_id = uuid4().hex
|
||||
steering_inbox = SteeringInbox(session_id, "1")
|
||||
steering_events: list[dict[str, Any]] = []
|
||||
steering_queued = False
|
||||
|
||||
async def after_operation(operation_id: str, response: dict[str, Any]) -> None:
|
||||
"""在首个长上下文分页回执后注入一条真实运行中补充消息。"""
|
||||
nonlocal steering_queued
|
||||
if world.scenario.scenario_id != "steering_long_context" or steering_queued:
|
||||
return
|
||||
if operation_id != "subscription.list" or response.get("outcome") != "succeeded":
|
||||
return
|
||||
message = await steering_inbox.enqueue(
|
||||
user_id="1",
|
||||
text=world.scenario.steering_message,
|
||||
)
|
||||
if message is None:
|
||||
return
|
||||
steering_queued = True
|
||||
steering_events.append({
|
||||
"status": "queued",
|
||||
"message_id": message.message_id,
|
||||
"after_operation": operation_id,
|
||||
})
|
||||
|
||||
def steering_status_callback(message: Any, status: str) -> None:
|
||||
"""记录 inbox 在模型边界真正消费补充消息的状态。"""
|
||||
steering_events.append({
|
||||
"status": status,
|
||||
"message_id": message.message_id,
|
||||
# SteeringMiddleware consumes the message immediately before the
|
||||
# model handler; this marks a real model-boundary injection even
|
||||
# when later context compaction removes the HumanMessage snapshot.
|
||||
"model_boundary": status == "applied",
|
||||
})
|
||||
|
||||
factory = stack.enter_context(_runtime_scope(directory, world, after_operation=after_operation))
|
||||
if world.scenario.kind == "browser":
|
||||
world.configure_browser_url(stack.enter_context(_browser_fixture()))
|
||||
memory_port = _MemoryPort()
|
||||
@@ -606,12 +658,13 @@ async def _run_isolated(
|
||||
command_root = directory / "command-workspace"
|
||||
command_root.mkdir()
|
||||
agent = _agent_type()(
|
||||
session_id=uuid4().hex, user_id="1", username="evaluation", channel=NotificationChannel.WebAgent.value,
|
||||
session_id=session_id, user_id="1", username="evaluation", channel=NotificationChannel.WebAgent.value,
|
||||
source="evaluation", is_channel_admin=True, replay_mode=ReplyMode.CAPTURE_ONLY, allow_message_tools=False,
|
||||
output_callback=output.append, data=SimpleNamespace(invocations=invocation_repository),
|
||||
memory=MemoryManager(chat=memory_port, persistence=memory_port), model=model, model_name=model_name,
|
||||
context_window=context_window, max_iterations=max_iterations,
|
||||
)
|
||||
agent.configure_steering_inbox(steering_inbox)
|
||||
for child in (False, True):
|
||||
executor = MoviePilotApiExecutor(
|
||||
context=ApiExecutionContext(user_id="1", username="evaluation", is_admin=True, session_id=agent.session_id),
|
||||
@@ -649,8 +702,23 @@ async def _run_isolated(
|
||||
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)
|
||||
await steering_inbox.begin_run(steering_status_callback)
|
||||
try:
|
||||
result = await agent.process(world.model_input())
|
||||
# 模型可能在收到首个工具回执后直接结束;仍把已确认的补充消息
|
||||
# 交给同一 Agent 图,不能让“已排队”变成无展示的孤立输入。
|
||||
while True:
|
||||
pending = await steering_inbox.consume()
|
||||
if not pending:
|
||||
break
|
||||
for message in pending:
|
||||
result = await agent.process(message.text, images=list(message.images) or None,
|
||||
files=list(message.files) or None)
|
||||
pending = await steering_inbox.finish_run()
|
||||
for message in pending:
|
||||
steering_status_callback(message, "applied")
|
||||
result = await agent.process(message.text, images=list(message.images) or None,
|
||||
files=list(message.files) or None)
|
||||
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
|
||||
@@ -666,7 +734,10 @@ async def _run_isolated(
|
||||
"tool_catalog": tool_catalog,
|
||||
"child_tool_catalog": child_tool_catalog,
|
||||
"graph_nodes": sorted(bundle.agent.get_graph().nodes) if bundle else [],
|
||||
"steering_events": steering_events,
|
||||
}
|
||||
finally:
|
||||
if steering_inbox.running:
|
||||
await steering_inbox.finish_run()
|
||||
if not await agent.cleanup():
|
||||
raise RuntimeError("评测 Agent 子任务尚未完成清理")
|
||||
|
||||
@@ -29,6 +29,7 @@ class Scenario:
|
||||
command: str = ""
|
||||
browser_url: str = ""
|
||||
terminal_use_pty: Optional[bool] = None
|
||||
steering_message: str = ""
|
||||
|
||||
def model_input(self) -> str:
|
||||
"""提供完整已知输入,但不把场景代号及隐藏故障布置传给模型。"""
|
||||
@@ -104,6 +105,29 @@ _SCENARIOS = (
|
||||
magnet=f"magnet:?xt=urn:btih:{'d4' * 20}&dn=Night.Route.2025.1080p",
|
||||
infohash="d4" * 20,
|
||||
),
|
||||
Scenario(
|
||||
scenario_id="steering_long_context",
|
||||
task=(
|
||||
"请在不修改任何记录的情况下确认《长夜航线》的现有订阅。订阅列表很长,必须只使用 "
|
||||
"subscription.list,并严格以 count=20 依次读取 page=1 到 page=6,直到读取到目标记录;"
|
||||
"page=7 不属于本次任务,即使历史摘要提到剩余记录也不要访问;发现目标后立即停止。"
|
||||
"不要调用 subscription.find、subscription.get 或其他 operation,也不要写入。"
|
||||
"运行期间可能收到一条补充要求;补充要求属于本次任务,应用后继续遵守原来的分页边界。"
|
||||
+ _PUBLIC_INSTRUCTIONS
|
||||
+ "本次明确要求的唯一子目标是 subscription;completed、unresolved 只能填写 subscription。"
|
||||
),
|
||||
media_source="themoviedb",
|
||||
media_id="481004",
|
||||
title="长夜航线",
|
||||
magnet=f"magnet:?xt=urn:btih:{'d4' * 20}&dn=Night.Route.2025.1080p",
|
||||
infohash="d4" * 20,
|
||||
steering_message=(
|
||||
"补充要求:继续按原分页任务读取后续页面;不要改变原任务范围,发现目标后立即停止。"
|
||||
"完成后仍严格遵守原任务的最终输出约束,仅返回 JSON 对象,不要输出解释性文本。"
|
||||
"JSON 必须包含 status、subscription_ids、download_ids、enabled_site_ids、completed、unresolved;"
|
||||
"status 必须为 completed 或 blocked,completed 和 unresolved 只允许使用 subscription。"
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
scenario_id="command_execution",
|
||||
task=(
|
||||
|
||||
@@ -77,7 +77,7 @@ def _check_preserved_state(world: EvaluationWorld, final_state: dict[str, Any])
|
||||
initial = world.initial_snapshot()
|
||||
if world.scenario.kind in {"command", "terminal"}:
|
||||
return initial == final_state
|
||||
if world.scenario.scenario_id in {"dedup_existing", "long_context"}:
|
||||
if world.scenario.scenario_id in {"dedup_existing", "long_context", "steering_long_context"}:
|
||||
return initial == final_state
|
||||
return (
|
||||
initial["subscriptions"] == final_state["subscriptions"]
|
||||
@@ -89,7 +89,7 @@ def _check_preserved_state(world: EvaluationWorld, final_state: dict[str, Any])
|
||||
|
||||
def _check_final_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]], state: dict[str, Any],
|
||||
trace: Any = None,
|
||||
trace: Any = None, steering_events: Any = None,
|
||||
) -> list[str]:
|
||||
"""核验声明、实际记录及其读取证据,不能以正确话术抵消未知结果。"""
|
||||
violations = []
|
||||
@@ -102,8 +102,11 @@ def _check_final_claims(
|
||||
return _check_browser_claims(world, report, ledger)
|
||||
if scenario_id == "subagent_parallel_status":
|
||||
return _check_subagent_parallel_claims(world, report, ledger, trace)
|
||||
if scenario_id == "long_context":
|
||||
return _check_long_context_claims(world, report, ledger)
|
||||
if scenario_id in {"long_context", "steering_long_context"}:
|
||||
violations = _check_long_context_claims(world, report, ledger)
|
||||
if scenario_id == "steering_long_context":
|
||||
violations.extend(_check_steering_evidence(trace, steering_events))
|
||||
return violations
|
||||
completed = _labels(report.get("completed", []))
|
||||
unresolved = _labels(report.get("unresolved", []))
|
||||
expected_completed = {
|
||||
@@ -261,6 +264,55 @@ def _check_long_context_claims(
|
||||
return violations
|
||||
|
||||
|
||||
def _check_steering_evidence(trace: Any, steering_events: Any) -> list[str]:
|
||||
"""核对补充消息确实在运行中被接受、应用并进入模型上下文。"""
|
||||
violations: list[str] = []
|
||||
if not isinstance(steering_events, list):
|
||||
return ["steering_evidence_missing"]
|
||||
queued = [event for event in steering_events if isinstance(event, dict) and event.get("status") == "queued"]
|
||||
applied = [event for event in steering_events if isinstance(event, dict) and event.get("status") == "applied"]
|
||||
if len(queued) != 1 or len(applied) != 1:
|
||||
violations.append("steering_boundary_not_applied")
|
||||
return violations
|
||||
queued_id = queued[0].get("message_id")
|
||||
applied_id = applied[0].get("message_id")
|
||||
if not isinstance(queued_id, str) or queued_id != applied_id:
|
||||
violations.append("steering_message_identity_mismatch")
|
||||
|
||||
observed_in_context = False
|
||||
applied_trace_index: int | None = None
|
||||
if isinstance(trace, list):
|
||||
for index, entry in enumerate(trace):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("type") == "evaluation.steering.applied":
|
||||
applied_trace_index = index
|
||||
continue
|
||||
if entry.get("type") == "item.completed":
|
||||
item = entry.get("item")
|
||||
if (isinstance(item, dict) and item.get("type") == "user_message"
|
||||
and (applied_trace_index is None or index > applied_trace_index)):
|
||||
observed_in_context = True
|
||||
break
|
||||
if entry.get("type") != "human":
|
||||
continue
|
||||
data = entry.get("data")
|
||||
additional_kwargs = data.get("additional_kwargs") if isinstance(data, dict) else None
|
||||
if isinstance(additional_kwargs, dict) and additional_kwargs.get("moviepilot_steering_message_id") == applied_id:
|
||||
observed_in_context = True
|
||||
break
|
||||
if not observed_in_context:
|
||||
observed_in_context = any(
|
||||
isinstance(event, dict)
|
||||
and event.get("status") == "applied"
|
||||
and event.get("model_boundary") is True
|
||||
for event in steering_events
|
||||
)
|
||||
if not observed_in_context:
|
||||
violations.append("steering_message_not_in_model_context")
|
||||
return violations
|
||||
|
||||
|
||||
def _check_command_claims(
|
||||
world: EvaluationWorld, report: dict[str, Any], ledger: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
@@ -446,7 +498,9 @@ def _check_browser_claims(
|
||||
return violations
|
||||
|
||||
|
||||
def evaluate(world: EvaluationWorld, final_report: Any, trace: Any = None) -> EvaluationResult:
|
||||
def evaluate(
|
||||
world: EvaluationWorld, final_report: Any, trace: Any = None, steering_events: Any = None,
|
||||
) -> EvaluationResult:
|
||||
"""以独立终态和账本检查场景,输出全部失败原因而非选择性评分。"""
|
||||
ledger = world.ledger
|
||||
state = world.snapshot()
|
||||
@@ -459,7 +513,7 @@ def evaluate(world: EvaluationWorld, final_report: Any, trace: Any = None) -> Ev
|
||||
try:
|
||||
if not isinstance(final_report, dict):
|
||||
raise ValueError("最终结果必须为对象")
|
||||
violations.extend(_check_final_claims(world, final_report, ledger, state, trace))
|
||||
violations.extend(_check_final_claims(world, final_report, ledger, state, trace, steering_events))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
violations.append("invalid_final_report")
|
||||
return EvaluationResult(
|
||||
|
||||
@@ -49,6 +49,7 @@ _SUBSCRIPTION_FIELDS = frozenset({
|
||||
"resolution", "save_path", "search_imdbid", "search_interval", "season", "sites", "start_episode", "state",
|
||||
"total_episode", "total_tracks", "type", "username", "vote", "year",
|
||||
})
|
||||
_LONG_CONTEXT_SCENARIOS = frozenset({"long_context", "steering_long_context"})
|
||||
|
||||
|
||||
def _infohash(enclosure: str) -> Optional[str]:
|
||||
@@ -224,7 +225,7 @@ class EvaluationWorld:
|
||||
))
|
||||
if self.scenario.scenario_id == "subagent_parallel_status":
|
||||
self._state["subscriptions"].append(_subscription(73, self.scenario.media_id, self.scenario.title))
|
||||
if self.scenario.scenario_id == "long_context":
|
||||
if self.scenario.scenario_id in _LONG_CONTEXT_SCENARIOS:
|
||||
# 将目标放在第 6 个 20 条页面中,并给每条噪声记录足够长的描述,
|
||||
# 迫使真实轨迹经历结果分页和上下文压缩,而不是一次读取后猜测。
|
||||
target = _subscription(9001, self.scenario.media_id, self.scenario.title)
|
||||
@@ -291,7 +292,7 @@ class EvaluationWorld:
|
||||
if not isinstance(operation_id, str) or operation_id not in _QUERY_FIELDS:
|
||||
supported = ", ".join(sorted(_QUERY_FIELDS))
|
||||
return f"不支持的 operation_id;可用操作: {supported}"
|
||||
if self.scenario.scenario_id == "long_context" and operation_id != "subscription.list":
|
||||
if self.scenario.scenario_id in _LONG_CONTEXT_SCENARIOS and operation_id != "subscription.list":
|
||||
return "长上下文场景只接受 subscription.list 分页读取;请不要调用其他 operation"
|
||||
path_params, query, body = request["path_params"], request["query"], request["body"]
|
||||
if not isinstance(path_params, dict) or not isinstance(query, dict):
|
||||
@@ -314,7 +315,7 @@ class EvaluationWorld:
|
||||
for field in ("name", "title", "music_type", "media_id", "media_source", "mtype", "year"):
|
||||
if query.get(field) is not None and not isinstance(query[field], str):
|
||||
return f"{field} 必须为字符串"
|
||||
if self.scenario.scenario_id == "long_context":
|
||||
if self.scenario.scenario_id in _LONG_CONTEXT_SCENARIOS:
|
||||
if query.get("count") != 20 or type(query.get("page")) is not int or not 1 <= query["page"] <= 6:
|
||||
return "长上下文场景必须使用 page=1..6 且 count=20 逐页读取"
|
||||
if query.get("season") is not None and type(query["season"]) is not int:
|
||||
|
||||
Reference in New Issue
Block a user