feat: implement skeleton phase

This commit is contained in:
hh
2026-05-19 14:19:15 +02:00
parent 75a23d231e
commit 221e660c5c
21 changed files with 586 additions and 3 deletions
+7 -2
View File
@@ -1,2 +1,7 @@
def main() -> None:
print("Hello from beaver-gateway!")
"""Beaver Gateway — personal AI agent gateway."""
from __future__ import annotations
from beaver_gateway.cli import main
__all__ = ["main"]
+8
View File
@@ -0,0 +1,8 @@
"""Allow ``python -m beaver_gateway``."""
from __future__ import annotations
from beaver_gateway.cli import main
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
"""Agent type definitions exposed to user config."""
from __future__ import annotations
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
__all__ = ["BaseAgent", "ClaudeAgent", "ExposedMcp", "RaycastAgent"]
+33
View File
@@ -0,0 +1,33 @@
"""Shared agent surface.
``BaseAgent`` is the structural contract every backend-specific agent
honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``,
``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime
matrix.
"""
from __future__ import annotations
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict
@dataclass(frozen=True, slots=True)
class ExposedMcp:
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
name: str
tools: tuple[str, ...] | None = None
class BaseAgent(BaseModel):
"""Common fields. Concrete backends subclass and add capabilities."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str
model: str
system_prompt: str
expose_mcps: tuple[ExposedMcp, ...] = ()
accept_client_tools: bool = False
+17
View File
@@ -0,0 +1,17 @@
"""Claude Code agent definition.
Notice the absence of a ``streaming`` field — claude-code does not emit
token-level deltas, and that fact is encoded in the type, not in a
runtime branch.
"""
from __future__ import annotations
from beaver_gateway.agents.base import BaseAgent
class ClaudeAgent(BaseAgent):
"""Agent backed by ``claude-code-api``."""
cwd: str
available_native_tools: tuple[str, ...] = ()
+15
View File
@@ -0,0 +1,15 @@
"""Raycast agent definition."""
from __future__ import annotations
from raycast_api import Source
from beaver_gateway.agents.base import BaseAgent
class RaycastAgent(BaseAgent):
"""Agent backed by ``raycast-api``."""
streaming: bool = True
available_native_tools: tuple[str, ...] = ()
source: Source = Source.AI_CHAT
+28
View File
@@ -0,0 +1,28 @@
"""Process entrypoint.
Phase 0.3 — load the user's ``/config/config.py`` via
``config_loader``, build registries, print the
``loaded N agents, M mcps, K frontends`` line from the Phase 0 DoD,
exit cleanly. Phase 0.4 will install uvloop and start uvicorn(s) for
the frontends and the internal MCP app.
"""
from __future__ import annotations
from beaver_gateway import config_loader
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.settings import Settings
def main() -> None:
settings = Settings() # ty: ignore[missing-argument]
gateway = config_loader.load(settings.config_path)
agents = AgentRegistry(gateway.agents)
mcps = McpRegistry(gateway.mcps)
print(
f"beaver-gateway: loaded {len(agents)} agents, "
f"{len(mcps)} mcps, {len(gateway.frontends)} frontends"
)
+99
View File
@@ -0,0 +1,99 @@
"""Load the user's ``/config/config.py``.
The config file is regular Python. We ``exec`` it in a namespace seeded
with the public surface the user is expected to use (``ClaudeAgent``,
``RaycastAgent``, ``McpServer``, ``ExposedMcp``, ``Gateway``). The file
must assign a top-level ``gateway = Gateway(...)``.
Element-level validation already happens at construction time — agents
and ``McpServer`` factories are pydantic models that reject garbage.
What we *can't* validate at construction is the ``Gateway`` container
itself (deliberately a plain dataclass per PRD), so we type-check its
contents here before handing it back.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.core.registry import Gateway
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
if TYPE_CHECKING:
from pathlib import Path
class ConfigError(Exception):
"""User config file is missing, unreadable, or structurally wrong."""
_PUBLIC_NAMES: dict[str, Any] = {
"ClaudeAgent": ClaudeAgent,
"RaycastAgent": RaycastAgent,
"McpServer": McpServer,
"ExposedMcp": ExposedMcp,
"Gateway": Gateway,
}
_McpInstance = StdioMcp | HttpMcp | PythonToolMcp
def load(path: Path) -> Gateway:
"""Execute ``path`` and return its top-level ``gateway`` object."""
try:
source = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
msg = f"config file not found: {path}"
raise ConfigError(msg) from exc
except OSError as exc:
msg = f"could not read config file {path}: {exc}"
raise ConfigError(msg) from exc
code = compile(source, str(path), "exec")
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
exec(code, namespace) # noqa: S102 - exec'ing user config is the feature
try:
gw = namespace["gateway"]
except KeyError as exc:
msg = f"{path}: no top-level `gateway = Gateway(...)` found"
raise ConfigError(msg) from exc
if not isinstance(gw, Gateway):
msg = (
f"{path}: top-level `gateway` must be a Gateway instance, "
f"got {type(gw).__name__}"
)
raise ConfigError(msg)
_validate(gw, path)
return gw
def _validate(gw: Gateway, path: Path) -> None:
for i, a in enumerate(gw.agents):
if not isinstance(a, BaseAgent):
msg = (
f"{path}: gateway.agents[{i}] must be a ClaudeAgent / "
f"RaycastAgent instance, got {type(a).__name__}"
)
raise ConfigError(msg)
for i, m in enumerate(gw.mcps):
if not isinstance(m, _McpInstance):
msg = (
f"{path}: gateway.mcps[{i}] must be built via "
f"McpServer.stdio/.http/.python_tool, "
f"got {type(m).__name__}"
)
raise ConfigError(msg)
for i, f in enumerate(gw.frontends):
if not isinstance(f, Frontend):
msg = (
f"{path}: gateway.frontends[{i}] must be a Frontend, "
f"got {type(f).__name__}"
)
raise ConfigError(msg)
+7
View File
@@ -0,0 +1,7 @@
"""Cross-cutting machinery: registries, event protocol, auth, sessions."""
from __future__ import annotations
from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry
__all__ = ["AgentRegistry", "Gateway", "McpRegistry"]
+83
View File
@@ -0,0 +1,83 @@
"""Agent / MCP registries + the user-facing ``Gateway`` collector.
The user's ``/config/config.py`` ends with::
gateway = Gateway(agents=[...], mcps=[...], frontends=[...])
``cli.main`` picks that object up and builds the registries.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import McpServerT
class AgentRegistry:
"""Name → agent lookup with duplicate detection."""
def __init__(self, agents: Iterable[BaseAgent]) -> None:
self._agents: dict[str, BaseAgent] = {}
for a in agents:
if a.name in self._agents:
msg = f"duplicate agent name: {a.name!r}"
raise ValueError(msg)
self._agents[a.name] = a
def __getitem__(self, name: str) -> BaseAgent:
return self._agents[name]
def get(self, name: str) -> BaseAgent | None:
return self._agents.get(name)
def __iter__(self) -> Iterator[BaseAgent]:
return iter(self._agents.values())
def __len__(self) -> int:
return len(self._agents)
def __contains__(self, name: object) -> bool:
return name in self._agents
class McpRegistry:
"""Name → MCP server lookup with duplicate detection."""
def __init__(self, mcps: Iterable[McpServerT]) -> None:
self._mcps: dict[str, McpServerT] = {}
for m in mcps:
if m.name in self._mcps:
msg = f"duplicate mcp name: {m.name!r}"
raise ValueError(msg)
self._mcps[m.name] = m
def __getitem__(self, name: str) -> McpServerT:
return self._mcps[name]
def get(self, name: str) -> McpServerT | None:
return self._mcps.get(name)
def __iter__(self) -> Iterator[McpServerT]:
return iter(self._mcps.values())
def __len__(self) -> int:
return len(self._mcps)
def __contains__(self, name: object) -> bool:
return name in self._mcps
@dataclass(slots=True)
class Gateway:
"""Top-level object the user assembles in ``/config/config.py``."""
agents: list[BaseAgent] = field(default_factory=list)
mcps: list[McpServerT] = field(default_factory=list)
frontends: list[Frontend] = field(default_factory=list)
+7
View File
@@ -0,0 +1,7 @@
"""Frontend protocols and (later) concrete app implementations."""
from __future__ import annotations
from beaver_gateway.frontends.base import Frontend
__all__ = ["Frontend"]
+24
View File
@@ -0,0 +1,24 @@
"""Frontend ABC.
A frontend is anything that listens on a port and routes inbound traffic
into the agent/MCP registries. ``configure`` is called once after the
``Gateway`` is built; ``serve`` runs the listening loop.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from beaver_gateway.core.registry import Gateway
class Frontend(ABC):
"""Listens on a port, dispatches into the gateway."""
@abstractmethod
def configure(self, gateway: Gateway) -> None: ...
@abstractmethod
async def serve(self) -> None: ...
+13
View File
@@ -0,0 +1,13 @@
"""MCP server definitions and (later) the internal aggregator app."""
from __future__ import annotations
from beaver_gateway.mcp.types import (
HttpMcp,
McpServer,
McpServerT,
PythonToolMcp,
StdioMcp,
)
__all__ = ["HttpMcp", "McpServer", "McpServerT", "PythonToolMcp", "StdioMcp"]
+77
View File
@@ -0,0 +1,77 @@
"""User-facing MCP server declarations.
Three flavours, one factory facade (``McpServer``). The factory returns
discriminated-union members so downstream code can ``match`` on ``kind``.
"""
from __future__ import annotations
from collections.abc import Callable # noqa: TC003 — runtime use by pydantic
from typing import TYPE_CHECKING, Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING:
from collections.abc import Iterable
class _BaseMcp(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str
class StdioMcp(_BaseMcp):
"""Subprocess MCP server we spawn and connect to over stdio."""
kind: Literal["stdio"] = "stdio"
command: tuple[str, ...]
env: dict[str, str] | None = None
cwd: str | None = None
class HttpMcp(_BaseMcp):
"""Remote MCP server reached over streamable HTTP."""
kind: Literal["http"] = "http"
url: str
auth: str | None = None
class PythonToolMcp(_BaseMcp):
"""Bundle of Python callables exposed as one FastMCP namespace."""
kind: Literal["python_tool"] = "python_tool"
tools: tuple[Callable[..., object], ...]
McpServerT = Annotated[
StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind")
]
class McpServer:
"""Factory facade matching the PRD-documented config surface."""
@classmethod
def stdio(
cls,
*,
name: str,
command: Iterable[str],
env: dict[str, str] | None = None,
cwd: str | None = None,
) -> StdioMcp:
return StdioMcp(name=name, command=tuple(command), env=env, cwd=cwd)
@classmethod
def http(
cls, *, name: str, url: str, auth: str | None = None
) -> HttpMcp:
return HttpMcp(name=name, url=url, auth=auth)
@classmethod
def python_tool(
cls, *, name: str, tools: Iterable[Callable[..., object]]
) -> PythonToolMcp:
return PythonToolMcp(name=name, tools=tuple(tools))
+31
View File
@@ -0,0 +1,31 @@
"""Process-wide configuration loaded from environment (`.env`).
Everything that varies between deployments (creds, paths, ports) lives here.
User-facing agent/MCP/frontend definitions live in ``/config/config.py``
and are loaded by ``config_loader`` (Phase 0.3).
"""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime environment for the gateway process."""
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
database_url: str
admin_user: str
admin_pass: str
session_secret: str
internal_mcp_port: int = 8765
config_path: Path = Path("/config/config.py")
raycast_bearer: str | None = None
raycast_config_path: Path = Path("/config/raycast.json")