feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror
This commit is contained in:
@@ -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"])
|
||||
Reference in New Issue
Block a user