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:
@@ -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,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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user