Initial commit
This commit is contained in:
114
server/agent/confirmation.py
Normal file
114
server/agent/confirmation.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
agent/confirmation.py — Confirmation flow for side-effect tool calls.
|
||||
|
||||
When a tool has requires_confirmation=True, the agent loop calls
|
||||
ConfirmationManager.request(). This suspends the tool call and returns
|
||||
control to the web layer, which shows the user a Yes/No prompt.
|
||||
|
||||
The web route calls ConfirmationManager.respond() when the user decides.
|
||||
The suspended coroutine resumes with the result.
|
||||
|
||||
Pending confirmations expire after TIMEOUT_SECONDS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingConfirmation:
|
||||
session_id: str
|
||||
tool_name: str
|
||||
arguments: dict
|
||||
description: str # Human-readable summary shown to user
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
||||
_approved: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"tool_name": self.tool_name,
|
||||
"arguments": self.arguments,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class ConfirmationManager:
|
||||
"""
|
||||
Singleton-style manager. One instance shared across the app.
|
||||
Thread-safe for asyncio (single event loop).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: dict[str, PendingConfirmation] = {}
|
||||
|
||||
async def request(
|
||||
self,
|
||||
session_id: str,
|
||||
tool_name: str,
|
||||
arguments: dict,
|
||||
description: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Called by the agent loop when a tool requires confirmation.
|
||||
Suspends until the user responds (Yes/No) or the timeout expires.
|
||||
|
||||
Returns True if approved, False if denied or timed out.
|
||||
"""
|
||||
if session_id in self._pending:
|
||||
# Previous confirmation timed out and wasn't cleaned up
|
||||
logger.warning(f"Overwriting stale pending confirmation for session {session_id}")
|
||||
|
||||
confirmation = PendingConfirmation(
|
||||
session_id=session_id,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
description=description,
|
||||
)
|
||||
self._pending[session_id] = confirmation
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(confirmation._event.wait(), timeout=TIMEOUT_SECONDS)
|
||||
approved = confirmation._approved
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"Confirmation timed out for session {session_id} / tool {tool_name}")
|
||||
approved = False
|
||||
finally:
|
||||
self._pending.pop(session_id, None)
|
||||
|
||||
action = "approved" if approved else "denied/timed out"
|
||||
logger.info(f"Confirmation {action}: session={session_id} tool={tool_name}")
|
||||
return approved
|
||||
|
||||
def respond(self, session_id: str, approved: bool) -> bool:
|
||||
"""
|
||||
Called by the web route (/api/confirm) when the user clicks Yes or No.
|
||||
Returns False if no pending confirmation exists for this session.
|
||||
"""
|
||||
confirmation = self._pending.get(session_id)
|
||||
if confirmation is None:
|
||||
logger.warning(f"No pending confirmation for session {session_id}")
|
||||
return False
|
||||
|
||||
confirmation._approved = approved
|
||||
confirmation._event.set()
|
||||
return True
|
||||
|
||||
def get_pending(self, session_id: str) -> PendingConfirmation | None:
|
||||
return self._pending.get(session_id)
|
||||
|
||||
def list_pending(self) -> list[dict]:
|
||||
return [c.to_dict() for c in self._pending.values()]
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
confirmation_manager = ConfirmationManager()
|
||||
Reference in New Issue
Block a user