"""待确认动作的存储(高风险工具调用的确认中转)。 司命决策为 ask 时不直接执行,而是登记一个 PendingConfirmation 并返回 confirmation_id; 前端展示确认卡片,用户 approve/deny 后由安全 API 消费。 提供两种实现: - ``InMemoryConfirmationStore``:进程内 dict,默认开发模式,不依赖 DB - ``DbConfirmationStore``:数据库持久化,生产多 worker 模式,确认项跨进程可见 """ from __future__ import annotations import json import uuid from collections.abc import Sequence from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any, Protocol from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.models.safety import PendingConfirmationRecord def _now_iso() -> str: return datetime.now(UTC).isoformat() def new_confirmation_id() -> str: return f"confirm_{uuid.uuid4().hex}" @dataclass(frozen=True) class PendingConfirmation: """一次待确认的工具调用。""" confirmation_id: str tool: str input: dict[str, Any] risk_level: str reason: str run_id: str | None = None character_id: str | None = None trace_id: str | None = None created_at: str = field(default_factory=_now_iso) class ConfirmationStore(Protocol): """确认存储协议(全异步接口)。""" async def create(self, pending: PendingConfirmation) -> PendingConfirmation: ... async def get(self, confirmation_id: str) -> PendingConfirmation | None: ... async def pop(self, confirmation_id: str) -> PendingConfirmation | None: ... async def list_pending(self) -> list[PendingConfirmation]: ... async def mark_timed_out(self, confirmation_id: str) -> None: ... class InMemoryConfirmationStore: """内存确认存储,供本地与测试使用。""" def __init__(self) -> None: self._items: dict[str, PendingConfirmation] = {} async def create(self, pending: PendingConfirmation) -> PendingConfirmation: self._items[pending.confirmation_id] = pending return pending async def get(self, confirmation_id: str) -> PendingConfirmation | None: return self._items.get(confirmation_id) async def pop(self, confirmation_id: str) -> PendingConfirmation | None: return self._items.pop(confirmation_id, None) async def list_pending(self) -> list[PendingConfirmation]: return sorted(self._items.values(), key=lambda item: item.created_at) async def mark_timed_out(self, confirmation_id: str) -> None: self._items.pop(confirmation_id, None) class DbConfirmationStore: """数据库确认存储,供多 worker 生产环境使用。 待确认项持久化到 ``pending_confirmations`` 表,跨进程可见。 需要从 ``app.state.session_factory`` 或 ``async_sessionmaker`` 注入。 """ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._session_factory = session_factory async def create(self, pending: PendingConfirmation) -> PendingConfirmation: async with self._session_factory() as session: record = PendingConfirmationRecord( id=pending.confirmation_id, tool=pending.tool, input_json=json.dumps(pending.input, ensure_ascii=False), risk_level=pending.risk_level, reason=pending.reason, run_id=pending.run_id, character_id=pending.character_id, trace_id=pending.trace_id, ) session.add(record) await session.commit() return pending async def get(self, confirmation_id: str) -> PendingConfirmation | None: async with self._session_factory() as session: result = await session.execute( select(PendingConfirmationRecord).where( PendingConfirmationRecord.id == confirmation_id, PendingConfirmationRecord.status == "pending", ) ) record = result.scalar_one_or_none() if record is None: return None return self._record_to_pending(record) async def pop(self, confirmation_id: str) -> PendingConfirmation | None: async with self._session_factory() as session: result = await session.execute( select(PendingConfirmationRecord).where( PendingConfirmationRecord.id == confirmation_id, PendingConfirmationRecord.status == "pending", ) ) record = result.scalar_one_or_none() if record is None: return None record.status = "consumed" await session.commit() return self._record_to_pending(record) async def list_pending(self) -> list[PendingConfirmation]: async with self._session_factory() as session: result = await session.execute( select(PendingConfirmationRecord) .where(PendingConfirmationRecord.status == "pending") .order_by(PendingConfirmationRecord.created_at.asc()) ) records: Sequence[PendingConfirmationRecord] = result.scalars().all() return [self._record_to_pending(r) for r in records] async def mark_timed_out(self, confirmation_id: str) -> None: """将待确认记录标记为超时(Agent 循环确认等待超时)。""" async with self._session_factory() as session: result = await session.execute( select(PendingConfirmationRecord).where( PendingConfirmationRecord.id == confirmation_id, PendingConfirmationRecord.status == "pending", ) ) record = result.scalar_one_or_none() if record is None: return record.status = "timed_out" record.resolved_at = datetime.now(UTC) await session.commit() @staticmethod def _record_to_pending(record: PendingConfirmationRecord) -> PendingConfirmation: return PendingConfirmation( confirmation_id=record.id, tool=record.tool, input=json.loads(record.input_json) if record.input_json else {}, risk_level=record.risk_level, reason=record.reason, run_id=record.run_id, character_id=record.character_id, trace_id=record.trace_id, created_at=record.created_at.isoformat() if record.created_at else _now_iso(), )