Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
35e92e0
feat(platform/copilot): reasoning UI render flag + SSE reconnect stor…
majdyz Apr 21, 2026
7ef10b2
fix(platform/copilot): address PR review on reasoning flag + reconnec…
majdyz Apr 21, 2026
37de838
test(frontend/copilot): cover reconnect debounce window
majdyz Apr 22, 2026
e516c9c
test(frontend/copilot): hook test for reconnect debounce burst
majdyz Apr 22, 2026
2e7b674
refactor(backend/copilot): trim verbose comments on reasoning + repla…
majdyz Apr 22, 2026
184f250
Merge branch 'dev' into feat/copilot-reasoning-render-flag-and-reconn…
majdyz Apr 22, 2026
a8802ae
fix(backend/copilot): cheaper web_search dispatch + cache-aware cost …
majdyz Apr 22, 2026
1768680
refactor(backend/copilot): route web_search through OpenRouter server…
majdyz Apr 22, 2026
143cf27
refactor(backend/copilot): call Exa directly instead of routing via O…
majdyz Apr 22, 2026
1cc43c9
fix(backend/copilot): apply black/isort formatting to web_search
majdyz Apr 22, 2026
cfcc483
refactor(backend/copilot): route web_search through OpenRouter, add d…
majdyz Apr 22, 2026
4a89228
refactor(backend/copilot): drop price numbers from web_search tool de…
majdyz Apr 22, 2026
68d9cdd
fix(backend/copilot): black-format web_search_test list comprehension
majdyz Apr 22, 2026
8f2d10d
refactor(backend/copilot): switch web_search to Perplexity Sonar, dro…
majdyz Apr 22, 2026
533cfb9
fix(backend/copilot): black-format web_search_test limit_caps inline …
majdyz Apr 22, 2026
d90f99c
feat(backend/copilot): surface Sonar's synthesised answer in web_sear…
majdyz Apr 22, 2026
72d9cef
fix(backend/copilot): black-format web_search_test extract_answer assert
majdyz Apr 22, 2026
a70e73e
refactor(backend/copilot): raise web_search max_tokens ceilings so re…
majdyz Apr 22, 2026
99046b4
feat(backend/executor): switch simulator to flash-lite + track cost
majdyz Apr 22, 2026
11f52d0
feat(frontend/copilot): differentiate web_search UI labels when deep=…
majdyz Apr 22, 2026
8956f05
refactor(backend/copilot): warn on deep-search cost in web_search too…
majdyz Apr 22, 2026
66aec4d
test(backend/copilot): bump tool-schema char budget 32800 → 33200 for…
majdyz Apr 22, 2026
b0e917f
feat(frontend/copilot): render Sonar synthesised answer in web_search…
majdyz Apr 22, 2026
bce2f5a
fix(backend/copilot): plumb user_id into simulate_block from execute_…
majdyz Apr 22, 2026
ca00575
test(backend/copilot): patch config to False in transient-backoff ada…
majdyz Apr 22, 2026
4d4f057
refactor(backend/copilot): drop leading-underscore on render_reasonin…
majdyz Apr 22, 2026
0773e35
refactor(backend/copilot): always persist reasoning; render flag gate…
majdyz Apr 22, 2026
c4a26ca
test(backend/copilot): update persistence-always-on contract for rend…
majdyz Apr 22, 2026
999f238
diag(backend/copilot): log flush cadence for BaselineReasoningEmitter
majdyz Apr 22, 2026
17f2e0f
docs(backend/copilot): correct render_reasoning_in_ui persistence wor…
majdyz Apr 22, 2026
285021c
fix(backend/copilot): stream reasoning deltas live instead of per-ite…
majdyz Apr 22, 2026
12399f4
fix(backend/copilot): stream reasoning deltas live instead of per-ite…
majdyz Apr 22, 2026
6287831
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into fe…
majdyz Apr 22, 2026
16e893b
Revert "diag(backend/copilot): log flush cadence for BaselineReasonin…
majdyz Apr 22, 2026
487c5c9
perf(backend/copilot): bump baseline reasoning coalescing to 64 chars…
majdyz Apr 22, 2026
599e835
feat(backend/copilot): per-token SDK streaming for text + thinking
majdyz Apr 22, 2026
530fa8f
fix(backend/copilot): gate SDK summary emission per-kind to stop trun…
majdyz Apr 22, 2026
e637ded
Revert "fix(backend/copilot): gate SDK summary emission per-kind to s…
majdyz Apr 22, 2026
2151051
Revert "feat(backend/copilot): per-token SDK streaming for text + thi…
majdyz Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions autogpt_platform/backend/backend/copilot/baseline/reasoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ class BaselineReasoningEmitter:
fresh ``ChatMessage(role="reasoning")`` is appended and mutated
in-place as further deltas arrive; :meth:`close` drops the reference
but leaves the appended row intact.

