Quick Start
1
Install
2
Auto-detect (Default)
max_tokens, top_p, temperature) are honoured whether the streaming attempt or the fallback runs, and if streaming raises the SDK surfaces the original cause on the error’s __cause__ — you won’t lose the real error to a fallback TypeError.3
Force Streaming
4
Shape the output while streaming
Pass sampling knobs to
start() the same way you would to a non-streaming call.5
Control reasoning effort while streaming
A reasoning model honours
reasoning_effort on the streaming path too.Sampling knobs on the streaming path
start(stream=True) forwards temperature, max_tokens, top_p, and reasoning_effort straight to the provider stream.
Leave a key out and it never reaches the provider — the provider’s own default applies. This is not the same as sending
0 or 1.Reasoning-effort precedence. The call kwarg wins; when it’s absent the agent attribute (
Agent(reasoning_effort=...)) is used. Pass reasoning_effort="off" on a single call to short-circuit an agent-wide default for that turn.Tool follow-ups inherit it. _start_stream_impl copies completion_args into the Phase-2 follow-up after tools, so the reasoning knob is carried into the synthesised-answer turn too — no extra work required.Extended-thinking models on the native OpenAI streaming path. For Anthropic Claude 3.7+ / Gemini 2.5+ named over an OpenAI-compatible endpoint,
resolve_reasoning_params returns {"thinking": {...}}. The raw OpenAI SDK rejects unknown top-level kwargs with TypeError, so the SDK routes the thinking budget through extra_body. reasoning_effort itself is a native OpenAI SDK keyword and stays top-level.What happens when streaming fails
Streaming can fail mid-flight — a context-length overflow, a provider hiccup, a sync adapter that refuses to stream — and when it does,start(stream=True) and iter_stream() fall back to a single non-streaming chat() call so your caller still gets an answer.
The fallback contract:
Internal helpers
One private helper onChatMixin implements the contract above and is what the regression tests drive directly.
This is internal — do not import it from application code. It is named here so debug traces are readable and so subclasses that override
chat() know that adding parameters to the override signature automatically opts those parameters into the fallback.
Debugging a failed fallback:
Before PraisonAI PRs #4731 and #4734 (issue #4719) the fallback forwarded
start()’s raw kwargs unchanged, so stream=True reached the sync adapter and max_tokens raised TypeError before any request was made. Callers on the desktop (which passes max_tokens on every turn) saw the TypeError with the original streaming error buried two __context__ levels deep. Since these PRs, the fallback filters kwargs, forces stream=False, chains the original cause via raise ... from streaming_error, and marks the raised exception exhausted so the outer handler does not run the fallback again. The executable spec lives in src/praisonai-agents/tests/test_streaming_fallback_kwargs.py.Choosing the Right Method
Common Patterns
Terminal Streaming
App Integration with iter_stream()
Best for integrating into your own application — yields raw chunks with no display overhead.
praisonai chat / praisonai code) consumes iter_stream() directly since PR #2906 — every token you see in the terminal is a real model delta, not a post-hoc word replay. If the provider does not stream, the CLI falls back to a single non-streamed chat() call and prints the completed answer as one block. See Interactive TUI.
Streaming with Callbacks
Hook into every streaming event for fine-grained control.For higher-level “a tool ran” telemetry — rather than token-level
StreamEvents — the tool_call display callback is the right entry point. It fires on the streaming path too (since PR #4735), carrying tool_name, tool_input, tool_output, elapsed_time, and success.FastAPI SSE Integration
Pipe streaming tokens directly to a web client using Server-Sent Events.Async Streaming
Streaming with Knowledge
Streaming responses retrieve and inject knowledge context using the same normalization as the non-streaming sync path.Fixed in PraisonAI PR #4887. Before this release, streaming with
knowledge=[...] raised TypeError: can only join an iterable — the streaming branches re-implemented a partial normalizer that tried to "\n".join(...) a SearchResult dataclass. Both streaming branches now route through the shared _get_knowledge_context() helper, so knowledge injection works identically on streaming, async, and sync paths. See Async Knowledge Retrieval and Search Results.Streaming with Tools
When your agent uses tools, streaming happens in two phases: the initial response that decides to call tools, and a follow-up response that synthesizes the tool results. Tools can also emit incremental progress while they run usingemit_tool_progress() — these arrive as TOOL_PROGRESS events in your stream callback before the tool returns its result. See Tool Progress Streaming.
Ollama doesn’t reliably stream tool calls. When your agent uses Ollama and has tools, PraisonAI automatically disables streaming for that turn (
OllamaAdapter.supports_streaming_with_tools() returns False) and falls back to the non-streaming path. Regular streaming without tools works normally.LM Studio, vLLM, and llama.cpp stream tool calls correctly — the LocalOpenAIAdapter deliberately does not disable streaming for them.Where the two-phase flow lives. The follow-up completion after tools is issued by the custom-LLM streaming path (
praisonaiagents/llm/llm.py::get_response_stream), which most providers route through. The follow-up keeps tools available and is fetched non-streamed, then yielded as a single block — so the model can call another tool if it needs to. This is not bounded to a single round.Fixed in PraisonAI PR #4757. Before #4757,
get_response_stream called an undefined _create_tool_message. The first tool result raised AttributeError, was silently swallowed by a broad except Exception, and the run fell through to a non-streaming branch that discarded tool_calls — so every tool turn under stream=True produced an empty answer. Providers whose _supports_streaming_tools() returns False — Anthropic, Gemini and Ollama — took that broken branch on every tools turn. Since #4757, _create_tool_message is a real method and the non-streaming fallback runs a bounded tool loop that actually executes tools and asks the model again with the results.The non-streaming fallback loop
Providers that cannot stream with tools run a bounded, non-streamed tool loop under the hood. Any provider whose_supports_streaming_tools() returns False takes this path — currently Anthropic (anthropic/claude-sonnet-4-20250514), Gemini (gemini/gemini-2.0-flash), and Ollama (ollama/llama3.2).
Stall detection and tools-disabled finalisation
Since PraisonAI PR #4870, the fallback stops when a provider makes no progress and asks the model to answer with tools disabled. Four ways the loop can stop early:
After any of these, the loop asks the model once more with
tools= removed and the wrap-up prompt "The tools have already been called and their results are above. Do not call any tools. Answer the original question now using those results." — the same bounded finalisation get_response and get_response_async already use.
If the finalisation returns empty or the fallback message, the stream raises LLMResponseError("Provider produced no answer after N tool call(s) on the streaming path (<reason>).") rather than yielding nothing.
Symptom this fixes. On a small local model the stream path used to run the same tool up to ten times and yield an empty string. Since #4870 the same scenario emits one tool call and a real model-written sentence.
Sync and async paths are byte-identical to before #4870. The stream path’s stall-detection + tools-disabled finalisation is deliberately not ported yet — sync/async return
str(tool_result) from _generate_ollama_tool_summary for the same input. Raising this to feature parity is tracked as a separate decision.- Issues a non-streamed request with
tools=still attached. - If the response has no
tool_calls, yields whatever prose is there and stops. - If it has
tool_calls, records the assistant turn (withtool_callsfor OpenAI-shaped providers, plain content for Ollama), yields any accompanying prose, then executes each tool. - A failing tool is reported to the model as
{"error": "..."}rather than aborting the run. - Loops back and asks the model again with the tool-result messages appended.
_create_tool_message — now a real method — mirrors the non-streaming loop’s formatting, including Ollama’s natural-language variant.
Tool activity telemetry
Streaming UIs now see every tool call through thetool_call display callback.
Register a tool_call handler and it fires on the streaming path with tool_name, tool_input, tool_output, elapsed_time, and success — the same kwargs as the non-streaming path.
Before PraisonAI PR #4735 (issue #4716), the
tool_call display callback fired only on the non-streaming path — a tool could run under stream=True with its result reaching the model, yet a streaming UI saw no tool activity at all. Since #4735 the callback fires on both sync-OpenAI (chat_mixin.py::_start_stream_impl) and custom-LLM (llm.py::get_response_stream) streaming paths.stream=False and yielded as one block — it is not a token stream. It carries the accumulated messages (system prompt, user turn, the assistant tool-call message, and the tool-result messages) and keeps tools, so the model may call another tool rather than being forced to answer.
_completion_with_retry wrapper, so transient rate-limit or network errors are retried automatically without any caller intervention. If every retry is exhausted on the follow-up, the stream ends with the error sentinel documented below rather than dropping silently.
Streaming with long histories
Context management runs on the streaming path exactly like it does on the non-streaming path — on both streaming branches. If your agent has a ContextManager configured (auto-enabled when the agent has tools; see Context Management Overview), long chat histories are compacted before the streamed request is sent — same budget, same strategy, same behaviour aschat().
The same flow now runs on both streaming branches — the OpenAI-client branch and the custom-LLM branch. Which one your agent takes is decided by the routing flag _using_custom_llm, not the vendor.
Which streaming branch does my agent use?
Two branches feed the streaming path, and they are chosen by_using_custom_llm:
The trigger is the routing flag, not the vendor —
llm="openai/gpt-4o-mini" takes the custom-LLM branch too. Before PR #4753 the custom-LLM rows were ❌: compaction was silently skipped, and long conversations were sent to the provider in full.
How this fix arrived, in two PRs.Before PraisonAI PR #4729
(issue #4714),
streaming bypassed compaction entirely: long-running streamed chats hit the
provider’s context-length limit. #4729 added compaction to
_start_stream_impl,
but only inside the OpenAI-client branch.Every agent routed through the custom-LLM branch — llm="openai/gpt-4o-mini",
llm="ollama/…", llm="anthropic/…", and any other routed provider — still
streamed the entire history uncompacted. The trigger is the routing flag
_using_custom_llm, not the vendor.PR #4753 closes that
second branch. Since #4753, start(stream=True) / iter_stream() and
chat() / start() behave identically for context management on all
providers — the same budget, strategy, and behaviour as chat().There is no configuration to enable this — it inherits from your existing
context= / Agent(tools=[...]) setup.The system prompt is embedded as
messages[0]. The streaming path splits it out so the token ledger accounts for it, then reattaches it after optimization — no user-visible change to prompt structure.Agent(context=False), or an agent with no tools and no explicit context=True), streaming and non-streaming both send the raw history — as before. The self.context_manager guard skips the whole step, so there is zero overhead when context management is disabled.
Error Handling in the Stream
If the follow-up LLM call fails after retries, the stream ends with a visible error sentence instead of silently dropping. This sentinel is emitted from the custom-LLM streaming path (praisonaiagents/llm/llm.py::get_response_stream) — the native sync-OpenAI path does not currently emit it.
You may receive this exact sentinel string:
Detect the error sentinel in your stream consumer:
The initial LLM call and the follow-up LLM call (after tool execution) now share the same retry and rate-limiting behavior — users no longer need to add their own retry wrapper around streaming + tools.
StreamEvent Protocol
Every streaming chunk emits aStreamEvent with full context.
TODO_UPDATED fires on every todo_add / todo_update — subscribe to render a live checklist. See Live Todo Streaming.
MODEL_FALLBACK fires the moment the runtime swaps models. Subscribe to render a “switched to backup” notice, or pair it with the HookEvent.MODEL_FALLBACK hook for programmatic reactions. See Model Fallback → Observing the Switch.
Reacting to Retries
When the agent hits a rate limit or transient error, it emits aRETRY event before it waits, then retries. The signal fires on both the sync and async retry loops, and reaches both add_callback(sync_fn) and add_async_callback(async_fn) consumers. Emission is guarded so there is zero overhead when nothing is listening.
The RETRY event carries its details in event.metadata:
- Sync callback
- Async callback
RETRY events reach every registered callback, sync or async. You can mix add_callback(fn) and add_async_callback(afn) on the same emitter — both fire for every retry. Requires PraisonAI 2026-07-23 or later (PR #3325); earlier versions dispatched async retries via the sync path only and skipped async-only consumers.Same signal, two consumers — the
ON_RETRY hook is for programmatic control, while the RETRY stream event feeds live UIs and stream-json pipelines. The run.retry NDJSON event is the CLI-facing form of this same event.Token usage on the streaming path
Streamed calls now record real prompt and completion tokens, not zeros. Before PraisonAI PR #4819, the Chat Completions streaming path’schoices guard swallowed the trailing usage-only chunk, and the Responses API streaming path never inspected the response.completed event — so CompletionUsage(0, 0, 0) reached your metrics collector on every streamed turn. Since #4819:
process_stream_chunksreads the usage chunk before thechoicesguard, andprocess_stream_responserequestsstream_options={"include_usage": True}on every streamed call (a caller-suppliedstream_optionsis respected)._stream_responses_api/_stream_responses_api_asynccapture the final response fromresponse.completedand hand it to_track_token_usage; the sync path also fires thellm_enddisplay callback (tokens_in,tokens_out,latency_ms).
If you set
stream_options yourself (for example to opt out of the usage chunk), the SDK respects your choice and does not clobber it. Otherwise {"include_usage": True} is the default._emit:
llm_end display callback now fires on the Responses API streaming path (sync) too, carrying the same tokens_in / tokens_out / latency_ms fields as the non-streaming path.
Metrics
Track Time To First Token (TTFT) and throughput. Token counterstokens_in / tokens_out are now accurate on the streaming path (see Token usage on the streaming path).
Key Concepts
Time To First Token (TTFT)
Streaming vs Non-Streaming
Sync vs Async Adapters: Async methods (
achat, astart, _execute_unified_achat_completion) still default to stream=True because async adapters universally support streaming. Sync methods (chat, start, run) use the new smart-fallback default. Some adapters (e.g., sync OpenAI/Deepseek adapter) currently do NOT support sync streaming and will trigger the fallback.Multi-agent teams (AgentTeam, PraisonAIAgents) default to stream=True for "verbose" and "minimal" presets. Use output=MultiAgentOutputConfig(stream=False) or output=["verbose", {"stream": False}] to opt out for sync-only providers.Streaming and guardrails
Streaming splits guardrail handling: input guardrails run, output guardrails do not.Input guardrails — enforced on streaming (since PR #4462)
Since PraisonAI PR #4462,iter_stream() and start(stream=True) validate the prompt before the first token is yielded — the full prompt is known up front, so input validation gates the stream exactly like chat(). A blocked prompt yields the single chunk [Input blocked by guardrail: <reason>] and stops; the durable-run record is never opened.
Output guardrails — bypassed on streaming
Output guardrails validate the full response before it is returned to the caller. Token-level streaming yields tokens as the model produces them — there is no full response to validate until streaming completes, and buffering tokens until then would break the streaming contract. Since PraisonAI PR #3632, the SDK emits a clear warning as soon as streaming begins on an agent with an output guardrail attached:
See Guardrails → Streaming and guardrails for the guardrail-side view.
Managed-backend streaming (backend=)
When Agent(backend=<ManagedBackendProtocol>) is used, start(stream=True) delegates to backend.stream(prompt) and yields each chunk as the backend emits it — even from ordinary sync code with no running event loop.
Prior to PraisonAI PR #3908, the sync (“no running event loop”) branch buffered internally, so the generator yielded only after the whole response was produced; since #3908 it is truly incremental, mirroring the running-loop branch.
Closing the generator early signals the producer to stop. The internal queue is bounded (
maxsize=64), so an abandoned consumer applies back-pressure instead of leaking memory, and the producer thread is a daemon — a stalled backend cannot block interpreter shutdown.start(stream=True) call — see Choosing the Right Method and Managed Runtime Protocol for the backend contract.
CLI Usage
Best Practices
Let the SDK pick streaming mode
Let the SDK pick streaming mode
Omit the
stream argument (or pass stream=None) and the SDK will choose streaming where supported and silently fall back where it isn’t. Only override when you have a specific reason.Use iter_stream() for app integration
Use iter_stream() for app integration
iter_stream() yields raw chunks with zero display overhead — ideal for piping into FastAPI, WebSocket, or custom UIs.Use start(stream=True) for terminal
Use start(stream=True) for terminal
start() handles display automatically. Pass stream=True for real-time token output in interactive sessions.Monitor TTFT for performance
Monitor TTFT for performance
High TTFT indicates model or network issues. Use
StreamMetrics to track and optimize.Handle errors in callbacks
Handle errors in callbacks
Two layers of error handling. Callback exceptions are still caught by the emitter to avoid breaking the stream — log them inside your callback. LLM call failures, however, are now retried automatically and, on persistent failure, surface as a visible
[Error: ... (ref: ...)] sentence at the end of the stream — check for this sentinel when consuming iter_stream().Streaming is safe for input guardrails, not output guardrails
Streaming is safe for input guardrails, not output guardrails
Input guardrails do run on
iter_stream() and start(stream=True) (since PR #4462) — a blocked prompt yields [Input blocked by guardrail: ...] and stops. So streaming is safe if you only need to block bad prompts. Output guardrails are still bypassed on streaming — the SDK logs a warning when one is combined with streaming. Use chat() (or the non-streaming path of start() / astart()) when you need validated output. See Streaming and guardrails.Anthropic, Gemini and Ollama don't natively stream with tools
Anthropic, Gemini and Ollama don't natively stream with tools
These providers do not natively stream with tools.
start(stream=True) still works, but under the hood the tool loop runs non-streamed and each model turn’s answer is yielded as one block. Since PR #4757, tool calls on this path actually execute — earlier versions silently produced an empty stream. max_iterations (falls back to Agent(max_iter=...)) bounds how many round trips the fallback makes, and max_tool_calls_per_turn bounds tool calls per model turn. See The non-streaming fallback loop.Troubleshooting
”Streaming seems to buffer before showing anything”
This is TTFT, not buffering. The model is generating the first token. Check:- Model complexity (larger models have higher TTFT)
- Prompt length (longer prompts take longer to process)
- Network latency to the API
”Tokens appear in chunks, not one at a time”
Normal. Providers may batch tokens for efficiency.”Stream ends with [Error: Failed to generate final response after tool execution (ref: followup-...)]”
The follow-up LLM call (the one that synthesizes tool results into a final answer) failed after the built-in retries. Common causes:
- Persistent rate limit — pair streaming with a Rate Limiter at higher RPM, or back off the caller.
- Context-length overflow — reduce conversation history or tool-result size.
- Provider outage — include the
ref:ID when reporting. The internal log line (ref=..., model=..., error=...) makes it searchable.
”Streaming with tools returns nothing on Anthropic / Gemini / Ollama”
Fixed in PraisonAI PR #4757. If you are on an older version, upgrade — earlier versions called an undefined method inside a swallowed exception and dropped every tool call. The failure symptom was aStreaming failed with unexpected error log line followed by an empty response. See The non-streaming fallback loop.
”Streaming is not supported in sync OpenAIAdapter” / Deepseek multi-agent crash
Fixed with single-agent smart-fallback in PR #1734. Since PR #4731 the fallback filters kwargs (max_tokens, top_p, and anything else chat() doesn’t declare) and chains the original streaming exception via raise ... from streaming_error; PR #4734 factored the whole fallback into ChatMixin._stream_fallback_chat (signature-driven, so it is robust to future chat() changes) and added the _praisonai_stream_fallback_exhausted sentinel so the outer handler does not run the fallback twice. If you were previously catching TypeError: chat() got an unexpected keyword argument 'max_tokens' as a proxy for “streaming failed”, switch to inspecting exc.__cause__ instead — the real cause is now preserved. For multi-agent teams that use sync-only providers, explicitly disable streaming with output=["verbose", {"stream": False}] or similar. See Multi-Agent Output for configuration options.
Routed Claude / Gemini crash on the async path
Routed Claude and Gemini models (Bedrock / Vertex AI / OpenRouter prefixes) now stream correctly on the async path. Older versions misdetected them as OpenAI and crashed. Provider detection now resolvesbedrock/anthropic.*, vertex_ai/claude-*, openrouter/anthropic/* to Anthropic and vertex_ai/gemini-* to Gemini, handing each to the correct streaming adapter. See Routed model provider detection.
Related
Bot Streaming Replies
Live draft replies on messaging platforms using streaming events
Multi-Agent Output
Configure streaming and display for agent teams
Output & Display
Single-agent output formatting options
Async
Async agent execution
Rate Limiter
Control request rates across initial and follow-up LLM calls
Guardrails
How streaming gates input guardrails but bypasses output guardrails
Live Todo Streaming
Render a live agent checklist from
TODO_UPDATED eventsManaged Runtime Protocol
backend= streams each chunk incrementally from sync codeContext Compaction
Compaction runs on the streaming path too — same as
chat()Display Callbacks
tool_call fires on the streaming path — tool telemetry for live UIsReasoning Effort
reasoning_effort reaches the provider on both streaming branchesAsync Knowledge Retrieval
Knowledge injection works on streaming and async paths, not just sync
Search Results
The
SearchResult shape streaming now normalizes correctly
