feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror
This commit is contained in:
@@ -38,14 +38,16 @@ if TYPE_CHECKING:
|
||||
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
|
||||
|
||||
|
||||
# User hook: take a turn + vault root, return where the new file should
|
||||
# live. Returning a relative ``Path`` is treated as relative to the
|
||||
# vault. ``None`` (the default) keeps the built-in
|
||||
# ``{vault}/{logged_subdir}/{agent}/{YYYY-MM-DD}_{hex8}.md`` layout.
|
||||
LogPathFn = "Callable[[TurnRecord, Path], Path]"
|
||||
__all__ = [
|
||||
"ChatPathFn",
|
||||
"CrossFrontendLogger",
|
||||
"fingerprint_messages",
|
||||
"strip_trailing_user_scaffold",
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["CrossFrontendLogger", "LogPathFn", "fingerprint_messages"]
|
||||
ChatPathFn = "Callable[[str, str, Path], Path]"
|
||||
"""``(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:
|
||||
@@ -96,18 +98,18 @@ class CrossFrontendLogger:
|
||||
*,
|
||||
vault_path: Path,
|
||||
logged_subdir: str,
|
||||
log_path: Callable[[TurnRecord, Path], Path] | None = None,
|
||||
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||
) -> None:
|
||||
self._vault = vault_path
|
||||
self._root = vault_path / logged_subdir
|
||||
self._index: dict[str, Path] = {}
|
||||
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
|
||||
# anywhere in the vault — so we have to scan the whole vault on
|
||||
# startup to rebuild the fingerprint→path map. With the default
|
||||
# layout we can bound the scan to ``_logs/``.
|
||||
self._scan_root = vault_path if 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:
|
||||
"""Scan logged files synchronously, populating the fingerprint map.
|
||||
@@ -171,7 +173,7 @@ class CrossFrontendLogger:
|
||||
if target.exists():
|
||||
existing = target.read_text(encoding="utf-8")
|
||||
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
|
||||
# input_messages, since prior turns are already on disk)
|
||||
# plus the assistant reply.
|
||||
@@ -206,13 +208,15 @@ class CrossFrontendLogger:
|
||||
def _new_file_path(self, record: TurnRecord) -> Path:
|
||||
"""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
|
||||
to ``{logged_subdir}/{agent}/{date}_{hex8}.md`` and ensure the
|
||||
``.md`` suffix in case the user picks a non-md extension by hand.
|
||||
"""
|
||||
if self._log_path_fn is not None:
|
||||
result = self._log_path_fn(record, self._vault)
|
||||
if self._chat_path_fn is not None:
|
||||
result = self._chat_path_fn(
|
||||
record.first_user_text, record.agent_name, self._vault
|
||||
)
|
||||
if not result.is_absolute():
|
||||
result = self._vault / result
|
||||
if result.suffix != ".md":
|
||||
@@ -270,7 +274,7 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
|
||||
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.
|
||||
|
||||
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
|
||||
``(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()``
|
||||
registers a handler on ``runtime.turn_log_handlers`` so every other
|
||||
@@ -30,17 +33,15 @@ shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import aiofile
|
||||
from anthropic.types import RawContentBlockStopEvent
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
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.markdown import parser, renderer
|
||||
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:
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
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")
|
||||
@@ -93,12 +100,13 @@ _STREAM_FLUSH_DEBOUNCE = 0.4
|
||||
# disk round-trip).
|
||||
_SSE_FLUSH_DEBOUNCE = 0.1
|
||||
|
||||
FRONTEND = "markdown"
|
||||
|
||||
|
||||
class MarkdownFrontend(Frontend):
|
||||
"""FastAPI app behind ``POST /chat`` driven by Obsidian-vault files."""
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -108,7 +116,7 @@ class MarkdownFrontend(Frontend):
|
||||
default_agent: str | None = None,
|
||||
log_all_chats: bool = False,
|
||||
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,
|
||||
) -> None:
|
||||
self.vault_path = Path(vault_path).expanduser().resolve()
|
||||
@@ -117,7 +125,7 @@ class MarkdownFrontend(Frontend):
|
||||
self.default_agent = default_agent
|
||||
self.log_all_chats = log_all_chats
|
||||
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
|
||||
# on the other bearer frontends. Trailing slash trimmed for
|
||||
# idempotent concatenation; ``None`` means "no proxy / advertise
|
||||
@@ -131,6 +139,7 @@ class MarkdownFrontend(Frontend):
|
||||
# request reliably loses the race to 409.
|
||||
self._busy: set[Path] = set()
|
||||
self._crossfront: CrossFrontendLogger | None = None
|
||||
self._mirror: ChatMirror | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
if runtime.conversations is None:
|
||||
@@ -138,11 +147,17 @@ class MarkdownFrontend(Frontend):
|
||||
raise RuntimeError(msg)
|
||||
self._runtime = runtime
|
||||
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:
|
||||
self._crossfront = CrossFrontendLogger(
|
||||
vault_path=self.vault_path,
|
||||
logged_subdir=self.logged_subdir,
|
||||
log_path=self.log_path,
|
||||
chat_path=self.chat_path,
|
||||
)
|
||||
# Scan the existing logged files synchronously here so the
|
||||
# fingerprint→path map is populated before the first
|
||||
@@ -151,6 +166,19 @@ class MarkdownFrontend(Frontend):
|
||||
runtime.turn_log_handlers.append(self._crossfront.handle)
|
||||
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:
|
||||
import uvicorn
|
||||
|
||||
@@ -161,7 +189,13 @@ class MarkdownFrontend(Frontend):
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
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 ---------------------------------------------------
|
||||
|
||||
@@ -309,7 +343,7 @@ class MarkdownFrontend(Frontend):
|
||||
if isinstance(content_override, str):
|
||||
file_text = content_override
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
file_text = await read_or_empty(file_path)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "`content` must be a string when present"
|
||||
@@ -480,7 +514,7 @@ class MarkdownFrontend(Frontend):
|
||||
if isinstance(content_override, str):
|
||||
file_text = content_override
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
file_text = await read_or_empty(file_path)
|
||||
else:
|
||||
yield sse_pack(
|
||||
"error",
|
||||
@@ -569,12 +603,18 @@ class MarkdownFrontend(Frontend):
|
||||
msgs=len(parsed.messages),
|
||||
)
|
||||
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime,
|
||||
metadata=parsed.metadata,
|
||||
agent_name=agent.name,
|
||||
file_path=file_path,
|
||||
)
|
||||
try:
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime,
|
||||
metadata=parsed.metadata,
|
||||
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(
|
||||
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
|
||||
"stored_msgs=%d incoming_turns=%d",
|
||||
@@ -619,7 +659,7 @@ class MarkdownFrontend(Frontend):
|
||||
return None
|
||||
rendered = renderer.render_assistant_message(partial)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
return _reattach_frontmatter(parsed.metadata, new_body)
|
||||
return reattach_frontmatter(parsed.metadata, new_body)
|
||||
|
||||
try:
|
||||
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))
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
yield sse_pack(
|
||||
"error",
|
||||
@@ -744,8 +784,8 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
rendered = renderer.render_assistant_message(partial)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -769,8 +809,8 @@ class MarkdownFrontend(Frontend):
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
raise
|
||||
return acc.finalize(model=model)
|
||||
@@ -793,9 +833,9 @@ class MarkdownFrontend(Frontend):
|
||||
updated_metadata.pop("fingerprint", None)
|
||||
updated_metadata["agent"] = agent_name
|
||||
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:
|
||||
await _write_atomic(file_path, new_content)
|
||||
await write_atomic(file_path, new_content)
|
||||
return new_content
|
||||
|
||||
async def _resolve_conversation(
|
||||
@@ -820,9 +860,12 @@ class MarkdownFrontend(Frontend):
|
||||
if conv is None:
|
||||
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
|
||||
if conv is None:
|
||||
conv = await conversations.create(
|
||||
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
|
||||
)
|
||||
try:
|
||||
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)
|
||||
bound = [
|
||||
b
|
||||
@@ -894,63 +937,6 @@ class MarkdownFrontend(Frontend):
|
||||
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]]:
|
||||
"""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"
|
||||
|
||||
|
||||
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:
|
||||
"""Render an assistant ``Message`` (with content blocks) into a turn block.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user