feat: add stateful conversation storage

This commit is contained in:
h
2026-05-21 12:27:11 +02:00
parent 4a405faf25
commit a83bec709d
6 changed files with 994 additions and 94 deletions
+50 -18
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Self
from claude_code_api import (
@@ -38,6 +39,7 @@ from claude_code_api import (
TextBlock,
ThinkingBlock,
ToolUseBlock,
synthesize_turn_messages,
)
from beaver_gateway.agents.claude import ClaudeAgent
@@ -65,7 +67,27 @@ if TYPE_CHECKING:
from beaver_gateway.core.events import MessageStreamEvent
__all__ = ["ClaudeCodeBackendAdapter"]
__all__ = ["ClaudeCodeBackendAdapter", "TurnCapture"]
@dataclass
class TurnCapture:
"""Side-channel sink for per-turn metadata.
Pass an instance via ``ClaudeCodeBackendAdapter.complete(capture=...)``.
After the stream finishes, :attr:`synthesized_messages` holds the
full assistant↔tool-result cycle (from
:func:`claude_code_api.synthesize_turn_messages`) — i.e. the exact
list of canonical Anthropic-shape messages claude-code-api stashed
the live session under. The markdown frontend uses this to write the
conversation history to its DB so a subsequent turn's prefix
fingerprint hits the same session.
Other backends (anthropic, raycast) ignore the kwarg — it lands in
their ``**options`` and is silently dropped.
"""
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
_CLAUDE_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
@@ -185,10 +207,7 @@ class ClaudeCodeBackendAdapter:
"""
def __init__(
self,
*,
agent: ClaudeAgent,
mcp_internal_urls: Mapping[str, str],
self, *, agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
) -> None:
self._agent = agent
self._backend = ClaudeCodeBackend(
@@ -207,9 +226,7 @@ class ClaudeCodeBackendAdapter:
await self._backend.__aenter__()
return self
async def __aexit__(
self, exc_type: object, exc: object, tb: object
) -> None:
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
await self._backend.__aexit__(exc_type, exc, tb)
async def aclose(self) -> None:
@@ -221,6 +238,7 @@ class ClaudeCodeBackendAdapter:
agent: BaseAgent,
messages: Iterable[MessageParam],
system: str | None = None, # noqa: ARG002 — see module docstring
capture: TurnCapture | None = None,
**options: Any, # noqa: ARG002 — no per-request knobs for claude-code yet
) -> AsyncIterator[MessageStreamEvent]:
if not isinstance(agent, ClaudeAgent):
@@ -245,27 +263,43 @@ class ClaudeCodeBackendAdapter:
next_index = 0
stop_reason: str | None = None
usage: Mapping[str, Any] | None = None
# We keep raw events so we can hand them to
# ``synthesize_turn_messages`` after the stream closes — the
# markdown frontend stores the result in its conversation
# history so the next turn's prefix matches the backend's
# session-pool fingerprint. UserMessage (tool_result) events
# are silently discarded from the wire but kept here.
raw_events: list[Any] = []
async for event in self._backend.complete(list(messages)):
raw_events.append(event)
if isinstance(event, AssistantMessage):
for block in event.content:
for ev in _emit_block(block, next_index):
yield ev
next_index += 1
elif isinstance(event, ResultMessage):
# ResultMessage is the terminal event from TurnManager
# — we capture its stop_reason / usage for the envelope
# below. We DO NOT break here: an early break would
# raise GeneratorExit inside claude-code-api's
# ``complete`` coroutine before it gets a chance to
# stash the live session under the post-turn
# fingerprint, so every continuation would miss the
# cache and reseed. Let the inner generator exit
# naturally instead.
stop_reason = event.stop_reason
usage = event.usage
# ResultMessage is always last (TurnManager synthesizes
# it as the terminal event), so we break after emitting
# the envelope close.
break
# UserMessage (tool_result records) and SystemMessage
# (turn_duration heartbeats) carry no content for the
# /v1/messages caller — skip silently.
# /v1/messages caller — skip silently on the wire, but they
# ARE retained in ``raw_events`` for synthesis below.
if capture is not None:
capture.synthesized_messages = synthesize_turn_messages(raw_events)
yield build_message_delta(
stop_reason=_map_stop_reason(stop_reason),
usage=_normalize_usage(usage),
stop_reason=_map_stop_reason(stop_reason), usage=_normalize_usage(usage)
)
yield build_message_stop()
@@ -292,9 +326,7 @@ def _emit_block(
build_content_block_stop(index),
)
if isinstance(block, ToolUseBlock):
partial = json.dumps(
block.input, separators=(",", ":"), ensure_ascii=False
)
partial = json.dumps(block.input, separators=(",", ":"), ensure_ascii=False)
return (
build_tool_use_block_start(index, tool_use_id=block.id, name=block.name),
build_input_json_delta(index, partial),