Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1285de46 | ||
|
|
120985deab | ||
|
|
13873f857b | ||
|
|
bbce425eeb | ||
|
|
e58153628c |
No files matched your search
+21
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
Reference in new issue
Block a user