Files
2026-05-20 21:30:10 +02:00

225 lines
11 KiB
Python

# Sample user config — grows alongside the implementation phases.
#
# Loader (beaver_gateway/config_loader.py) execs this file with
# ClaudeAgent, RaycastAgent, McpServer, ExposedMcp, Gateway already
# bound — so importing them is optional. We import explicitly here so
# IDEs and type-checkers see real symbols instead of free variables.
import tempfile
from datetime import date
from pathlib import Path
from beaver_gateway.agents.base import ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
from beaver_gateway.core.registry import Gateway
from beaver_gateway.core.turn_record import TurnRecord, slugify
from beaver_gateway.frontends.admin import AdminFrontend
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.markdown import MarkdownFrontend
from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.mcp.types import McpServer
def chat_log_path(record: TurnRecord, vault: Path) -> Path:
"""Decide where a logged chat from another frontend lands in the vault.
Called by ``MarkdownFrontend`` (with ``log_all_chats=True``) the first
time a conversation needs a file — continuation turns are matched by
fingerprint and stick to the file picked here. Return value can be
absolute or relative; relative paths are anchored under ``vault``.
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md`` where
``topic`` is a slug of the very first user message in the chat.
"""
today = date.today()
topic = slugify(record.first_user_text, maxlen=40)
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{topic}.md"
def current_time() -> str:
"""Return the current local time as an ISO-8601 string.
Trivial demo tool for the Phase 2.1 internal MCP aggregator —
confirms a ``python_tool`` namespace is reachable on
``http://127.0.0.1:<INTERNAL_MCP_PORT>/mcp/time``.
"""
from datetime import datetime
return datetime.now().astimezone().isoformat()
gateway = Gateway(
agents=[
# Phase 2.2 — ClaudeCodeBackendAdapter routes this agent's
# ``/v1/messages`` calls through ``claude-code-api``. The
# ``time`` MCP gets exposed as ``mcp__time__current_time`` to
# the subscription claude session via
# ``BackendOptions.mcp_servers`` pointing at the internal
# aggregator on ``127.0.0.1:INTERNAL_MCP_PORT/mcp/time/``.
#
# Fresh empty tempdir (not a hardcoded ``/tmp``) for two
# reasons: claude-code-api derives the JSONL project-key from
# ``cwd``, but claude itself writes the JSONL using the cwd's
# realpath — on macOS ``/tmp`` and ``/var/folders/...`` are
# both ``/private/...`` symlinks, so unresolved cwds make
# ``JsonlWatcher`` time out waiting on the wrong path. The
# explicit ``.resolve()`` collapses the symlink before claude
# ever sees the dir, and ``mkdtemp`` guarantees the directory
# is empty so claude does not pick up leftover files.
ClaudeAgent(
name="stub",
model="claude-sonnet-4-6",
# ``system_prompt`` is appended to claude's built-in agent
# prompt (via ``--append-system-prompt``) — so it adds the
# agent's identity on top of claude-code's baseline tool
# knowledge, rather than replacing it. Same shape as the
# RaycastAgent's ``system_prompt → additional_system_instructions``
# mapping. For full ``BackendOptions`` knobs (timeouts,
# extra_args, history mode, etc.) import ``ClaudeCodeOptions``
# and pass ``options=ClaudeCodeOptions(...)``.
system_prompt=(
"You are a stub agent used to validate the Phase 0 skeleton.\n"
"If asked the current time, call the `current_time`"
" MCP tool instead of guessing."
),
cwd=Path(tempfile.mkdtemp(prefix="beaver-stub-cwd-")).resolve(),
expose_mcps=(ExposedMcp(name="time"),),
),
# Phase 1.2 — a RaycastAgent the AnthropicMessagesFrontend will
# route via RaycastBackend. Phase 1.5 added the per-agent knobs
# (`temperature` / `additional_system_instructions` / etc.) —
# only `model`, `system_prompt`, and at least one of the others
# is mandatory.
RaycastAgent(
name="research",
model="Gemini 3.1 Flash Lite",
system_prompt=(
"You are a research assistant. "
"Reply in the user's language. Cite URLs when you use web search."
),
temperature=0.5,
available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE),
# Lambda so today's date is rebuilt on every request while
# locale/timezone stay pinned. ``True`` would give the same
# fresh-date behaviour but would also auto-pick host locale
# and timezone (``en-US`` / system tz), which isn't what we
# want here.
user_preferences=lambda: UserPreferences(
locale="en-GB",
timezone="Europe/Berlin",
current_date=date.today().isoformat(), # noqa: DTZ011 — local date is intended
),
),
],
mcps=[
# Phase 2.1 — bundle of plain Python callables exposed as one
# FastMCP namespace. The internal aggregator mounts it under
# ``/mcp/time`` on ``127.0.0.1:INTERNAL_MCP_PORT``; Phase 2.2's
# ClaudeCode adapter forwards that URL into
# ``BackendOptions.mcp_servers``. Phase 3's ``McpServerFrontend``
# reverse-proxies the same internal URL out to external clients.
McpServer.python_tool(name="time", tools=[current_time])
# Phase 3 — illustrates the ``lenient`` flag. Real-world stdio MCPs
# sometimes print "Processing..." or other chatter to stdout before
# their actual JSON-RPC frames; the default mcp client forwards
# those parse failures downstream as warnings (visible in
# Cursor/Cline). With ``lenient=True`` we silently drop non-JSON
# lines, so downstream UIs see clean JSON-RPC only. The command
# below is just a placeholder — replace with whatever stdio MCP
# you actually want gateway to ingest (e.g. an obsidian-mcp).
#
# Commented out by default: example users won't have the binary
# installed and an unreachable command makes ``docker compose up``
# surface a confusing "command not found" line at first request.
# Uncomment after pointing ``command`` at a real stdio MCP.
#
# McpServer.stdio(
# name="obsidian",
# command=["uvx", "mcp-obsidian"],
# env={"OBSIDIAN_API_KEY": "..."},
# lenient=True,
# ),
],
frontends=[
# Phase 1.4 — expose the agents as `model=<name>` on an
# Anthropic-compatible Messages endpoint. Auth comes from
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
#
# Behind a reverse proxy (Caddy / nginx / Cloudflare) pass
# `public_base_url=` so the admin dashboard advertises the
# outside URL instead of `host:port`. Caddy strips its own
# prefix and the frontend's internal paths (`/v1/messages`,
# `/v1/models`) get appended:
# Caddy: handle_path /ai/* { reverse_proxy localhost:8000 }
# Config: AnthropicMessagesFrontend(
# port=8000,
# public_base_url="https://domain.com/ai")
# Result: https://domain.com/ai/v1/messages
AnthropicMessagesFrontend(host="0.0.0.0", port=8000),
# Phase 3 — re-exposes every declared `McpServer` outside the
# gateway with bearer auth + audit log. Each namespace lives
# at `/<name>/` on this port (the port itself disambiguates
# MCP traffic — no extra `/mcp` segment in the route); a flat
# bundle is published at `/all/`. Discovery page (HTML,
# auth-gated) at `/` with copy-pastable Cursor / Claude
# Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
#
# Same `public_base_url=` knob as above. Caddy strips its
# prefix; the frontend's `/<name>/` segment gets appended:
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
# Config: McpServerFrontend(
# port=8001,
# public_base_url="https://domain.com/mcp")
# Result: https://domain.com/mcp/<name>/ (and /mcp/all/)
McpServerFrontend(host="0.0.0.0", port=8001),
# Phase 4.3 — browser admin UI. Creds come from
# `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with
# `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in
# the DB), revoke them, and watch the audit log. Scope is
# enforced on the bearer frontends: tokens minted with scope
# `messages` only work on `/v1/messages`; `mcp` only on
# `/mcp/<name>`; `*` works everywhere.
AdminFrontend(host="0.0.0.0", port=8002),
# Obsidian-vault chat frontend. Each `.md` is one conversation
# (User/Assistant turn pairs). The Obsidian companion plugin
# POSTs `{filename, content?}` to `/chat` — the frontend reads
# the file, runs the agent if the last turn is `user`, and
# appends the assistant reply back. With `log_all_chats=True`
# *every* turn (from Anthropic Messages too) is mirrored into
# `{vault}/_logs/<agent>/` so the vault is the central archive.
#
# `vault_path` here points at a per-restart tempdir so the
# example boots cleanly; in real deployments mount the
# Obsidian-sync container's vault volume to a stable path and
# pass that instead.
MarkdownFrontend(
host="0.0.0.0",
port=8003,
# Point at the dedicated chats subdir of your real Obsidian
# vault — the gateway has no idea (and no need) about other
# notes outside it. Path resolution / vault-escape checks
# are anchored here, so absolute-path attempts (and ``..``
# tricks) can't reach notes alongside it.
#
# vault_path=Path("/Users/me/Obsidian/Personal/chats"),
#
# Per-restart tempdir kept here so the example boots even
# without a real vault on the host.
vault_path=Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve(),
default_agent="research",
log_all_chats=True,
# ``log_path`` (optional) overrides the default
# ``{vault}/_logs/<agent>/<date>_<hex>.md`` layout for chats
# logged from OTHER frontends (Anthropic Messages, admin
# in-browser chat). Defined as a top-of-file function so the
# types are explicit and the IDE can hover them; a lambda
# works too, but a real ``def`` keeps the signature visible
# and lets you docstring it. Heads up: any custom path
# forces ``warm_index`` to scan the entire vault on startup
# so the fingerprint→file map survives a restart no matter
# where you put files.
log_path=chat_log_path,
),
],
)