Files
MoviePilot/app/agent/policy/orchestrator.py

201 lines
7.0 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.
"""Agent 工具策略观测、脱敏回执与共享执行边界。"""
import time
import uuid
from collections.abc import Callable
from typing import Any, Mapping, Optional, TypeVar
from langchain_core.messages import ToolMessage
from pydantic import ValidationError
from app.agent.policy.contracts import (
ConfirmationMode,
ExecutionOutcome,
ExecutionReceipt,
MigrationState,
PolicyDecision,
PolicyObservation,
ToolInvocation,
ToolPolicyContext,
)
from app.agent.policy.registry import DEFAULT_TOOL_POLICY_REGISTRY, ToolPolicyRegistry
from app.agent.policy.sanitizer import (
stable_type_name,
summarize_error,
summarize_input,
summarize_result,
)
from app.log import logger
_HookResult = TypeVar("_HookResult")
def call_policy_hook(
phase: str,
hook: Callable[..., _HookResult],
*args: Any,
**kwargs: Any,
) -> Optional[_HookResult]:
"""以 fail-open 方式调用兼容观测 hook故障只记录稳定类型。"""
try:
return hook(*args, **kwargs)
except Exception as error:
try:
logger.warning(
f"Agent工具策略观测失败: phase={phase}, "
f"error_type={stable_type_name(error)}"
)
except Exception:
pass
return None
def _normalize_policy_arguments(tool: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
"""为策略生成 Pydantic 规范化副本,不改变真实执行参数。"""
raw_arguments = dict(arguments or {})
args_schema = getattr(tool, "args_schema", None)
if not args_schema:
return raw_arguments
try:
validated = args_schema.model_validate(raw_arguments)
return validated.model_dump(mode="json")
except (AttributeError, TypeError, ValueError, ValidationError):
# 实际 handler 仍负责既有参数错误语义;策略观测按原始值保守处理。
return raw_arguments
def _result_payload(result: Any) -> Any:
"""从 LangChain 工具消息中提取模型可见结果供脱敏摘要使用。"""
if isinstance(result, ToolMessage):
return result.content
return result
class AgentToolPolicyOrchestrator:
"""让 Agent middleware 与 direct manager 复用同一策略生命周期。"""
def __init__(self, registry: ToolPolicyRegistry = DEFAULT_TOOL_POLICY_REGISTRY) -> None:
"""绑定工具策略解析表。"""
self.registry = registry
def start(
self,
*,
context: ToolPolicyContext,
tool: Any,
arguments: Mapping[str, Any],
invocation_id: Optional[str] = None,
) -> PolicyObservation:
"""解析调用策略,并创建不影响现有 allow 行为的观测对象。"""
tool_name = str(getattr(tool, "name", None) or "unknown_tool")
normalized_arguments = _normalize_policy_arguments(tool, arguments)
policy = self.registry.resolve(
tool_name=tool_name,
arguments=normalized_arguments,
requires_admin=bool(getattr(tool, "_require_admin", False)),
)
if policy.migration_state is MigrationState.LEGACY_SHADOW:
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="legacy_shadow_allow",
)
elif policy.confirmation is ConfirmationMode.REQUIRED:
# 通用编排器保持 shadow支持的 Agent 入口会在 ToolNode 前独立完成确认。
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="confirmation_policy_shadow_allow",
)
else:
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=False,
reason_code="safe_read_allow",
)
invocation = ToolInvocation(
invocation_id=invocation_id or uuid.uuid4().hex,
tool_name=tool_name,
arguments=normalized_arguments,
principal=context.principal,
session_id=context.session_id,
origin=context.origin,
channel=context.channel,
source=context.source,
)
input_summary = summarize_input(normalized_arguments)
observation = PolicyObservation(
invocation=invocation,
policy=policy,
decision=decision,
input_summary=input_summary,
started_at=time.monotonic(),
)
logger.debug(
f"Agent工具策略: tool={tool_name}, origin={context.origin.value}, "
f"decision={decision.reason_code}, input={input_summary}"
)
return observation
@staticmethod
def finish(observation: PolicyObservation, result: Any) -> ExecutionReceipt:
"""生成成功回执 envelope并只记录脱敏结果摘要。"""
result_summary = summarize_result(_result_payload(result))
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
tool_name=observation.invocation.tool_name,
origin=observation.invocation.origin,
decision=observation.decision,
outcome=ExecutionOutcome.SUCCEEDED,
input_summary=observation.input_summary,
result_summary=result_summary,
duration_ms=max(
0,
int((time.monotonic() - observation.started_at) * 1000),
),
)
logger.info(
f"Agent工具执行完成: tool={receipt.tool_name}, "
f"origin={receipt.origin.value}, shadow={receipt.decision.shadow}, "
f"duration_ms={receipt.duration_ms}, result={result_summary}"
)
return receipt
@staticmethod
def fail(observation: PolicyObservation, error: BaseException) -> ExecutionReceipt:
"""生成失败回执 envelope不把异常中的凭据写入日志。"""
error_summary = summarize_error(error)
receipt = ExecutionReceipt(
invocation_id=observation.invocation.invocation_id,
tool_name=observation.invocation.tool_name,
origin=observation.invocation.origin,
decision=observation.decision,
outcome=ExecutionOutcome.FAILED,
input_summary=observation.input_summary,
error_summary=error_summary,
duration_ms=max(
0,
int((time.monotonic() - observation.started_at) * 1000),
),
)
logger.error(
f"Agent工具执行失败: tool={receipt.tool_name}, "
f"origin={receipt.origin.value}, shadow={receipt.decision.shadow}, "
f"duration_ms={receipt.duration_ms}, error={error_summary}"
)
return receipt
DEFAULT_TOOL_POLICY_ORCHESTRATOR = AgentToolPolicyOrchestrator()
__all__ = [
"AgentToolPolicyOrchestrator",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR",
"call_policy_hook",
]