mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
158 lines
6.9 KiB
Python
158 lines
6.9 KiB
Python
"""
|
|
每个 Python cell 独立的宿主身份、只读工具集合和调用预算。
|
|
"""
|
|
|
|
import asyncio
|
|
import contextvars
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
from app.agent.policy.api import resolve_api_operation
|
|
from app.agent.policy.contracts import (
|
|
ActionEffect,
|
|
ExecutionOutcome,
|
|
PrincipalRole,
|
|
ResultSensitivity,
|
|
ToolOrigin,
|
|
ToolPolicyContext,
|
|
)
|
|
from app.agent.policy.registry import requests_system_setting_secrets
|
|
from app.agent.policy.sanitizer import summarize_error, summarize_input, summarize_result
|
|
from app.agent.terminal.ownership import TerminalScope
|
|
from app.agent.tools.impl.api import MoviePilotApiTool
|
|
from app.agent.tools.impl.read_file import ReadFileTool
|
|
from app.agent.tools.impl.search_web import SearchWebTool
|
|
from app.agent.tools.result import inspect_tool_result
|
|
from app.runtime.tasks import get_task_registry
|
|
|
|
MAX_TOOL_CALLS = 50
|
|
RPC_RESULT_KEY = '_moviepilot_code_result'
|
|
CURRENT_CELL: contextvars.ContextVar['CellAuthority | None'] = contextvars.ContextVar('agent_code_cell', default=None)
|
|
_READ_CLASSES = (MoviePilotApiTool, ReadFileTool, SearchWebTool)
|
|
Dispatch = Callable[[Any, dict[str, Any]], Awaitable[Any]]
|
|
|
|
|
|
def allowed_tools(tools: list[Any]) -> dict[str, Any]:
|
|
"""
|
|
只取本轮实际启用的原生工具对象,插件或 MCP 同名工具不能借用只读声明。
|
|
"""
|
|
return {tool.name: tool for tool in tools if type(tool) in _READ_CLASSES
|
|
and getattr(tool, '_agent_tool_source', None) == 'builtin'}
|
|
|
|
|
|
class CellAuthority:
|
|
"""
|
|
当前 cell 的调用窗口;持久 socket 和 Python 状态不延长宿主调用身份。
|
|
"""
|
|
|
|
def __init__(self, scope: TerminalScope, context: ToolPolicyContext,
|
|
tools: dict[str, Any], dispatch: Dispatch) -> None:
|
|
"""
|
|
从正在执行的宿主协程复制上下文,每次 cell 都重新建立,结束即撤销。
|
|
"""
|
|
self.scope, self.context, self.tools = scope, context, tools
|
|
self._dispatch = dispatch
|
|
self._context = contextvars.copy_context()
|
|
self._active = True
|
|
self._pending: set[asyncio.Task[Any]] = set()
|
|
self.calls = 0
|
|
self.log: list[dict[str, Any]] = []
|
|
self.errors: list[dict[str, str]] = []
|
|
|
|
def available(self) -> bool:
|
|
"""
|
|
角色引用随宿主更新而变化,排队或持久连接不得继续使用已撤销权限。
|
|
"""
|
|
return (self._active and not self.scope.closed and bool(self.scope.user_id)
|
|
and self.scope.user_id == self.context.user_id
|
|
and self.context.principal.role is PrincipalRole.SYSTEM_ADMIN
|
|
and self.context.origin not in {ToolOrigin.SUBAGENT, ToolOrigin.AGENT_API})
|
|
|
|
def _error(self, name: str, reason: str) -> dict[str, Any]:
|
|
"""
|
|
即使脚本忽略失败回执,外层结果仍保留最多五条明确错误。
|
|
"""
|
|
if len(self.errors) < 5:
|
|
self.errors.append({'tool': name, 'error': reason})
|
|
return {'success': False, 'error': reason}
|
|
|
|
@staticmethod
|
|
def _read_allowed(tool: Any, arguments: dict[str, Any]) -> bool:
|
|
"""
|
|
API 的操作副作用与敏感参数都由宿主合同解析,不采用模型声明的读取标签。
|
|
"""
|
|
if type(tool) is not MoviePilotApiTool:
|
|
return type(tool) in {ReadFileTool, SearchWebTool}
|
|
operation = resolve_api_operation(str(arguments.get('operation_id') or ''))
|
|
if (operation is None or operation.effect is not ActionEffect.SAFE_READ
|
|
or operation.result_sensitivity not in {ResultSensitivity.NORMAL, ResultSensitivity.PRIVATE}):
|
|
return False
|
|
normalized = tool.canonical_arguments(arguments)
|
|
return not requests_system_setting_secrets(normalized)
|
|
|
|
async def call(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
"""
|
|
逐次检查、计数并在当前 cell 上下文执行,串行 RPC 不会超发预算。
|
|
"""
|
|
if not self.available():
|
|
return self._error(name, '当前 Python 调用窗口已结束或用户权限已变化。')
|
|
tool = self.tools.get(name)
|
|
if tool is None:
|
|
return self._error(name, '当前 cell 未启用此原生只读工具。')
|
|
if self.calls >= MAX_TOOL_CALLS:
|
|
return self._error(name, f'已达到单次代码执行的 {MAX_TOOL_CALLS} 次工具调用上限。')
|
|
self.calls += 1
|
|
started = time.monotonic()
|
|
try:
|
|
if not self._read_allowed(tool, arguments):
|
|
result = self._error(name, '代码内只允许安全只读操作,写入、凭据与未知敏感级别的读取不可调用。')
|
|
else:
|
|
task = self._context.run(get_task_registry().create, self._invoke(tool, arguments), owner='agent.code.dispatch')
|
|
self._pending.add(task)
|
|
try:
|
|
result = await task
|
|
finally:
|
|
self._pending.discard(task)
|
|
except (TypeError, ValueError) as error:
|
|
result = self._error(name, summarize_error(error))
|
|
except asyncio.CancelledError:
|
|
self._record(name, arguments, started, ExecutionOutcome.UNKNOWN)
|
|
raise
|
|
self._record(name, arguments, started, inspect_tool_result(result))
|
|
return result
|
|
|
|
def _record(self, name: str, arguments: dict[str, Any], started: float, outcome: ExecutionOutcome) -> None:
|
|
"""
|
|
每次实际准入调用都有脱敏回执,中断也不能从调用记录中消失。
|
|
"""
|
|
self.log.append({'tool': name, 'args_preview': summarize_input(arguments, max_chars=80),
|
|
'duration': round(time.monotonic() - started, 2), 'outcome': outcome.value})
|
|
|
|
async def _invoke(self, tool: Any, arguments: dict[str, Any]) -> Any:
|
|
"""
|
|
重新检查排队期间变化的身份;工具异常只产生脱敏回执,取消保持传播。
|
|
"""
|
|
if not self.available():
|
|
return self._error(tool.name, '当前 Python 调用窗口已结束或用户权限已变化。')
|
|
try:
|
|
result = await self._dispatch(tool, arguments)
|
|
except Exception as error:
|
|
return self._error(tool.name, summarize_error(error))
|
|
if inspect_tool_result(result) is not ExecutionOutcome.SUCCEEDED:
|
|
self._error(tool.name, summarize_result(result, max_chars=300))
|
|
return result
|
|
|
|
async def retire(self) -> None:
|
|
"""
|
|
先同步封口,再取消 cell 遗留调用并等待真实结算。
|
|
"""
|
|
self._active = False
|
|
pending = [task for task in self._pending if not task.done()]
|
|
if pending:
|
|
self._error('execute_code', f'cell 结束时仍有 {len(pending)} 次工具调用未完成,已取消。')
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|