Files
MoviePilot/scripts/evaluation/server.py
2026-09-11 09:50:56 +08:00

574 lines
31 KiB
Python
Raw 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.

"""仅绑定回环地址的受控 MCP JSON-RPC 子集,不加载 MoviePilot 运行时或真实业务服务。"""
import asyncio
import hashlib
import json
import re
import secrets
import socket
import time
from collections import Counter, OrderedDict
from collections.abc import Iterator
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import uvicorn
import yaml # type: ignore[import-untyped] # 锁定的 PyYAML 不提供类型声明。
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from scripts.evaluation.world import EvaluationWorld
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
MAX_REQUEST_BYTES = 1024 * 1024
MAX_SKILL_BYTES = 512 * 1024
MAX_RESULT_BYTES = 1024 * 1024
MAX_TOTAL_RESULT_BYTES = 4 * MAX_RESULT_BYTES
MAX_RESULTS = 8
RESULT_TTL_SECONDS = 900
FIRST_PAGE_CHARS = 8192
NEXT_PAGE_CHARS = 16000
DIRECT_RESULT_MAX_CHARS = 64 * 1024
# 与 Agent 的 MoviePilotApiInput 保持相同字段,不提前暴露场景支持操作或 oracle 判据。
API_INPUT_SCHEMA: dict[str, Any] = {
"type": "object", "required": ["operation_id"], "additionalProperties": False,
"properties": {
"operation_id": {"type": "string", "description": (
"Exact allowlisted MoviePilot operation ID selected from the loaded domain Skill. "
"Never supply a URL, authentication header, or API token."
)},
"path_params": {"type": "object", "additionalProperties": True, "description": "Route placeholder values declared by the selected operation."},
"query": {"type": "object", "additionalProperties": True, "description": "Query-string fields declared by the selected operation."},
"body": {"default": None, "description": (
"JSON request value declared by the selected operation and its loaded Skill contract. "
"Most operations use an object; a oneOf branch may require an exact scalar."
)},
},
}
@dataclass(frozen=True)
class _StoredResult:
"""本次评测独占的有界结果归档MCP 客户端会话共享但其他服务实例不可读取。"""
text: str
tool_name: str
byte_size: int
expires_at: float
@dataclass
class _Session:
"""只保存 MCP 握手状态,不拥有或重置业务世界。"""
protocol: str
initialized: bool = False
def _operation_input_contract(schema: dict[str, Any], operation_id: str) -> dict[str, Any]:
"""压缩一个 operation 的参数字段,保持评测服务不依赖 MoviePilot 运行时。"""
branch = next(
(
item for item in schema.get("oneOf", [])
if isinstance(item, dict)
and item.get("properties", {}).get("operation_id", {}).get("const") == operation_id
),
None,
)
if not isinstance(branch, dict):
return {"operation_id": operation_id, "available": False}
properties = branch.get("properties", {})
contract: dict[str, Any] = {
"operation_id": operation_id,
"allowed_arguments": [name for name in properties if name != "operation_id"],
"required_arguments": [
name for name in branch.get("required", []) if name != "operation_id"
],
}
for name in ("path_params", "query", "body"):
node = properties.get(name)
if not isinstance(node, dict):
continue
shape: dict[str, Any] = {}
if isinstance(node.get("type"), str):
shape["type"] = node["type"]
if isinstance(node.get("required"), list) and node["required"]:
shape["required"] = list(node["required"])
fields = node.get("properties")
if isinstance(fields, dict):
shape["fields"] = {}
for field, declaration in fields.items():
if not isinstance(declaration, dict):
continue
field_shape: dict[str, Any] = {}
for key in ("type", "enum", "const", "default", "$ref"):
if key in declaration:
field_shape[key] = deepcopy(declaration[key])
alternatives = declaration.get("anyOf", declaration.get("oneOf"))
if isinstance(alternatives, list):
field_shape["one_of"] = [
{key: deepcopy(option[key]) for key in ("type", "enum", "const") if key in option}
for option in alternatives if isinstance(option, dict)
]
shape["fields"][field] = field_shape
contract[name] = shape
return contract
class _LocalServer(uvicorn.Server): # type: ignore[misc] # follow_imports=skip 不展开外部 Server 基类。
"""让控制器拥有服务启停,嵌入运行时不替换进程信号处理器。"""
def __init__(self, config: uvicorn.Config, ready: asyncio.Event) -> None:
"""通过事件通知实际套接字监听已就绪。"""
super().__init__(config)
self.ready = ready
@contextmanager
def capture_signals(self) -> Iterator[None]:
"""主控制器处理取消和信号MCP 服务不接管全局状态。"""
yield
async def startup(self, sockets: Optional[list[socket.socket]] = None) -> None:
"""完成 uvicorn 的真实监听后才允许模型开始握手。"""
await super().startup(sockets=sockets)
self.ready.set()
class EvaluationMcpServer:
"""为一个独立世界提供受认证的本地 Streamable HTTP 必需子集。"""
def __init__(self, world: EvaluationWorld, *, root_dir: Optional[Path] = None, max_world_calls: int = 32) -> None:
"""固定世界与可信源码目录;模型只能读取预先列出的 Skill 文档。"""
if type(max_world_calls) is not int or not 1 <= max_world_calls <= 32:
raise ValueError("max_world_calls 必须为 1 到 32 的整数")
self._world = world
self._max_world_calls = max_world_calls
self._world_calls = 0
self._blocked_world_calls = 0
self._tool_calls: Counter[str] = Counter()
self._bearer_token = secrets.token_urlsafe(32)
self._endpoint: Optional[str] = None
self._listener: Optional[socket.socket] = None
self._server: Optional[_LocalServer] = None
self._task: Optional[asyncio.Task[None]] = None
self._ready = asyncio.Event()
self._lifecycle_lock = asyncio.Lock()
self._closed = False
self._sessions: dict[str, _Session] = {}
self._results: OrderedDict[str, _StoredResult] = OrderedDict()
self._skill_root = Path(root_dir) if root_dir is not None else Path(__file__).resolve().parents[2]
self._skill = self._load_skill(self._skill_root)
self._api_contract_schema = self._load_api_contract(self._skill_root)
@property
def endpoint(self) -> str:
"""控制器在 start 完成后取得回环 MCP 地址,不包含认证秘密。"""
if self._endpoint is None or self._closed:
raise RuntimeError("MCP 评测服务尚未运行")
return self._endpoint
@property
def bearer_token(self) -> str:
"""仅供控制器放入客户端局部环境,不会出现在任何工具或协议响应中。"""
return self._bearer_token
@property
def stats(self) -> dict[str, Any]:
"""提供无场景答案、无参数正文的控制器计数快照。"""
return {"world_calls": self._world_calls, "blocked_world_calls": self._blocked_world_calls,
"tool_calls": dict(self._tool_calls), "max_world_calls": self._max_world_calls}
@property
def skill_sha256(self) -> str:
"""控制器可记录实际公开技能正文的指纹,模型工具不暴露额外上下文。"""
return hashlib.sha256(self._skill["content"].encode("utf-8")).hexdigest()
async def __aenter__(self) -> "EvaluationMcpServer":
"""异步上下文进入时等待真实端点就绪。"""
return await self.start()
async def __aexit__(self, *_args: Any) -> None:
"""控制器正常退出、失败或取消时均关闭端点。"""
await self.close()
async def start(self) -> "EvaluationMcpServer":
"""绑定系统分配的 127.0.0.1 端口,不读取任何业务运行配置。"""
async with self._lifecycle_lock:
if self._closed:
raise RuntimeError("已关闭的评测服务不能重新启动")
if self._task is not None:
return self
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._listener = listener
try:
listener.bind(("127.0.0.1", 0))
listener.setblocking(False)
self._endpoint = f"http://127.0.0.1:{listener.getsockname()[1]}/mcp"
app = Starlette(routes=[Route("/mcp", self._handle, methods=["POST", "GET", "DELETE"])])
config = uvicorn.Config(app, host="127.0.0.1", log_config=None, log_level=None, access_log=False,
lifespan="off", proxy_headers=False, ws="none", timeout_graceful_shutdown=2)
self._server = _LocalServer(config, self._ready)
self._task = asyncio.create_task(self._server.serve(sockets=[listener]))
self._task.add_done_callback(lambda _task: self._ready.set())
await asyncio.wait_for(self._ready.wait(), timeout=10)
if self._task.done() or not self._server.started:
raise RuntimeError("本地 MCP 服务启动失败")
except BaseException:
await self._stop()
raise
return self
async def close(self) -> None:
"""显式、幂等关闭 HTTP 服务,保留业务世界供控制器独立评分。"""
async with self._lifecycle_lock:
await self._stop()
async def _stop(self) -> None:
"""有限等待服务退出,异常路径也回收监听套接字与归档。"""
self._closed = True
try:
if self._server is not None:
self._server.should_exit = True
if self._task is not None:
try:
await asyncio.wait_for(asyncio.shield(self._task), timeout=5)
except TimeoutError:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
except asyncio.CancelledError:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
raise
finally:
if self._listener is not None:
self._listener.close()
self._sessions.clear()
self._results.clear()
@staticmethod
def _error(request_id: Any, code: int, message: str, status: int = 400) -> JSONResponse:
"""错误只使用固定协议文案,不回显异常、环境、参数或私有文件路径。"""
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}, status_code=status)
def _http_error(self, request: Request) -> Optional[Response]:
"""先校验认证、Host 与 Origin防止回环服务被浏览器或代理借用。"""
expected = f"Bearer {self._bearer_token}"
if len(request.headers.getlist("authorization")) != 1 or not secrets.compare_digest(
request.headers.get("authorization", "").encode("utf-8"), expected.encode("ascii"),
):
return self._error(None, -32001, "Unauthorized", 401)
origin = self.endpoint.removesuffix("/mcp")
if request.headers.get("host") != origin.removeprefix("http://") or request.headers.get("origin", origin) != origin:
return self._error(None, -32001, "Forbidden", 403)
if request.client is None or request.client.host != "127.0.0.1":
return self._error(None, -32001, "Forbidden", 403)
version = request.headers.get("mcp-protocol-version")
if version is not None and version not in PROTOCOL_VERSIONS:
return self._error(None, -32600, "Unsupported protocol version")
return None
async def _handle(self, request: Request) -> Response:
"""处理 JSON-RPC POST本服务不提供服务器推送 SSE 或远程关闭能力。"""
rejected = self._http_error(request)
if rejected is not None:
return rejected
if request.method != "POST":
return Response(status_code=405, headers={"Allow": "POST"})
accepted = {item.split(";", 1)[0].strip().lower() for item in request.headers.get("accept", "").split(",")}
if not {"application/json", "text/event-stream"} <= accepted:
return self._error(None, -32600, "Accept must include application/json and text/event-stream", 406)
if request.headers.get("content-type", "").split(";", 1)[0].lower() != "application/json":
return self._error(None, -32600, "Content-Type must be application/json", 415)
try:
data = bytearray()
async for chunk in request.stream():
data.extend(chunk)
if len(data) > MAX_REQUEST_BYTES:
return self._error(None, -32600, "Request too large", 413)
payload = json.loads(data.decode("utf-8"), parse_constant=self._invalid_json_constant)
except (ValueError, UnicodeError):
return self._error(None, -32700, "Parse error")
if not self._valid_request(payload):
return self._error(None, -32600, "Invalid Request")
try:
return self._dispatch(request, payload)
except Exception:
return self._error(payload.get("id"), -32603, "Internal error", 500)
@staticmethod
def _invalid_json_constant(_value: str) -> None:
"""JSON-RPC 仅接受标准 JSON拒绝 Python 默认允许的 NaN/Infinity 扩展。"""
raise ValueError("Non-finite JSON number")
@staticmethod
def _valid_request(payload: Any) -> bool:
"""仅接受单条 JSON-RPC 请求或通知,拒绝 batch 和可混淆的标量类型。"""
return (type(payload) is dict and payload.get("jsonrpc") == "2.0"
and not set(payload) - {"jsonrpc", "id", "method", "params"}
and type(payload.get("method")) is str and bool(payload["method"])
and type(payload.get("params", {})) is dict
and ("_meta" not in payload.get("params", {}) or type(payload["params"]["_meta"]) is dict)
and ("id" not in payload or type(payload["id"]) in (str, int)))
def _dispatch(self, request: Request, payload: dict[str, Any]) -> Response:
"""握手会话只管理协议状态,所有合法工具调用始终进入同一评测世界。"""
method, params, request_id = payload["method"], payload.get("params", {}), payload.get("id")
if method == "initialize":
return self._initialize(params, request_id) if "id" in payload else self._error(None, -32600, "initialize requires an id")
session = self._sessions.get(request.headers.get("mcp-session-id", ""))
if session is None:
return self._error(request_id, -32000, "Unknown MCP session", 404 if request.headers.get("mcp-session-id") else 400)
if request.headers.get("mcp-protocol-version", session.protocol) != session.protocol:
return self._error(request_id, -32600, "Protocol version differs from initialized session")
if method == "notifications/initialized":
if "id" in payload or set(params) - {"_meta"}:
return self._error(request_id, -32600, "Invalid initialized notification")
session.initialized = True
return Response(status_code=202)
if method == "notifications/cancelled" and "id" not in payload:
if (set(params) - {"requestId", "reason", "_meta"} or type(params.get("requestId")) not in (str, int)
or ("reason" in params and type(params["reason"]) is not str)):
return self._error(None, -32600, "Invalid cancellation notification")
return Response(status_code=202)
if "id" not in payload:
return self._error(None, -32600, "Unsupported notification")
if not session.initialized and method != "ping":
return self._error(request_id, -32000, "MCP session is not initialized")
if method in {"ping", "tools/list"}:
allowed = {"_meta", "cursor"} if method == "tools/list" else {"_meta"}
if set(params) - allowed or params.get("cursor") not in (None, ""):
return self._error(request_id, -32602, "Invalid params")
result: Any = {} if method == "ping" else {"tools": self._tool_catalog()}
elif method == "tools/call":
if set(params) - {"name", "arguments", "_meta"} or type(params.get("name")) is not str or type(params.get("arguments", {})) is not dict:
return self._error(request_id, -32602, "Invalid tools/call params")
result = self._call_tool(params["name"], params.get("arguments", {}))
else:
return self._error(request_id, -32601, "Method not found", 200)
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result})
def _initialize(self, params: dict[str, Any], request_id: Any) -> Response:
"""按正式握手版本协商,新客户端会话不会重置业务状态或调用预算。"""
client = params.get("clientInfo")
if (set(params) - {"protocolVersion", "capabilities", "clientInfo", "_meta"}
or type(params.get("protocolVersion")) is not str
or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", params["protocolVersion"])
or type(params.get("capabilities")) is not dict or type(client) is not dict
or any(type(client.get(key)) is not str or not client[key] for key in ("name", "version"))):
return self._error(request_id, -32602, "Invalid initialize params")
if len(self._sessions) >= 64:
return self._error(request_id, -32000, "Session limit reached", 429)
protocol = params["protocolVersion"] if params["protocolVersion"] in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
session_id = secrets.token_hex(24)
self._sessions[session_id] = _Session(protocol)
result = {"protocolVersion": protocol, "capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "MoviePilot Evaluation", "version": "1"},
"instructions": "Use the MoviePilot domain Skill to select API operations and interpret results."}
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result}, headers={"Mcp-Session-Id": session_id})
@staticmethod
def _tool_catalog() -> list[dict[str, Any]]:
"""只公开与 MoviePilot 任务输入等价的工具合同,不注入场景答案。"""
return [
{"name": "moviepilot_api", "description": (
"Call allowlisted MoviePilot business APIs through operation-specific input contracts. "
"Load the relevant domain Skill before calling and use operation error feedback to correct inputs. "
"Arbitrary URLs, commands, authentication endpoints, headers, and tokens are forbidden."
), "inputSchema": deepcopy(API_INPUT_SCHEMA)},
{"name": "read_skill", "description": "Read the named MoviePilot Skill or one of its listed supporting documents. Available skill: moviepilot-api.", "inputSchema": {
"type": "object", "required": ["name"], "additionalProperties": False, "properties": {
"name": {"type": "string"},
"file": {"type": "string", "description": "Relative supporting Skill document path listed by a prior read_skill call."},
},
}},
{"name": "read_tool_result", "description": "Read an archived tool result using result_id and next_offset. Offsets count Unicode characters. Results expire after 15 minutes or eviction; never guess result IDs.", "inputSchema": {
"type": "object", "required": ["result_id"], "additionalProperties": False, "properties": {
"result_id": {"type": "string", "minLength": 32, "maxLength": 32},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"limit": {"type": "integer", "minimum": 1, "maximum": NEXT_PAGE_CHARS, "default": 4000},
},
}},
]
@staticmethod
def _failure(error: str, message: str) -> dict[str, Any]:
"""工具失败使用固定四态字段,异常细节不交给模型。"""
return {"success": False, "execution_outcome": "failed", "error": error, "message": message}
def _call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""精确工具白名单与全局预算均在进入业务世界前检查。"""
self._tool_calls[name if name in {"moviepilot_api", "read_skill", "read_tool_result"} else "unknown"] += 1
try:
if name == "moviepilot_api":
if set(arguments) - set(API_INPUT_SCHEMA["properties"]) or type(arguments.get("operation_id")) is not str:
result: Any = self._failure("invalid_arguments", "Invalid moviepilot_api arguments")
elif any(key in arguments and type(arguments[key]) is not dict for key in ("path_params", "query")):
result = self._failure("invalid_arguments", "path_params and query must be objects")
elif self._world_calls >= self._max_world_calls:
self._blocked_world_calls += 1
result = self._failure("world_call_limit", "World tool call budget exhausted")
else:
self._world_calls += 1
result = self._world.execute(**arguments)
result = self._attach_input_contract(result, arguments)
elif name == "read_skill":
result = self._read_skill(arguments)
elif name == "read_tool_result":
result = self._read_result(arguments)
else:
result = self._failure("tool_not_found", "Unknown tool")
except Exception:
result = self._failure("evaluation_tool_error", "Controlled tool execution failed")
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, indent=2)
outcome = json.loads(text).get("execution_outcome")
return {"content": [{"type": "text", "text": text}], "isError": outcome in {"failed", "unknown"}}
@staticmethod
def _load_skill(root: Path) -> dict[str, Any]:
"""读取唯一可信技能文件,不从请求中接受路径,也不加载宿主元数据解析器。"""
skill_path = root / "skills/moviepilot-api/SKILL.md"
with skill_path.open("rb") as handle:
data = handle.read(MAX_SKILL_BYTES + 1)
truncated = len(data) > MAX_SKILL_BYTES
content = data[:MAX_SKILL_BYTES].decode("utf-8", errors="ignore" if truncated else "replace")
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
metadata = yaml.safe_load(match.group(1)) if match else None
if not isinstance(metadata, dict) or metadata.get("name") != "moviepilot-api":
raise ValueError("moviepilot-api Skill 元数据无效")
return {"success": True, "skill": {
"id": "moviepilot-api", "name": "moviepilot-api", "description": str(metadata.get("description", ""))[:1024],
"path": "skills/moviepilot-api/SKILL.md", "allowed_tools": str(metadata.get("allowed-tools", "")).split(),
"allowed_api_operations": str(metadata.get("allowed-api-operations", "")).split(),
}, "content": content, "content_limit_bytes": MAX_SKILL_BYTES, "supporting_files": sorted(
str(path.relative_to(skill_path.parent)) for path in skill_path.parent.rglob("*") if path.is_file() and path != skill_path
), "truncated": truncated,
"truncation_message": "SKILL.md exceeds 512 KiB; content contains only the first 512 KiB." if truncated else None}
@staticmethod
def _load_api_contract(root: Path) -> dict[str, Any]:
"""读取与生产网关相同的 operation schema供评测错误回执提供纠正提示。"""
path = root / "app/agent/policy/resources/api_mcp_schema.json"
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict) or not isinstance(payload.get("oneOf"), list):
raise ValueError("moviepilot_api operation schema 无效")
return payload
def _attach_input_contract(self, result: Any, arguments: dict[str, Any]) -> Any:
"""为 operation 失败回执附加允许字段和必填字段,不回显错误请求值。"""
if not isinstance(result, dict) or result.get("success") is not False:
return result
operation_id = arguments.get("operation_id")
if type(operation_id) is not str:
return result
payload = dict(result)
payload["operation_id"] = operation_id
payload["message"] = (
f"{operation_id} 调用失败,请按 input_contract 只提交允许字段并补齐 required 字段后重试。"
f" 原因:{str(result.get('message', ''))[:240]}"
)
payload["input_contract"] = _operation_input_contract(self._api_contract_schema, operation_id)
return payload
def _prune_results(self) -> None:
"""匹配 MoviePilot 的 15 分钟、8 项、4MiB 会话归档约束。"""
now = time.monotonic()
for key in list(self._results):
if self._results[key].expires_at <= now:
del self._results[key]
while self._results and (len(self._results) > MAX_RESULTS or sum(value.byte_size for value in self._results.values()) > MAX_TOTAL_RESULT_BYTES):
self._results.popitem(last=False)
@staticmethod
def _bounded_page(build: Any, limit: int, length: int) -> str:
"""按最终 JSON 文本长度二分页边界,游标只覆盖实际交付的字符。"""
low, high = 0, length
best = json.dumps(build(0), ensure_ascii=False, indent=2)
while low <= high:
middle = (low + high) // 2
candidate = json.dumps(build(middle), ensure_ascii=False, indent=2)
if len(candidate) <= limit:
best, low = candidate, middle + 1
else:
high = middle - 1
return best
def _read_skill(self, arguments: dict[str, Any]) -> Any:
"""返回真实 Skill 主体或一个分类文档的首屏及可续读归档。"""
requested_file = arguments.get("file")
if (
set(arguments) - {"name", "file"}
or arguments.get("name") != "moviepilot-api"
or (requested_file is not None and type(requested_file) is not str)
):
return self._failure("skill_not_found", "Unknown Skill")
if requested_file is None:
skill_payload = self._skill
elif requested_file not in self._skill["supporting_files"]:
return self._failure("skill_file_not_found", "Unknown Skill supporting file")
else:
skill_payload = dict(self._skill)
content_path = self._skill_root / "skills/moviepilot-api" / requested_file
if content_path.is_symlink() or not content_path.is_file():
return self._failure("skill_file_not_found", "Unknown Skill supporting file")
skill_payload["loaded_file"] = requested_file
with content_path.open("rb") as handle:
data = handle.read(MAX_SKILL_BYTES + 1)
truncated = len(data) > MAX_SKILL_BYTES
skill_payload["content"] = data[:MAX_SKILL_BYTES].decode(
"utf-8", errors="ignore" if truncated else "replace"
)
skill_payload["truncated"] = truncated
skill_payload["truncation_message"] = (
"The requested supporting skill file exceeds 512 KiB; content contains only the first 512 KiB."
if truncated
else None
)
text = json.dumps(skill_payload, ensure_ascii=False, indent=2)
# 生产 read_skill 先走统一工具结果上限 64 KiB当前拆分后的主文档
# 与分类文档都应一次交付,只有更大的文档才进入 8 KiB 首屏归档。
if len(text) <= DIRECT_RESULT_MAX_CHARS:
return text
if len(text.encode("utf-8")) > MAX_RESULT_BYTES:
return self._failure("result_too_large", "Skill result exceeds archive limit")
result_id = secrets.token_hex(16)
self._results[result_id] = _StoredResult(text, "read_skill", len(text.encode("utf-8")), time.monotonic() + RESULT_TTL_SECONDS)
self._prune_results()
def page(length: int) -> dict[str, Any]:
"""首屏与 MoviePilot 归档预览使用相同的定位字段。"""
return {"result_id": result_id, "read_tool": "read_tool_result", "expires_in_seconds": RESULT_TTL_SECONDS,
"offset_unit": "unicode_characters", "next_offset": length, "execution_outcome": "succeeded",
"tool_result_truncated": True, "tool_name": "read_skill", "total_chars": len(text),
"returned_chars": length, "content_preview": text[:length],
"message": "完整结果已在当前会话临时保存;使用 read_tool_result 的 result_id 和 next_offset 继续读取,无需重复执行原工具。"}
return self._bounded_page(page, FIRST_PAGE_CHARS, len(text))
def _read_result(self, arguments: dict[str, Any]) -> Any:
"""按 Unicode 字符续读本次服务归档,非法位置不得生成伪观察内容。"""
offset, limit, result_id = arguments.get("offset", 0), arguments.get("limit", 4000), arguments.get("result_id")
if (set(arguments) - {"result_id", "offset", "limit"} or type(result_id) is not str or len(result_id) != 32
or type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= NEXT_PAGE_CHARS):
return self._failure("invalid_arguments", "Invalid result cursor or limit")
self._prune_results()
result = self._results.get(result_id)
if result is None:
return self._failure("result_unavailable", "Result unavailable")
if offset > len(result.text):
return self._failure("offset_out_of_range", "Result offset out of range")
def page(length: int) -> dict[str, Any]:
"""最终响应预算包含转义开销next_offset 始终对应实际返回的正文。"""
end = offset + length
return {"success": True, "result_id": result_id, "tool_name": result.tool_name, "offset": offset,
"next_offset": end if end < len(result.text) else None, "total_chars": len(result.text), "content": result.text[offset:end]}
return self._bounded_page(page, NEXT_PAGE_CHARS, min(limit, len(result.text) - offset))