Skip to content

Python: Stream Bedrock responses with ConverseStream instead of returning them in one update - #8685

Merged
Eduard van Valkenburg (eavanvalkenburg) merged 2 commits into
microsoft:mainfrom
kimnamu:fix/bedrock-gpt-6-streaming
Sep 24, 2026
Merged

Eduard van Valkenburg (eavanvalkenburg) merged 2 commits into
microsoft:mainfrom
kimnamu:fix/bedrock-gpt-6-streaming

Conversation

@kimnamu

@kimnamu Lucas Kim (kimnamu) commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

Thanks for the Bedrock connector and for the Anthropic client's streaming code, whose tool-input delta pattern this PR follows. stream=True called non-streaming Converse and yielded one update at the end, so OpenAI GPT-6 Sol/Luna/Astra first text arrived 3-7x later than with ConverseStream.

Description & Review Guide

  • What are the major changes? _invoke_converse(request, stream=True) calls converse_stream with the same error handling. The streaming branch reads events in a worker thread and a new _process_converse_stream_event maps text deltas, toolUse start/input deltas (merged by call_id), messageStop and metadata usage to updates. The stream is closed in finally; the non-streaming path is untouched.
  • What is the impact of these changes?
Live, us-east-1, n=5 median, same prompt Before (main @ f42ff01) After (this branch)
GPT-6 Sol, TTFT vs raw converse_stream ❌ 3.56 s vs 1.03 s, 1 update ✅ 0.95 s vs 0.97 s, ~310 updates
GPT-6 Luna, TTFT vs raw converse_stream ❌ 2.83 s vs 0.96 s, 1 update ✅ 1.17 s vs 1.15 s, ~350 updates
GPT-6 Astra us., TTFT vs raw converse_stream ❌ 8.35 s vs 1.88 s, 1 update ✅ 1.10 s vs 1.13 s, ~330 updates
GPT-6 Astra global., TTFT vs raw converse_stream ❌ 9.24 s vs 1.26 s, 1 update ✅ 1.22 s vs 1.18 s, ~325 updates
Total time (Sol / Luna / Astra us.) 3.56 s / 2.83 s / 8.35 s ✅ 3.21 s / 2.83 s / 7.81 s, same as raw
Agent.run(stream=True) tool round-trip, GPT-6 Sol / Luna / Astra ✅ tool called, answer in 1 update ✅ tool called, answer in 15 / 15 / 18 updates
stream=True with response_format ✅ .value parsed ✅ unchanged (existing test + live City(name='Tokyo', country='Japan') on Sol, Luna and Astra)
Non-streaming get_response, public API ✅ ✅ unchanged
$ python d3_live.py   # main @ f42ff01
us.openai.gpt-6-sol [stream text] text updates=1 first=1.4s last=1.4s
$ python d3_live.py   # this branch
us.openai.gpt-6-sol [stream text] text updates=59 first=1.12s last=1.31s
us.openai.gpt-6-sol [stream tool round-trip] calls=['get_weather'] text updates=15 -> 'The forecast for Paris is sunny, with a temperature of 21°C.'
us.openai.gpt-6-luna [stream text] text updates=59 first=0.94s last=1.13s
$ python stream_live.py us.openai.gpt-6-astra global.openai.gpt-6-astra   # main @ f42ff01
us.openai.gpt-6-astra [stream text] text updates=1 first=1.97s last=1.97s
$ python stream_live.py us.openai.gpt-6-astra global.openai.gpt-6-astra   # this branch
us.openai.gpt-6-astra [stream text] text updates=59 first=1.44s last=1.89s
us.openai.gpt-6-astra [stream tool round-trip] calls=['get_weather'] text updates=18 -> 'The forecast for Paris is sunny, with a temperature of 21°C (70°F).'
  • What do you want reviewers to focus on? The per-event asyncio.to_thread(next, ...) loop. Reasoning deltas are skipped, like the non-streaming parser does.

Related Issue

Fixes #8684

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.
Raw perf medians on this branch (TTFT s, total s, output tokens, updates)
us.openai.gpt-6-sol | raw ConverseStream | 5 | 0.97 | 3.45 | 322 | -
us.openai.gpt-6-sol | agent-framework stream=True | 5 | 0.95 | 3.21 | 316 | [306, 315, 299, 310, 314]
us.openai.gpt-6-luna | raw ConverseStream | 5 | 1.15 | 2.82 | 415 | -
us.openai.gpt-6-luna | agent-framework stream=True | 5 | 1.17 | 2.83 | 408 | [351, 358, 342, 348, 374]
us.openai.gpt-6-astra | raw ConverseStream | 5 | 1.13 | 7.99 | 330 | -
us.openai.gpt-6-astra | agent-framework stream=True | 5 | 1.10 | 7.81 | 332 | [327, 331, 325, 330, 332]
global.openai.gpt-6-astra | raw ConverseStream | 5 | 1.18 | 8.10 | 332 | -
global.openai.gpt-6-astra | agent-framework stream=True | 5 | 1.22 | 8.24 | 324 | [322, 320, 319, 332, 330]

…ning them in one update

stream=True called the non-streaming Converse API and yielded a single
update after the whole response had arrived, so time to first text was
the full generation time.

