fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn

This commit is contained in:
hh
2026-09-04 20:23:33 +02:00
parent 1f051f261e
commit 96e18156eb
15 changed files with 608 additions and 91 deletions
+234 -52
View File
@@ -37,6 +37,7 @@ from claude_agent_sdk import (
PermissionResultDeny, PermissionResultDeny,
ResultMessage, ResultMessage,
StreamEvent, StreamEvent,
TaskNotificationMessage,
TextBlock, TextBlock,
ThinkingBlock, ThinkingBlock,
ToolResultBlock, ToolResultBlock,
@@ -72,7 +73,14 @@ from beaver_gateway.events.stream import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Coroutine,
Iterable,
Sequence,
)
from anthropic.types import MessageParam from anthropic.types import MessageParam
from claude_agent_sdk import ( from claude_agent_sdk import (
@@ -201,8 +209,16 @@ class ClaudeSdkBackend:
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None, tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None, asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None, audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
waker: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
) -> None: ) -> None:
"""One backend per Claude agent.
``waker(key, note)`` is called when a session starts a turn by itself
(a background task reported back); the caller then attaches.
"""
self._agent = agent self._agent = agent
self.waker = waker
self._tasks: set[asyncio.Task[None]] = set()
self._audit_sink = audit_sink self._audit_sink = audit_sink
self._store = session_store self._store = session_store
self._runner = runner or RunnerConfig() self._runner = runner or RunnerConfig()
@@ -256,6 +272,51 @@ class ClaudeSdkBackend:
def live(self, key: str) -> Session | None: def live(self, key: str) -> Session | None:
return self._pool.get(key) return self._pool.get(key)
def has_pending(self, key: str) -> bool:
"""A turn the session started on its own is waiting for ``attach``."""
live = self._pool.get(key)
return live is not None and bool(live.pending)
def pending_note(self, key: str) -> str | None:
live = self._pool.get(key)
return live.note if live is not None else None
async def attach(
self,
*,
key: str,
capture: TurnCapture | None = None,
observer: Callable[[Any], None] | None = None,
turn_id: str | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Consume the turn a live session started by itself as a normal turn.
The CLI runs one when a background task (a subagent) reports back:
no prompt goes out, the events are what the model said in reply to
the report, the turn ends on that result.
"""
live = self._pool.get(key)
if live is None or not live.pending:
return
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=self._agent.model)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
live.last_used = time.monotonic()
async for event in self._run_turn(live, None, turn, observer, capture):
yield event
live.turns += 1
live.last_used = time.monotonic()
live.running_turn = None
usage, _ = await self._after_turn(
live, turn, conversation_id=key, history=[], capture=capture
)
yield build_message_delta(
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
)
yield build_message_stop()
async def repair_session( async def repair_session(
self, session_id: str, *, text: str = "interrupted" self, session_id: str, *, text: str = "interrupted"
) -> int: ) -> int:
@@ -404,70 +465,71 @@ class ClaudeSdkBackend:
async def _run_turn( async def _run_turn(
self, self,
live: Session, live: Session,
prompt: str | list[dict[str, Any]], prompt: str | list[dict[str, Any]] | None,
turn: _Turn, turn: _Turn,
observer: Callable[[Any], None] | None = None, observer: Callable[[Any], None] | None = None,
capture: TurnCapture | None = None, capture: TurnCapture | None = None,
) -> AsyncIterator[MessageStreamEvent]: ) -> AsyncIterator[MessageStreamEvent]:
"""Run one prompt, yielding wire events as they arrive. """Run one prompt, or attach to a self-started one, yielding events.
``turn`` is filled in place (result, synthesized history, count of ``turn`` is filled in place (result, synthesized history, count of
events already yielded) so the caller can finish bookkeeping - and events already yielded) so the caller can finish bookkeeping - and
decide whether a retry is still possible - after a failure. The decide whether a retry is still possible - after a failure. The
session id lands in ``capture`` with the first frame, so a turn cut session id lands in ``capture`` with the first frame, so a turn cut
by a restart still leaves a resumable session behind. by a restart still leaves a resumable session behind.
A result whose ``origin`` is not the human's ends a turn the CLI ran
on its own; inside a prompted turn it is passed over, the events it
came with stay in this turn rather than getting lost.
""" """
streaming = self._agent.options.include_partial_messages
raw: list[Any] = [] raw: list[Any] = []
next_index = 0 cursor = _Cursor()
offset = 0 live.observer = observer
await live.client.query(prompt if isinstance(prompt, str) else _stream(prompt)) live.attached = True
async for message in live.client.receive_response(): for queued in live.pending:
if observer is not None: live.inbox.put_nowait(queued)
observer(message) live.pending.clear()
session_id = getattr(message, "session_id", None) try:
if isinstance(session_id, str) and session_id and live.session_id is None: if prompt is not None:
live.session_id = session_id await live.client.query(
if capture is not None: prompt if isinstance(prompt, str) else _stream(prompt)
capture.session_id = session_id
if isinstance(message, MirrorErrorMessage):
live.dirty = True
_log.error(
"session %s: mirror error, marked dirty: %s",
live.session_id,
message.error,
) )
continue while True:
if getattr(message, "parent_tool_use_id", None) is not None: message = await live.inbox.get()
continue if message is _END:
if isinstance(message, StreamEvent): if prompt is not None:
event = message.event msg = f"claude exited before answering ({live.session_id})"
if event.get("type") == "message_start": raise RuntimeError(msg)
offset = next_index break
turn.context_tokens = _context_of(event.get("message")) if isinstance(message, BaseException):
raise message
if observer is not None:
observer(message)
if not self._note(live, message, capture):
continue continue
index = event.get("index") if isinstance(message, ResultMessage):
if isinstance(index, int): if prompt is not None and _self_started(message):
next_index = max(next_index, offset + index + 1) _log.info(
if streaming: "session %s: a self-started turn ended inside a "
for out in _emit_stream_event(event, offset + index): "prompted one, folded in",
turn.events += 1 live.session_id,
yield out )
elif isinstance(message, AssistantMessage): continue
raw.append(message) turn.result = message
if not streaming: turn.stop_reason = _STOP_REASONS.get(
for block in message.content: message.stop_reason or "", "end_turn"
for out in _emit_block(block, next_index): )
turn.events += 1 break
yield out for out in self._emit(message, turn, cursor, raw):
next_index += 1 turn.events += 1
elif isinstance(message, UserMessage): yield out
raw.append(message) if turn.events == 0:
elif isinstance(message, ResultMessage): for out in _emit_whole(raw, cursor):
turn.result = message turn.events += 1
turn.stop_reason = _STOP_REASONS.get( yield out
message.stop_reason or "", "end_turn" finally:
) live.attached = False
live.woken = False
turn.synthesized = synthesize_turn_messages(raw) turn.synthesized = synthesize_turn_messages(raw)
_log.info( _log.info(
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s", "turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
@@ -478,6 +540,88 @@ class ClaudeSdkBackend:
turn.stop_reason, turn.stop_reason,
) )
@staticmethod
def _note(live: Session, message: Any, capture: TurnCapture | None) -> bool:
"""Session bookkeeping for one message; False if not the main thread's."""
session_id = getattr(message, "session_id", None)
if isinstance(session_id, str) and session_id and live.session_id is None:
live.session_id = session_id
if capture is not None:
capture.session_id = session_id
if isinstance(message, MirrorErrorMessage):
live.dirty = True
_log.error(
"session %s: mirror error, marked dirty: %s",
live.session_id,
message.error,
)
return False
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
return False
return getattr(message, "parent_tool_use_id", None) is None
def _emit(
self, message: Any, turn: _Turn, cursor: _Cursor, raw: list[Any]
) -> Iterable[MessageStreamEvent]:
streaming = self._agent.options.include_partial_messages
if isinstance(message, StreamEvent):
event = message.event
if event.get("type") == "message_start":
cursor.offset = cursor.next_index
turn.context_tokens = _context_of(event.get("message"))
return
index = event.get("index")
if isinstance(index, int):
cursor.next_index = max(cursor.next_index, cursor.offset + index + 1)
if streaming:
yield from _emit_stream_event(event, cursor.offset + index)
elif isinstance(message, AssistantMessage):
raw.append(message)
if not streaming:
for block in message.content:
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
elif isinstance(message, UserMessage):
raw.append(message)
async def _read(self, live: Session) -> None:
"""Read the client for the session's whole life.
During a turn every message goes to the turn's inbox. Between turns
a subagent still running is shown to the last observer, and the
first frame of the main thread means the CLI started a turn by
itself: it is buffered in ``pending`` and the waker is told once.
"""
try:
async for message in live.client.receive_messages():
if live.attached:
live.inbox.put_nowait(message)
continue
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
if _main_thread(message):
live.pending.append(message)
if not live.woken:
live.woken = True
self._wake(live)
elif live.observer is not None:
live.observer(message)
live.inbox.put_nowait(_END)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
_log.error("session %s: reader died: %s", live.session_id, exc)
live.dirty = True
live.inbox.put_nowait(exc)
def _wake(self, live: Session) -> None:
if self.waker is None:
return
task = asyncio.create_task(self.waker(live.key, live.note or ""))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _acquire( async def _acquire(
self, self,
key: str, key: str,
@@ -541,7 +685,7 @@ class ClaudeSdkBackend:
spec.tools, spec.tools,
self._runner.user, self._runner.user,
) )
return Session( live = Session(
key=key, key=key,
agent=self._agent.name, agent=self._agent.name,
kind=spec.kind, kind=spec.kind,
@@ -550,6 +694,8 @@ class ClaudeSdkBackend:
resumed=resume is not None, resumed=resume is not None,
pinned=spec.pinned, pinned=spec.pinned,
) )
live.reader = asyncio.create_task(self._read(live))
return live
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient: def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
return _RunnerClient(options, uid=self._uid) return _RunnerClient(options, uid=self._uid)
@@ -739,6 +885,15 @@ class _SessionSpec:
tools: bool tools: bool
_END = object()
@dataclass
class _Cursor:
next_index: int = 0
offset: int = 0
@dataclass @dataclass
class _Turn: class _Turn:
events: int = 0 events: int = 0
@@ -866,6 +1021,33 @@ async def _stream(content: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]
} }
def _emit_whole(raw: Iterable[Any], cursor: _Cursor) -> Iterable[MessageStreamEvent]:
"""Blocks of a turn that streamed no deltas, e.g. one reported whole."""
for message in raw:
if isinstance(message, AssistantMessage):
for block in message.content:
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
def _self_started(result: ResultMessage) -> bool:
origin = result.origin
return origin is not None and origin.get("kind") != "human"
def _main_thread(message: Any) -> bool:
if getattr(message, "parent_tool_use_id", None) is not None:
return False
return isinstance(
message, (StreamEvent, AssistantMessage, UserMessage, ResultMessage)
)
def _note_of(message: TaskNotificationMessage) -> str:
summary = message.summary or message.data.get("description") or message.task_id
return f"{summary} ({message.status})"
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]: def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for message in raw: for message in raw:
+25 -2
View File
@@ -20,7 +20,13 @@ from typing import TYPE_CHECKING, Any, Protocol
import psutil import psutil
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping from collections.abc import (
AsyncIterable,
AsyncIterator,
Callable,
Iterator,
Mapping,
)
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"] __all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
@@ -42,7 +48,7 @@ _RSS_HEADROOM = 0.8
class SessionClient(Protocol): class SessionClient(Protocol):
async def connect(self) -> None: ... async def connect(self) -> None: ...
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ... async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
def receive_response(self) -> AsyncIterator[Any]: ... def receive_messages(self) -> AsyncIterator[Any]: ...
async def interrupt(self) -> None: ... async def interrupt(self) -> None: ...
async def disconnect(self) -> None: ... async def disconnect(self) -> None: ...
@@ -67,6 +73,20 @@ class Session:
state: dict[str, Any] = field(default_factory=dict) state: dict[str, Any] = field(default_factory=dict)
"""Scratch for policy rules (``agents/policy.py``); dies with the process.""" """Scratch for policy rules (``agents/policy.py``); dies with the process."""
reader: asyncio.Task[None] | None = None
"""Reads the client for the session's whole life, not only during a turn."""
inbox: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
"""Where the reader puts messages while a turn is attached."""
pending: list[Any] = field(default_factory=list)
"""A turn the CLI started on its own (a background task reported back),
buffered until a consumer attaches."""
attached: bool = False
woken: bool = False
note: str | None = None
"""Summary of the last task notification, the pending turn's prompt."""
observer: Callable[[Any], None] | None = None
"""The last turn's observer; sees subagent traffic between turns."""
@property @property
def busy(self) -> bool: def busy(self) -> bool:
return self.lock.locked() or self.running_turn is not None return self.lock.locked() or self.running_turn is not None
@@ -192,6 +212,8 @@ class SessionPool:
if session is None: if session is None:
return return
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key) _log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
if session.reader is not None and session.reader is not asyncio.current_task():
session.reader.cancel()
try: try:
await session.client.disconnect() await session.client.disconnect()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@@ -238,6 +260,7 @@ class SessionPool:
"turns": s.turns, "turns": s.turns,
"busy": s.busy, "busy": s.busy,
"running_turn": s.running_turn, "running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question, "pending_question": s.pending_question,
"pinned": s.pinned, "pinned": s.pinned,
"dirty": s.dirty, "dirty": s.dirty,
@@ -127,6 +127,12 @@ class State:
self._tasks = set() self._tasks = set()
self._idle_task = None self._idle_task = None
self.scheduler = None self.scheduler = None
for backend in backends.values():
if hasattr(backend, "waker"):
backend.waker = self._woken
async def _woken(self, key: str, note: str) -> None:
raise NotImplementedError
@property @property
def db(self) -> Database: def db(self) -> Database:
@@ -77,6 +77,8 @@ class ConversationTexts:
) )
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
bundle_header: str = "[injects accumulated since {since}; not the user]" bundle_header: str = "[injects accumulated since {since}; not the user]"
task_prompt: str = "[subagent reported back: {note}]"
"""Stands in for the user's words in mirrors of a turn a subagent started."""
interrupted: str = "interrupted" interrupted: str = "interrupted"
answered: str = "The user answered: {answer}" answered: str = "The user answered: {answer}"
unanswered: str = ( unanswered: str = (
+87 -14
View File
@@ -54,8 +54,15 @@ class Turns(Seeds):
tools: bool = True, tools: bool = True,
turn_id: str | None = None, turn_id: str | None = None,
item_origin: str | None = None, item_origin: str | None = None,
shown: str | None = None,
attach: bool = False,
) -> AsyncIterator[MessageStreamEvent]: ) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn under the conversation's lock; the only path to the backend.""" """Run one turn under the conversation's lock; the only path to the backend.
``shown`` is what frontends display as the turn's prompt (the user's
words, not the envelope). ``attach`` sends nothing: the turn is one
the live session started by itself, a subagent reporting back.
"""
row_id = cast("int", conv.id) row_id = cast("int", conv.id)
runner = self._runner(row_id) runner = self._runner(row_id)
backend = self._backend(conv.agent_name) backend = self._backend(conv.agent_name)
@@ -65,7 +72,7 @@ class Turns(Seeds):
async with runner.lock: async with runner.lock:
runner.turn_id = turn_id runner.turn_id = turn_id
runner.origin = origin runner.origin = origin
runner.text = _prompt_preview(messages) runner.text = shown if shown is not None else _prompt_preview(messages)
runner.started_at = datetime.now(UTC) runner.started_at = datetime.now(UTC)
runner.tools = {} runner.tools = {}
await self._mark_running(conv, turn_id) await self._mark_running(conv, turn_id)
@@ -81,18 +88,28 @@ class Turns(Seeds):
stop = "error" stop = "error"
cut = False cut = False
try: try:
events = backend.complete( observer = self._observer(conv, runner, turn_id, origin)
agent=self._claude_agent(conv.agent_name), events = (
messages=messages, backend.attach(
conversation_id=conv.external_id, key=conv.external_id,
session_id=resume if use_session else None, capture=capture,
reseed=not use_session, observer=observer,
capture=capture, turn_id=turn_id,
kind=conv.kind, )
pinned=conv.kind == "master", if attach
tools=tools, else backend.complete(
observer=self._observer(conv, runner, turn_id, origin), agent=self._claude_agent(conv.agent_name),
turn_id=turn_id, messages=messages,
conversation_id=conv.external_id,
session_id=resume if use_session else None,
reseed=not use_session,
capture=capture,
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=observer,
turn_id=turn_id,
)
) )
async for event in events: async for event in events:
yield event yield event
@@ -128,6 +145,8 @@ class Turns(Seeds):
turn_id: str | None = None, turn_id: str | None = None,
item_origin: str | None = None, item_origin: str | None = None,
attachments: Sequence[dict[str, Any]] | None = None, attachments: Sequence[dict[str, Any]] | None = None,
shown: str | None = None,
attach: bool = False,
) -> tuple[str, TurnCapture]: ) -> tuple[str, TurnCapture]:
capture = TurnCapture() capture = TurnCapture()
acc = StreamAccumulator() acc = StreamAccumulator()
@@ -140,6 +159,8 @@ class Turns(Seeds):
tools=tools, tools=tools,
turn_id=turn_id, turn_id=turn_id,
item_origin=item_origin, item_origin=item_origin,
shown=shown,
attach=attach,
): ):
acc.feed(event) acc.feed(event)
message = acc.finalize(model=agent.model) message = acc.finalize(model=agent.model)
@@ -160,6 +181,47 @@ class Turns(Seeds):
runner = self._runners.get(cast("int", conv.id)) runner = self._runners.get(cast("int", conv.id))
return runner.origin if runner is not None and runner.turn_id else None return runner.origin if runner is not None and runner.turn_id else None
async def _woken(self, key: str, note: str) -> None:
"""The live session of ``key`` started a turn by itself; run it next."""
conv = await self.get(key)
if conv is None:
_log.warning("self-started turn on unknown conversation %s: %s", key, note)
return
_log.info("conversation %s woke by itself: %s", key, note)
self._ensure_worker(cast("int", conv.id))
async def _run_pending(self, conv: Conversation) -> None:
turn_id = f"turn_{uuid4().hex[:12]}"
note = self._backend(conv.agent_name).pending_note(conv.external_id) or ""
try:
text, _ = await self.run_text_turn(
conv,
note,
origin="task",
turn_id=turn_id,
item_origin="task",
shown=note,
attach=True,
)
except Exception: # noqa: BLE001
_log.exception(
"self-started turn %s on %s failed", turn_id, conv.external_id
)
return
if not text.strip():
return
self._bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
item=None,
item_origin="task",
source="task",
prompt=self._texts.task_prompt.format(note=note),
user_text=None,
text=text,
)
def _ensure_worker(self, row_id: int) -> None: def _ensure_worker(self, row_id: int) -> None:
runner = self._runner(row_id) runner = self._runner(row_id)
runner.wake.set() runner.wake.set()
@@ -170,6 +232,10 @@ class Turns(Seeds):
async def _worker(self, row_id: int) -> None: async def _worker(self, row_id: int) -> None:
runner = self._runner(row_id) runner = self._runner(row_id)
while True: while True:
conv = await self.get_row(row_id)
if conv is not None and self._has_pending(conv):
await self._run_pending(conv)
continue
items = await self._queue.pending(row_id) items = await self._queue.pending(row_id)
batch, wait = self._pick(items) batch, wait = self._pick(items)
if batch is None: if batch is None:
@@ -233,6 +299,7 @@ class Turns(Seeds):
turn_id=turn_id, turn_id=turn_id,
item_origin=head.origin, item_origin=head.origin,
attachments=head.attachments if origin == "user" else None, attachments=head.attachments if origin == "user" else None,
shown=head.text if origin == "user" else None,
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_log.exception("turn %s on %s failed", turn_id, conv.external_id) _log.exception("turn %s on %s failed", turn_id, conv.external_id)
@@ -254,6 +321,12 @@ class Turns(Seeds):
text=text, text=text,
) )
def _has_pending(self, conv: Conversation) -> bool:
try:
return self._backend(conv.agent_name).has_pending(conv.external_id)
except LookupError:
return False
def _bundle(self, items: Sequence[InjectQueueItem]) -> str: def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))] lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
lines.extend(f"- [{i.origin}] {i.text}" for i in items) lines.extend(f"- [{i.origin}] {i.text}" for i in items)
@@ -47,6 +47,7 @@ class Draft:
self._interval = interval self._interval = interval
self.status = status self.status = status
self.text = "" self.text = ""
self._paused = False
self._dirty = True self._dirty = True
self._broken = False self._broken = False
self._last_sent = 0.0 self._last_sent = 0.0
@@ -65,11 +66,22 @@ class Draft:
if status != self.status: if status != self.status:
self.status = status self.status = status
self._dirty = True self._dirty = True
self._paused = bool(self.text.strip())
def append(self, text: str) -> None: def append(self, text: str) -> None:
if text: """Add streamed text; a tool call in between starts a new paragraph.
The model ends a sentence, calls a tool, then goes on without a
break - the same text the final message shows as paragraphs.
"""
if not text:
return
if self._paused and not self.text.endswith("\n"):
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
else:
self.text += text self.text += text
self._dirty = True self._paused = False
self._dirty = True
async def stop(self) -> None: async def stop(self) -> None:
"""Stop pushing and wait for the push in flight. """Stop pushing and wait for the push in flight.
@@ -777,7 +777,7 @@ class TelegramFrontend(Frontend):
return return
match kind: match kind:
case "turn.start": case "turn.start":
if event.get("origin") == "user": if event.get("origin") in ("user", "task"):
await self._open_draft(key, event, target) await self._open_draft(key, event, target)
case "stream": case "stream":
self._feed_draft(key, event) self._feed_draft(key, event)
+12
View File
@@ -86,6 +86,7 @@ class FakeClient:
self.prompts: list[str] = [] self.prompts: list[str] = []
self.connected = False self.connected = False
self.session_id = options.resume or "fresh-session" self.session_id = options.resume or "fresh-session"
self.asked = asyncio.Event()
FakeClient.instances.append(self) FakeClient.instances.append(self)
async def connect(self) -> None: async def connect(self) -> None:
@@ -93,6 +94,17 @@ class FakeClient:
async def query(self, prompt: str) -> None: async def query(self, prompt: str) -> None:
self.prompts.append(prompt) self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def interrupt(self) -> None: async def interrupt(self) -> None:
self.interrupted = True self.interrupted = True
+100
View File
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from claude_agent_sdk import ( from claude_agent_sdk import (
TaskNotificationMessage,
AssistantMessage, AssistantMessage,
InMemorySessionStore, InMemorySessionStore,
ResultMessage, ResultMessage,
@@ -92,6 +93,8 @@ class ScriptedClient:
self.session_id = options.resume or str(uuid.uuid4()) self.session_id = options.resume or str(uuid.uuid4())
self.interrupted = False self.interrupted = False
self.connected = False self.connected = False
self.asked = asyncio.Event()
self.extra: list[Any] = []
ScriptedClient.instances.append(self) ScriptedClient.instances.append(self)
async def connect(self) -> None: async def connect(self) -> None:
@@ -106,6 +109,26 @@ class ScriptedClient:
b.get("text", "") for b in content if b.get("type") == "text" b.get("text", "") for b in content if b.get("type") == "text"
) )
self.prompts.append(prompt) self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
"""One response per prompt, forever; ``extra`` is what the CLI sends
on its own between prompts (a subagent reporting back)."""
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
if self.extra:
for message in self.extra:
if isinstance(message, asyncio.Event):
await message.wait()
continue
yield message
self.extra = []
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def receive_response(self): async def receive_response(self):
prompt = self.prompts[-1] prompt = self.prompts[-1]
@@ -858,3 +881,80 @@ async def test_inject_into_a_closed_master_lands_in_the_open_one(world: World) -
assert item.conversation_id == new.id assert item.conversation_id == new.id
assert await world.statuses(old) == [] assert await world.statuses(old) == []
assert await world.statuses(new) == [("normal", "queued")] assert await world.statuses(new) == [("normal", "queued")]
def _self_started(session_id: str, text: str) -> list[Any]:
"""What the CLI sends by itself once a background subagent reports back."""
return [
AssistantMessage(
content=[ToolUseBlock(id="tu_dig", name="Grep", input={"pattern": "au"})],
model="m",
parent_tool_use_id="tu_bg",
),
TaskNotificationMessage(
subtype="task_notification",
data={},
task_id="t1",
status="completed",
output_file="",
summary='Agent "dig" finished',
uuid="n1",
session_id=session_id,
),
AssistantMessage(content=[TextBlock(text=text)], model="m"),
ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id=session_id,
stop_reason="end_turn",
total_cost_usd=0.0,
usage={"input_tokens": 1, "output_tokens": 1},
origin={"kind": "task-notification"},
),
]
async def test_subagent_report_between_turns_is_its_own_turn(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
seen: list[dict[str, Any]] = []
async def collect() -> None:
async for event in world.bus.stream(conversation_id=conv.external_id):
seen.append(event)
task = asyncio.create_task(collect())
await world.conversations.post(conv, "go")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "the agent found gold")
client.asked.set()
await asyncio.sleep(0.3)
task.cancel()
replies = [e for e in seen if e["type"] == "reply"]
assert [r["text"] for r in replies] == ["ok:go", "the agent found gold"]
assert replies[1]["item_origin"] == "task"
starts = [e for e in seen if e["type"] == "turn.start"]
assert [s["origin"] for s in starts] == ["user", "task"]
assert starts[1]["text"] == 'Agent "dig" finished (completed)'
assert len(client.prompts) == 1
first_end = next(i for i, e in enumerate(seen) if e["type"] == "turn.end")
later = [e for e in seen[first_end + 1 :] if e["type"] in ("stream", "tool")]
assert later and all(e["turn_id"] == starts[0]["turn_id"] for e in later[:1])
assert all(e["turn_id"] == starts[1]["turn_id"] for e in later[1:])
assert (await world.conversations.get(conv.external_id)).running_turn is None
async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.post(conv, "one")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "late report")
await world.conversations.post(conv, "two")
await world.settle(conv, 2)
assert client.prompts[-1].startswith("two")
rows = await world.conversations.queue.recent(conv.id)
assert [r.status for r in rows] == ["done", "done"]
+18 -1
View File
@@ -4,7 +4,7 @@ import contextlib
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any, cast
import pytest import pytest
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
@@ -16,6 +16,7 @@ from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.render import ( from beaver_gateway.frontends.telegram.render import (
LIMIT, LIMIT,
chunks, chunks,
@@ -719,6 +720,22 @@ def test_chunks_split_blockquote_and_stay_under_limit() -> None:
assert " ".join(parts).split() == long_line.split() assert " ".join(parts).split() == long_line.split()
def test_draft_breaks_a_paragraph_after_a_tool_call() -> None:
draft = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="t")
draft.append("Checking the balance.")
draft.set_status("⏳ Bash")
draft.set_status("✍️ writing")
draft.append("It is 20 378.")
assert draft.text == "Checking the balance.\n\nIt is 20 378."
draft.set_status("⏳ Read")
draft.append("\n\nAnd a list:")
assert draft.text.endswith("It is 20 378.\n\nAnd a list:")
fresh = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="u")
fresh.set_status("⏳ Bash")
fresh.append("First words")
assert fresh.text == "First words"
def test_to_html_tail_inside_fence() -> None: def test_to_html_tail_inside_fence() -> None:
full = ( full = (
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter" "intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
+1 -1
View File
@@ -23,7 +23,7 @@
onMount(() => { onMount(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
if (model.running) { if (model.live.length > 0) {
now = Date.now(); now = Date.now();
} }
}, TICK_MS); }, TICK_MS);
+82 -5
View File
@@ -27,6 +27,9 @@ export interface Turn {
itemOrigin: string | null; itemOrigin: string | null;
nodes: Record<string, ToolNode>; nodes: Record<string, ToolNode>;
origin: string; origin: string;
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
resultSubtype: string | null; resultSubtype: string | null;
roots: string[]; roots: string[];
says: string[]; says: string[];
@@ -51,6 +54,20 @@ function str(value: unknown): string | null {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
export function joinText(head: string, tail: string, paused: boolean): string {
if (paused && head.trim() && !head.endsWith("\n")) {
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
}
return head + tail;
}
export function isLive(turn: Turn): boolean {
return (
turn.status === "running" ||
Object.values(turn.nodes).some((node) => node.status === "running")
);
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus { function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") { if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done"; return turn.status === "error" ? "error" : "done";
@@ -65,6 +82,9 @@ export class ActivityModel {
turns = $state<Turn[]>([]); turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null); question = $state<PendingQuestion | null>(null);
// A subagent still working after its turn ended: nothing on this page
// knows the turn, so its tool calls hang under the launching Agent node
// of a turn named after that node.
private readonly handlers: Record<string, (c: Cursor) => void> = { private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c), "conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c), "conversation.updated": (c) => this.onConversation(c),
@@ -89,6 +109,12 @@ export class ActivityModel {
return this.turns.find((turn) => turn.status === "running") ?? null; return this.turns.find((turn) => turn.status === "running") ?? null;
} }
// A turn is live while it runs or while a subagent it launched still
// works: the CLI reports subagent tool calls after the turn's own end.
get live(): Turn[] {
return this.turns.filter((turn) => isLive(turn));
}
setConversation(info: ConversationInfo): void { setConversation(info: ConversationInfo): void {
this.conversation = info; this.conversation = info;
this.question = info.question; this.question = info.question;
@@ -151,6 +177,42 @@ export class ActivityModel {
turn.startedAt = ts; turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin); turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText; turn.userText = str(event.text) ?? turn.userText;
if (turn.origin === "task") {
this.settleAgents(ts);
}
}
// The report is in: whatever Agent node still runs has finished.
private settleAgents(ts: string): void {
for (const turn of this.turns) {
if (turn.origin === "agent" && turn.status === "running") {
turn.status = "done";
turn.endedAt = ts;
}
for (const node of Object.values(turn.nodes)) {
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
if (node.status === "running" && node.parent === null && agent) {
node.status = "done";
node.endedAt = ts;
}
}
}
}
// Subagent traffic belongs to the turn that launched the subagent, whatever
// turn the gateway was in when it arrived.
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
if (parent) {
const owner = this.turns.find((turn) => parent in turn.nodes);
if (owner) {
return owner;
}
const current = this.turns.find((turn) => turn.id === turnId);
if (current?.status !== "running") {
return this.ensureTurn(`agent:${parent}`, ts, "agent");
}
}
return this.ensureTurn(turnId, ts, null);
} }
private onStream({ event, ts, turnId, parent }: Cursor): void { private onStream({ event, ts, turnId, parent }: Cursor): void {
@@ -158,7 +220,7 @@ export class ActivityModel {
if (!(turnId && raw && typeof raw === "object")) { if (!(turnId && raw && typeof raw === "object")) {
return; return;
} }
const turn = this.ensureTurn(turnId, ts, null); const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>; const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") { if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined; const block = sdk.content_block as Record<string, unknown> | undefined;
@@ -172,7 +234,8 @@ export class ActivityModel {
if (sdk.type === "content_block_delta" && parent === null) { if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined; const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") { if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text += delta.text; turn.text = joinText(turn.text, delta.text, turn.paused);
turn.paused = false;
} }
} }
} }
@@ -181,7 +244,10 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) { if (!(turnId && typeof event.tool_use_id === "string")) {
return; return;
} }
const turn = this.ensureTurn(turnId, ts, null); const turn = this.turnFor(turnId, ts, parent);
if (parent === null) {
turn.paused = true;
}
const node = this.ensureNode( const node = this.ensureNode(
turn, turn,
event.tool_use_id, event.tool_use_id,
@@ -196,7 +262,7 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) { if (!(turnId && typeof event.tool_use_id === "string")) {
return; return;
} }
const turn = this.ensureTurn(turnId, ts, null); const turn = this.turnFor(turnId, ts, parent);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts); const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done"; node.status = event.is_error ? "error" : "done";
node.endedAt = ts; node.endedAt = ts;
@@ -266,11 +332,13 @@ export class ActivityModel {
}; };
} }
// The turn's own tool calls die with it; a subagent's keep going until
// their own results arrive.
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void { private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status; turn.status = status;
turn.endedAt = ts; turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) { for (const node of Object.values(turn.nodes)) {
if (node.status === "running") { if (node.status === "running" && node.parent === null) {
node.status = "aborted"; node.status = "aborted";
node.endedAt = ts; node.endedAt = ts;
} }
@@ -291,6 +359,7 @@ export class ActivityModel {
itemOrigin: null, itemOrigin: null,
nodes: {}, nodes: {},
origin: origin ?? "?", origin: origin ?? "?",
paused: false,
resultSubtype: null, resultSubtype: null,
roots: [], roots: [],
says: [], says: [],
@@ -394,3 +463,11 @@ export function summarizeInput(name: string, input: unknown): string {
export function toolLabel(name: string): string { export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name; return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
} }
// How a turn was started, as a word for the header.
export const ORIGIN_LABELS: Record<string, string> = {
agent: "subagent at work",
inject: "inject",
task: "subagent report",
user: "user",
};
+6 -4
View File
@@ -7,7 +7,7 @@
import { clip, fmtDateTime, fmtTime } from "$lib/format"; import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte"; import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte"; import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache"; import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte"; import Markdown from "./markdown.svelte";
@@ -48,7 +48,7 @@
const tail = $derived( const tail = $derived(
model.turns model.turns
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt) .filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
.reverse() .reverse()
); );
const tailSize = $derived( const tailSize = $derived(
@@ -150,14 +150,16 @@
$effect(() => { $effect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
if (model.running) { if (model.live.length > 0) {
now = Date.now(); now = Date.now();
} }
}, TICK_MS); }, TICK_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}); });
const SYSTEM_HEAD = /^\[[^\]\n]+\]/; // Text the gateway or the CLI put in the user's seat: an inject header,
// an envelope, a subagent's report.
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/; const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/; const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set()); let openSystem = $state<Set<number>>(new Set());
+1 -1
View File
@@ -53,7 +53,7 @@
<span class={cn("size-2 rounded-full", DOT[node.status])}></span> <span class={cn("size-2 rounded-full", DOT[node.status])}></span>
<span class="flex min-w-0 items-baseline gap-2"> <span class="flex min-w-0 items-baseline gap-2">
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}> <span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
{toolLabel(node.name)} {subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
</span> </span>
{#if summary} {#if summary}
<span class="truncate text-muted-foreground">{summary}</span> <span class="truncate text-muted-foreground">{summary}</span>
+19 -8
View File
@@ -9,7 +9,7 @@
fmtTokens, fmtTokens,
} from "$lib/format"; } from "$lib/format";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte"; import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte"; import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte"; import ToolNodeView from "./tool-node.svelte";
@@ -18,10 +18,14 @@
const duration = $derived( const duration = $derived(
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now) turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
); );
const origin = $derived(ORIGIN_LABELS[turn.origin] ?? turn.origin);
const originLabel = $derived( const originLabel = $derived(
turn.itemOrigin && turn.itemOrigin !== turn.origin turn.itemOrigin && turn.itemOrigin !== turn.origin
? `${turn.origin} · ${turn.itemOrigin}` ? `${origin} · ${turn.itemOrigin}`
: turn.origin : origin
);
const pill = $derived(
turn.status !== "running" && isLive(turn) ? "running" : turn.status
); );
const toolCount = $derived(Object.keys(turn.nodes).length); const toolCount = $derived(Object.keys(turn.nodes).length);
</script> </script>
@@ -29,15 +33,20 @@
<article <article
class={cn( class={cn(
"flex flex-col gap-2 border-b py-3", "flex flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]" pill === "running" && "bg-signal/[0.03]"
)} )}
> >
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs"> <header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
<StatusPill status={turn.status} /> {#if turn.origin !== "agent"}
<StatusPill status={pill} />
{/if}
<span <span
class={cn( class={cn(
"font-medium", "font-medium",
turn.origin === "inject" ? "text-note" : "text-foreground" turn.origin === "inject" && "text-note",
turn.origin === "agent" || turn.origin === "task"
? "text-kind-deep"
: "text-foreground"
)} )}
> >
{originLabel} {originLabel}
@@ -65,7 +74,9 @@
</span> </span>
{/if} {/if}
</header> </header>
{#if turn.userText} {#if turn.userText && turn.origin === "task"}
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
{:else if turn.userText}
<Markdown <Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5" class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
text={turn.userText} text={turn.userText}
@@ -88,7 +99,7 @@
{/each} {/each}
{#if turn.text} {#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} /> <Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0} {:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm"> <p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model… Waiting for the model…
</p> </p>