Comment thread
majdyz marked this conversation as resolved.
``render_in_ui=False`` suppresses wire events + persistence row;
state machine still advances.
"""

def __init__(
Expand All @@ -250,21 +253,19 @@ def __init__(
*,
coalesce_min_chars: int = _COALESCE_MIN_CHARS,
coalesce_max_interval_ms: float = _COALESCE_MAX_INTERVAL_MS,
render_in_ui: bool = True,
) -> None:
self._block_id: str = str(uuid.uuid4())
self._open: bool = False
self._session_messages = session_messages
self._current_row: ChatMessage | None = None
# Coalescing state — ``_pending_delta`` accumulates reasoning text
# between wire flushes. Providers like Kimi K2.6 emit very fine-
# grained chunks; batching them reduces Redis ``xadd`` + SSE + React
# re-render load by ~100x for equivalent text output. Tuning knobs
# are kwargs so tests can disable coalescing (``=0``) for
# deterministic event assertions.
# Coalescing state — tests can disable (``=0``) for deterministic
# event assertions.
self._coalesce_min_chars = coalesce_min_chars
self._coalesce_max_interval_ms = coalesce_max_interval_ms
self._pending_delta: str = ""
self._last_flush_monotonic: float = 0.0
self._render_in_ui = render_in_ui

@property
def is_open(self) -> bool:
Expand Down Expand Up @@ -296,26 +297,25 @@ def on_delta(self, delta: ChoiceDelta) -> list[StreamBaseResponse]:
# syscalls off the hot path without changing semantics.
now = time.monotonic()
if not self._open:
events.append(StreamReasoningStart(id=self._block_id))
events.append(StreamReasoningDelta(id=self._block_id, delta=text))
if self._render_in_ui:
events.append(StreamReasoningStart(id=self._block_id))
events.append(StreamReasoningDelta(id=self._block_id, delta=text))
Comment thread
majdyz marked this conversation as resolved.
self._open = True
self._last_flush_monotonic = now
if self._session_messages is not None:
self._current_row = ChatMessage(role="reasoning", content=text)
self._session_messages.append(self._current_row)
return events

# Persist per-delta (no coalescing here — the session snapshot stays
# consistent at every chunk boundary, independent of the wire
# coalesce window).
if self._current_row is not None:
self._current_row.content = (self._current_row.content or "") + text

self._pending_delta += text
if self._should_flush_pending(now):
events.append(
StreamReasoningDelta(id=self._block_id, delta=self._pending_delta)
)
if self._render_in_ui:
events.append(
StreamReasoningDelta(id=self._block_id, delta=self._pending_delta)
)
self._pending_delta = ""
self._last_flush_monotonic = now
return events
Expand Down Expand Up @@ -348,12 +348,13 @@ def close(self) -> list[StreamBaseResponse]:
if not self._open:
return []
events: list[StreamBaseResponse] = []
if self._pending_delta:
events.append(
StreamReasoningDelta(id=self._block_id, delta=self._pending_delta)
)
self._pending_delta = ""
events.append(StreamReasoningEnd(id=self._block_id))
if self._render_in_ui:
if self._pending_delta:
events.append(
StreamReasoningDelta(id=self._block_id, delta=self._pending_delta)
)
events.append(StreamReasoningEnd(id=self._block_id))
self._pending_delta = ""
self._open = False
self._block_id = str(uuid.uuid4())
self._current_row = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,63 @@ def test_no_session_means_no_persistence(self):
events = emitter.on_delta(_delta(reasoning="pure wire"))
assert len(events) == 2 # start + delta, no crash
# Nothing else to assert — just proves None session is supported.


class TestBaselineReasoningEmitterRenderFlag:
"""``render_in_ui=False`` must silence ``StreamReasoning*`` wire events
AND drop persistence of ``role="reasoning"`` rows — the operator hides
the collapse on both the live wire and on reload. Persistence is tied
to the wire events because the frontend's hydration path unconditionally
re-renders persisted reasoning rows; keeping them would make the flag a
no-op post-reload. These tests pin the contract in both directions so
future refactors can't flip only one half."""

def test_render_off_suppresses_start_and_delta(self):
emitter = BaselineReasoningEmitter(render_in_ui=False)
events = emitter.on_delta(_delta(reasoning="hidden"))
# No wire events, but state advanced (is_open == True) so close()
# below has something to rotate.
assert events == []
assert emitter.is_open is True

def test_render_off_suppresses_close_end(self):
emitter = BaselineReasoningEmitter(render_in_ui=False)
emitter.on_delta(_delta(reasoning="hidden"))
events = emitter.close()
assert events == []
assert emitter.is_open is False

