feat: add stateful conversation storage
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user