diff --git a/src/beaver_gateway/agents/base.py b/src/beaver_gateway/agents/base.py index 6856637..ecad1f5 100644 --- a/src/beaver_gateway/agents/base.py +++ b/src/beaver_gateway/agents/base.py @@ -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 diff --git a/src/beaver_gateway/agents/claude.py b/src/beaver_gateway/agents/claude.py index cd80383..a276c56 100644 --- a/src/beaver_gateway/agents/claude.py +++ b/src/beaver_gateway/agents/claude.py @@ -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) diff --git a/src/beaver_gateway/backends/transcript.py b/src/beaver_gateway/backends/transcript.py index ee51117..c127c7e 100644 --- a/src/beaver_gateway/backends/transcript.py +++ b/src/beaver_gateway/backends/transcript.py @@ -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() diff --git a/src/beaver_gateway/conversations/rows.py b/src/beaver_gateway/conversations/rows.py index 109e5b3..a18439f 100644 --- a/src/beaver_gateway/conversations/rows.py +++ b/src/beaver_gateway/conversations/rows.py @@ -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: diff --git a/src/beaver_gateway/conversations/turns.py b/src/beaver_gateway/conversations/turns.py index fbd0586..89dc55b 100644 --- a/src/beaver_gateway/conversations/turns.py +++ b/src/beaver_gateway/conversations/turns.py @@ -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]]: diff --git a/src/beaver_gateway/frontends/anthropic.py b/src/beaver_gateway/frontends/anthropic.py index b473e27..b27219b 100644 --- a/src/beaver_gateway/frontends/anthropic.py +++ b/src/beaver_gateway/frontends/anthropic.py @@ -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, ) diff --git a/src/beaver_gateway/frontends/markdown/crossfront.py b/src/beaver_gateway/frontends/markdown/crossfront.py index 655887e..73dcc69 100644 --- a/src/beaver_gateway/frontends/markdown/crossfront.py +++ b/src/beaver_gateway/frontends/markdown/crossfront.py @@ -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: diff --git a/src/beaver_gateway/frontends/markdown/frontend.py b/src/beaver_gateway/frontends/markdown/frontend.py index 250eb66..a6a1d38 100644 --- a/src/beaver_gateway/frontends/markdown/frontend.py +++ b/src/beaver_gateway/frontends/markdown/frontend.py @@ -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) diff --git a/src/beaver_gateway/frontends/markdown/history.py b/src/beaver_gateway/frontends/markdown/history.py index 4612899..4c559e4 100644 --- a/src/beaver_gateway/frontends/markdown/history.py +++ b/src/beaver_gateway/frontends/markdown/history.py @@ -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 diff --git a/src/beaver_gateway/frontends/markdown/renderer.py b/src/beaver_gateway/frontends/markdown/renderer.py index 5634e9d..23575b1 100644 --- a/src/beaver_gateway/frontends/markdown/renderer.py +++ b/src/beaver_gateway/frontends/markdown/renderer.py @@ -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() diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index c11d735..96a2fd2 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -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: diff --git a/src/beaver_gateway/frontends/turn_record.py b/src/beaver_gateway/frontends/turn_record.py index 5a8e637..c7ce73e 100644 --- a/src/beaver_gateway/frontends/turn_record.py +++ b/src/beaver_gateway/frontends/turn_record.py @@ -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" diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 19b14fb..6cc8497 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -24,7 +24,7 @@ from anthropic.types import TextBlock as AnthropicTextBlock from anthropic.types import ThinkingBlock from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions -from beaver_gateway.conversations.turns import reply_text +from beaver_gateway.agents.raycast import RaycastAgent from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend from beaver_gateway.events.bus import EventBus from beaver_gateway.conversations.service import ( @@ -40,6 +40,7 @@ from beaver_gateway.backends.transcript import ( close_open_tool_uses, open_tool_uses, render_messages, + reply_text, strip_tool_entries, window_entries, ) @@ -718,12 +719,68 @@ def test_transcript_helpers() -> None: assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2 assert ( 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" ) +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)" + ) + + +def test_only_claude_agents_read_their_thinking_as_progress(tmp_path: Path) -> None: + raycast = RaycastAgent(name="r", model="google-gemini-3.1-pro", system_prompt="") + claude = ClaudeAgent(name="c", model="claude-opus-5-5", cwd=tmp_path) + 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 claude.thinking_is_progress + assert not summarized.thinking_is_progress + + async def test_agent_kinds_gate_create(world: World) -> None: with pytest.raises(ValueError, match="does not serve kind 'deep'"): await world.conversations.create(kind="deep", agent="a", origin="test") @@ -970,9 +1027,10 @@ def test_reply_text_keeps_progress_updates_and_drops_hidden_reasoning() -> None: ThinkingBlock(type="thinking", thinking="looking it up", signature="s"), AnthropicTextBlock(type="text", text="found it"), ] - assert reply_text(blocks) == "looking it up\n\nfound 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) == "just an answer" + assert reply_text(blocks, thinking_is_progress=True) == "just an answer" diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 3990ff3..5d23337 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -1,7 +1,11 @@ +from pathlib import Path + from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage 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: @@ -25,9 +29,10 @@ def test_tool_calls_leave_no_blank_lines() -> None: ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}), TextBlock(type="text", text="second"), ) - assert renderer.render_assistant_message(message) == ( - "### Assistant:\n\nfirst\n\nsecond\n" - ) + for progress in (True, False): + assert renderer.render_assistant_message( + message, thinking_is_progress=progress + ) == ("### Assistant:\n\nfirst\n\nsecond\n") def _progress_turn() -> Message: @@ -44,38 +49,88 @@ def _progress_turn() -> Message: ) +def _reasoning_turn() -> Message: + return _message( + 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"), + ThinkingBlock(type="thinking", thinking="four\nfive", signature=""), + TextBlock(type="text", text="more"), + ) + + +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()) == ( + 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_user_text("status?") + renderer.render_assistant_message( - message - ) - turns = parser.parse(body + renderer.render_user_text("thanks")).turns - assert turns[1].structure == ( + 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." - ), - ) - 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) - assert outcome.divergence_index is None - assert not outcome.edited - assert outcome.messages[1] == stored[1] + _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).replace("Found it", "Fixed it") + 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 @@ -83,7 +138,9 @@ def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() - {"role": "user", "content": "status?"}, {"role": "assistant", "content": [b.model_dump() for b in message.content]}, ] - outcome = history.diff_and_fork(stored=stored, incoming=turns) + outcome = history.diff_and_fork( + stored=stored, incoming=turns, thinking_is_progress=True + ) assert outcome.edited assert outcome.messages[1] == { "role": "assistant", @@ -91,6 +148,22 @@ def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() - } +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" diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 6bc4304..f5871a0 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -1215,7 +1215,9 @@ def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> Non draft = _progress_draft() texts = TelegramTexts() stub = SimpleNamespace(_drafts={"k": draft}, texts=texts) - feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k") + feed = functools.partial( + TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True + ) feed(_thinking_delta("")) assert draft.text == "" @@ -1226,6 +1228,20 @@ def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> Non 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() @@ -1234,7 +1250,9 @@ def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None: texts=texts, _tool_status=lambda name, _input: texts.tool.format(label=name), ) - feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k") + feed = functools.partial( + TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True + ) feed(_thinking_delta("reading the note")) feed( diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 9ba5bc9..292d672 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -79,6 +79,7 @@ export interface ConversationInfo extends ConversationSummary { parent: string | null; question: PendingQuestion | null; queue: QueueItem[]; + thinking_is_progress?: boolean; turn: TurnSnapshot | null; } diff --git a/ui/src/lib/panel/activity.svelte.ts b/ui/src/lib/panel/activity.svelte.ts index a3e0417..92dddad 100644 --- a/ui/src/lib/panel/activity.svelte.ts +++ b/ui/src/lib/panel/activity.svelte.ts @@ -233,8 +233,11 @@ export class ActivityModel { } if (sdk.type === "content_block_delta" && parent === null) { const delta = sdk.delta as Record | undefined; + const progress = this.conversation?.thinking_is_progress === true; const text = - delta?.type === "thinking_delta" ? delta.thinking : delta?.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; diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 4892935..c5cff5d 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -193,6 +193,9 @@ if (typeof message.content === "string") { return [{ text: message.content, type: "text" }]; } + if (!model.conversation?.thinking_is_progress) { + return message.content; + } return message.content.flatMap((block) => { if (block.type !== "thinking") { return [block]; @@ -342,6 +345,8 @@ : "bg-muted/40" )} >{clip(resultText(block), RESULT_CLIP)} + {:else if block.type === "thinking"} +

thinking

{/if} {/each}