def test_render_off_still_persists(self):
"""Persistence is decoupled from the render flag — session
transcript always keeps the ``role="reasoning"`` row so audit
and ``--resume``-equivalent replay never lose thinking text.
The frontend gates rendering separately."""
session: list[ChatMessage] = []
emitter = BaselineReasoningEmitter(session, render_in_ui=False)

emitter.on_delta(_delta(reasoning="part one "))
emitter.on_delta(_delta(reasoning="part two"))
emitter.close()

assert len(session) == 1
assert session[0].role == "reasoning"
assert session[0].content == "part one part two"

def test_render_off_rotates_block_id_between_sessions(self):
"""Even with wire events silenced the block id must rotate on close,
otherwise a hypothetical mid-session flip would reuse a stale id."""
emitter = BaselineReasoningEmitter(render_in_ui=False)
emitter.on_delta(_delta(reasoning="first"))
first_block_id = emitter._block_id
emitter.close()
emitter.on_delta(_delta(reasoning="second"))
assert emitter._block_id != first_block_id

def test_render_on_is_default(self):
"""Defaulting to True preserves backward compat — existing callers
that don't pass the kwarg keep emitting wire events as before."""
emitter = BaselineReasoningEmitter()
events = emitter.on_delta(_delta(reasoning="hello"))
assert len(events) == 2
assert isinstance(events[0], StreamReasoningStart)
assert isinstance(events[1], StreamReasoningDelta)
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,13 @@ def __post_init__(self) -> None:
# frontend's ``convertChatSessionToUiMessages`` relies on these
# rows to render the Reasoning collapse after the AI SDK's
# stream-end hydrate swaps in the DB-backed message list.
self.reasoning_emitter = BaselineReasoningEmitter(self.session_messages)
# ``render_in_ui`` is sourced from ``config.render_reasoning_in_ui``
# so the operator can silence the reasoning collapse globally
# without dropping the persisted audit trail.
self.reasoning_emitter = BaselineReasoningEmitter(
self.session_messages,
render_in_ui=config.render_reasoning_in_ui,
)