Call converse_stream and yield an update per event: text deltas, tool
use start and input deltas (merged by call_id), messageStop finish
reason and metadata usage. The event stream is closed when iteration
ends. The non-streaming path is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 23, 2026 10:36
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Sep 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Stream failures must be propagated, and streamed updates must preserve the assistant role.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Adds incremental AWS Bedrock ConverseStream support for Python responses.

Changes:

  • Streams text, tool calls, finish reasons, and usage metadata.
  • Reliably closes event streams.
  • Adds streaming and structured-output tests.
File Review
python/​packages/​bedrock/​tests/​test_bedrock_structured_output.py Extends structured-output streaming stubs and coverage.
python/​packages/​bedrock/​tests/​test_bedrock_client.py Tests streamed responses, tool calls, usage, finish reasons, and cleanup.
python/​packages/​bedrock/​agent_framework_bedrock/​_chat_client.py Implements stream processing. Critical (2 votes): modeled stream errors are silently ignored. Moderate (1 vote): streamed updates omit the assistant role.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Copilot's review of microsoft#8685 noted that the ConverseStream updates carry no
role. The other chat clients tag their streaming updates with
role="assistant" (OpenAI, Mistral, Ollama), and the non-streaming Bedrock
path already builds Message(role="assistant"). Without it,
Agent.run(stream=True) yielded AgentResponseUpdate.role=None for Bedrock
while the other providers yield "assistant".

Set role="assistant" on every streamed update. The aggregated
ChatResponse already defaulted to assistant and is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
@kimnamu

Copy link
Copy Markdown
Contributor Author

Thanks for the Copilot review pass on this draft: both findings were worth checking, and the assistant-role one turned out to be a real gap.

The stream-error finding is answered in its thread (botocore already raises, so no code change), and the missing role is fixed in 3885687. The runs behind each verdict are below.

Review item Verdict Commit
Raise exceptions for ConverseStream error events (r4081526871) Not changed. botocore turns :message-type: exception events into EventStreamError while iterating, so they never reach _process_converse_stream_event. Re-run today on this branch, output in the fold below. none
Streamed updates omit the assistant role (overview, moderate) Fixed. _process_converse_stream_event now sets role="assistant" on every update, like the OpenAI, Mistral and Ollama clients and like the non-streaming Bedrock path (Message(role="assistant", ...)). 3885687

Why the commit: map_chat_to_agent_update copies update.role as-is, so Agent.run(stream=True) on Bedrock yielded AgentResponseUpdate.role=None while the other providers yield "assistant". The aggregated ChatResponse already defaulted to assistant and is unchanged.

Bedrock stub, stream=True, no AWS call Before (b73066c) After (3885687)
ChatResponseUpdate.role over the stream {None} {'assistant'}
AgentResponseUpdate.role from Agent.run(stream=True) {None} {'assistant'}
ChatResponse.messages[0].role from get_final_response() assistant assistant, unchanged

One assertion added to test_stream_yields_updates_as_converse_stream_events_arrive, red on b73066c and green after the fix:

# b73066cb
>       assert all(update.role == "assistant" for update in updates)
E       assert False
1 failed, 27 deselected in 0.13s

# 3885687b
1 passed, 27 deselected in 0.08s
55 passed, 1 deselected, 1 warning in 0.13s        # packages/bedrock/tests, -m "not integration"
ruff format: 8 files already formatted             # ruff check: All checks passed!
pyright (source, strict): 0 errors, 0 warnings     # mypy / zuban / pyrefly / ty / pyright on tests: 0 errors
Stream error events, re-run today on this branch (real boto3 client against a local event-stream server, no AWS call)
modelStreamErrorException    iterate: received ['Hello'] then raised botocore.exceptions.EventStreamError
modelStreamErrorException    get_final_response(): raised botocore.exceptions.EventStreamError
throttlingException          iterate: received ['Hello'] then raised botocore.exceptions.EventStreamError
throttlingException          get_final_response(): raised botocore.exceptions.EventStreamError
validationException          iterate: received ['Hello'] then raised botocore.exceptions.EventStreamError
validationException          get_final_response(): raised botocore.exceptions.EventStreamError
internalServerException      iterate: received ['Hello'] then raised botocore.exceptions.EventStreamError
internalServerException      get_final_response(): raised botocore.exceptions.EventStreamError
serviceUnavailableException  iterate: received ['Hello'] then raised botocore.exceptions.EventStreamError
serviceUnavailableException  get_final_response(): raised botocore.exceptions.EventStreamError

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lucas Kim (@kimnamu) The current head is green, and the dedicated full-diff review found no remaining actionable issues with Bedrock ConverseStream text, tool, finish, usage, role propagation, structured-output finalization, or stream cleanup behavior.

Merged via the queue into microsoft:main with commit 0843a9c Sep 24, 2026
47 checks passed

This branch was previously deployed

1 inactive deployment
github-app-auth — 3885687b Deployed Sep 24, 2026 by kimnamu via team_check #5206
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: BedrockChatClient stream=True returns the whole response in one update, so GPT-6 Sol/Luna/Astra first text arrives 3-7x later

3 participants