feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation
This commit is contained in:
@@ -11,6 +11,7 @@ wheels/
|
||||
|
||||
# Sloppy
|
||||
docs/
|
||||
t/
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
|
||||
+13
-17
@@ -44,26 +44,22 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Bun native binary (glibc) — fast `npm install` replacement, only used
|
||||
# to drop the claude CLI into the image. Not invoked at runtime.
|
||||
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
|
||||
# the user's config. The claude CLI itself ships inside the
|
||||
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
|
||||
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
|
||||
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
|
||||
|
||||
# `--trust` is required: without it bun skips the postinstall step that
|
||||
# fetches claude's native binary (anthropics/claude-code#50203). The
|
||||
# postinstall itself is bun's smoke check — if it fails the layer
|
||||
# fails. We deliberately DO NOT run `claude --version` here: claude
|
||||
# touches `$HOME` on every invocation (creates `/root/.claude/`,
|
||||
# `/root/.claude.json`, sometimes `/root/.config/claude/`), and those
|
||||
# build-time artifacts seed the runtime named-volume `claude-home`
|
||||
# with stale "haven't onboarded" state, so the user gets re-prompted
|
||||
# for trust/bypass dialogs on every rebuild and the subscription auth
|
||||
# can land on a tainted credential file.
|
||||
ENV BUN_INSTALL=/usr/local/bun-global \
|
||||
PATH=/usr/local/bun-global/bin:/app/.venv/bin:$PATH
|
||||
RUN bun install -g --trust @anthropic-ai/claude-code \
|
||||
&& test -x "$(command -v claude)" \
|
||||
&& rm -rf /root/.claude /root/.claude.json /root/.config/claude
|
||||
# The model process runs as `beaver-runner`, not as the gateway: the
|
||||
# adapter spawns claude under this uid with a whitelisted environment.
|
||||
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
|
||||
# without chowning files that Obsidian Sync keeps rewriting as root.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends acl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
|
||||
|
||||
ENV PATH=/app/.venv/bin:$PATH
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app /app
|
||||
|
||||
+5
-6
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"anthropic>=0.103.0",
|
||||
"anyio>=4.13.0",
|
||||
"argon2-cffi>=25.1.0",
|
||||
"claude-agent-sdk>=0.2.146",
|
||||
"fastapi>=0.136.1",
|
||||
"fastmcp>=3.3.1",
|
||||
"greenlet>=3.5.0",
|
||||
@@ -30,11 +31,9 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
local = [
|
||||
"raycast-api",
|
||||
"claude-code-api",
|
||||
]
|
||||
prod = [
|
||||
"raycast-api",
|
||||
"claude-code-api",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -47,10 +46,6 @@ raycast-api = [
|
||||
{ path = "../raycast-api", editable = true, extra = "local" },
|
||||
{ git = "https://git.kotikot.com/beaver/raycast-api.git", extra = "prod" },
|
||||
]
|
||||
claude-code-api = [
|
||||
{ path = "../claude-code-api", editable = true, extra = "local" },
|
||||
{ git = "https://git.kotikot.com/beaver/claude-code-api.git", extra = "prod" },
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
beaver-gateway = "beaver_gateway:main"
|
||||
@@ -67,3 +62,7 @@ dev = [
|
||||
"ruff>=0.15.13",
|
||||
"ty>=0.0.37",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
``BaseAgent`` is the structural contract every backend-specific agent
|
||||
honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``,
|
||||
``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime
|
||||
``RaycastAgent.streaming``) - backends dispatch on type, not on a runtime
|
||||
matrix.
|
||||
"""
|
||||
|
||||
@@ -15,10 +15,16 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExposedMcp:
|
||||
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
|
||||
"""Reference to an ``McpServer`` (by name) exposed to a single agent.
|
||||
|
||||
``tools`` is an allowlist of tool names (``None`` = every tool);
|
||||
``deny`` is a tuple of ``fnmatch`` patterns removed on top of that,
|
||||
e.g. ``("delete_*",)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
tools: tuple[str, ...] | None = None
|
||||
deny: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class BaseAgent(BaseModel):
|
||||
|
||||
@@ -1,258 +1,54 @@
|
||||
"""Claude Code agent definition.
|
||||
"""Claude agent definition, backed by the Claude Agent SDK.
|
||||
|
||||
There is no ``streaming`` field, but there is
|
||||
:attr:`ClaudeCodeOptions.include_partial_messages`, and the difference
|
||||
matters. Token-level deltas are not a property of the agent, they are a
|
||||
property of the transport: the PTY transport reads a JSONL that only
|
||||
ever contains finished blocks, so it cannot stream no matter what the
|
||||
agent asks for, while ``transport="stream_json"`` can. The flag lives
|
||||
next to the transport that grants it rather than pretending to be a
|
||||
free-standing capability.
|
||||
|
||||
``BaseAgent.system_prompt`` maps onto the claude CLI's
|
||||
``--system-prompt`` — i.e. it really *is* the agent's system prompt,
|
||||
not "added on top of claude-code's giant built-in". The additive slot
|
||||
``--append-system-prompt`` is exposed via
|
||||
:attr:`ClaudeCodeOptions.append_system_prompt` for the rare case
|
||||
when the user wants claude-code's planning conventions / dynamic
|
||||
sections *and* a delta on top. Difference, measured empirically:
|
||||
|
||||
* tools (names, JSON schemas, embedded guidance like Bash's "prefer
|
||||
Read over cat/head/tail") survive both flags — they ride the
|
||||
Anthropic API ``tools=[]`` field, not the system prompt text;
|
||||
* ``--system-prompt`` drops ~8.6k tokens of claude-code's *textual*
|
||||
baseline — agent persona, multi-step-work conventions ("use
|
||||
TaskCreate proactively", etc.), and the dynamic per-machine sections
|
||||
(cwd, env info, git status, memory paths — see the
|
||||
``--exclude-dynamic-system-prompt-sections`` flag note in
|
||||
``claude --help``: "Only applies with the default system prompt
|
||||
(ignored with --system-prompt)").
|
||||
|
||||
We pick override as the default because it preserves the principle of
|
||||
least surprise: the ``system_prompt=...`` you wrote on the agent is
|
||||
what claude actually receives. If you need claude-code's full
|
||||
planning/dynamic-context behaviour on top of your prompt, opt in via
|
||||
``options=ClaudeCodeOptions(append_system_prompt=...)`` and leave
|
||||
``system_prompt`` for your agent's identity (or vice versa — set
|
||||
``system_prompt=""``-ish and put everything in ``append_*``).
|
||||
|
||||
Per-agent passthrough of ``claude_code_api.BackendOptions`` lives in
|
||||
:class:`ClaudeCodeOptions`, attached to :class:`ClaudeAgent` as
|
||||
``options``. Every tunable knob ``BackendOptions`` supports — except
|
||||
the ones derived from the agent's own primary surface
|
||||
(``cwd`` / ``model`` / ``system_prompt`` / ``available_native_tools`` →
|
||||
``allowed_tools`` / ``expose_mcps`` → ``mcp_servers``) — is exposed
|
||||
there. Defaults match upstream except where correctness demands
|
||||
otherwise (``wait_for_turn_duration=True``,
|
||||
``dangerously_skip_permissions=True``).
|
||||
The system prompt is either ``system_prompt`` verbatim or, when
|
||||
``prompt_sources`` is set, the concatenation of those files assembled at
|
||||
every session spawn (see ``core/prompt.py``). ``skill_sets`` are
|
||||
directories of ``<skill>/SKILL.md`` folders; each becomes a local SDK
|
||||
plugin. Nothing from disk is loaded otherwise: the adapter runs with
|
||||
``setting_sources=[]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping # noqa: TC003 — pydantic runtime
|
||||
from pathlib import Path # noqa: TC003 — pydantic runtime
|
||||
from typing import Literal
|
||||
from collections.abc import Mapping # noqa: TC003 - pydantic runtime
|
||||
from pathlib import Path # noqa: TC003 - pydantic runtime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
|
||||
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
|
||||
"""Mirrors ``claude_code_api.HistoryInjectionMode`` to avoid an import
|
||||
cycle on the user-facing path (configs may be loaded without
|
||||
``claude-code-api`` installed, e.g. ``--extra prod`` minus claude)."""
|
||||
|
||||
Transport = Literal["pty", "stream_json"]
|
||||
"""Mirrors ``claude_code_api.Transport``, for the same reason."""
|
||||
__all__ = ["ClaudeAgent", "ClaudeOptions"]
|
||||
|
||||
|
||||
class ClaudeCodeOptions(BaseModel):
|
||||
"""Per-agent passthrough for ``claude_code_api.BackendOptions``.
|
||||
|
||||
Mirrors every field of ``BackendOptions`` except those that come
|
||||
from the agent's primary surface
|
||||
(``cwd`` / ``model`` / ``system_prompt`` /
|
||||
``available_native_tools`` / ``expose_mcps``). Defaults match
|
||||
upstream, with two exceptions for correctness:
|
||||
|
||||
* ``wait_for_turn_duration=True`` — without it, extended-thinking
|
||||
turns drop the text response because ``TurnManager`` returns on
|
||||
the first terminal assistant record (the thinking snapshot) and
|
||||
never reads the second one (the actual text). Phase 2.2 PROGRESS
|
||||
describes the incident in detail.
|
||||
* ``dangerously_skip_permissions=True`` — Beaver Gateway is a
|
||||
single-user trusted-config product; the user already wrote the
|
||||
``config.py`` that spawns claude. Permission prompts on a
|
||||
headless backend mean stuck turns.
|
||||
|
||||
Any field on :class:`claude_code_api.BackendOptions` we forward
|
||||
here lives on this model with the same name and type, so a future
|
||||
``BackendOptions`` field addition is a one-liner here plus a
|
||||
one-liner in :mod:`backends.claude_code`.
|
||||
"""
|
||||
|
||||
class ClaudeOptions(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||
|
||||
transport: Transport = "pty"
|
||||
"""Which claude process backs this agent.
|
||||
|
||||
``stream_json`` runs ``claude -p`` with stream-json on stdin and
|
||||
stdout: prompts go into a pipe, events come back as they happen, and
|
||||
a native ``result`` record closes each turn. ``pty`` (the default,
|
||||
kept for backwards compatibility) drives the interactive TUI and
|
||||
tails the session JSONL, which costs a multi-second readiness wait
|
||||
per spawn — the thing ``startup_delay`` bounds — and brings the
|
||||
swallowed-paste failure mode with it.
|
||||
|
||||
Switching also makes the PTY-only knobs below inert
|
||||
(``startup_delay`` / ``file_wait_timeout`` /
|
||||
``turn_duration_timeout`` / ``wait_for_turn_duration``) and drops
|
||||
interactive-only ``extra_args`` such as ``--remote-control``, which
|
||||
would take claude out of print mode. Everything else — model,
|
||||
prompts, tools, MCPs, history seeding — behaves identically."""
|
||||
|
||||
warmup_turn: bool | None = None
|
||||
"""Spend a throwaway turn when a session spawns, so MCP tools are
|
||||
live on the agent's first reply.
|
||||
|
||||
``None`` (the default) decides per agent: on when it exposes any
|
||||
MCP, off when it doesn't. Set it explicitly to override.
|
||||
|
||||
It exists because the CLI begins connecting MCP servers when a turn
|
||||
starts and doesn't wait for them — measured on the pi, turn one saw
|
||||
29 tools with both servers ``pending``, turn two saw 90 with both
|
||||
``connected``. For an agent whose whole job is those tools, a first
|
||||
reply without them is worse than a couple of extra seconds. Nothing
|
||||
cheaper works: sleeping longer changes nothing, and neither a
|
||||
``/status`` command nor the SDK's ``initialize`` handshake touches
|
||||
the MCP client.
|
||||
|
||||
``stream_json`` only — the PTY transport's readiness wait covers
|
||||
this incidentally."""
|
||||
|
||||
include_partial_messages: bool = False
|
||||
"""Stream token-level deltas to the client instead of one delta per
|
||||
finished block. Requires ``transport="stream_json"``; inert on the
|
||||
PTY transport, whose JSONL contains only finished blocks. Off by
|
||||
default because it multiplies the SSE event count — worth it for
|
||||
interactive chat, pointless for a client that waits for the whole
|
||||
message anyway."""
|
||||
|
||||
append_system_prompt: str | None = None
|
||||
"""Maps to claude CLI's ``--append-system-prompt``. Opting in
|
||||
re-attaches claude-code's full built-in prompt (persona, planning
|
||||
conventions like "use TaskCreate proactively", and the dynamic
|
||||
per-machine sections — cwd, env info, git status, memory paths)
|
||||
plus this string on top. By default we ship only
|
||||
:attr:`BaseAgent.system_prompt` via ``--system-prompt`` (~8.6k
|
||||
tokens lighter); use this when the agent should behave like a
|
||||
real claude-code coding session and your text is a delta on top
|
||||
of those built-ins. Tool schemas survive either way — they ride
|
||||
the Anthropic ``tools=[]`` channel, not the prompt text."""
|
||||
|
||||
disallowed_tools: tuple[str, ...] = ()
|
||||
"""Tool names that claude must refuse to call. Combines with the
|
||||
agent's ``available_native_tools`` allowlist — disallow wins."""
|
||||
effort: str | None = None
|
||||
include_partial_messages: bool = True
|
||||
"""Token-level deltas on the wire; off means one delta per finished block."""
|
||||
|
||||
permission_mode: str = "bypassPermissions"
|
||||
"""``claude --permission-mode`` value. Only meaningful when
|
||||
``dangerously_skip_permissions=False``; otherwise the CLI bypasses
|
||||
the prompt path entirely."""
|
||||
tools: tuple[str, ...] | None = None
|
||||
"""Base set of built-in tools; ``None`` keeps the CLI default set."""
|
||||
|
||||
dangerously_skip_permissions: bool = True
|
||||
"""Pass ``--dangerously-skip-permissions`` to claude. ``True`` by
|
||||
default — see the class docstring for why."""
|
||||
disallowed_tools: tuple[str, ...] = ()
|
||||
add_dirs: tuple[str, ...] = ()
|
||||
env: Mapping[str, str] = Field(default_factory=dict)
|
||||
"""Extra environment for the claude subprocess (always passed through)."""
|
||||
|
||||
effort: str | None = None
|
||||
"""``claude --effort`` value (typically ``low`` / ``medium`` /
|
||||
``high``). Tunes reasoning effort budget for newer models."""
|
||||
|
||||
add_dir: tuple[str, ...] = ()
|
||||
"""Extra directories claude is allowed to read/edit beyond
|
||||
``cwd``. Maps to repeated ``--add-dir`` flags."""
|
||||
|
||||
settings: str | None = None
|
||||
"""Path to a ``--settings`` JSON file claude should load (hooks,
|
||||
MCP servers, etc. that don't belong in our internal aggregator)."""
|
||||
|
||||
extra_args: tuple[str, ...] = ()
|
||||
"""Raw extra argv to append to the claude command. Escape hatch
|
||||
for flags we haven't surfaced as first-class options."""
|
||||
|
||||
extra_env: Mapping[str, str] = Field(default_factory=dict)
|
||||
"""Additional environment variables for the spawned claude
|
||||
process. Layered on top of the gateway's own env after
|
||||
``preserve_provider_env`` is applied."""
|
||||
|
||||
preserve_provider_env: bool = False
|
||||
"""When ``False`` (default), the spawn env strips
|
||||
``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` /
|
||||
``ANTHROPIC_BASE_URL`` so claude uses subscription auth instead of
|
||||
leaking through whatever the gateway process inherited."""
|
||||
|
||||
history_injection_mode: HistoryInjectionMode = "native_jsonl"
|
||||
"""How prior turns are seeded into a fresh session when no live
|
||||
session matches: ``native_jsonl`` writes a hand-crafted transcript
|
||||
and ``--resume``s; ``concat_message`` instead folds history into
|
||||
the first user prompt. ``native_jsonl`` is more faithful."""
|
||||
|
||||
wait_for_turn_duration: bool = True
|
||||
"""Keep reading JSONL until the ``turn_duration`` heartbeat
|
||||
arrives, instead of returning on the first terminal assistant
|
||||
record. ``True`` by default — see class docstring."""
|
||||
|
||||
include_meta_user: bool = False
|
||||
"""Surface claude's ``isMeta=True`` user records (local-command
|
||||
caveats) as ``UserMessage`` events. Off by default — they're not
|
||||
part of the real conversation."""
|
||||
|
||||
startup_delay: float = 60.0
|
||||
"""Upper bound (seconds) on waiting for claude's TUI to enable
|
||||
bracketed-paste mode after spawn. claude-code-api polls PTY output
|
||||
for the DECSET 2004 marker and returns as soon as it arrives, so a
|
||||
generous cap costs nothing on a warm host — it only bounds the case
|
||||
where the marker never shows.
|
||||
|
||||
It has to be generous, because timing out here means writing the
|
||||
prompt into a terminal that hasn't enabled bracketed paste, where our
|
||||
``ESC[200~`` framing is parsed as junk and the message is silently
|
||||
dropped. Observed on a cold Raspberry Pi: 10s elapsed with *zero*
|
||||
bytes of PTY output, the prompt went into the void, and the turn hung
|
||||
until a human typed into the session by hand."""
|
||||
|
||||
file_wait_timeout: float = 30.0
|
||||
"""How long to wait for the session JSONL to appear after spawn.
|
||||
Failure here usually means a CLI auth / config problem."""
|
||||
|
||||
turn_duration_timeout: float = 5.0
|
||||
"""How long to wait for the ``turn_duration`` heartbeat once a
|
||||
terminal assistant has been seen. Bound on extra latency when
|
||||
``wait_for_turn_duration=True``."""
|
||||
env_keep: tuple[str, ...] = ()
|
||||
"""Extra inherited variable names to let through the env whitelist."""
|
||||
|
||||
max_turns: int | None = None
|
||||
idle_session_ttl: float = 1800.0
|
||||
"""Seconds a pooled claude session may sit unused before it is
|
||||
terminated. The backend pools live PTYs by conversation-history
|
||||
fingerprint; when a conversation forks, the session behind the old
|
||||
fingerprint becomes unreachable but stays resident — one live
|
||||
``claude`` (hundreds of MB) each. 30 minutes keeps an active chat
|
||||
warm across a coffee break while bounding what a fork storm can
|
||||
strand. There is no count cap: concurrent conversations should all
|
||||
stay warm. ``0`` disables reaping."""
|
||||
"""Seconds a live session may sit unused before it is closed."""
|
||||
|
||||
session_store_flush: str = "batched"
|
||||
|
||||
|
||||
class ClaudeAgent(BaseAgent):
|
||||
"""Agent backed by ``claude-code-api``.
|
||||
|
||||
``cwd`` is the working directory the claude CLI is spawned in;
|
||||
typed as ``Path`` (Pydantic coerces strings) since claude-code-api
|
||||
accepts ``str | os.PathLike[str]``. ``available_native_tools`` stays
|
||||
``tuple[str, ...]`` because Claude Code's tool set is a moving target
|
||||
(Bash/Read/Edit/Write/WebSearch/etc. — versions add and rename), so
|
||||
pinning it as a ``Literal`` would rot the type each release.
|
||||
"""
|
||||
|
||||
cwd: Path
|
||||
available_native_tools: tuple[str, ...] = ()
|
||||
options: ClaudeCodeOptions = Field(default_factory=ClaudeCodeOptions)
|
||||
"""Per-agent passthrough for the underlying claude-code-api
|
||||
``BackendOptions``. See :class:`ClaudeCodeOptions`."""
|
||||
system_prompt: str = ""
|
||||
prompt_sources: tuple[Path, ...] = ()
|
||||
skill_sets: tuple[Path, ...] = ()
|
||||
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Backend adapters.
|
||||
|
||||
Each backend wraps a provider-specific SDK (``raycast-api``, ``claude-code-api``)
|
||||
Each backend wraps a provider SDK (``raycast-api``, ``claude-agent-sdk``)
|
||||
and yields the unified :class:`~beaver_gateway.core.events.MessageStreamEvent`
|
||||
family. The Anthropic-style frontend serialises events straight to SSE.
|
||||
"""
|
||||
|
||||
@@ -5,8 +5,15 @@ into a stream of :class:`~beaver_gateway.core.events.MessageStreamEvent`
|
||||
records. The frontend serializes whatever comes out straight to SSE, so
|
||||
backends are the only place where provider quirks are translated.
|
||||
|
||||
Implementations are plain :class:`typing.Protocol` conformers — no ABC
|
||||
subclassing — to keep them swappable in tests with bare async generators.
|
||||
Implementations are plain :class:`typing.Protocol` conformers - no ABC
|
||||
subclassing - to keep them swappable in tests with bare async generators.
|
||||
|
||||
``**options`` is the one extension point. Known keys, all optional and
|
||||
ignored by backends that don't keep state: ``conversation_id`` (stable id
|
||||
the backend may pin a live session to), ``session_id`` (backend session to
|
||||
resume when nothing is live), ``capture`` (a
|
||||
:class:`~beaver_gateway.core.turn_capture.TurnCapture` the backend fills
|
||||
after the stream closes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
"""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
|
||||
@@ -0,0 +1,789 @@
|
||||
"""Claude Agent SDK backend adapter.
|
||||
|
||||
One :class:`ClaudeSdkBackend` per :class:`ClaudeAgent`. A live session is
|
||||
one ``ClaudeSDKClient`` (one claude subprocess) and runs one turn at a
|
||||
time. Sessions are keyed by ``conversation_id`` when the frontend passes
|
||||
one (markdown chats) or by a text-only fingerprint of ``messages[:-1]``
|
||||
for stateless callers (``/v1/messages``). Without a live session the
|
||||
adapter resumes ``session_id`` from the session store, or seeds the
|
||||
incoming history into the store via ``core/transcript`` and resumes that.
|
||||
|
||||
Events on the wire are the Anthropic ``MessageStreamEvent`` family: one
|
||||
``message_start``/``message_stop`` envelope per turn, block indices
|
||||
rebased across the API calls claude makes inside the turn.
|
||||
|
||||
Process isolation: claude is spawned through a small exec wrapper that
|
||||
drops every inherited environment variable outside a whitelist, and,
|
||||
when ``RunnerConfig.user`` is set, under that uid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, cast
|
||||
|
||||
import claude_agent_sdk
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ClaudeSDKClient,
|
||||
ResultMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
project_key_for_directory,
|
||||
)
|
||||
|
||||
from beaver_gateway.core import prompt as prompt_assembly
|
||||
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,
|
||||
)
|
||||
from beaver_gateway.core.transcript import build_entries
|
||||
from beaver_gateway.core.turn_capture import TurnCapture, TurnUsage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
from claude_agent_sdk import SessionStore
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.backends.claude_sdk")
|
||||
|
||||
__all__ = [
|
||||
"ClaudeSdkBackend",
|
||||
"RunnerConfig",
|
||||
"SessionClient",
|
||||
"UsageSink",
|
||||
"fingerprint",
|
||||
]
|
||||
|
||||
ENV_KEEP: tuple[str, ...] = (
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"TERM",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"TMPDIR",
|
||||
"PWD",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"NODE_OPTIONS",
|
||||
"IS_SANDBOX",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
)
|
||||
ENV_KEEP_PREFIXES: tuple[str, ...] = ("CLAUDE_", "ANTHROPIC_", "DISABLE_")
|
||||
|
||||
_REAP_INTERVAL = 60.0
|
||||
_STOP_REASONS: dict[str, StopReason] = {
|
||||
"end_turn": "end_turn",
|
||||
"tool_use": "tool_use",
|
||||
"max_tokens": "max_tokens",
|
||||
"stop_sequence": "stop_sequence",
|
||||
"refusal": "refusal",
|
||||
"pause_turn": "pause_turn",
|
||||
}
|
||||
|
||||
|
||||
class SessionClient(Protocol):
|
||||
async def connect(self) -> None: ...
|
||||
async def query(self, prompt: str) -> None: ...
|
||||
def receive_response(self) -> AsyncIterator[Any]: ...
|
||||
async def disconnect(self) -> None: ...
|
||||
|
||||
|
||||
ClientFactory = "Callable[[ClaudeAgentOptions], SessionClient]"
|
||||
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunnerConfig:
|
||||
user: str | None = None
|
||||
home: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UsageEvent:
|
||||
agent_name: str
|
||||
model: str
|
||||
effort: str | None
|
||||
conversation_id: str | None
|
||||
session_id: str | None
|
||||
usage: TurnUsage
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Live:
|
||||
client: SessionClient
|
||||
session_id: str | None
|
||||
resumed: bool
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
last_used: float = field(default_factory=time.monotonic)
|
||||
turns: int = 0
|
||||
|
||||
|
||||
class _RunnerClient(ClaudeSDKClient):
|
||||
"""``ClaudeSDKClient`` that hands the materialized resume dir to the runner uid."""
|
||||
|
||||
def __init__(self, options: ClaudeAgentOptions, *, uid: int | None) -> None:
|
||||
super().__init__(options=options)
|
||||
self._runner_uid = uid
|
||||
|
||||
async def _connect_inner(self, prompt: Any, actual_prompt: Any) -> None:
|
||||
materialized = self._materialized
|
||||
if materialized is not None and self._runner_uid is not None:
|
||||
_chown_tree(materialized.config_dir, self._runner_uid)
|
||||
await super()._connect_inner(prompt, actual_prompt)
|
||||
|
||||
|
||||
class ClaudeSdkBackend:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
agent: ClaudeAgent,
|
||||
mcp_internal_urls: Mapping[str, str],
|
||||
session_store: SessionStore,
|
||||
mcp_tool_names: Mapping[str, Sequence[str]] | None = None,
|
||||
runner: RunnerConfig | None = None,
|
||||
usage_sink: Callable[[UsageEvent], Awaitable[None]] | None = None,
|
||||
client_factory: Callable[[ClaudeAgentOptions], SessionClient] | None = None,
|
||||
work_dir: Path | None = None,
|
||||
) -> None:
|
||||
self._agent = agent
|
||||
self._store = session_store
|
||||
self._runner = runner or RunnerConfig()
|
||||
self._usage_sink = usage_sink
|
||||
self._factory = client_factory or self._default_factory
|
||||
self._work_dir = work_dir or Path(tempfile.gettempdir()) / "beaver-claude"
|
||||
self._servers = _mcp_servers(agent, mcp_internal_urls)
|
||||
self._mcp_disallowed = _mcp_disallowed(agent, mcp_tool_names or {})
|
||||
self._sessions: dict[str, _Live] = {}
|
||||
self._reaper: asyncio.Task[None] | None = None
|
||||
self._uid = _resolve_uid(self._runner.user)
|
||||
self._wrapper: Path | None = None
|
||||
|
||||
@property
|
||||
def agent(self) -> ClaudeAgent:
|
||||
return self._agent
|
||||
|
||||
@property
|
||||
def sessions(self) -> dict[str, dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
return {
|
||||
key: {
|
||||
"session_id": live.session_id,
|
||||
"idle_seconds": now - live.last_used,
|
||||
"turns": live.turns,
|
||||
"busy": live.lock.locked(),
|
||||
}
|
||||
for key, live in self._sessions.items()
|
||||
}
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
self._reaper = asyncio.create_task(self._reap_loop())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._reaper is not None:
|
||||
self._reaper.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
await self._reaper
|
||||
self._reaper = None
|
||||
for key in list(self._sessions):
|
||||
await self._close(key)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
agent: BaseAgent,
|
||||
messages: Iterable[MessageParam],
|
||||
system: str | None = None, # noqa: ARG002 - the agent owns its prompt
|
||||
conversation_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
capture: TurnCapture | None = None,
|
||||
**options: Any, # noqa: ARG002 - per-request knobs are not supported
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
if agent.name != self._agent.name:
|
||||
msg = f"backend bound to {self._agent.name!r}, got {agent.name!r}"
|
||||
raise ValueError(msg)
|
||||
history = [dict(m) for m in messages]
|
||||
if not history or history[-1].get("role") != "user":
|
||||
msg = "the last message must be a user turn"
|
||||
raise ValueError(msg)
|
||||
prompt = _prompt_text(history[-1].get("content"))
|
||||
prior = history[:-1]
|
||||
key = conversation_id or fingerprint(prior)
|
||||
live = await self._acquire(key, session_id=session_id, history=prior)
|
||||
message_id = f"msg_{uuid.uuid4().hex}"
|
||||
yield build_message_start(message_id=message_id, model=self._agent.model)
|
||||
async with live.lock:
|
||||
live.last_used = time.monotonic()
|
||||
try:
|
||||
turn = await self._run_turn(live, prompt)
|
||||
except Exception:
|
||||
if live.resumed and live.turns == 0:
|
||||
_log.exception(
|
||||
"resume of %s failed, reseeding from history", live.session_id
|
||||
)
|
||||
await self._close(key)
|
||||
live = await self._acquire(key, session_id=None, history=prior)
|
||||
async with live.lock:
|
||||
turn = await self._run_turn(live, prompt)
|
||||
else:
|
||||
raise
|
||||
for event in turn.events:
|
||||
yield event
|
||||
live.turns += 1
|
||||
live.last_used = time.monotonic()
|
||||
if turn.result is not None and turn.result.session_id:
|
||||
live.session_id = turn.result.session_id
|
||||
if conversation_id is None:
|
||||
self._rekey(key, fingerprint([*history, *turn.synthesized]))
|
||||
usage = _usage_of(turn.result)
|
||||
if capture is not None:
|
||||
capture.synthesized_messages = turn.synthesized
|
||||
capture.session_id = live.session_id
|
||||
capture.usage = usage
|
||||
if self._usage_sink is not None:
|
||||
await self._usage_sink(
|
||||
UsageEvent(
|
||||
agent_name=self._agent.name,
|
||||
model=self._agent.model,
|
||||
effort=self._agent.options.effort,
|
||||
conversation_id=conversation_id,
|
||||
session_id=live.session_id,
|
||||
usage=usage,
|
||||
)
|
||||
)
|
||||
if turn.result is not None and turn.result.is_error:
|
||||
msg = f"claude: {turn.result.result or turn.result.subtype}"
|
||||
raise RuntimeError(msg)
|
||||
yield build_message_delta(
|
||||
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
|
||||
)
|
||||
yield build_message_stop()
|
||||
|
||||
async def _run_turn(self, live: _Live, prompt: str) -> _Turn:
|
||||
streaming = self._agent.options.include_partial_messages
|
||||
turn = _Turn()
|
||||
raw: list[Any] = []
|
||||
next_index = 0
|
||||
offset = 0
|
||||
await live.client.query(prompt)
|
||||
async for message in live.client.receive_response():
|
||||
if getattr(message, "parent_tool_use_id", None) is not None:
|
||||
continue
|
||||
if isinstance(message, StreamEvent):
|
||||
event = message.event
|
||||
if event.get("type") == "message_start":
|
||||
offset = next_index
|
||||
continue
|
||||
index = event.get("index")
|
||||
if isinstance(index, int):
|
||||
next_index = max(next_index, offset + index + 1)
|
||||
if streaming:
|
||||
turn.events.extend(_emit_stream_event(event, offset + index))
|
||||
elif isinstance(message, AssistantMessage):
|
||||
raw.append(message)
|
||||
if not streaming:
|
||||
for block in message.content:
|
||||
turn.events.extend(_emit_block(block, next_index))
|
||||
next_index += 1
|
||||
elif isinstance(message, UserMessage):
|
||||
raw.append(message)
|
||||
elif isinstance(message, ResultMessage):
|
||||
turn.result = message
|
||||
turn.stop_reason = _STOP_REASONS.get(
|
||||
message.stop_reason or "", "end_turn"
|
||||
)
|
||||
turn.synthesized = synthesize_turn_messages(raw)
|
||||
_log.info(
|
||||
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
|
||||
self._agent.name,
|
||||
live.session_id,
|
||||
len(turn.events),
|
||||
len(turn.synthesized),
|
||||
turn.stop_reason,
|
||||
)
|
||||
return turn
|
||||
|
||||
async def _acquire(
|
||||
self, key: str, *, session_id: str | None, history: list[dict[str, Any]]
|
||||
) -> _Live:
|
||||
live = self._sessions.get(key)
|
||||
if live is not None:
|
||||
return live
|
||||
resume = session_id
|
||||
if resume is None and history:
|
||||
resume = await self._seed(history)
|
||||
live = await self._spawn(resume)
|
||||
self._sessions[key] = live
|
||||
return live
|
||||
|
||||
async def _seed(self, history: list[dict[str, Any]]) -> str:
|
||||
session_id = str(uuid.uuid4())
|
||||
entries = build_entries(
|
||||
history,
|
||||
session_id=session_id,
|
||||
cwd=str(self._agent.cwd),
|
||||
model=self._agent.model,
|
||||
permission_mode=self._agent.options.permission_mode,
|
||||
)
|
||||
key = {
|
||||
"project_key": project_key_for_directory(str(self._agent.cwd)),
|
||||
"session_id": session_id,
|
||||
}
|
||||
await self._store.append(cast("Any", key), cast("Any", entries))
|
||||
_log.info(
|
||||
"seeded session %s with %d entries from %d messages",
|
||||
session_id,
|
||||
len(entries),
|
||||
len(history),
|
||||
)
|
||||
return session_id
|
||||
|
||||
async def _spawn(self, resume: str | None) -> _Live:
|
||||
options = self._build_options(resume)
|
||||
client = self._factory(options)
|
||||
await client.connect()
|
||||
_log.info(
|
||||
"spawned claude: agent=%s resume=%s user=%s",
|
||||
self._agent.name,
|
||||
resume,
|
||||
self._runner.user,
|
||||
)
|
||||
return _Live(client=client, session_id=resume, resumed=resume is not None)
|
||||
|
||||
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
|
||||
return _RunnerClient(options, uid=self._uid)
|
||||
|
||||
def _build_options(self, resume: str | None) -> ClaudeAgentOptions:
|
||||
agent = self._agent
|
||||
opt = agent.options
|
||||
env = dict(opt.env)
|
||||
if self._runner.home is not None:
|
||||
env["HOME"] = str(self._runner.home)
|
||||
plugins = self._plugins()
|
||||
system_prompt = (
|
||||
prompt_assembly.assemble(agent.prompt_sources)
|
||||
if agent.prompt_sources
|
||||
else agent.system_prompt
|
||||
)
|
||||
return ClaudeAgentOptions(
|
||||
model=agent.model or None,
|
||||
effort=cast("Any", opt.effort),
|
||||
system_prompt=system_prompt,
|
||||
setting_sources=[],
|
||||
strict_mcp_config=True,
|
||||
mcp_servers=cast("Any", self._servers),
|
||||
permission_mode=cast("Any", opt.permission_mode),
|
||||
tools=list(opt.tools) if opt.tools is not None else None,
|
||||
disallowed_tools=[*opt.disallowed_tools, *self._mcp_disallowed],
|
||||
cwd=str(agent.cwd),
|
||||
add_dirs=list(opt.add_dirs),
|
||||
env=env,
|
||||
user=self._runner.user,
|
||||
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
|
||||
include_partial_messages=opt.include_partial_messages,
|
||||
session_store=self._store,
|
||||
session_store_flush=cast("Any", opt.session_store_flush),
|
||||
resume=resume,
|
||||
plugins=cast("Any", plugins),
|
||||
skills="all" if plugins else None,
|
||||
max_turns=opt.max_turns,
|
||||
stderr=lambda line: _log.warning(
|
||||
"claude[%s]: %s", agent.name, line.rstrip()
|
||||
),
|
||||
)
|
||||
|
||||
def _plugins(self) -> list[dict[str, str]]:
|
||||
plugins: list[dict[str, str]] = []
|
||||
root = self._work_dir / "plugins" / self._agent.name
|
||||
for raw in sorted(self._agent.skill_sets, key=str):
|
||||
source = Path(str(raw))
|
||||
name = source.name
|
||||
target = root / name
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(source, target / "skills")
|
||||
(target / ".claude-plugin").mkdir(parents=True, exist_ok=True)
|
||||
(target / ".claude-plugin" / "plugin.json").write_text(
|
||||
json.dumps({"name": name, "version": "0.0.0"}), encoding="utf-8"
|
||||
)
|
||||
_chmod_tree(target)
|
||||
plugins.append({"type": "local", "path": str(target)})
|
||||
return plugins
|
||||
|
||||
def _exec_wrapper(self, *, extra_keep: tuple[str, ...]) -> Path:
|
||||
if self._wrapper is not None:
|
||||
return self._wrapper
|
||||
keep = sorted({*ENV_KEEP, *self._agent.options.env_keep, *extra_keep})
|
||||
target = _claude_binary()
|
||||
script = _WRAPPER.format(
|
||||
python=sys.executable,
|
||||
target=json.dumps(target),
|
||||
keep=json.dumps(keep),
|
||||
prefixes=json.dumps(list(ENV_KEEP_PREFIXES)),
|
||||
)
|
||||
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()[:12]
|
||||
path = self._work_dir / f"claude-exec-{digest}.py"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != script:
|
||||
path.write_text(script, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
path.parent.chmod(0o755)
|
||||
self._wrapper = path
|
||||
return path
|
||||
|
||||
def _rekey(self, old: str, new: str) -> None:
|
||||
live = self._sessions.pop(old, None)
|
||||
if live is None:
|
||||
return
|
||||
stale = self._sessions.pop(new, None)
|
||||
self._sessions[new] = live
|
||||
if stale is not None and stale is not live:
|
||||
asyncio.get_running_loop().create_task(_disconnect(stale))
|
||||
|
||||
async def _close(self, key: str) -> None:
|
||||
live = self._sessions.pop(key, None)
|
||||
if live is not None:
|
||||
await _disconnect(live)
|
||||
|
||||
async def _reap_loop(self) -> None:
|
||||
ttl = self._agent.options.idle_session_ttl
|
||||
while True:
|
||||
await asyncio.sleep(_REAP_INTERVAL)
|
||||
if ttl <= 0:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
for key, live in list(self._sessions.items()):
|
||||
if not live.lock.locked() and now - live.last_used > ttl:
|
||||
_log.info("closing idle session %s (%s)", live.session_id, key)
|
||||
await self._close(key)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Turn:
|
||||
events: list[Any] = field(default_factory=list)
|
||||
synthesized: list[dict[str, Any]] = field(default_factory=list)
|
||||
result: ResultMessage | None = None
|
||||
stop_reason: StopReason = "end_turn"
|
||||
|
||||
|
||||
_WRAPPER = """#!{python}
|
||||
import os
|
||||
import sys
|
||||
|
||||
TARGET = {target}
|
||||
KEEP = set({keep})
|
||||
PREFIXES = tuple({prefixes})
|
||||
env = {{
|
||||
k: v for k, v in os.environ.items() if k in KEEP or k.startswith(PREFIXES)
|
||||
}}
|
||||
os.execve(TARGET, [TARGET, *sys.argv[1:]], env)
|
||||
"""
|
||||
|
||||
|
||||
async def _disconnect(live: _Live) -> None:
|
||||
try:
|
||||
await live.client.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("disconnect failed for session %s", live.session_id)
|
||||
|
||||
|
||||
def _claude_binary() -> str:
|
||||
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
found = shutil.which("claude")
|
||||
if found is None:
|
||||
msg = "claude CLI not found: neither bundled in claude_agent_sdk nor on PATH"
|
||||
raise FileNotFoundError(msg)
|
||||
return found
|
||||
|
||||
|
||||
def _resolve_uid(user: str | None) -> int | None:
|
||||
if user is None:
|
||||
return None
|
||||
if user.isdigit():
|
||||
return int(user)
|
||||
return pwd.getpwnam(user).pw_uid
|
||||
|
||||
|
||||
def _chown_tree(root: Path, uid: int) -> None:
|
||||
for path in [root, *root.rglob("*")]:
|
||||
with contextlib.suppress(OSError):
|
||||
os.chown(path, uid, -1)
|
||||
|
||||
|
||||
def _chmod_tree(root: Path) -> None:
|
||||
for path in [root, *root.rglob("*")]:
|
||||
with contextlib.suppress(OSError):
|
||||
path.chmod(0o755 if path.is_dir() else 0o644)
|
||||
|
||||
|
||||
def _mcp_servers(
|
||||
agent: ClaudeAgent, urls: Mapping[str, str]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
servers: dict[str, dict[str, Any]] = {}
|
||||
for exposed in agent.expose_mcps:
|
||||
url = urls.get(exposed.name)
|
||||
if url is None:
|
||||
msg = f"agent {agent.name!r} exposes MCP {exposed.name!r} without a URL"
|
||||
raise ValueError(msg)
|
||||
servers[exposed.name] = {"type": "http", "url": url}
|
||||
return servers
|
||||
|
||||
|
||||
def _mcp_disallowed(
|
||||
agent: ClaudeAgent, catalog: Mapping[str, Sequence[str]]
|
||||
) -> list[str]:
|
||||
out: list[str] = []
|
||||
for exposed in agent.expose_mcps:
|
||||
if exposed.tools is None and not exposed.deny:
|
||||
continue
|
||||
names = list(catalog.get(exposed.name, ()))
|
||||
if not names:
|
||||
_log.warning(
|
||||
"MCP %r has no tool catalog; disallowing the whole server for %s",
|
||||
exposed.name,
|
||||
agent.name,
|
||||
)
|
||||
out.append(f"mcp__{exposed.name}")
|
||||
continue
|
||||
for name in names:
|
||||
allowed = exposed.tools is None or name in exposed.tools
|
||||
denied = any(fnmatch.fnmatchcase(name, pat) for pat in exposed.deny)
|
||||
if not allowed or denied:
|
||||
out.append(f"mcp__{exposed.name}__{name}")
|
||||
return out
|
||||
|
||||
|
||||
def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str:
|
||||
turns: list[tuple[str, str]] = []
|
||||
for message in messages:
|
||||
text = _text_of(message.get("content"))
|
||||
if not text:
|
||||
continue
|
||||
role = str(message.get("role", ""))
|
||||
if turns and turns[-1][0] == role:
|
||||
turns[-1] = (role, turns[-1][1] + "\n" + text)
|
||||
else:
|
||||
turns.append((role, text))
|
||||
digest = hashlib.sha1(usedforsecurity=False)
|
||||
for role, text in turns:
|
||||
digest.update(role.encode("utf-8"))
|
||||
digest.update(b"\x00")
|
||||
digest.update(text.strip().encode("utf-8"))
|
||||
digest.update(b"\x01")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _text_of(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
str(b.get("text", ""))
|
||||
for b in content
|
||||
if isinstance(b, Mapping) and b.get("type") == "text"
|
||||
]
|
||||
return "\n".join(p for p in parts if p)
|
||||
return ""
|
||||
|
||||
|
||||
def _prompt_text(content: Any) -> str:
|
||||
text = _text_of(content)
|
||||
if not text:
|
||||
msg = "user message has no text content"
|
||||
raise ValueError(msg)
|
||||
return text
|
||||
|
||||
|
||||
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for message in raw:
|
||||
if isinstance(message, AssistantMessage):
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [_block_to_dict(b) for b in message.content],
|
||||
}
|
||||
)
|
||||
elif isinstance(message, UserMessage):
|
||||
content = message.content
|
||||
if isinstance(content, list) and content:
|
||||
out.append(
|
||||
{"role": "user", "content": [_block_to_dict(b) for b in content]}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _block_to_dict(block: Any) -> dict[str, Any]:
|
||||
if isinstance(block, TextBlock):
|
||||
return {"type": "text", "text": block.text}
|
||||
if isinstance(block, ToolUseBlock):
|
||||
return {
|
||||
"type": "tool_use",
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"input": block.input,
|
||||
}
|
||||
if isinstance(block, ToolResultBlock):
|
||||
result: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.tool_use_id,
|
||||
"content": block.content,
|
||||
}
|
||||
if block.is_error is not None:
|
||||
result["is_error"] = block.is_error
|
||||
return result
|
||||
if isinstance(block, ThinkingBlock):
|
||||
return {
|
||||
"type": "thinking",
|
||||
"thinking": block.thinking,
|
||||
"signature": block.signature,
|
||||
}
|
||||
msg = f"unknown content block type: {type(block).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
def _usage_of(result: ResultMessage | None) -> TurnUsage:
|
||||
if result is None:
|
||||
return TurnUsage()
|
||||
usage = result.usage or {}
|
||||
return TurnUsage(
|
||||
input_tokens=_int(usage.get("input_tokens")),
|
||||
output_tokens=_int(usage.get("output_tokens")),
|
||||
cache_read_tokens=_int(usage.get("cache_read_input_tokens")),
|
||||
cache_creation_tokens=_int(usage.get("cache_creation_input_tokens")),
|
||||
cost_usd=result.total_cost_usd,
|
||||
duration_ms=result.duration_ms,
|
||||
num_turns=result.num_turns,
|
||||
)
|
||||
|
||||
|
||||
def _wire_usage(usage: TurnUsage) -> dict[str, int]:
|
||||
return {
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"cache_read_input_tokens": usage.cache_read_tokens,
|
||||
"cache_creation_input_tokens": usage.cache_creation_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _emit_stream_event(
|
||||
event: Mapping[str, Any], index: int
|
||||
) -> Iterable[MessageStreamEvent]:
|
||||
etype = event.get("type")
|
||||
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: Any, index: int) -> Iterable[MessageStreamEvent]:
|
||||
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 ()
|
||||
@@ -26,11 +26,12 @@ tools we route it back to the underlying MCP in-process, append the
|
||||
result as a ``tool`` message, and re-issue the stream — all inside one
|
||||
Anthropic envelope (one ``message_start`` … one ``message_stop``). The
|
||||
tool_use blocks DO surface to the caller (mirrors what
|
||||
``ClaudeCodeBackendAdapter`` does), but tool_results stay internal.
|
||||
``ClaudeSdkBackend`` does), but tool_results stay internal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -297,6 +298,8 @@ def _build_agent_catalog(
|
||||
for mt in mcp_tools.get(em.name, []):
|
||||
if em.tools is not None and mt.name not in em.tools:
|
||||
continue
|
||||
if any(fnmatch.fnmatchcase(mt.name, pat) for pat in em.deny):
|
||||
continue
|
||||
wire_name = f"{em.name}__{mt.name}"
|
||||
routing[wire_name] = (em.name, mt.name)
|
||||
local_tools.append(
|
||||
|
||||
+49
-13
@@ -36,14 +36,18 @@ from raycast_api.config import Config as RaycastConfig
|
||||
from beaver_gateway import config_loader
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.agents.raycast import RaycastAgent
|
||||
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter
|
||||
from beaver_gateway.backends.claude_sdk import (
|
||||
ClaudeSdkBackend,
|
||||
RunnerConfig,
|
||||
UsageEvent,
|
||||
)
|
||||
from beaver_gateway.backends.raycast import RaycastBackend
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||
from beaver_gateway.settings import Settings
|
||||
from beaver_gateway.storage import Database
|
||||
from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp import FastMCP
|
||||
@@ -122,8 +126,8 @@ async def _async_main() -> None:
|
||||
await token_store.start()
|
||||
stack.push_async_callback(token_store.stop)
|
||||
# Internal MCP URLs must exist before we construct any
|
||||
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
|
||||
# ``BackendOptions.mcp_servers`` at construction time. The
|
||||
# ClaudeSdkBackend - adapters bake the URLs into their
|
||||
# ``mcp_servers`` at construction time. The
|
||||
# ``mcp_servers`` map is used by the Raycast backend, which
|
||||
# needs in-process ``list_tools`` / ``call_tool`` access (the
|
||||
# Raycast wire has no native MCP concept).
|
||||
@@ -140,6 +144,7 @@ async def _async_main() -> None:
|
||||
settings=settings,
|
||||
agents=agents,
|
||||
stack=stack,
|
||||
db=db,
|
||||
mcp_internal_urls=internal_urls,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_tools=mcp_tools,
|
||||
@@ -252,6 +257,7 @@ async def _build_backends(
|
||||
settings: Settings,
|
||||
agents: AgentRegistry,
|
||||
stack: AsyncExitStack,
|
||||
db: Database,
|
||||
mcp_internal_urls: dict[str, str],
|
||||
mcp_servers: dict[str, FastMCP],
|
||||
mcp_tools: dict[str, list[FastMCPTool]],
|
||||
@@ -259,15 +265,13 @@ async def _build_backends(
|
||||
"""Construct one backend per agent name.
|
||||
|
||||
The Raycast ``Client`` is shared across every ``RaycastAgent``
|
||||
(bearer + device-id are process-wide), so we open it lazily — only
|
||||
when at least one ``RaycastAgent`` is present — and close it via
|
||||
(bearer + device-id are process-wide), so we open it lazily - only
|
||||
when at least one ``RaycastAgent`` is present - and close it via
|
||||
the caller's exit stack.
|
||||
|
||||
Each :class:`ClaudeAgent` gets its own
|
||||
:class:`ClaudeCodeBackendAdapter`: ``BackendOptions`` pins
|
||||
``cwd`` / ``model`` / ``system_prompt`` / ``mcp_servers`` for the
|
||||
lifetime of the underlying ``ClaudeCodeBackend``, so different
|
||||
agents can't share one.
|
||||
Each :class:`ClaudeAgent` gets its own :class:`ClaudeSdkBackend`
|
||||
(own live-session pool, own prompt and MCP set); all of them share
|
||||
the session store and the usage sink.
|
||||
"""
|
||||
backends: dict[str, Backend] = {}
|
||||
|
||||
@@ -281,10 +285,42 @@ async def _build_backends(
|
||||
for a in raycast_agents:
|
||||
backends[a.name] = raycast_backend
|
||||
|
||||
session_store = PostgresSessionStore(db)
|
||||
runner = RunnerConfig(user=settings.claude_runner_user, home=settings.claude_home)
|
||||
mcp_tool_names = {
|
||||
name: [t.name for t in tools] for name, tools in mcp_tools.items()
|
||||
}
|
||||
|
||||
async def record_usage(event: UsageEvent) -> None:
|
||||
row = Usage(
|
||||
agent_name=event.agent_name,
|
||||
conversation_id=event.conversation_id,
|
||||
session_id=event.session_id,
|
||||
model=event.model,
|
||||
effort=event.effort,
|
||||
input_tokens=event.usage.input_tokens,
|
||||
output_tokens=event.usage.output_tokens,
|
||||
cache_read_tokens=event.usage.cache_read_tokens,
|
||||
cache_creation_tokens=event.usage.cache_creation_tokens,
|
||||
cost_usd=event.usage.cost_usd,
|
||||
duration_ms=event.usage.duration_ms,
|
||||
num_turns=event.usage.num_turns,
|
||||
)
|
||||
try:
|
||||
async with db.session() as session:
|
||||
await append_usage(session, row)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("usage write failed for %s", event.agent_name)
|
||||
|
||||
for a in agents:
|
||||
if isinstance(a, ClaudeAgent):
|
||||
adapter = ClaudeCodeBackendAdapter(
|
||||
agent=a, mcp_internal_urls=mcp_internal_urls
|
||||
adapter = ClaudeSdkBackend(
|
||||
agent=a,
|
||||
mcp_internal_urls=mcp_internal_urls,
|
||||
session_store=session_store,
|
||||
mcp_tool_names=mcp_tool_names,
|
||||
runner=runner,
|
||||
usage_sink=record_usage,
|
||||
)
|
||||
await stack.enter_async_context(adapter)
|
||||
backends[a.name] = adapter
|
||||
|
||||
@@ -63,6 +63,7 @@ __all__ = [
|
||||
"load_messages",
|
||||
"mint_conversation",
|
||||
"rewrite_messages",
|
||||
"set_session_id",
|
||||
]
|
||||
|
||||
|
||||
@@ -121,6 +122,17 @@ async def mint_conversation(
|
||||
return row
|
||||
|
||||
|
||||
async def set_session_id(
|
||||
session: AsyncSession, *, conversation_id: int, session_id: str | None
|
||||
) -> None:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv is None or conv.session_id == session_id:
|
||||
return
|
||||
conv.session_id = session_id
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def load_messages(
|
||||
session: AsyncSession, *, conversation_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""System prompt assembly from a list of source files.
|
||||
|
||||
The gateway holds no prompt text: an agent names its granules (paths from
|
||||
``config.py``) and :func:`assemble` concatenates them in that order, so the
|
||||
result is byte-for-byte identical for every session of the same agent as
|
||||
long as the files are. Each granule's hash is logged at assembly so a
|
||||
drifted prompt can be traced to the file that changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
__all__ = ["assemble"]
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.core.prompt")
|
||||
|
||||
|
||||
def assemble(sources: Iterable[str | Path]) -> str:
|
||||
parts: list[str] = []
|
||||
for source in sources:
|
||||
path = Path(source)
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
_log.info("prompt granule %s sha=%s bytes=%d", path, digest, len(text))
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts) + "\n"
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Anthropic messages <-> Agent SDK transcript entries.
|
||||
|
||||
:func:`build_entries` renders a message list as the entries the Claude CLI
|
||||
itself writes (reference: ``t/spike_sdk/entries_reference.json``, CLI
|
||||
2.1.248 via ``import_session_to_store``): one ``user`` entry per prompt,
|
||||
one ``assistant`` entry per content block sharing a message id, one
|
||||
``user`` entry per ``tool_result`` parented on the matching ``tool_use``
|
||||
entry. Only ``user``/``assistant`` entries are produced - no attachments,
|
||||
titles or queue markers. Appending the result to a session store and
|
||||
resuming that session id seeds an external history into the SDK.
|
||||
|
||||
:func:`messages_from_entries` is the projection back, used by tests and by
|
||||
anything that needs Anthropic-shape history out of a mirrored transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk._cli_version import __cli_version__ as _cli_version
|
||||
except ImportError: # pragma: no cover
|
||||
_cli_version = "2.1.248"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
__all__ = ["CLI_VERSION", "build_entries", "messages_from_entries"]
|
||||
|
||||
CLI_VERSION = _cli_version
|
||||
_ENTRYPOINT = "sdk-py"
|
||||
_USER_TYPE = "external"
|
||||
_PROMPT_SOURCE = "sdk"
|
||||
_GIT_BRANCH = "HEAD"
|
||||
|
||||
|
||||
def build_entries(
|
||||
messages: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
model: str,
|
||||
permission_mode: str = "bypassPermissions",
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + (
|
||||
f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z"
|
||||
)
|
||||
common = {
|
||||
"isSidechain": False,
|
||||
"userType": _USER_TYPE,
|
||||
"entrypoint": _ENTRYPOINT,
|
||||
"cwd": cwd,
|
||||
"sessionId": session_id,
|
||||
"version": CLI_VERSION,
|
||||
"gitBranch": _GIT_BRANCH,
|
||||
}
|
||||
entries: list[dict[str, Any]] = []
|
||||
parent: str | None = None
|
||||
prompt_id: str | None = None
|
||||
tool_use_owner: dict[str, str] = {}
|
||||
|
||||
for message in messages:
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
if role == "user":
|
||||
results = _tool_results(content)
|
||||
if results:
|
||||
for block in results:
|
||||
owner = tool_use_owner.get(
|
||||
str(block.get("tool_use_id", "")), parent
|
||||
)
|
||||
uid = _new_uuid()
|
||||
entries.append(
|
||||
{
|
||||
"parentUuid": owner,
|
||||
"promptId": prompt_id,
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": [block]},
|
||||
"uuid": uid,
|
||||
"timestamp": stamp,
|
||||
"toolUseResult": _result_text(block),
|
||||
"sourceToolAssistantUUID": owner,
|
||||
**common,
|
||||
}
|
||||
)
|
||||
parent = uid
|
||||
continue
|
||||
prompt_id = _new_uuid()
|
||||
uid = _new_uuid()
|
||||
entries.append(
|
||||
{
|
||||
"parentUuid": parent,
|
||||
"promptId": prompt_id,
|
||||
"type": "user",
|
||||
"message": {"role": "user", "content": _user_content(content)},
|
||||
"uuid": uid,
|
||||
"timestamp": stamp,
|
||||
"permissionMode": permission_mode,
|
||||
"promptSource": _PROMPT_SOURCE,
|
||||
**common,
|
||||
}
|
||||
)
|
||||
parent = uid
|
||||
elif role == "assistant":
|
||||
blocks = _assistant_blocks(content)
|
||||
message_id = f"msg_{_uuid.uuid4().hex[:24]}"
|
||||
request_id = f"req_{_uuid.uuid4().hex[:24]}"
|
||||
stop_reason = (
|
||||
"tool_use"
|
||||
if any(b.get("type") == "tool_use" for b in blocks)
|
||||
else "end_turn"
|
||||
)
|
||||
for block in blocks:
|
||||
uid = _new_uuid()
|
||||
entries.append(
|
||||
{
|
||||
"parentUuid": parent,
|
||||
"message": {
|
||||
"model": model,
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [block],
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": None,
|
||||
"stop_details": None,
|
||||
"usage": _zero_usage(),
|
||||
"diagnostics": None,
|
||||
},
|
||||
"requestId": request_id,
|
||||
"type": "assistant",
|
||||
"uuid": uid,
|
||||
"timestamp": stamp,
|
||||
**common,
|
||||
}
|
||||
)
|
||||
if block.get("type") == "tool_use" and block.get("id"):
|
||||
tool_use_owner[str(block["id"])] = uid
|
||||
parent = uid
|
||||
else:
|
||||
msg = f"message role must be user or assistant, got {role!r}"
|
||||
raise ValueError(msg)
|
||||
return entries
|
||||
|
||||
|
||||
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
last_message_id: str | None = None
|
||||
for entry in entries:
|
||||
kind = entry.get("type")
|
||||
message = entry.get("message")
|
||||
if kind not in ("user", "assistant") or not isinstance(message, dict):
|
||||
continue
|
||||
content = message.get("content")
|
||||
if kind == "user":
|
||||
results = _tool_results(content)
|
||||
if (
|
||||
results
|
||||
and out
|
||||
and out[-1]["role"] == "user"
|
||||
and _tool_results(out[-1]["content"])
|
||||
):
|
||||
out[-1]["content"].extend(results)
|
||||
else:
|
||||
out.append({"role": "user", "content": _user_content(content)})
|
||||
last_message_id = None
|
||||
continue
|
||||
blocks = _assistant_blocks(content)
|
||||
message_id = message.get("id")
|
||||
if (
|
||||
out
|
||||
and out[-1]["role"] == "assistant"
|
||||
and message_id is not None
|
||||
and message_id == last_message_id
|
||||
):
|
||||
out[-1]["content"].extend(blocks)
|
||||
else:
|
||||
out.append({"role": "assistant", "content": blocks})
|
||||
last_message_id = message_id
|
||||
return out
|
||||
|
||||
|
||||
def _new_uuid() -> str:
|
||||
return str(_uuid.uuid4())
|
||||
|
||||
|
||||
def _tool_results(content: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
return [
|
||||
_clean_block(b)
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
]
|
||||
|
||||
|
||||
def _user_content(content: Any) -> Any:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return [_clean_block(b) for b in content if isinstance(b, dict)]
|
||||
msg = f"user content must be str or list, got {type(content).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
def _assistant_blocks(content: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(content, str):
|
||||
return [{"type": "text", "text": content}]
|
||||
if isinstance(content, list):
|
||||
blocks = [_clean_block(b) for b in content if isinstance(b, dict)]
|
||||
if blocks:
|
||||
return blocks
|
||||
msg = "assistant content must be a non-empty list of blocks"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _clean_block(block: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = block.get("type")
|
||||
if kind == "tool_use":
|
||||
return {
|
||||
"type": "tool_use",
|
||||
"id": block.get("id", ""),
|
||||
"name": block.get("name", ""),
|
||||
"input": block.get("input", {}),
|
||||
}
|
||||
if kind == "tool_result":
|
||||
out: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.get("tool_use_id", ""),
|
||||
"content": block.get("content", ""),
|
||||
}
|
||||
if block.get("is_error") is not None:
|
||||
out["is_error"] = bool(block["is_error"])
|
||||
return out
|
||||
if kind == "thinking":
|
||||
return {
|
||||
"type": "thinking",
|
||||
"thinking": block.get("thinking", ""),
|
||||
"signature": block.get("signature", ""),
|
||||
}
|
||||
if kind == "text":
|
||||
return {"type": "text", "text": str(block.get("text", ""))}
|
||||
return dict(block)
|
||||
|
||||
|
||||
def _result_text(block: dict[str, Any]) -> str:
|
||||
content = block.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(
|
||||
str(b.get("text", ""))
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _zero_usage() -> dict[str, Any]:
|
||||
return {
|
||||
"input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"service_tier": "standard",
|
||||
"server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
|
||||
"cache_creation": {
|
||||
"ephemeral_1h_input_tokens": 0,
|
||||
"ephemeral_5m_input_tokens": 0,
|
||||
},
|
||||
"inference_geo": "not_available",
|
||||
"iterations": [],
|
||||
"speed": "standard",
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Side channel for what a backend learned during one turn.
|
||||
|
||||
Frontends pass ``capture=TurnCapture()`` through ``Backend.complete``'s
|
||||
``**options``. Backends that keep state per conversation (the Claude SDK
|
||||
adapter) fill it in after the stream closes; backends that don't
|
||||
(anthropic, raycast) drop the kwarg and the frontend falls back to a
|
||||
text-only history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["TurnCapture", "TurnUsage"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnUsage:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_creation_tokens: int = 0
|
||||
cost_usd: float | None = None
|
||||
duration_ms: int | None = None
|
||||
num_turns: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnCapture:
|
||||
synthesized_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
"""The assistant/tool_result cycle of the turn as Anthropic messages."""
|
||||
|
||||
session_id: str | None = None
|
||||
"""Backend session that ran the turn; persist and pass back as ``session_id``."""
|
||||
|
||||
usage: TurnUsage | None = None
|
||||
@@ -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,6 +658,7 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
@@ -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,6 +774,7 @@ class MarkdownFrontend(Frontend):
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
if write_disk:
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
@@ -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,6 +808,7 @@ 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)
|
||||
if write_disk:
|
||||
await _write_atomic(file_path, new_content)
|
||||
return new_content
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -17,7 +17,7 @@ heavy setups (PRD §6 cites the ~95%→~71% tool-selection drop on flat
|
||||
namespaces) but real and reachable.
|
||||
|
||||
The aggregator returns both the app and a ``{name: url}`` map; Phase
|
||||
2.2's ``ClaudeCodeBackendAdapter`` plugs the map directly into
|
||||
``ClaudeSdkBackend`` plugs the map directly into
|
||||
``BackendOptions.mcp_servers``. ``/mcp/all/`` is NOT included in that
|
||||
map — claude-code-agents always get per-domain framing; only the
|
||||
external MCP frontend (Phase 3.1) reverse-proxies the flat endpoint.
|
||||
|
||||
@@ -27,6 +27,12 @@ class Settings(BaseSettings):
|
||||
internal_mcp_port: int = 8765
|
||||
config_path: Path = Path("/config/config.py")
|
||||
|
||||
claude_runner_user: str | None = None
|
||||
"""Unix user the claude subprocess runs as. Needs the gateway to be root."""
|
||||
|
||||
claude_home: Path | None = None
|
||||
"""``HOME`` for the claude subprocess (its ``~/.claude`` lives there)."""
|
||||
|
||||
raycast_bearer: str | None = None
|
||||
raycast_config_path: Path = Path("/config/raycast.json")
|
||||
raycast_device_id: str | None = None
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""SQLModel-backed persistence.
|
||||
|
||||
Two tables — :class:`Token`, :class:`AuditLog` — plus a thin
|
||||
:class:`Database` wrapper around an async SQLAlchemy engine. The
|
||||
``GatewayRuntime`` carries the handle so auth (token verify / touch)
|
||||
and admin (token CRUD + audit listing) can reach it.
|
||||
Tokens, audit, conversations, Agent SDK transcript entries and per-turn
|
||||
usage, plus a thin :class:`Database` wrapper around an async SQLAlchemy
|
||||
engine. ``GatewayRuntime`` carries the handle.
|
||||
"""
|
||||
|
||||
from beaver_gateway.storage.db import (
|
||||
Database,
|
||||
append_audit,
|
||||
append_usage,
|
||||
create_token,
|
||||
list_active_tokens,
|
||||
list_audit_records,
|
||||
@@ -16,13 +16,18 @@ from beaver_gateway.storage.db import (
|
||||
revoke_token,
|
||||
touch_token,
|
||||
)
|
||||
from beaver_gateway.storage.models import AuditLog, Token
|
||||
from beaver_gateway.storage.models import AuditLog, Token, TranscriptEntry, Usage
|
||||
from beaver_gateway.storage.session_store import PostgresSessionStore
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Database",
|
||||
"PostgresSessionStore",
|
||||
"Token",
|
||||
"TranscriptEntry",
|
||||
"Usage",
|
||||
"append_audit",
|
||||
"append_usage",
|
||||
"create_token",
|
||||
"list_active_tokens",
|
||||
"list_audit_records",
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from beaver_gateway.storage.models import AuditLog, Token
|
||||
from beaver_gateway.storage.models import AuditLog, Token, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -183,9 +183,18 @@ async def list_audit_records(
|
||||
return result.all()
|
||||
|
||||
|
||||
# ---- Usage --------------------------------------------------------------
|
||||
|
||||
|
||||
async def append_usage(session: AsyncSession, row: Usage) -> None:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Database",
|
||||
"append_audit",
|
||||
"append_usage",
|
||||
"create_token",
|
||||
"list_active_tokens",
|
||||
"list_audit_records",
|
||||
|
||||
@@ -23,8 +23,10 @@ needs an id uses ``Optional[int]`` so SQLAlchemy can autoincrement.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
@@ -92,6 +94,7 @@ class Conversation(SQLModel, table=True):
|
||||
frontend: str = Field(index=True)
|
||||
external_id: str = Field(index=True)
|
||||
agent_name: str = Field(index=True)
|
||||
session_id: str | None = Field(default=None, index=True)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
updated_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
@@ -125,4 +128,73 @@ class ConversationMessage(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
__all__ = ["AuditLog", "Conversation", "ConversationMessage", "Token"]
|
||||
class TranscriptEntry(SQLModel, table=True):
|
||||
"""One Agent SDK transcript line, mirrored from the session store protocol.
|
||||
|
||||
``(project_key, session_id, subpath)`` is the ``SessionKey``; ``subpath``
|
||||
is ``""`` for the main transcript so the unique constraints stay simple.
|
||||
``uuid`` is the SDK's idempotency key - the partial unique index rejects
|
||||
a replayed batch, entries without a uuid are appended as-is.
|
||||
"""
|
||||
|
||||
__tablename__ = "transcript_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"project_key", "session_id", "subpath", "seq", name="uq_transcript_seq"
|
||||
),
|
||||
Index(
|
||||
"uq_transcript_uuid",
|
||||
"project_key",
|
||||
"session_id",
|
||||
"subpath",
|
||||
"uuid",
|
||||
unique=True,
|
||||
postgresql_where=text("uuid IS NOT NULL"),
|
||||
sqlite_where=text("uuid IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_key: str = Field(index=True)
|
||||
session_id: str = Field(index=True)
|
||||
subpath: str = Field(default="")
|
||||
seq: int
|
||||
uuid: str | None = Field(default=None)
|
||||
entry: dict[str, Any] = Field(
|
||||
sa_column=Column(JSON().with_variant(JSONB(), "postgresql"), nullable=False)
|
||||
)
|
||||
origin: str | None = Field(default=None)
|
||||
source: str | None = Field(default=None)
|
||||
turn_id: str | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class Usage(SQLModel, table=True):
|
||||
"""Per-turn token accounting from ``ResultMessage.usage``."""
|
||||
|
||||
__tablename__ = "usage"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
ts: datetime = Field(default_factory=_utcnow, index=True)
|
||||
agent_name: str = Field(index=True)
|
||||
conversation_id: str | None = Field(default=None, index=True)
|
||||
session_id: str | None = Field(default=None, index=True)
|
||||
model: str
|
||||
effort: str | None = Field(default=None)
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_creation_tokens: int = 0
|
||||
cost_usd: float | None = Field(default=None)
|
||||
duration_ms: int | None = Field(default=None)
|
||||
num_turns: int | None = Field(default=None)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Conversation",
|
||||
"ConversationMessage",
|
||||
"Token",
|
||||
"TranscriptEntry",
|
||||
"Usage",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""``SessionStore`` adapter for the Claude Agent SDK on top of :class:`Database`.
|
||||
|
||||
Entries are stored verbatim as JSON (``jsonb`` on Postgres), ordered by a
|
||||
per-key ``seq``. ``append`` is idempotent on ``entry["uuid"]`` because the
|
||||
SDK re-delivers a batch on retry; entries without a uuid are appended as
|
||||
they come. Runs on SQLite too - the conformance suite uses that in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from claude_agent_sdk import SessionStore
|
||||
from sqlalchemy import delete, func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from beaver_gateway.storage.models import TranscriptEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from claude_agent_sdk.types import (
|
||||
SessionKey,
|
||||
SessionListSubkeysKey,
|
||||
SessionStoreEntry,
|
||||
)
|
||||
|
||||
from beaver_gateway.storage.db import Database
|
||||
|
||||
__all__ = ["PostgresSessionStore"]
|
||||
|
||||
|
||||
class PostgresSessionStore(SessionStore):
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None:
|
||||
if not entries:
|
||||
return
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
wanted = [
|
||||
u for u in (e.get("uuid") for e in entries) if isinstance(u, str) and u
|
||||
]
|
||||
async with self._db.session() as session:
|
||||
known: set[str] = set()
|
||||
if wanted:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.uuid).where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
col(TranscriptEntry.uuid).in_(wanted),
|
||||
)
|
||||
)
|
||||
known = {u for u in result.all() if u is not None}
|
||||
seq_result = await session.exec(
|
||||
select(func.max(TranscriptEntry.seq)).where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
)
|
||||
)
|
||||
seq = seq_result.one() or 0
|
||||
for entry in entries:
|
||||
uuid = entry.get("uuid")
|
||||
uuid_str = uuid if isinstance(uuid, str) and uuid else None
|
||||
if uuid_str is not None:
|
||||
if uuid_str in known:
|
||||
continue
|
||||
known.add(uuid_str)
|
||||
seq += 1
|
||||
session.add(
|
||||
TranscriptEntry(
|
||||
project_key=project_key,
|
||||
session_id=session_id,
|
||||
subpath=subpath,
|
||||
seq=seq,
|
||||
uuid=uuid_str,
|
||||
entry=cast("dict[str, Any]", dict(entry)),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None:
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.entry)
|
||||
.where(
|
||||
TranscriptEntry.project_key == project_key,
|
||||
TranscriptEntry.session_id == session_id,
|
||||
TranscriptEntry.subpath == subpath,
|
||||
)
|
||||
.order_by(col(TranscriptEntry.seq))
|
||||
)
|
||||
rows = result.all()
|
||||
if not rows:
|
||||
return None
|
||||
return [cast("SessionStoreEntry", row) for row in rows]
|
||||
|
||||
async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]:
|
||||
async with self._db.session() as session:
|
||||
result = await session.exec(
|
||||
select(TranscriptEntry.subpath)
|
||||
.where(
|
||||
TranscriptEntry.project_key == key["project_key"],
|
||||
TranscriptEntry.session_id == key["session_id"],
|
||||
TranscriptEntry.subpath != "",
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return sorted(result.all())
|
||||
|
||||
async def delete(self, key: SessionKey) -> None:
|
||||
project_key, session_id, subpath = _parts(key)
|
||||
stmt = delete(TranscriptEntry).where(
|
||||
col(TranscriptEntry.project_key) == project_key,
|
||||
col(TranscriptEntry.session_id) == session_id,
|
||||
)
|
||||
if key.get("subpath"):
|
||||
stmt = stmt.where(col(TranscriptEntry.subpath) == subpath)
|
||||
async with self._db.session() as session:
|
||||
await session.execute(stmt) # ty: ignore[deprecated]
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _parts(key: SessionKey) -> tuple[str, str, str]:
|
||||
return key["project_key"], key["session_id"], key.get("subpath") or ""
|
||||
@@ -0,0 +1,385 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from anthropic.types import (
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
)
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
InMemorySessionStore,
|
||||
ResultMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
project_key_for_directory,
|
||||
)
|
||||
|
||||
from beaver_gateway.agents.base import ExposedMcp
|
||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend, UsageEvent, fingerprint
|
||||
from beaver_gateway.core.transcript import messages_from_entries
|
||||
from beaver_gateway.core.turn_capture import TurnCapture
|
||||
|
||||
|
||||
def _stream(index: int, text: str) -> list[StreamEvent]:
|
||||
def ev(event: dict[str, Any]) -> StreamEvent:
|
||||
return StreamEvent(uuid="u", session_id="s", event=event)
|
||||
|
||||
return [
|
||||
ev(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
}
|
||||
),
|
||||
ev(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
}
|
||||
),
|
||||
ev({"type": "content_block_stop", "index": index}),
|
||||
]
|
||||
|
||||
|
||||
def _result(session_id: str) -> ResultMessage:
|
||||
return ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=10,
|
||||
duration_api_ms=5,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id=session_id,
|
||||
stop_reason="end_turn",
|
||||
total_cost_usd=0.01,
|
||||
usage={
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 7,
|
||||
"cache_read_input_tokens": 100,
|
||||
"cache_creation_input_tokens": 20,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
instances: list["FakeClient"] = []
|
||||
|
||||
def __init__(self, options: Any) -> None:
|
||||
self.options = options
|
||||
self.prompts: list[str] = []
|
||||
self.connected = False
|
||||
self.session_id = options.resume or "fresh-session"
|
||||
FakeClient.instances.append(self)
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.connected = True
|
||||
|
||||
async def query(self, prompt: str) -> None:
|
||||
self.prompts.append(prompt)
|
||||
|
||||
async def receive_response(self):
|
||||
start = {"type": "message_start", "message": {}}
|
||||
yield StreamEvent(uuid="u", session_id="s", event=start)
|
||||
for e in _stream(0, "calling "):
|
||||
yield e
|
||||
tool = ToolUseBlock(id="toolu_1", name="Read", input={"path": "x"})
|
||||
yield StreamEvent(
|
||||
uuid="u",
|
||||
session_id="s",
|
||||
event={
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_1", "name": "Read"},
|
||||
},
|
||||
)
|
||||
yield StreamEvent(
|
||||
uuid="u", session_id="s", event={"type": "content_block_stop", "index": 1}
|
||||
)
|
||||
yield AssistantMessage(content=[TextBlock(text="calling "), tool], model="m")
|
||||
yield UserMessage(
|
||||
content=[ToolResultBlock(tool_use_id="toolu_1", content="42")]
|
||||
)
|
||||
yield StreamEvent(uuid="u", session_id="s", event=start)
|
||||
for e in _stream(0, "done"):
|
||||
yield e
|
||||
yield AssistantMessage(content=[TextBlock(text="done")], model="m")
|
||||
yield StreamEvent(
|
||||
uuid="sub",
|
||||
session_id="s",
|
||||
event={"type": "content_block_stop", "index": 5},
|
||||
parent_tool_use_id="toolu_9",
|
||||
)
|
||||
yield _result(self.session_id)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.connected = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cwd() -> Path:
|
||||
return Path(tempfile.mkdtemp(prefix="beaver-sdk-"))
|
||||
|
||||
|
||||
def _backend(
|
||||
cwd: Path, store: InMemorySessionStore, sink=None, **agent_kwargs
|
||||
) -> ClaudeSdkBackend:
|
||||
FakeClient.instances.clear()
|
||||
agent = ClaudeAgent(
|
||||
name="a",
|
||||
model="claude-x",
|
||||
system_prompt="hi",
|
||||
cwd=cwd,
|
||||
options=ClaudeOptions(effort="low"),
|
||||
**agent_kwargs,
|
||||
)
|
||||
return ClaudeSdkBackend(
|
||||
agent=agent,
|
||||
mcp_internal_urls={"firefly": "http://127.0.0.1:1/mcp/firefly/"},
|
||||
session_store=store,
|
||||
mcp_tool_names={"firefly": ["list_account", "delete_account", "store_account"]},
|
||||
usage_sink=sink,
|
||||
client_factory=FakeClient,
|
||||
work_dir=cwd / "work",
|
||||
)
|
||||
|
||||
|
||||
async def _drain(events) -> list[Any]:
|
||||
return [e async for e in events]
|
||||
|
||||
|
||||
async def test_stream_envelope_and_index_rebase(cwd: Path) -> None:
|
||||
backend = _backend(
|
||||
cwd, InMemorySessionStore(), expose_mcps=(ExposedMcp(name="firefly"),)
|
||||
)
|
||||
capture = TurnCapture()
|
||||
events = await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
conversation_id="conv-1",
|
||||
capture=capture,
|
||||
)
|
||||
)
|
||||
assert isinstance(events[0], RawMessageStartEvent)
|
||||
assert isinstance(events[-1], RawMessageStopEvent)
|
||||
assert isinstance(events[-2], RawMessageDeltaEvent)
|
||||
assert events[-2].usage.output_tokens == 7
|
||||
assert events[-2].usage.cache_read_input_tokens == 100
|
||||
starts = [e.index for e in events if isinstance(e, RawContentBlockStartEvent)]
|
||||
assert starts == [0, 1, 2]
|
||||
stops = [e.index for e in events if isinstance(e, RawContentBlockStopEvent)]
|
||||
assert stops == [0, 1, 2]
|
||||
deltas = [e.delta.text for e in events if isinstance(e, RawContentBlockDeltaEvent)]
|
||||
assert deltas == ["calling ", "done"]
|
||||
|
||||
assert capture.session_id == "fresh-session"
|
||||
assert capture.usage is not None and capture.usage.cost_usd == 0.01
|
||||
assert capture.synthesized_messages == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "calling "},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Read",
|
||||
"input": {"path": "x"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "42"}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "done"}]},
|
||||
]
|
||||
client = FakeClient.instances[0]
|
||||
assert client.prompts == ["hi"]
|
||||
opts = client.options
|
||||
assert opts.setting_sources == []
|
||||
assert opts.strict_mcp_config is True
|
||||
assert opts.permission_mode == "bypassPermissions"
|
||||
assert opts.mcp_servers == {
|
||||
"firefly": {"type": "http", "url": "http://127.0.0.1:1/mcp/firefly/"}
|
||||
}
|
||||
assert opts.session_store is not None
|
||||
assert opts.resume is None
|
||||
assert Path(opts.cli_path).exists()
|
||||
wrapper = Path(opts.cli_path).read_text()
|
||||
assert "CLAUDE_" in wrapper and "DATABASE_URL" not in wrapper
|
||||
|
||||
|
||||
async def test_conversation_reuses_live_session(cwd: Path) -> None:
|
||||
backend = _backend(cwd, InMemorySessionStore())
|
||||
for i in range(2):
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": f"turn {i}"}],
|
||||
conversation_id="conv-1",
|
||||
)
|
||||
)
|
||||
assert len(FakeClient.instances) == 1
|
||||
assert FakeClient.instances[0].prompts == ["turn 0", "turn 1"]
|
||||
assert backend.sessions["conv-1"]["turns"] == 2
|
||||
|
||||
|
||||
async def test_history_is_seeded_into_store_and_resumed(cwd: Path) -> None:
|
||||
store = InMemorySessionStore()
|
||||
backend = _backend(cwd, store)
|
||||
history = [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "reply"}]},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent, messages=history, conversation_id="conv-2"
|
||||
)
|
||||
)
|
||||
client = FakeClient.instances[0]
|
||||
assert client.options.resume is not None
|
||||
key = {
|
||||
"project_key": project_key_for_directory(str(cwd)),
|
||||
"session_id": client.options.resume,
|
||||
}
|
||||
entries = store.get_entries(key)
|
||||
assert messages_from_entries(entries) == history[:2]
|
||||
assert client.prompts == ["second"]
|
||||
|
||||
|
||||
async def test_known_session_id_is_resumed_without_seeding(cwd: Path) -> None:
|
||||
store = InMemorySessionStore()
|
||||
backend = _backend(cwd, store)
|
||||
history = [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=history,
|
||||
conversation_id="conv-3",
|
||||
session_id="known-sid",
|
||||
)
|
||||
)
|
||||
assert FakeClient.instances[0].options.resume == "known-sid"
|
||||
assert store.size == 0
|
||||
|
||||
|
||||
async def test_stateless_caller_hits_same_session_next_turn(cwd: Path) -> None:
|
||||
backend = _backend(cwd, InMemorySessionStore())
|
||||
first = [{"role": "user", "content": "hi"}]
|
||||
await _drain(backend.complete(agent=backend.agent, messages=first))
|
||||
follow_up = [
|
||||
*first,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "calling "},
|
||||
{"type": "text", "text": "done"},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "more"},
|
||||
]
|
||||
assert list(backend.sessions) == [fingerprint(follow_up[:-1])]
|
||||
await _drain(backend.complete(agent=backend.agent, messages=follow_up))
|
||||
assert len(FakeClient.instances) == 1
|
||||
assert FakeClient.instances[0].prompts == ["hi", "more"]
|
||||
|
||||
|
||||
async def test_mcp_deny_and_usage_sink(cwd: Path) -> None:
|
||||
seen: list[UsageEvent] = []
|
||||
|
||||
async def sink(event: UsageEvent) -> None:
|
||||
seen.append(event)
|
||||
|
||||
backend = _backend(
|
||||
cwd,
|
||||
InMemorySessionStore(),
|
||||
sink,
|
||||
expose_mcps=(ExposedMcp(name="firefly", deny=("delete_*",)),),
|
||||
)
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
conversation_id="c",
|
||||
)
|
||||
)
|
||||
assert FakeClient.instances[0].options.disallowed_tools == [
|
||||
"mcp__firefly__delete_account"
|
||||
]
|
||||
assert len(seen) == 1
|
||||
assert seen[0].usage.input_tokens == 3
|
||||
assert seen[0].conversation_id == "c"
|
||||
assert seen[0].session_id == "fresh-session"
|
||||
|
||||
|
||||
async def test_skill_sets_become_sorted_plugins(cwd: Path) -> None:
|
||||
sets = cwd / "skills"
|
||||
for name in ("zeta", "общие"):
|
||||
(sets / name / "demo").mkdir(parents=True)
|
||||
(sets / name / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n")
|
||||
backend = _backend(
|
||||
cwd, InMemorySessionStore(), skill_sets=(sets / "zeta", sets / "общие")
|
||||
)
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
conversation_id="c",
|
||||
)
|
||||
)
|
||||
plugins = FakeClient.instances[0].options.plugins
|
||||
assert [p["type"] for p in plugins] == ["local", "local"]
|
||||
paths = [Path(p["path"]) for p in plugins]
|
||||
assert [p.name for p in paths] == ["zeta", "общие"]
|
||||
for path in paths:
|
||||
assert (path / ".claude-plugin" / "plugin.json").exists()
|
||||
assert (path / "skills" / "demo" / "SKILL.md").exists()
|
||||
assert FakeClient.instances[0].options.skills == "all"
|
||||
|
||||
|
||||
async def test_prompt_sources_are_assembled(cwd: Path) -> None:
|
||||
(cwd / "a.md").write_text("alpha\n")
|
||||
(cwd / "b.md").write_text("\nbeta\n\n")
|
||||
backend = _backend(
|
||||
cwd, InMemorySessionStore(), prompt_sources=(cwd / "a.md", cwd / "b.md")
|
||||
)
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
conversation_id="c",
|
||||
)
|
||||
)
|
||||
assert FakeClient.instances[0].options.system_prompt == "alpha\n\nbeta\n"
|
||||
|
||||
|
||||
async def test_close_disconnects(cwd: Path) -> None:
|
||||
backend = _backend(cwd, InMemorySessionStore())
|
||||
async with backend:
|
||||
await _drain(
|
||||
backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
conversation_id="c",
|
||||
)
|
||||
)
|
||||
assert FakeClient.instances[0].connected is False
|
||||
assert backend.sessions == {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from claude_agent_sdk.testing import run_session_store_conformance
|
||||
from sqlalchemy import delete
|
||||
|
||||
from beaver_gateway.storage import Database, PostgresSessionStore, TranscriptEntry
|
||||
|
||||
POSTGRES_URL = os.environ.get("BEAVER_TEST_DATABASE_URL")
|
||||
|
||||
|
||||
async def test_sqlite_conformance() -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-store-"))
|
||||
counter = 0
|
||||
|
||||
async def make_store() -> PostgresSessionStore:
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
db = Database(f"sqlite:///{root / f'{counter}.db'}")
|
||||
await db.create_all()
|
||||
return PostgresSessionStore(db)
|
||||
|
||||
await run_session_store_conformance(make_store)
|
||||
|
||||
|
||||
@pytest.mark.skipif(POSTGRES_URL is None, reason="BEAVER_TEST_DATABASE_URL not set")
|
||||
async def test_postgres_conformance() -> None:
|
||||
assert POSTGRES_URL is not None
|
||||
db = Database(POSTGRES_URL)
|
||||
await db.create_all()
|
||||
|
||||
async def make_store() -> PostgresSessionStore:
|
||||
async with db.session() as session:
|
||||
await session.execute(delete(TranscriptEntry))
|
||||
await session.commit()
|
||||
return PostgresSessionStore(db)
|
||||
|
||||
try:
|
||||
await run_session_store_conformance(make_store)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
async def test_append_dedups_by_uuid_and_keeps_floats() -> None:
|
||||
db = Database(f"sqlite:///{tempfile.mkdtemp(prefix='beaver-store-')}/d.db")
|
||||
await db.create_all()
|
||||
store = PostgresSessionStore(db)
|
||||
key = {"project_key": "p", "session_id": "s"}
|
||||
batch = [
|
||||
{"type": "x", "uuid": "a", "n": 1.5},
|
||||
{"type": "y", "n": 2},
|
||||
{"type": "x", "uuid": "a", "n": 999},
|
||||
]
|
||||
await store.append(key, batch)
|
||||
await store.append(key, batch)
|
||||
loaded = await store.load(key)
|
||||
assert loaded == [
|
||||
{"type": "x", "uuid": "a", "n": 1.5},
|
||||
{"type": "y", "n": 2},
|
||||
{"type": "y", "n": 2},
|
||||
]
|
||||
assert isinstance(loaded[0]["n"], float)
|
||||
@@ -0,0 +1,129 @@
|
||||
from beaver_gateway.core.transcript import (
|
||||
CLI_VERSION,
|
||||
build_entries,
|
||||
messages_from_entries,
|
||||
)
|
||||
|
||||
HISTORY = [
|
||||
{"role": "user", "content": "Write бобёр to notes.txt"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "hm", "signature": "sig"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Write",
|
||||
"input": {"file_path": "notes.txt"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": "ok",
|
||||
"is_error": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Done."}]},
|
||||
{"role": "user", "content": [{"type": "text", "text": "thanks"}]},
|
||||
{"role": "assistant", "content": "np"},
|
||||
]
|
||||
|
||||
|
||||
def test_round_trip() -> None:
|
||||
entries = build_entries(HISTORY, session_id="sid", cwd="/vault", model="claude-x")
|
||||
assert messages_from_entries(entries) == [
|
||||
*HISTORY[:5],
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "np"}]},
|
||||
]
|
||||
|
||||
|
||||
def test_chain_and_shape() -> None:
|
||||
entries = build_entries(HISTORY, session_id="sid", cwd="/vault", model="claude-x")
|
||||
assert [e["type"] for e in entries] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert entries[0]["parentUuid"] is None
|
||||
for prev, cur in zip(entries, entries[1:], strict=False):
|
||||
assert cur["parentUuid"] == prev["uuid"]
|
||||
assert len({e["uuid"] for e in entries}) == len(entries)
|
||||
|
||||
user = entries[0]
|
||||
assert user["promptSource"] == "sdk"
|
||||
assert user["entrypoint"] == "sdk-py"
|
||||
assert user["permissionMode"] == "bypassPermissions"
|
||||
assert user["sessionId"] == "sid"
|
||||
assert user["cwd"] == "/vault"
|
||||
assert user["version"] == CLI_VERSION
|
||||
|
||||
thinking, tool_use = entries[1], entries[2]
|
||||
assert thinking["message"]["id"] == tool_use["message"]["id"]
|
||||
assert thinking["requestId"] == tool_use["requestId"]
|
||||
assert tool_use["message"]["stop_reason"] == "tool_use"
|
||||
assert tool_use["message"]["content"] == [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Write",
|
||||
"input": {"file_path": "notes.txt"},
|
||||
}
|
||||
]
|
||||
|
||||
result = entries[3]
|
||||
assert result["sourceToolAssistantUUID"] == tool_use["uuid"]
|
||||
assert result["promptId"] == user["promptId"]
|
||||
assert result["toolUseResult"] == "ok"
|
||||
assert result["message"]["content"][0]["is_error"] is False
|
||||
|
||||
final = entries[4]
|
||||
assert final["message"]["stop_reason"] == "end_turn"
|
||||
assert final["message"]["usage"]["input_tokens"] == 0
|
||||
|
||||
assert entries[5]["promptId"] != user["promptId"]
|
||||
|
||||
|
||||
def test_tool_results_split_and_merged_back() -> None:
|
||||
history = [
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "a", "name": "Read", "input": {}},
|
||||
{"type": "tool_use", "id": "b", "name": "Read", "input": {}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "a", "content": "1"},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "b",
|
||||
"content": [{"type": "text", "text": "2"}],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
entries = build_entries(history, session_id="s", cwd="/", model="m")
|
||||
assert [e["type"] for e in entries] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"user",
|
||||
"user",
|
||||
]
|
||||
assert entries[3]["parentUuid"] == entries[1]["uuid"]
|
||||
assert entries[4]["parentUuid"] == entries[2]["uuid"]
|
||||
assert entries[4]["toolUseResult"] == "2"
|
||||
assert messages_from_entries(entries) == history
|
||||
@@ -267,6 +267,7 @@ dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "anyio" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "claude-agent-sdk" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "greenlet" },
|
||||
@@ -283,11 +284,9 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
local = [
|
||||
{ name = "claude-code-api", version = "0.1.0", source = { editable = "../claude-code-api" } },
|
||||
{ name = "raycast-api", version = "0.1.0", source = { editable = "../raycast-api" } },
|
||||
]
|
||||
prod = [
|
||||
{ name = "claude-code-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#b0d7c6d32d4de04862e109002459ec0c47f01b2f" } },
|
||||
{ name = "raycast-api", version = "0.1.0", source = { git = "https://git.kotikot.com/beaver/raycast-api.git#e73894c8e435da5c0709f92f69f11bcd0dab9afe" } },
|
||||
]
|
||||
|
||||
@@ -308,8 +307,7 @@ requires-dist = [
|
||||
{ name = "anthropic", specifier = ">=0.103.0" },
|
||||
{ name = "anyio", specifier = ">=4.13.0" },
|
||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||
{ name = "claude-code-api", marker = "extra == 'local'", editable = "../claude-code-api" },
|
||||
{ name = "claude-code-api", marker = "extra == 'prod'", git = "https://git.kotikot.com/beaver/claude-code-api.git" },
|
||||
{ name = "claude-agent-sdk", specifier = ">=0.2.146" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||
@@ -417,37 +415,22 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-api"
|
||||
version = "0.1.0"
|
||||
source = { git = "https://git.kotikot.com/beaver/claude-code-api.git#b0d7c6d32d4de04862e109002459ec0c47f01b2f" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
name = "claude-agent-sdk"
|
||||
version = "0.2.146"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess" },
|
||||
{ name = "anyio" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp" },
|
||||
{ name = "sniffio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-api"
|
||||
version = "0.1.0"
|
||||
source = { editable = "../claude-code-api" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "ptyprocess" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "ptyprocess", specifier = ">=0.7" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.0" },
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/08/a79f73aad2156f4518ae6e0fffbcdb403cd5347d62b994b005a0c9bb12fb/claude_agent_sdk-0.2.146.tar.gz", hash = "sha256:9e402548466018421cfd0725a7601e14d9e3e879276a6a4a877c5a2ff6633546", size = 344704, upload-time = "2026-08-27T22:25:46.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e2/7618d7ffd3d4580d53d1147e7baba005c3bf4359d8bb8ab9beab62b5a0ec/claude_agent_sdk-0.2.146-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7bcb91dd34c2e0e5b4f006648d457bbfba9aa1e44cf2e5d02abb441dcbbaff98", size = 84087716, upload-time = "2026-08-27T22:25:50.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e6/004b957547e32f6df4948c3a5a3c06974c6c19539c357ebe40a1a23941d3/claude_agent_sdk-0.2.146-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:9285b3dd900376b045a06d2d6f5e19bfd96a3f6cb88d21166bb840aff14ee505", size = 88567306, upload-time = "2026-08-27T22:25:54.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/fa/1c1cb847ff8a92f6ec9a0176a984ec1902cf6eab6c027fd45059d211a96d/claude_agent_sdk-0.2.146-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ad536806e6c6fc1972ae3950b1bb171f1494e6f47002c24dbc68f19af1c34997", size = 94195787, upload-time = "2026-08-27T22:25:59.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f9/48aa74e5a85ebe11b916a99b053cc64f47bea8409ec2b9ada5b5a1169a7a/claude_agent_sdk-0.2.146-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:1f2bae0759f3962be2714c786c1a6961824c5ce3d4b7f44f0828191bd44ccebe", size = 94385743, upload-time = "2026-08-27T22:26:03.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/d4/ff986dc35330146cba60a1bb6ee3f521cdec25ccc0306ef359b9d0cb59c4/claude_agent_sdk-0.2.146-py3-none-win_amd64.whl", hash = "sha256:dc2c4773dc02492b52c74f957a059ecbc0aec7063bd1a16c039b78613194b3cf", size = 97049829, upload-time = "2026-08-27T22:26:08.701Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1449,15 +1432,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.4.4"
|
||||
|
||||
Reference in New Issue
Block a user