refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README

This commit is contained in:
hh committed 2026-09-02 00:33:04 +02:00
1 parent 3915ab48f9
commit 0e07e8409d
65 files changed
+795 -1891

No files matched your search

+34 -180
View File
@@ -1,41 +1,7 @@
"""Stateful conversation history for the markdown frontend.
"""Stored 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`.
Aligns the file the user is editing against the DB-stored Anthropic-shape
message history, splicing text edits or forking on structural changes.
"""
from __future__ import annotations
@@ -67,21 +33,12 @@ __all__ = [
]
# ---- 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`` is the backend input; ``persist_messages`` is history to
store before the new reply; ``divergence_index`` is where they diverged.
"""
messages: list[MessageParam]
@@ -95,9 +52,6 @@ class ForkOutcome:
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:
@@ -115,9 +69,7 @@ async def mint_conversation(
) -> 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.
Caller must persist the returned ``external_id`` so future requests can find it.
"""
row = Conversation(
frontend=frontend, external_id=str(uuid.uuid4()), agent_name=agent_name
@@ -144,9 +96,8 @@ async def load_messages(
) -> 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.
Each entry is a canonical Anthropic ``MessageParam`` dict, the same
shape fed to the backend on continuation.
"""
stmt = (
select(ConversationMessage)
@@ -164,10 +115,8 @@ async def load_messages(
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.
Drops ``tool_result.is_error: null`` — the API rejects null there
though the field is optional.
"""
if not isinstance(content, list):
return content
@@ -188,27 +137,17 @@ def _sanitize_content(content: Any) -> Any:
async def rewrite_messages(
session: AsyncSession, *, conversation_id: int, messages: list[dict[str, Any]]
) -> None:
"""Replace the conversation's stored messages with ``messages``.
"""Replace the conversation's stored messages (full overwrite, no branch history).
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.
Deletes and flushes before inserting — SQLAlchemy's default INSERT-before-DELETE
flush order would otherwise collide with ``uq_msg_conv_seq``.
"""
# 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(
@@ -218,7 +157,6 @@ async def rewrite_messages(
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
@@ -228,21 +166,11 @@ async def rewrite_messages(
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.
Parser-equivalent view used to diff the file against the DB.
"""
role: str
@@ -255,10 +183,8 @@ class _StoredDisplayTurn:
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.
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
tool-result-only messages roll into the current assistant turn.
"""
out: list[_StoredDisplayTurn] = []
i = 0
@@ -277,7 +203,6 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
)
i += 1
continue
# Assistant display turn: collect consecutive non-prompt messages.
group: list[dict[str, Any]] = []
while i < len(stored):
m = stored[i]
@@ -299,14 +224,16 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
def _is_user_prompt(content: Any) -> bool:
"""A user message is a *prompt* unless its content carries tool_result blocks."""
"""A user message is a *prompt* unless its content carries tool_result blocks.
Unknown content shapes are conservatively treated as a prompt.
"""
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
@@ -328,13 +255,9 @@ def _summarize_assistant_group(
) -> 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.
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
one segment, tool_use becomes a skeleton entry, tool_result/thinking 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] = []
@@ -351,7 +274,6 @@ def _summarize_assistant_group(
for msg in group:
if msg["role"] == "user":
# tool_result message — boundary for text but emits no segment.
_flush()
continue
content = msg.get("content")
@@ -368,7 +290,6 @@ def _summarize_assistant_group(
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()
@@ -377,30 +298,14 @@ def _summarize_assistant_group(
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.
``incoming`` must end with a user turn (the new prompt); raises otherwise.
Segment-class imports below are deferred to avoid a cycle with ``parser``.
"""
# ``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 "
@@ -417,16 +322,10 @@ def diff_and_fork(
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(
@@ -448,26 +347,9 @@ def _file_lags_store(
) -> 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.
Tell: if the DB already holds the submitted prompt at the position the
file stops at, it's a lagging render, not a deletion — misjudging this
forks history, breaks the fingerprint, and respawns the backend session.
"""
if prior_len >= len(stored_groups):
return False
@@ -480,12 +362,8 @@ def _walk_prefix(
) -> 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.
An empty incoming skeleton (no tool callouts rendered) means prose
alone decides the match; a mismatch forces a fresh backend session.
"""
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
@@ -506,8 +384,6 @@ def _walk_prefix(
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:
@@ -576,21 +452,10 @@ def _splice_in_place(
) -> 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.
Preserves message/thinking-block boundaries that a rebuild would lose
(and that a cache-hit continuation depends on); returns ``None`` if
text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead.
"""
# See ``diff_and_fork`` for why the parser-type import is deferred.
from beaver_gateway.frontends.markdown.parser import TextSegment
new_texts = [
@@ -626,18 +491,9 @@ def _splice_by_rebuild(
) -> 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.
Matches tool_use blocks to incoming ``ToolSegment``s by position; returns
``None`` (caller forks) if a matching stored ``tool_result`` is missing.
"""
# 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)
@@ -664,8 +520,6 @@ def _splice_by_rebuild(
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