Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
116 lines
4.1 KiB
Python
116 lines
4.1 KiB
Python
"""Bounded, process-local conversation state for the single-worker Vocero API."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import secrets
|
|
from dataclasses import dataclass, field
|
|
from time import monotonic
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from starlette.requests import HTTPConnection
|
|
|
|
from vocero_api.core import config as settings
|
|
from vocero_api.models.mcp import BackendLogin, CallerIdentity, IntegrationError, McpToolDefinition
|
|
|
|
COOKIE_NAME = "vocero_browser"
|
|
CONVERSATION_HEADER = "x-vocero-conversation"
|
|
|
|
|
|
@dataclass
|
|
class Conversation:
|
|
id: str
|
|
owner: str = field(repr=False)
|
|
backend_token: str = field(repr=False)
|
|
profile: CallerIdentity
|
|
expires_at: float
|
|
catalog: list[McpToolDefinition] = field(default_factory=list)
|
|
history: list[list[dict[str, str]]] = field(default_factory=list, repr=False)
|
|
generation: int = 0
|
|
client_turn: int = 0
|
|
pending: asyncio.Task[Any] | None = field(default=None, repr=False)
|
|
socket_attached: bool = False
|
|
closed: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
|
|
def require_active(self) -> None:
|
|
if self.closed.is_set() or monotonic() >= self.expires_at:
|
|
raise IntegrationError(401, "La conversación expiró. Inicia una nueva sesión.")
|
|
|
|
def interrupt(self) -> int:
|
|
self.generation += 1
|
|
if self.pending is not None and not self.pending.done() and self.pending is not asyncio.current_task():
|
|
self.pending.cancel()
|
|
self.pending = None
|
|
return self.generation
|
|
|
|
def close(self) -> None:
|
|
self.interrupt()
|
|
self.closed.set()
|
|
self.backend_token = ""
|
|
self.history.clear()
|
|
self.catalog.clear()
|
|
|
|
|
|
class ConversationStore:
|
|
def __init__(self) -> None:
|
|
self._items: dict[str, Conversation] = {}
|
|
|
|
def prune(self) -> None:
|
|
for item in list(self._items.values()):
|
|
if item.closed.is_set() or monotonic() >= item.expires_at:
|
|
self.remove(item.id)
|
|
|
|
def create(self, browser: str, login: BackendLogin) -> Conversation:
|
|
self.prune()
|
|
if len(self._items) >= settings.S2S_MAX_CONVERSATIONS:
|
|
raise IntegrationError(503, "No hay capacidad para otra conversación. Intenta más tarde.")
|
|
item = Conversation(
|
|
id=secrets.token_urlsafe(24),
|
|
owner=self._owner(browser),
|
|
backend_token=login.sessionToken.get_secret_value(),
|
|
profile=login.user,
|
|
expires_at=monotonic() + settings.S2S_CONVERSATION_TTL_SEC,
|
|
)
|
|
self._items[item.id] = item
|
|
return item
|
|
|
|
@staticmethod
|
|
def _owner(browser: str) -> str:
|
|
return hashlib.sha256(browser.encode()).hexdigest()
|
|
|
|
def get(self, browser: str, conversation_id: str) -> Conversation:
|
|
self.prune()
|
|
item = self._items.get(conversation_id)
|
|
if not browser or item is None or not secrets.compare_digest(item.owner, self._owner(browser)):
|
|
raise IntegrationError(401, "La conversación no es válida. Inicia una nueva sesión.")
|
|
item.require_active()
|
|
return item
|
|
|
|
def remove(self, conversation_id: str) -> None:
|
|
item = self._items.pop(conversation_id, None)
|
|
if item is not None:
|
|
item.close()
|
|
|
|
def clear(self) -> None:
|
|
for conversation_id in list(self._items):
|
|
self.remove(conversation_id)
|
|
|
|
|
|
store = ConversationStore()
|
|
|
|
|
|
def check_origin(connection: HTTPConnection) -> None:
|
|
origin = connection.headers.get("origin")
|
|
if origin:
|
|
parsed = urlsplit(origin)
|
|
if parsed.scheme not in {"http", "https"} or parsed.netloc != connection.headers.get("host"):
|
|
raise IntegrationError(403, "El origen de la solicitud no está permitido.")
|
|
|
|
|
|
def resolve(connection: HTTPConnection) -> Conversation:
|
|
check_origin(connection)
|
|
conversation_id = connection.headers.get(CONVERSATION_HEADER, "")
|
|
if connection.scope["type"] == "websocket":
|
|
conversation_id = connection.query_params.get("conversation_id", "")
|
|
return store.get(connection.cookies.get(COOKIE_NAME, ""), conversation_id)
|