mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 21:17:06 +08:00
230 lines
8.1 KiB
Python
230 lines
8.1 KiB
Python
"""Agent 工具策略观测、脱敏回执与共享执行边界。"""
|
|
|
|
import asyncio
|
|
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 (
|
|
ActionEffect,
|
|
ConfirmationMode,
|
|
ExecutionOutcome,
|
|
ExecutionReceipt,
|
|
MigrationState,
|
|
PolicyDecision,
|
|
PolicyObservation,
|
|
RecoveryMode,
|
|
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.runtime.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 _uncertain_external_state(
|
|
observation: PolicyObservation,
|
|
error: BaseException,
|
|
) -> tuple[bool, bool]:
|
|
"""标记取消或超时后无法确认的写操作终态。"""
|
|
interrupted = isinstance(error, (asyncio.CancelledError, TimeoutError))
|
|
read_only = observation.policy.effect in {
|
|
ActionEffect.SAFE_READ,
|
|
ActionEffect.SENSITIVE_READ,
|
|
}
|
|
if not interrupted or read_only:
|
|
return False, False
|
|
needs_reconcile = observation.policy.recovery not in {
|
|
RecoveryMode.TRANSACTION,
|
|
RecoveryMode.IDEMPOTENT,
|
|
}
|
|
return True, needs_reconcile
|
|
|
|
@staticmethod
|
|
def fail(observation: PolicyObservation, error: BaseException) -> ExecutionReceipt:
|
|
"""生成失败回执 envelope,不把异常中的凭据写入日志。"""
|
|
error_summary = summarize_error(error)
|
|
external_may_continue, needs_reconcile = (
|
|
AgentToolPolicyOrchestrator._uncertain_external_state(observation, 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),
|
|
),
|
|
external_may_continue=external_may_continue,
|
|
needs_reconcile=needs_reconcile,
|
|
)
|
|
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}, "
|
|
f"external_may_continue={receipt.external_may_continue}, "
|
|
f"needs_reconcile={receipt.needs_reconcile}"
|
|
)
|
|
return receipt
|
|
|
|
|
|
DEFAULT_TOOL_POLICY_ORCHESTRATOR = AgentToolPolicyOrchestrator()
|
|
|
|
|
|
__all__ = [
|
|
"AgentToolPolicyOrchestrator",
|
|
"DEFAULT_TOOL_POLICY_ORCHESTRATOR",
|
|
"call_policy_hook",
|
|
]
|