feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation

This commit is contained in:
hh
2026-08-28 01:56:39 +02:00
parent ee2dc918ee
commit e1f242a87a
28 changed files with 2154 additions and 875 deletions
+30 -234
View File
@@ -1,258 +1,54 @@
"""Claude Code agent definition.
"""Claude agent definition, backed by the Claude Agent SDK.
There is no ``streaming`` field, but there is
:attr:`ClaudeCodeOptions.include_partial_messages`, and the difference
matters. Token-level deltas are not a property of the agent, they are a
property of the transport: the PTY transport reads a JSONL that only
ever contains finished blocks, so it cannot stream no matter what the
agent asks for, while ``transport="stream_json"`` can. The flag lives
next to the transport that grants it rather than pretending to be a
free-standing capability.
``BaseAgent.system_prompt`` maps onto the claude CLI's
``--system-prompt`` — i.e. it really *is* the agent's system prompt,
not "added on top of claude-code's giant built-in". The additive slot
``--append-system-prompt`` is exposed via
:attr:`ClaudeCodeOptions.append_system_prompt` for the rare case
when the user wants claude-code's planning conventions / dynamic
sections *and* a delta on top. Difference, measured empirically:
* tools (names, JSON schemas, embedded guidance like Bash's "prefer
Read over cat/head/tail") survive both flags — they ride the
Anthropic API ``tools=[]`` field, not the system prompt text;
* ``--system-prompt`` drops ~8.6k tokens of claude-code's *textual*
baseline — agent persona, multi-step-work conventions ("use
TaskCreate proactively", etc.), and the dynamic per-machine sections
(cwd, env info, git status, memory paths — see the
``--exclude-dynamic-system-prompt-sections`` flag note in
``claude --help``: "Only applies with the default system prompt
(ignored with --system-prompt)").
We pick override as the default because it preserves the principle of
least surprise: the ``system_prompt=...`` you wrote on the agent is
what claude actually receives. If you need claude-code's full
planning/dynamic-context behaviour on top of your prompt, opt in via
``options=ClaudeCodeOptions(append_system_prompt=...)`` and leave
``system_prompt`` for your agent's identity (or vice versa — set
``system_prompt=""``-ish and put everything in ``append_*``).
Per-agent passthrough of ``claude_code_api.BackendOptions`` lives in
:class:`ClaudeCodeOptions`, attached to :class:`ClaudeAgent` as
``options``. Every tunable knob ``BackendOptions`` supports — except
the ones derived from the agent's own primary surface
(``cwd`` / ``model`` / ``system_prompt`` / ``available_native_tools`` →
``allowed_tools`` / ``expose_mcps`` → ``mcp_servers``) — is exposed
there. Defaults match upstream except where correctness demands
otherwise (``wait_for_turn_duration=True``,
``dangerously_skip_permissions=True``).
The system prompt is either ``system_prompt`` verbatim or, when
``prompt_sources`` is set, the concatenation of those files assembled at
every session spawn (see ``core/prompt.py``). ``skill_sets`` are
directories of ``<skill>/SKILL.md`` folders; each becomes a local SDK
plugin. Nothing from disk is loaded otherwise: the adapter runs with
``setting_sources=[]``.
"""
from __future__ import annotations
from collections.abc import Mapping # noqa: TC003 pydantic runtime
from pathlib import Path # noqa: TC003 pydantic runtime
from typing import Literal
from collections.abc import Mapping # noqa: TC003 - pydantic runtime
from pathlib import Path # noqa: TC003 - pydantic runtime
from pydantic import BaseModel, ConfigDict, Field
from beaver_gateway.agents.base import BaseAgent
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
"""Mirrors ``claude_code_api.HistoryInjectionMode`` to avoid an import
cycle on the user-facing path (configs may be loaded without
``claude-code-api`` installed, e.g. ``--extra prod`` minus claude)."""
Transport = Literal["pty", "stream_json"]
"""Mirrors ``claude_code_api.Transport``, for the same reason."""
__all__ = ["ClaudeAgent", "ClaudeOptions"]
class ClaudeCodeOptions(BaseModel):
"""Per-agent passthrough for ``claude_code_api.BackendOptions``.
Mirrors every field of ``BackendOptions`` except those that come
from the agent's primary surface
(``cwd`` / ``model`` / ``system_prompt`` /
``available_native_tools`` / ``expose_mcps``). Defaults match
upstream, with two exceptions for correctness:
* ``wait_for_turn_duration=True`` — without it, extended-thinking
turns drop the text response because ``TurnManager`` returns on
the first terminal assistant record (the thinking snapshot) and
never reads the second one (the actual text). Phase 2.2 PROGRESS
describes the incident in detail.
* ``dangerously_skip_permissions=True`` — Beaver Gateway is a
single-user trusted-config product; the user already wrote the
``config.py`` that spawns claude. Permission prompts on a
headless backend mean stuck turns.
Any field on :class:`claude_code_api.BackendOptions` we forward
here lives on this model with the same name and type, so a future
``BackendOptions`` field addition is a one-liner here plus a
one-liner in :mod:`backends.claude_code`.
"""
class ClaudeOptions(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
transport: Transport = "pty"
"""Which claude process backs this agent.
``stream_json`` runs ``claude -p`` with stream-json on stdin and
stdout: prompts go into a pipe, events come back as they happen, and
a native ``result`` record closes each turn. ``pty`` (the default,
kept for backwards compatibility) drives the interactive TUI and
tails the session JSONL, which costs a multi-second readiness wait
per spawn — the thing ``startup_delay`` bounds — and brings the
swallowed-paste failure mode with it.
Switching also makes the PTY-only knobs below inert
(``startup_delay`` / ``file_wait_timeout`` /
``turn_duration_timeout`` / ``wait_for_turn_duration``) and drops
interactive-only ``extra_args`` such as ``--remote-control``, which
would take claude out of print mode. Everything else — model,
prompts, tools, MCPs, history seeding — behaves identically."""
warmup_turn: bool | None = None
"""Spend a throwaway turn when a session spawns, so MCP tools are
live on the agent's first reply.
``None`` (the default) decides per agent: on when it exposes any
MCP, off when it doesn't. Set it explicitly to override.
It exists because the CLI begins connecting MCP servers when a turn
starts and doesn't wait for them — measured on the pi, turn one saw
29 tools with both servers ``pending``, turn two saw 90 with both
``connected``. For an agent whose whole job is those tools, a first
reply without them is worse than a couple of extra seconds. Nothing
cheaper works: sleeping longer changes nothing, and neither a
``/status`` command nor the SDK's ``initialize`` handshake touches
the MCP client.
``stream_json`` only — the PTY transport's readiness wait covers
this incidentally."""
include_partial_messages: bool = False
"""Stream token-level deltas to the client instead of one delta per
finished block. Requires ``transport="stream_json"``; inert on the
PTY transport, whose JSONL contains only finished blocks. Off by
default because it multiplies the SSE event count — worth it for
interactive chat, pointless for a client that waits for the whole
message anyway."""
append_system_prompt: str | None = None
"""Maps to claude CLI's ``--append-system-prompt``. Opting in
re-attaches claude-code's full built-in prompt (persona, planning
conventions like "use TaskCreate proactively", and the dynamic
per-machine sections — cwd, env info, git status, memory paths)
plus this string on top. By default we ship only
:attr:`BaseAgent.system_prompt` via ``--system-prompt`` (~8.6k
tokens lighter); use this when the agent should behave like a
real claude-code coding session and your text is a delta on top
of those built-ins. Tool schemas survive either way — they ride
the Anthropic ``tools=[]`` channel, not the prompt text."""
disallowed_tools: tuple[str, ...] = ()
"""Tool names that claude must refuse to call. Combines with the
agent's ``available_native_tools`` allowlist — disallow wins."""
effort: str | None = None
include_partial_messages: bool = True
"""Token-level deltas on the wire; off means one delta per finished block."""
permission_mode: str = "bypassPermissions"
"""``claude --permission-mode`` value. Only meaningful when
``dangerously_skip_permissions=False``; otherwise the CLI bypasses
the prompt path entirely."""
tools: tuple[str, ...] | None = None
"""Base set of built-in tools; ``None`` keeps the CLI default set."""
dangerously_skip_permissions: bool = True
"""Pass ``--dangerously-skip-permissions`` to claude. ``True`` by
default — see the class docstring for why."""
disallowed_tools: tuple[str, ...] = ()
add_dirs: tuple[str, ...] = ()
env: Mapping[str, str] = Field(default_factory=dict)
"""Extra environment for the claude subprocess (always passed through)."""
effort: str | None = None
"""``claude --effort`` value (typically ``low`` / ``medium`` /
``high``). Tunes reasoning effort budget for newer models."""
add_dir: tuple[str, ...] = ()
"""Extra directories claude is allowed to read/edit beyond
``cwd``. Maps to repeated ``--add-dir`` flags."""
settings: str | None = None
"""Path to a ``--settings`` JSON file claude should load (hooks,
MCP servers, etc. that don't belong in our internal aggregator)."""
extra_args: tuple[str, ...] = ()
"""Raw extra argv to append to the claude command. Escape hatch
for flags we haven't surfaced as first-class options."""
extra_env: Mapping[str, str] = Field(default_factory=dict)
"""Additional environment variables for the spawned claude
process. Layered on top of the gateway's own env after
``preserve_provider_env`` is applied."""
preserve_provider_env: bool = False
"""When ``False`` (default), the spawn env strips
``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` /
``ANTHROPIC_BASE_URL`` so claude uses subscription auth instead of
leaking through whatever the gateway process inherited."""
history_injection_mode: HistoryInjectionMode = "native_jsonl"
"""How prior turns are seeded into a fresh session when no live
session matches: ``native_jsonl`` writes a hand-crafted transcript
and ``--resume``s; ``concat_message`` instead folds history into
the first user prompt. ``native_jsonl`` is more faithful."""
wait_for_turn_duration: bool = True
"""Keep reading JSONL until the ``turn_duration`` heartbeat
arrives, instead of returning on the first terminal assistant
record. ``True`` by default — see class docstring."""
include_meta_user: bool = False
"""Surface claude's ``isMeta=True`` user records (local-command
caveats) as ``UserMessage`` events. Off by default — they're not
part of the real conversation."""
startup_delay: float = 60.0
"""Upper bound (seconds) on waiting for claude's TUI to enable
bracketed-paste mode after spawn. claude-code-api polls PTY output
for the DECSET 2004 marker and returns as soon as it arrives, so a
generous cap costs nothing on a warm host — it only bounds the case
where the marker never shows.
It has to be generous, because timing out here means writing the
prompt into a terminal that hasn't enabled bracketed paste, where our
``ESC[200~`` framing is parsed as junk and the message is silently
dropped. Observed on a cold Raspberry Pi: 10s elapsed with *zero*
bytes of PTY output, the prompt went into the void, and the turn hung
until a human typed into the session by hand."""
file_wait_timeout: float = 30.0
"""How long to wait for the session JSONL to appear after spawn.
Failure here usually means a CLI auth / config problem."""
turn_duration_timeout: float = 5.0
"""How long to wait for the ``turn_duration`` heartbeat once a
terminal assistant has been seen. Bound on extra latency when
``wait_for_turn_duration=True``."""
env_keep: tuple[str, ...] = ()
"""Extra inherited variable names to let through the env whitelist."""
max_turns: int | None = None
idle_session_ttl: float = 1800.0
"""Seconds a pooled claude session may sit unused before it is
terminated. The backend pools live PTYs by conversation-history
fingerprint; when a conversation forks, the session behind the old
fingerprint becomes unreachable but stays resident — one live
``claude`` (hundreds of MB) each. 30 minutes keeps an active chat
warm across a coffee break while bounding what a fork storm can
strand. There is no count cap: concurrent conversations should all
stay warm. ``0`` disables reaping."""
"""Seconds a live session may sit unused before it is closed."""
session_store_flush: str = "batched"
class ClaudeAgent(BaseAgent):
"""Agent backed by ``claude-code-api``.
``cwd`` is the working directory the claude CLI is spawned in;
typed as ``Path`` (Pydantic coerces strings) since claude-code-api
accepts ``str | os.PathLike[str]``. ``available_native_tools`` stays
``tuple[str, ...]`` because Claude Code's tool set is a moving target
(Bash/Read/Edit/Write/WebSearch/etc. — versions add and rename), so
pinning it as a ``Literal`` would rot the type each release.
"""
cwd: Path
available_native_tools: tuple[str, ...] = ()
options: ClaudeCodeOptions = Field(default_factory=ClaudeCodeOptions)
"""Per-agent passthrough for the underlying claude-code-api
``BackendOptions``. See :class:`ClaudeCodeOptions`."""
system_prompt: str = ""
prompt_sources: tuple[Path, ...] = ()
skill_sets: tuple[Path, ...] = ()
options: ClaudeOptions = Field(default_factory=ClaudeOptions)