refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README

This commit is contained in:
hh
2026-09-02 00:33:04 +02:00
parent 90d0fb0f00
commit c9c2e94954
65 changed files with 795 additions and 1891 deletions
+10 -13
View File
@@ -2,18 +2,7 @@
A backend turns an Anthropic-style turn (``messages`` + agent definition)
into a stream of :class:`~beaver_gateway.events.stream.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.
``**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.backends.capture.TurnCapture` the backend fills
after the stream closes).
records; provider quirks are translated here, not in the frontend.
"""
from __future__ import annotations
@@ -40,5 +29,13 @@ class Backend(Protocol):
system: str | None = None,
**options: Any,
) -> AsyncIterator[MessageStreamEvent]:
"""Yield Anthropic stream events for one turn against ``agent``."""
"""Yield Anthropic stream events for one turn against ``agent``.
``**options`` keys are 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.backends.capture.TurnCapture` the backend
fills after the stream closes).
"""
...
+1 -4
View File
@@ -1,10 +1,7 @@
"""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.
``**options``; only backends that keep state per conversation fill it in.
"""
from __future__ import annotations
+31 -35
View File
@@ -1,29 +1,7 @@
"""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; the sessions of every agent live in one shared
:class:`~beaver_gateway.backends.sessions.SessionPool` that owns TTL and
memory-pressure eviction. Sessions are keyed by ``conversation_id`` when
the caller passes one 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 (after closing any
``tool_use`` left open by a crash), or seeds the incoming history into the
store via ``core/transcript`` and resumes that.
Per-turn ``**options`` beyond the protocol's: ``kind`` (conversation kind,
picks the prompt assembly and the pool TTL), ``pinned`` (never evicted),
``tools=False`` (no MCP at all - forks and jobs), ``observer`` (callback
receiving every raw SDK message, subagent ones included), ``turn_id``.
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, switches to that uid before exec (done in the
wrapper rather than via ``subprocess(user=...)``, which uvloop rejects).
One :class:`ClaudeSdkBackend` per :class:`ClaudeAgent`; its sessions live in
a shared :class:`~beaver_gateway.backends.sessions.SessionPool`.
"""
from __future__ import annotations
@@ -113,8 +91,6 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.backends.claude_sdk")
# §3.7: in bypass the callback only ever sees AskUserQuestion, and that is
# exactly the one we want - the SDK's warning about the rest is noise here.
warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
ASK_TOOL = "AskUserQuestion"
@@ -176,8 +152,7 @@ AuditSink = "Callable[[policy_mod.ToolAudit], Awaitable[None]]"
Asker = "Callable[[str, dict[str, Any]], Awaitable[str]]"
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
tool result``. The only channel an answer has in bypass mode is
``PermissionResultDeny.message`` (spike S1, s05): ``updated_input`` never
reaches the model."""
``PermissionResultDeny.message``: ``updated_input`` never reaches the model."""
@dataclass(frozen=True, slots=True)
@@ -306,7 +281,7 @@ class ClaudeSdkBackend:
*,
agent: BaseAgent,
messages: Iterable[MessageParam],
system: str | None = None, # noqa: ARG002 - the agent owns its prompt
system: str | None = None, # noqa: ARG002
conversation_id: str | None = None,
session_id: str | None = None,
reseed: bool = False,
@@ -316,8 +291,21 @@ class ClaudeSdkBackend:
tools: bool = True,
observer: Callable[[Any], None] | None = None,
turn_id: str | None = None,
**options: Any, # noqa: ARG002 - per-request knobs are not supported
**options: Any, # noqa: ARG002
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn against ``agent``, yielding Anthropic stream events.
Sessions are keyed by ``conversation_id`` when given, else a
text-only fingerprint of ``messages[:-1]``. Without a live session,
resumes ``session_id`` (closing any ``tool_use`` a crash left open),
or seeds ``messages[:-1]`` via ``backends/transcript.py`` and resumes
that. ``kind`` picks the prompt assembly and pool TTL; ``pinned``
sessions are never evicted; ``tools=False`` mounts no MCP servers
(forks and jobs); ``observer`` sees every raw SDK message.
A resumed session that dies before any event reached the caller is
reseeded from history and retried once.
"""
if agent.name != self._agent.name:
msg = f"backend bound to {self._agent.name!r}, got {agent.name!r}"
raise ValueError(msg)
@@ -339,8 +327,6 @@ class ClaudeSdkBackend:
live.running_turn = turn_id or message_id
live.last_used = time.monotonic()
try:
# Events go out as the CLI produces them: the frontends
# stream text and thinking live, the turn is not buffered.
async for event in self._run_turn(
live, prompt, turn, observer, capture
):
@@ -348,8 +334,6 @@ class ClaudeSdkBackend:
except Exception:
live.running_turn = None
await self._pool.close(key)
# A dead resume can be reseeded from history, but only
# while nothing of this turn has reached the caller yet.
if not (live.resumed and live.turns == 0) or turn.events:
raise
_log.exception(
@@ -626,6 +610,12 @@ class ClaudeSdkBackend:
) -> Callable[
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
]:
"""Build the ``can_use_tool`` callback for one session.
In bypass mode the SDK only ever calls this back for
``AskUserQuestion``; the warning about the rest is suppressed at
import time.
"""
asker = self._asker
async def can_use_tool(
@@ -648,7 +638,7 @@ class ClaudeSdkBackend:
def _hooks(
self, key: str, spec: _SessionSpec
) -> dict[HookEvent, list[HookMatcher]] | None:
"""§3.7: one in-process ``PreToolUse`` hook - policy rules, then audit."""
"""One in-process ``PreToolUse`` hook: policy rules, then audit."""
agent = self._agent
if not agent.policy and self._audit_sink is None:
return None
@@ -708,6 +698,12 @@ class ClaudeSdkBackend:
return plugins
def _exec_wrapper(self, *, extra_keep: tuple[str, ...]) -> Path:
"""Path to a generated wrapper that execs claude with a whitelisted env.
Drops every inherited variable outside the whitelist and, when
``RunnerConfig.user`` is set, switches to that uid before exec - done
here rather than via ``subprocess(user=...)``, which uvloop rejects.
"""
if self._wrapper is not None:
return self._wrapper
keep = sorted({*ENV_KEEP, *self._agent.options.env_keep, *extra_keep})
+31 -87
View File
@@ -1,32 +1,8 @@
"""Raycast backend adapter.
Translates between Anthropic's ``/v1/messages`` wire vocabulary (incoming
``MessageParam`` history, outgoing ``MessageStreamEvent`` SSE) and the
``raycast-api`` SDK (``Message`` history, ``ChatStreamChunk`` SSE).
Two halves live here:
* :func:`_to_raycast_messages` — pure conversion of an Anthropic message
list into ``list[raycast_api.Message]``. ``tool_result`` blocks carry no
tool name in Anthropic; we recover it by remembering each ``tool_use``
id we saw upstream.
* :meth:`RaycastBackend.complete` — opens a ``client.chat.stream`` and
walks chunks through a tiny block-state machine. The state machine
exists only because Raycast streams ``tool_calls`` in three phases
(open with id+name, deltas with empty id, final summary with the full
``arguments``) — Anthropic wants one ``content_block_start`` → deltas
→ ``content_block_stop`` per block, so we de-duplicate the final
summary against the per-delta increments already emitted.
MCP wiring: Raycast has no native MCP concept, so when an agent declares
``expose_mcps`` we splice each MCP's tools into the wire request as
``Tool.local(name=f"{mcp}__{tool}", ...)`` and run a gateway-internal
loop. Every time the model emits a tool_call for one of those local
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
``ClaudeSdkBackend`` does), but tool_results stay internal.
Translates between Anthropic's ``/v1/messages`` wire vocabulary and the
``raycast-api`` SDK, splicing MCP tools into Raycast's ``tool_calls`` and
routing them back in-process where the agent declares ``expose_mcps``.
"""
from __future__ import annotations
@@ -76,11 +52,9 @@ __all__ = ["RaycastBackend"]
_log = logging.getLogger("beaver_gateway.backends.raycast")
# Cap on consecutive tool-call turns inside one Anthropic envelope.
# Real conversations rarely chain more than a handful; the limit only
# fires on a model that loops, and surfaces as a clean ``end_turn``
# with an error tool_result rather than a hang.
_MAX_TOOL_TURNS = 20
"""Cap on consecutive tool-call turns inside one Anthropic envelope; a model
that loops hits this and gets a clean ``end_turn`` instead of a hang."""
_RAYCAST_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
@@ -392,7 +366,7 @@ class _BlockState:
def __init__(self) -> None:
self.index: int = -1
self.kind: str | None = None # "text" | "thinking" | "tool_use" | None
self.kind: str | None = None
self.tool_id_to_block: dict[str, int] = {}
self.tool_idx_to_id: dict[int, str] = {}
@@ -428,11 +402,14 @@ class RaycastBackend:
self._client = client
self._mcp_servers: Mapping[str, FastMCP] = mcp_servers or {}
self._mcp_tools: Mapping[str, list[FastMCPTool]] = mcp_tools or {}
# Cached per-agent catalog — agents are immutable, so a single
# render at first use covers the gateway's lifetime.
self._agent_catalog: dict[str, _AgentToolCatalog] = {}
def _catalog_for(self, agent: RaycastAgent) -> _AgentToolCatalog:
"""Build (or reuse) the tool catalog for ``agent``.
Agents are immutable, so a single render at first use covers the
gateway's lifetime.
"""
cached = self._agent_catalog.get(agent.name)
if cached is not None:
return cached
@@ -448,31 +425,26 @@ class RaycastBackend:
system: str | None = None,
**options: Any,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn against ``agent``, yielding Anthropic stream events.
On the wire Raycast uses ``system_instructions`` as a format marker
(``"markdown"``/``"plain"``, filled from the source default when we
pass ``None``) and ``additional_system_instructions`` for the actual
prompt content; ``system`` (or ``agent.system_prompt``) flows into
the latter.
"""
if not isinstance(agent, RaycastAgent):
msg = f"RaycastBackend requires RaycastAgent, got {type(agent).__name__}"
raise TypeError(msg)
raycast_messages = _to_raycast_messages(messages)
catalog = self._catalog_for(agent)
# Native remote tools + spliced MCP locals. ``None`` keeps the
# SDK from sending a ``tools`` field at all when neither is
# declared.
tools_arg: list[Tool | RemoteTool | str] | None = (
list(catalog.tools) if catalog.tools else None
)
# On the wire Raycast uses ``system_instructions`` as a format
# marker (``"markdown"`` for AI_CHAT, ``"plain"`` otherwise —
# filled in by the SDK from the source default when we pass
# ``None``) and ``additional_system_instructions`` as the actual
# prompt content. So our ``system_prompt`` (or the per-request
# Anthropic ``system``, if present) flows into the *additional*
# slot. The SDK still prepends ``<user-preferences>`` to whatever
# we hand it via ``_build_preamble``.
prompt_content = system if system is not None else agent.system_prompt
# Per-request options win over agent defaults; agent defaults
# win over Raycast SDK defaults. ``None`` means "fall back".
async for event in self._stream(
agent=agent,
raycast_messages=raycast_messages,
@@ -499,12 +471,16 @@ class RaycastBackend:
reasoning_effort: str | None,
tool_choice: str | None,
) -> AsyncIterator[MessageStreamEvent]:
"""Yield one Anthropic envelope spanning one or more ``chat.stream`` calls.
A tool_call not in ``mcp_routing`` is left to bubble out as a
regular ``tool_use`` block instead, for the caller to answer with a
``tool_result`` the usual way.
"""
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=agent.model)
state = _BlockState()
# ``working_messages`` is the rolling history fed back to
# Raycast as we resolve MCP tool_calls turn by turn.
working_messages = list(raycast_messages)
last_usage: dict[str, int] | None = None
last_finish: str | None = None
@@ -517,9 +493,6 @@ class RaycastBackend:
model=agent.model,
messages=working_messages,
source=agent.source,
# ``system_instructions=None`` → SDK substitutes the
# source default (``"markdown"`` / ``"plain"``). Real
# prompt goes into ``additional_system_instructions``.
additional_system_instructions=prompt_content,
user_preferences=agent.user_preferences,
tools=tools,
@@ -540,27 +513,16 @@ class RaycastBackend:
if acc.usage:
last_usage = acc.usage
# Figure out which (if any) tool_calls land on our MCP
# routing table. Anything not in the table is left to bubble
# out of the envelope as a regular tool_use block — the
# Anthropic caller can then respond with a tool_result the
# usual way, and the next ``complete`` invocation will
# carry it back in.
pending_mcp_calls = [
tid for tid in acc.tool_order if acc.tool_names.get(tid) in mcp_routing
]
if not pending_mcp_calls:
break
# Close whatever block is still open before we step into
# tool execution — the next turn's chunks start a fresh
# block sequence.
if state.kind is not None:
yield build_content_block_stop(state.index)
state.kind = None
# Echo the assistant turn so Raycast sees its own reply +
# tool_calls in subsequent context.
assistant_text = "".join(acc.text_parts)
assistant_tool_calls = [
ToolCall(
@@ -576,10 +538,6 @@ class RaycastBackend:
)
)
# Dispatch each MCP-routed call and append a ``tool``
# message. Calls not in the routing table get a placeholder
# error so the model can correct itself rather than the
# gateway hanging the conversation.
for tid in acc.tool_order:
tool_name = acc.tool_names.get(tid, "")
args_str = "".join(acc.tool_args.get(tid, [])) or "{}"
@@ -588,16 +546,12 @@ class RaycastBackend:
tool_name=tool_name, args_json=args_str, mcp_routing=mcp_routing
)
else:
# Non-MCP tool — shouldn't really happen because we
# haven't surfaced any other locals, but defend.
result_text = f"Tool {tool_name!r} is not handled by the gateway."
working_messages.append(
RaycastMessage.tool(
tool_call_id=tid, name=tool_name, result=result_text
)
)
# Loop: re-stream with the new history.
else:
_log.warning(
"raycast tool-call loop hit %d-turn cap for agent %r; "
@@ -606,7 +560,6 @@ class RaycastBackend:
agent.name,
)
# Close whatever block is still open before the final delta.
if state.kind is not None:
yield build_content_block_stop(state.index)
state.kind = None
@@ -716,6 +669,12 @@ class RaycastBackend:
the final-summary chunk's arguments string is dropped because the
deltas already streamed it.
Providers disagree on how ``arguments`` arrives: streaming ones
(GPT) send deltas across chunks and then restate the full string in
a final summary chunk (skip it, already streamed); non-streaming
ones (Gemini) send it only in that final summary (emit it once,
since nothing streamed it first).
Side-effects on ``acc`` mirror what gets emitted to the wire so
the gateway can rebuild a full ``ToolCall`` for the next Raycast
turn (it needs the joined ``arguments`` JSON string, which the
@@ -728,9 +687,6 @@ class RaycastBackend:
raw_tc = raw_tcs[i] if i < len(raw_tcs) else {}
idx_field = raw_tc.get("index") if isinstance(raw_tc, dict) else None
# Resolve this entry to a tool-id key, mirroring
# `raycast_api.ChatResult._merge_tool_calls`. Phase 1 carries
# id+index, phase 2 only index, phase 3 only id.
tool_id: str | None = None
if tc.id:
tool_id = tc.id
@@ -743,7 +699,6 @@ class RaycastBackend:
block_idx = state.tool_id_to_block.get(tool_id)
if block_idx is None:
# New tool_use block. Close any open text/thinking block first.
if state.kind is not None:
events.append(build_content_block_stop(state.index))
state.index += 1
@@ -756,22 +711,11 @@ class RaycastBackend:
block_idx, tool_use_id=tool_id, name=tc.name or ""
)
)
# Streaming providers (GPT) deliver arguments as deltas
# across chunks and re-state the full string in a final
# summary chunk; non-streaming-args providers (Gemini)
# only send args in the final summary. Emit args in
# both cases when this is the first appearance — final
# summary then IS the full string.
if tc.arguments:
events.append(build_input_json_delta(block_idx, tc.arguments))
acc.add_tool_args(tool_id, tc.arguments)
continue
# Existing block. Final summary chunks restate the full args
# string; if we already streamed deltas, that restatement is
# a duplicate (skip). If we streamed nothing (the streaming
# provider didn't send mid-arg deltas — Gemini path again),
# the summary IS the args — emit it once.
if is_final_summary:
if tc.arguments and not acc.tool_args.get(tool_id):
events.append(build_input_json_delta(block_idx, tc.arguments))
+11 -10
View File
@@ -1,13 +1,9 @@
"""Pool of live Agent SDK sessions across every Claude agent (§3.2).
"""Pool of live Agent SDK sessions across every Claude agent.
One :class:`Session` is one ``ClaudeSDKClient`` (one claude subprocess).
The pool owns the two decisions the adapters used to make on their own:
when a session is closed for idleness (TTL by conversation kind) and
which one goes when memory runs out (measured RSS of the subprocess tree
against the cgroup limit, ``max_live`` where there is no limit). Eviction
only ever picks ``idle && !running_turn && !pending_question`` sessions
that are neither pinned (the master) nor ``dirty`` (mirror gap not yet
repaired); forks and jobs go first.
One :class:`Session` is one ``ClaudeSDKClient`` (one claude subprocess). The
pool decides when a session is closed for idleness (TTL by conversation
kind) and which one goes when memory runs out (RSS of the subprocess tree
against the cgroup limit, or ``max_live`` with no limit).
"""
from __future__ import annotations
@@ -69,7 +65,7 @@ class Session:
created_at: float = field(default_factory=time.monotonic)
turns: int = 0
state: dict[str, Any] = field(default_factory=dict)
"""Scratch for policy rules (``core/policy``); dies with the process."""
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
@property
def busy(self) -> bool:
@@ -77,6 +73,10 @@ class Session:
@property
def evictable(self) -> bool:
"""Never evict a pinned (master) session or a ``dirty`` one.
``dirty`` means a mirror gap not yet repaired.
"""
return not (self.pinned or self.dirty or self.busy or self.pending_question)
@property
@@ -167,6 +167,7 @@ class SessionPool:
return len(self._sessions) >= self._max_live
def victims(self) -> list[Session]:
"""Evictable sessions, forks/jobs first, then oldest idle."""
candidates = [s for s in self._sessions.values() if s.evictable]
candidates.sort(key=lambda s: (_EVICT_ORDER.get(s.kind, 1), s.last_used))
return candidates
+10 -13
View File
@@ -1,16 +1,7 @@
"""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.
:func:`build_entries` renders a message list as entries the Claude CLI
itself writes; :func:`messages_from_entries` is the projection back.
"""
from __future__ import annotations
@@ -61,6 +52,14 @@ def build_entries(
permission_mode: str = "bypassPermissions",
now: datetime | None = None,
) -> list[dict[str, Any]]:
"""Render ``messages`` as entries a session store can resume from.
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``. Appending the result to a
session store and resuming that session id seeds external history
into the SDK.
"""
stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + (
f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z"
)
@@ -293,8 +292,6 @@ def _zero_usage() -> dict[str, Any]:
}
# ---- repair, windows, projections ---------------------------------------
_PROMPT_TYPES = ("user", "assistant")
_INTERRUPTED = "interrupted"