feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror
This commit is contained in:
+16
-21
@@ -12,7 +12,7 @@ from beaver_gateway.agents.base import ExposedMcp
|
|||||||
from beaver_gateway.agents.claude import ClaudeAgent
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
||||||
from beaver_gateway.core.registry import Gateway
|
from beaver_gateway.core.registry import Gateway
|
||||||
from beaver_gateway.core.turn_record import TurnRecord, slugify
|
from beaver_gateway.core.turn_record import slugify
|
||||||
from beaver_gateway.frontends.admin import AdminFrontend
|
from beaver_gateway.frontends.admin import AdminFrontend
|
||||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||||
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
||||||
@@ -20,20 +20,19 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
|||||||
from beaver_gateway.mcp.types import McpServer
|
from beaver_gateway.mcp.types import McpServer
|
||||||
|
|
||||||
|
|
||||||
def chat_log_path(record: TurnRecord, vault: Path) -> Path:
|
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
|
||||||
"""Decide where a logged chat from another frontend lands in the vault.
|
"""Where a new chat file lands in the vault.
|
||||||
|
|
||||||
Called by ``MarkdownFrontend`` (with ``log_all_chats=True``) the first
|
Called by ``MarkdownFrontend`` for every conversation that needs a
|
||||||
time a conversation needs a file — continuation turns are matched by
|
file: a ``deep`` chat spawned by the dispatcher, a ``/v1/messages``
|
||||||
fingerprint and stick to the file picked here. Return value can be
|
chat (``title`` = first user message), or with ``log_all_chats=True``
|
||||||
|
the archive of a stateless agent's turns. Return value can be
|
||||||
absolute or relative; relative paths are anchored under ``vault``.
|
absolute or relative; relative paths are anchored under ``vault``.
|
||||||
|
|
||||||
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md`` where
|
Layout below: ``<vault>/<YYYY-MM>/<YYYY-MM-DD>_<topic>.md``.
|
||||||
``topic`` is a slug of the very first user message in the chat.
|
|
||||||
"""
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
topic = slugify(record.first_user_text, maxlen=40)
|
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{slugify(title, maxlen=40)}.md"
|
||||||
return vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d}_{topic}.md"
|
|
||||||
|
|
||||||
|
|
||||||
def current_time() -> str:
|
def current_time() -> str:
|
||||||
@@ -208,17 +207,13 @@ gateway = Gateway(
|
|||||||
vault_path=Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve(),
|
vault_path=Path(tempfile.mkdtemp(prefix="beaver-vault-")).resolve(),
|
||||||
default_agent="research",
|
default_agent="research",
|
||||||
log_all_chats=True,
|
log_all_chats=True,
|
||||||
# ``log_path`` (optional) overrides the default
|
# ``chat_path`` (optional) overrides the default
|
||||||
# ``{vault}/_logs/<agent>/<date>_<hex>.md`` layout for chats
|
# ``{vault}/_logs/<agent>/<date>_<slug>.md`` layout. Heads up:
|
||||||
# logged from OTHER frontends (Anthropic Messages, admin
|
# any custom path forces ``warm_index`` to scan the entire
|
||||||
# in-browser chat). Defined as a top-of-file function so the
|
# vault on startup so the fingerprint→file map of archived
|
||||||
# types are explicit and the IDE can hover them; a lambda
|
# stateless chats survives a restart no matter where you put
|
||||||
# works too, but a real ``def`` keeps the signature visible
|
# files.
|
||||||
# and lets you docstring it. Heads up: any custom path
|
chat_path=chat_path,
|
||||||
# forces ``warm_index`` to scan the entire vault on startup
|
|
||||||
# so the fingerprint→file map survives a restart no matter
|
|
||||||
# where you put files.
|
|
||||||
log_path=chat_log_path,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping # noqa: TC003 - pydantic runtime
|
from collections.abc import Mapping # noqa: TC003 - pydantic runtime
|
||||||
from pathlib import Path # noqa: TC003 - pydantic runtime
|
from pathlib import Path # noqa: TC003 - pydantic runtime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from beaver_gateway.agents.base import BaseAgent
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
from beaver_gateway.core.prompt import PromptSource # noqa: TC001 - pydantic runtime
|
from beaver_gateway.core.prompt import PromptSource # noqa: TC001 - pydantic runtime
|
||||||
@@ -57,6 +58,10 @@ class ClaudeAgent(BaseAgent):
|
|||||||
"""Per conversation kind (``master``/``branch``/``deep``/``job``/``fork``)
|
"""Per conversation kind (``master``/``branch``/``deep``/``job``/``fork``)
|
||||||
assembly; falls back to ``prompt_sources``. Constant per kind (§3.12)."""
|
assembly; falls back to ``prompt_sources``. Constant per kind (§3.12)."""
|
||||||
|
|
||||||
|
kinds: tuple[str, ...] = ()
|
||||||
|
"""Conversation kinds this agent serves; ``create``/``spawn`` reject the
|
||||||
|
rest. Defaults to the keys of ``prompt_sources_by_kind`` or ``("deep",)``."""
|
||||||
|
|
||||||
skill_sets: tuple[Path, ...] = ()
|
skill_sets: tuple[Path, ...] = ()
|
||||||
gateway_tools: tuple[str, ...] = ()
|
gateway_tools: tuple[str, ...] = ()
|
||||||
"""Gateway tools exposed in-process (``read_conversation``, ``spawn``,
|
"""Gateway tools exposed in-process (``read_conversation``, ``spawn``,
|
||||||
@@ -64,5 +69,16 @@ class ClaudeAgent(BaseAgent):
|
|||||||
|
|
||||||
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
|
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _default_kinds(cls, data: Any) -> Any:
|
||||||
|
if isinstance(data, dict) and not data.get("kinds"):
|
||||||
|
by_kind = data.get("prompt_sources_by_kind") or {}
|
||||||
|
data = {**data, "kinds": tuple(by_kind) or ("deep",)}
|
||||||
|
return data
|
||||||
|
|
||||||
def prompt_for(self, kind: str) -> tuple[PromptSource, ...]:
|
def prompt_for(self, kind: str) -> tuple[PromptSource, ...]:
|
||||||
return self.prompt_sources_by_kind.get(kind, self.prompt_sources)
|
return self.prompt_sources_by_kind.get(kind, self.prompt_sources)
|
||||||
|
|
||||||
|
def serves(self, kind: str) -> bool:
|
||||||
|
return kind in self.kinds
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ from beaver_gateway.core.events import (
|
|||||||
build_tool_use_block_start,
|
build_tool_use_block_start,
|
||||||
)
|
)
|
||||||
from beaver_gateway.core.sessions import Session, SessionClient, SessionPool
|
from beaver_gateway.core.sessions import Session, SessionClient, SessionPool
|
||||||
from beaver_gateway.core.transcript import build_entries, close_open_tool_uses
|
from beaver_gateway.core.transcript import (
|
||||||
|
build_entries,
|
||||||
|
close_open_tool_uses,
|
||||||
|
fingerprint,
|
||||||
|
text_of,
|
||||||
|
)
|
||||||
from beaver_gateway.core.turn_capture import TurnCapture, TurnUsage
|
from beaver_gateway.core.turn_capture import TurnCapture, TurnUsage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -700,41 +705,8 @@ def _mcp_disallowed(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str:
|
|
||||||
turns: list[tuple[str, str]] = []
|
|
||||||
for message in messages:
|
|
||||||
text = _text_of(message.get("content"))
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
role = str(message.get("role", ""))
|
|
||||||
if turns and turns[-1][0] == role:
|
|
||||||
turns[-1] = (role, turns[-1][1] + "\n" + text)
|
|
||||||
else:
|
|
||||||
turns.append((role, text))
|
|
||||||
digest = hashlib.sha1(usedforsecurity=False)
|
|
||||||
for role, text in turns:
|
|
||||||
digest.update(role.encode("utf-8"))
|
|
||||||
digest.update(b"\x00")
|
|
||||||
digest.update(text.strip().encode("utf-8"))
|
|
||||||
digest.update(b"\x01")
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _text_of(content: Any) -> str:
|
|
||||||
if isinstance(content, str):
|
|
||||||
return content
|
|
||||||
if isinstance(content, list):
|
|
||||||
parts = [
|
|
||||||
str(b.get("text", ""))
|
|
||||||
for b in content
|
|
||||||
if isinstance(b, Mapping) and b.get("type") == "text"
|
|
||||||
]
|
|
||||||
return "\n".join(p for p in parts if p)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_text(content: Any) -> str:
|
def _prompt_text(content: Any) -> str:
|
||||||
text = _text_of(content)
|
text = text_of(content)
|
||||||
if not text:
|
if not text:
|
||||||
msg = "user message has no text content"
|
msg = "user message has no text content"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ async def _async_main() -> None:
|
|||||||
pool=pool,
|
pool=pool,
|
||||||
store=session_store,
|
store=session_store,
|
||||||
texts=gateway.texts,
|
texts=gateway.texts,
|
||||||
|
frontends=gateway.frontends,
|
||||||
)
|
)
|
||||||
late.conversations = conversations
|
late.conversations = conversations
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ if TYPE_CHECKING:
|
|||||||
from beaver_gateway.core.injects import Priority
|
from beaver_gateway.core.injects import Priority
|
||||||
from beaver_gateway.core.registry import AgentRegistry
|
from beaver_gateway.core.registry import AgentRegistry
|
||||||
from beaver_gateway.core.sessions import SessionPool
|
from beaver_gateway.core.sessions import SessionPool
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
from beaver_gateway.storage.db import Database
|
from beaver_gateway.storage.db import Database
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -138,6 +139,7 @@ class Conversations:
|
|||||||
pool: SessionPool,
|
pool: SessionPool,
|
||||||
store: SessionStore,
|
store: SessionStore,
|
||||||
texts: ConversationTexts | None = None,
|
texts: ConversationTexts | None = None,
|
||||||
|
frontends: Sequence[Frontend] = (),
|
||||||
normal_window: float = 3600.0,
|
normal_window: float = 3600.0,
|
||||||
idle_days: Sequence[int] = (2,),
|
idle_days: Sequence[int] = (2,),
|
||||||
idle_interval: float = 3600.0,
|
idle_interval: float = 3600.0,
|
||||||
@@ -149,6 +151,7 @@ class Conversations:
|
|||||||
self._pool = pool
|
self._pool = pool
|
||||||
self._store = store
|
self._store = store
|
||||||
self._texts = texts or ConversationTexts()
|
self._texts = texts or ConversationTexts()
|
||||||
|
self._frontends = [f for f in frontends if f.name]
|
||||||
self._normal_window = normal_window
|
self._normal_window = normal_window
|
||||||
self._idle_days = tuple(sorted(idle_days))
|
self._idle_days = tuple(sorted(idle_days))
|
||||||
self._idle_interval = idle_interval
|
self._idle_interval = idle_interval
|
||||||
@@ -185,7 +188,9 @@ class Conversations:
|
|||||||
if kind not in KINDS:
|
if kind not in KINDS:
|
||||||
msg = f"unknown conversation kind {kind!r}"
|
msg = f"unknown conversation kind {kind!r}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
self._claude_agent(agent)
|
if not self._claude_agent(agent).serves(kind):
|
||||||
|
msg = f"agent {agent!r} does not serve kind {kind!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
row = Conversation(
|
row = Conversation(
|
||||||
frontend=origin,
|
frontend=origin,
|
||||||
@@ -251,6 +256,9 @@ class Conversations:
|
|||||||
external_id: str,
|
external_id: str,
|
||||||
visible: bool = True,
|
visible: bool = True,
|
||||||
) -> ConversationBinding:
|
) -> ConversationBinding:
|
||||||
|
if conv.kind not in self.frontend(frontend).kinds:
|
||||||
|
msg = f"frontend {frontend!r} does not show kind {conv.kind!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
existing = list(
|
existing = list(
|
||||||
(
|
(
|
||||||
@@ -385,13 +393,41 @@ class Conversations:
|
|||||||
out["busy"] = live.busy if live is not None else False
|
out["busy"] = live.busy if live is not None else False
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# ---- routing -------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frontends(self) -> list[Frontend]:
|
||||||
|
return list(self._frontends)
|
||||||
|
|
||||||
|
def frontend(self, name: str) -> Frontend:
|
||||||
|
for fe in self._frontends:
|
||||||
|
if fe.name == name:
|
||||||
|
return fe
|
||||||
|
msg = f"unknown frontend {name!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
def default_agent(self, kind: str) -> str | None:
|
||||||
|
for fe in self._frontends:
|
||||||
|
if kind in fe.kinds and (agent := fe.agent_for(kind)):
|
||||||
|
return agent
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||||
|
for fe in self._frontends:
|
||||||
|
if conv.kind not in fe.kinds:
|
||||||
|
continue
|
||||||
|
binding = await fe.materialize(conv)
|
||||||
|
if binding is not None:
|
||||||
|
return binding
|
||||||
|
return None
|
||||||
|
|
||||||
# ---- §3.1 api ------------------------------------------------------
|
# ---- §3.1 api ------------------------------------------------------
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
kind: str,
|
kind: str,
|
||||||
agent: str,
|
agent: str | None = None,
|
||||||
seed: str = "clean",
|
seed: str = "clean",
|
||||||
parent: Conversation | None = None,
|
parent: Conversation | None = None,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
@@ -405,6 +441,12 @@ class Conversations:
|
|||||||
if seed == "brief" and not text:
|
if seed == "brief" and not text:
|
||||||
msg = "seed=brief needs text"
|
msg = "seed=brief needs text"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
if agent is None and kind == "branch" and parent is not None:
|
||||||
|
agent = parent.agent_name
|
||||||
|
agent = agent or self.default_agent(kind)
|
||||||
|
if agent is None:
|
||||||
|
msg = f"no default agent for kind {kind!r}; pass `agent`"
|
||||||
|
raise ValueError(msg)
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
if seed == "copy":
|
if seed == "copy":
|
||||||
if parent is None or parent.session_id is None:
|
if parent is None or parent.session_id is None:
|
||||||
@@ -421,6 +463,7 @@ class Conversations:
|
|||||||
origin=origin,
|
origin=origin,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
await self.materialize(conv)
|
||||||
prompt = await self._seed_text(
|
prompt = await self._seed_text(
|
||||||
SeedContext(
|
SeedContext(
|
||||||
kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title
|
kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title
|
||||||
@@ -888,6 +931,8 @@ class Conversations:
|
|||||||
conversation_id=conv.external_id,
|
conversation_id=conv.external_id,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
item=head.id,
|
item=head.id,
|
||||||
|
source="queue",
|
||||||
|
prompt=prompt,
|
||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,14 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
"Open a new conversation of the given kind (branch = your own thread, "
|
"Open a new conversation of the given kind (branch = your own thread, "
|
||||||
"deep = a long research chat, job = a headless task). `seed` is how it "
|
"deep = a long research chat, job = a headless task). `seed` is how it "
|
||||||
"starts: clean (nothing), morning (handout), copy (copy of this "
|
"starts: clean (nothing), morning (handout), copy (copy of this "
|
||||||
"conversation, last `window` turns), brief (your `text`). Returns the id.",
|
"conversation, last `window` turns), brief (your `text`). A branch "
|
||||||
|
"keeps your agent, other kinds get their frontend's default unless "
|
||||||
|
"`agent` says otherwise. Returns the id.",
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {"type": "string", "enum": ["branch", "deep", "job"]},
|
"kind": {"type": "string", "enum": ["branch", "deep", "job"]},
|
||||||
|
"agent": {"type": "string", "description": "agent name"},
|
||||||
"seed": {
|
"seed": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["clean", "morning", "copy", "brief"],
|
"enum": ["clean", "morning", "copy", "brief"],
|
||||||
@@ -94,16 +97,19 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
)
|
)
|
||||||
async def spawn(args: dict[str, Any]) -> dict[str, Any]:
|
async def spawn(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
parent = await current()
|
parent = await current()
|
||||||
child = await conversations.spawn(
|
try:
|
||||||
kind=str(args["kind"]),
|
child = await conversations.spawn(
|
||||||
agent=parent.agent_name,
|
kind=str(args["kind"]),
|
||||||
seed=str(args.get("seed") or "clean"),
|
agent=args.get("agent"),
|
||||||
parent=parent,
|
seed=str(args.get("seed") or "clean"),
|
||||||
text=args.get("text"),
|
parent=parent,
|
||||||
title=args.get("title"),
|
text=args.get("text"),
|
||||||
window=args.get("window"),
|
title=args.get("title"),
|
||||||
origin="mcp",
|
window=args.get("window"),
|
||||||
)
|
origin="mcp",
|
||||||
|
)
|
||||||
|
except (ValueError, LookupError) as exc:
|
||||||
|
return _error(str(exc))
|
||||||
return _text(f"spawned {child.kind} {child.external_id}")
|
return _text(f"spawned {child.kind} {child.external_id}")
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ anything that needs Anthropic-shape history out of a mirrored transcript.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
from collections.abc import Mapping
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -25,17 +27,19 @@ except ImportError: # pragma: no cover
|
|||||||
_cli_version = "2.1.248"
|
_cli_version = "2.1.248"
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CLI_VERSION",
|
"CLI_VERSION",
|
||||||
"build_entries",
|
"build_entries",
|
||||||
"close_open_tool_uses",
|
"close_open_tool_uses",
|
||||||
|
"fingerprint",
|
||||||
"messages_from_entries",
|
"messages_from_entries",
|
||||||
"open_tool_uses",
|
"open_tool_uses",
|
||||||
"prompt_count",
|
"prompt_count",
|
||||||
"render_messages",
|
"render_messages",
|
||||||
"strip_tool_entries",
|
"strip_tool_entries",
|
||||||
|
"text_of",
|
||||||
"window_entries",
|
"window_entries",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -503,3 +507,37 @@ def _text_of_content(content: Any) -> str:
|
|||||||
if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
|
if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str:
|
||||||
|
"""Text-only hash of a history; stateless callers are keyed by it."""
|
||||||
|
turns: list[tuple[str, str]] = []
|
||||||
|
for message in messages:
|
||||||
|
text = text_of(message.get("content"))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
role = str(message.get("role", ""))
|
||||||
|
if turns and turns[-1][0] == role:
|
||||||
|
turns[-1] = (role, turns[-1][1] + "\n" + text)
|
||||||
|
else:
|
||||||
|
turns.append((role, text))
|
||||||
|
digest = hashlib.sha1(usedforsecurity=False)
|
||||||
|
for role, text in turns:
|
||||||
|
digest.update(role.encode("utf-8"))
|
||||||
|
digest.update(b"\x00")
|
||||||
|
digest.update(text.strip().encode("utf-8"))
|
||||||
|
digest.update(b"\x01")
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def text_of(content: Any) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = [
|
||||||
|
str(b.get("text", ""))
|
||||||
|
for b in content
|
||||||
|
if isinstance(b, Mapping) and b.get("type") == "text"
|
||||||
|
]
|
||||||
|
return "\n".join(p for p in parts if p)
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -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,
|
client that already speaks Anthropic (Cursor, Cline, the official SDK,
|
||||||
``curl``) can hit a configured agent by passing its name as ``model``.
|
``curl``) can hit a configured agent by passing its name as ``model``.
|
||||||
|
|
||||||
The frontend is intentionally thin: it authenticates the bearer token,
|
A Claude agent behind this endpoint is a ``deep`` conversation: the client
|
||||||
resolves ``body.model`` to an agent + its backend, and then either
|
knows nothing about our ids, so the text fingerprint of the history it
|
||||||
streams the backend's events straight to SSE or accumulates them into a
|
sends is the ``(anthropic, fingerprint)`` binding of the conversation,
|
||||||
single ``Message`` for ``stream=false`` callers. All provider quirks
|
rebound after every turn to the fingerprint the next request will carry.
|
||||||
already live in the backend adapters; we don't translate here.
|
A history nobody has seen becomes a new conversation, materialized by the
|
||||||
|
home frontend of ``deep`` (the vault file), and every reply is published
|
||||||
Phase 1.4 wires only ``RaycastAgent`` through ``RaycastBackend``;
|
on the bus so that file follows the chat. Other agents (Raycast) stay
|
||||||
``ClaudeAgent`` lands in Phase 2 and will plug into the same dispatch
|
stateless and are only archived through ``turn_log_handlers``.
|
||||||
table without changes to this module.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, status
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
from beaver_gateway.core import audit
|
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.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._auth import require_token
|
||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
|
||||||
from anthropic.types import Message, MessageParam
|
from anthropic.types import Message, MessageParam
|
||||||
|
|
||||||
|
from beaver_gateway.core.conversations import Conversations
|
||||||
from beaver_gateway.core.events import MessageStreamEvent
|
from beaver_gateway.core.events import MessageStreamEvent
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.storage.models import Conversation
|
||||||
|
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
||||||
@@ -44,10 +49,16 @@ _log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
|||||||
|
|
||||||
__all__ = ["AnthropicMessagesFrontend"]
|
__all__ = ["AnthropicMessagesFrontend"]
|
||||||
|
|
||||||
|
FRONTEND = "anthropic"
|
||||||
|
_TITLE_LEN = 80
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesFrontend(Frontend):
|
class AnthropicMessagesFrontend(Frontend):
|
||||||
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
|
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
|
||||||
|
|
||||||
|
name = FRONTEND
|
||||||
|
kinds = ("deep",)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -57,14 +68,6 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
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.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||||
self._runtime: GatewayRuntime | None = None
|
self._runtime: GatewayRuntime | None = None
|
||||||
self._app: FastAPI | None = None
|
self._app: FastAPI | None = None
|
||||||
@@ -74,9 +77,6 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
self._app = self._build_app(runtime)
|
self._app = self._build_app(runtime)
|
||||||
|
|
||||||
async def serve(self) -> None:
|
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
|
import uvicorn
|
||||||
|
|
||||||
if self._app is None:
|
if self._app is None:
|
||||||
@@ -134,9 +134,6 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
|
|
||||||
backend = runtime.backends.get(agent.name)
|
backend = runtime.backends.get(agent.name)
|
||||||
if backend is None:
|
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(
|
raise HTTPException(
|
||||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
f"no backend configured for agent {agent.name!r}",
|
f"no backend configured for agent {agent.name!r}",
|
||||||
@@ -144,7 +141,12 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
|
|
||||||
messages = body.get("messages") or []
|
messages = body.get("messages") or []
|
||||||
system = body.get("system")
|
system = body.get("system")
|
||||||
|
system_str = system if isinstance(system, str) else None
|
||||||
stream_flag = bool(body.get("stream", False))
|
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(
|
_log.info(
|
||||||
"messages: actor=%s agent=%s stream=%s msgs=%d",
|
"messages: actor=%s agent=%s stream=%s msgs=%d",
|
||||||
@@ -153,10 +155,6 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
stream_flag,
|
stream_flag,
|
||||||
len(messages),
|
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(
|
await audit.log(
|
||||||
runtime,
|
runtime,
|
||||||
actor=f"token:{token_name}",
|
actor=f"token:{token_name}",
|
||||||
@@ -166,74 +164,139 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
msgs=len(messages),
|
msgs=len(messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Forward per-request knobs the Anthropic body may carry —
|
if isinstance(agent, ClaudeAgent) and runtime.conversations is not None:
|
||||||
# backend adapters layer these over per-agent defaults. Only
|
conv, history = await _resolve(runtime.conversations, agent, messages)
|
||||||
# values explicitly present (not Anthropic-defaulted ones we
|
turn_id = f"turn_{uuid.uuid4().hex[:12]}"
|
||||||
# never received) are forwarded, so the agent's default still
|
capture = TurnCapture()
|
||||||
# wins when the caller omits the field.
|
events = runtime.conversations.turn(
|
||||||
options: dict[str, Any] = {}
|
conv,
|
||||||
if isinstance(body.get("temperature"), int | float):
|
messages=messages,
|
||||||
options["temperature"] = body["temperature"]
|
origin="user",
|
||||||
|
capture=capture,
|
||||||
|
turn_id=turn_id,
|
||||||
|
)
|
||||||
|
|
||||||
events = backend.complete(
|
async def after(message: Message) -> None:
|
||||||
agent=agent,
|
await _finish(
|
||||||
messages=messages,
|
runtime,
|
||||||
system=system if isinstance(system, str) else None,
|
conv,
|
||||||
**options,
|
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
|
async def after(message: Message) -> None:
|
||||||
|
await _broadcast_turn(
|
||||||
if stream_flag:
|
runtime,
|
||||||
# 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,
|
|
||||||
agent_name=agent.name,
|
agent_name=agent.name,
|
||||||
input_messages=messages,
|
input_messages=messages,
|
||||||
|
output_message=message,
|
||||||
system=system_str,
|
system=system_str,
|
||||||
model=model,
|
)
|
||||||
),
|
|
||||||
|
if stream_flag:
|
||||||
|
return StreamingResponse(
|
||||||
|
_sse(events, model=model, after=after),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
message = await accumulate(events, model=model)
|
acc = StreamAccumulator()
|
||||||
await _broadcast_turn(
|
async for ev in events:
|
||||||
runtime,
|
acc.feed(ev)
|
||||||
agent_name=agent.name,
|
message = acc.finalize(model=model)
|
||||||
input_messages=messages,
|
await after(message)
|
||||||
output_message=message,
|
|
||||||
system=system_str,
|
|
||||||
)
|
|
||||||
return JSONResponse(content=message.model_dump(mode="json"))
|
return JSONResponse(content=message.model_dump(mode="json"))
|
||||||
|
|
||||||
return app
|
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],
|
events: AsyncIterator[MessageStreamEvent],
|
||||||
*,
|
*,
|
||||||
runtime: GatewayRuntime,
|
|
||||||
agent_name: str,
|
|
||||||
input_messages: list[MessageParam],
|
|
||||||
system: str | None,
|
|
||||||
model: str,
|
model: str,
|
||||||
|
after: Callable[[Message], Awaitable[None]],
|
||||||
) -> AsyncIterator[bytes]:
|
) -> 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
|
the Anthropic SDK's SSE decoder expects. Errors mid-stream are
|
||||||
swallowed into a synthetic ``error`` event so the client sees the
|
swallowed into a synthetic ``error`` event so the client sees the
|
||||||
failure rather than a hung connection.
|
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()
|
acc = StreamAccumulator()
|
||||||
try:
|
try:
|
||||||
@@ -248,14 +311,10 @@ async def _sse_and_broadcast(
|
|||||||
)
|
)
|
||||||
yield f"event: error\ndata: {err}\n\n".encode()
|
yield f"event: error\ndata: {err}\n\n".encode()
|
||||||
return
|
return
|
||||||
message = acc.finalize(model=model)
|
try:
|
||||||
await _broadcast_turn(
|
await after(acc.finalize(model=model))
|
||||||
runtime,
|
except Exception: # noqa: BLE001
|
||||||
agent_name=agent_name,
|
_log.exception("post-turn hook failed")
|
||||||
input_messages=input_messages,
|
|
||||||
output_message=message,
|
|
||||||
system=system,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast_turn(
|
async def _broadcast_turn(
|
||||||
@@ -266,11 +325,6 @@ async def _broadcast_turn(
|
|||||||
output_message: Message,
|
output_message: Message,
|
||||||
system: str | None,
|
system: str | None,
|
||||||
) -> 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:
|
if not runtime.turn_log_handlers:
|
||||||
return
|
return
|
||||||
record = TurnRecord(
|
record = TurnRecord(
|
||||||
@@ -278,7 +332,7 @@ async def _broadcast_turn(
|
|||||||
input_messages=list(input_messages),
|
input_messages=list(input_messages),
|
||||||
output_message=output_message,
|
output_message=output_message,
|
||||||
system=system,
|
system=system,
|
||||||
source="anthropic",
|
source=FRONTEND,
|
||||||
)
|
)
|
||||||
for handler in runtime.turn_log_handlers:
|
for handler in runtime.turn_log_handlers:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from beaver_gateway.frontends.base import Frontend
|
|||||||
from beaver_gateway.storage.models import Usage
|
from beaver_gateway.storage.models import Usage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Mapping
|
||||||
|
|
||||||
from beaver_gateway.core.conversations import Conversations
|
from beaver_gateway.core.conversations import Conversations
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
@@ -48,18 +48,26 @@ SCOPE = "api"
|
|||||||
|
|
||||||
|
|
||||||
class ApiFrontend(Frontend):
|
class ApiFrontend(Frontend):
|
||||||
|
name = "api"
|
||||||
|
kinds = ("master", "branch", "deep", "job")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
host: str = "0.0.0.0", # noqa: S104
|
host: str = "0.0.0.0", # noqa: S104
|
||||||
port: int = 8004,
|
port: int = 8004,
|
||||||
public_base_url: str | None = None,
|
public_base_url: str | None = None,
|
||||||
|
default_agents: Mapping[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||||
|
self.default_agents = dict(default_agents or {})
|
||||||
self._app: FastAPI | None = None
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
|
def agent_for(self, kind: str) -> str | None:
|
||||||
|
return self.default_agents.get(kind)
|
||||||
|
|
||||||
def configure(self, runtime: GatewayRuntime) -> None:
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
if runtime.conversations is None or runtime.bus is None:
|
if runtime.conversations is None or runtime.bus is None:
|
||||||
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
|
||||||
@@ -130,6 +138,30 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/api/agents")
|
||||||
|
async def list_agents(request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
|
return {
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"name": a.name,
|
||||||
|
"model": a.model,
|
||||||
|
"kinds": list(getattr(a, "kinds", ())),
|
||||||
|
}
|
||||||
|
for a in runtime.agents
|
||||||
|
],
|
||||||
|
"frontends": [
|
||||||
|
{
|
||||||
|
"name": fe.name,
|
||||||
|
"kinds": list(fe.kinds),
|
||||||
|
"default_agents": {
|
||||||
|
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for fe in conversations.frontends
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/conversations")
|
@app.get("/api/conversations")
|
||||||
async def list_conversations(request: Request) -> dict[str, Any]:
|
async def list_conversations(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
@@ -149,10 +181,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST, f"kind must be one of {KINDS[:-1]}"
|
status.HTTP_400_BAD_REQUEST, f"kind must be one of {KINDS[:-1]}"
|
||||||
)
|
)
|
||||||
if not isinstance(agent, str) or agent not in runtime.agents:
|
if agent is not None and (
|
||||||
raise HTTPException(
|
not isinstance(agent, str) or agent not in runtime.agents
|
||||||
status.HTTP_400_BAD_REQUEST, "unknown or missing `agent`"
|
):
|
||||||
)
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown `agent`")
|
||||||
seed = str(data.get("seed") or "clean")
|
seed = str(data.get("seed") or "clean")
|
||||||
if seed not in SEEDS:
|
if seed not in SEEDS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -170,13 +202,13 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
window=int_or_none(data, "window"),
|
window=int_or_none(data, "window"),
|
||||||
origin="api",
|
origin="api",
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except (ValueError, LookupError) as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
await audit.log(
|
await audit.log(
|
||||||
runtime,
|
runtime,
|
||||||
actor=f"token:{token}",
|
actor=f"token:{token}",
|
||||||
kind="api_spawn",
|
kind="api_spawn",
|
||||||
agent_name=agent,
|
agent_name=conv.agent_name,
|
||||||
conversation=conv.external_id,
|
conversation=conv.external_id,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
)
|
)
|
||||||
@@ -276,7 +308,9 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
|
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
|
||||||
)
|
)
|
||||||
agent = str(data.get("agent") or parent.agent_name)
|
agent = data.get("agent")
|
||||||
|
if agent is not None and not isinstance(agent, str):
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`agent` must be a string")
|
||||||
try:
|
try:
|
||||||
child = await conversations.spawn(
|
child = await conversations.spawn(
|
||||||
kind="branch",
|
kind="branch",
|
||||||
@@ -294,7 +328,7 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
runtime,
|
runtime,
|
||||||
actor=f"token:{token}",
|
actor=f"token:{token}",
|
||||||
kind="api_branch",
|
kind="api_branch",
|
||||||
agent_name=agent,
|
agent_name=child.agent_name,
|
||||||
conversation=child.external_id,
|
conversation=child.external_id,
|
||||||
parent=parent.external_id,
|
parent=parent.external_id,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
@@ -350,12 +384,15 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
|||||||
data = await body_of(request)
|
data = await body_of(request)
|
||||||
frontend = text_of(data, "frontend")
|
frontend = text_of(data, "frontend")
|
||||||
external_id = text_of(data, "external_id")
|
external_id = text_of(data, "external_id")
|
||||||
await conversations.bind(
|
try:
|
||||||
conv,
|
await conversations.bind(
|
||||||
frontend=frontend,
|
conv,
|
||||||
external_id=external_id,
|
frontend=frontend,
|
||||||
visible=bool(data.get("visible", True)),
|
external_id=external_id,
|
||||||
)
|
visible=bool(data.get("visible", True)),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
return await conversations.describe(conv)
|
return await conversations.describe(conv)
|
||||||
|
|
||||||
@app.patch("/api/conversations/{public_id}/flags")
|
@app.patch("/api/conversations/{public_id}/flags")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
|||||||
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
||||||
from beaver_gateway.core.turn_record import TurnRecord
|
from beaver_gateway.core.turn_record import TurnRecord
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
|
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||||
|
|
||||||
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
|
TurnLogHandler = Callable[[TurnRecord], Awaitable[None]]
|
||||||
|
|
||||||
@@ -86,10 +87,30 @@ class GatewayRuntime:
|
|||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
"""Listens on a port, dispatches into the gateway."""
|
"""Listens on a port, dispatches into the gateway.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
kinds: tuple[str, ...] = ()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def serve(self) -> None: ...
|
async def serve(self) -> None: ...
|
||||||
|
|
||||||
|
def agent_for(self, kind: str) -> str | None: # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def materialize(self, conv: Conversation) -> ConversationBinding | None: # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ if TYPE_CHECKING:
|
|||||||
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
|
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
|
||||||
|
|
||||||
|
|
||||||
# User hook: take a turn + vault root, return where the new file should
|
__all__ = [
|
||||||
# live. Returning a relative ``Path`` is treated as relative to the
|
"ChatPathFn",
|
||||||
# vault. ``None`` (the default) keeps the built-in
|
"CrossFrontendLogger",
|
||||||
# ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{hex8}.md`` layout.
|
"fingerprint_messages",
|
||||||
LogPathFn = "Callable[[TurnRecord, Path], Path]"
|
"strip_trailing_user_scaffold",
|
||||||
|
]
|
||||||
|
|
||||||
|
ChatPathFn = "Callable[[str, str, Path], Path]"
|
||||||
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
|
"""``(title, agent, vault) -> path`` of a new chat file; relative = under the
|
||||||
|
vault. ``None`` keeps ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{slug}.md``."""
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
|
def fingerprint_messages(messages: Iterable[MessageParam]) -> str:
|
||||||
@@ -96,18 +98,18 @@ class CrossFrontendLogger:
|
|||||||
*,
|
*,
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
logged_subdir: str,
|
logged_subdir: str,
|
||||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._vault = vault_path
|
self._vault = vault_path
|
||||||
self._root = vault_path / logged_subdir
|
self._root = vault_path / logged_subdir
|
||||||
self._index: dict[str, Path] = {}
|
self._index: dict[str, Path] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._log_path_fn = log_path
|
self._chat_path_fn = chat_path
|
||||||
# When the user supplies a custom path function, files can land
|
# When the user supplies a custom path function, files can land
|
||||||
# anywhere in the vault — so we have to scan the whole vault on
|
# anywhere in the vault — so we have to scan the whole vault on
|
||||||
# startup to rebuild the fingerprint→path map. With the default
|
# startup to rebuild the fingerprint→path map. With the default
|
||||||
# layout we can bound the scan to ``_logs/``.
|
# layout we can bound the scan to ``_logs/``.
|
||||||
self._scan_root = vault_path if log_path is not None else self._root
|
self._scan_root = vault_path if chat_path is not None else self._root
|
||||||
|
|
||||||
def warm_index(self) -> None:
|
def warm_index(self) -> None:
|
||||||
"""Scan logged files synchronously, populating the fingerprint map.
|
"""Scan logged files synchronously, populating the fingerprint map.
|
||||||
@@ -171,7 +173,7 @@ class CrossFrontendLogger:
|
|||||||
if target.exists():
|
if target.exists():
|
||||||
existing = target.read_text(encoding="utf-8")
|
existing = target.read_text(encoding="utf-8")
|
||||||
parsed = frontmatter.loads(existing)
|
parsed = frontmatter.loads(existing)
|
||||||
body = _strip_trailing_user_scaffold(parsed.content)
|
body = strip_trailing_user_scaffold(parsed.content)
|
||||||
# We append only the *new* user turn (the last one in
|
# We append only the *new* user turn (the last one in
|
||||||
# input_messages, since prior turns are already on disk)
|
# input_messages, since prior turns are already on disk)
|
||||||
# plus the assistant reply.
|
# plus the assistant reply.
|
||||||
@@ -206,13 +208,15 @@ class CrossFrontendLogger:
|
|||||||
def _new_file_path(self, record: TurnRecord) -> Path:
|
def _new_file_path(self, record: TurnRecord) -> Path:
|
||||||
"""Pick a fresh filename for a brand-new conversation.
|
"""Pick a fresh filename for a brand-new conversation.
|
||||||
|
|
||||||
With a user-supplied ``log_path`` we delegate to it (joining a
|
With a user-supplied ``chat_path`` we delegate to it (joining a
|
||||||
relative result with the vault root). Without one, we fall back
|
relative result with the vault root). Without one, we fall back
|
||||||
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
|
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
|
||||||
``.md`` suffix in case the user picks a non-md extension by hand.
|
``.md`` suffix in case the user picks a non-md extension by hand.
|
||||||
"""
|
"""
|
||||||
if self._log_path_fn is not None:
|
if self._chat_path_fn is not None:
|
||||||
result = self._log_path_fn(record, self._vault)
|
result = self._chat_path_fn(
|
||||||
|
record.first_user_text, record.agent_name, self._vault
|
||||||
|
)
|
||||||
if not result.is_absolute():
|
if not result.is_absolute():
|
||||||
result = self._vault / result
|
result = self._vault / result
|
||||||
if result.suffix != ".md":
|
if result.suffix != ".md":
|
||||||
@@ -270,7 +274,7 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _strip_trailing_user_scaffold(body: str) -> str:
|
def strip_trailing_user_scaffold(body: str) -> str:
|
||||||
"""Drop a trailing empty ``### User:`` block if present.
|
"""Drop a trailing empty ``### User:`` block if present.
|
||||||
|
|
||||||
Cross-frontend turns aren't typed into the file by the human — they
|
Cross-frontend turns aren't typed into the file by the human — they
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Vault file IO shared by the markdown frontend and its mirror."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import aiofile
|
||||||
|
import frontmatter
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["read_or_empty", "reattach_frontmatter", "write_atomic"]
|
||||||
|
|
||||||
|
|
||||||
|
async def read_or_empty(path: Path) -> str:
|
||||||
|
if not path.exists(): # noqa: ASYNC240
|
||||||
|
return ""
|
||||||
|
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
|
||||||
|
return await f.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def write_atomic(path: Path, content: str) -> None:
|
||||||
|
"""Write via tmp + ``os.replace`` in the same directory: no torn reads."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
await f.write(content)
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(tmp_path, path) # noqa: PTH105
|
||||||
|
except BaseException:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(fd)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp_path) # noqa: PTH108
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
|
||||||
|
"""Re-emit a ``.md`` with YAML frontmatter; no block at all for empty metadata."""
|
||||||
|
if not metadata:
|
||||||
|
return body if body.endswith("\n") else body + "\n"
|
||||||
|
post = frontmatter.Post(content=body, **metadata)
|
||||||
|
return frontmatter.dumps(post) + "\n"
|
||||||
@@ -19,7 +19,10 @@ posted to the same conversation via ``/api`` waits its turn.
|
|||||||
|
|
||||||
A chat file is a ``deep`` conversation bound as
|
A chat file is a ``deep`` conversation bound as
|
||||||
``(markdown, <vault-relative path>)``; frontmatter carries only ``agent``
|
``(markdown, <vault-relative path>)``; frontmatter carries only ``agent``
|
||||||
and ``conversation_id`` (§3.10), tool calls are never rendered.
|
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()``
|
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
|
||||||
registers a handler on ``runtime.turn_log_handlers`` so every other
|
registers a handler on ``runtime.turn_log_handlers`` so every other
|
||||||
@@ -30,17 +33,15 @@ shape.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import aiofile
|
|
||||||
from anthropic.types import RawContentBlockStopEvent
|
from anthropic.types import RawContentBlockStopEvent
|
||||||
from fastapi import FastAPI, HTTPException, Request, status
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -65,12 +66,18 @@ from beaver_gateway.frontends._sse import (
|
|||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
from beaver_gateway.frontends.markdown import parser, renderer
|
from beaver_gateway.frontends.markdown import parser, renderer
|
||||||
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
|
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
|
||||||
|
from beaver_gateway.frontends.markdown.files import (
|
||||||
|
read_or_empty,
|
||||||
|
reattach_frontmatter,
|
||||||
|
write_atomic,
|
||||||
|
)
|
||||||
|
from beaver_gateway.frontends.markdown.mirror import FRONTEND, ChatMirror
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
from beaver_gateway.storage.models import Conversation
|
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||||
|
|
||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.frontends.markdown")
|
_log = logging.getLogger("beaver_gateway.frontends.markdown")
|
||||||
@@ -93,12 +100,13 @@ _STREAM_FLUSH_DEBOUNCE = 0.4
|
|||||||
# disk round-trip).
|
# disk round-trip).
|
||||||
_SSE_FLUSH_DEBOUNCE = 0.1
|
_SSE_FLUSH_DEBOUNCE = 0.1
|
||||||
|
|
||||||
FRONTEND = "markdown"
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownFrontend(Frontend):
|
class MarkdownFrontend(Frontend):
|
||||||
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
|
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
|
||||||
|
|
||||||
|
name = FRONTEND
|
||||||
|
kinds = ("deep",)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -108,7 +116,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
default_agent: str | None = None,
|
default_agent: str | None = None,
|
||||||
log_all_chats: bool = False,
|
log_all_chats: bool = False,
|
||||||
logged_subdir: str = "_logs",
|
logged_subdir: str = "_logs",
|
||||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||||
public_base_url: str | None = None,
|
public_base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.vault_path = Path(vault_path).expanduser().resolve()
|
self.vault_path = Path(vault_path).expanduser().resolve()
|
||||||
@@ -117,7 +125,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
self.default_agent = default_agent
|
self.default_agent = default_agent
|
||||||
self.log_all_chats = log_all_chats
|
self.log_all_chats = log_all_chats
|
||||||
self.logged_subdir = logged_subdir
|
self.logged_subdir = logged_subdir
|
||||||
self.log_path = log_path
|
self.chat_path = chat_path
|
||||||
# External URL prefix when behind a reverse proxy — same role as
|
# External URL prefix when behind a reverse proxy — same role as
|
||||||
# on the other bearer frontends. Trailing slash trimmed for
|
# on the other bearer frontends. Trailing slash trimmed for
|
||||||
# idempotent concatenation; ``None`` means "no proxy / advertise
|
# idempotent concatenation; ``None`` means "no proxy / advertise
|
||||||
@@ -131,6 +139,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
# request reliably loses the race to 409.
|
# request reliably loses the race to 409.
|
||||||
self._busy: set[Path] = set()
|
self._busy: set[Path] = set()
|
||||||
self._crossfront: CrossFrontendLogger | None = None
|
self._crossfront: CrossFrontendLogger | None = None
|
||||||
|
self._mirror: ChatMirror | None = None
|
||||||
|
|
||||||
def configure(self, runtime: GatewayRuntime) -> None:
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
if runtime.conversations is None:
|
if runtime.conversations is None:
|
||||||
@@ -138,11 +147,17 @@ class MarkdownFrontend(Frontend):
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._mirror = ChatMirror(
|
||||||
|
vault_path=self.vault_path,
|
||||||
|
runtime=runtime,
|
||||||
|
logged_subdir=self.logged_subdir,
|
||||||
|
chat_path=self.chat_path,
|
||||||
|
)
|
||||||
if self.log_all_chats:
|
if self.log_all_chats:
|
||||||
self._crossfront = CrossFrontendLogger(
|
self._crossfront = CrossFrontendLogger(
|
||||||
vault_path=self.vault_path,
|
vault_path=self.vault_path,
|
||||||
logged_subdir=self.logged_subdir,
|
logged_subdir=self.logged_subdir,
|
||||||
log_path=self.log_path,
|
chat_path=self.chat_path,
|
||||||
)
|
)
|
||||||
# Scan the existing logged files synchronously here so the
|
# Scan the existing logged files synchronously here so the
|
||||||
# fingerprint→path map is populated before the first
|
# fingerprint→path map is populated before the first
|
||||||
@@ -151,6 +166,19 @@ class MarkdownFrontend(Frontend):
|
|||||||
runtime.turn_log_handlers.append(self._crossfront.handle)
|
runtime.turn_log_handlers.append(self._crossfront.handle)
|
||||||
self._app = self._build_app(runtime)
|
self._app = self._build_app(runtime)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mirror(self) -> ChatMirror:
|
||||||
|
if self._mirror is None:
|
||||||
|
msg = "configure() must be called first"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return self._mirror
|
||||||
|
|
||||||
|
def agent_for(self, kind: str) -> str | None:
|
||||||
|
return self.default_agent if kind == "deep" else None
|
||||||
|
|
||||||
|
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||||
|
return await self.mirror.materialize(conv)
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -161,7 +189,13 @@ class MarkdownFrontend(Frontend):
|
|||||||
self._app, host=self.host, port=self.port, log_level="info"
|
self._app, host=self.host, port=self.port, log_level="info"
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
await server.serve()
|
mirror = asyncio.create_task(self.mirror.run())
|
||||||
|
try:
|
||||||
|
await server.serve()
|
||||||
|
finally:
|
||||||
|
mirror.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await mirror
|
||||||
|
|
||||||
# ---- app builder ---------------------------------------------------
|
# ---- app builder ---------------------------------------------------
|
||||||
|
|
||||||
@@ -309,7 +343,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
if isinstance(content_override, str):
|
if isinstance(content_override, str):
|
||||||
file_text = content_override
|
file_text = content_override
|
||||||
elif content_override is None:
|
elif content_override is None:
|
||||||
file_text = await _read_or_empty(file_path)
|
file_text = await read_or_empty(file_path)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
|
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
|
||||||
@@ -480,7 +514,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
if isinstance(content_override, str):
|
if isinstance(content_override, str):
|
||||||
file_text = content_override
|
file_text = content_override
|
||||||
elif content_override is None:
|
elif content_override is None:
|
||||||
file_text = await _read_or_empty(file_path)
|
file_text = await read_or_empty(file_path)
|
||||||
else:
|
else:
|
||||||
yield sse_pack(
|
yield sse_pack(
|
||||||
"error",
|
"error",
|
||||||
@@ -569,12 +603,18 @@ class MarkdownFrontend(Frontend):
|
|||||||
msgs=len(parsed.messages),
|
msgs=len(parsed.messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
try:
|
||||||
runtime=runtime,
|
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||||
metadata=parsed.metadata,
|
runtime=runtime,
|
||||||
agent_name=agent.name,
|
metadata=parsed.metadata,
|
||||||
file_path=file_path,
|
agent_name=agent.name,
|
||||||
)
|
file_path=file_path,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
yield sse_pack(
|
||||||
|
"error", {"status_code": exc.status_code, "detail": exc.detail}
|
||||||
|
)
|
||||||
|
return
|
||||||
_log.info(
|
_log.info(
|
||||||
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
|
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
|
||||||
"stored_msgs=%d incoming_turns=%d",
|
"stored_msgs=%d incoming_turns=%d",
|
||||||
@@ -619,7 +659,7 @@ class MarkdownFrontend(Frontend):
|
|||||||
return None
|
return None
|
||||||
rendered = renderer.render_assistant_message(partial)
|
rendered = renderer.render_assistant_message(partial)
|
||||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||||
return _reattach_frontmatter(parsed.metadata, new_body)
|
return reattach_frontmatter(parsed.metadata, new_body)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for ev in events_with_heartbeat(events):
|
async for ev in events_with_heartbeat(events):
|
||||||
@@ -653,8 +693,8 @@ class MarkdownFrontend(Frontend):
|
|||||||
)
|
)
|
||||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||||
if write_disk:
|
if write_disk:
|
||||||
await _write_atomic(
|
await write_atomic(
|
||||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||||
)
|
)
|
||||||
yield sse_pack(
|
yield sse_pack(
|
||||||
"error",
|
"error",
|
||||||
@@ -744,8 +784,8 @@ class MarkdownFrontend(Frontend):
|
|||||||
return
|
return
|
||||||
rendered = renderer.render_assistant_message(partial)
|
rendered = renderer.render_assistant_message(partial)
|
||||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||||
await _write_atomic(
|
await write_atomic(
|
||||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -769,8 +809,8 @@ class MarkdownFrontend(Frontend):
|
|||||||
)
|
)
|
||||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||||
if write_disk:
|
if write_disk:
|
||||||
await _write_atomic(
|
await write_atomic(
|
||||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
return acc.finalize(model=model)
|
return acc.finalize(model=model)
|
||||||
@@ -793,9 +833,9 @@ class MarkdownFrontend(Frontend):
|
|||||||
updated_metadata.pop("fingerprint", None)
|
updated_metadata.pop("fingerprint", None)
|
||||||
updated_metadata["agent"] = agent_name
|
updated_metadata["agent"] = agent_name
|
||||||
updated_metadata["conversation_id"] = conv_external_id
|
updated_metadata["conversation_id"] = conv_external_id
|
||||||
new_content = _reattach_frontmatter(updated_metadata, new_body)
|
new_content = reattach_frontmatter(updated_metadata, new_body)
|
||||||
if write_disk:
|
if write_disk:
|
||||||
await _write_atomic(file_path, new_content)
|
await write_atomic(file_path, new_content)
|
||||||
return new_content
|
return new_content
|
||||||
|
|
||||||
async def _resolve_conversation(
|
async def _resolve_conversation(
|
||||||
@@ -820,9 +860,12 @@ class MarkdownFrontend(Frontend):
|
|||||||
if conv is None:
|
if conv is None:
|
||||||
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
|
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
|
||||||
if conv is None:
|
if conv is None:
|
||||||
conv = await conversations.create(
|
try:
|
||||||
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
|
conv = await conversations.create(
|
||||||
)
|
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
_log.info("minted conversation %s for %s", conv.external_id, rel)
|
_log.info("minted conversation %s for %s", conv.external_id, rel)
|
||||||
bound = [
|
bound = [
|
||||||
b
|
b
|
||||||
@@ -894,63 +937,6 @@ class MarkdownFrontend(Frontend):
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
# ---- module-level utilities ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
# gating an async read on whether the file is there is exactly the
|
|
||||||
# check we want. Switching to anyio.Path / aiofiles.os just to
|
|
||||||
# silence the async-pathlib lint would cost a dep edge for no
|
|
||||||
# practical win.
|
|
||||||
if not path.exists(): # noqa: ASYNC240
|
|
||||||
return ""
|
|
||||||
async with aiofile.async_open(path, "r", encoding="utf-8") as f:
|
|
||||||
return await f.read()
|
|
||||||
|
|
||||||
|
|
||||||
async def _write_atomic(path: Path, content: str) -> None:
|
|
||||||
"""Write ``content`` to ``path`` via tmp + ``os.replace`` (atomic)."""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
# ``NamedTemporaryFile`` keeps the file open which complicates
|
|
||||||
# ``os.replace`` on some platforms. Build the tmp name manually,
|
|
||||||
# write+fsync, then rename. Same-directory so the rename is atomic.
|
|
||||||
tmp_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
||||||
)
|
|
||||||
fd, tmp_path = tmp_name
|
|
||||||
try:
|
|
||||||
async with aiofile.async_open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
await f.write(content)
|
|
||||||
os.close(fd)
|
|
||||||
# ``os.replace`` is the atomic primitive — ``Path.replace`` is a
|
|
||||||
# thin wrapper around the same syscall; either works, ``os.`` is
|
|
||||||
# the one Linux/POSIX docs reach for.
|
|
||||||
os.replace(tmp_path, path) # noqa: PTH105
|
|
||||||
except BaseException:
|
|
||||||
# Cleanup on failure: close fd, remove tmp.
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.close(fd)
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.unlink(tmp_path) # noqa: PTH108
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _reattach_frontmatter(metadata: dict[str, Any], body: str) -> str:
|
|
||||||
r"""Re-emit a ``.md`` file with YAML frontmatter at the top.
|
|
||||||
|
|
||||||
Empty metadata → no frontmatter block (avoid littering every file
|
|
||||||
with a hollow ``---\n---``).
|
|
||||||
"""
|
|
||||||
if not metadata:
|
|
||||||
return body if body.endswith("\n") else body + "\n"
|
|
||||||
import frontmatter as _fm
|
|
||||||
|
|
||||||
post = _fm.Post(content=body, **metadata)
|
|
||||||
return _fm.dumps(post) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
|
def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
|
||||||
"""Build a single-assistant ``synthesized_messages`` list from a raw ``Message``.
|
"""Build a single-assistant ``synthesized_messages`` list from a raw ``Message``.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Vault files for ``deep`` conversations that were not typed into a file (§3.10).
|
||||||
|
|
||||||
|
``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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
|
||||||
|
from beaver_gateway.core.conversation_store import load_messages, rewrite_messages
|
||||||
|
from beaver_gateway.core.turn_record import slugify
|
||||||
|
from beaver_gateway.frontends.markdown import renderer
|
||||||
|
from beaver_gateway.frontends.markdown.crossfront import strip_trailing_user_scaffold
|
||||||
|
from beaver_gateway.frontends.markdown.files import (
|
||||||
|
read_or_empty,
|
||||||
|
reattach_frontmatter,
|
||||||
|
write_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.core.bus import Event
|
||||||
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||||
|
|
||||||
|
__all__ = ["FRONTEND", "ChatMirror"]
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.frontends.markdown.mirror")
|
||||||
|
|
||||||
|
FRONTEND = "markdown"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMirror:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
vault_path: Path,
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
logged_subdir: str,
|
||||||
|
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._vault = vault_path
|
||||||
|
self._runtime = runtime
|
||||||
|
self._root = vault_path / logged_subdir
|
||||||
|
self._chat_path_fn = chat_path
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def path_for(self, title: str, agent: str) -> Path:
|
||||||
|
if self._chat_path_fn is not None:
|
||||||
|
path = self._chat_path_fn(title, agent, self._vault)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = self._vault / path
|
||||||
|
else:
|
||||||
|
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
path = self._root / agent / f"{day}_{slugify(title, maxlen=60)}.md"
|
||||||
|
if path.suffix != ".md":
|
||||||
|
path = path.with_suffix(".md")
|
||||||
|
path = path.resolve()
|
||||||
|
path.relative_to(self._vault)
|
||||||
|
candidate, n = path, 1
|
||||||
|
while candidate.exists():
|
||||||
|
n += 1
|
||||||
|
candidate = path.with_name(f"{path.stem} ({n}){path.suffix}")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
async def materialize(self, conv: Conversation) -> ConversationBinding:
|
||||||
|
path = self.path_for(conv.title or conv.external_id, conv.agent_name)
|
||||||
|
rel = path.relative_to(self._vault).as_posix()
|
||||||
|
async with self._lock:
|
||||||
|
await write_atomic(path, reattach_frontmatter(_frontmatter(conv), ""))
|
||||||
|
binding = await self._runtime.conversations.bind(
|
||||||
|
conv, frontend=FRONTEND, external_id=rel
|
||||||
|
)
|
||||||
|
_log.info("materialized %s as %s", conv.external_id, rel)
|
||||||
|
return binding
|
||||||
|
|
||||||
|
async def bound_path(self, conv: Conversation) -> Path | None:
|
||||||
|
for b in await self._runtime.conversations.bindings(conv):
|
||||||
|
if b.frontend == FRONTEND and b.visible:
|
||||||
|
return self._vault / b.external_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
async for event in self._runtime.bus.stream():
|
||||||
|
if event.get("type") != "reply":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._on_reply(event)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("mirror of reply %s failed", event.get("turn_id"))
|
||||||
|
|
||||||
|
async def _on_reply(self, event: Event) -> None:
|
||||||
|
conv = await self._runtime.conversations.get(str(event["conversation_id"]))
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
await self.append(
|
||||||
|
conv,
|
||||||
|
prompt=str(event.get("prompt") or ""),
|
||||||
|
text=str(event.get("text") or ""),
|
||||||
|
history=event.get("history"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def append(
|
||||||
|
self,
|
||||||
|
conv: Conversation,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
text: str,
|
||||||
|
history: Sequence[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
path = await self.bound_path(conv)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
prior = [
|
||||||
|
{"role": str(h["role"]), "content": str(h["text"])}
|
||||||
|
for h in history or ()
|
||||||
|
if h.get("text")
|
||||||
|
]
|
||||||
|
async with self._lock:
|
||||||
|
parsed = frontmatter.loads(await read_or_empty(path))
|
||||||
|
body = strip_trailing_user_scaffold(parsed.content)
|
||||||
|
if prior and "### " not in body:
|
||||||
|
for m in prior:
|
||||||
|
body = renderer.append_to_body(body, _render(m))
|
||||||
|
body = renderer.append_to_body(body, renderer.render_user_text(prompt))
|
||||||
|
body = renderer.append_to_body(body, renderer.render_assistant_text(text))
|
||||||
|
body = renderer.append_to_body(body, renderer.USER_SCAFFOLD)
|
||||||
|
metadata = {**parsed.metadata, **_frontmatter(conv)}
|
||||||
|
metadata.pop("fingerprint", None)
|
||||||
|
await write_atomic(path, reattach_frontmatter(metadata, body))
|
||||||
|
await self._persist(conv, prior=prior, prompt=prompt, text=text)
|
||||||
|
|
||||||
|
async def _persist(
|
||||||
|
self, conv: Conversation, *, prior: list[dict[str, Any]], prompt: str, text: str
|
||||||
|
) -> None:
|
||||||
|
if conv.id is None:
|
||||||
|
return
|
||||||
|
async with self._runtime.db.session() as session:
|
||||||
|
stored = await load_messages(session, conversation_id=conv.id)
|
||||||
|
canonical = [
|
||||||
|
*(stored or prior),
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
{"role": "assistant", "content": [{"type": "text", "text": text}]},
|
||||||
|
]
|
||||||
|
await rewrite_messages(session, conversation_id=conv.id, messages=canonical)
|
||||||
|
|
||||||
|
|
||||||
|
def _frontmatter(conv: Conversation) -> dict[str, Any]:
|
||||||
|
return {"agent": conv.agent_name, "conversation_id": conv.external_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(message: dict[str, Any]) -> str:
|
||||||
|
if message["role"] == "user":
|
||||||
|
return renderer.render_user_text(message["content"])
|
||||||
|
return renderer.render_assistant_text(message["content"])
|
||||||
@@ -53,6 +53,11 @@ def render_user_text(content: str) -> str:
|
|||||||
return f"### User:\n\n{content.strip()}\n"
|
return f"### User:\n\n{content.strip()}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def render_assistant_text(text: str) -> str:
|
||||||
|
r"""Render a text-only assistant turn as ``### Assistant:\n\n<text>``."""
|
||||||
|
return f"### Assistant:\n\n{text.strip()}\n"
|
||||||
|
|
||||||
|
|
||||||
def render_assistant_message(message: Message) -> str:
|
def render_assistant_message(message: Message) -> str:
|
||||||
"""Render an assistant ``Message`` (with content blocks) into a turn block.
|
"""Render an assistant ``Message`` (with content blocks) into a turn block.
|
||||||
|
|
||||||
|
|||||||
+101
-11
@@ -29,8 +29,47 @@ from beaver_gateway.core.transcript import (
|
|||||||
strip_tool_entries,
|
strip_tool_entries,
|
||||||
window_entries,
|
window_entries,
|
||||||
)
|
)
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
from beaver_gateway.storage.models import InjectQueueItem
|
from beaver_gateway.storage.models import (
|
||||||
|
Conversation,
|
||||||
|
ConversationBinding,
|
||||||
|
InjectQueueItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubFrontend(Frontend):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
kinds: tuple[str, ...],
|
||||||
|
agents: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
home: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.kinds = kinds
|
||||||
|
self.agents = agents or {}
|
||||||
|
self.home = home
|
||||||
|
self.materialized: list[str] = []
|
||||||
|
self.conversations = None
|
||||||
|
|
||||||
|
def configure(self, runtime) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def serve(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def agent_for(self, kind: str) -> str | None:
|
||||||
|
return self.agents.get(kind)
|
||||||
|
|
||||||
|
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||||
|
if not self.home:
|
||||||
|
return None
|
||||||
|
self.materialized.append(conv.external_id)
|
||||||
|
return await self.conversations.bind(
|
||||||
|
conv, frontend=self.name, external_id=f"{self.name}:{conv.external_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScriptedClient:
|
class ScriptedClient:
|
||||||
@@ -116,29 +155,41 @@ class World:
|
|||||||
model="m",
|
model="m",
|
||||||
system_prompt="hi",
|
system_prompt="hi",
|
||||||
cwd=root,
|
cwd=root,
|
||||||
|
kinds=("master", "branch", "job", "fork"),
|
||||||
gateway_tools=("say",),
|
gateway_tools=("say",),
|
||||||
options=ClaudeOptions(effort="low"),
|
options=ClaudeOptions(effort="low"),
|
||||||
)
|
)
|
||||||
|
self.deep_agent = ClaudeAgent(
|
||||||
|
name="d", model="m", system_prompt="deep", cwd=root
|
||||||
|
)
|
||||||
|
self.markdown = StubFrontend("markdown", ("deep",), {"deep": "d"}, home=True)
|
||||||
|
self.api = StubFrontend("api", ("master", "branch", "deep", "job"))
|
||||||
self.pool = SessionPool(rss_limit=1 << 40, max_live=100)
|
self.pool = SessionPool(rss_limit=1 << 40, max_live=100)
|
||||||
self.backend = ClaudeSdkBackend(
|
self.backend, self.deep_backend = (
|
||||||
agent=self.agent,
|
ClaudeSdkBackend(
|
||||||
mcp_internal_urls={},
|
agent=agent,
|
||||||
session_store=self.store,
|
mcp_internal_urls={},
|
||||||
client_factory=ScriptedClient,
|
session_store=self.store,
|
||||||
work_dir=root / "work",
|
client_factory=ScriptedClient,
|
||||||
pool=self.pool,
|
work_dir=root / "work",
|
||||||
|
pool=self.pool,
|
||||||
|
)
|
||||||
|
for agent in (self.agent, self.deep_agent)
|
||||||
)
|
)
|
||||||
self.bus = EventBus()
|
self.bus = EventBus()
|
||||||
self.conversations = Conversations(
|
self.conversations = Conversations(
|
||||||
db=self.db,
|
db=self.db,
|
||||||
agents=AgentRegistry([self.agent]),
|
agents=AgentRegistry([self.agent, self.deep_agent]),
|
||||||
backends={"a": self.backend},
|
backends={"a": self.backend, "d": self.deep_backend},
|
||||||
bus=self.bus,
|
bus=self.bus,
|
||||||
pool=self.pool,
|
pool=self.pool,
|
||||||
store=self.store,
|
store=self.store,
|
||||||
texts=ConversationTexts(),
|
texts=ConversationTexts(),
|
||||||
|
frontends=[self.api, self.markdown],
|
||||||
idle_interval=3600,
|
idle_interval=3600,
|
||||||
)
|
)
|
||||||
|
self.markdown.conversations = self.conversations
|
||||||
|
self.api.conversations = self.conversations
|
||||||
|
|
||||||
async def setup(self) -> "World":
|
async def setup(self) -> "World":
|
||||||
await self.db.create_all()
|
await self.db.create_all()
|
||||||
@@ -460,7 +511,7 @@ async def test_read_and_bindings(world: World) -> None:
|
|||||||
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
|
build_entries(history, session_id=sid, cwd=str(world.root), model="m"),
|
||||||
)
|
)
|
||||||
conv = await world.conversations.create(
|
conv = await world.conversations.create(
|
||||||
kind="deep", agent="a", origin="test", session_id=sid
|
kind="deep", agent="d", origin="test", session_id=sid
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
await world.conversations.read(conv)
|
await world.conversations.read(conv)
|
||||||
@@ -544,3 +595,42 @@ def test_transcript_helpers() -> None:
|
|||||||
)
|
)
|
||||||
== "user:\nq\n\nassistant:\na"
|
== "user:\nq\n\nassistant:\na"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agent_kinds_gate_create(world: World) -> None:
|
||||||
|
with pytest.raises(ValueError, match="does not serve kind 'deep'"):
|
||||||
|
await world.conversations.create(kind="deep", agent="a", origin="test")
|
||||||
|
with pytest.raises(ValueError, match="does not serve kind 'master'"):
|
||||||
|
await world.conversations.create(kind="master", agent="d", origin="test")
|
||||||
|
with pytest.raises(ValueError, match="'d' does not serve kind 'master'"):
|
||||||
|
await world.conversations.spawn(kind="master", seed="clean", agent="d")
|
||||||
|
conv = await world.conversations.create(kind="deep", agent="d", origin="test")
|
||||||
|
assert conv.agent_name == "d"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_frontend_kinds_gate_bind(world: World) -> None:
|
||||||
|
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
with pytest.raises(ValueError, match="'markdown' does not show kind 'master'"):
|
||||||
|
await world.conversations.bind(master, frontend="markdown", external_id="m.md")
|
||||||
|
with pytest.raises(ValueError, match="unknown frontend 'telegram'"):
|
||||||
|
await world.conversations.bind(master, frontend="telegram", external_id="1")
|
||||||
|
await world.conversations.bind(master, frontend="api", external_id="x")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_defaults_agent_and_materializes(world: World) -> None:
|
||||||
|
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
deep = await world.conversations.spawn(
|
||||||
|
kind="deep", seed="brief", text="dig", title="t", parent=master, origin="mcp"
|
||||||
|
)
|
||||||
|
assert deep.agent_name == "d"
|
||||||
|
assert world.markdown.materialized == [deep.external_id]
|
||||||
|
bindings = await world.conversations.bindings(deep)
|
||||||
|
assert [(b.frontend, b.external_id) for b in bindings] == [
|
||||||
|
("markdown", f"markdown:{deep.external_id}")
|
||||||
|
]
|
||||||
|
branch = await world.conversations.spawn(kind="branch", seed="clean", parent=master)
|
||||||
|
assert branch.agent_name == "a"
|
||||||
|
assert world.markdown.materialized == [deep.external_id]
|
||||||
|
with pytest.raises(ValueError, match="no default agent for kind 'job'"):
|
||||||
|
await world.conversations.spawn(kind="job", seed="clean")
|
||||||
|
await world.settle(deep, 1)
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beaver_gateway.core.auth import TokenStore
|
||||||
|
from beaver_gateway.core.conversation_store import load_messages
|
||||||
|
from beaver_gateway.core.gateway_tools import _tools
|
||||||
|
from beaver_gateway.core.registry import McpRegistry
|
||||||
|
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||||
|
from beaver_gateway.frontends.api import ApiFrontend
|
||||||
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.markdown import MarkdownFrontend
|
||||||
|
from test_conversations import ScriptedClient, World
|
||||||
|
|
||||||
|
AUTH = {"Authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
|
||||||
|
class Stack:
|
||||||
|
def __init__(self, world: World) -> None:
|
||||||
|
self.world = world
|
||||||
|
self.vault = world.root / "vault"
|
||||||
|
self.api = ApiFrontend(default_agents={"master": "a"})
|
||||||
|
self.markdown = MarkdownFrontend(vault_path=self.vault, default_agent="d")
|
||||||
|
self.anthropic = AnthropicMessagesFrontend()
|
||||||
|
frontends = [self.api, self.anthropic, self.markdown]
|
||||||
|
world.conversations._frontends = frontends
|
||||||
|
self.runtime = GatewayRuntime(
|
||||||
|
agents=world.conversations._agents,
|
||||||
|
mcps=McpRegistry([]),
|
||||||
|
backends=world.conversations._backends,
|
||||||
|
token_store=TokenStore(bootstrap={"t": "tok"}),
|
||||||
|
db=world.db,
|
||||||
|
conversations=world.conversations,
|
||||||
|
bus=world.bus,
|
||||||
|
pool=world.pool,
|
||||||
|
frontends=tuple(frontends),
|
||||||
|
)
|
||||||
|
for fe in frontends:
|
||||||
|
fe.configure(self.runtime)
|
||||||
|
self.mirror = asyncio.create_task(self.markdown.mirror.run())
|
||||||
|
|
||||||
|
def client(self, fe) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=fe._app), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.mirror.cancel()
|
||||||
|
|
||||||
|
async def file_of(self, conv_id: str) -> Path:
|
||||||
|
conv = await self.world.conversations.get(conv_id)
|
||||||
|
path = await self.markdown.mirror.bound_path(conv)
|
||||||
|
assert path is not None
|
||||||
|
return path
|
||||||
|
|
||||||
|
async def wait_file(self, path: Path, needle: str, timeout: float = 5.0) -> str:
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
|
if path.exists() and needle in path.read_text(encoding="utf-8"):
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
msg = f"{needle!r} never showed up in {path}"
|
||||||
|
raise AssertionError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def stack() -> Stack:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-routing-"))
|
||||||
|
world = await World(root).setup()
|
||||||
|
await world.conversations.start()
|
||||||
|
s = Stack(world)
|
||||||
|
yield s
|
||||||
|
await s.close()
|
||||||
|
await world.conversations.stop()
|
||||||
|
await world.pool.close_all()
|
||||||
|
await world.db.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None:
|
||||||
|
async with stack.client(stack.api) as c:
|
||||||
|
r = await c.post(
|
||||||
|
"/api/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "does not serve kind 'deep'" in r.json()["error"]
|
||||||
|
r = await c.post("/api/conversations", json={"kind": "job"}, headers=AUTH)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "no default agent" in r.json()["error"]
|
||||||
|
r = await c.get("/api/agents", headers=AUTH)
|
||||||
|
agents = {a["name"]: a["kinds"] for a in r.json()["agents"]}
|
||||||
|
assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]}
|
||||||
|
homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]}
|
||||||
|
assert homes == {
|
||||||
|
"api": {"master": "a"},
|
||||||
|
"anthropic": {},
|
||||||
|
"markdown": {"deep": "d"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
|
||||||
|
async with stack.client(stack.api) as c:
|
||||||
|
r = await c.post("/api/conversations", json={"kind": "master"}, headers=AUTH)
|
||||||
|
assert r.status_code == 201 and r.json()["agent"] == "a"
|
||||||
|
master = r.json()["id"]
|
||||||
|
r = await c.post(
|
||||||
|
"/api/conversations",
|
||||||
|
json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["agent"] == "d"
|
||||||
|
assert [b["frontend"] for b in body["bindings"]] == ["markdown"]
|
||||||
|
rel = body["bindings"][0]["external_id"]
|
||||||
|
assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/")
|
||||||
|
r = await c.post(
|
||||||
|
f"/api/conversations/{master}/bind",
|
||||||
|
json={"frontend": "markdown", "external_id": "x.md"},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
path = stack.vault / rel
|
||||||
|
text = await stack.wait_file(path, "ok:[сид: brief]")
|
||||||
|
post = frontmatter.loads(text)
|
||||||
|
assert post.metadata == {"agent": "d", "conversation_id": body["id"]}
|
||||||
|
assert post.content.startswith("### User:\n\n[сид: brief] deep «Тема», ")
|
||||||
|
assert post.content.rstrip().endswith("### User:")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_markdown_chat_rejects_dispatcher(stack: Stack) -> None:
|
||||||
|
async with stack.client(stack.markdown) as c:
|
||||||
|
r = await c.post(
|
||||||
|
"/chat",
|
||||||
|
json={"filename": "x.md", "agent": "a", "content": "### User:\n\nhi\n"},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "does not serve kind 'deep'" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_tool_reports_bad_pair(stack: Stack) -> None:
|
||||||
|
master = await stack.world.conversations.create(
|
||||||
|
kind="master", agent="a", origin="test"
|
||||||
|
)
|
||||||
|
spawn = next(
|
||||||
|
t
|
||||||
|
for t in _tools(stack.world.conversations, master.external_id)
|
||||||
|
if t.name == "spawn"
|
||||||
|
)
|
||||||
|
out = await spawn.handler({"kind": "deep", "agent": "a"})
|
||||||
|
assert (
|
||||||
|
out.get("is_error")
|
||||||
|
and "does not serve kind 'deep'" in out["content"][0]["text"]
|
||||||
|
)
|
||||||
|
out = await spawn.handler({"kind": "deep", "seed": "brief", "text": "go"})
|
||||||
|
assert not out.get("is_error")
|
||||||
|
conv_id = out["content"][0]["text"].split()[-1]
|
||||||
|
path = await stack.file_of(conv_id)
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_anthropic_turns_become_one_deep_conversation(stack: Stack) -> None:
|
||||||
|
async with stack.client(stack.anthropic) as c:
|
||||||
|
r = await c.post(
|
||||||
|
"/v1/messages",
|
||||||
|
json={"model": "a", "messages": [{"role": "user", "content": "hi"}]},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
first = [{"role": "user", "content": "first question"}]
|
||||||
|
r = await c.post(
|
||||||
|
"/v1/messages", json={"model": "d", "messages": first}, headers=AUTH
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
reply = r.json()["content"][0]["text"]
|
||||||
|
assert reply == "ok:first question"
|
||||||
|
convs = await stack.world.conversations.find(kind="deep")
|
||||||
|
assert len(convs) == 1
|
||||||
|
conv = convs[0]
|
||||||
|
assert conv.title == "first question" and conv.frontend == "anthropic"
|
||||||
|
path = await stack.file_of(conv.external_id)
|
||||||
|
text = await stack.wait_file(path, "ok:first question")
|
||||||
|
assert frontmatter.loads(text).metadata == {
|
||||||
|
"agent": "d",
|
||||||
|
"conversation_id": conv.external_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
second = [
|
||||||
|
*first,
|
||||||
|
{"role": "assistant", "content": reply},
|
||||||
|
{"role": "user", "content": "second"},
|
||||||
|
]
|
||||||
|
r = await c.post(
|
||||||
|
"/v1/messages",
|
||||||
|
json={"model": "d", "messages": second, "stream": True},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "ok:second" in r.text
|
||||||
|
assert len(await stack.world.conversations.find(kind="deep")) == 1
|
||||||
|
assert len(ScriptedClient.instances) == 1
|
||||||
|
assert ScriptedClient.instances[0].prompts == ["first question", "second"]
|
||||||
|
text = await stack.wait_file(path, "ok:second")
|
||||||
|
assert text.count("### User:") == 3 and text.count("### Assistant:") == 2
|
||||||
|
bindings = await stack.world.conversations.bindings(conv)
|
||||||
|
assert [b.frontend for b in bindings if b.visible] == ["markdown", "anthropic"]
|
||||||
|
async with stack.world.db.session() as session:
|
||||||
|
stored = await load_messages(session, conversation_id=conv.id)
|
||||||
|
assert [m["role"] for m in stored] == ["user", "assistant", "user", "assistant"]
|
||||||
|
assert stored[-1]["content"] == [{"type": "text", "text": "ok:second"}]
|
||||||
|
|
||||||
|
r = await c.post(
|
||||||
|
"/v1/messages",
|
||||||
|
json={
|
||||||
|
"model": "a",
|
||||||
|
"messages": [
|
||||||
|
*second,
|
||||||
|
{"role": "assistant", "content": "ok:second"},
|
||||||
|
{"role": "user", "content": "x"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=AUTH,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400 and "runs on 'd'" in r.json()["detail"]
|
||||||
Reference in New Issue
Block a user