feat: implement skeleton phase
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Allow ``python -m beaver_gateway``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver_gateway.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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, ...] = ()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Frontend protocols and (later) concrete app implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
|
||||
__all__ = ["Frontend"]
|
||||
@@ -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: ...
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user