Files
beaver-gateway/src/beaver_gateway/frontends/base.py
T

107 lines
3.9 KiB
Python

"""Frontend ABC and the runtime context handed to ``configure``.
A frontend routes inbound traffic into the gateway (an HTTP mount, a
poller, or both); ``GatewayRuntime`` carries the built state each needs.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from beaver_gateway.security.auth import BUILTIN_SCOPES
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from starlette.types import ASGIApp
from beaver_gateway.app import AgentRegistry, McpRegistry
from beaver_gateway.backends.base import Backend
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation, ConversationBinding
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class GatewayRuntime:
"""Post-build state of the gateway, shared with every frontend.
Backends are keyed by agent name, not type: one backend instance may
serve several agents, so the indirection lives at lookup time.
"""
agents: AgentRegistry
mcps: McpRegistry
backends: dict[str, Backend]
token_store: TokenStore
db: Database
mcp_internal_urls: Mapping[str, str] = field(default_factory=dict)
admin_user: str = ""
"""Operator login for the admin console, checked by ``AdminFrontend``."""
admin_pass: str = ""
session_secret: str = ""
frontends: Sequence[Frontend] = field(default_factory=tuple)
"""Every frontend in declaration order, for advertising their URLs."""
turn_log_handlers: list[TurnLogHandler] = field(default_factory=list)
"""Called with a ``TurnRecord`` after each turn; failures never reach the user."""
conversations: Any = None
bus: Any = None
pool: Any = None
scheduler: Any = None
public_url: str | None = None
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
scopes: frozenset[str] = BUILTIN_SCOPES
"""Every token scope this gateway knows: the builtins plus each frontend's."""
class Frontend(ABC):
"""Routes inbound traffic into the gateway.
HTTP frontends set ``path`` and return their ASGI app from ``app()``;
these are mounted under that path on the one gateway port. ``serve()``
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
``landing`` marks the app that ``/`` redirects to.
``scope`` is the token scope this frontend's routes are gated by; a
frontend that names its own scope gets keys nobody else's token opens,
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows). The first
frontend whose ``materialize`` returns a binding is the *home* of
that kind, used by ``spawn`` for new conversations; ``agent_for``
names the default agent for a kind. Stateless frontends (MCP, admin)
leave these at their defaults.
"""
name: str = ""
kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
scope: str | None = None
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
def app(self) -> ASGIApp | None:
return None
async def serve(self) -> None:
return
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
return None
async def materialize(self, conv: Conversation) -> ConversationBinding | None: # noqa: ARG002
return None
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
"""Show in the window that the conversation is over (a renamed topic)."""
return False