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:
hh
2026-09-02 00:33:04 +02:00
parent 90d0fb0f00
commit c9c2e94954
65 changed files with 795 additions and 1891 deletions
+13 -17
View File
@@ -1,11 +1,7 @@
"""Collapse an Anthropic event stream into one ``Message``.
Extracted from ``AnthropicMessagesFrontend`` so the markdown frontend
can run the same accumulation logic when it wants the finalized turn
rather than raw SSE chunks. Mirrors the Anthropic SDK's own accumulator:
walks events, builds block dicts indexed by their ``content_block``
index, folds text / thinking deltas in, buffers ``input_json_delta``
chunks until the block closes (then JSON-parses them once).
Mirrors the Anthropic SDK's own accumulator: folds content-block deltas
into finalized blocks, indexed by their ``content_block`` index.
"""
from __future__ import annotations
@@ -42,9 +38,7 @@ __all__ = ["StreamAccumulator", "accumulate"]
class StreamAccumulator:
"""Folds a stream of events into one ``Message``, incrementally.
Use when you need to *both* forward events somewhere (SSE) *and*
keep a finalized ``Message`` for post-stream work (audit, logging
to disk). Call :meth:`feed` for each event, :meth:`finalize` once.
Call :meth:`feed` for each event, then :meth:`finalize` once.
"""
__slots__ = (
@@ -67,10 +61,11 @@ class StreamAccumulator:
self._stop_sequence: str | None = None
def feed(self, ev: MessageStreamEvent) -> None:
# isinstance, not ``ev.type == "..."``: ty narrows on the
# discriminator only via the class, and the raw event union
# carries its own discriminators (``Raw*Event``) the SDK
# already promises.
"""Fold one stream event into the in-progress message state.
Uses ``isinstance`` rather than ``ev.type == ...`` so ty narrows
the event type from the class, not a string comparison.
"""
if isinstance(ev, RawMessageStartEvent):
self._message_id = ev.message.id
self._role = ev.message.role
@@ -109,6 +104,11 @@ class StreamAccumulator:
)
def finalize(self, *, model: str) -> Message:
"""Build the finalized ``Message`` from accumulated block state.
``role`` is always ``"assistant"`` at the wire level; the cast
avoids a runtime check ty would otherwise require.
"""
content: list[Any] = []
for idx in sorted(self._blocks):
bd = self._blocks[idx]
@@ -120,10 +120,6 @@ class StreamAccumulator:
elif btype == "thinking":
content.append(ThinkingBlock.model_validate(bd))
# ``role`` is always ``"assistant"`` at the wire level — we
# initialised the field to that and only overwrite from a
# ``RawMessageStartEvent`` which itself carries the same literal.
# The cast keeps both type-checkers happy without a runtime check.
return Message(
id=self._message_id or "msg_unknown",
type="message",
+2 -10
View File
@@ -1,15 +1,7 @@
"""Admin console: serves the ``ui/`` SPA and signs the operator in.
Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
with a bearer. This app, mounted at ``/admin``, owns three JSON routes
under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
``session``, which hands a signed-in browser the process-lifetime admin
bearer - and the static build under ``/admin/``.
The bearer is minted at startup, registered in the token store with
scope ``*`` and never persisted; a gateway restart rotates it, and the
SPA refetches ``session`` on a 401.
Login/logout/session live under ``/admin/auth``; a process-lifetime
admin bearer (scope ``*``) is minted at startup and rotates on restart.
"""
from __future__ import annotations
+4 -15
View File
@@ -1,17 +1,8 @@
"""``POST /v1/messages`` frontend.
Exposes the gateway as an Anthropic-compatible Messages endpoint, so any
client that already speaks Anthropic (Cursor, Cline, the official SDK,
``curl``) can hit a configured agent by passing its name as ``model``.
A Claude agent behind this endpoint is a ``deep`` conversation: the client
knows nothing about our ids, so the text fingerprint of the history it
sends is the ``(anthropic, fingerprint)`` binding of the conversation,
rebound after every turn to the fingerprint the next request will carry.
A history nobody has seen becomes a new conversation, materialized by the
home frontend of ``deep`` (the vault file), and every reply is published
on the bus so that file follows the chat. Other agents (Raycast) stay
stateless and are only archived through ``turn_log_handlers``.
client that already speaks Anthropic can reach a configured agent by
passing its name as ``model``.
"""
from __future__ import annotations
@@ -275,10 +266,8 @@ async def _sse(
) -> AsyncIterator[bytes]:
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
Each event becomes ``event: <type>\ndata: <json>\n\n`` - the shape
the Anthropic SDK's SSE decoder expects. Errors mid-stream are
swallowed into a synthetic ``error`` event so the client sees the
failure rather than a hung connection.
Wire format is ``event: <type>\ndata: <json>\n\n``; mid-stream errors
become a synthetic ``error`` event instead of a hung connection.
"""
acc = StreamAccumulator()
try:
+1 -1
View File
@@ -1,4 +1,4 @@
"""``ApiFrontend`` - the conversations API and event stream (§3.9)."""
"""``ApiFrontend`` - the conversations API and event stream."""
from beaver_gateway.frontends.api.frontend import ApiFrontend
+2 -13
View File
@@ -1,18 +1,7 @@
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits (§3.9).
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
Bearer scope ``api``; token and audit management need ``admin``. Every
write goes through ``core/conversations``; the frontend only shapes JSON.
``/api/events`` and ``/api/conversations/{id}/events`` replay the gateway
bus as SSE with the same keepalive the markdown frontend uses, so a proxy
never sees a silent socket.
Usage figures come from the ``usage`` table (one row per turn; API-price
``cost_usd`` and per-model ``model_usage`` are per-turn deltas of the SDK's
cumulative ``ResultMessage`` counters, see ``storage.append_usage``);
subscription quotas come from ``rate_limits``
(``RateLimitEvent``). The quota covers the whole subscription, so
``/api/limits`` puts the gateway's own spend for the window next to it
for calibration by eye.
write goes through conversations/service.py; the frontend only shapes JSON.
"""
from __future__ import annotations
+17 -61
View File
@@ -1,13 +1,7 @@
"""Frontend ABC + the runtime context handed to ``configure``.
"""Frontend ABC and the runtime context handed to ``configure``.
A frontend is anything that routes inbound traffic into the gateway: an
HTTP surface mounted under its ``path`` on the single gateway port
(``frontends/root.py``), a poller (Telegram), or both. ``GatewayRuntime``
carries everything a frontend may need that isn't user-config: built
registries, per-agent backends, and the in-memory token store. The
user's ``/config/config.py`` defines a ``Gateway`` (lists); ``cli.main``
turns that into a ``GatewayRuntime`` and hands it to each frontend's
``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
@@ -36,20 +30,8 @@ if TYPE_CHECKING:
class GatewayRuntime:
"""Post-build state of the gateway, shared with every frontend.
Backends are keyed by **agent name**, not type one ``RaycastBackend``
instance can serve many ``RaycastAgent`` instances, but the lookup
site (an inbound request with ``model=<agent.name>``) already has
the name in hand, so the indirection lives one step earlier.
``mcp_internal_urls`` is filled in Phase 2.1: one loopback URL per
declared ``McpServer`` so ``ClaudeSdkBackend``
can pass them to ``BackendOptions.mcp_servers`` without re-running
discovery.
``db`` (Phase 4.1) is the shared :class:`Database` handle. Phase 4.2
will switch ``TokenStore`` to read from it; Phase 4.3 admin/audit
write through it. Phase 4.1 only attaches it — existing frontends
ignore it.
Backends are keyed by agent name, not type: one backend instance may
serve several agents, so the indirection lives at lookup time.
"""
agents: AgentRegistry
@@ -58,62 +40,36 @@ class GatewayRuntime:
token_store: TokenStore
db: Database
mcp_internal_urls: Mapping[str, str] = field(default_factory=dict)
# Phase 4.3 — AdminFrontend reads creds + cookie-signing key from
# the runtime so the user's ``config.py`` doesn't have to know
# anything about env wiring. Defaulted to empty so existing tests /
# call sites that don't touch the admin path keep building; the
# admin frontend ``configure()`` itself rejects empty values.
admin_user: str = ""
"""Operator login for the admin console, checked by ``AdminFrontend``."""
admin_pass: str = ""
session_secret: str = ""
# The full sibling-frontends list, in declaration order. AdminFrontend
# uses it to advertise concrete bearer-endpoint URLs (host/port) on
# the dashboard so the operator can copy ready-to-use links / curl
# snippets. Other frontends ignore it.
frontends: Sequence[Frontend] = field(default_factory=tuple)
# Frontends that finish a turn (Anthropic Messages, Markdown) iterate
# this list and ``await`` each handler with a ``TurnRecord``. Handlers
# are appended during ``configure()`` by frontends that want a
# cross-frontend chat archive — currently the markdown frontend's
# ``log_all_chats`` mode. Handler exceptions are caught at the call
# site; they never block the user-visible response.
#
# The field is typed as ``list[Any]`` rather than the precise
# ``list[TurnLogHandler]`` because the alias lives under TYPE_CHECKING
# to keep ``anthropic.types`` out of the runtime import graph for
# this base module.
"""Every frontend in declaration order, for advertising their URLs."""
turn_log_handlers: list[TurnLogHandler] = field(default_factory=list)
# M1b: conversations service, event bus and the shared session pool.
# ``Any`` for the same import-graph reason as above; ``None`` only in
# tests that build a runtime without them.
"""Called with a ``TurnRecord`` after each turn; failures never reach the user."""
conversations: Any = None
bus: Any = None
pool: Any = None
scheduler: Any = None
# External origin the reverse proxy puts in front of the gateway
# (``Gateway.public_url``); ``None`` means "derive from the request".
public_url: str | None = None
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
class Frontend(ABC):
"""Routes inbound traffic into the gateway.
HTTP frontends set ``path`` and return their ASGI app from ``app()``;
``cli`` mounts every such app under that path on the one gateway
port, so ``/anthropic/v1/messages`` reaches the Anthropic frontend's
``/v1/messages``. ``serve()`` is for work outside HTTP - polling,
vault mirrors - and defaults to nothing. ``landing`` marks the app
that ``/`` redirects to (the admin console).
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.
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows);
``core/conversations`` refuses to bind a conversation to a frontend
outside its declaration. The first frontend in declaration order
whose ``materialize`` returns a binding is the *home* of that kind:
``spawn`` calls it so a new conversation gets a window (a vault file,
a Telegram topic). ``agent_for`` names the default agent for a kind
so callers may omit ``agent``. Stateless frontends (MCP, admin) keep
the defaults and stay outside the routing.
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 = ""
+6 -12
View File
@@ -1,8 +1,7 @@
"""Shared bearer-token verification for HTTP frontends.
Extracted from ``AnthropicMessagesFrontend`` so the markdown frontend
(and any future bearer-protected frontend) can reuse one canonical
verifier instead of copy-pasting the header-parsing dance.
Reused by every bearer-protected frontend instead of duplicating the
header-parsing logic.
"""
from __future__ import annotations
@@ -23,14 +22,11 @@ __all__ = ["require_token"]
async def require_token(
request: Request, runtime: GatewayRuntime, *, scope: str
) -> str:
"""Verify the request's bearer + scope, return the token's audit name.
"""Verify the request's bearer token and scope; return its audit name.
Accepts ``X-Api-Key: <token>`` (Anthropic SDK / LibreChat),
``Authorization: Bearer <token>`` (curl, Cursor) and, when neither
header is present, ``?token=<token>`` (Komodo alerters). 401 on missing /
unknown token; 403 on a known token whose scope doesn't cover
``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass every
scope check.
Checks ``X-Api-Key``, then ``Authorization: Bearer``, then ``?token=``.
401 on a missing/unknown token, 403 if the token's scope doesn't cover
``scope``. Bootstrap tokens carry ``"*"`` and pass every scope check.
"""
api_key = request.headers.get("x-api-key")
authorization = request.headers.get("authorization")
@@ -39,8 +35,6 @@ async def require_token(
elif authorization:
identity = await runtime.token_store.verify_bearer(authorization)
else:
# Webhook senders that cannot set headers (Komodo alerters) put the
# token in the query string; the URL is not logged with it.
qs_token = request.query_params.get("token")
identity = await runtime.token_store.verify(qs_token) if qs_token else None
if identity is None:
@@ -1,13 +1,7 @@
"""Markdown frontend — turn-by-turn chat archive backed by ``.md`` files.
"""Markdown frontend — chat archive backed by ``.md`` files in an Obsidian vault.
The user maintains chats as plain markdown files in an Obsidian vault.
A plugin in Obsidian POSTs ``{filename, content?}`` to ``/chat`` and the
frontend parses the file, finds the last turn, and runs the agent if
the last turn is ``user``. The full response is appended back to the
file as an ``### Assistant:`` turn. With ``log_all_chats=True`` the
frontend also subscribes to every other frontend's turns and writes
them into ``{vault_path}/{logged_subdir}/`` so the vault is the single
chronological archive of all conversations.
An Obsidian plugin POSTs ``{filename, content?}`` to ``/chat``; the
frontend runs the agent on a trailing user turn and appends the reply.
"""
from beaver_gateway.frontends.markdown.frontend import MarkdownFrontend
@@ -1,17 +1,7 @@
"""Cross-frontend chat logger.
When ``MarkdownFrontend(log_all_chats=True)`` is configured, every turn
completed by any other frontend (currently the Anthropic Messages
frontend) is mirrored into the vault as a ``.md`` file. Subsequent
turns of the same conversation append to the same file — matched by a
content-hash fingerprint stored in YAML frontmatter.
The fingerprint hashes the message history *before* the new assistant
reply. So the next request's input history (which now includes the
prior assistant reply) hashes to the value we just persisted —
``hash(prev_input + [assistant_reply])`` — and the lookup hits the
same file. New conversations (no prior fingerprint match) get a fresh
file under ``{vault_path}/{logged_subdir}/{agent_name}/``.
Mirrors turns completed by other frontends into the vault as ``.md``
files, matching a conversation's continuation by content-hash fingerprint.
"""
from __future__ import annotations
@@ -53,12 +43,8 @@ vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
"""Stable, short hex hash of a conversation prefix.
Built from ``(role, normalized_content)`` pairs only — so the
Markdown frontend's parser-shaped messages (text-only) and the
Anthropic frontend's raw ``messages`` payload (which may also be
string-only at v1) hash compatibly when they represent the same
conversation. Tool blocks / images would diverge, but those aren't
in the v1 ingest path.
Hashes ``(role, text-only content)`` pairs so differently-shaped message
histories that carry the same text still fingerprint identically.
"""
h = hashlib.sha1(usedforsecurity=False)
for msg in messages:
@@ -85,12 +71,8 @@ def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
class CrossFrontendLogger:
"""Maintains the fingerprint→file map and writes turns to disk.
The map is in-process; on startup ``warm_index`` rebuilds it from
YAML frontmatter of every file under ``logged_subdir``. A miss
creates a new file, a hit appends to the existing one. All disk
work funnels through one ``asyncio.Lock`` because the writes are
cheap and serializing them sidesteps a class of races we don't need
to think about.
The map is in-process, rebuilt by ``warm_index`` on startup; all disk
writes funnel through one lock to sidestep races.
"""
def __init__(
@@ -105,20 +87,13 @@ class CrossFrontendLogger:
self._index: dict[str, Path] = {}
self._lock = asyncio.Lock()
self._chat_path_fn = chat_path
# When the user supplies a custom path function, files can land
# anywhere in the vault — so we have to scan the whole vault on
# startup to rebuild the fingerprint→path map. With the default
# layout we can bound the scan to ``_logs/``.
self._scan_root = vault_path if chat_path is not None else self._root
def warm_index(self) -> None:
"""Scan logged files synchronously, populating the fingerprint map.
Called from ``MarkdownFrontend.configure`` so the map is ready
before any cross-frontend turn arrives. ``frontmatter.load``
reads only enough of the file to parse the YAML head, so the
scan is cheap even on large vaults — but a custom ``log_path``
forces a full-vault walk; mention that in the constructor doc.
Called before any cross-frontend turn arrives; a custom ``chat_path``
forces a full-vault walk instead of scanning just ``logged_subdir``.
"""
if not self._scan_root.exists():
return
@@ -140,29 +115,19 @@ class CrossFrontendLogger:
async def handle(self, record: TurnRecord) -> None:
"""Append or create a logged file for ``record``.
Records that the markdown frontend itself produced
(``source=="markdown"``) are skipped — those already live in the
user's hand-written file and shouldn't be duplicated into the
``_logs`` shadow tree.
Skips ``source == "markdown"`` records (already on disk); matches
the target file by fingerprinting ``input_messages`` sans the new turn.
"""
if record.source == "markdown":
return
async with self._lock:
# ``input_messages`` is the *full* history sent to the backend
# (last entry is the new user turn). Match against the prefix
# that excludes the new user turn — that's what the previous
# write stored as its fingerprint. Empty prefix is the
# well-known "brand new chat" sentinel.
prefix = record.input_messages[:-1]
prev_fp = fingerprint_messages(prefix) if prefix else None
target = self._index.get(prev_fp) if prev_fp else None
if target is None:
target = self._new_file_path(record)
# Build the full history including the assistant reply; the
# new fingerprint matches *that* prefix, so the next user
# turn (history grows by one user msg) will hit this file.
assistant_msg: MessageParam = {
"role": "assistant",
"content": _flatten_text(record.output_message),
@@ -174,9 +139,6 @@ class CrossFrontendLogger:
existing = target.read_text(encoding="utf-8")
parsed = frontmatter.loads(existing)
body = strip_trailing_user_scaffold(parsed.content)
# We append only the *new* user turn (the last one in
# input_messages, since prior turns are already on disk)
# plus the assistant reply.
new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user)
new_block = renderer.append_to_body(
@@ -185,7 +147,6 @@ class CrossFrontendLogger:
new_body = renderer.append_to_body(body, new_block)
metadata = dict(parsed.metadata)
else:
# Materialize the whole conversation from scratch.
new_body = _render_full_history(
record.input_messages, record.output_message
)
@@ -197,21 +158,15 @@ class CrossFrontendLogger:
metadata["fingerprint"] = new_fp
metadata["source"] = record.source
self._write(target, metadata, new_body)
# Maintain the index: drop the old fp (it's stale once we
# write the new turn), add the new one.
if prev_fp:
self._index.pop(prev_fp, None)
self._index[new_fp] = target
# ---- internals -----------------------------------------------------
def _new_file_path(self, record: TurnRecord) -> Path:
"""Pick a fresh filename for a brand-new conversation.
With a user-supplied ``chat_path`` we delegate to it (joining a
relative result with the vault root). Without one, we fall back
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
``.md`` suffix in case the user picks a non-md extension by hand.
Delegates to ``chat_path`` if set; otherwise
``{logged_subdir}/{agent}/{date}_{hex8}.md``.
"""
if self._chat_path_fn is not None:
result = self._chat_path_fn(
@@ -224,8 +179,6 @@ class CrossFrontendLogger:
result.parent.mkdir(parents=True, exist_ok=True)
return result
day = datetime.now(UTC).strftime("%Y-%m-%d")
# Short hex from the input hash so two same-day chats sort
# stably and don't collide.
salt = fingerprint_messages(record.input_messages)[:8]
agent_dir = self._root / record.agent_name
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -238,9 +191,6 @@ class CrossFrontendLogger:
text = frontmatter.dumps(post) + "\n"
else:
text = body if body.endswith("\n") else body + "\n"
# Sync write inside the lock — keeps the implementation tiny;
# individual logged turns are small enough that the blocking
# write doesn't matter at human conversation rates.
path.write_text(text, encoding="utf-8")
@@ -277,18 +227,13 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
def strip_trailing_user_scaffold(body: str) -> str:
"""Drop a trailing empty ``### User:`` block if present.
Cross-frontend turns aren't typed into the file by the human — they
arrive whole from another frontend. If we leave the previous run's
scaffold in place, we'd write the new user turn right after an
empty marker (visual noise, two ``### User:`` headers in a row).
Trim it and let the append flow add a fresh scaffold at the end.
Avoids leaving two ``### User:`` headers in a row when appending a
turn that wasn't typed into the file by hand.
"""
stripped = body.rstrip()
marker = "### User:"
if not stripped.endswith(marker):
return body
# Walk back: the scaffold is the marker preceded by either start-of-file
# or an HR/blank line. Find the last newline before the marker, cut.
head = stripped[: -len(marker)].rstrip()
if head.endswith("---"):
head = head[: -len("---")].rstrip()
+35 -131
View File
@@ -1,34 +1,7 @@
"""``MarkdownFrontend`` — chat-via-markdown-files frontend.
Wires:
* ``POST /chat {filename, content?, agent?}`` — bearer-authenticated
trigger. The plugin in Obsidian fires this after the user edits a
``.md`` and the file gets synced (or with ``content`` to short-circuit
the sync delay). We parse the file, check the last turn — assistant
→ no-op, user → run the agent and append.
* ``GET /healthz`` — liveness.
Concurrency model: an in-memory ``set[Path]`` of files currently in
flight. Two concurrent requests for the same file → the second gets
409. The set is single-process (one gateway instance) — that's by
design; the markdown frontend is the only writer in its vault from
the gateway side. The turn itself runs through ``core/conversations``
(one turn per conversation, ``running_turn`` in the DB), so a message
posted to the same conversation via ``/api`` waits its turn.
A chat file is a ``deep`` conversation bound as
``(markdown, <vault-relative path>)``; frontmatter carries only ``agent``
and ``conversation_id`` (§3.10), tool calls are never rendered. The
frontend is the home of ``deep``: ``materialize`` gives a conversation
spawned elsewhere its file, and :class:`.mirror.ChatMirror` keeps that
file in step with replies produced outside ``/chat``.
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
registers a handler on ``runtime.turn_log_handlers`` so every other
frontend's completed turns also land in the vault. The handler logic
lives in :mod:`.crossfront` so this module stays focused on the HTTP
shape.
``POST /chat`` (and SSE ``/chat/stream``) parses the vault file, runs
the agent on the trailing user turn, and appends the reply.
"""
from __future__ import annotations
@@ -85,23 +58,17 @@ _log = logging.getLogger("beaver_gateway.frontends.markdown")
__all__ = ["MarkdownFrontend"]
# How often we re-render the assistant turn into the .md file while the
# backend stream is still open. Trades responsiveness (faster updates to
# Obsidian sync / Raycast tailers) against write amplification. Each
# ``RawContentBlockStopEvent`` also forces a flush regardless of the
# timer, so block boundaries always land in the file.
_STREAM_FLUSH_DEBOUNCE = 0.4
# Debounce for the SSE ``/chat/stream`` path. Network IO is cheaper than
# atomic file rewrites, so we send updates more frequently — the client
# wants the lowest possible latency and we control the renderer on the
# other end (the Obsidian plugin splices deltas into the editor, no
# disk round-trip).
_SSE_FLUSH_DEBOUNCE = 0.1
class MarkdownFrontend(Frontend):
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files.
``_busy`` tracks in-flight files; check-and-add must stay atomic (no
``await`` between them) so concurrent requests reliably lose to 409.
"""
name = FRONTEND
kinds = ("deep",)
@@ -116,6 +83,11 @@ class MarkdownFrontend(Frontend):
logged_subdir: str = "_logs",
chat_path: Callable[[str, str, Path], Path] | None = None,
) -> None:
"""Configure the vault-backed frontend.
``chat_path``, if given, overrides where new chat files are created;
``logged_subdir`` holds cross-frontend logs when ``log_all_chats`` is set.
"""
self.vault_path = Path(vault_path).expanduser().resolve()
self.default_agent = default_agent
self.log_all_chats = log_all_chats
@@ -123,10 +95,6 @@ class MarkdownFrontend(Frontend):
self.chat_path = chat_path
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
# Files currently being processed by an in-flight ``POST /chat``.
# Checked-and-added atomically in the request handler (no
# ``await`` between the check and the insert) so a concurrent
# request reliably loses the race to 409.
self._busy: set[Path] = set()
self._crossfront: CrossFrontendLogger | None = None
self._mirror: ChatMirror | None = None
@@ -149,9 +117,6 @@ class MarkdownFrontend(Frontend):
logged_subdir=self.logged_subdir,
chat_path=self.chat_path,
)
# Scan the existing logged files synchronously here so the
# fingerprint→path map is populated before the first
# cross-frontend turn arrives. Cheap: frontmatter-only read.
self._crossfront.warm_index()
runtime.turn_log_handlers.append(self._crossfront.handle)
self._app = self._build_app(runtime)
@@ -175,16 +140,10 @@ class MarkdownFrontend(Frontend):
async def serve(self) -> None:
await self.mirror.run()
# ---- app builder ---------------------------------------------------
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
"""CORS is wide open here since auth is bearer-token, not cookie-based."""
app = FastAPI(title="beaver-gateway / Markdown")
# ``/chat/stream`` is consumed via ``fetch`` from the Obsidian
# plugin (``requestUrl`` can't read a body incrementally), and
# ``fetch`` is subject to CORS. Auth is bearer-token so we don't
# need credentialed mode; allow any origin and the standard
# methods/headers. The other endpoints are happy to ride along.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -226,8 +185,6 @@ class MarkdownFrontend(Frontend):
file_path = self._resolve_path(filename)
# Atomic check-and-claim: both ops run between awaits, so a
# second request can't slip into the same file slot.
if file_path in self._busy:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
@@ -248,12 +205,10 @@ class MarkdownFrontend(Frontend):
@app.post("/chat/stream")
async def chat_stream(request: Request) -> Any:
# Same contract as ``/chat`` (bearer auth, identical body),
# but the response is ``text/event-stream`` and intermediate
# rendered states are pushed as ``delta`` events. The
# gateway-side disk write only happens once, at end of turn,
# so streaming consumers (Obsidian plugin) and Obsidian Sync
# don't fight over the same file mid-stream.
"""SSE variant of ``/chat``: ``delta`` events, one disk write at the end.
The 409-conflict response is still plain JSON — the stream hasn't started.
"""
token_name = await require_token(request, runtime, scope="messages")
try:
body = await request.json()
@@ -276,8 +231,6 @@ class MarkdownFrontend(Frontend):
file_path = self._resolve_path(filename)
# 409 path stays JSON — the stream hasn't started yet, so
# the caller can read it the same way as on ``/chat``.
if file_path in self._busy:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
@@ -305,8 +258,6 @@ class MarkdownFrontend(Frontend):
return app
# ---- dispatch ------------------------------------------------------
async def _handle_chat(
self,
*,
@@ -317,6 +268,7 @@ class MarkdownFrontend(Frontend):
content_override: Any,
agent_override: str | None,
) -> Any:
"""Non-streaming ``/chat`` handler: parse, align, run, persist."""
write_disk = content_override is None
if isinstance(content_override, str):
file_text = content_override
@@ -340,8 +292,6 @@ class MarkdownFrontend(Frontend):
"or configure `default_agent`",
)
# When the parser produced no messages (file empty / only
# frontmatter), there's nothing to dispatch.
if not parsed.messages:
return {
"status": "nothing_to_do",
@@ -384,9 +334,6 @@ class MarkdownFrontend(Frontend):
msgs=len(parsed.messages),
)
# Resolve / mint the conversation row, align incoming against
# stored history, and feed the aligned messages to the backend
# - see ``core/conversation_store.py`` for the full rationale.
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime,
metadata=parsed.metadata,
@@ -437,9 +384,6 @@ class MarkdownFrontend(Frontend):
message=message,
)
# Broadcast our own turn so other handlers (none today, but the
# symmetry is worth keeping) see what happened. ``source`` marks
# the origin so ``CrossFrontendLogger`` can skip its own files.
record = TurnRecord(
agent_name=agent.name,
input_messages=list(parsed.messages),
@@ -460,9 +404,7 @@ class MarkdownFrontend(Frontend):
"new_content": new_content,
}
# ---- streaming dispatch (SSE) --------------------------------------
async def _handle_chat_streaming( # noqa: PLR0915 — mirrors _handle_chat, splitting only doubles read cost
async def _handle_chat_streaming( # noqa: PLR0915
self,
*,
runtime: GatewayRuntime,
@@ -474,21 +416,9 @@ class MarkdownFrontend(Frontend):
) -> AsyncIterator[bytes]:
"""SSE counterpart of :meth:`_handle_chat`.
Mirrors the same pipeline (resolve file → parse → resolve agent →
run backend → persist), but emits ``event: delta`` frames as the
rendered turn grows and a single terminal ``event: done`` /
``event: error``. Errors that ``_handle_chat`` would surface as
``HTTPException`` go out as ``error`` frames here (the HTTP
envelope is already 200 by the time the stream starts).
Intermediate disk writes are deliberately skipped — only the
post-stream :meth:`_write_assistant_reply` lands on disk, so the
gateway-side vault and the plugin-side editor are the only
writers in their respective halves of Obsidian Sync. Final
content is identical on both sides, so Sync no-ops.
Errors surface as ``error`` frames (HTTP is already 200 by then); disk
is only written once at the end, avoiding a write race with Obsidian Sync.
"""
# With ``content`` the plugin is the only writer of the file
# (§3.10): the gateway never touches disk in that case.
write_disk = content_override is None
if isinstance(content_override, str):
file_text = content_override
@@ -653,18 +583,12 @@ class MarkdownFrontend(Frontend):
or (now - last_flush) >= _SSE_FLUSH_DEBOUNCE
):
payload = snapshot()
# Skip duplicate snapshots — e.g. tool_use blocks
# render to the same prefix as before they closed
# (we don't surface the tool-call args in markdown).
if payload is not None and payload != last_payload:
yield sse_pack("delta", {"new_content": payload})
last_payload = payload
last_flush = now
except Exception as exc: # noqa: BLE001 — wire any backend failure as an SSE error frame
except Exception as exc: # noqa: BLE001
_log.exception("backend failed for %s", filename)
# Mirror the legacy path: write the last partial + an error
# callout to disk so other consumers (logs, file watchers)
# see what arrived. The client gets a clean SSE ``error``.
partial = acc.finalize(model=model)
new_body = parsed.body
if partial.content:
@@ -728,8 +652,6 @@ class MarkdownFrontend(Frontend):
},
)
# ---- helpers -------------------------------------------------------
async def _stream_to_file(
self,
*,
@@ -742,17 +664,8 @@ class MarkdownFrontend(Frontend):
) -> Any:
"""Drain ``events`` into a ``Message``, flushing partials to disk.
Flushes happen on each ``RawContentBlockStopEvent`` (natural
block boundary, content is markdown-consistent) and on the
``_STREAM_FLUSH_DEBOUNCE`` timer between events. The partial
write keeps the as-parsed frontmatter; the post-stream final
write in ``_write_assistant_reply`` is what stamps the refreshed
fingerprint / agent / conversation_id.
On backend exception we still flush the last partial and append
an error callout, so the human sees both what arrived and why it
stopped. The exception propagates so ``_handle_chat`` can map it
to a 500.
Flushes on each block boundary and on a debounce timer; on backend
failure it still flushes a partial + error callout, then re-raises.
"""
acc = StreamAccumulator()
@@ -829,11 +742,8 @@ class MarkdownFrontend(Frontend):
) -> tuple[Conversation, str, list[dict[str, Any]]]:
"""Resolve the ``deep`` conversation for this file + its stored messages.
Frontmatter ``conversation_id`` wins; a file that lost it is found
by its visible ``(markdown, path)`` binding, then by adopting the
one unbound pre-SDK conversation that starts with the same prompt;
otherwise a new conversation is created. The binding follows the
file: a moved chat re-binds to its new path on the next turn.
Precedence: frontmatter ``conversation_id`` > existing path binding >
an adopted unbound conversation with the same first prompt > a new one.
"""
conversations = runtime.conversations
rel = file_path.relative_to(self.vault_path).as_posix()
@@ -882,9 +792,8 @@ class MarkdownFrontend(Frontend):
) -> None:
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
Combines the matched/spliced prior state, the new user prompt,
and the synthesized assistant/tool cycle from the backend (or a
text-only fallback for backends that left ``capture`` empty).
Combines prior state + new user prompt + the backend's synthesized
cycle, falling back to text-only if ``capture`` is empty.
"""
new_user_msg = {"role": "user", "content": new_user_text}
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
@@ -906,12 +815,11 @@ class MarkdownFrontend(Frontend):
)
def _resolve_path(self, filename: str) -> Path:
"""Resolve ``filename`` under the vault; reject escapes."""
# ``filename`` may be relative or absolute; we always anchor
# under ``vault_path`` so absolute paths from outside the vault
# don't sneak through. ``Path("/foo/bar")`` combined with a
# vault path keeps the absolute side; we strip leading slashes
# to coerce the rooted form into a relative path before joining.
"""Resolve ``filename`` under the vault; reject escapes.
Leading slashes are stripped first — ``Path.__truediv__`` would
otherwise discard ``vault_path`` for an absolute ``filename``.
"""
rel = filename.lstrip("/")
if not rel.endswith(".md"):
rel = rel + ".md"
@@ -928,12 +836,8 @@ class MarkdownFrontend(Frontend):
def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
"""Build a single-assistant ``synthesized_messages`` list from a raw ``Message``.
For backends that don't populate a :class:`TurnCapture` (anthropic
HTTP, raycast, …) we don't have access to per-tool-cycle
granularity, so the assistant reply lands in the DB as one
canonical-block message. Tool memory across cache misses would
degrade in that case, but those backends don't have the cache-miss
re-seed problem to begin with — they manage history client-side.
Fallback for backends that don't populate :class:`TurnCapture` (anthropic
HTTP, raycast, …); the reply lands as one canonical-block message.
"""
content: list[dict[str, Any]] = []
for block in getattr(message, "content", ()):
+34 -180
View File
@@ -1,41 +1,7 @@
"""Stateful conversation history for the markdown frontend.
"""Stored conversation history for the markdown frontend.
The gateway used to be stateless about identity: claude-code-api's
in-memory session pool was keyed by a fingerprint of the messages the
gateway forwarded, and on a fingerprint miss the same fingerprint was
used to seed a fresh PTY's JSONL transcript. That worked as long as
the frontend could round-trip the *exact* content blocks the live
session had observed. The markdown frontend can't — the parser strips
``[!tool]-`` callouts because the human is allowed to edit the prose,
and the rendered tool callouts don't carry the canonical ``tool_use``
block fields anyway. So a continuation hit was *only* reliable for
turns that never used a tool; once tools entered the picture, every
subsequent turn missed the cache and reseeded from a tool-less
transcript, leading to "assistant doesn't remember the tool calls it
just made."
This module makes the gateway stateful for the markdown frontend (and
any other frontend that wants in). The DB stores the full
Anthropic-shape message list — text blocks, ``tool_use`` blocks,
``tool_result`` blocks, thinking signatures — exactly as
claude-code-api would have seen on the wire. Before each turn we
align the file the user is editing against the stored history:
* If the user just appended a new user turn at the bottom, we feed
the backend our stored-plus-new history and the fingerprint hits.
* If the user edited the *text* inside an assistant turn but left the
tool callouts alone, we splice the new text into the stored
``tool_use`` blocks and feed *that* — the fingerprint misses (text
differs), claude-code-api reseeds with a full transcript (tools and
all), the new live session has memory of the prior tool calls.
* If the user changed the *structure* (added/removed/reordered a tool
callout, edited an old user turn, etc.) we fork: take stored history
up to the divergence, take incoming text-only past the divergence.
The fingerprint misses; claude-code-api reseeds with a clean
truncated history; downstream turns continue from there.
"Divergence point" is found by walking the file's turns and the
stored display turns in lockstep. See :func:`diff_and_fork`.
Aligns the file the user is editing against the DB-stored Anthropic-shape
message history, splicing text edits or forking on structural changes.
"""
from __future__ import annotations
@@ -67,21 +33,12 @@ __all__ = [
]
# ---- types --------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ForkOutcome:
"""Result of aligning the incoming file against stored history.
``messages`` is what the gateway feeds to the backend (already
includes the new user prompt at the tail). ``persist_messages``
is the canonical conversation state the gateway should hold in
the DB *up to but not including* the new assistant reply — the
caller appends the synthesized turn from the backend onto this
and writes the result back. ``divergence_index`` is the
display-turn index at which incoming first disagreed with stored
(``None`` if everything matched; the new tail is appended cleanly).
``messages`` is the backend input; ``persist_messages`` is history to
store before the new reply; ``divergence_index`` is where they diverged.
"""
messages: list[MessageParam]
@@ -95,9 +52,6 @@ class ForkOutcome:
return self.divergence_index is None and not self.edited
# ---- public store API ---------------------------------------------------
async def load_conversation(
session: AsyncSession, *, frontend: str, external_id: str
) -> Conversation | None:
@@ -115,9 +69,7 @@ async def mint_conversation(
) -> Conversation:
"""Create a fresh conversation row with a new uuid for external_id.
Caller is responsible for persisting the returned ``external_id`` on
the frontend side (frontmatter, response header, …) so future
requests can find this conversation again.
Caller must persist the returned ``external_id`` so future requests can find it.
"""
row = Conversation(
frontend=frontend, external_id=str(uuid.uuid4()), agent_name=agent_name
@@ -144,9 +96,8 @@ async def load_messages(
) -> list[dict[str, Any]]:
"""Return stored messages ordered by ``seq`` ascending.
Each entry is a canonical Anthropic ``MessageParam`` dict — ``role``
plus ``content`` (string or list of block dicts). The same shape
we feed to the backend on continuation.
Each entry is a canonical Anthropic ``MessageParam`` dict, the same
shape fed to the backend on continuation.
"""
stmt = (
select(ConversationMessage)
@@ -164,10 +115,8 @@ async def load_messages(
def _sanitize_content(content: Any) -> Any:
"""Strip wire-illegal fields from stored Anthropic content blocks.
Older capture code emitted ``"is_error": null`` on ``tool_result``
blocks; the Anthropic API rejects null there (the field is optional
but, when present, must be boolean). We omit the key on read so
historical rows don't break continuation.
Drops ``tool_result.is_error: null`` — the API rejects null there
though the field is optional.
"""
if not isinstance(content, list):
return content
@@ -188,27 +137,17 @@ def _sanitize_content(content: Any) -> Any:
async def rewrite_messages(
session: AsyncSession, *, conversation_id: int, messages: list[dict[str, Any]]
) -> None:
"""Replace the conversation's stored messages with ``messages``.
"""Replace the conversation's stored messages (full overwrite, no branch history).
The user said no branch history — we overwrite on fork. Cheap at
our volume; if it ever matters we can switch to soft-delete +
branch pointers.
Deletes and flushes before inserting — SQLAlchemy's default INSERT-before-DELETE
flush order would otherwise collide with ``uq_msg_conv_seq``.
"""
# Bulk-delete and flush before inserting the new sequence: SQLAlchemy's
# unit-of-work flushes INSERTs before DELETEs by default, which would
# trip ``uq_msg_conv_seq`` when the new rows reuse the same seq numbers
# as the soon-to-be-deleted ones.
# SQLModel descriptors resolve to ColumnElement at runtime but to bare
# ``int`` in ty's stubs; the select-path at line 135 lives behind sqlmodel's
# own ``select`` overloads that hide it, but ``sqlalchemy.delete().where``
# uses the raw stubs.
await session.execute( # ty: ignore[deprecated]
delete(ConversationMessage).where(
ConversationMessage.conversation_id == conversation_id # ty: ignore[invalid-argument-type]
)
)
await session.flush()
# Insert the new sequence.
for seq, m in enumerate(messages):
session.add(
ConversationMessage(
@@ -218,7 +157,6 @@ async def rewrite_messages(
content_json=json.dumps(m["content"], separators=(",", ":")),
)
)
# Bump conversation.updated_at.
conv = await session.get(Conversation, conversation_id)
if conv is not None:
from datetime import UTC, datetime
@@ -228,21 +166,11 @@ async def rewrite_messages(
await session.commit()
# ---- alignment ----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class _StoredDisplayTurn:
"""A "display turn" reconstructed from stored raw messages.
``role`` is ``"user"`` (single user-prompt message) or
``"assistant"`` (one or more assistant messages, optionally
interleaved with user-only-tool_result messages). ``messages`` is
the slice of stored raw messages this display turn covers, in
order. ``spoken_text`` and ``skeleton`` are the
parser-equivalents for diff purposes; ``text_segment_count`` lets
us refuse a splice when the user edited across a tool boundary in
a way we can't safely undo.
Parser-equivalent view used to diff the file against the DB.
"""
role: str
@@ -255,10 +183,8 @@ class _StoredDisplayTurn:
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]:
"""Walk raw stored messages, group them into Obsidian-visible turns.
A user-prompt message (``role=user`` with string content, or list
content with no ``tool_result`` blocks) opens a user display turn.
Otherwise it's a tool-result follow-up and rolls into the current
assistant display turn.
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
tool-result-only messages roll into the current assistant turn.
"""
out: list[_StoredDisplayTurn] = []
i = 0
@@ -277,7 +203,6 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
)
i += 1
continue
# Assistant display turn: collect consecutive non-prompt messages.
group: list[dict[str, Any]] = []
while i < len(stored):
m = stored[i]
@@ -299,14 +224,16 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
def _is_user_prompt(content: Any) -> bool:
"""A user message is a *prompt* unless its content carries tool_result blocks."""
"""A user message is a *prompt* unless its content carries tool_result blocks.
Unknown content shapes are conservatively treated as a prompt.
"""
if isinstance(content, str):
return True
if isinstance(content, list):
return not any(
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
)
# Unknown shape — be conservative, treat as prompt.
return True
@@ -328,13 +255,9 @@ def _summarize_assistant_group(
) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Mirrors what ``parser.parse_assistant_structure`` would produce when
re-parsing the rendered version of this group: consecutive text
blocks across assistant messages collapse into one text segment;
tool_use blocks become skeleton entries; tool_result messages and
thinking blocks are invisible.
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible.
"""
# See ``diff_and_fork`` for why the parser-type imports are deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
segments: list[TextSegment | ToolSegment] = []
@@ -351,7 +274,6 @@ def _summarize_assistant_group(
for msg in group:
if msg["role"] == "user":
# tool_result message — boundary for text but emits no segment.
_flush()
continue
content = msg.get("content")
@@ -368,7 +290,6 @@ def _summarize_assistant_group(
elif btype == "tool_use":
_flush()
segments.append(ToolSegment(name=str(blk.get("name", ""))))
# thinking: skip silently
_flush()
spoken_chunks = [s.text for s in segments if isinstance(s, TextSegment)]
spoken = "\n\n".join(c for c in spoken_chunks if c).strip()
@@ -377,30 +298,14 @@ def _summarize_assistant_group(
return spoken, skeleton, text_count
# ---- the core algorithm -------------------------------------------------
def diff_and_fork(
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn]
) -> ForkOutcome:
"""Align the incoming parsed file against stored history.
``stored`` is the raw Anthropic-shape message list from the DB
(one entry per ``ConversationMessage`` row). ``incoming`` is the
user-visible turn list from the markdown parser. The last
``incoming`` entry must be a user turn — that's the new prompt
triggering this request.
Returns a :class:`ForkOutcome` whose ``messages`` is what the
backend should run on and whose ``persist_messages`` is the
canonical history to store in the DB once the backend's
synthesized cycle is appended.
``incoming`` must end with a user turn (the new prompt); raises otherwise.
Segment-class imports below are deferred to avoid a cycle with ``parser``.
"""
# ``parser`` lives under ``frontends/markdown/`` whose ``__init__``
# eagerly loads ``frontend.py``, which in turn imports this module
# — pulling the parser at module-import time creates a cycle. The
# helpers below import the segment classes lazily inside their own
# function bodies to break it.
if not incoming or incoming[-1].role != "user":
msg = (
"diff_and_fork expects incoming to end with a user turn "
@@ -417,16 +322,10 @@ def diff_and_fork(
if divergence is None and len(prior_incoming) < len(stored_groups):
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
# Not a deletion — the file simply never received turns we
# already ran. Adopt the stored tail verbatim so history stays
# structured and its fingerprint still matches the live
# session's. See ``_file_lags_store`` for why this matters.
spliced_groups.extend(
list(g.messages) for g in stored_groups[len(prior_incoming) :]
)
else:
# Incoming truncated stored (user deleted some past turns).
# Truncate stored to match.
divergence = len(prior_incoming)
backend_msgs, persist_msgs = _assemble_tail(
@@ -448,26 +347,9 @@ def _file_lags_store(
) -> bool:
"""Is the shorter incoming file a stale view rather than a deletion?
A file with fewer display turns than the DB has two possible causes,
and they need opposite handling:
* the user deleted trailing turns — we should truncate to match;
* the turn ran, was persisted, but its reply never made it back into
the ``.md`` (the render lost a race with the user's next prompt, or
the reply rendered to nothing visible). The file is simply behind.
The tell is the prompt the user is submitting right now: if the DB
already holds it at exactly the position the file stops at, this is a
re-submission of a turn we've already run, not a deletion. Nobody
deletes a turn and immediately retypes it verbatim.
Getting this wrong is expensive and self-sustaining. Forking here
flattens every post-divergence turn into plain text (losing tool_use /
tool_result structure), persists that flattened history, and changes
the conversation fingerprint — so the backend's session pool misses,
spawns a fresh ``claude``, reseeds it from a multi-MB JSONL, and
strands the previous process. The file still lags afterwards, so the
next turn does it again.
Tell: if the DB already holds the submitted prompt at the position the
file stops at, it's a lagging render, not a deletion — misjudging this
forks history, breaks the fingerprint, and respawns the backend session.
"""
if prior_len >= len(stored_groups):
return False
@@ -480,12 +362,8 @@ def _walk_prefix(
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
"""Walk incoming vs stored side-by-side until first divergence.
Returns the spliced/matched group list (one entry per matched
display turn, each carrying the raw messages we'll feed to the
backend for that turn), the divergence index (``None`` if all
of ``prior_incoming`` matched) and whether any assistant prose
was spliced in from the file - a rewritten reply keeps the
structure but must not resume the session that said otherwise.
An empty incoming skeleton (no tool callouts rendered) means prose
alone decides the match; a mismatch forces a fresh backend session.
"""
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
@@ -506,8 +384,6 @@ def _walk_prefix(
s.name for s in inc.structure if isinstance(s, ToolSegment)
)
inc_text_count = sum(1 for s in inc.structure if isinstance(s, TextSegment))
# Files rendered without tool callouts (§3.10) carry no skeleton:
# prose alone decides whether the turn matched.
if inc_skeleton and inc_skeleton != st.skeleton:
return spliced_groups, i, edited
if inc.text == st.spoken_text:
@@ -576,21 +452,10 @@ def _splice_in_place(
) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents.
Rebuilding a turn from the file loses everything the markdown never
carried — thinking blocks, and the message boundaries claude chose.
Both matter: an ``assistant[thinking] + assistant[text]`` pair (what
claude emits for a reasoning turn) collapses into a single message,
so the history is one message shorter than the one the backend
pooled its live session under, and the next turn misses the cache
and respawns. Substituting in place keeps the message count and the
invisible blocks exactly as stored.
Returns ``None`` when stored text blocks and incoming text segments
aren't one-to-one — consecutive text blocks merge into a single
rendered segment, so there'd be no way to know how to split the
edited prose back apart. The caller then rebuilds instead.
Preserves message/thinking-block boundaries that a rebuild would lose
(and that a cache-hit continuation depends on); returns ``None`` if
text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead.
"""
# See ``diff_and_fork`` for why the parser-type import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
new_texts = [
@@ -626,18 +491,9 @@ def _splice_by_rebuild(
) -> list[dict[str, Any]] | None:
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
Walks the incoming structure; for each ``TextSegment`` emits a
text block into the current assistant message; for each
``ToolSegment`` consumes the next stored ``tool_use`` block (by
position), closes the current assistant message, emits the
matching ``tool_result`` user message, and opens a new assistant
message. Final ``TextSegment`` closes the last assistant message.
Returns ``None`` if we can't find a matching tool_result for some
tool_use (stored history is malformed) — caller falls back to
fork.
Matches tool_use blocks to incoming ``ToolSegment``s by position; returns
``None`` (caller forks) if a matching stored ``tool_result`` is missing.
"""
# See ``diff_and_fork`` for why this import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
tool_uses, tool_results_by_id = _harvest_tool_blocks(stored_group)
@@ -664,8 +520,6 @@ def _splice_by_rebuild(
if current_asst:
spliced.append({"role": "assistant", "content": current_asst})
elif not spliced:
# Defensive: assistant turn with no text and no tools makes no
# sense; caller will treat as fork.
return None
return spliced
@@ -1,13 +1,8 @@
"""Vault files for ``deep`` conversations that were not typed into a file (§3.10).
"""Vault files for ``deep`` conversations that were not typed into a file.
``materialize`` is the markdown frontend's answer to ``spawn(kind=deep)``:
a new file in the vault with ``agent`` + ``conversation_id`` frontmatter
and the ``(markdown, path)`` binding. ``run`` tails the gateway bus and
appends every ``reply`` of a markdown-bound conversation to its file -
the seed turn of a spawn, a message posted through ``/api``, a turn
that came in over ``/v1/messages`` - and stamps the same exchange into
the canonical history, so a continuation typed in Obsidian aligns
against the store and resumes the same SDK session instead of reseeding.
``materialize`` creates the file for a conversation spawned elsewhere;
``run`` appends replies produced by other origins and keeps the canonical
history in sync so a continuation typed in Obsidian resumes the same session.
"""
from __future__ import annotations
+17 -100
View File
@@ -1,18 +1,7 @@
"""Parse a markdown chat file into Anthropic ``MessageParam`` history.
The file format is documented in ``frontends/markdown/__init__.py``:
``### User:`` / ``### Assistant:`` H3 headers split turns, optional
``---`` HRs between turns are visual-only, ``> [!thinking]-`` and
``> [!tool]- <name>`` callouts mark structured assistant content.
For backend consumption we strip thinking and tool_use callouts —
assistant turns become text-only. Rationale: history replay through
claude-code's JSONL injection only needs the *narrated* answer (the
thinking signatures expire and the original tool_results aren't
captured in the renderer's output, so a faithful tool_use round-trip
isn't possible today). The renderer keeps callouts in the file because
they're informational for the human reader; the parser drops them when
shaping the backend's input.
Turn markers are ``### User:`` / ``### Assistant:`` H3 headers; backend
input drops thinking/tool callouts, keeping assistant turns text-only.
"""
from __future__ import annotations
@@ -51,11 +40,7 @@ class TextSegment:
class ToolSegment:
"""A ``> [!tool]- <name>`` callout placeholder.
Only the tool ``name`` is captured the " · summary" suffix on the
callout title and the JSON body inside the quote block are
decorative for the human reader; the canonical tool_use block lives
in the DB and is keyed by *position+name* against the structure
parsed here.
Only the tool name is captured; the summary suffix is decorative.
"""
name: str
@@ -64,26 +49,11 @@ class ToolSegment:
AssistantSegment = TextSegment | ToolSegment
# Turn marker — must be exactly ``### User:`` or ``### Assistant:`` on
# its own line. Trailing whitespace tolerated; nothing after the colon
# on the same line (any inline content would mean the user typed
# something that just happens to look like a header, and we'd rather
# misparse than silently fold inline content into a turn).
_TURN_RE = re.compile(r"^###\s+(User|Assistant):\s*$", re.MULTILINE)
# Callout-start lines we strip from assistant turns when extracting
# text. We don't try to parse the contents — for backend input we just
# need to drop the whole quoted block.
_CALLOUT_START_RE = re.compile(r"^>\s+\[!(thinking|tool)\]")
# Tool-callout title line: ``> [!tool]- <name>`` or ``> [!tool]- <name> · <summary>``.
# We only need the ``<name>`` part for skeleton matching; the summary is
# decorative (built by ``renderer.summarize_tool_input`` from inputs the
# user can edit visually without semantic consequence).
_TOOL_TITLE_RE = re.compile(r"^>\s+\[!tool\]-\s*(.*?)\s*$")
# Renderer joins name + summary with " · " (U+00B7) — see
# ``renderer.summarize_tool_input``. We split on it to recover the
# bare tool name.
_TOOL_TITLE_SEP = " · "
@@ -91,14 +61,8 @@ _TOOL_TITLE_SEP = " · "
class ParsedTurn:
"""One turn extracted from the chat file.
``role`` is ``"user"`` or ``"assistant"``. ``text`` is the spoken
content with callouts stripped and HRs dropped — used both as the
backend's ``MessageParam.content`` (back-compat with the existing
parser shape) and as the diff key against stored turns.
``structure`` is non-empty only for assistant turns: an ordered
list of ``TextSegment`` / ``ToolSegment`` reflecting the visible
layout of the assistant block, used by the conversation store to
align with the canonical tool_use blocks held in DB.
``text`` is spoken content only; ``structure`` (assistant turns only)
carries the ordered text/tool segments used to align with stored history.
"""
role: str
@@ -110,16 +74,8 @@ class ParsedTurn:
class ParsedFile:
"""Result of parsing a single chat ``.md``.
``metadata`` is the YAML frontmatter as a plain dict (empty if the
file has none). ``messages`` is the conversation history shaped for
``Backend.complete`` — assistant turns are text-only. ``turns`` is
1:1 with ``messages`` and carries the per-turn structure (for
assistant turns) that the conversation store needs to detect
text-only edits vs. structural forks. ``body`` is the raw markdown
content *after* the frontmatter is stripped; the renderer needs it
when it appends a new assistant turn so it can preserve whatever
the human typed verbatim (including any callouts or HRs they
added).
``messages`` is text-only history for the backend; ``turns`` carries
per-turn structure; ``body`` is the raw content after frontmatter.
"""
metadata: dict[str, Any]
@@ -131,15 +87,9 @@ class ParsedFile:
def parse(text: str) -> ParsedFile:
"""Parse a chat ``.md`` into ``(metadata, body, messages, turns)``.
A file with no turn markers but non-empty body is treated as a
single user turn — the friendly path for "user types into a new
file and hits send" before any turn markers exist.
Assistant turns that have *only* tool callouts (no spoken text) are
preserved here even though their ``MessageParam.content`` is empty
— the structure carries tool-segment information the conversation
store needs for skeleton matching. The renderer in practice always
emits at least a trailing text block, so this branch is defensive.
A bare file (no turn markers) is treated as a single user turn; a
tool-only assistant turn gets placeholder ``" "`` content since the
backend rejects an empty string.
"""
parsed = frontmatter.loads(text)
metadata = dict(parsed.metadata)
@@ -175,14 +125,6 @@ def parse(text: str) -> ParsedFile:
)
)
elif has_tools:
# Tool-only assistant turn: nothing to feed the backend
# as ``content`` (it'd reject an empty string), but the
# structure must survive so the store can align it
# against stored tool_use blocks. We synthesize a
# single-space text content for backend round-trip; the
# conversation store will replace this payload with the
# canonical stored blocks before the backend ever sees
# it on a continuation.
messages.append({"role": "assistant", "content": " "})
parsed_turns.append(
ParsedTurn(role="assistant", text="", structure=tuple(structure))
@@ -196,18 +138,8 @@ def parse(text: str) -> ParsedFile:
def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
"""Walk an assistant turn body, return its ordered text/tool segments.
Tool callouts become :class:`ToolSegment` with just the tool name —
the title's optional ``" · summary"`` suffix and the JSON body
inside the quote block are decorative; the canonical tool_use
block is held in the conversation store. Thinking callouts are
stripped entirely (they were never round-trippable through the
file — signatures expire). HR separator lines drop out.
Empty / whitespace-only text segments at the boundaries (start,
end, between adjacent tool callouts) are dropped so the skeleton
is robust against renderer whitespace choices; a non-empty text
segment with surrounding whitespace is trimmed on both ends but
preserved.
Tool callouts become :class:`ToolSegment` (name only); thinking callouts
and HR lines are stripped; boundary-empty text segments are dropped.
"""
segments: list[TextSegment | ToolSegment] = []
pending_text: list[str] = []
@@ -216,9 +148,6 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
if not pending_text:
return
joined = "\n".join(pending_text)
# Collapse runs of >2 blank lines (created when we stripped a
# mid-block callout) into one so the diff against a re-render
# is stable.
cleaned = re.sub(r"\n{3,}", "\n\n", joined).strip()
pending_text.clear()
if cleaned:
@@ -231,7 +160,6 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
callout_match = _CALLOUT_START_RE.match(line)
if callout_match:
kind = callout_match.group(1)
# Capture tool name *before* advancing past the block.
if kind == "tool":
title_match = _TOOL_TITLE_RE.match(line)
title = title_match.group(1) if title_match else ""
@@ -239,9 +167,7 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
_flush_text()
segments.append(ToolSegment(name=name))
else:
# Thinking callout — drop the whole block, emit nothing.
_flush_text()
# Skip the rest of the quote block.
while i < len(lines) and lines[i].lstrip().startswith(">"):
i += 1
continue
@@ -257,11 +183,7 @@ def parse_assistant_structure(raw: str) -> list[TextSegment | ToolSegment]:
def _segments_to_spoken_text(segments: list[TextSegment | ToolSegment]) -> str:
r"""Reduce a structure list to the spoken-text view the backend sees.
Concatenates :class:`TextSegment` contents with ``\n\n`` between
them, dropping :class:`ToolSegment` entries. Equivalent to what
the pre-Conversation-store parser did — we keep that behavior so
existing fingerprints (frontmatter ``fingerprint`` field) stay
valid.
Concatenates text segments with ``\n\n``, dropping tool segments.
"""
chunks = [s.text for s in segments if isinstance(s, TextSegment)]
return "\n\n".join(c for c in chunks if c).strip()
@@ -290,15 +212,11 @@ def resolve_agent(
return default
# ---- internals ---------------------------------------------------------
def _split_turns(body: str) -> list[tuple[str, str]]:
"""Walk turn markers, return ``[(role_lc, raw_body), ...]``.
Body for each turn is everything between this marker and the next
(or EOF). Leading marker line itself is dropped. We don't trim
whitespace here — that's per-role.
A marker is an exact ``### User:`` / ``### Assistant:`` line; trailing
content after the colon means it's not a marker, not a turn boundary.
"""
matches = list(_TURN_RE.finditer(body))
if not matches:
@@ -315,9 +233,8 @@ def _split_turns(body: str) -> list[tuple[str, str]]:
def _strip_hrs(raw: str) -> str:
"""Drop decorative ``---`` separator lines (whole-line HRs only).
A ``---`` mid-paragraph (rare, but possible) stays. Only lines that
are *exactly* the HR after optional surrounding whitespace are
removed — those are the ones the renderer emits between turns.
Only lines that are exactly ``---`` (with optional surrounding
whitespace) are removed; a ``---`` mid-paragraph stays.
"""
lines = raw.splitlines()
kept = [ln for ln in lines if ln.strip() != "---"]
@@ -1,10 +1,7 @@
"""Render Anthropic ``Message`` (and individual user turns) into markdown.
"""Render Anthropic ``Message`` (and user turns) into markdown.
The renderer is one-way: it produces the human-facing artifact in the
vault. The parser strips tool/thinking callouts when reshaping the file
for backend replay — so what we write here is purely for the human
reader (and for the cross-frontend logger, which materializes turns
from other frontends).
One-way: produces the human-facing artifact only. The parser strips
tool/thinking callouts separately when reshaping history for backend replay.
"""
from __future__ import annotations
@@ -30,23 +27,11 @@ __all__ = [
]
# Empty ``### User:`` block appended after each assistant reply so the
# human has an obvious place to type the next turn. Parser drops empty
# user blocks, so this doesn't re-trigger dispatch on its own.
# Blank line after the header, like every rendered turn - the file stays
# symmetric whether the human or the gateway wrote the marker.
USER_SCAFFOLD = "### User:\n\n"
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
# don't collide. JSON inputs use 3 backticks because they almost never
# contain ``` and we get language syntax highlighting in Obsidian for free.
FENCE = "````"
# Input keys we dangle after the tool name in the callout title, best
# first — purely cosmetic. ``description`` wins because when a tool
# offers one it's a human-written summary of the call, which beats a
# truncated shell command or path.
_TITLE_KEYS = ("description", "path", "file", "filename", "url", "command", "query")
@@ -61,24 +46,15 @@ def render_assistant_text(text: str) -> str:
def render_assistant_message(message: Message) -> str:
"""Render an assistant ``Message`` (with content blocks) into a turn block.
"""Render an assistant ``Message`` into a turn block.
Blocks render in their original order:
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
* ``TextBlock`` → plain text (the spoken answer)
* ``ToolUseBlock`` → nothing (§3.10: tool calls never reach the file;
"what the agent is doing" is the activity panel fed by SSE)
Blank lines separate adjacent blocks; trailing newline guarantees
the next ``---`` / ``### User:`` marker lands on its own line.
Tool-use blocks render to nothing; only text and thinking content reach
the file (see :mod:`.parser` for how the reverse strip works).
"""
parts: list[str] = ["### Assistant:", ""]
for block in message.content:
lines = list(_render_block(block))
if not lines:
# Tool calls render to nothing - no separator for them either,
# or every tool leaves a blank line behind.
continue
parts.extend(lines)
parts.append("")
@@ -88,19 +64,12 @@ def render_assistant_message(message: Message) -> str:
def render_user_param(param: MessageParam) -> str:
"""Render a ``MessageParam`` user message into a ``### User:`` block.
Used by the cross-frontend logger when materializing turns from
other frontends. Tool_result blocks in the content list are dropped
silently — the markdown view doesn't track them (see ``parser.py``).
Tool_result blocks in the content list are dropped silently.
"""
content = param.get("content", "")
if isinstance(content, str):
text = content
else:
# The Anthropic SDK types ``content`` as a union of typed-dict
# *Param classes plus pydantic block models — both shapes appear
# in practice (raw incoming JSON yields dicts, SDK-built params
# yield BaseModels). Treat each entry as a dict-like and pull
# ``text`` opportunistically.
chunks = [
str(blk.get("text", ""))
for blk in content
@@ -111,11 +80,9 @@ def render_user_param(param: MessageParam) -> str:
def append_to_body(existing: str, new_block: str) -> str:
"""Append ``new_block`` to ``existing`` with a decorative HR separator.
"""Append ``new_block`` to ``existing`` with a decorative ``---`` separator.
Preserves the original body verbatim (whitespace, callouts, any
formatting the human added). The HR is purely visual: parser ignores
it.
The separator is visual only; the parser ignores it.
"""
head = existing.rstrip()
if head:
@@ -124,18 +91,9 @@ def append_to_body(existing: str, new_block: str) -> str:
def summarize_tool_input(name: str, tool_input: object) -> str:
"""Build the ``[!tool]- <summary>`` title string.
Tries to pick a single salient field (``description``, ``path``,
``command``, etc.) from the input dict so the collapsed callout shows
something meaningful in Obsidian. Falls back to just the tool name.
"""
"""Build the ``[!tool]- <summary>`` title, picking one salient input field."""
if not isinstance(tool_input, dict):
return name
# Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the
# SDK's runtime value is always a JSON dict (str→Any), so a local
# cast keeps the rest of the function readable without sprinkling
# per-line type narrowing on every ``.get`` call.
d = cast("dict[str, Any]", tool_input)
for key in _TITLE_KEYS:
value = d.get(key)
@@ -145,9 +103,6 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
return name
# ---- internals ---------------------------------------------------------
def _render_block(block: object) -> Iterable[str]:
if isinstance(block, TextBlock):
text = (block.text or "").strip()
@@ -157,7 +112,6 @@ def _render_block(block: object) -> Iterable[str]:
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
# Tool-use blocks and unknown block types never reach the file.
def _render_thinking(text: str) -> Iterable[str]:
@@ -167,12 +121,7 @@ def _render_thinking(text: str) -> Iterable[str]:
def adaptive_fence(content: str) -> str:
"""Return a backtick fence at least one longer than the longest run in ``content``.
Currently unused (tool *results* aren't persisted yet) — kept here
so when result capture lands the rendering side already has the
primitive.
"""
"""Return a backtick fence longer than any backtick run in ``content``."""
longest = 0
for match in re.finditer(r"`+", content):
longest = max(longest, len(match.group(0)))
+31 -83
View File
@@ -1,36 +1,7 @@
"""External MCP frontend (Phase 3.1).
"""External MCP frontend.
A streamable-HTTP gateway in front of the internal MCP aggregator
(``beaver_gateway.mcp.internal_app``). The aggregator hosts every
declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
under ``/mcp/<name>`` plus a flat ``/mcp/all`` bundle on
``127.0.0.1:INTERNAL_MCP_PORT`` — that's the *internal* shape.
This frontend re-exposes those namespaces on its own port directly at
``/<name>/`` (no ``/mcp/`` prefix in the external routes — the port
itself already disambiguates). Caddy / nginx / Cloudflare in front
typically strips a prefix back on: ``domain.com/mcp/* → :8001/*``,
controlled by the operator's reverse-proxy config and surfaced to the
admin dashboard via ``public_base_url``. Three additions on top of the
raw aggregator:
* **Bearer auth** — ``Authorization: Bearer <token>``, ``X-Api-Key``,
or ``?token=<…>`` query string. All three forms verify against the
same :class:`TokenStore` as ``AnthropicMessagesFrontend``.
* **Audit log** — one line per request (token name, namespace,
request method/path, response status). The DB-backed audit log lives
in Phase 4; for now we just emit a structured log line.
* **Discovery page** at ``GET /`` (auth-gated) — HTML rendered with a
tiny inline Jinja2 template listing every namespace plus copy-pastable
config snippets for Cursor / claude.ai / Claude Desktop.
Why a reverse proxy and not a second mount? FastMCP's session managers
are tied to the lifespan they were created in; running the same
aggregator under two uvicorn servers double-initializes state. Building
two parallel aggregators would double upstream connections (two
subprocesses for every stdio MCP, two HTTP clients for every remote).
A loopback proxy keeps one source of truth — the internal aggregator —
and lets us layer policy on the outside.
Streamable-HTTP gateway that reverse-proxies the internal MCP aggregator,
adding bearer auth, an audit log line per request, and a discovery page.
"""
from __future__ import annotations
@@ -61,10 +32,6 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.frontends.mcp_server")
# Hop-by-hop headers that must NOT be forwarded across an HTTP proxy
# (RFC 7230 §6.1). Bypassing this filter would break chunked transfer
# encoding when ``Content-Length`` arrives, or upstream-aware proxies
# would refuse the second hop's connection-pool reuse.
_HOP_BY_HOP_HEADERS = frozenset(
{
"connection",
@@ -80,11 +47,6 @@ _HOP_BY_HOP_HEADERS = frozenset(
}
)
# Standard auth-bearing headers we *do not* forward to the internal app —
# the internal app is on loopback with no auth of its own, and forwarding
# the inbound bearer would only confuse it. Each method-specific MCP
# request from the upstream Cursor/etc. carries a fresh ``mcp-session-id``
# that we *must* forward.
_AUTH_HEADERS = frozenset({"authorization", "x-api-key"})
@@ -119,17 +81,16 @@ class McpServerFrontend(Frontend):
self._http = None
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
"""Build the Starlette app.
Literal routes (``/``, ``/healthz``) are listed before the
namespace wildcard so they win the match instead of being
swallowed by it; two routes per namespace cover both ``/x`` and
``/x/y`` since Starlette won't fold them into one.
"""
routes = [
Route("/", self._discovery, methods=["GET"]),
Route("/healthz", self._healthz, methods=["GET"]),
# Namespaces mount at the root of this port — the port
# itself already disambiguates this from any other gateway
# surface. Two routes per namespace so both the
# trailing-slash and sub-path forms work (``/time`` AND
# ``/time/foo``); Starlette doesn't fold them into one
# route automatically. The literal routes above (``/``,
# ``/healthz``) are listed first and win the match, so
# they're not eaten by ``/{namespace}``.
Route(
"/{namespace}",
self._proxy_endpoint,
@@ -151,7 +112,7 @@ class McpServerFrontend(Frontend):
token_name, err = await _verify_request(request, runtime)
if err is not None:
return err
assert token_name is not None # noqa: S101 — narrow for ty
assert token_name is not None # noqa: S101
base = external_base(request, runtime)
html = _render_discovery_page(
base_url=base, namespaces=list(runtime.mcps), actor=token_name
@@ -165,7 +126,7 @@ class McpServerFrontend(Frontend):
token_name, err = await _verify_request(request, runtime)
if err is not None:
return err
assert token_name is not None # noqa: S101 — narrow for ty
assert token_name is not None # noqa: S101
namespace = request.path_params["namespace"]
subpath = request.path_params.get("path", "")
@@ -201,11 +162,12 @@ class McpServerFrontend(Frontend):
)
def _upstream_url(self, namespace: str, subpath: str) -> str | None:
"""Resolve ``namespace`` to its internal loopback URL.
``ALL_NAMESPACE`` isn't in the URL map, so its URL is synthesized
from any per-domain entry's authority.
"""
runtime = self._require_runtime()
# ``all`` is built by the aggregator unconditionally when at least
# one MCP is configured; the URL map only contains per-domain
# entries (see ``build_internal_app``), so we synthesize ``all``'s
# loopback URL from any per-domain URL's authority.
if namespace == ALL_NAMESPACE:
sample = next(iter(runtime.mcp_internal_urls.values()), None)
if sample is None:
@@ -232,13 +194,8 @@ async def _verify_request(
) -> tuple[str | None, JSONResponse | None]:
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
The third form is the escape hatch for clients that can only put
secrets in the URL (claude.ai's MCP config historically did this).
All three roads end at the same :class:`TokenStore`. Returns
``(actor_name, None)`` on success, ``(None, 401|403)`` otherwise
— the caller forwards the response as-is. Splitting auth vs scope
failures matters: 401 says "send me a token", 403 says "this token
is real but not for this endpoint".
Returns ``(actor_name, None)`` on success, else ``(None, error_response)``
— 401 for a missing/unknown token, 403 for one whose scope doesn't cover this call.
"""
api_key = request.headers.get("x-api-key")
if api_key:
@@ -276,9 +233,7 @@ def _forbidden(scope: str, required: str) -> JSONResponse:
def _join_subpath(base_url: str, subpath: str) -> str:
"""Concatenate the loopback URL with the proxied sub-path.
``base_url`` always ends in ``/`` (the aggregator publishes URLs
that way to avoid Starlette's 307 redirect dance); the sub-path is
appended verbatim, with the query string handled by the caller.
``base_url`` always ends in ``/`` to avoid Starlette's redirect dance.
"""
if subpath:
return base_url + subpath.lstrip("/")
@@ -294,18 +249,14 @@ async def _reverse_proxy(
actor: str,
runtime: GatewayRuntime,
) -> StreamingResponse | JSONResponse:
"""Bidirectionally stream an MCP request between client internal aggregator.
"""Bidirectionally stream an MCP request between client and internal aggregator.
Streamable-HTTP MCP responses can be a long-running SSE stream
(tools that emit partial progress) or a one-shot JSON body; we
don't peek — just relay chunks as they arrive in either direction
until both sides close.
The audit row is written right after the upstream response headers
arrive, before relaying its body, so a client-truncated stream is
still audited.
"""
qs = request.url.query
if qs:
# Drop ``?token=`` from the forwarded URL — internal app doesn't
# need it, and propagating creds further than necessary widens
# the leak surface (logs, metrics, traces all see query strings).
scrubbed = _scrub_query(qs, drop={"token"})
if scrubbed:
upstream_url = f"{upstream_url}?{scrubbed}"
@@ -351,9 +302,6 @@ async def _reverse_proxy(
request.url.path,
upstream_resp.status,
)
# Audit at upstream-response time: status reflects the MCP call's
# outcome (200 / tool-error / 4xx). Streaming relay below may be
# cut short by the client, but the row is already in by then.
await audit.log(
runtime,
actor=f"token:{actor}",
@@ -370,7 +318,6 @@ async def _reverse_proxy(
async for chunk in upstream_resp.content.iter_any():
yield chunk
except (aiohttp.ClientError, asyncio.CancelledError):
# Caller hung up or upstream dropped — just stop relaying.
return
finally:
upstream_resp.release()
@@ -390,6 +337,11 @@ async def _request_body_iter(request: Request) -> AsyncIterator[bytes]:
def _forward_headers(request: Request) -> dict[str, str]:
"""Drop hop-by-hop headers (RFC 7230 §6.1) and inbound auth headers.
The internal aggregator is loopback-only with no auth of its own,
so forwarding the caller's bearer would only confuse it.
"""
out: dict[str, str] = {}
for key, value in request.headers.items():
lowered = key.lower()
@@ -422,11 +374,7 @@ def _scrub_query(query: str, *, drop: frozenset[str] | set[str]) -> str:
def _render_discovery_page(*, base_url: str, namespaces: list[Any], actor: str) -> str:
"""Render the auth-gated namespace + config-snippet page.
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
plumbing that Phase 4's AdminFrontend will own.
"""
"""Render the auth-gated namespace + config-snippet page."""
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
rows = (
"\n".join(
@@ -460,7 +408,7 @@ def _escape(value: str) -> str:
)
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-string-blob
_DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002
[
"<!doctype html>",
'<html lang="en">',
+1 -1
View File
@@ -46,7 +46,7 @@ def build_root_app(
]
for fe in mounted:
app = fe.app()
assert app is not None # noqa: S101 - filtered above; narrows for ty
assert app is not None # noqa: S101
assert fe.path is not None # noqa: S101
routes.append(Mount(fe.path, app=app, name=fe.name or fe.path.strip("/")))
for path, app in (extra or {}).items():
+2 -4
View File
@@ -29,10 +29,8 @@ async def events_with_heartbeat(
) -> AsyncIterator[Any]:
"""Pass ``events`` through, yielding ``None`` after ``interval`` seconds of silence.
One in-flight ``__anext__`` task is reused across timeouts: a second
consumer on the same async generator raises ``RuntimeError``.
Cancellation of the outer scope cancels that task instead of leaving
it dangling.
Only one consumer may iterate the result at a time; cancelling the
outer scope cancels the in-flight upstream fetch instead of leaking it.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
@@ -1,13 +1,8 @@
"""One ``sendMessageDraft`` stream per running turn (§3.8).
"""One ``sendMessageDraft`` stream per running turn.
The client folds a draft into the message that follows only when their
texts are identical - so the last push is the final text itself, rendered
exactly as the outbox will send it, with no status line.
A draft is ephemeral and lives 30 s, Telegram throttles edits to about one
per second per chat, and thinking or a tool call would otherwise look like a
hang - so the draft opens with a status line straight away, is refreshed on
a timer rather than on every delta, and is kept alive while nothing changes.
Telegram folds a draft into the following message only when their texts are
identical, so the last push is the final text verbatim. Drafts live 30s and
throttle to about one edit/s, so this refreshes on a timer, not every delta.
"""
from __future__ import annotations
@@ -1,13 +1,8 @@
"""``TelegramFrontend`` - the private chat with the bot as the window (§3.8).
"""``TelegramFrontend`` - the private chat with the bot as the window.
A private chat with topics has no General: the gateway makes one topic for
the master (``master_topic``) and rebinds it to every new master; any other
topic is a branch. The user makes a topic and the
first message in it spawns the branch (``seed=morning``); a message into a
topic whose branch is merged or closed spawns a new branch on the same
topic. Replies stream as drafts and land through the outbox; turns that
came from other windows are mirrored with a marker; ``origin=system`` is
never shown. ``AskUserQuestion`` becomes inline buttons (§3.7).
A private chat with topics has no General, so the gateway makes and rebinds
one topic for the master; any other topic is a branch, and a message into a
new one spawns it. Replies stream as drafts and land through the outbox.
"""
from __future__ import annotations
@@ -62,13 +57,10 @@ _COMMANDS = ("merge", "new", "chat", "status", "help", "start")
@dataclass(frozen=True, slots=True)
class Attachments:
"""Where files from Telegram go.
"""Where files land.
Files land in ``<root>/YYYY-MM-DD/<unixts>-<name>`` (the day in ``tz``)
and whatever is older than ``keep_days`` is swept; ``None`` never
sweeps. ``ephemeral`` - under the gateway's data dir; ``vault`` -
``dir`` is an inbox inside the agent's zone: the agent moves keepers
next to the note, the rest is swept after ``keep_days``.
``ephemeral`` uses the gateway's data dir, ``vault`` an inbox under
``dir``; ``keep_days`` sweeps older files, ``None`` never sweeps.
"""
mode: Literal["ephemeral", "vault"] = "ephemeral"
@@ -148,8 +140,6 @@ class TelegramFrontend(Frontend):
self._reactions: dict[int, tuple[int, int]] = {}
self._tasks: set[asyncio.Task[None]] = set()
# ---- Frontend --------------------------------------------------------
def agent_for(self, kind: Kind) -> str | None:
return {"master": self.master_agent, "branch": self.branch_agent}.get(kind)
@@ -223,8 +213,6 @@ class TelegramFrontend(Frontend):
self._topic_names[target[1]] = f"{prefix}{name}"
return True
# ---- plumbing --------------------------------------------------------
@property
def bot(self) -> Bot:
if self._bot is None:
@@ -355,8 +343,6 @@ class TelegramFrontend(Frontend):
binding=(FRONTEND, self._ext(thread_id)),
)
# ---- inbox -----------------------------------------------------------
async def _handle(self, update: Update) -> None:
if update.message is not None:
await self._on_message(update.message)
@@ -556,8 +542,6 @@ class TelegramFrontend(Frontend):
if folder.is_dir() and not any(folder.iterdir()):
folder.rmdir()
# ---- commands --------------------------------------------------------
async def _command(
self, command: str, args: str, message: Message, thread_id: int | None
) -> None:
@@ -663,8 +647,6 @@ class TelegramFrontend(Frontend):
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
# ---- bus -------------------------------------------------------------
async def _events(self) -> None:
async for event in self.bus.stream():
try:
@@ -790,8 +772,6 @@ class TelegramFrontend(Frontend):
)
await self._deliver(conv, text, turn_id=turn_id, key=f"{turn_id}:reply")
# ---- drafts ------------------------------------------------------------
async def _open_draft(
self, key: str, event: Event, target: tuple[int, int | None]
) -> None:
@@ -841,8 +821,6 @@ class TelegramFrontend(Frontend):
if draft is not None:
await draft.stop()
# ---- questions (§3.7) ---------------------------------------------------
async def _ask(
self, conv: Conversation, event: Event, target: tuple[int, int | None]
) -> None:
@@ -1,8 +1,7 @@
"""Long-polling inbox (§3.8).
"""Long-polling inbox.
Every update lands in ``telegram_updates`` before the offset moves past it;
a worker handles rows from the table, oldest first, and finishes whatever a
previous process left unprocessed at startup.
a worker handles rows from the table, oldest first, resuming after a restart.
"""
from __future__ import annotations
@@ -1,8 +1,7 @@
"""Outbox (§3.8): a reply is a ``deliveries`` row first, a message second.
"""Outbox: a reply is a ``deliveries`` row first, a message second.
Rows are sent oldest first, retried with backoff on network errors and
flood limits, resent as plain text when Telegram rejects our HTML, and
given up only when Telegram says the window is gone.
Rows are sent oldest first, retried with backoff on network errors, resent
as plain text when Telegram rejects our HTML, and given up once it's gone.
"""
from __future__ import annotations
+8 -46
View File
@@ -1,16 +1,7 @@
"""Cross-frontend turn record.
Frontends that finish a turn (the Anthropic Messages frontend, the
markdown frontend) emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers``. The markdown frontend uses this
to persist chats from other frontends into the Obsidian vault — see
``frontends/markdown/crossfront.py``.
Kept tiny on purpose: it carries the structured-enough payload a logger
needs (which agent ran, input history, the assembled assistant reply)
and nothing else. The full event stream is gone by the time handlers
run — if a future consumer needs deltas it would subscribe at a lower
level, not here.
Frontends emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers`` after a turn finishes.
"""
from __future__ import annotations
@@ -26,10 +17,6 @@ if TYPE_CHECKING:
__all__ = ["TurnRecord", "slugify"]
# Filesystem-safe slug: collapse anything that isn't a word char or
# space/hyphen to a hyphen, then squash runs of separators. Aimed at
# letting users build filenames from ``record.first_user_text`` without
# hand-rolling sanitization in every config.
_SLUG_BAD_RE = re.compile(r"[^\w\s\-]+", flags=re.UNICODE)
_SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
@@ -37,10 +24,7 @@ _SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
def slugify(text: str, *, maxlen: int = 40) -> str:
"""Sanitize ``text`` for use as a filename fragment.
Strips punctuation, collapses whitespace/hyphens into single ``-``,
and truncates to ``maxlen``. Returns ``"untitled"`` for empty input.
Unicode letters are preserved (Obsidian handles them fine; macOS
and modern Linux fs's too).
Truncates to ``maxlen`` and returns ``"untitled"`` for empty input.
"""
cleaned = _SLUG_BAD_RE.sub(" ", text).strip()
cleaned = _SLUG_SEP_RE.sub("-", cleaned).strip("-")
@@ -55,17 +39,8 @@ def slugify(text: str, *, maxlen: int = 40) -> str:
class TurnRecord:
"""One completed turn, as seen by a frontend.
``input_messages`` is the conversation history sent to the backend
(everything *before* the assistant reply). ``output_message`` is the
finalized assistant ``Message`` (post-accumulation, with all content
blocks attached). ``system`` is the per-request system prompt if any
— agents own their own ``system_prompt``, this is the override the
caller passed; most handlers can ignore it.
``source`` names the frontend that produced the record so the
cross-frontend logger can avoid logging its own turns (markdown
frontend writing the file would otherwise also receive its own
broadcast and double-write).
``input_messages`` excludes the assistant reply; ``source`` names the
producing frontend so a cross-frontend logger can skip its own turns.
"""
agent_name: str
@@ -76,12 +51,7 @@ class TurnRecord:
@property
def first_user_text(self) -> str:
"""Plain text of the *earliest* user turn in this conversation.
Useful for naming new files by topic. Empty string if the input
history somehow has no user turn (shouldn't happen — turns are
broadcast only after a user → assistant cycle).
"""
"""Empty string if the input history has no user turn."""
for msg in self.input_messages:
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
@@ -89,7 +59,6 @@ class TurnRecord:
@property
def last_user_text(self) -> str:
"""Plain text of the most recent user turn (the trigger)."""
for msg in reversed(self.input_messages):
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
@@ -109,10 +78,8 @@ class TurnRecord:
def _text_of(content: object) -> str:
"""Flatten an Anthropic ``MessageParam.content`` field to a plain string.
Handles both shapes the SDK accepts: raw string, or a list of block
dicts (we pull ``text`` blocks only). Anything we don't recognize is
silently skipped — these helpers exist for naming files, not for
faithful content reconstruction.
Handles both raw-string and block-list shapes; unrecognized content is
silently skipped since this exists for naming, not faithful reconstruction.
"""
if isinstance(content, str):
return content
@@ -121,11 +88,6 @@ def _text_of(content: object) -> str:
for blk in content:
if not isinstance(blk, dict):
continue
# ``MessageParam.content`` is typed as a union of typed-dicts
# per Anthropic SDK; we only care about plain ``text`` blocks
# and look them up via ``Any`` to dodge the keyed-typed-dict
# variance gymnastics (``ty`` won't let an open dict alias a
# closed-keyed one).
d: Any = blk
if d.get("type") == "text":
parts.append(str(d.get("text", "")))