Skip to content

Commit bc62031

Browse files
committed
Fix Bedrock Converse streaming losing reasoning blocks on replay
Converse::Chat (non-streaming) already collects every reasoning (thinking/redacted_thinking) content block from a turn into Thinking#blocks and replays them verbatim, since Anthropic requires the full original block set to be replayed byte-for-byte on the next request. Converse::Streaming#build_chunk never did the same — it only ever built Thinking from merged text/signature per chunk, so StreamAccumulator's blocks-collecting logic had nothing to collect and the final streamed message lost the raw blocks. Replaying that lossy reconstruction on the next turn gets rejected by Anthropic with "Invalid data in redacted_thinking block" (surfaced downstream as appfolio/agents_app DUX-7838, in an app that streams every Bedrock Converse completion). Reassemble each reasoning block from its contentBlockStart/Delta/Stop events (they always stream fully sequentially, so a single running accumulator suffices) and emit it once complete, in the same {'reasoningContent' => {...}} shape the non-streaming parser produces.
1 parent 1aeb08d commit bc62031

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

lib/ruby_llm/protocols/converse/streaming.rb

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ def build_chunk(event)
143143
content: extract_content_delta(event),
144144
thinking: Thinking.build(
145145
text: extract_thinking_delta(event),
146-
signature: extract_thinking_signature(event)
146+
signature: extract_thinking_signature(event),
147+
blocks: extract_finished_reasoning_block(event)
147148
),
148149
tool_calls: extract_tool_calls(event),
149150
input_tokens: extract_input_tokens(metadata_usage, usage),
@@ -252,6 +253,55 @@ def extract_signature_from_start(event)
252253
nil
253254
end
254255

256+
# Anthropic (and Bedrock Converse, which proxies to it) requires every reasoning
257+
# block from a turn to be replayed byte-for-byte on the next request (see
258+
# Thinking's doc comment) — merging text+signature loses any additional blocks.
259+
# Content blocks always stream fully sequentially (start -> delta* -> stop for one
260+
# index before the next begins), so a single running accumulator is enough; no
261+
# per-index bookkeeping is needed. Returns the finished block, in the same
262+
# {'reasoningContent' => {...}} shape Converse::Chat's non-streaming parser
263+
# produces, once its contentBlockStop arrives — nil for every other event,
264+
# including every event belonging to a non-reasoning content block (nothing was
265+
# accumulated for it).
266+
def extract_finished_reasoning_block(event)
267+
accumulate_reasoning_content(event)
268+
return nil unless event.key?('contentBlockStop')
269+
270+
finalize_reasoning_block
271+
end
272+
273+
def accumulate_reasoning_content(event)
274+
start = event.dig('contentBlockStart', 'start', 'reasoningContent')
275+
accumulate_reasoning_fragment(start) if start
276+
277+
delta = event.dig('contentBlockDelta', 'delta', 'reasoningContent')
278+
accumulate_reasoning_fragment(delta) if delta
279+
end
280+
281+
def reasoning_block
282+
@reasoning_block ||= { text: +'', signature: nil, redacted: nil }
283+
end
284+
285+
def accumulate_reasoning_fragment(fragment)
286+
reasoning_text = fragment['reasoningText'] || {}
287+
reasoning_block[:text] << reasoning_text['text'] if reasoning_text['text'].is_a?(String)
288+
reasoning_block[:signature] ||= reasoning_text['signature']
289+
reasoning_block[:redacted] ||= fragment['redactedContent']
290+
end
291+
292+
def finalize_reasoning_block
293+
block = @reasoning_block
294+
@reasoning_block = nil
295+
return nil unless block
296+
return [{ 'reasoningContent' => { 'redactedContent' => block[:redacted] } }] if block[:redacted]
297+
298+
text = block[:text].empty? ? nil : block[:text]
299+
return nil if text.nil? && block[:signature].nil?
300+
301+
[{ 'reasoningContent' => { 'reasoningText' => { 'text' => text,
302+
'signature' => block[:signature] }.compact } }]
303+
end
304+
255305
def extract_tool_calls(event)
256306
return extract_tool_call_start(event) if tool_call_start_event?(event)
257307
return extract_tool_call_delta(event) if tool_call_delta_event?(event)

