`ClaudeCodeOptions.warmup_turn` defaults to `None`, which decides per agent: on when the agent exposes any MCP, off otherwise. An agent whose tools are the point shouldn't answer its first message without them, and an agent with no MCPs shouldn't pay for a warm-up it gains nothing from. The underlying reason lives in claude-code-api: the CLI connects MCP servers when a turn starts and doesn't await them, so turn one runs with built-in tools only.
503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""Claude Code backend adapter.
|
|
|
|
One :class:`ClaudeCodeBackendAdapter` per :class:`ClaudeAgent`. The
|
|
underlying :class:`claude_code_api.ClaudeCodeBackend` bakes ``cwd`` /
|
|
``model`` / ``system_prompt`` / MCP wiring into a single
|
|
:class:`~claude_code_api.BackendOptions` at construction time, so a
|
|
single backend instance is conceptually bound to one agent (different
|
|
agents would mean different cwds / system prompts / exposed MCPs and
|
|
thus different live-session pools).
|
|
|
|
Per :meth:`complete` we:
|
|
|
|
* hand the full Anthropic-style ``messages`` list to
|
|
``ClaudeCodeBackend.complete`` — it does its own fingerprint-based
|
|
session lookup, so we never need to track sessions ourselves;
|
|
* turn its events into one ``message_start`` … ``message_stop``
|
|
envelope per ``complete`` call, with content-block indices increasing
|
|
monotonically across the whole turn;
|
|
* close the envelope on the ``ResultMessage``.
|
|
|
|
How the blocks inside that envelope are produced depends on the agent's
|
|
transport. By default each ``AssistantMessage`` is re-emitted as
|
|
``content_block_start`` + one delta + ``content_block_stop`` per block —
|
|
a "stream" of finished blocks, because the PTY transport has nothing
|
|
finer to offer. With ``transport="stream_json"`` *and*
|
|
``include_partial_messages``, the backend instead feeds us the real
|
|
token-level ``StreamEvent``s and we emit from those; the whole-block
|
|
records still arrive and are still kept for :class:`TurnCapture`, but
|
|
emitting from both would duplicate every block on the wire.
|
|
|
|
The per-request ``system`` parameter is intentionally **ignored** —
|
|
``BackendOptions.system_prompt`` is fixed at session-spawn time, and the
|
|
agent's ``system_prompt`` is the canonical identity of the agent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Mapping # runtime import: isinstance in _emit_stream_event
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Self
|
|
|
|
from claude_code_api import (
|
|
AssistantMessage,
|
|
BackendOptions,
|
|
ClaudeCodeBackend,
|
|
ResultMessage,
|
|
StreamEvent,
|
|
TextBlock,
|
|
ThinkingBlock,
|
|
ToolUseBlock,
|
|
synthesize_turn_messages,
|
|
)
|
|
|
|
from beaver_gateway.agents.claude import ClaudeAgent
|
|
from beaver_gateway.core.events import (
|
|
StopReason,
|
|
build_content_block_stop,
|
|
build_input_json_delta,
|
|
build_message_delta,
|
|
build_message_start,
|
|
build_message_stop,
|
|
build_signature_delta,
|
|
build_text_block_start,
|
|
build_text_delta,
|
|
build_thinking_block_start,
|
|
build_thinking_delta,
|
|
build_tool_use_block_start,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator, Iterable
|
|
|
|
from anthropic.types import MessageParam
|
|
|
|
from beaver_gateway.agents.base import BaseAgent
|
|
from beaver_gateway.core.events import MessageStreamEvent
|
|
|
|
|
|
_log = logging.getLogger("beaver_gateway.backends.claude_code")
|
|
|
|
|
|
__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] = {
|
|
"end_turn": "end_turn",
|
|
"tool_use": "tool_use",
|
|
"max_tokens": "max_tokens",
|
|
"stop_sequence": "stop_sequence",
|
|
"refusal": "refusal",
|
|
}
|
|
|
|
|
|
def _map_stop_reason(raw: str | None) -> StopReason:
|
|
"""Map claude-code's stop reason into Anthropic's vocabulary.
|
|
|
|
Unknown / missing values collapse to ``end_turn`` so the client sees
|
|
a clean finish rather than a wire-format error.
|
|
"""
|
|
if raw is None:
|
|
return "end_turn"
|
|
return _CLAUDE_TO_ANTHROPIC_STOP.get(raw, "end_turn")
|
|
|
|
|
|
def _build_mcp_servers(
|
|
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
|
|
) -> dict[str, dict[str, Any]] | None:
|
|
"""Render ``agent.expose_mcps`` into ``BackendOptions.mcp_servers``.
|
|
|
|
Each exposed MCP is a streamable-HTTP pointer at the gateway's
|
|
internal aggregator (built by :mod:`beaver_gateway.mcp.internal_app`).
|
|
``None`` keeps claude-code from materializing an ``--mcp-config``
|
|
file when the agent exposes nothing.
|
|
"""
|
|
if not agent.expose_mcps:
|
|
return None
|
|
servers: dict[str, dict[str, Any]] = {}
|
|
for em in agent.expose_mcps:
|
|
url = mcp_internal_urls.get(em.name)
|
|
if url is None:
|
|
msg = (
|
|
f"agent {agent.name!r} exposes MCP {em.name!r} "
|
|
"but no internal URL is registered for it"
|
|
)
|
|
raise ValueError(msg)
|
|
servers[em.name] = {"type": "http", "url": url}
|
|
return servers
|
|
|
|
|
|
def _build_backend_options(
|
|
agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
|
|
) -> BackendOptions:
|
|
"""Compose the per-agent :class:`BackendOptions`.
|
|
|
|
Agent-primary fields:
|
|
|
|
* ``cwd`` / ``model`` come from the agent directly;
|
|
* ``system_prompt`` carries :attr:`BaseAgent.system_prompt`
|
|
verbatim — i.e. wire-level ``--system-prompt`` (~8.6k tokens
|
|
lighter than ``--append-system-prompt`` because claude-code's
|
|
persona/planning conventions and dynamic sections drop out;
|
|
tool schemas survive via the API ``tools=[]`` channel);
|
|
* ``append_system_prompt`` carries
|
|
:attr:`ClaudeCodeOptions.append_system_prompt`, normally
|
|
``None``. Setting it re-attaches claude-code's built-in prompt
|
|
*and* this delta — opt-in for "claude as a real coding session";
|
|
* ``allowed_tools`` follows the PLAN: when the user lists native
|
|
tools we restrict to those *plus* a per-MCP wildcard so MCP tools
|
|
stay reachable; when no native list is declared we leave
|
|
``allowed_tools`` empty (= all tools allowed by claude-code's
|
|
default);
|
|
* ``mcp_servers`` comes from :func:`_build_mcp_servers`.
|
|
|
|
Every other tunable knob is passed through from
|
|
:attr:`ClaudeAgent.options`. Our default overrides
|
|
(``wait_for_turn_duration=True``,
|
|
``dangerously_skip_permissions=True``) live on
|
|
:class:`ClaudeCodeOptions`, not here, so a user who builds
|
|
``ClaudeCodeOptions(...)`` explicitly inherits the same defaults
|
|
instead of getting whatever claude-code-api ships.
|
|
"""
|
|
allowed_tools: tuple[str, ...] = ()
|
|
if agent.available_native_tools:
|
|
mcp_wildcards = tuple(f"mcp__{em.name}" for em in agent.expose_mcps)
|
|
allowed_tools = tuple(agent.available_native_tools) + mcp_wildcards
|
|
|
|
opt = agent.options
|
|
return BackendOptions(
|
|
cwd=agent.cwd,
|
|
model=agent.model or None,
|
|
system_prompt=agent.system_prompt,
|
|
append_system_prompt=opt.append_system_prompt,
|
|
allowed_tools=allowed_tools,
|
|
mcp_servers=_build_mcp_servers(agent, mcp_internal_urls),
|
|
transport=opt.transport,
|
|
include_partial_messages=opt.include_partial_messages,
|
|
# ``None`` means "decide from the agent": an agent that exposes
|
|
# MCPs needs them on its first reply, one that doesn't shouldn't
|
|
# pay for a warm-up it gains nothing from.
|
|
warmup_turn=(
|
|
bool(agent.expose_mcps) if opt.warmup_turn is None else opt.warmup_turn
|
|
),
|
|
disallowed_tools=opt.disallowed_tools,
|
|
permission_mode=opt.permission_mode,
|
|
dangerously_skip_permissions=opt.dangerously_skip_permissions,
|
|
effort=opt.effort,
|
|
add_dir=opt.add_dir,
|
|
settings=opt.settings,
|
|
extra_args=opt.extra_args,
|
|
extra_env=opt.extra_env,
|
|
preserve_provider_env=opt.preserve_provider_env,
|
|
history_injection_mode=opt.history_injection_mode,
|
|
wait_for_turn_duration=opt.wait_for_turn_duration,
|
|
include_meta_user=opt.include_meta_user,
|
|
startup_delay=opt.startup_delay,
|
|
file_wait_timeout=opt.file_wait_timeout,
|
|
turn_duration_timeout=opt.turn_duration_timeout,
|
|
idle_session_ttl=opt.idle_session_ttl,
|
|
)
|
|
|
|
|
|
class ClaudeCodeBackendAdapter:
|
|
"""One ``claude-code-api`` backend bound to a single :class:`ClaudeAgent`.
|
|
|
|
Owns the underlying :class:`ClaudeCodeBackend`'s lifecycle through
|
|
the async-context-manager protocol so :mod:`beaver_gateway.cli` can
|
|
park it in its ``AsyncExitStack``.
|
|
"""
|
|
|
|
def __init__(
|
|
self, *, agent: ClaudeAgent, mcp_internal_urls: Mapping[str, str]
|
|
) -> None:
|
|
self._agent = agent
|
|
options = _build_backend_options(agent, mcp_internal_urls)
|
|
self._backend = ClaudeCodeBackend(options)
|
|
self._streaming = (
|
|
options.transport == "stream_json" and options.include_partial_messages
|
|
)
|
|
"""Whether the backend feeds us token-level ``StreamEvent``s.
|
|
|
|
When it does, blocks are emitted from those and the whole-block
|
|
``AssistantMessage`` records are used only for bookkeeping —
|
|
emitting from both would duplicate every block on the wire."""
|
|
|
|
@property
|
|
def agent(self) -> ClaudeAgent:
|
|
return self._agent
|
|
|
|
@property
|
|
def live_session_count(self) -> int:
|
|
return self._backend.live_session_count
|
|
|
|
@property
|
|
def live_sessions(self) -> dict[str, Any]:
|
|
"""Live claude processes keyed by claude session_id.
|
|
|
|
Pass-through to the underlying ``ClaudeCodeBackend``. The value
|
|
is a ``PtyClaudeProcess`` or a ``StreamClaudeProcess`` depending
|
|
on the agent's transport; both implement the
|
|
``captured_output()`` / ``add_output_listener`` / ``write``
|
|
surface the admin terminal consumes, so it works either way —
|
|
with the caveat that the stream transport's "terminal" is a
|
|
read-only view of the JSON event stream rather than a TUI you
|
|
can type into. Typed as ``Any`` to avoid leaking the lower
|
|
layer's type into the gateway's public surface.
|
|
"""
|
|
return self._backend.live_sessions
|
|
|
|
async def __aenter__(self) -> Self:
|
|
await self._backend.__aenter__()
|
|
return self
|
|
|
|
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:
|
|
await self._backend.aclose()
|
|
|
|
async def complete(
|
|
self,
|
|
*,
|
|
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):
|
|
msg = (
|
|
"ClaudeCodeBackendAdapter requires ClaudeAgent, "
|
|
f"got {type(agent).__name__}"
|
|
)
|
|
raise TypeError(msg)
|
|
if agent.name != self._agent.name:
|
|
# Adapter is per-agent; routing a different agent through it
|
|
# would mean a different cwd / system_prompt / MCP set than
|
|
# the live-session pool was spawned with.
|
|
msg = (
|
|
f"ClaudeCodeBackendAdapter bound to {self._agent.name!r} "
|
|
f"got request for {agent.name!r}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
msgs_list: list[Mapping[str, Any]] = list(messages)
|
|
_log.info(
|
|
"complete: agent=%s n_messages=%d capture=%s live_sessions=%d",
|
|
agent.name,
|
|
len(msgs_list),
|
|
capture is not None,
|
|
self._backend.live_session_count,
|
|
)
|
|
|
|
message_id = f"msg_{uuid.uuid4().hex}"
|
|
yield build_message_start(message_id=message_id, model=agent.model)
|
|
|
|
next_index = 0
|
|
stop_reason: str | None = None
|
|
usage: Mapping[str, Any] | None = None
|
|
n_text = 0
|
|
n_thinking = 0
|
|
n_tool_use = 0
|
|
# 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(msgs_list):
|
|
raw_events.append(event)
|
|
if isinstance(event, StreamEvent):
|
|
# Only ever arrives when ``self._streaming`` — the
|
|
# backend doesn't emit these otherwise. Indices are
|
|
# already rebased onto the whole turn by claude-code-api.
|
|
for ev in _emit_stream_event(event.event):
|
|
yield ev
|
|
elif isinstance(event, AssistantMessage):
|
|
for block in event.content:
|
|
if isinstance(block, TextBlock):
|
|
n_text += 1
|
|
elif isinstance(block, ThinkingBlock):
|
|
n_thinking += 1
|
|
elif isinstance(block, ToolUseBlock):
|
|
n_tool_use += 1
|
|
if not self._streaming:
|
|
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
|
|
# UserMessage (tool_result records) and SystemMessage
|
|
# (turn_duration heartbeats) carry no content for the
|
|
# /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)
|
|
|
|
_log.info(
|
|
"complete: agent=%s DONE text=%d thinking=%d tool_use=%d stop=%s synth=%d",
|
|
agent.name,
|
|
n_text,
|
|
n_thinking,
|
|
n_tool_use,
|
|
stop_reason,
|
|
len(capture.synthesized_messages) if capture is not None else 0,
|
|
)
|
|
|
|
yield build_message_delta(
|
|
stop_reason=_map_stop_reason(stop_reason), usage=_normalize_usage(usage)
|
|
)
|
|
yield build_message_stop()
|
|
|
|
|
|
def _emit_stream_event(event: Mapping[str, Any]) -> Iterable[MessageStreamEvent]:
|
|
"""Render one raw Anthropic streaming event through our own builders.
|
|
|
|
The payload is already Anthropic-shaped, so forwarding it verbatim
|
|
is tempting — but it arrives as a plain dict and the frontend
|
|
serializes pydantic models, so it would have to be validated against
|
|
the SDK's ``RawMessageStreamEvent`` union anyway. Rebuilding it from
|
|
the fields we recognize is the same amount of work and fails the
|
|
same way :func:`_emit_block` already does: a block or delta type
|
|
this gateway doesn't know is dropped rather than raising mid-stream
|
|
on a claude release that added one.
|
|
|
|
Only ``content_block_*`` events reach here — claude-code-api strips
|
|
the inner per-request message envelopes, because the caller owns the
|
|
single envelope spanning the whole turn.
|
|
"""
|
|
etype = event.get("type")
|
|
index = event.get("index")
|
|
if not isinstance(index, int):
|
|
return ()
|
|
|
|
if etype == "content_block_start":
|
|
block = event.get("content_block")
|
|
if not isinstance(block, Mapping):
|
|
return ()
|
|
btype = block.get("type")
|
|
if btype == "text":
|
|
return (build_text_block_start(index),)
|
|
if btype == "thinking":
|
|
return (build_thinking_block_start(index),)
|
|
if btype == "tool_use":
|
|
return (
|
|
build_tool_use_block_start(
|
|
index,
|
|
tool_use_id=str(block.get("id", "")),
|
|
name=str(block.get("name", "")),
|
|
),
|
|
)
|
|
return ()
|
|
|
|
if etype == "content_block_delta":
|
|
delta = event.get("delta")
|
|
if not isinstance(delta, Mapping):
|
|
return ()
|
|
dtype = delta.get("type")
|
|
if dtype == "text_delta":
|
|
return (build_text_delta(index, str(delta.get("text", ""))),)
|
|
if dtype == "thinking_delta":
|
|
return (build_thinking_delta(index, str(delta.get("thinking", ""))),)
|
|
if dtype == "signature_delta":
|
|
return (build_signature_delta(index, str(delta.get("signature", ""))),)
|
|
if dtype == "input_json_delta":
|
|
return (build_input_json_delta(index, str(delta.get("partial_json", ""))),)
|
|
return ()
|
|
|
|
if etype == "content_block_stop":
|
|
return (build_content_block_stop(index),)
|
|
|
|
return ()
|
|
|
|
|
|
def _emit_block(
|
|
block: TextBlock | ThinkingBlock | ToolUseBlock | Any, index: int
|
|
) -> Iterable[MessageStreamEvent]:
|
|
"""Render one ``claude-code`` content block as Anthropic stream events.
|
|
|
|
``ToolResultBlock`` would arrive only on user-role records — we
|
|
don't emit it here because :meth:`complete` skips ``UserMessage``.
|
|
"""
|
|
if isinstance(block, TextBlock):
|
|
return (
|
|
build_text_block_start(index),
|
|
build_text_delta(index, block.text),
|
|
build_content_block_stop(index),
|
|
)
|
|
if isinstance(block, ThinkingBlock):
|
|
return (
|
|
build_thinking_block_start(index),
|
|
build_thinking_delta(index, block.thinking),
|
|
build_signature_delta(index, block.signature),
|
|
build_content_block_stop(index),
|
|
)
|
|
if isinstance(block, ToolUseBlock):
|
|
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),
|
|
build_content_block_stop(index),
|
|
)
|
|
return ()
|
|
|
|
|
|
def _normalize_usage(usage: Mapping[str, Any] | None) -> dict[str, int] | None:
|
|
"""Coerce claude-code's ``usage`` dict to Anthropic ``MessageDeltaUsage`` shape.
|
|
|
|
claude-code copies whatever the JSONL ``usage`` record carried —
|
|
fields can be missing, strings, or ints. We pass through only the
|
|
fields ``MessageDeltaUsage`` knows about and discard the rest so an
|
|
odd ``cache_creation`` object structure doesn't fail pydantic
|
|
validation downstream.
|
|
"""
|
|
if not usage:
|
|
return None
|
|
out: dict[str, int] = {}
|
|
for key in (
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"cache_creation_input_tokens",
|
|
"cache_read_input_tokens",
|
|
):
|
|
value = usage.get(key)
|
|
if isinstance(value, int):
|
|
out[key] = value
|
|
return out or None
|