feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation
This commit is contained in:
@@ -564,7 +564,7 @@ def _collect_pty_sessions(runtime: GatewayRuntime) -> list[dict[str, Any]]:
|
||||
"""Enumerate live PTY sessions across all backends.
|
||||
|
||||
A backend qualifies if it exposes a ``live_sessions`` mapping
|
||||
(currently only ``ClaudeCodeBackendAdapter``). Other backend types
|
||||
(none since the SDK backend; kept for M1b). Other backend types
|
||||
are quietly skipped — the admin terminal viewer only makes sense for
|
||||
PTY-backed agents.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class GatewayRuntime:
|
||||
the name in hand, so the indirection lives one step earlier.
|
||||
|
||||
``mcp_internal_urls`` is filled in Phase 2.1: one loopback URL per
|
||||
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
|
||||
declared ``McpServer`` so ``ClaudeSdkBackend``
|
||||
can pass them to ``BackendOptions.mcp_servers`` without re-running
|
||||
discovery.
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter, TurnCapture
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.conversation_store import (
|
||||
diff_and_fork,
|
||||
@@ -49,7 +48,9 @@ from beaver_gateway.core.conversation_store import (
|
||||
load_messages,
|
||||
mint_conversation,
|
||||
rewrite_messages,
|
||||
set_session_id,
|
||||
)
|
||||
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
|
||||
@@ -312,8 +313,8 @@ class MarkdownFrontend(Frontend):
|
||||
content_override: Any,
|
||||
agent_override: str | None,
|
||||
) -> Any:
|
||||
write_disk = content_override is None
|
||||
if isinstance(content_override, str):
|
||||
await _write_atomic(file_path, content_override)
|
||||
file_text = content_override
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
@@ -381,22 +382,18 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
# Resolve / mint the conversation row, align incoming against
|
||||
# stored history, and feed the aligned messages to the backend
|
||||
# — see ``core/conversation_store.py`` for the full rationale.
|
||||
# If the backend isn't claude-code (no ``TurnCapture`` support)
|
||||
# we fall through to the legacy parser-only path.
|
||||
# - see ``core/conversation_store.py`` for the full rationale.
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
|
||||
)
|
||||
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
|
||||
capture: TurnCapture | None = (
|
||||
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if capture is not None:
|
||||
kwargs["capture"] = capture
|
||||
capture = TurnCapture()
|
||||
events = backend.complete(
|
||||
agent=agent, messages=outcome.messages, system=None, **kwargs
|
||||
agent=agent,
|
||||
messages=outcome.messages,
|
||||
system=None,
|
||||
capture=capture,
|
||||
**_session_options(conv, outcome.divergence_index),
|
||||
)
|
||||
try:
|
||||
message = await self._stream_to_file(
|
||||
@@ -405,6 +402,7 @@ class MarkdownFrontend(Frontend):
|
||||
parsed=parsed,
|
||||
model=agent.model or agent.name,
|
||||
filename=filename,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -419,6 +417,7 @@ class MarkdownFrontend(Frontend):
|
||||
message=message,
|
||||
agent_name=agent.name,
|
||||
conv_external_id=conv_external_id,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
|
||||
await self._persist_canonical_history(
|
||||
@@ -480,12 +479,10 @@ class MarkdownFrontend(Frontend):
|
||||
writers in their respective halves of Obsidian Sync. Final
|
||||
content is identical on both sides, so Sync no-ops.
|
||||
"""
|
||||
# File-text resolution + early bailouts. ``content_override`` is
|
||||
# still written to disk on the gateway side because that's the
|
||||
# state the rest of the request consumes; it just doesn't keep
|
||||
# ticking after that single write.
|
||||
# With ``content`` the plugin is the only writer of the file
|
||||
# (§3.10): the gateway never touches disk in that case.
|
||||
write_disk = content_override is None
|
||||
if isinstance(content_override, str):
|
||||
await _write_atomic(file_path, content_override)
|
||||
file_text = content_override
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
@@ -598,21 +595,19 @@ class MarkdownFrontend(Frontend):
|
||||
len(outcome.messages),
|
||||
len(outcome.persist_messages),
|
||||
)
|
||||
capture: TurnCapture | None = (
|
||||
TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if capture is not None:
|
||||
kwargs["capture"] = capture
|
||||
capture = TurnCapture()
|
||||
_log.info(
|
||||
"chat/stream: file=%s calling backend.complete agent=%s capture=%s",
|
||||
"chat/stream: file=%s calling backend.complete agent=%s session=%s",
|
||||
filename,
|
||||
agent.name,
|
||||
capture is not None,
|
||||
conv.session_id,
|
||||
)
|
||||
events = backend.complete(
|
||||
agent=agent, messages=outcome.messages, system=None, **kwargs
|
||||
agent=agent,
|
||||
messages=outcome.messages,
|
||||
system=None,
|
||||
capture=capture,
|
||||
**_session_options(conv, outcome.divergence_index),
|
||||
)
|
||||
|
||||
acc = StreamAccumulator()
|
||||
@@ -663,9 +658,10 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
yield _sse_pack(
|
||||
"error",
|
||||
{
|
||||
@@ -683,6 +679,7 @@ class MarkdownFrontend(Frontend):
|
||||
message=message,
|
||||
agent_name=agent.name,
|
||||
conv_external_id=conv_external_id,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
|
||||
await self._persist_canonical_history(
|
||||
@@ -727,6 +724,7 @@ class MarkdownFrontend(Frontend):
|
||||
parsed: parser.ParsedFile,
|
||||
model: str,
|
||||
filename: str,
|
||||
write_disk: bool = True,
|
||||
) -> Any:
|
||||
"""Drain ``events`` into a ``Message``, flushing partials to disk.
|
||||
|
||||
@@ -745,6 +743,8 @@ class MarkdownFrontend(Frontend):
|
||||
acc = StreamAccumulator()
|
||||
|
||||
async def flush_partial() -> None:
|
||||
if not write_disk:
|
||||
return
|
||||
partial = acc.finalize(model=model)
|
||||
if not partial.content:
|
||||
return
|
||||
@@ -774,9 +774,10 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
raise
|
||||
return acc.finalize(model=model)
|
||||
|
||||
@@ -788,8 +789,9 @@ class MarkdownFrontend(Frontend):
|
||||
message: Any,
|
||||
agent_name: str,
|
||||
conv_external_id: str,
|
||||
write_disk: bool = True,
|
||||
) -> str:
|
||||
"""Render the assistant turn, append to the file, refresh frontmatter."""
|
||||
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
|
||||
rendered = renderer.render_assistant_message(message)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
|
||||
@@ -806,7 +808,8 @@ class MarkdownFrontend(Frontend):
|
||||
updated_metadata["conversation_id"] = conv_external_id
|
||||
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
|
||||
new_content = _reattach_frontmatter(updated_metadata, new_body)
|
||||
await _write_atomic(file_path, new_content)
|
||||
if write_disk:
|
||||
await _write_atomic(file_path, new_content)
|
||||
return new_content
|
||||
|
||||
async def _resolve_conversation(
|
||||
@@ -867,21 +870,17 @@ class MarkdownFrontend(Frontend):
|
||||
conversation_id: int,
|
||||
persist_messages: list[dict[str, Any]],
|
||||
new_user_text: str,
|
||||
capture: TurnCapture | None,
|
||||
capture: TurnCapture,
|
||||
message: Any,
|
||||
) -> None:
|
||||
"""Stamp the DB with the post-turn canonical Anthropic-shape history.
|
||||
|
||||
Combines the matched/spliced prior state, the new user prompt,
|
||||
and the synthesized assistant↔tool cycle from the backend (or
|
||||
a text-only fallback for backends without ``TurnCapture``).
|
||||
and the synthesized assistant/tool cycle from the backend (or a
|
||||
text-only fallback for backends that left ``capture`` empty).
|
||||
"""
|
||||
new_user_msg = {"role": "user", "content": new_user_text}
|
||||
synthesized = (
|
||||
capture.synthesized_messages
|
||||
if capture is not None
|
||||
else _fallback_synthesized(message)
|
||||
)
|
||||
synthesized = capture.synthesized_messages or _fallback_synthesized(message)
|
||||
canonical = [*persist_messages, new_user_msg, *synthesized]
|
||||
_log.info(
|
||||
"_persist_canonical_history: conv_id=%d writing %d msgs "
|
||||
@@ -895,6 +894,12 @@ class MarkdownFrontend(Frontend):
|
||||
await rewrite_messages(
|
||||
session, conversation_id=conversation_id, messages=canonical
|
||||
)
|
||||
if capture.session_id is not None:
|
||||
await set_session_id(
|
||||
session,
|
||||
conversation_id=conversation_id,
|
||||
session_id=capture.session_id,
|
||||
)
|
||||
_log.info(
|
||||
"_persist_canonical_history: conv_id=%d DB committed", conversation_id
|
||||
)
|
||||
@@ -922,6 +927,19 @@ class MarkdownFrontend(Frontend):
|
||||
# ---- module-level utilities ----------------------------------------------
|
||||
|
||||
|
||||
def _session_options(conv: Any, divergence_index: int | None) -> dict[str, Any]:
|
||||
"""Backend options that pin the turn to the conversation's live session.
|
||||
|
||||
A divergence means the file's history no longer matches what the
|
||||
session saw, so the stored ``session_id`` is withheld and the backend
|
||||
seeds a fresh one from the aligned messages.
|
||||
"""
|
||||
return {
|
||||
"conversation_id": conv.external_id,
|
||||
"session_id": conv.session_id if divergence_index is None else None,
|
||||
}
|
||||
|
||||
|
||||
async def _events_with_heartbeat(
|
||||
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
|
||||
) -> AsyncIterator[Any]:
|
||||
|
||||
Reference in New Issue
Block a user