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
@@ -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