Compare commits

..
24 changed files with 686 additions and 141 deletions

No files matched your search

+21 -2
View File
@@ -45,11 +45,30 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in # Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
# the user's config. The claude CLI itself ships inside the # the user's config.
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
# The claude-agent-sdk wheel bundles a CLI, but it trails the npm release:
# 0.2.157 carries 2.1.277, which does not know claude-opus-5-5 and writes an
# [claude-code:unrecognized_model] warning per turn. Install the npm build and
# point BEAVER_CLAUDE_CLI at it; the adapter prefers it over the bundled one.
#
# Only the glibc platform package is pulled: the meta package also drags in the
# musl build, and this image is glibc for the reason stated at the top. The
# tree lands under /opt world-readable because the model runs as beaver-runner,
# not as root.
ARG CLAUDE_CODE_VERSION=2.1.280
RUN mkdir -p /opt/claude-code \
&& cd /opt/claude-code \
&& echo '{"name":"claude-cli-holder","private":true}' > package.json \
&& bun add @anthropic-ai/claude-code-linux-x64@${CLAUDE_CODE_VERSION} \
&& ln -sf /opt/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude \
/usr/local/bin/claude-cli \
&& chmod -R a+rX /opt/claude-code \
&& /usr/local/bin/claude-cli --version
ENV BEAVER_CLAUDE_CLI=/usr/local/bin/claude-cli
# The model process runs as `beaver-runner`, not as the gateway: the # The model process runs as `beaver-runner`, not as the gateway: the
# adapter spawns claude under this uid with a whitelisted environment. # adapter spawns claude under this uid with a whitelisted environment.
# `acl` lets the entrypoint grant it write access to the vault sub-mounts # `acl` lets the entrypoint grant it write access to the vault sub-mounts
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies = [
"anthropic>=0.103.0", "anthropic>=0.103.0",
"anyio>=4.13.0", "anyio>=4.13.0",
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"claude-agent-sdk>=0.2.150", "claude-agent-sdk>=0.2.157",
"croniter>=6.2.4", "croniter>=6.2.4",
"fastapi>=0.136.1", "fastapi>=0.136.1",
"fastmcp>=3.3.1", "fastmcp>=3.3.1",
+5
View File
@@ -36,3 +36,8 @@ class BaseAgent(BaseModel):
system_prompt: str system_prompt: str
expose_mcps: tuple[ExposedMcp, ...] = () expose_mcps: tuple[ExposedMcp, ...] = ()
accept_client_tools: bool = False accept_client_tools: bool = False
@property
def thinking_is_progress(self) -> bool:
"""Whether non-empty thinking is text for the human rather than reasoning."""
return False
+13 -1
View File
@@ -17,13 +17,19 @@ from beaver_gateway.agents.policy import PolicyRule # noqa: TC001
from beaver_gateway.agents.prompts import PromptSource # noqa: TC001 from beaver_gateway.agents.prompts import PromptSource # noqa: TC001
from beaver_gateway.conversations.kinds import KINDS, Kind from beaver_gateway.conversations.kinds import KINDS, Kind
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"] __all__ = ["PROGRESS_MODELS", "ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
PROGRESS_MODELS = frozenset({"claude-opus-5-5", "claude-fable-5-1"})
"""Models whose non-empty thinking is the text written between tool calls."""
class ClaudeOptions(BaseModel): class ClaudeOptions(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
effort: str | None = None effort: str | None = None
thinking: Mapping[str, str] | None = None
"""Thinking config for the SDK; ``None`` keeps the CLI default."""
include_partial_messages: bool = True include_partial_messages: bool = True
"""Token-level deltas on the wire; off means one delta per finished block.""" """Token-level deltas on the wire; off means one delta per finished block."""
@@ -118,6 +124,12 @@ class ClaudeAgent(BaseAgent):
raise ValueError(msg) raise ValueError(msg)
return self return self
@property
def thinking_is_progress(self) -> bool:
"""A progress model, unless ``display: summarized`` brings reasoning."""
summarized = (self.options.thinking or {}).get("display") == "summarized"
return self.model in PROGRESS_MODELS and not summarized
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None: def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return self.prompts.for_kind(kind) return self.prompts.for_kind(kind)
+11
View File
@@ -726,6 +726,7 @@ class ClaudeSdkBackend:
return ClaudeAgentOptions( return ClaudeAgentOptions(
model=agent.model or None, model=agent.model or None,
effort=cast("Any", opt.effort), effort=cast("Any", opt.effort),
thinking=cast("Any", dict(opt.thinking) if opt.thinking else None),
system_prompt=system_prompt, system_prompt=system_prompt,
setting_sources=[], setting_sources=[],
strict_mcp_config=True, strict_mcp_config=True,
@@ -933,7 +934,17 @@ async def _disconnect(live: Session) -> None:
_log.exception("disconnect failed for session %s", live.session_id) _log.exception("disconnect failed for session %s", live.session_id)
CLI_ENV = "BEAVER_CLAUDE_CLI"
"""Path to a ``claude`` CLI that wins over the one bundled in the wheel."""
def _claude_binary() -> str: def _claude_binary() -> str:
override = os.environ.get(CLI_ENV)
if override:
if not Path(override).is_file():
msg = f"{CLI_ENV} points at {override!r}, which is not a file"
raise FileNotFoundError(msg)
return override
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude" bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
if bundled.is_file(): if bundled.is_file():
return str(bundled) return str(bundled)
+40 -5
View File
@@ -10,7 +10,7 @@ import hashlib
import uuid as _uuid import uuid as _uuid
from collections.abc import Mapping from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
try: try:
from claude_agent_sdk._cli_version import __cli_version__ from claude_agent_sdk._cli_version import __cli_version__
@@ -31,6 +31,8 @@ __all__ = [
"open_tool_uses", "open_tool_uses",
"prompt_count", "prompt_count",
"render_messages", "render_messages",
"reply_text",
"spoken_text",
"strip_tool_entries", "strip_tool_entries",
"text_of", "text_of",
"window_entries", "window_entries",
@@ -434,13 +436,45 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e)) return sum(1 for e in entries if _is_prompt_entry(e))
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
"""What a content block (dict or model) says to the human.
Text always; thinking only when the agent's thinking is progress
written for the human, never when it is reasoning.
"""
if isinstance(block, Mapping):
fields = cast("Mapping[str, Any]", block)
kind, text, thinking = (
fields.get("type"),
fields.get("text"),
fields.get("thinking"),
)
else:
kind = getattr(block, "type", None)
text, thinking = getattr(block, "text", None), getattr(block, "thinking", None)
if kind == "text":
return str(text or "").strip()
if kind == "thinking" and thinking_is_progress:
return str(thinking or "").strip()
return ""
def reply_text(content: Iterable[Any], *, thinking_is_progress: bool) -> str:
"""The reply as the human reads it: spoken blocks in order, a blank line apart."""
parts = (spoken_text(b, thinking_is_progress=thinking_is_progress) for b in content)
return "\n\n".join(p for p in parts if p)
def render_messages( def render_messages(
messages: Iterable[Mapping[str, Any]], *, window: int | None = None messages: Iterable[Mapping[str, Any]],
*,
thinking_is_progress: bool,
window: int | None = None,
) -> str: ) -> str:
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns. """Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
Tool calls collapse to a one-line summary per assistant turn; tool Tool calls collapse to a one-line summary per assistant turn; tool
results and thinking are dropped. results and reasoning are dropped, progress thinking reads as text.
""" """
turns: list[str] = [] turns: list[str] = []
tools: list[str] = [] tools: list[str] = []
@@ -466,8 +500,9 @@ def render_messages(
turns.append("user:\n" + _text_of_content(content)) turns.append("user:\n" + _text_of_content(content))
continue continue
for block in _assistant_blocks(content): for block in _assistant_blocks(content):
if block.get("type") == "text" and block.get("text"): text = spoken_text(block, thinking_is_progress=thinking_is_progress)
current.append(str(block["text"])) if text:
current.append(text)
elif block.get("type") == "tool_use": elif block.get("type") == "tool_use":
tools.append(str(block.get("name", ""))) tools.append(str(block.get("name", "")))
flush() flush()
+9 -1
View File
@@ -352,6 +352,8 @@ class Rows(State):
for b in await self.bindings(conv) for b in await self.bindings(conv)
] ]
live = self._pool.get(conv.external_id) live = self._pool.get(conv.external_id)
agent = self._agents.get(conv.agent_name)
out["thinking_is_progress"] = agent is not None and agent.thinking_is_progress
out["live"] = live is not None out["live"] = live is not None
out["busy"] = live.busy if live is not None else False out["busy"] = live.busy if live is not None else False
runner = self._runners.get(cast("int", conv.id)) runner = self._runners.get(cast("int", conv.id))
@@ -466,7 +468,13 @@ class Rows(State):
return marked return marked
async def read(self, conv: Conversation, *, window: int | None = None) -> str: async def read(self, conv: Conversation, *, window: int | None = None) -> str:
return render_messages(await self.history(conv), window=window) return render_messages(
await self.history(conv),
thinking_is_progress=self._claude_agent(
conv.agent_name
).thinking_is_progress,
window=window,
)
async def history(self, conv: Conversation) -> list[dict[str, Any]]: async def history(self, conv: Conversation) -> list[dict[str, Any]]:
if conv.session_id is None: if conv.session_id is None:
+4 -7
View File
@@ -23,7 +23,7 @@ from claude_agent_sdk import (
) )
from beaver_gateway.backends.capture import TurnCapture from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import text_of from beaver_gateway.backends.transcript import reply_text, text_of
from beaver_gateway.conversations import injects from beaver_gateway.conversations import injects
from beaver_gateway.conversations.seeds import Seeds from beaver_gateway.conversations.seeds import Seeds
from beaver_gateway.conversations.state import Runner, aware, iso from beaver_gateway.conversations.state import Runner, aware, iso
@@ -164,12 +164,9 @@ class Turns(Seeds):
): ):
acc.feed(event) acc.feed(event)
message = acc.finalize(model=agent.model) message = acc.finalize(model=agent.model)
reply = "\n\n".join( return reply_text(
getattr(b, "text", "") message.content, thinking_is_progress=agent.thinking_is_progress
for b in message.content ), capture
if getattr(b, "type", "") == "text"
).strip()
return reply, capture
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002 async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
return None return None
+9 -9
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.backends.capture import TurnCapture from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import fingerprint, text_of from beaver_gateway.backends.transcript import fingerprint, reply_text, text_of
from beaver_gateway.frontends.accumulate import StreamAccumulator from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token from beaver_gateway.frontends.bearer import require_token
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from anthropic.types import Message, MessageParam from anthropic.types import Message, MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.conversations.service import Conversations from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.stream import MessageStreamEvent from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import GatewayRuntime
@@ -158,6 +159,7 @@ class AnthropicMessagesFrontend(Frontend):
capture=capture, capture=capture,
turn_id=turn_id, turn_id=turn_id,
history=history, history=history,
thinking_is_progress=agent.thinking_is_progress,
) )
else: else:
options: dict[str, Any] = {} options: dict[str, Any] = {}
@@ -170,7 +172,7 @@ class AnthropicMessagesFrontend(Frontend):
async def after(message: Message) -> None: async def after(message: Message) -> None:
await _broadcast_turn( await _broadcast_turn(
runtime, runtime,
agent_name=agent.name, agent=agent,
input_messages=messages, input_messages=messages,
output_message=message, output_message=message,
system=system_str, system=system_str,
@@ -235,12 +237,9 @@ async def _finish(
capture: TurnCapture, capture: TurnCapture,
turn_id: str, turn_id: str,
history: list[dict[str, str]] | None, history: list[dict[str, str]] | None,
thinking_is_progress: bool,
) -> None: ) -> None:
reply = "\n\n".join( reply = reply_text(message.content, thinking_is_progress=thinking_is_progress)
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
tail = capture.synthesized_messages or [ tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]} {"role": "assistant", "content": [{"type": "text", "text": reply}]}
] ]
@@ -291,7 +290,7 @@ async def _sse(
async def _broadcast_turn( async def _broadcast_turn(
runtime: GatewayRuntime, runtime: GatewayRuntime,
*, *,
agent_name: str, agent: BaseAgent,
input_messages: list[MessageParam], input_messages: list[MessageParam],
output_message: Message, output_message: Message,
system: str | None, system: str | None,
@@ -299,9 +298,10 @@ async def _broadcast_turn(
if not runtime.turn_log_handlers: if not runtime.turn_log_handlers:
return return
record = TurnRecord( record = TurnRecord(
agent_name=agent_name, agent_name=agent.name,
input_messages=list(input_messages), input_messages=list(input_messages),
output_message=output_message, output_message=output_message,
thinking_is_progress=agent.thinking_is_progress,
system=system, system=system,
source=FRONTEND, source=FRONTEND,
) )
@@ -142,14 +142,16 @@ class CrossFrontendLogger:
new_user = record.input_messages[-1] new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user) new_block = renderer.render_user_param(new_user)
new_block = renderer.append_to_body( new_block = renderer.append_to_body(
new_block, renderer.render_assistant_message(record.output_message) new_block,
renderer.render_assistant_message(
record.output_message,
thinking_is_progress=record.thinking_is_progress,
),
) )
new_body = renderer.append_to_body(body, new_block) new_body = renderer.append_to_body(body, new_block)
metadata = dict(parsed.metadata) metadata = dict(parsed.metadata)
else: else:
new_body = _render_full_history( new_body = _render_full_history(record)
record.input_messages, record.output_message
)
metadata = {} metadata = {}
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD) new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
@@ -204,10 +206,10 @@ def _flatten_text(message: Any) -> str:
return "\n\n".join(c for c in chunks if c) return "\n\n".join(c for c in chunks if c)
def _render_full_history(messages: list[MessageParam], assistant: Any) -> str: def _render_full_history(record: TurnRecord) -> str:
"""Render an entire conversation (used when materializing a new logged file).""" """Render an entire conversation (used when materializing a new logged file)."""
blocks: list[str] = [] blocks: list[str] = []
for msg in messages: for msg in record.input_messages:
role = msg.get("role") role = msg.get("role")
if role == "user": if role == "user":
blocks.append(renderer.render_user_param(msg)) blocks.append(renderer.render_user_param(msg))
@@ -215,7 +217,11 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
content = msg.get("content", "") content = msg.get("content", "")
text = content if isinstance(content, str) else _content_to_text(content) text = content if isinstance(content, str) else _content_to_text(content)
blocks.append(f"### Assistant:\n\n{text.strip()}\n" if text.strip() else "") blocks.append(f"### Assistant:\n\n{text.strip()}\n" if text.strip() else "")
blocks.append(renderer.render_assistant_message(assistant)) blocks.append(
renderer.render_assistant_message(
record.output_message, thinking_is_progress=record.thinking_is_progress
)
)
body = "" body = ""
for block in blocks: for block in blocks:
if not block: if not block:
@@ -341,7 +341,11 @@ class MarkdownFrontend(Frontend):
file_path=file_path, file_path=file_path,
first_user_text=parsed.turns[0].text, first_user_text=parsed.turns[0].text,
) )
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns) outcome = diff_and_fork(
stored=stored_msgs,
incoming=parsed.turns,
thinking_is_progress=agent.thinking_is_progress,
)
capture = TurnCapture() capture = TurnCapture()
events = runtime.conversations.turn( events = runtime.conversations.turn(
conv, conv,
@@ -356,6 +360,7 @@ class MarkdownFrontend(Frontend):
file_path=file_path, file_path=file_path,
parsed=parsed, parsed=parsed,
model=agent.model or agent.name, model=agent.model or agent.name,
thinking_is_progress=agent.thinking_is_progress,
filename=filename, filename=filename,
write_disk=write_disk, write_disk=write_disk,
) )
@@ -371,6 +376,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed, parsed=parsed,
message=message, message=message,
agent_name=agent.name, agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id, conv_external_id=conv_external_id,
write_disk=write_disk, write_disk=write_disk,
) )
@@ -388,6 +394,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name, agent_name=agent.name,
input_messages=list(parsed.messages), input_messages=list(parsed.messages),
output_message=message, output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None, system=None,
source="markdown", source="markdown",
) )
@@ -534,7 +541,11 @@ class MarkdownFrontend(Frontend):
len(stored_msgs), len(stored_msgs),
len(parsed.turns), len(parsed.turns),
) )
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns) outcome = diff_and_fork(
stored=stored_msgs,
incoming=parsed.turns,
thinking_is_progress=agent.thinking_is_progress,
)
_log.info( _log.info(
"chat/stream: file=%s diff_and_fork divergence=%s " "chat/stream: file=%s diff_and_fork divergence=%s "
"backend_msgs=%d persist_msgs=%d", "backend_msgs=%d persist_msgs=%d",
@@ -567,7 +578,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model) partial = acc.finalize(model=model)
if not partial.content: if not partial.content:
return None return None
rendered = renderer.render_assistant_message(partial) rendered = renderer.render_assistant_message(
partial, thinking_is_progress=agent.thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered) new_body = renderer.append_to_body(parsed.body, rendered)
return reattach_frontmatter(parsed.metadata, new_body) return reattach_frontmatter(parsed.metadata, new_body)
@@ -593,7 +606,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body new_body = parsed.body
if partial.content: if partial.content:
new_body = renderer.append_to_body( new_body = renderer.append_to_body(
new_body, renderer.render_assistant_message(partial) new_body,
renderer.render_assistant_message(
partial, thinking_is_progress=agent.thinking_is_progress
),
) )
new_body = renderer.append_to_body(new_body, _render_error_block(exc)) new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk: if write_disk:
@@ -616,6 +632,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed, parsed=parsed,
message=message, message=message,
agent_name=agent.name, agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id, conv_external_id=conv_external_id,
write_disk=write_disk, write_disk=write_disk,
) )
@@ -633,6 +650,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name, agent_name=agent.name,
input_messages=list(parsed.messages), input_messages=list(parsed.messages),
output_message=message, output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None, system=None,
source="markdown", source="markdown",
) )
@@ -659,6 +677,7 @@ class MarkdownFrontend(Frontend):
file_path: Path, file_path: Path,
parsed: parser.ParsedFile, parsed: parser.ParsedFile,
model: str, model: str,
thinking_is_progress: bool,
filename: str, filename: str,
write_disk: bool = True, write_disk: bool = True,
) -> Any: ) -> Any:
@@ -675,7 +694,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model) partial = acc.finalize(model=model)
if not partial.content: if not partial.content:
return return
rendered = renderer.render_assistant_message(partial) rendered = renderer.render_assistant_message(
partial, thinking_is_progress=thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered) new_body = renderer.append_to_body(parsed.body, rendered)
await write_atomic( await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body) file_path, reattach_frontmatter(parsed.metadata, new_body)
@@ -698,7 +719,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body new_body = parsed.body
if partial.content: if partial.content:
new_body = renderer.append_to_body( new_body = renderer.append_to_body(
new_body, renderer.render_assistant_message(partial) new_body,
renderer.render_assistant_message(
partial, thinking_is_progress=thinking_is_progress
),
) )
new_body = renderer.append_to_body(new_body, _render_error_block(exc)) new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk: if write_disk:
@@ -715,11 +739,14 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile, parsed: parser.ParsedFile,
message: Any, message: Any,
agent_name: str, agent_name: str,
thinking_is_progress: bool,
conv_external_id: str, conv_external_id: str,
write_disk: bool = True, write_disk: bool = True,
) -> str: ) -> str:
"""Render the assistant turn, refresh frontmatter, write if we own the file.""" """Render the assistant turn, refresh frontmatter, write if we own the file."""
rendered = renderer.render_assistant_message(message) rendered = renderer.render_assistant_message(
message, thinking_is_progress=thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered) new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD) new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
updated_metadata = dict(parsed.metadata) updated_metadata = dict(parsed.metadata)
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import delete from sqlalchemy import delete
from sqlmodel import select from sqlmodel import select
from beaver_gateway.backends.transcript import spoken_text
from beaver_gateway.storage.models import Conversation, ConversationMessage from beaver_gateway.storage.models import Conversation, ConversationMessage
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -180,7 +181,9 @@ class _StoredDisplayTurn:
text_segment_count: int text_segment_count: int
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]: def _group_display_turns(
stored: list[dict[str, Any]], *, thinking_is_progress: bool
) -> list[_StoredDisplayTurn]:
"""Walk raw stored messages, group them into Obsidian-visible turns. """Walk raw stored messages, group them into Obsidian-visible turns.
A user-prompt message (no ``tool_result`` blocks) opens a new turn; A user-prompt message (no ``tool_result`` blocks) opens a new turn;
@@ -210,7 +213,9 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
break break
group.append(m) group.append(m)
i += 1 i += 1
spoken, skeleton, text_count = _summarize_assistant_group(group) spoken, skeleton, text_count = _summarize_assistant_group(
group, thinking_is_progress=thinking_is_progress
)
out.append( out.append(
_StoredDisplayTurn( _StoredDisplayTurn(
role="assistant", role="assistant",
@@ -251,12 +256,13 @@ def _user_prompt_text(content: Any) -> str:
def _summarize_assistant_group( def _summarize_assistant_group(
group: list[dict[str, Any]], group: list[dict[str, Any]], *, thinking_is_progress: bool
) -> tuple[str, list[str], int]: ) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group. """Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into Must mirror what the renderer writes and the parser reads back: text and
one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible. progress thinking collapse into one segment, tool_use becomes a skeleton
entry, tool_result, reasoning and empty thinking are invisible.
""" """
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
@@ -282,12 +288,10 @@ def _summarize_assistant_group(
for blk in content: for blk in content:
if not isinstance(blk, dict): if not isinstance(blk, dict):
continue continue
btype = blk.get("type") text = spoken_text(blk, thinking_is_progress=thinking_is_progress)
if btype == "text": if text:
text = str(blk.get("text", "")).strip() pending.append(text)
if text: elif blk.get("type") == "tool_use":
pending.append(text)
elif btype == "tool_use":
_flush() _flush()
segments.append(ToolSegment(name=str(blk.get("name", "")))) segments.append(ToolSegment(name=str(blk.get("name", ""))))
_flush() _flush()
@@ -299,7 +303,10 @@ def _summarize_assistant_group(
def diff_and_fork( def diff_and_fork(
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn] *,
stored: list[dict[str, Any]],
incoming: list[ParsedTurn],
thinking_is_progress: bool,
) -> ForkOutcome: ) -> ForkOutcome:
"""Align the incoming parsed file against stored history. """Align the incoming parsed file against stored history.
@@ -314,11 +321,15 @@ def diff_and_fork(
) )
raise ValueError(msg) raise ValueError(msg)
stored_groups = _group_display_turns(stored) stored_groups = _group_display_turns(
stored, thinking_is_progress=thinking_is_progress
)
new_user_turn = incoming[-1] new_user_turn = incoming[-1]
prior_incoming = incoming[:-1] prior_incoming = incoming[:-1]
spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups) spliced_groups, divergence, edited = _walk_prefix(
prior_incoming, stored_groups, thinking_is_progress=thinking_is_progress
)
if divergence is None and len(prior_incoming) < len(stored_groups): if divergence is None and len(prior_incoming) < len(stored_groups):
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn): if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
@@ -358,7 +369,10 @@ def _file_lags_store(
def _walk_prefix( def _walk_prefix(
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn] prior_incoming: list[ParsedTurn],
stored_groups: list[_StoredDisplayTurn],
*,
thinking_is_progress: bool,
) -> tuple[list[list[dict[str, Any]]], int | None, bool]: ) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
"""Walk incoming vs stored side-by-side until first divergence. """Walk incoming vs stored side-by-side until first divergence.
@@ -391,7 +405,9 @@ def _walk_prefix(
continue continue
if inc_skeleton and inc_text_count != st.text_segment_count: if inc_skeleton and inc_text_count != st.text_segment_count:
return spliced_groups, i, edited return spliced_groups, i, edited
spliced = _splice_assistant_group(stored_group=st, incoming=inc) spliced = _splice_assistant_group(
stored_group=st, incoming=inc, thinking_is_progress=thinking_is_progress
)
if spliced is None: if spliced is None:
return spliced_groups, i, edited return spliced_groups, i, edited
spliced_groups.append(spliced) spliced_groups.append(spliced)
@@ -426,7 +442,10 @@ def _assemble_tail(
def _splice_assistant_group( def _splice_assistant_group(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn *,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
"""Apply the file's prose to a stored assistant turn. """Apply the file's prose to a stored assistant turn.
@@ -434,27 +453,28 @@ def _splice_assistant_group(
markdown can't express. Falls back to rebuilding from the file's markdown can't express. Falls back to rebuilding from the file's
structure when the text blocks don't line up one-to-one. structure when the text blocks don't line up one-to-one.
""" """
preserved = _splice_in_place(stored_group=stored_group, incoming=incoming) preserved = _splice_in_place(
stored_group=stored_group,
incoming=incoming,
thinking_is_progress=thinking_is_progress,
)
if preserved is not None: if preserved is not None:
return preserved return preserved
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming) return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
def _is_text_block(blk: object) -> bool:
if not isinstance(blk, dict):
return False
block = cast("dict[str, Any]", blk)
return block.get("type") == "text" and bool(str(block.get("text", "")).strip())
def _splice_in_place( def _splice_in_place(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn *,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents. """Copy the stored messages, swapping only their text block contents.
Preserves message/thinking-block boundaries that a rebuild would lose Preserves message/thinking-block boundaries that a rebuild would lose
(and that a cache-hit continuation depends on); returns ``None`` if (and that a cache-hit continuation depends on); returns ``None`` if
text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead. text blocks aren't 1:1 with incoming segments or the prose runs through
signed thinking blocks, so the caller rebuilds instead.
""" """
from beaver_gateway.frontends.markdown.parser import TextSegment from beaver_gateway.frontends.markdown.parser import TextSegment
@@ -466,9 +486,12 @@ def _splice_in_place(
content = msg.get("content") content = msg.get("content")
if msg["role"] != "assistant" or not isinstance(content, list): if msg["role"] != "assistant" or not isinstance(content, list):
continue continue
positions.extend( for bi, blk in enumerate(cast("list[dict[str, Any]]", content)):
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk) if not spoken_text(blk, thinking_is_progress=thinking_is_progress):
) continue
if blk["type"] == "thinking":
return None
positions.append((mi, bi))
if len(positions) != len(new_texts): if len(positions) != len(new_texts):
return None return None
@@ -12,8 +12,6 @@ from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock from anthropic.types import Message, TextBlock, ThinkingBlock
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from anthropic.types import MessageParam from anthropic.types import MessageParam
@@ -45,24 +43,25 @@ def render_assistant_text(text: str) -> str:
return f"### Assistant:\n\n{text.strip()}\n" return f"### Assistant:\n\n{text.strip()}\n"
def render_assistant_message(message: Message) -> str: def render_assistant_message(message: Message, *, thinking_is_progress: bool) -> str:
"""Render an assistant ``Message`` into a turn block. """Render an assistant ``Message`` into a turn block.
Tool-use blocks render to nothing; of the thinking blocks between two Tool-use blocks render to nothing. Progress thinking renders as text in
texts only the first one reaches the file. place and vanishes when empty; reasoning renders as a collapsed callout,
of which only the first between two texts reaches the file.
""" """
parts: list[str] = ["### Assistant:", ""] parts: list[str] = ["### Assistant:", ""]
after_thinking = False after_reasoning = False
for block in message.content: for block in message.content:
thinking = isinstance(block, ThinkingBlock) if isinstance(block, ThinkingBlock) and not thinking_is_progress:
if thinking and after_thinking: if not after_reasoning:
parts.extend((*_render_reasoning(block.thinking or ""), ""))
after_reasoning = True
continue continue
lines = list(_render_block(block)) text = _spoken(block)
if not lines: if text:
continue parts.extend((text, ""))
after_thinking = thinking after_reasoning = False
parts.extend(lines)
parts.append("")
return "\n".join(parts).rstrip() + "\n" return "\n".join(parts).rstrip() + "\n"
@@ -108,21 +107,17 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
return name return name
def _render_block(block: object) -> Iterable[str]: def _render_reasoning(text: str) -> tuple[str, ...]:
lines = text.strip().splitlines() or [""]
return ("> [!thinking]-", *(f"> {line}" if line else ">" for line in lines))
def _spoken(block: object) -> str:
if isinstance(block, TextBlock): if isinstance(block, TextBlock):
text = (block.text or "").strip() return (block.text or "").strip()
if text:
yield text
return
if isinstance(block, ThinkingBlock): if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "") return (block.thinking or "").strip()
return return ""
def _render_thinking(text: str) -> Iterable[str]:
yield "> [!thinking]-"
for line in text.strip().splitlines() or [""]:
yield f"> {line}" if line else ">"
def adaptive_fence(content: str) -> str: def adaptive_fence(content: str) -> str:
@@ -852,7 +852,10 @@ class TelegramFrontend(Frontend):
if event.get("origin") in ("user", "task"): 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) agents = cast("GatewayRuntime", self._runtime).agents
agent = agents.get(conv.agent_name)
progress = agent is not None and agent.thinking_is_progress
self._feed_draft(key, event, thinking_is_progress=progress)
case "tool": case "tool":
draft = self._drafts.get(key) draft = self._drafts.get(key)
if draft is not None and event.get("parent_tool_use_id") is None: if draft is not None and event.get("parent_tool_use_id") is None:
@@ -971,7 +974,9 @@ class TelegramFrontend(Frontend):
self._drafts[key] = draft self._drafts[key] = draft
draft.start() draft.start()
def _feed_draft(self, key: str, event: Event) -> None: def _feed_draft(
self, key: str, event: Event, *, thinking_is_progress: bool
) -> None:
draft = self._drafts.get(key) draft = self._drafts.get(key)
if draft is None or event.get("parent_tool_use_id") is not None: if draft is None or event.get("parent_tool_use_id") is not None:
return return
@@ -983,7 +988,12 @@ class TelegramFrontend(Frontend):
draft.set_status(self.texts.writing) draft.set_status(self.texts.writing)
draft.append(str(delta.get("text") or "")) draft.append(str(delta.get("text") or ""))
elif delta.get("type") == "thinking_delta": elif delta.get("type") == "thinking_delta":
draft.set_status(self.texts.thinking) progress = str(delta.get("thinking") or "")
if progress and thinking_is_progress:
draft.set_status(self.texts.writing)
draft.append(progress)
else:
draft.set_status(self.texts.thinking)
elif kind == "content_block_start": elif kind == "content_block_start":
block = raw.get("content_block") or {} block = raw.get("content_block") or {}
if block.get("type") == "tool_use": if block.get("type") == "tool_use":
@@ -46,6 +46,7 @@ class TurnRecord:
agent_name: str agent_name: str
input_messages: list[MessageParam] input_messages: list[MessageParam]
output_message: Message output_message: Message
thinking_is_progress: bool
system: str | None = None system: str | None = None
source: str = "anthropic" source: str = "anthropic"
+25 -2
View File
@@ -19,12 +19,14 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from watchfiles import awatch from watchfiles import DefaultFilter, awatch
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable
__all__ = ["Change", "VaultWatch", "WatchRules"] from watchfiles import Change as FileChange
__all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"]
_log = logging.getLogger("beaver_gateway.vault.watch") _log = logging.getLogger("beaver_gateway.vault.watch")
@@ -74,6 +76,26 @@ class _Seen:
count: int count: int
IGNORE_DIRS = (".obsidian", ".trash", ".git")
"""Housekeeping directories whose churn never means an edit of the vault."""
IGNORE_NAMES = (".sync.lock", "*.lock", "*.tmp", "*~")
"""Lock and scratch file names, matched against the entity name."""
class VaultFilter(DefaultFilter):
"""Drops housekeeping paths inside ``awatch``, before the loop is woken."""
def __init__(self) -> None:
super().__init__(ignore_dirs=(*DefaultFilter.ignore_dirs, *IGNORE_DIRS))
def __call__(self, change: FileChange, path: str) -> bool:
name = Path(path).name
if any(fnmatch.fnmatchcase(name, pattern) for pattern in IGNORE_NAMES):
return False
return super().__call__(change, path)
class VaultWatch: class VaultWatch:
def __init__( def __init__(
self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0 self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0
@@ -98,6 +120,7 @@ class VaultWatch:
_log.info("watching %s (%d files in snapshot)", self.root, len(self._seen)) _log.info("watching %s (%d files in snapshot)", self.root, len(self._seen))
async for changes in awatch( async for changes in awatch(
self.root, self.root,
watch_filter=VaultFilter(),
debounce=int(self._debounce * 1000), debounce=int(self._debounce * 1000),
stop_event=self._stop, stop_event=self._stop,
ignore_permission_denied=True, ignore_permission_denied=True,
+95 -1
View File
@@ -20,7 +20,11 @@ from claude_agent_sdk import (
project_key_for_directory, project_key_for_directory,
) )
from anthropic.types import TextBlock as AnthropicTextBlock
from anthropic.types import ThinkingBlock
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.events.bus import EventBus from beaver_gateway.events.bus import EventBus
from beaver_gateway.conversations.service import ( from beaver_gateway.conversations.service import (
@@ -36,6 +40,7 @@ from beaver_gateway.backends.transcript import (
close_open_tool_uses, close_open_tool_uses,
open_tool_uses, open_tool_uses,
render_messages, render_messages,
reply_text,
strip_tool_entries, strip_tool_entries,
window_entries, window_entries,
) )
@@ -714,12 +719,86 @@ def test_transcript_helpers() -> None:
assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2 assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2
assert ( assert (
render_messages( render_messages(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}] [{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
thinking_is_progress=True,
) )
== "user:\nq\n\nassistant:\na" == "user:\nq\n\nassistant:\na"
) )
def test_read_conversation_keeps_progress_and_drops_reasoning() -> None:
history = [
{"role": "user", "content": "status?"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Checking."},
{"type": "thinking", "thinking": "", "signature": "s"},
{"type": "tool_use", "id": "t1", "name": "komodo", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}],
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "All up, reading logs.",
"signature": "s",
},
{"type": "tool_use", "id": "t2", "name": "komodo", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": "ok"}],
},
{"role": "assistant", "content": [{"type": "text", "text": "Healthy."}]},
]
assert render_messages(history, thinking_is_progress=True) == (
"user:\nstatus?\n\nassistant:\nChecking.\n\nAll up, reading logs.\n\n"
"Healthy.\n(tools: komodo, komodo)"
)
assert render_messages(history, thinking_is_progress=False) == (
"user:\nstatus?\n\nassistant:\nChecking.\n\nHealthy.\n(tools: komodo, komodo)"
)
@pytest.mark.parametrize(
("model", "expected"),
[
("claude-opus-5-5", True),
("claude-fable-5-1", True),
("claude-opus-5", False),
("claude-sonnet-5", False),
("claude-haiku-4-5-20251001", False),
("claude-next-unknown", False),
],
)
def test_only_progress_models_read_their_thinking_as_progress(
tmp_path: Path, model: str, expected: bool
) -> None:
assert (
ClaudeAgent(name="c", model=model, cwd=tmp_path).thinking_is_progress
is expected
)
def test_summarized_display_and_raycast_are_never_progress(tmp_path: Path) -> None:
raycast = RaycastAgent(name="r", model="google-gemini-3.1-pro", system_prompt="")
summarized = ClaudeAgent(
name="s",
model="claude-opus-5-5",
cwd=tmp_path,
options=ClaudeOptions(thinking={"type": "adaptive", "display": "summarized"}),
)
assert not raycast.thinking_is_progress
assert not summarized.thinking_is_progress
async def test_agent_kinds_gate_create(world: World) -> None: async def test_agent_kinds_gate_create(world: World) -> None:
with pytest.raises(ValueError, match="does not serve kind 'deep'"): with pytest.raises(ValueError, match="does not serve kind 'deep'"):
await world.conversations.create(kind="deep", agent="a", origin="test") await world.conversations.create(kind="deep", agent="a", origin="test")
@@ -958,3 +1037,18 @@ async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> No
assert client.prompts[-1].startswith("two") assert client.prompts[-1].startswith("two")
rows = await world.conversations.queue.recent(conv.id) rows = await world.conversations.queue.recent(conv.id)
assert [r.status for r in rows] == ["done", "done"] assert [r.status for r in rows] == ["done", "done"]
def test_reply_text_keeps_progress_updates_and_drops_hidden_reasoning() -> None:
blocks = [
ThinkingBlock(type="thinking", thinking="", signature="s"),
ThinkingBlock(type="thinking", thinking="looking it up", signature="s"),
AnthropicTextBlock(type="text", text="found it"),
]
assert reply_text(blocks, thinking_is_progress=True) == "looking it up\n\nfound it"
assert reply_text(blocks, thinking_is_progress=False) == "found it"
def test_reply_text_on_a_plain_answer_is_unchanged() -> None:
blocks = [AnthropicTextBlock(type="text", text=" just an answer ")]
assert reply_text(blocks, thinking_is_progress=True) == "just an answer"
+152 -24
View File
@@ -1,6 +1,11 @@
from pathlib import Path
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage
from beaver_gateway.frontends.markdown import renderer from beaver_gateway.frontends.markdown import history, parser, renderer
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
from beaver_gateway.frontends.turn_record import TurnRecord
def _message(*blocks: object) -> Message: def _message(*blocks: object) -> Message:
@@ -24,36 +29,159 @@ def test_tool_calls_leave_no_blank_lines() -> None:
ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}), ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}),
TextBlock(type="text", text="second"), TextBlock(type="text", text="second"),
) )
assert renderer.render_assistant_message(message) == ( for progress in (True, False):
"### Assistant:\n\nfirst\n\nsecond\n" assert renderer.render_assistant_message(
message, thinking_is_progress=progress
) == ("### Assistant:\n\nfirst\n\nsecond\n")
def _progress_turn() -> Message:
return _message(
TextBlock(type="text", text="Checking the containers."),
ThinkingBlock(type="thinking", thinking="", signature="s"),
ToolUseBlock(type="tool_use", id="t1", name="komodo", input={}),
ThinkingBlock(type="thinking", thinking="", signature="s"),
ThinkingBlock(type="thinking", thinking="All up, reading logs.", signature="s"),
ToolUseBlock(type="tool_use", id="t2", name="komodo", input={}),
ThinkingBlock(type="thinking", thinking="Found it.\nOne more.", signature="s"),
ToolUseBlock(type="tool_use", id="t3", name="komodo", input={}),
TextBlock(type="text", text="The stack is healthy."),
) )
def test_thinking_renders_as_collapsed_callout() -> None: def _reasoning_turn() -> Message:
message = _message( return _message(
ThinkingBlock(type="thinking", thinking="a\nb", signature="s"), ThinkingBlock(type="thinking", thinking="one", signature=""),
ToolUseBlock(type="tool_use", id="t1", name="web_search", input={}),
ThinkingBlock(type="thinking", thinking="two", signature=""),
ToolUseBlock(type="tool_use", id="t2", name="read_page", input={}),
ThinkingBlock(type="thinking", thinking="three", signature=""),
TextBlock(type="text", text="answer"), TextBlock(type="text", text="answer"),
) ThinkingBlock(type="thinking", thinking="four\nfive", signature=""),
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> a\n> b\n\nanswer\n"
)
def test_thinking_between_hidden_tool_calls_collapses_to_the_first() -> None:
message = _message(
ThinkingBlock(type="thinking", thinking="one", signature="s"),
ToolUseBlock(type="tool_use", id="t1", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="two", signature="s"),
ToolUseBlock(type="tool_use", id="t2", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="three", signature="s"),
TextBlock(type="text", text="answer"),
ThinkingBlock(type="thinking", thinking="four", signature="s"),
TextBlock(type="text", text="more"), TextBlock(type="text", text="more"),
) )
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
"> [!thinking]-\n> four\n\nmore\n" REASONING_TURN_AS_BEFORE = (
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
"> [!thinking]-\n> four\n> five\n\nmore\n"
)
def _full_circle(
message: Message, *, thinking_is_progress: bool
) -> history.ForkOutcome:
body = renderer.render_user_text("status?") + renderer.render_assistant_message(
message, thinking_is_progress=thinking_is_progress
) )
turns = parser.parse(body + renderer.render_user_text("thanks")).turns
stored = [
{"role": "user", "content": "status?"},
{"role": "assistant", "content": [b.model_dump() for b in message.content]},
]
outcome = history.diff_and_fork(
stored=stored, incoming=turns, thinking_is_progress=thinking_is_progress
)
assert outcome.divergence_index is None
assert not outcome.edited
assert outcome.messages[1] == stored[1]
return outcome
def test_progress_renders_as_text_in_place_and_empty_thinking_vanishes() -> None:
assert renderer.render_assistant_message(
_progress_turn(), thinking_is_progress=True
) == (
"### Assistant:\n\nChecking the containers.\n\nAll up, reading logs.\n\n"
"Found it.\nOne more.\n\nThe stack is healthy.\n"
)
def test_reasoning_renders_as_collapsed_callouts_as_before() -> None:
assert (
renderer.render_assistant_message(_reasoning_turn(), thinking_is_progress=False)
== REASONING_TURN_AS_BEFORE
)
def test_progress_round_trips_through_the_parser_and_the_stored_history() -> None:
message = _progress_turn()
body = renderer.render_assistant_message(message, thinking_is_progress=True)
assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [
TextSegment(
text="Checking the containers.\n\nAll up, reading logs.\n\n"
"Found it.\nOne more.\n\nThe stack is healthy."
)
]
_full_circle(message, thinking_is_progress=True)
def test_reasoning_round_trips_through_the_parser_and_the_stored_history() -> None:
message = _reasoning_turn()
body = renderer.render_assistant_message(message, thinking_is_progress=False)
assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [
TextSegment(text="answer"),
TextSegment(text="more"),
]
_full_circle(message, thinking_is_progress=False)
def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -> None:
message = _progress_turn()
body = renderer.render_assistant_message(
message, thinking_is_progress=True
).replace("Found it", "Fixed it")
turns = parser.parse(
renderer.render_user_text("status?") + body + renderer.render_user_text("ok")
).turns
stored = [
{"role": "user", "content": "status?"},
{"role": "assistant", "content": [b.model_dump() for b in message.content]},
]
outcome = history.diff_and_fork(
stored=stored, incoming=turns, thinking_is_progress=True
)
assert outcome.edited
assert outcome.messages[1] == {
"role": "assistant",
"content": [{"type": "text", "text": turns[1].text}],
}
async def test_a_raycast_archive_with_reasoning_renders_as_before(
tmp_path: Path,
) -> None:
logger = CrossFrontendLogger(vault_path=tmp_path, logged_subdir="logged")
record = TurnRecord(
agent_name="beaver-gemini-pro-high",
input_messages=[{"role": "user", "content": "q"}],
output_message=_reasoning_turn(),
thinking_is_progress=False,
)
await logger.handle(record)
[written] = (tmp_path / "logged" / "beaver-gemini-pro-high").glob("*.md")
body = parser.parse(written.read_text()).body
assert f"---\n\n{REASONING_TURN_AS_BEFORE}\n---\n\n### User:" in body
def test_a_file_with_collapsed_thinking_callouts_parses_as_before() -> None:
body = (
"### User:\n\nq\n\n---\n\n### Assistant:\n\n"
"> [!thinking]-\n> weighing it\n> twice\n\nanswer\n\n"
"> [!tool]- Read · notes.md\n> input\n\n"
"> [!thinking]-\n>\n\nmore\n\n---\n\n### User:\n\nnext\n"
)
parsed = parser.parse(body)
assert parsed.turns[1].structure == (
TextSegment(text="answer"),
ToolSegment(name="Read"),
TextSegment(text="more"),
)
assert parsed.messages == [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "answer\n\nmore"},
{"role": "user", "content": "next"},
]
def test_user_scaffold_matches_rendered_user_turn_shape() -> None: def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
+72
View File
@@ -20,6 +20,7 @@ from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
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.drafts import Draft
from beaver_gateway.frontends.telegram.texts import TelegramTexts
from beaver_gateway.frontends.telegram.render import ( from beaver_gateway.frontends.telegram.render import (
LIMIT, LIMIT,
chunks, chunks,
@@ -1194,3 +1195,74 @@ async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
outside = await _send(stack, master, path="/etc/passwd") outside = await _send(stack, master, path="/etc/passwd")
assert outside["is_error"] assert outside["is_error"]
assert "outside the allowed roots" in outside["content"][0]["text"] assert "outside the allowed roots" in outside["content"][0]["text"]
def _progress_draft() -> Draft:
return Draft(cast("Any", None), chat_id=USER, thread_id=None, turn_id="t")
def _thinking_delta(text: str) -> Any:
return {
"event": {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": text},
}
}
def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
feed = functools.partial(
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
)
feed(_thinking_delta(""))
assert draft.text == ""
assert draft.status == texts.thinking
feed(_thinking_delta("checking the vault"))
assert draft.text == "checking the vault"
assert draft.status == texts.writing
def test_reasoning_from_an_agent_whose_thinking_is_not_progress_stays_hidden() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
TelegramFrontend._feed_draft(
cast("Any", stub),
"k",
_thinking_delta("weighing it"),
thinking_is_progress=False,
)
assert draft.text == ""
assert draft.status == texts.thinking
def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(
_drafts={"k": draft},
texts=texts,
_tool_status=lambda name, _input: texts.tool.format(label=name),
)
feed = functools.partial(
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
)
feed(_thinking_delta("reading the note"))
feed(
{
"event": {
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "name": "Read"},
}
}
)
feed(_thinking_delta("now writing it back"))
assert draft.text == "reading the note\n\nnow writing it back"
+63
View File
@@ -0,0 +1,63 @@
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from watchfiles import Change as FileChange
from beaver_gateway.vault import watch as watch_module
from beaver_gateway.vault.watch import VaultFilter, VaultWatch, WatchRules
VAULT = Path("/vault")
FILTER = VaultFilter()
def wakes(rel: str) -> bool:
return FILTER(FileChange.modified, str(VAULT / rel))
def test_obsidian_never_wakes_the_watcher() -> None:
assert not wakes(".obsidian/.sync.lock")
assert not wakes(".obsidian/.sync.lock/data")
assert not wakes(".obsidian")
assert not wakes(".obsidian/workspace.json")
assert not wakes(".obsidian/plugins/obsidian-git/data.json")
def test_other_service_paths_never_wake_the_watcher() -> None:
assert not wakes(".trash/📅 дни/2026-09-01.md")
assert not wakes(".git/index")
def test_notes_still_wake_the_watcher() -> None:
assert wakes("📅 дни/2026-09-09.md")
assert wakes("мета/бобер/состояние.md")
assert wakes("заметка.md")
assert wakes("💻 проекты/бобер/план.md")
def test_lock_beside_a_note_is_quiet_but_the_note_is_not() -> None:
assert not wakes("📅 дни/.2026-09-09.md.lock")
assert not wakes("📅 дни/2026-09-09.md.tmp")
assert not wakes("📅 дни/2026-09-09.md~")
assert wakes("📅 дни/2026-09-09.md")
async def test_the_filter_is_applied_by_awatch_itself(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, object] = {}
async def fake_awatch(
*paths: object, **kwargs: object
) -> AsyncIterator[set[object]]:
seen.update(kwargs)
seen["paths"] = paths
for change in ():
yield change
monkeypatch.setattr(watch_module, "awatch", fake_awatch)
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
watch = VaultWatch(root, WatchRules(names=("**",)), tz="UTC")
await watch.run()
assert isinstance(seen["watch_filter"], VaultFilter)
+1
View File
@@ -79,6 +79,7 @@ export interface ConversationInfo extends ConversationSummary {
parent: string | null; parent: string | null;
question: PendingQuestion | null; question: PendingQuestion | null;
queue: QueueItem[]; queue: QueueItem[];
thinking_is_progress?: boolean;
turn: TurnSnapshot | null; turn: TurnSnapshot | null;
} }
+7 -2
View File
@@ -233,8 +233,13 @@ 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") { const progress = this.conversation?.thinking_is_progress === true;
turn.text = joinText(turn.text, delta.text, turn.paused); const text =
delta?.type === "thinking_delta" && progress
? delta.thinking
: delta?.text;
if (typeof text === "string" && text) {
turn.text = joinText(turn.text, text, turn.paused);
turn.paused = false; turn.paused = false;
} }
} }
+15 -4
View File
@@ -190,9 +190,20 @@
} }
function blocks(message: HistoryMessage): ContentBlock[] { function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string" if (typeof message.content === "string") {
? [{ text: message.content, type: "text" }] return [{ text: message.content, type: "text" }];
: message.content; }
if (!model.conversation?.thinking_is_progress) {
return message.content;
}
return message.content.flatMap((block) => {
if (block.type !== "thinking") {
return [block];
}
return block.thinking?.trim()
? [{ text: block.thinking, type: "text" }]
: [];
});
} }
function resultText(block: ContentBlock): string { function resultText(block: ContentBlock): string {
@@ -287,7 +298,7 @@
{/if} {/if}
</button> </button>
</div> </div>
{:else if !(isResultOnly && !showResults)} {:else if parts.length > 0 && !(isResultOnly && !showResults)}
<div <div
class={cn( class={cn(
"group flex min-w-0 max-w-full flex-col gap-1.5", "group flex min-w-0 max-w-full flex-col gap-1.5",
Generated
+7 -8
View File
@@ -338,7 +338,7 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.103.0" }, { name = "anthropic", specifier = ">=0.103.0" },
{ name = "anyio", specifier = ">=4.13.0" }, { name = "anyio", specifier = ">=4.13.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.150" }, { name = "claude-agent-sdk", specifier = ">=0.2.157" },
{ name = "croniter", specifier = ">=6.2.4" }, { name = "croniter", specifier = ">=6.2.4" },
{ name = "fastapi", specifier = ">=0.136.1" }, { name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" }, { name = "fastmcp", specifier = ">=3.3.1" },
@@ -450,7 +450,7 @@ wheels = [
[[package]] [[package]]
name = "claude-agent-sdk" name = "claude-agent-sdk"
version = "0.2.150" version = "0.2.157"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@@ -458,13 +458,12 @@ dependencies = [
{ name = "mcp" }, { name = "mcp" },
{ name = "sniffio" }, { name = "sniffio" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" } sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" }, { url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" }, { url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" }, { url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" }, { url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
] ]
[[package]] [[package]]