spec/ruby_llm/protocols/converse/streaming_spec.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,85 @@
9898
expect(message.thinking.text).to eq('thinking text')
9999
expect(message.thinking.signature).to eq('thinking-signature')
100100
end
101+
102+
describe 'reasoning block replay (DUX-7838)' do
103+
# Anthropic requires every thinking/redacted_thinking block from a turn to be replayed
104+
# unmodified on the next request. Converse::Chat (non-streaming) already collects these
105+
# into thinking.blocks; build_chunk must do the same by reassembling each block from its
106+
# contentBlockStart/Delta/Stop events, or the accumulator has nothing to collect and the
107+
# next request gets rejected with "Invalid data in redacted_thinking block".
108+
109+
def stop_event(index: 0)
110+
{ 'contentBlockStop' => { 'contentBlockIndex' => index } }
111+
end
112+
113+
def start_event(index: 0, reasoning_content: {})
114+
{ 'contentBlockStart' => { 'contentBlockIndex' => index,
115+
'start' => { 'reasoningContent' => reasoning_content } } }
116+
end
117+
118+
def reasoning_delta_event(index: 0, text: nil, signature: nil)
119+
reasoning_text = { 'text' => text, 'signature' => signature }.compact
120+
delta = { 'reasoningContent' => { 'reasoningText' => reasoning_text } }
121+
{ 'contentBlockDelta' => { 'contentBlockIndex' => index, 'delta' => delta } }
122+
end
123+
124+
def accumulate(events)
125+
accumulator = RubyLLM::StreamAccumulator.new
126+
events.each { |event| accumulator.add(streaming.send(:build_chunk, event)) }
127+
accumulator.to_message(nil)
128+
end
129+
130+
it 'captures a redacted block that arrives fully at contentBlockStart' do
131+
message = accumulate([
132+
start_event(reasoning_content: { 'redactedContent' => 'opaque-blob-1' }),
133+
stop_event
134+
])
135+
136+
expect(message.thinking.blocks).to eq([{ 'reasoningContent' => { 'redactedContent' => 'opaque-blob-1' } }])
137+
end
138+
139+
it 'assembles a normal reasoning block streamed as incremental text+signature deltas' do
140+
message = accumulate([
141+
reasoning_delta_event(text: 'step one. '),
142+
reasoning_delta_event(text: 'step two.'),
143+
reasoning_delta_event(signature: 'sig-1'),
144+
stop_event
145+
])
146+
147+
expect(message.thinking.blocks).to eq(
148+
[{ 'reasoningContent' => { 'reasoningText' => { 'text' => 'step one. step two.', 'signature' => 'sig-1' } } }]
149+
)
150+
expect(message.thinking.text).to eq('step one. step two.')
151+
end
152+
153+
it 'preserves block order across a redacted block followed by a normal reasoning block' do
154+
message = accumulate([
155+
start_event(index: 0, reasoning_content: { 'redactedContent' => 'opaque-blob-1' }),
156+
stop_event(index: 0),
157+
start_event(index: 1),
158+
reasoning_delta_event(index: 1, text: 'step two', signature: 'sig-2'),
159+
stop_event(index: 1)
160+
])
161+
162+
expect(message.thinking.blocks).to eq(
163+
[
164+
{ 'reasoningContent' => { 'redactedContent' => 'opaque-blob-1' } },
165+
{ 'reasoningContent' => { 'reasoningText' => { 'text' => 'step two', 'signature' => 'sig-2' } } }
166+
]
167+
)
168+
end
169+
170+
it 'leaves a stream with no reasoning content unaffected' do
171+
text_start = { 'contentBlockStart' => { 'contentBlockIndex' => 0, 'start' => {} } }
172+
text_delta = { 'contentBlockDelta' => { 'contentBlockIndex' => 0, 'delta' => { 'text' => 'Hello' } } }
173+
174+
accumulator = RubyLLM::StreamAccumulator.new
175+
[text_start, text_delta, stop_event].each { |event| accumulator.add(streaming.send(:build_chunk, event)) }
176+
message = accumulator.to_message(nil)
177+
178+
expect(message.thinking).to be_nil
179+
expect(message.content).to eq('Hello')
180+
end
181+
end
101182
end

0 commit comments

Comments
 (0)