def _is_anthropic_model(model: str) -> bool:
Expand Down
18 changes: 16 additions & 2 deletions autogpt_platform/backend/backend/copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ class ChatConfig(BaseSettings):
description="Model to use for generating session titles (should be fast/cheap)",
)
simulation_model: str = Field(
default="google/gemini-2.5-flash",
description="Model for dry-run block simulation (should be fast/cheap with good JSON output)",
default="google/gemini-2.5-flash-lite",
description="Model for dry-run block simulation (should be fast/cheap with good JSON output). "
"Gemini 2.5 Flash-Lite is ~3x cheaper than Flash ($0.10/$0.40 vs $0.30/$1.20 per MTok) "
"with JSON-mode reliability adequate for shape-matching block outputs.",
)
api_key: str | None = Field(default=None, description="OpenAI API key")
base_url: str | None = Field(
Expand Down Expand Up @@ -249,6 +251,18 @@ class ChatConfig(BaseSettings):
"``max_thinking_tokens`` kwarg so the CLI falls back to model default "
"(which, without the flag, leaves extended thinking off).",
)
render_reasoning_in_ui: bool = Field(
default=True,
description="Render reasoning as live UI parts + persist "
"``role='reasoning'`` rows. False suppresses both; tokens are still "
"billed upstream.",
)
stream_replay_count: int = Field(
default=200,
ge=1,
le=10000,
description="Max Redis stream entries replayed on SSE reconnect.",
)
claude_agent_thinking_effort: Literal["low", "medium", "high", "max"] | None = (
Field(
default=None,
Expand Down
37 changes: 37 additions & 0 deletions autogpt_platform/backend/backend/copilot/config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"OPENAI_BASE_URL",
"CHAT_CLAUDE_AGENT_CLI_PATH",
"CLAUDE_AGENT_CLI_PATH",
"CHAT_RENDER_REASONING_IN_UI",
"CHAT_STREAM_REPLAY_COUNT",
)


Expand Down Expand Up @@ -164,3 +166,38 @@ def test_directory_path_raises_validation_error(
monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(tmp_path))
with pytest.raises(Exception, match="not a regular file"):
ChatConfig()


class TestRenderReasoningInUi:
"""``render_reasoning_in_ui`` gates reasoning wire events globally."""

def test_defaults_to_true(self):
"""Default must stay True — flipping it silences the reasoning
collapse for every user, which is an opt-in operator decision."""
cfg = ChatConfig()
assert cfg.render_reasoning_in_ui is True

def test_env_override_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CHAT_RENDER_REASONING_IN_UI", "false")
cfg = ChatConfig()
assert cfg.render_reasoning_in_ui is False


class TestStreamReplayCount:
"""``stream_replay_count`` caps the SSE reconnect replay batch size."""

def test_default_is_200(self):
"""200 covers a full Kimi turn after coalescing (~150 events) while
bounding the replay storm from 1000+ chunks."""
cfg = ChatConfig()
assert cfg.stream_replay_count == 200

def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CHAT_STREAM_REPLAY_COUNT", "500")
cfg = ChatConfig()
assert cfg.stream_replay_count == 500

def test_zero_rejected(self):
"""count=0 would make XREAD replay nothing — rejected via ge=1."""
with pytest.raises(Exception):
ChatConfig(stream_replay_count=0)
Original file line number Diff line number Diff line change
Expand Up @@ -714,26 +714,36 @@ async def test_sleeps_for_exactly_backoff_seconds(self):
mock_sleep.assert_called_once_with(7)

async def test_replaces_adapter_with_new_instance(self):
"""state.adapter is replaced with a new SDKResponseAdapter after yield."""
"""state.adapter is replaced with a new SDKResponseAdapter after yield,
and ``render_reasoning_in_ui`` is threaded from the SDK service config
(not hardcoded) so ``CHAT_RENDER_REASONING_IN_UI=false`` at runtime
flips the reconstruction consistently with the rest of the path."""
from unittest.mock import AsyncMock, MagicMock, patch

from backend.copilot.sdk.service import _do_transient_backoff

cfg = _make_config(render_reasoning_in_ui=False)

original_adapter = MagicMock()
state = MagicMock()
state.adapter = original_adapter
state.usage = MagicMock()

with (
patch("asyncio.sleep", new=AsyncMock()),
patch(f"{_SVC}.config", cfg),
patch("backend.copilot.sdk.service.SDKResponseAdapter") as mock_cls,
):
new_adapter = MagicMock()
mock_cls.return_value = new_adapter
async for _ in _do_transient_backoff(3, state, "msg-1", "sess-1"):
pass

mock_cls.assert_called_once_with(message_id="msg-1", session_id="sess-1")
mock_cls.assert_called_once_with(
message_id="msg-1",
session_id="sess-1",
render_reasoning_in_ui=False,
)
assert state.adapter is new_adapter

async def test_resets_usage_after_yield(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ class SDKResponseAdapter:
text blocks, tool calls, and message lifecycle.
"""

def __init__(self, message_id: str | None = None, session_id: str | None = None):
def __init__(
self,
message_id: str | None = None,
session_id: str | None = None,
*,
render_reasoning_in_ui: bool = True,
):
self.message_id = message_id or str(uuid.uuid4())
self.session_id = session_id
self.text_block_id = str(uuid.uuid4())
Expand All @@ -62,6 +68,7 @@ def __init__(self, message_id: str | None = None, session_id: str | None = None)
self.reasoning_block_id = str(uuid.uuid4())
self.has_started_reasoning = False
self.has_ended_reasoning = True
self.render_reasoning_in_ui = render_reasoning_in_ui
self.current_tool_calls: dict[str, dict[str, str]] = {}
self.resolved_tool_calls: set[str] = set()
self.step_open = False
Expand Down Expand Up @@ -142,6 +149,17 @@ def convert_message(self, sdk_message: Message) -> list[StreamBaseResponse]:
# it live, extended_thinking turns that end
# thinking-only left the UI stuck on "Thought for Xs"
# with nothing rendered until a page refresh.
#
# When ``render_reasoning_in_ui=False`` the three
# reasoning helpers below (and the append) no-op, so
# the frontend sees a text-only stream AND no
# ``ChatMessage(role='reasoning')`` row is persisted
# (the row is only created by ``_dispatch_response``
# when ``StreamReasoningStart`` arrives, which is
# suppressed here). Persistence of the thinking text
# into the SDK transcript via
# ``_format_sdk_content_blocks`` is unaffected — that
# feeds ``--resume`` continuity, not the UI.
if block.thinking:
self._end_text_if_open(responses)
self._ensure_reasoning_started(responses)
Expand Down Expand Up @@ -347,8 +365,12 @@ def _ensure_reasoning_started(self, responses: list[StreamBaseResponse]) -> None
"""Start (or restart) a reasoning block if needed.

Each ``ThinkingBlock`` the SDK emits gets its own streaming block
on the wire so the frontend can render a new ``Reasoning`` part
per LLM turn (rather than concatenating across the whole session).
so the frontend can render a new ``Reasoning`` part per LLM turn
(rather than concatenating across the whole session). Events
are emitted unconditionally — the caller filters them out of the
SSE wire when ``render_reasoning_in_ui=False`` but still feeds
them through ``_dispatch_response`` so the session transcript
keeps a ``role='reasoning'`` row.
"""
if not self.has_started_reasoning or self.has_ended_reasoning:
if self.has_ended_reasoning:
Expand Down
Loading
Loading