From e1f242a87a1c45d031710f33da37b99ba71cd2fd Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 01:56:39 +0200 Subject: [PATCH] feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation --- .gitignore | 1 + Dockerfile | 30 +- pyproject.toml | 11 +- src/beaver_gateway/agents/base.py | 10 +- src/beaver_gateway/agents/claude.py | 264 +----- src/beaver_gateway/backends/__init__.py | 2 +- src/beaver_gateway/backends/base.py | 11 +- src/beaver_gateway/backends/claude_code.py | 502 ----------- src/beaver_gateway/backends/claude_sdk.py | 789 ++++++++++++++++++ src/beaver_gateway/backends/raycast.py | 5 +- src/beaver_gateway/cli.py | 62 +- src/beaver_gateway/core/conversation_store.py | 12 + src/beaver_gateway/core/prompt.py | 34 + src/beaver_gateway/core/transcript.py | 277 ++++++ src/beaver_gateway/core/turn_capture.py | 37 + .../frontends/admin/frontend.py | 2 +- src/beaver_gateway/frontends/base.py | 2 +- .../frontends/markdown/frontend.py | 106 ++- src/beaver_gateway/mcp/internal_app.py | 2 +- src/beaver_gateway/settings.py | 6 + src/beaver_gateway/storage/__init__.py | 15 +- src/beaver_gateway/storage/db.py | 11 +- src/beaver_gateway/storage/models.py | 76 +- src/beaver_gateway/storage/session_store.py | 126 +++ tests/test_claude_sdk_backend.py | 385 +++++++++ tests/test_session_store.py | 64 ++ tests/test_transcript.py | 129 +++ uv.lock | 58 +- 28 files changed, 2154 insertions(+), 875 deletions(-) delete mode 100644 src/beaver_gateway/backends/claude_code.py create mode 100644 src/beaver_gateway/backends/claude_sdk.py create mode 100644 src/beaver_gateway/core/prompt.py create mode 100644 src/beaver_gateway/core/transcript.py create mode 100644 src/beaver_gateway/core/turn_capture.py create mode 100644 src/beaver_gateway/storage/session_store.py create mode 100644 tests/test_claude_sdk_backend.py create mode 100644 tests/test_session_store.py create mode 100644 tests/test_transcript.py diff --git a/.gitignore b/.gitignore index 05ce672..04c2cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ wheels/ # Sloppy docs/ +t/ # IDE .idea diff --git a/Dockerfile b/Dockerfile index aca666d..81fd41e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0a5751e..a3c2129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/beaver_gateway/agents/base.py b/src/beaver_gateway/agents/base.py index 19ef84e..ad7a564 100644 --- a/src/beaver_gateway/agents/base.py +++ b/src/beaver_gateway/agents/base.py @@ -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): diff --git a/src/beaver_gateway/agents/claude.py b/src/beaver_gateway/agents/claude.py index 0853297..4b04c45 100644 --- a/src/beaver_gateway/agents/claude.py +++ b/src/beaver_gateway/agents/claude.py @@ -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.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) diff --git a/src/beaver_gateway/backends/__init__.py b/src/beaver_gateway/backends/__init__.py index d588149..8764015 100644 --- a/src/beaver_gateway/backends/__init__.py +++ b/src/beaver_gateway/backends/__init__.py @@ -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. """ diff --git a/src/beaver_gateway/backends/base.py b/src/beaver_gateway/backends/base.py index 4d0031a..64f95ef 100644 --- a/src/beaver_gateway/backends/base.py +++ b/src/beaver_gateway/backends/base.py @@ -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 diff --git a/src/beaver_gateway/backends/claude_code.py b/src/beaver_gateway/backends/claude_code.py deleted file mode 100644 index 25d2921..0000000 --- a/src/beaver_gateway/backends/claude_code.py +++ /dev/null @@ -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 diff --git a/src/beaver_gateway/backends/claude_sdk.py b/src/beaver_gateway/backends/claude_sdk.py new file mode 100644 index 0000000..64b2fa8 --- /dev/null +++ b/src/beaver_gateway/backends/claude_sdk.py @@ -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 () diff --git a/src/beaver_gateway/backends/raycast.py b/src/beaver_gateway/backends/raycast.py index 39ce4bd..1738e6a 100644 --- a/src/beaver_gateway/backends/raycast.py +++ b/src/beaver_gateway/backends/raycast.py @@ -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( diff --git a/src/beaver_gateway/cli.py b/src/beaver_gateway/cli.py index c095a24..6010293 100644 --- a/src/beaver_gateway/cli.py +++ b/src/beaver_gateway/cli.py @@ -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 diff --git a/src/beaver_gateway/core/conversation_store.py b/src/beaver_gateway/core/conversation_store.py index 2eaa6bb..4cd0e04 100644 --- a/src/beaver_gateway/core/conversation_store.py +++ b/src/beaver_gateway/core/conversation_store.py @@ -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]]: diff --git a/src/beaver_gateway/core/prompt.py b/src/beaver_gateway/core/prompt.py new file mode 100644 index 0000000..3b9a82c --- /dev/null +++ b/src/beaver_gateway/core/prompt.py @@ -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" diff --git a/src/beaver_gateway/core/transcript.py b/src/beaver_gateway/core/transcript.py new file mode 100644 index 0000000..e039744 --- /dev/null +++ b/src/beaver_gateway/core/transcript.py @@ -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", + } diff --git a/src/beaver_gateway/core/turn_capture.py b/src/beaver_gateway/core/turn_capture.py new file mode 100644 index 0000000..fb3c497 --- /dev/null +++ b/src/beaver_gateway/core/turn_capture.py @@ -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 diff --git a/src/beaver_gateway/frontends/admin/frontend.py b/src/beaver_gateway/frontends/admin/frontend.py index 5ea36c6..f48b2bb 100644 --- a/src/beaver_gateway/frontends/admin/frontend.py +++ b/src/beaver_gateway/frontends/admin/frontend.py @@ -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. diff --git a/src/beaver_gateway/frontends/base.py b/src/beaver_gateway/frontends/base.py index 14b1d7b..9babb7d 100644 --- a/src/beaver_gateway/frontends/base.py +++ b/src/beaver_gateway/frontends/base.py @@ -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. diff --git a/src/beaver_gateway/frontends/markdown/frontend.py b/src/beaver_gateway/frontends/markdown/frontend.py index 58ae12b..14ea497 100644 --- a/src/beaver_gateway/frontends/markdown/frontend.py +++ b/src/beaver_gateway/frontends/markdown/frontend.py @@ -41,7 +41,6 @@ from fastapi import FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse -from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter, TurnCapture from beaver_gateway.core import audit from beaver_gateway.core.conversation_store import ( diff_and_fork, @@ -49,7 +48,9 @@ from beaver_gateway.core.conversation_store import ( load_messages, mint_conversation, rewrite_messages, + set_session_id, ) +from beaver_gateway.core.turn_capture import TurnCapture from beaver_gateway.core.turn_record import TurnRecord from beaver_gateway.frontends._accumulate import StreamAccumulator from beaver_gateway.frontends._auth import require_token @@ -312,8 +313,8 @@ class MarkdownFrontend(Frontend): content_override: Any, agent_override: str | None, ) -> Any: + write_disk = content_override is None if isinstance(content_override, str): - await _write_atomic(file_path, content_override) file_text = content_override elif content_override is None: file_text = await _read_or_empty(file_path) @@ -381,22 +382,18 @@ class MarkdownFrontend(Frontend): # Resolve / mint the conversation row, align incoming against # stored history, and feed the aligned messages to the backend - # — see ``core/conversation_store.py`` for the full rationale. - # If the backend isn't claude-code (no ``TurnCapture`` support) - # we fall through to the legacy parser-only path. + # - see ``core/conversation_store.py`` for the full rationale. conv, conv_external_id, stored_msgs = await self._resolve_conversation( runtime=runtime, metadata=parsed.metadata, agent_name=agent.name ) outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns) - capture: TurnCapture | None = ( - TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None - ) - - kwargs: dict[str, Any] = {} - if capture is not None: - kwargs["capture"] = capture + capture = TurnCapture() events = backend.complete( - agent=agent, messages=outcome.messages, system=None, **kwargs + agent=agent, + messages=outcome.messages, + system=None, + capture=capture, + **_session_options(conv, outcome.divergence_index), ) try: message = await self._stream_to_file( @@ -405,6 +402,7 @@ class MarkdownFrontend(Frontend): parsed=parsed, model=agent.model or agent.name, filename=filename, + write_disk=write_disk, ) except HTTPException: raise @@ -419,6 +417,7 @@ class MarkdownFrontend(Frontend): message=message, agent_name=agent.name, conv_external_id=conv_external_id, + write_disk=write_disk, ) await self._persist_canonical_history( @@ -480,12 +479,10 @@ class MarkdownFrontend(Frontend): writers in their respective halves of Obsidian Sync. Final content is identical on both sides, so Sync no-ops. """ - # File-text resolution + early bailouts. ``content_override`` is - # still written to disk on the gateway side because that's the - # state the rest of the request consumes; it just doesn't keep - # ticking after that single write. + # With ``content`` the plugin is the only writer of the file + # (§3.10): the gateway never touches disk in that case. + write_disk = content_override is None if isinstance(content_override, str): - await _write_atomic(file_path, content_override) file_text = content_override elif content_override is None: file_text = await _read_or_empty(file_path) @@ -598,21 +595,19 @@ class MarkdownFrontend(Frontend): len(outcome.messages), len(outcome.persist_messages), ) - capture: TurnCapture | None = ( - TurnCapture() if isinstance(backend, ClaudeCodeBackendAdapter) else None - ) - - kwargs: dict[str, Any] = {} - if capture is not None: - kwargs["capture"] = capture + capture = TurnCapture() _log.info( - "chat/stream: file=%s calling backend.complete agent=%s capture=%s", + "chat/stream: file=%s calling backend.complete agent=%s session=%s", filename, agent.name, - capture is not None, + conv.session_id, ) events = backend.complete( - agent=agent, messages=outcome.messages, system=None, **kwargs + agent=agent, + messages=outcome.messages, + system=None, + capture=capture, + **_session_options(conv, outcome.divergence_index), ) acc = StreamAccumulator() @@ -663,9 +658,10 @@ class MarkdownFrontend(Frontend): new_body, renderer.render_assistant_message(partial) ) new_body = renderer.append_to_body(new_body, _render_error_block(exc)) - await _write_atomic( - file_path, _reattach_frontmatter(parsed.metadata, new_body) - ) + if write_disk: + await _write_atomic( + file_path, _reattach_frontmatter(parsed.metadata, new_body) + ) yield _sse_pack( "error", { @@ -683,6 +679,7 @@ class MarkdownFrontend(Frontend): message=message, agent_name=agent.name, conv_external_id=conv_external_id, + write_disk=write_disk, ) await self._persist_canonical_history( @@ -727,6 +724,7 @@ class MarkdownFrontend(Frontend): parsed: parser.ParsedFile, model: str, filename: str, + write_disk: bool = True, ) -> Any: """Drain ``events`` into a ``Message``, flushing partials to disk. @@ -745,6 +743,8 @@ class MarkdownFrontend(Frontend): acc = StreamAccumulator() async def flush_partial() -> None: + if not write_disk: + return partial = acc.finalize(model=model) if not partial.content: return @@ -774,9 +774,10 @@ class MarkdownFrontend(Frontend): new_body, renderer.render_assistant_message(partial) ) new_body = renderer.append_to_body(new_body, _render_error_block(exc)) - await _write_atomic( - file_path, _reattach_frontmatter(parsed.metadata, new_body) - ) + if write_disk: + await _write_atomic( + file_path, _reattach_frontmatter(parsed.metadata, new_body) + ) raise return acc.finalize(model=model) @@ -788,8 +789,9 @@ class MarkdownFrontend(Frontend): message: Any, agent_name: str, conv_external_id: str, + write_disk: bool = True, ) -> str: - """Render the assistant turn, append to the file, refresh frontmatter.""" + """Render the assistant turn, refresh frontmatter, write if we own the file.""" rendered = renderer.render_assistant_message(message) new_body = renderer.append_to_body(parsed.body, rendered) new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD) @@ -806,7 +808,8 @@ class MarkdownFrontend(Frontend): updated_metadata["conversation_id"] = conv_external_id updated_metadata["fingerprint"] = fingerprint_messages(updated_messages) new_content = _reattach_frontmatter(updated_metadata, new_body) - await _write_atomic(file_path, new_content) + if write_disk: + await _write_atomic(file_path, new_content) return new_content async def _resolve_conversation( @@ -867,21 +870,17 @@ class MarkdownFrontend(Frontend): conversation_id: int, persist_messages: list[dict[str, Any]], new_user_text: str, - capture: TurnCapture | None, + capture: TurnCapture, message: Any, ) -> None: """Stamp the DB with the post-turn canonical Anthropic-shape history. Combines the matched/spliced prior state, the new user prompt, - and the synthesized assistant↔tool cycle from the backend (or - a text-only fallback for backends without ``TurnCapture``). + and the synthesized assistant/tool cycle from the backend (or a + text-only fallback for backends that left ``capture`` empty). """ new_user_msg = {"role": "user", "content": new_user_text} - synthesized = ( - capture.synthesized_messages - if capture is not None - else _fallback_synthesized(message) - ) + synthesized = capture.synthesized_messages or _fallback_synthesized(message) canonical = [*persist_messages, new_user_msg, *synthesized] _log.info( "_persist_canonical_history: conv_id=%d writing %d msgs " @@ -895,6 +894,12 @@ class MarkdownFrontend(Frontend): await rewrite_messages( session, conversation_id=conversation_id, messages=canonical ) + if capture.session_id is not None: + await set_session_id( + session, + conversation_id=conversation_id, + session_id=capture.session_id, + ) _log.info( "_persist_canonical_history: conv_id=%d DB committed", conversation_id ) @@ -922,6 +927,19 @@ class MarkdownFrontend(Frontend): # ---- module-level utilities ---------------------------------------------- +def _session_options(conv: Any, divergence_index: int | None) -> dict[str, Any]: + """Backend options that pin the turn to the conversation's live session. + + A divergence means the file's history no longer matches what the + session saw, so the stored ``session_id`` is withheld and the backend + seeds a fresh one from the aligned messages. + """ + return { + "conversation_id": conv.external_id, + "session_id": conv.session_id if divergence_index is None else None, + } + + async def _events_with_heartbeat( events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL ) -> AsyncIterator[Any]: diff --git a/src/beaver_gateway/mcp/internal_app.py b/src/beaver_gateway/mcp/internal_app.py index faf639f..224b708 100644 --- a/src/beaver_gateway/mcp/internal_app.py +++ b/src/beaver_gateway/mcp/internal_app.py @@ -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. diff --git a/src/beaver_gateway/settings.py b/src/beaver_gateway/settings.py index 9947653..9ea3952 100644 --- a/src/beaver_gateway/settings.py +++ b/src/beaver_gateway/settings.py @@ -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 diff --git a/src/beaver_gateway/storage/__init__.py b/src/beaver_gateway/storage/__init__.py index de28541..561ee73 100644 --- a/src/beaver_gateway/storage/__init__.py +++ b/src/beaver_gateway/storage/__init__.py @@ -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", diff --git a/src/beaver_gateway/storage/db.py b/src/beaver_gateway/storage/db.py index 62dfeb1..1214274 100644 --- a/src/beaver_gateway/storage/db.py +++ b/src/beaver_gateway/storage/db.py @@ -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", diff --git a/src/beaver_gateway/storage/models.py b/src/beaver_gateway/storage/models.py index 4ec0170..9c3a680 100644 --- a/src/beaver_gateway/storage/models.py +++ b/src/beaver_gateway/storage/models.py @@ -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", +] diff --git a/src/beaver_gateway/storage/session_store.py b/src/beaver_gateway/storage/session_store.py new file mode 100644 index 0000000..a83c2aa --- /dev/null +++ b/src/beaver_gateway/storage/session_store.py @@ -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 "" diff --git a/tests/test_claude_sdk_backend.py b/tests/test_claude_sdk_backend.py new file mode 100644 index 0000000..cda7100 --- /dev/null +++ b/tests/test_claude_sdk_backend.py @@ -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 == {} diff --git a/tests/test_session_store.py b/tests/test_session_store.py new file mode 100644 index 0000000..8038f90 --- /dev/null +++ b/tests/test_session_store.py @@ -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) diff --git a/tests/test_transcript.py b/tests/test_transcript.py new file mode 100644 index 0000000..3348196 --- /dev/null +++ b/tests/test_transcript.py @@ -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 diff --git a/uv.lock b/uv.lock index 058335a..4c736e4 100644 --- a/uv.lock +++ b/uv.lock @@ -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"