feat(agents,backends,conversations,frontends,ui): progress thinking is told apart by its agent, raycast reasoning stays collapsed, read_conversation keeps progress

This commit is contained in:
hh committed 2026-09-22 20:51:23 +02:00
1 parent 13873f857b
commit 120985deab
18 files changed
+374 -110

No files matched your search

+5
View File
@@ -36,3 +36,8 @@ class BaseAgent(BaseModel):
system_prompt: str
expose_mcps: tuple[ExposedMcp, ...] = ()
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
+5
View File
@@ -121,6 +121,11 @@ class ClaudeAgent(BaseAgent):
raise ValueError(msg)
return self
@property
def thinking_is_progress(self) -> bool:
"""Reasoning arrives empty unless ``display`` is ``summarized``."""
return (self.options.thinking or {}).get("display") != "summarized"
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return self.prompts.for_kind(kind)
+40 -5
View File
@@ -10,7 +10,7 @@ import hashlib
import uuid as _uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
try:
from claude_agent_sdk._cli_version import __cli_version__
@@ -31,6 +31,8 @@ __all__ = [
"open_tool_uses",
"prompt_count",
"render_messages",
"reply_text",
"spoken_text",
"strip_tool_entries",
"text_of",
"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))
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(
messages: Iterable[Mapping[str, Any]], *, window: int | None = None
messages: Iterable[Mapping[str, Any]],
*,
thinking_is_progress: bool,
window: int | None = None,
) -> str:
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
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] = []
tools: list[str] = []
@@ -466,8 +500,9 @@ def render_messages(
turns.append("user:\n" + _text_of_content(content))
continue
for block in _assistant_blocks(content):
if block.get("type") == "text" and block.get("text"):
current.append(str(block["text"]))
text = spoken_text(block, thinking_is_progress=thinking_is_progress)
if text:
current.append(text)
elif block.get("type") == "tool_use":
tools.append(str(block.get("name", "")))
flush()
+9 -1
View File
@@ -352,6 +352,8 @@ class Rows(State):
for b in await self.bindings(conv)
]
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["busy"] = live.busy if live is not None else False
runner = self._runners.get(cast("int", conv.id))
@@ -466,7 +468,13 @@ class Rows(State):
return marked
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]]:
if conv.session_id is None:
+6 -20
View File
@@ -23,7 +23,7 @@ from claude_agent_sdk import (
)
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.seeds import Seeds
from beaver_gateway.conversations.state import Runner, aware, iso
@@ -32,11 +32,11 @@ from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.storage.models import Conversation, InjectQueueItem, RateLimit
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from collections.abc import AsyncIterator, Callable, Sequence
from beaver_gateway.events.stream import MessageStreamEvent
__all__ = ["Turns", "content_of", "image_block", "reply_text"]
__all__ = ["Turns", "content_of", "image_block"]
_log = logging.getLogger(__name__)
@@ -164,7 +164,9 @@ class Turns(Seeds):
):
acc.feed(event)
message = acc.finalize(model=agent.model)
return reply_text(message.content), capture
return reply_text(
message.content, thinking_is_progress=agent.thinking_is_progress
), capture
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
return None
@@ -484,22 +486,6 @@ class Turns(Seeds):
IMAGE_MAX_BYTES = 5 * 1024 * 1024
def reply_text(content: Iterable[Any]) -> str:
"""Text blocks plus non-empty thinking blocks, in block order.
What the model writes between tool calls rides in thinking blocks; the
hidden reasoning in them is empty and drops out on its own.
"""
parts: list[str] = []
for block in content:
kind = getattr(block, "type", "")
if kind == "text":
parts.append(getattr(block, "text", "") or "")
elif kind == "thinking":
parts.append(getattr(block, "thinking", "") or "")
return "\n\n".join(p for p in parts if p).strip()
def content_of(
text: str, attachments: Sequence[dict[str, Any]] | None
) -> str | list[dict[str, Any]]:
+9 -9
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
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.base import Frontend
from beaver_gateway.frontends.bearer import require_token
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
@@ -158,6 +159,7 @@ class AnthropicMessagesFrontend(Frontend):
capture=capture,
turn_id=turn_id,
history=history,
thinking_is_progress=agent.thinking_is_progress,
)
else:
options: dict[str, Any] = {}
@@ -170,7 +172,7 @@ class AnthropicMessagesFrontend(Frontend):
async def after(message: Message) -> None:
await _broadcast_turn(
runtime,
agent_name=agent.name,
agent=agent,
input_messages=messages,
output_message=message,
system=system_str,
@@ -235,12 +237,9 @@ async def _finish(
capture: TurnCapture,
turn_id: str,
history: list[dict[str, str]] | None,
thinking_is_progress: bool,
) -> None:
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
reply = reply_text(message.content, thinking_is_progress=thinking_is_progress)
tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
]
@@ -291,7 +290,7 @@ async def _sse(
async def _broadcast_turn(
runtime: GatewayRuntime,
*,
agent_name: str,
agent: BaseAgent,
input_messages: list[MessageParam],
output_message: Message,
system: str | None,
@@ -299,9 +298,10 @@ async def _broadcast_turn(
if not runtime.turn_log_handlers:
return
record = TurnRecord(
agent_name=agent_name,
agent_name=agent.name,
input_messages=list(input_messages),
output_message=output_message,
thinking_is_progress=agent.thinking_is_progress,
system=system,
source=FRONTEND,
)
@@ -142,14 +142,16 @@ class CrossFrontendLogger:
new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user)
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)
metadata = dict(parsed.metadata)
else:
new_body = _render_full_history(
record.input_messages, record.output_message
)
new_body = _render_full_history(record)
metadata = {}
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)
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)."""
blocks: list[str] = []
for msg in messages:
for msg in record.input_messages:
role = msg.get("role")
if role == "user":
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", "")
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(renderer.render_assistant_message(assistant))
blocks.append(
renderer.render_assistant_message(
record.output_message, thinking_is_progress=record.thinking_is_progress
)
)
body = ""
for block in blocks:
if not block:
@@ -341,7 +341,11 @@ class MarkdownFrontend(Frontend):
file_path=file_path,
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()
events = runtime.conversations.turn(
conv,
@@ -356,6 +360,7 @@ class MarkdownFrontend(Frontend):
file_path=file_path,
parsed=parsed,
model=agent.model or agent.name,
thinking_is_progress=agent.thinking_is_progress,
filename=filename,
write_disk=write_disk,
)
@@ -371,6 +376,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
message=message,
agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
@@ -388,6 +394,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name,
input_messages=list(parsed.messages),
output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None,
source="markdown",
)
@@ -534,7 +541,11 @@ class MarkdownFrontend(Frontend):
len(stored_msgs),
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(
"chat/stream: file=%s diff_and_fork divergence=%s "
"backend_msgs=%d persist_msgs=%d",
@@ -567,7 +578,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model)
if not partial.content:
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)
return reattach_frontmatter(parsed.metadata, new_body)
@@ -593,7 +606,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body
if partial.content:
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))
if write_disk:
@@ -616,6 +632,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
message=message,
agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
@@ -633,6 +650,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name,
input_messages=list(parsed.messages),
output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None,
source="markdown",
)
@@ -659,6 +677,7 @@ class MarkdownFrontend(Frontend):
file_path: Path,
parsed: parser.ParsedFile,
model: str,
thinking_is_progress: bool,
filename: str,
write_disk: bool = True,
) -> Any:
@@ -675,7 +694,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model)
if not partial.content:
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)
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
@@ -698,7 +719,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body
if partial.content:
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))
if write_disk:
@@ -715,11 +739,14 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile,
message: Any,
agent_name: str,
thinking_is_progress: bool,
conv_external_id: str,
write_disk: bool = True,
) -> str:
"""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(new_body, renderer.USER_SCAFFOLD)
updated_metadata = dict(parsed.metadata)
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import delete
from sqlmodel import select
from beaver_gateway.backends.transcript import spoken_text
from beaver_gateway.storage.models import Conversation, ConversationMessage
if TYPE_CHECKING:
@@ -180,7 +181,9 @@ class _StoredDisplayTurn:
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.
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
group.append(m)
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(
_StoredDisplayTurn(
role="assistant",
@@ -251,13 +256,13 @@ def _user_prompt_text(content: Any) -> str:
def _summarize_assistant_group(
group: list[dict[str, Any]],
group: list[dict[str, Any]], *, thinking_is_progress: bool
) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Must mirror what the renderer writes and the parser reads back: text and
non-empty thinking collapse into one segment, tool_use becomes a skeleton
entry, tool_result and empty 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
@@ -283,7 +288,7 @@ def _summarize_assistant_group(
for blk in content:
if not isinstance(blk, dict):
continue
text = _spoken(blk)
text = spoken_text(blk, thinking_is_progress=thinking_is_progress)
if text:
pending.append(text)
elif blk.get("type") == "tool_use":
@@ -298,7 +303,10 @@ def _summarize_assistant_group(
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:
"""Align the incoming parsed file against stored history.
@@ -313,11 +321,15 @@ def diff_and_fork(
)
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]
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 _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
@@ -357,7 +369,10 @@ def _file_lags_store(
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]:
"""Walk incoming vs stored side-by-side until first divergence.
@@ -390,7 +405,9 @@ def _walk_prefix(
continue
if inc_skeleton and inc_text_count != st.text_segment_count:
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:
return spliced_groups, i, edited
spliced_groups.append(spliced)
@@ -425,7 +442,10 @@ def _assemble_tail(
def _splice_assistant_group(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
*,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None:
"""Apply the file's prose to a stored assistant turn.
@@ -433,26 +453,21 @@ def _splice_assistant_group(
markdown can't express. Falls back to rebuilding from the file's
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:
return preserved
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
def _spoken(blk: object) -> str:
if not isinstance(blk, dict):
return ""
block = cast("dict[str, Any]", blk)
kind = block.get("type")
if kind == "text":
return str(block.get("text", "")).strip()
if kind == "thinking":
return str(block.get("thinking", "")).strip()
return ""
def _splice_in_place(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
*,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents.
@@ -472,7 +487,7 @@ def _splice_in_place(
if msg["role"] != "assistant" or not isinstance(content, list):
continue
for bi, blk in enumerate(cast("list[dict[str, Any]]", content)):
if not _spoken(blk):
if not spoken_text(blk, thinking_is_progress=thinking_is_progress):
continue
if blk["type"] == "thinking":
return None
@@ -1,7 +1,7 @@
"""Render Anthropic ``Message`` (and user turns) into markdown.
One-way: produces the human-facing artifact only. The parser strips
the tool/thinking callouts of older files when reshaping history for replay.
tool/thinking callouts separately when reshaping history for backend replay.
"""
from __future__ import annotations
@@ -43,17 +43,25 @@ def render_assistant_text(text: str) -> str:
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.
Tool-use blocks and empty thinking render to nothing; a non-empty thinking
block is what the model wrote between tool calls and renders as text.
Tool-use blocks render to nothing. Progress thinking renders as text in
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:", ""]
after_reasoning = False
for block in message.content:
if isinstance(block, ThinkingBlock) and not thinking_is_progress:
if not after_reasoning:
parts.extend((*_render_reasoning(block.thinking or ""), ""))
after_reasoning = True
continue
text = _spoken(block)
if text:
parts.extend((text, ""))
after_reasoning = False
return "\n".join(parts).rstrip() + "\n"
@@ -99,6 +107,11 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
return name
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):
return (block.text or "").strip()
@@ -852,7 +852,10 @@ class TelegramFrontend(Frontend):
if event.get("origin") in ("user", "task"):
await self._open_draft(key, event, target)
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":
draft = self._drafts.get(key)
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
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)
if draft is None or event.get("parent_tool_use_id") is not None:
return
@@ -984,7 +989,7 @@ class TelegramFrontend(Frontend):
draft.append(str(delta.get("text") or ""))
elif delta.get("type") == "thinking_delta":
progress = str(delta.get("thinking") or "")
if progress:
if progress and thinking_is_progress:
draft.set_status(self.texts.writing)
draft.append(progress)
else:
@@ -46,6 +46,7 @@ class TurnRecord:
agent_name: str
input_messages: list[MessageParam]
output_message: Message
thinking_is_progress: bool
system: str | None = None
source: str = "anthropic"