feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend

This commit is contained in:
hh
2026-08-28 03:08:30 +02:00
parent 50b7057fa4
commit 33ccc78fec
28 changed files with 3543 additions and 345 deletions
+84 -194
View File
@@ -13,7 +13,13 @@ 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 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.
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
registers a handler on ``runtime.turn_log_handlers`` so every other
@@ -24,7 +30,6 @@ shape.
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
@@ -33,7 +38,7 @@ import tempfile
import time
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import aiofile
from anthropic.types import RawContentBlockStopEvent
@@ -44,29 +49,28 @@ from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.core import audit
from beaver_gateway.core.conversation_store import (
diff_and_fork,
load_conversation,
load_messages,
mint_conversation,
rewrite_messages,
set_session_id,
)
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.markdown import parser, renderer
from beaver_gateway.frontends.markdown.crossfront import (
CrossFrontendLogger,
fingerprint_messages,
)
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
from anthropic.types import MessageParam
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
_log = logging.getLogger("beaver_gateway.frontends.markdown")
@@ -89,12 +93,7 @@ _STREAM_FLUSH_DEBOUNCE = 0.4
# disk round-trip).
_SSE_FLUSH_DEBOUNCE = 0.1
# Interval between SSE comment-frames sent when the backend is silent
# (e.g. claude is mid-thinking on a large context). The Obsidian plugin
# and any intermediate proxies will hold the connection open as long as
# bytes keep flowing; a comment-frame is the cheapest legal SSE keepalive.
# Set well under typical proxy/client idle timeouts (60s).
_SSE_HEARTBEAT_INTERVAL = 15.0
FRONTEND = "markdown"
class MarkdownFrontend(Frontend):
@@ -134,6 +133,9 @@ class MarkdownFrontend(Frontend):
self._crossfront: CrossFrontendLogger | None = None
def configure(self, runtime: GatewayRuntime) -> None:
if runtime.conversations is None:
msg = "MarkdownFrontend needs runtime.conversations"
raise RuntimeError(msg)
self._runtime = runtime
self.vault_path.mkdir(parents=True, exist_ok=True)
if self.log_all_chats:
@@ -286,17 +288,7 @@ class MarkdownFrontend(Frontend):
self._busy.discard(file_path)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# nginx default-buffers SSE bodies; this header tells
# both nginx and uvicorn-behind-proxy to flush as we
# write. Harmless if the deployment has no reverse
# proxy in front.
"X-Accel-Buffering": "no",
},
gen(), media_type="text/event-stream", headers=SSE_HEADERS
)
return app
@@ -384,16 +376,19 @@ class MarkdownFrontend(Frontend):
# 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, agent_name=agent.name
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
capture = TurnCapture()
events = backend.complete(
agent=agent,
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
system=None,
origin="user",
capture=capture,
**_session_options(conv, outcome.divergence_index),
use_session=outcome.divergence_index is None,
)
try:
message = await self._stream_to_file(
@@ -422,7 +417,7 @@ class MarkdownFrontend(Frontend):
await self._persist_canonical_history(
runtime=runtime,
conversation_id=conv.id,
conversation_id=cast("int", conv.id),
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
@@ -487,7 +482,7 @@ class MarkdownFrontend(Frontend):
elif content_override is None:
file_text = await _read_or_empty(file_path)
else:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -503,7 +498,7 @@ class MarkdownFrontend(Frontend):
default=self.default_agent,
)
if not agent_name:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_400_BAD_REQUEST,
@@ -516,7 +511,7 @@ class MarkdownFrontend(Frontend):
return
if not parsed.messages:
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -527,7 +522,7 @@ class MarkdownFrontend(Frontend):
return
if parser.last_role(parsed.messages) == "assistant":
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "nothing_to_do",
@@ -539,7 +534,7 @@ class MarkdownFrontend(Frontend):
agent = runtime.agents.get(agent_name)
if agent is None:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_404_NOT_FOUND,
@@ -549,7 +544,7 @@ class MarkdownFrontend(Frontend):
return
backend = runtime.backends.get(agent.name)
if backend is None:
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -575,7 +570,10 @@ class MarkdownFrontend(Frontend):
)
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
runtime=runtime,
metadata=parsed.metadata,
agent_name=agent.name,
file_path=file_path,
)
_log.info(
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
@@ -602,12 +600,12 @@ class MarkdownFrontend(Frontend):
agent.name,
conv.session_id,
)
events = backend.complete(
agent=agent,
events = runtime.conversations.turn(
conv,
messages=outcome.messages,
system=None,
origin="user",
capture=capture,
**_session_options(conv, outcome.divergence_index),
use_session=outcome.divergence_index is None,
)
acc = StreamAccumulator()
@@ -624,13 +622,9 @@ class MarkdownFrontend(Frontend):
return _reattach_frontmatter(parsed.metadata, new_body)
try:
async for ev in _events_with_heartbeat(events):
async for ev in events_with_heartbeat(events):
if ev is None:
# Backend is quiet (claude mid-thinking, MCP slow,
# whatever). SSE comment-frame keeps the TCP socket
# warm so the plugin / uvicorn / any reverse proxy
# doesn't time the request out before we finish.
yield b": keepalive\n\n"
yield KEEPALIVE
continue
acc.feed(ev)
now = time.monotonic()
@@ -643,7 +637,7 @@ class MarkdownFrontend(Frontend):
# 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})
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
@@ -662,7 +656,7 @@ class MarkdownFrontend(Frontend):
await _write_atomic(
file_path, _reattach_frontmatter(parsed.metadata, new_body)
)
yield _sse_pack(
yield sse_pack(
"error",
{
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -684,7 +678,7 @@ class MarkdownFrontend(Frontend):
await self._persist_canonical_history(
runtime=runtime,
conversation_id=conv.id,
conversation_id=cast("int", conv.id),
persist_messages=outcome.persist_messages,
new_user_text=parsed.turns[-1].text,
capture=capture,
@@ -704,7 +698,7 @@ class MarkdownFrontend(Frontend):
except Exception: # noqa: BLE001
_log.exception("turn_log_handler raised; continuing")
yield _sse_pack(
yield sse_pack(
"done",
{
"status": "ok",
@@ -795,71 +789,53 @@ class MarkdownFrontend(Frontend):
rendered = renderer.render_assistant_message(message)
new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
# Recompute fingerprint so a future cross-frontend hit on this
# same conversation can find it. Stored as hex string in
# frontmatter — only the markdown frontend reads it.
assistant_param: MessageParam = {
"role": "assistant",
"content": _flatten_assistant_text(message),
}
updated_messages: list[MessageParam] = [*parsed.messages, assistant_param]
updated_metadata = dict(parsed.metadata)
updated_metadata.pop("fingerprint", None)
updated_metadata["agent"] = agent_name
updated_metadata["conversation_id"] = conv_external_id
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
new_content = _reattach_frontmatter(updated_metadata, new_body)
if write_disk:
await _write_atomic(file_path, new_content)
return new_content
async def _resolve_conversation(
self, *, runtime: GatewayRuntime, metadata: dict[str, Any], agent_name: str
) -> tuple[Any, str, list[dict[str, Any]]]:
"""Resolve the conversation row + stored messages for this request.
self,
*,
runtime: GatewayRuntime,
metadata: dict[str, Any],
agent_name: str,
file_path: Path,
) -> tuple[Conversation, str, list[dict[str, Any]]]:
"""Resolve the ``deep`` conversation for this file + its stored messages.
Looks up by frontmatter ``conversation_id``, mints a new row if
missing, and returns ``(conv, external_id, stored_messages)``.
``conv.id`` is guaranteed non-None because both
``load_conversation`` (after refresh on a committed row) and
``mint_conversation`` (post-commit refresh) populate it. We
coerce with a runtime check so the rest of the handler can
treat it as ``int``.
Frontmatter ``conversation_id`` wins; a file that lost it is found
by its visible ``(markdown, path)`` binding; otherwise a new
conversation is created. The binding follows the file: a moved
chat re-binds to its new path on the next turn.
"""
conversations = runtime.conversations
rel = file_path.relative_to(self.vault_path).as_posix()
raw = metadata.get("conversation_id")
lookup_id = raw if isinstance(raw, str) and raw else None
conv = await conversations.get(raw) if isinstance(raw, str) and raw else None
if conv is None:
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
if conv is None:
conv = await conversations.create(
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
)
_log.info("minted conversation %s for %s", conv.external_id, rel)
bound = [
b
for b in await conversations.bindings(conv)
if b.frontend == FRONTEND and b.visible and b.external_id == rel
]
if not bound:
await conversations.bind(conv, frontend=FRONTEND, external_id=rel)
await conversations.touch_user(conv)
if conv.id is None:
msg = "conversation row missing primary key after commit"
raise RuntimeError(msg)
async with runtime.db.session() as session:
conv = None
if lookup_id is not None:
conv = await load_conversation(
session, frontend="markdown", external_id=lookup_id
)
if conv is None:
_log.info(
"_resolve_conversation: frontmatter conv_id=%s "
"not found in DB, will mint new",
lookup_id,
)
else:
_log.info(
"_resolve_conversation: LOADED existing conv "
"id=%d external_id=%s",
conv.id or -1,
conv.external_id,
)
if conv is None:
conv = await mint_conversation(
session, frontend="markdown", agent_name=agent_name
)
_log.info(
"_resolve_conversation: MINTED new conv "
"id=%d external_id=%s agent=%s",
conv.id or -1,
conv.external_id,
agent_name,
)
if conv.id is None:
msg = "conversation row missing primary key after commit"
raise RuntimeError(msg)
stored = await load_messages(session, conversation_id=conv.id)
return conv, conv.external_id, stored
@@ -894,12 +870,6 @@ class MarkdownFrontend(Frontend):
await rewrite_messages(
session, conversation_id=conversation_id, messages=canonical
)
if capture.session_id is not None:
await set_session_id(
session,
conversation_id=conversation_id,
session_id=capture.session_id,
)
_log.info(
"_persist_canonical_history: conv_id=%d DB committed", conversation_id
)
@@ -927,71 +897,6 @@ class MarkdownFrontend(Frontend):
# ---- module-level utilities ----------------------------------------------
def _session_options(conv: Any, divergence_index: int | None) -> dict[str, Any]:
"""Backend options that pin the turn to the conversation's live session.
A divergence means the file's history no longer matches what the
session saw, so the stored ``session_id`` is withheld and the backend
seeds a fresh one from the aligned messages.
"""
return {
"conversation_id": conv.external_id,
"session_id": conv.session_id if divergence_index is None else None,
}
async def _events_with_heartbeat(
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
) -> AsyncIterator[Any]:
"""Wrap an async event stream with idle-time heartbeat markers.
Yields ``None`` every ``interval`` seconds during silence; real
events pass through unchanged. When the wrapped iterator is
exhausted, this generator returns. Cancellation propagates: if the
outer scope is cancelled we cancel the pending ``__anext__`` task
instead of leaving it dangling.
"""
src = events.__aiter__()
next_task: asyncio.Task[Any] | None = None
try:
while True:
# Reuse the in-flight task across timeouts. Spawning a fresh
# ``__anext__()`` while the previous one is still pending
# puts two consumers on the same async generator — that
# raises ``RuntimeError: anext(): asynchronous generator is
# already running``.
if next_task is None:
next_task = asyncio.ensure_future(src.__anext__())
done, _pending = await asyncio.wait({next_task}, timeout=interval)
if not done:
yield None
continue
task = next_task
next_task = None
try:
result = task.result()
except StopAsyncIteration:
return
yield result
finally:
if next_task is not None and not next_task.done():
next_task.cancel()
with contextlib.suppress(BaseException):
await next_task
def _sse_pack(event: str, data: dict[str, Any]) -> bytes:
r"""Format one Server-Sent Event frame.
Uses named events (``event: <name>``) so the plugin can dispatch on
type without parsing JSON discriminators. ``ensure_ascii=False`` so
multibyte content rides through verbatim instead of becoming
``\uXXXX`` blobs that bloat the wire.
"""
body = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {body}\n\n".encode()
async def _read_or_empty(path: Path) -> str:
"""Return file contents, or empty string if the file doesn't exist."""
# ``path.exists()`` here is a metadata stat — microseconds — and
@@ -1083,21 +988,6 @@ def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
return [{"role": "assistant", "content": content}]
def _flatten_assistant_text(message: Any) -> str:
"""Pull all text blocks from an assistant ``Message`` and join them.
Used when we need the assistant content as a plain string for
fingerprinting / equality with a parser-shaped history (parser
already drops thinking + tool_use from assistant turns).
"""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _render_error_block(exc: BaseException) -> str:
"""Render a backend failure as an Assistant turn with a ``[!error]-`` callout."""
msg = str(exc) or exc.__class__.__name__
@@ -9,11 +9,10 @@ from other frontends).
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
from anthropic.types import Message, TextBlock, ThinkingBlock
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -61,9 +60,8 @@ def render_assistant_message(message: Message) -> str:
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
* ``TextBlock`` → plain text (the spoken answer)
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
JSON quoted inside. Tool *results* are not persisted — see
module docstring on ``parser.py`` for why.
* ``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.
@@ -147,10 +145,7 @@ def _render_block(block: object) -> Iterable[str]:
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
if isinstance(block, ToolUseBlock):
yield from _render_tool_use(block)
return
# Unknown block type — skip silently rather than corrupting the file.
# Tool-use blocks and unknown block types never reach the file.
def _render_thinking(text: str) -> Iterable[str]:
@@ -159,17 +154,6 @@ def _render_thinking(text: str) -> Iterable[str]:
yield f"> {line}" if line else ">"
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
title = summarize_tool_input(block.name, block.input)
yield f"> [!tool]- {title}"
yield "> **input:**"
yield "> ```json"
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
for line in pretty.splitlines():
yield f"> {line}" if line else ">"
yield "> ```"
def adaptive_fence(content: str) -> str:
"""Return a backtick fence at least one longer than the longest run in ``content``.