refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user