feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror

This commit is contained in:
hh committed 2026-08-28 03:50:33 +02:00
1 parent 33ccc78fec
commit 1d8d65b69a
17 files changed
+1009 -292

No files matched your search

+145 -91
View File
@@ -4,39 +4,44 @@ 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``.
The frontend is intentionally thin: it authenticates the bearer token,
resolves ``body.model`` to an agent + its backend, and then either
streams the backend's events straight to SSE or accumulates them into a
single ``Message`` for ``stream=false`` callers. All provider quirks
already live in the backend adapters; we don't translate here.
Phase 1.4 wires only ``RaycastAgent`` through ``RaycastBackend``;
``ClaudeAgent`` lands in Phase 2 and will plug into the same dispatch
table without changes to this module.
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``.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import TYPE_CHECKING, Any
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core import audit
from beaver_gateway.core.transcript import fingerprint, text_of
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator, accumulate
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends.base import Frontend
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from anthropic.types import Message, MessageParam
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
@@ -44,10 +49,16 @@ _log = logging.getLogger("beaver_gateway.frontends.anthropic")
__all__ = ["AnthropicMessagesFrontend"]
FRONTEND = "anthropic"
_TITLE_LEN = 80
class AnthropicMessagesFrontend(Frontend):
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
name = FRONTEND
kinds = ("deep",)
def __init__(
self,
*,
@@ -57,14 +68,6 @@ class AnthropicMessagesFrontend(Frontend):
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
# are appended to it when the admin dashboard renders copy-pastable
# URLs. Trailing slash is stripped so the concatenation is
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
# no proxy) — the dashboard then derives the base from the
# browser's own request hostname.
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
@@ -74,9 +77,6 @@ class AnthropicMessagesFrontend(Frontend):
self._app = self._build_app(runtime)
async def serve(self) -> None:
# Local import: uvicorn pulls in a lot, no reason to load it when
# something else (a test, a script) imports this module just for
# the FastAPI factory.
import uvicorn
if self._app is None:
@@ -134,9 +134,6 @@ class AnthropicMessagesFrontend(Frontend):
backend = runtime.backends.get(agent.name)
if backend is None:
# Agent exists in config but its backend isn't wired in
# this phase (e.g. ClaudeAgent before Phase 2, or a
# RaycastAgent without RAYCAST_BEARER set at startup).
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
f"no backend configured for agent {agent.name!r}",
@@ -144,7 +141,12 @@ class AnthropicMessagesFrontend(Frontend):
messages = body.get("messages") or []
system = body.get("system")
system_str = system if isinstance(system, str) else None
stream_flag = bool(body.get("stream", False))
if not messages or messages[-1].get("role") != "user":
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the last message must be a user turn"
)
_log.info(
"messages: actor=%s agent=%s stream=%s msgs=%d",
@@ -153,10 +155,6 @@ class AnthropicMessagesFrontend(Frontend):
stream_flag,
len(messages),
)
# Record at request acceptance, not at stream completion:
# a long streaming response can be aborted mid-flight by
# the client, and we still want the row in the audit trail.
# Detail stays small — no message bodies, no system prompt.
await audit.log(
runtime,
actor=f"token:{token_name}",
@@ -166,74 +164,139 @@ class AnthropicMessagesFrontend(Frontend):
msgs=len(messages),
)
# Forward per-request knobs the Anthropic body may carry —
# backend adapters layer these over per-agent defaults. Only
# values explicitly present (not Anthropic-defaulted ones we
# never received) are forwarded, so the agent's default still
# wins when the caller omits the field.
options: dict[str, Any] = {}
if isinstance(body.get("temperature"), int | float):
options["temperature"] = body["temperature"]
if isinstance(agent, ClaudeAgent) and runtime.conversations is not None:
conv, history = await _resolve(runtime.conversations, agent, messages)
turn_id = f"turn_{uuid.uuid4().hex[:12]}"
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
messages=messages,
origin="user",
capture=capture,
turn_id=turn_id,
)
events = backend.complete(
agent=agent,
messages=messages,
system=system if isinstance(system, str) else None,
**options,
)
async def after(message: Message) -> None:
await _finish(
runtime,
conv,
messages=messages,
message=message,
capture=capture,
turn_id=turn_id,
history=history,
)
else:
options: dict[str, Any] = {}
if isinstance(body.get("temperature"), int | float):
options["temperature"] = body["temperature"]
events = backend.complete(
agent=agent, messages=messages, system=system_str, **options
)
system_str = system if isinstance(system, str) else None
if stream_flag:
# Side-accumulate while streaming so we can still emit a
# ``TurnRecord`` after the response closes. The buffered
# ``Message`` lives only in this coroutine's frame; SSE
# bytes still flow to the client unchanged.
return StreamingResponse(
_sse_and_broadcast(
events,
runtime=runtime,
async def after(message: Message) -> None:
await _broadcast_turn(
runtime,
agent_name=agent.name,
input_messages=messages,
output_message=message,
system=system_str,
model=model,
),
)
if stream_flag:
return StreamingResponse(
_sse(events, model=model, after=after),
media_type="text/event-stream",
)
message = await accumulate(events, model=model)
await _broadcast_turn(
runtime,
agent_name=agent.name,
input_messages=messages,
output_message=message,
system=system_str,
)
acc = StreamAccumulator()
async for ev in events:
acc.feed(ev)
message = acc.finalize(model=model)
await after(message)
return JSONResponse(content=message.model_dump(mode="json"))
return app
async def _sse_and_broadcast(
async def _resolve(
conversations: Conversations, agent: ClaudeAgent, messages: list[MessageParam]
) -> tuple[Conversation, list[dict[str, str]] | None]:
prior = [dict(m) for m in messages[:-1]]
if prior:
conv = await conversations.find_bound(
frontend=FRONTEND, external_id=fingerprint(prior)
)
if conv is not None:
if conv.agent_name != agent.name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"this chat runs on {conv.agent_name!r}, not {agent.name!r}",
)
return conv, None
first = next(
(text_of(m.get("content")) for m in messages if m.get("role") == "user"), ""
)
try:
conv = await conversations.create(
kind="deep",
agent=agent.name,
origin=FRONTEND,
title=" ".join(first.split())[:_TITLE_LEN] or None,
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await conversations.materialize(conv)
history = [
{"role": str(m.get("role", "")), "text": text_of(m.get("content"))}
for m in prior
]
return conv, history
async def _finish(
runtime: GatewayRuntime,
conv: Conversation,
*,
messages: list[MessageParam],
message: Message,
capture: TurnCapture,
turn_id: str,
history: list[dict[str, str]] | None,
) -> None:
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
]
await runtime.conversations.bind(
conv, frontend=FRONTEND, external_id=fingerprint([*messages, *tail])
)
runtime.bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
source=FRONTEND,
prompt=text_of(messages[-1].get("content")),
text=reply,
history=history,
)
async def _sse(
events: AsyncIterator[MessageStreamEvent],
*,
runtime: GatewayRuntime,
agent_name: str,
input_messages: list[MessageParam],
system: str | None,
model: str,
after: Callable[[Message], Awaitable[None]],
) -> AsyncIterator[bytes]:
r"""Serialize an event stream to SSE; broadcast a :class:`TurnRecord` after.
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
Each event becomes ``event: <type>\ndata: <json>\n\n`` — the shape
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.
The same events feed a :class:`StreamAccumulator` on the side so that
once the SSE response closes we can hand a fully-assembled
``Message`` to every ``runtime.turn_log_handlers`` entry (the
markdown frontend's archive logger lives in there). Broadcast
failures are caught — they must never bubble up to the client.
"""
acc = StreamAccumulator()
try:
@@ -248,14 +311,10 @@ async def _sse_and_broadcast(
)
yield f"event: error\ndata: {err}\n\n".encode()
return
message = acc.finalize(model=model)
await _broadcast_turn(
runtime,
agent_name=agent_name,
input_messages=input_messages,
output_message=message,
system=system,
)
try:
await after(acc.finalize(model=model))
except Exception: # noqa: BLE001
_log.exception("post-turn hook failed")
async def _broadcast_turn(
@@ -266,11 +325,6 @@ async def _broadcast_turn(
output_message: Message,
system: str | None,
) -> None:
"""Fire each ``turn_log_handlers`` entry with a fresh :class:`TurnRecord`.
Handler exceptions are caught and logged — they're observability
plumbing, not part of the user-visible request path.
"""
if not runtime.turn_log_handlers:
return
record = TurnRecord(
@@ -278,7 +332,7 @@ async def _broadcast_turn(
input_messages=list(input_messages),
output_message=output_message,
system=system,
source="anthropic",
source=FRONTEND,
)
for handler in runtime.turn_log_handlers:
try: