Files
2026-08-21 16:00:13 +08:00

179 lines
6.6 KiB
Python
Raw Permalink 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.
"""待确认动作的存储(高风险工具调用的确认中转)。
司命决策为 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(),
)