refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README
This commit is contained in:
+35
-175
@@ -1,204 +1,64 @@
|
||||
# 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.
|
||||
"""The smallest setup: one agent, one tool, one job, the built-in frontends.
|
||||
|
||||
The loader execs this file with ``ClaudeAgent``, ``RaycastAgent``,
|
||||
``McpServer``, ``ExposedMcp`` and ``Gateway`` already bound; the imports
|
||||
below are for type checkers. A real setup grows into a package next to
|
||||
this file (see beaver-agent).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from datetime import UTC, datetime
|
||||
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.app import Gateway
|
||||
from beaver_gateway.frontends.turn_record import slugify
|
||||
from beaver_gateway.agents.claude import ClaudeAgent, Prompts
|
||||
from beaver_gateway.config import Gateway
|
||||
from beaver_gateway.frontends.admin import AdminFrontend
|
||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||
from beaver_gateway.frontends.api import ApiFrontend
|
||||
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
||||
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||
from beaver_gateway.jobs.scheduler import Job, JobRun
|
||||
from beaver_gateway.mcp.types import McpServer
|
||||
|
||||
log = logging.getLogger("example")
|
||||
|
||||
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
|
||||
"""Where a new chat file lands in the vault.
|
||||
|
||||
Called by ``MarkdownFrontend`` for every conversation that needs a
|
||||
file: a ``deep`` chat spawned by the dispatcher, a ``/v1/messages``
|
||||
chat (``title`` = first user message), or with ``log_all_chats=True``
|
||||
the archive of a stateless agent's turns. Return value can be
|
||||
absolute or relative; relative paths are anchored under ``vault``.
|
||||
|
||||
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md``.
|
||||
"""
|
||||
today = date.today()
|
||||
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{slugify(title, maxlen=40)}.md"
|
||||
VAULT = Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve()
|
||||
PROMPT = VAULT / "assistant.md"
|
||||
PROMPT.write_text(
|
||||
"You are a concise assistant. When asked the time, call `current_time`.\n"
|
||||
)
|
||||
|
||||
|
||||
def current_time() -> str:
|
||||
"""Return the current local time as an ISO-8601 string.
|
||||
return datetime.now(UTC).astimezone().isoformat()
|
||||
|
||||
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()
|
||||
async def heartbeat(run: JobRun) -> None:
|
||||
master = await run.master()
|
||||
log.info("heartbeat: master=%s", master.external_id if master else None)
|
||||
|
||||
|
||||
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(),
|
||||
name="assistant",
|
||||
model="claude-sonnet-5",
|
||||
cwd=VAULT,
|
||||
prompts=Prompts(master=(PROMPT,), branch=(PROMPT,), deep=(PROMPT,)),
|
||||
gateway_tools=("spawn", "say", "schedule"),
|
||||
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,
|
||||
# ),
|
||||
)
|
||||
],
|
||||
mcps=[McpServer.python_tool(name="time", tools=[current_time])],
|
||||
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`).
|
||||
#
|
||||
# Every HTTP frontend is mounted under its own path on the one
|
||||
# gateway port (`Gateway.port`, 8000 here): `/anthropic/v1/messages`.
|
||||
# Behind a reverse proxy set `Gateway(public_url="https://domain.com")`
|
||||
# so advertised endpoints use the outside origin; the proxy just
|
||||
# forwards everything to the gateway, no prefix stripping.
|
||||
AnthropicMessagesFrontend(),
|
||||
# Phase 3 — re-exposes every declared `McpServer` outside the
|
||||
# gateway with bearer auth + audit log. Each namespace lives at
|
||||
# `/mcp/<name>/`; a flat bundle is published at `/mcp/all/`.
|
||||
# Discovery page (HTML, auth-gated) at `/mcp/` with copy-pastable
|
||||
# Cursor / Claude Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
|
||||
McpServerFrontend(),
|
||||
# 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. Served at `/admin/`; `/`
|
||||
# redirects there.
|
||||
AdminFrontend(),
|
||||
# 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.
|
||||
# Mounted at `/md` (`/md/chat`, `/md/chat/stream`).
|
||||
MarkdownFrontend(
|
||||
# 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,
|
||||
# ``chat_path`` (optional) overrides the default
|
||||
# ``{vault}/_logs/<agent>/<date>_<slug>.md`` layout. Heads up:
|
||||
# any custom path forces ``warm_index`` to scan the entire
|
||||
# vault on startup so the fingerprint→file map of archived
|
||||
# stateless chats survives a restart no matter where you put
|
||||
# files.
|
||||
chat_path=chat_path,
|
||||
),
|
||||
ApiFrontend(master_agent="assistant", branch_agent="assistant"),
|
||||
MarkdownFrontend(vault_path=VAULT / "chats", default_agent="assistant"),
|
||||
],
|
||||
jobs=[Job("heartbeat", heartbeat, cron="0 * * * *")],
|
||||
tz="UTC",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user