Repository navigation
feat: end-to-end streaming output for providers, channels, and CLI - #2365
Merged
Merged
Conversation
Preserve the provider and agent-loop streaming primitives plus the CLI experiment scaffolding so this work can be resumed later without blocking urgent bug fixes on main. Made-with: Cursor
Provider layer: add chat_stream / chat_stream_with_retry to all providers (base fallback, litellm, custom, azure, codex). Refactor shared kwargs building in each provider. Channel layer: BaseChannel gains send_delta (no-op) and supports_streaming (checks config + method override). ChannelManager routes _stream_delta / _stream_end to send_delta, skips _streamed final messages. AgentLoop._dispatch builds bus-backed on_stream/on_stream_end callbacks when _wants_stream metadata is set. Non-streaming path unchanged. CLI: clean up spinner ANSI workarounds, simplify commands.py flow. Made-with: Cursor
Move ThinkingSpinner and StreamRenderer into a dedicated module to keep commands.py focused on orchestration. Uses Rich Live with manual refresh (auto_refresh=False) and ellipsis overflow for stable streaming output. Made-with: Cursor
…aming - Add strip_think() to helpers.py as single source of truth - Filter deltas in agent loop before dispatching to consumers - Implement send_delta in TelegramChannel with progressive edit_message_text - Remove duplicate think filtering from CLI stream.py and telegram.py - Remove legacy fake streaming (send_message_draft) from Telegram - Default Telegram streaming to true - Update CHANNEL_PLUGIN_GUIDE.md with streaming documentation Made-with: Cursor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add end-to-end streaming output infrastructure across the entire stack: LLM providers → agent loop → channels / CLI. Content is delivered token-by-token instead of waiting for the full response, significantly improving perceived latency.
Telegram is the first channel to ship with real streaming support (default on). Other built-in channels (Discord, Slack, Feishu, DingTalk, etc.) remain unchanged and can be adapted incrementally — the infrastructure is ready, each just needs a
send_delta()override.What Changed
1. Provider Layer —
chat_stream/chat_stream_with_retryAll four providers now implement native token-by-token streaming:
LiteLLMProvideracompletion(stream=True)+ async chunk iterationCustomProviderhttpx.AsyncClient().stream("POST", ...)+ SSE line parsingAzureOpenAIProviderAsyncAzureOpenAI.chat.completions.create(stream=True)OpenAICodexProviderAsyncOpenAI.chat.completions.create(stream=True)Base class (
LLMProvider) provides two methods:chat_stream()— abstract with default fallback: callschat()and delivers the full content as a single delta. Providers that don't override it still work.chat_stream_with_retry()— retry wrapper with exponential backoff, mirrors the existingchat_with_retry()behavior.Files:
nanobot/providers/base.py,litellm_provider.py,custom_provider.py,azure_openai_provider.py,openai_codex_provider.py2. Agent Loop — Stateful Think-Tag Filter + Delta Dispatch
_run_agent_loop()now accepts optionalon_stream/on_stream_endcallbacks. When present, it callschat_stream_with_retryinstead ofchat_with_retry.Key design: think-tag filtering happens once at the source. A stateful wrapper in the loop accumulates raw LLM output, applies
strip_think(), and only emits the clean incremental text. No downstream consumer (CLI, Telegram, any future channel plugin) ever sees<think>tags.strip_think()is defined innanobot/utils/helpers.pyas the single source of truth, replacing three previously duplicated implementations.In
_dispatch(), bus-backed callbacks are built when_wants_streamis set on the inbound message. Outbound messages carry these metadata flags:_stream_delta: True_stream_end: True_resuming: True_streamed: Truesend()is skippedFiles:
nanobot/agent/loop.py,nanobot/utils/helpers.py3. Channel Infrastructure —
BaseChannel.send_delta+supports_streamingBaseChannelgains two new members:async send_delta(chat_id, delta, metadata)— no-op by default. Override in subclass to handle streaming chunks.supports_streaming(property) — returnsTrueonly when both conditions are met:"streaming": truesend_delta()_handle_message()automatically sets_wants_streamin message metadata whensupports_streamingis true. If either condition fails, the agent loop falls back to the standard non-streamingchat_with_retry→send()path. Existing channels are completely unaffected.ChannelManager._dispatch_outbound()routes messages based on metadata:_stream_delta/_stream_end→channel.send_delta()_streamed→ skip (already delivered)channel.send()(unchanged)Files:
nanobot/channels/base.py,nanobot/channels/manager.py,nanobot/config/schema.py4. Telegram — Real Streaming via Progressive Message Editing
Replaces the previous fake streaming (
send_message_draftsimulation) with real progressive output:send_message(creates the message, capturesmessage_id)edit_message_text(throttled to every 0.6s to respect Telegram rate limits)edit_message_textwith full HTML formatting via_markdown_to_telegram_htmlWhen
streaming: false, Telegram sends a single one-shot message — no fake streaming, no draft simulation. Clean and simple.TelegramConfigaddsstreaming: bool = True(default on for new installs).Removed: the entire
_send_with_streaming()method andsend_message_draftlogic.File:
nanobot/channels/telegram.py5. CLI —
StreamRenderer+ThinkingSpinnerExtracted to a dedicated
nanobot/cli/stream.pymodule to keepcommands.pyfocused on orchestration.ThinkingSpinner: shows "nanobot is thinking..." withpause()support for clean progress outputStreamRenderer: uses Rich Live withauto_refresh=Falsefor manual-refresh control. Renders accumulated Markdown on each delta. Spinner starts on init, stops on first visible content, restarts between tool call rounds.Both single-message mode (
nanobot agent -m "...") and interactive mode wire upStreamRenderer.on_delta/on_endas callbacks. Falls back to one-shot_print_agent_responseif streaming didn't occur.Files:
nanobot/cli/stream.py,nanobot/cli/commands.py6. Documentation — Channel Plugin Streaming Guide
Updated
docs/CHANNEL_PLUGIN_GUIDE.mdwith a new Streaming Support section covering:supports_streamingworks (dual condition: config + implementation)send_delta()method signature and metadata flags referencesend_deltaandsupports_streamingFor channel plugin authors who want to add streaming: see
docs/CHANNEL_PLUGIN_GUIDE.md— Streaming Support section.Channels Streaming Status
edit_message_text, default onedit_messageAPI, good candidatechat.updateAPI, good candidatesend_delta()overrideEach channel can be enabled independently. The infrastructure is fully in place — adding streaming to a new channel requires:
send_delta()in the channel classstreaming: boolto the channel's config modelTest Plan
nanobot agentCLI streaming renders markdown correctly<think>tags visiblestreaming: falsesends clean one-shot messages