refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text

This commit is contained in:
hh
2026-09-02 00:13:20 +02:00
parent b96714338f
commit cae2ed4161
77 changed files with 2987 additions and 2944 deletions
@@ -33,7 +33,7 @@ from anthropic.types import (
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from beaver_gateway.core.events import MessageStreamEvent, StopReason
from beaver_gateway.events.stream import MessageStreamEvent, StopReason
__all__ = ["StreamAccumulator", "accumulate"]
@@ -27,8 +27,8 @@ import itsdangerous
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import FileResponse, JSONResponse, Response
from beaver_gateway.core import audit
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.security import audit
if TYPE_CHECKING:
from beaver_gateway.frontends.base import GatewayRuntime
+8 -8
View File
@@ -25,21 +25,21 @@ from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.core import audit
from beaver_gateway.core.transcript import fingerprint, text_of
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import fingerprint, text_of
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
from anthropic.types import Message, MessageParam
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
+11 -11
View File
@@ -29,20 +29,20 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from sqlmodel import col, select
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.conversations import SEEDS, implied_title
from beaver_gateway.core.injects import URGENCY
from beaver_gateway.core.kinds import Kind, as_kind
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import Kind, as_kind
from beaver_gateway.conversations.service import SEEDS, implied_title
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends._urls import frontend_url
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.urls import frontend_url
from beaver_gateway.security import audit
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
from beaver_gateway.storage import (
create_token,
list_audit_records,
@@ -61,9 +61,9 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence
from pathlib import Path
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.scheduler import Job, Scheduler
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.jobs.scheduler import Job, Scheduler
_log = logging.getLogger("beaver_gateway.frontends.api")
+4 -4
View File
@@ -21,11 +21,11 @@ if TYPE_CHECKING:
from starlette.types import ASGIApp
from beaver_gateway.app import AgentRegistry, McpRegistry
from beaver_gateway.backends.base import Backend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.kinds import Kind
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation, ConversationBinding
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
from anthropic.types import MessageParam
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends.turn_record import TurnRecord
_log = logging.getLogger("beaver_gateway.frontends.markdown.crossfront")
@@ -45,23 +45,10 @@ from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.core import audit
from beaver_gateway.core.conversation_store import (
diff_and_fork,
load_messages,
rewrite_messages,
)
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.markdown import parser, renderer
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
from beaver_gateway.frontends.markdown.files import (
@@ -69,12 +56,25 @@ from beaver_gateway.frontends.markdown.files import (
reattach_frontmatter,
write_atomic,
)
from beaver_gateway.frontends.markdown.history import (
diff_and_fork,
load_messages,
rewrite_messages,
)
from beaver_gateway.frontends.markdown.mirror import FRONTEND, ChatMirror
from beaver_gateway.frontends.sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
from beaver_gateway.core.kinds import Kind
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
@@ -0,0 +1,696 @@
"""Stateful conversation history for the markdown frontend.
The gateway used to be stateless about identity: claude-code-api's
in-memory session pool was keyed by a fingerprint of the messages the
gateway forwarded, and on a fingerprint miss the same fingerprint was
used to seed a fresh PTY's JSONL transcript. That worked as long as
the frontend could round-trip the *exact* content blocks the live
session had observed. The markdown frontend can't — the parser strips
``[!tool]-`` callouts because the human is allowed to edit the prose,
and the rendered tool callouts don't carry the canonical ``tool_use``
block fields anyway. So a continuation hit was *only* reliable for
turns that never used a tool; once tools entered the picture, every
subsequent turn missed the cache and reseeded from a tool-less
transcript, leading to "assistant doesn't remember the tool calls it
just made."
This module makes the gateway stateful for the markdown frontend (and
any other frontend that wants in). The DB stores the full
Anthropic-shape message list — text blocks, ``tool_use`` blocks,
``tool_result`` blocks, thinking signatures — exactly as
claude-code-api would have seen on the wire. Before each turn we
align the file the user is editing against the stored history:
* If the user just appended a new user turn at the bottom, we feed
the backend our stored-plus-new history and the fingerprint hits.
* If the user edited the *text* inside an assistant turn but left the
tool callouts alone, we splice the new text into the stored
``tool_use`` blocks and feed *that* — the fingerprint misses (text
differs), claude-code-api reseeds with a full transcript (tools and
all), the new live session has memory of the prior tool calls.
* If the user changed the *structure* (added/removed/reordered a tool
callout, edited an old user turn, etc.) we fork: take stored history
up to the divergence, take incoming text-only past the divergence.
The fingerprint misses; claude-code-api reseeds with a clean
truncated history; downstream turns continue from there.
"Divergence point" is found by walking the file's turns and the
stored display turns in lockstep. See :func:`diff_and_fork`.
"""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import delete
from sqlmodel import select
from beaver_gateway.storage.models import Conversation, ConversationMessage
if TYPE_CHECKING:
from anthropic.types import MessageParam
from sqlmodel.ext.asyncio.session import AsyncSession
from beaver_gateway.frontends.markdown.parser import ParsedTurn
__all__ = [
"ForkOutcome",
"diff_and_fork",
"load_conversation",
"load_messages",
"mint_conversation",
"rewrite_messages",
"set_session_id",
]
# ---- types --------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ForkOutcome:
"""Result of aligning the incoming file against stored history.
``messages`` is what the gateway feeds to the backend (already
includes the new user prompt at the tail). ``persist_messages``
is the canonical conversation state the gateway should hold in
the DB *up to but not including* the new assistant reply — the
caller appends the synthesized turn from the backend onto this
and writes the result back. ``divergence_index`` is the
display-turn index at which incoming first disagreed with stored
(``None`` if everything matched; the new tail is appended cleanly).
"""
messages: list[MessageParam]
persist_messages: list[dict[str, Any]]
divergence_index: int | None
edited: bool = False
"""An earlier assistant turn's prose was rewritten in the file."""
@property
def reuse_session(self) -> bool:
return self.divergence_index is None and not self.edited
# ---- public store API ---------------------------------------------------
async def load_conversation(
session: AsyncSession, *, frontend: str, external_id: str
) -> Conversation | None:
stmt = (
select(Conversation)
.where(Conversation.frontend == frontend)
.where(Conversation.external_id == external_id)
)
result = await session.exec(stmt)
return result.first()
async def mint_conversation(
session: AsyncSession, *, frontend: str, agent_name: str
) -> Conversation:
"""Create a fresh conversation row with a new uuid for external_id.
Caller is responsible for persisting the returned ``external_id`` on
the frontend side (frontmatter, response header, …) so future
requests can find this conversation again.
"""
row = Conversation(
frontend=frontend, external_id=str(uuid.uuid4()), agent_name=agent_name
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def set_session_id(
session: AsyncSession, *, conversation_id: int, session_id: str | None
) -> None:
conv = await session.get(Conversation, conversation_id)
if conv is None or conv.session_id == session_id:
return
conv.session_id = session_id
session.add(conv)
await session.commit()
async def load_messages(
session: AsyncSession, *, conversation_id: int
) -> list[dict[str, Any]]:
"""Return stored messages ordered by ``seq`` ascending.
Each entry is a canonical Anthropic ``MessageParam`` dict — ``role``
plus ``content`` (string or list of block dicts). The same shape
we feed to the backend on continuation.
"""
stmt = (
select(ConversationMessage)
.where(ConversationMessage.conversation_id == conversation_id)
.order_by(ConversationMessage.seq.asc()) # ty: ignore[unresolved-attribute]
)
result = await session.exec(stmt)
rows = result.all()
return [
{"role": r.role, "content": _sanitize_content(json.loads(r.content_json))}
for r in rows
]
def _sanitize_content(content: Any) -> Any:
"""Strip wire-illegal fields from stored Anthropic content blocks.
Older capture code emitted ``"is_error": null`` on ``tool_result``
blocks; the Anthropic API rejects null there (the field is optional
but, when present, must be boolean). We omit the key on read so
historical rows don't break continuation.
"""
if not isinstance(content, list):
return content
cleaned: list[Any] = []
for blk in content:
out_blk = blk
if (
isinstance(blk, dict)
and blk.get("type") == "tool_result"
and blk.get("is_error") is None
and "is_error" in blk
):
out_blk = {k: v for k, v in blk.items() if k != "is_error"}
cleaned.append(out_blk)
return cleaned
async def rewrite_messages(
session: AsyncSession, *, conversation_id: int, messages: list[dict[str, Any]]
) -> None:
"""Replace the conversation's stored messages with ``messages``.
The user said no branch history — we overwrite on fork. Cheap at
our volume; if it ever matters we can switch to soft-delete +
branch pointers.
"""
# Bulk-delete and flush before inserting the new sequence: SQLAlchemy's
# unit-of-work flushes INSERTs before DELETEs by default, which would
# trip ``uq_msg_conv_seq`` when the new rows reuse the same seq numbers
# as the soon-to-be-deleted ones.
# SQLModel descriptors resolve to ColumnElement at runtime but to bare
# ``int`` in ty's stubs; the select-path at line 135 lives behind sqlmodel's
# own ``select`` overloads that hide it, but ``sqlalchemy.delete().where``
# uses the raw stubs.
await session.execute( # ty: ignore[deprecated]
delete(ConversationMessage).where(
ConversationMessage.conversation_id == conversation_id # ty: ignore[invalid-argument-type]
)
)
await session.flush()
# Insert the new sequence.
for seq, m in enumerate(messages):
session.add(
ConversationMessage(
conversation_id=conversation_id,
seq=seq,
role=str(m["role"]),
content_json=json.dumps(m["content"], separators=(",", ":")),
)
)
# Bump conversation.updated_at.
conv = await session.get(Conversation, conversation_id)
if conv is not None:
from datetime import UTC, datetime
conv.updated_at = datetime.now(UTC)
session.add(conv)
await session.commit()
# ---- alignment ----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class _StoredDisplayTurn:
"""A "display turn" reconstructed from stored raw messages.
``role`` is ``"user"`` (single user-prompt message) or
``"assistant"`` (one or more assistant messages, optionally
interleaved with user-only-tool_result messages). ``messages`` is
the slice of stored raw messages this display turn covers, in
order. ``spoken_text`` and ``skeleton`` are the
parser-equivalents for diff purposes; ``text_segment_count`` lets
us refuse a splice when the user edited across a tool boundary in
a way we can't safely undo.
"""
role: str
messages: tuple[dict[str, Any], ...]
spoken_text: str
skeleton: tuple[str, ...]
text_segment_count: int
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]:
"""Walk raw stored messages, group them into Obsidian-visible turns.
A user-prompt message (``role=user`` with string content, or list
content with no ``tool_result`` blocks) opens a user display turn.
Otherwise it's a tool-result follow-up and rolls into the current
assistant display turn.
"""
out: list[_StoredDisplayTurn] = []
i = 0
while i < len(stored):
msg = stored[i]
role = msg["role"]
if role == "user" and _is_user_prompt(msg.get("content")):
out.append(
_StoredDisplayTurn(
role="user",
messages=(msg,),
spoken_text=_user_prompt_text(msg.get("content")),
skeleton=(),
text_segment_count=0,
)
)
i += 1
continue
# Assistant display turn: collect consecutive non-prompt messages.
group: list[dict[str, Any]] = []
while i < len(stored):
m = stored[i]
if m["role"] == "user" and _is_user_prompt(m.get("content")):
break
group.append(m)
i += 1
spoken, skeleton, text_count = _summarize_assistant_group(group)
out.append(
_StoredDisplayTurn(
role="assistant",
messages=tuple(group),
spoken_text=spoken,
skeleton=tuple(skeleton),
text_segment_count=text_count,
)
)
return out
def _is_user_prompt(content: Any) -> bool:
"""A user message is a *prompt* unless its content carries tool_result blocks."""
if isinstance(content, str):
return True
if isinstance(content, list):
return not any(
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
)
# Unknown shape — be conservative, treat as prompt.
return True
def _user_prompt_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = [
str(b.get("text", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "text"
]
return "\n\n".join(c for c in chunks if c)
return ""
def _summarize_assistant_group(
group: list[dict[str, Any]],
) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Mirrors what ``parser.parse_assistant_structure`` would produce when
re-parsing the rendered version of this group: consecutive text
blocks across assistant messages collapse into one text segment;
tool_use blocks become skeleton entries; tool_result messages and
thinking blocks are invisible.
"""
# See ``diff_and_fork`` for why the parser-type imports are deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
segments: list[TextSegment | ToolSegment] = []
pending: list[str] = []
def _flush() -> None:
if not pending:
return
joined = "\n\n".join(p for p in pending if p)
pending.clear()
cleaned = joined.strip()
if cleaned:
segments.append(TextSegment(text=cleaned))
for msg in group:
if msg["role"] == "user":
# tool_result message — boundary for text but emits no segment.
_flush()
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for blk in content:
if not isinstance(blk, dict):
continue
btype = blk.get("type")
if btype == "text":
text = str(blk.get("text", "")).strip()
if text:
pending.append(text)
elif btype == "tool_use":
_flush()
segments.append(ToolSegment(name=str(blk.get("name", ""))))
# thinking: skip silently
_flush()
spoken_chunks = [s.text for s in segments if isinstance(s, TextSegment)]
spoken = "\n\n".join(c for c in spoken_chunks if c).strip()
skeleton = [s.name for s in segments if isinstance(s, ToolSegment)]
text_count = sum(1 for s in segments if isinstance(s, TextSegment))
return spoken, skeleton, text_count
# ---- the core algorithm -------------------------------------------------
def diff_and_fork(
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn]
) -> ForkOutcome:
"""Align the incoming parsed file against stored history.
``stored`` is the raw Anthropic-shape message list from the DB
(one entry per ``ConversationMessage`` row). ``incoming`` is the
user-visible turn list from the markdown parser. The last
``incoming`` entry must be a user turn — that's the new prompt
triggering this request.
Returns a :class:`ForkOutcome` whose ``messages`` is what the
backend should run on and whose ``persist_messages`` is the
canonical history to store in the DB once the backend's
synthesized cycle is appended.
"""
# ``parser`` lives under ``frontends/markdown/`` whose ``__init__``
# eagerly loads ``frontend.py``, which in turn imports this module
# — pulling the parser at module-import time creates a cycle. The
# helpers below import the segment classes lazily inside their own
# function bodies to break it.
if not incoming or incoming[-1].role != "user":
msg = (
"diff_and_fork expects incoming to end with a user turn "
"(the new prompt); got "
f"{incoming[-1].role if incoming else 'empty'}"
)
raise ValueError(msg)
stored_groups = _group_display_turns(stored)
new_user_turn = incoming[-1]
prior_incoming = incoming[:-1]
spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups)
if divergence is None and len(prior_incoming) < len(stored_groups):
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
# Not a deletion — the file simply never received turns we
# already ran. Adopt the stored tail verbatim so history stays
# structured and its fingerprint still matches the live
# session's. See ``_file_lags_store`` for why this matters.
spliced_groups.extend(
list(g.messages) for g in stored_groups[len(prior_incoming) :]
)
else:
# Incoming truncated stored (user deleted some past turns).
# Truncate stored to match.
divergence = len(prior_incoming)
backend_msgs, persist_msgs = _assemble_tail(
spliced_groups=spliced_groups,
prior_incoming=prior_incoming,
divergence=divergence,
new_user_turn=new_user_turn,
)
return ForkOutcome(
messages=backend_msgs,
persist_messages=persist_msgs,
divergence_index=divergence,
edited=edited,
)
def _file_lags_store(
stored_groups: list[_StoredDisplayTurn], prior_len: int, new_user_turn: ParsedTurn
) -> bool:
"""Is the shorter incoming file a stale view rather than a deletion?
A file with fewer display turns than the DB has two possible causes,
and they need opposite handling:
* the user deleted trailing turns — we should truncate to match;
* the turn ran, was persisted, but its reply never made it back into
the ``.md`` (the render lost a race with the user's next prompt, or
the reply rendered to nothing visible). The file is simply behind.
The tell is the prompt the user is submitting right now: if the DB
already holds it at exactly the position the file stops at, this is a
re-submission of a turn we've already run, not a deletion. Nobody
deletes a turn and immediately retypes it verbatim.
Getting this wrong is expensive and self-sustaining. Forking here
flattens every post-divergence turn into plain text (losing tool_use /
tool_result structure), persists that flattened history, and changes
the conversation fingerprint — so the backend's session pool misses,
spawns a fresh ``claude``, reseeds it from a multi-MB JSONL, and
strands the previous process. The file still lags afterwards, so the
next turn does it again.
"""
if prior_len >= len(stored_groups):
return False
candidate = stored_groups[prior_len]
return candidate.role == "user" and candidate.spoken_text == new_user_turn.text
def _walk_prefix(
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn]
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
"""Walk incoming vs stored side-by-side until first divergence.
Returns the spliced/matched group list (one entry per matched
display turn, each carrying the raw messages we'll feed to the
backend for that turn), the divergence index (``None`` if all
of ``prior_incoming`` matched) and whether any assistant prose
was spliced in from the file - a rewritten reply keeps the
structure but must not resume the session that said otherwise.
"""
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
spliced_groups: list[list[dict[str, Any]]] = []
edited = False
for i, inc in enumerate(prior_incoming):
if i >= len(stored_groups):
return spliced_groups, i, edited
st = stored_groups[i]
if inc.role != st.role:
return spliced_groups, i, edited
if inc.role == "user":
if inc.text != st.spoken_text:
return spliced_groups, i, edited
spliced_groups.append(list(st.messages))
continue
inc_skeleton = tuple(
s.name for s in inc.structure if isinstance(s, ToolSegment)
)
inc_text_count = sum(1 for s in inc.structure if isinstance(s, TextSegment))
# Files rendered without tool callouts (§3.10) carry no skeleton:
# prose alone decides whether the turn matched.
if inc_skeleton and inc_skeleton != st.skeleton:
return spliced_groups, i, edited
if inc.text == st.spoken_text:
spliced_groups.append(list(st.messages))
continue
if inc_skeleton and inc_text_count != st.text_segment_count:
return spliced_groups, i, edited
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
if spliced is None:
return spliced_groups, i, edited
spliced_groups.append(spliced)
edited = True
return spliced_groups, None, edited
def _assemble_tail(
*,
spliced_groups: list[list[dict[str, Any]]],
prior_incoming: list[ParsedTurn],
divergence: int | None,
new_user_turn: ParsedTurn,
) -> tuple[list[MessageParam], list[dict[str, Any]]]:
"""Build the (backend, persist) lists from aligned + post-divergence tail."""
backend_msgs: list[MessageParam] = []
persist_msgs: list[dict[str, Any]] = []
for spliced in spliced_groups:
for m in spliced:
entry: dict[str, Any] = {"role": m["role"], "content": m["content"]}
backend_msgs.append(cast("MessageParam", entry))
persist_msgs.append(entry)
if divergence is not None:
for inc in prior_incoming[divergence:]:
if not inc.text:
continue
entry = {"role": inc.role, "content": inc.text}
backend_msgs.append(cast("MessageParam", entry))
persist_msgs.append(entry)
backend_msgs.append({"role": "user", "content": new_user_turn.text})
return backend_msgs, persist_msgs
def _splice_assistant_group(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
) -> list[dict[str, Any]] | None:
"""Apply the file's prose to a stored assistant turn.
Prefers substituting text in place, which keeps everything the
markdown can't express. Falls back to rebuilding from the file's
structure when the text blocks don't line up one-to-one.
"""
preserved = _splice_in_place(stored_group=stored_group, incoming=incoming)
if preserved is not None:
return preserved
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
def _is_text_block(blk: object) -> bool:
if not isinstance(blk, dict):
return False
block = cast("dict[str, Any]", blk)
return block.get("type") == "text" and bool(str(block.get("text", "")).strip())
def _splice_in_place(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents.
Rebuilding a turn from the file loses everything the markdown never
carried — thinking blocks, and the message boundaries claude chose.
Both matter: an ``assistant[thinking] + assistant[text]`` pair (what
claude emits for a reasoning turn) collapses into a single message,
so the history is one message shorter than the one the backend
pooled its live session under, and the next turn misses the cache
and respawns. Substituting in place keeps the message count and the
invisible blocks exactly as stored.
Returns ``None`` when stored text blocks and incoming text segments
aren't one-to-one — consecutive text blocks merge into a single
rendered segment, so there'd be no way to know how to split the
edited prose back apart. The caller then rebuilds instead.
"""
# See ``diff_and_fork`` for why the parser-type import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
new_texts = [
s.text for s in incoming.structure if isinstance(s, TextSegment) and s.text
]
positions: list[tuple[int, int]] = []
for mi, msg in enumerate(stored_group.messages):
content = msg.get("content")
if msg["role"] != "assistant" or not isinstance(content, list):
continue
positions.extend(
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk)
)
if len(positions) != len(new_texts):
return None
out: list[dict[str, Any]] = [
{
"role": m["role"],
"content": list(m["content"])
if isinstance(m.get("content"), list)
else m.get("content"),
}
for m in stored_group.messages
]
for (mi, bi), text in zip(positions, new_texts, strict=True):
out[mi]["content"][bi] = {**out[mi]["content"][bi], "text": text}
return out
def _splice_by_rebuild(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
) -> list[dict[str, Any]] | None:
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
Walks the incoming structure; for each ``TextSegment`` emits a
text block into the current assistant message; for each
``ToolSegment`` consumes the next stored ``tool_use`` block (by
position), closes the current assistant message, emits the
matching ``tool_result`` user message, and opens a new assistant
message. Final ``TextSegment`` closes the last assistant message.
Returns ``None`` if we can't find a matching tool_result for some
tool_use (stored history is malformed) — caller falls back to
fork.
"""
# See ``diff_and_fork`` for why this import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
tool_uses, tool_results_by_id = _harvest_tool_blocks(stored_group)
spliced: list[dict[str, Any]] = []
current_asst: list[dict[str, Any]] = []
next_tool = 0
for seg in incoming.structure:
if isinstance(seg, TextSegment):
if seg.text:
current_asst.append({"type": "text", "text": seg.text})
continue
if next_tool >= len(tool_uses):
return None
tu = tool_uses[next_tool]
next_tool += 1
current_asst.append(tu)
spliced.append({"role": "assistant", "content": current_asst})
current_asst = []
tr = tool_results_by_id.get(str(tu.get("id", "")))
if tr is None:
return None
spliced.append({"role": "user", "content": [tr]})
if current_asst:
spliced.append({"role": "assistant", "content": current_asst})
elif not spliced:
# Defensive: assistant turn with no text and no tools makes no
# sense; caller will treat as fork.
return None
return spliced
def _harvest_tool_blocks(
stored_group: _StoredDisplayTurn,
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""Pull stored ``tool_use`` blocks (ordered) and ``tool_result`` blocks (by id)."""
tool_uses: list[dict[str, Any]] = []
tool_results_by_id: dict[str, dict[str, Any]] = {}
for msg in stored_group.messages:
content = msg.get("content")
if not isinstance(content, list):
continue
if msg["role"] == "assistant":
tool_uses.extend(
blk
for blk in content
if isinstance(blk, dict) and blk.get("type") == "tool_use"
)
continue
for blk in content:
if not isinstance(blk, dict) or blk.get("type") != "tool_result":
continue
tid = blk.get("tool_use_id")
if isinstance(tid, str):
tool_results_by_id[tid] = blk
return tool_uses, tool_results_by_id
@@ -19,8 +19,6 @@ 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 (
@@ -28,12 +26,14 @@ from beaver_gateway.frontends.markdown.files import (
reattach_frontmatter,
write_atomic,
)
from beaver_gateway.frontends.markdown.history import load_messages, rewrite_messages
from beaver_gateway.frontends.turn_record import slugify
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from pathlib import Path
from beaver_gateway.core.bus import Event
from beaver_gateway.events.bus import Event
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
+2 -2
View File
@@ -45,10 +45,10 @@ from starlette.applications import Starlette
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route
from beaver_gateway.core import audit
from beaver_gateway.frontends._urls import external_base
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.urls import external_base
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
from beaver_gateway.security import audit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Mapping
@@ -44,9 +44,9 @@ from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import chunks, status_label
if TYPE_CHECKING:
from beaver_gateway.core.bus import Event, EventBus
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.kinds import Kind
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.bus import Event, EventBus
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation, ConversationBinding
@@ -773,7 +773,7 @@ class TelegramFrontend(Frontend):
draft = self._drafts.pop(conv.external_id, None)
if draft is not None:
await draft.finish(chunks(text)[-1] if text.strip() else "")
if origin != FRONTEND and not origin.startswith("сид"):
if origin != FRONTEND and not origin.startswith("seed"):
user_text = str(event.get("user_text") or "")
if user_text:
await self._deliver(
@@ -32,7 +32,7 @@ from beaver_gateway.storage.models import Delivery
if TYPE_CHECKING:
from aiogram import Bot
from beaver_gateway.core.bus import EventBus
from beaver_gateway.events.bus import EventBus
from beaver_gateway.storage.db import Database
__all__ = ["Outbox"]
+133
View File
@@ -0,0 +1,133 @@
"""Cross-frontend turn record.
Frontends that finish a turn (the Anthropic Messages frontend, the
markdown frontend) emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers``. The markdown frontend uses this
to persist chats from other frontends into the Obsidian vault — see
``frontends/markdown/crossfront.py``.
Kept tiny on purpose: it carries the structured-enough payload a logger
needs (which agent ran, input history, the assembled assistant reply)
and nothing else. The full event stream is gone by the time handlers
run — if a future consumer needs deltas it would subscribe at a lower
level, not here.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
__all__ = ["TurnRecord", "slugify"]
# Filesystem-safe slug: collapse anything that isn't a word char or
# space/hyphen to a hyphen, then squash runs of separators. Aimed at
# letting users build filenames from ``record.first_user_text`` without
# hand-rolling sanitization in every config.
_SLUG_BAD_RE = re.compile(r"[^\w\s\-]+", flags=re.UNICODE)
_SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
def slugify(text: str, *, maxlen: int = 40) -> str:
"""Sanitize ``text`` for use as a filename fragment.
Strips punctuation, collapses whitespace/hyphens into single ``-``,
and truncates to ``maxlen``. Returns ``"untitled"`` for empty input.
Unicode letters are preserved (Obsidian handles them fine; macOS
and modern Linux fs's too).
"""
cleaned = _SLUG_BAD_RE.sub(" ", text).strip()
cleaned = _SLUG_SEP_RE.sub("-", cleaned).strip("-")
if not cleaned:
return "untitled"
if len(cleaned) > maxlen:
cleaned = cleaned[:maxlen].rstrip("-")
return cleaned or "untitled"
@dataclass(frozen=True, slots=True)
class TurnRecord:
"""One completed turn, as seen by a frontend.
``input_messages`` is the conversation history sent to the backend
(everything *before* the assistant reply). ``output_message`` is the
finalized assistant ``Message`` (post-accumulation, with all content
blocks attached). ``system`` is the per-request system prompt if any
— agents own their own ``system_prompt``, this is the override the
caller passed; most handlers can ignore it.
``source`` names the frontend that produced the record so the
cross-frontend logger can avoid logging its own turns (markdown
frontend writing the file would otherwise also receive its own
broadcast and double-write).
"""
agent_name: str
input_messages: list[MessageParam]
output_message: Message
system: str | None = None
source: str = "anthropic"
@property
def first_user_text(self) -> str:
"""Plain text of the *earliest* user turn in this conversation.
Useful for naming new files by topic. Empty string if the input
history somehow has no user turn (shouldn't happen — turns are
broadcast only after a user → assistant cycle).
"""
for msg in self.input_messages:
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def last_user_text(self) -> str:
"""Plain text of the most recent user turn (the trigger)."""
for msg in reversed(self.input_messages):
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def assistant_text(self) -> str:
"""Plain text of the assistant reply, with thinking/tool blocks dropped."""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(self.output_message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _text_of(content: object) -> str:
"""Flatten an Anthropic ``MessageParam.content`` field to a plain string.
Handles both shapes the SDK accepts: raw string, or a list of block
dicts (we pull ``text`` blocks only). Anything we don't recognize is
silently skipped — these helpers exist for naming files, not for
faithful content reconstruction.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for blk in content:
if not isinstance(blk, dict):
continue
# ``MessageParam.content`` is typed as a union of typed-dicts
# per Anthropic SDK; we only care about plain ``text`` blocks
# and look them up via ``Any`` to dodge the keyed-typed-dict
# variance gymnastics (``ty`` won't let an open dict alias a
# closed-keyed one).
d: Any = blk
if d.get("type") == "text":
parts.append(str(d.get("text", "")))
return "\n\n".join(p for p in parts if p)
return ""