@@ -65,20 +65,65 @@ def wrap_model_call(
6565 def _fix_messages_for_bedrock (messages : list ) -> list :
6666 """Fix messages loaded from checkpoint before sending to Bedrock.
6767
68- Handles three issues caused by CopilotKit's after_agent restoring
68+ Handles four issues caused by CopilotKit's after_agent restoring
6969 frontend tool_calls to the checkpoint:
7070 1. Strip unanswered tool_calls (no matching ToolMessage) — Bedrock
7171 rejects toolUse without a corresponding toolResult.
7272 2. Sync msg.content tool_use blocks with msg.tool_calls.
7373 3. Fix tool_use content blocks with string input (must be dict).
74+ 4. Deduplicate ToolMessages by tool_call_id — patch_orphan_tool_calls
75+ injects a placeholder with a new random ID on every checkpoint load;
76+ when the real result is later appended alongside it, Bedrock rejects
77+ the duplicate toolResult IDs. We keep the real result (non-interrupted)
78+ over the placeholder, falling back to the last occurrence if both look
79+ real.
7480 """
75- # Collect all tool_call_ids that have a ToolMessage answer
76- answered_tc_ids = {
77- m .tool_call_id for m in messages
78- if isinstance (m , ToolMessage ) and hasattr (m , 'tool_call_id' )
79- }
81+ # 4. Deduplicate ToolMessages by tool_call_id before all other processing.
82+ # patch_orphan_tool_calls adds "…was interrupted before completion."
83+ # placeholders with fresh random IDs on every checkpoint load. The real
84+ # result comes in as a separate message with a different ID, so both end
85+ # up in the list. Keep the real (non-interrupted) one; if multiple real
86+ # ones exist, keep the last.
87+ _INTERRUPTED_PAT = re .compile (
88+ r"^Tool call '.+' with id '.+' was interrupted before completion\.$"
89+ )
90+ # Group ToolMessages by tool_call_id, preserving position
91+ tc_groups : dict [str , list ] = {}
92+ for i , msg in enumerate (messages ):
93+ if isinstance (msg , ToolMessage ):
94+ tc_id = getattr (msg , 'tool_call_id' , None )
95+ if tc_id :
96+ tc_groups .setdefault (tc_id , []).append (i )
97+
98+ drop_indices : set = set ()
99+ for tc_id , indices in tc_groups .items ():
100+ if len (indices ) <= 1 :
101+ continue
102+ # Separate interrupted placeholders from real results
103+ real_indices = [
104+ i for i in indices
105+ if not (isinstance (messages [i ].content , str )
106+ and _INTERRUPTED_PAT .match (messages [i ].content ))
107+ ]
108+ interrupted_indices = [i for i in indices if i not in real_indices ]
109+ if real_indices and interrupted_indices :
110+ # Replace the first placeholder (correct position, adjacent to AI
111+ # message) with the last real result (likely appended at the end).
112+ # This keeps the tool result in the right position for Bedrock.
113+ messages [interrupted_indices [0 ]] = messages [real_indices [- 1 ]]
114+ drop_indices .update (interrupted_indices [1 :])
115+ drop_indices .update (real_indices ) # drop all originals (we moved one)
116+ elif real_indices :
117+ # No placeholders, multiple real — keep only the last
118+ drop_indices .update (real_indices [:- 1 ])
119+ else :
120+ # All interrupted — keep only the last
121+ drop_indices .update (interrupted_indices [:- 1 ])
80122
81- for msg in messages :
123+ if drop_indices :
124+ messages [:] = [msg for i , msg in enumerate (messages ) if i not in drop_indices ]
125+
126+ for idx , msg in enumerate (messages ):
82127 if not isinstance (msg , AIMessage ):
83128 continue
84129
@@ -106,11 +151,23 @@ def _fix_messages_for_bedrock(messages: list) -> list:
106151 if not tool_calls :
107152 continue
108153
109- # 2. Strip unanswered tool_calls (no matching ToolMessage)
110- unanswered = [tc for tc in tool_calls if tc .get ('id' ) not in answered_tc_ids ]
154+ # 2. Strip unanswered tool_calls — only consider ToolMessages that
155+ # are ADJACENT (immediately following this AIMessage, before the
156+ # next non-ToolMessage). A ToolMessage at the wrong position
157+ # won't satisfy Bedrock's Converse API requirement that toolResult
158+ # blocks appear in the user turn right after the assistant turn.
159+ adjacent_tc_ids : set = set ()
160+ j = idx + 1
161+ while j < len (messages ) and isinstance (messages [j ], ToolMessage ):
162+ tc_id = getattr (messages [j ], 'tool_call_id' , None )
163+ if tc_id :
164+ adjacent_tc_ids .add (tc_id )
165+ j += 1
166+
167+ unanswered = [tc for tc in tool_calls if tc .get ('id' ) not in adjacent_tc_ids ]
111168 if unanswered :
112169 unanswered_ids = {tc ['id' ] for tc in unanswered }
113- msg .tool_calls = [tc for tc in tool_calls if tc .get ('id' ) in answered_tc_ids ]
170+ msg .tool_calls = [tc for tc in tool_calls if tc .get ('id' ) in adjacent_tc_ids ]
114171
115172 # Also strip matching content blocks
116173 if isinstance (msg .content , list ):
@@ -142,6 +199,22 @@ def _fix_messages_for_bedrock(messages: list) -> list:
142199 elif inp is None :
143200 block ['input' ] = {}
144201
202+ # 5. Remove orphan ToolMessages whose tool_call_id no longer matches
203+ # any remaining tool_call in any AIMessage. These can be left over
204+ # after stripping unanswered tool_calls above.
205+ remaining_tc_ids : set = set ()
206+ for msg in messages :
207+ if isinstance (msg , AIMessage ):
208+ for tc in (getattr (msg , 'tool_calls' , None ) or []):
209+ tc_id = tc .get ('id' )
210+ if tc_id :
211+ remaining_tc_ids .add (tc_id )
212+ messages [:] = [
213+ msg for msg in messages
214+ if not isinstance (msg , ToolMessage )
215+ or getattr (msg , 'tool_call_id' , None ) in remaining_tc_ids
216+ ]
217+
145218 return messages
146219
147220 async def awrap_model_call (
0 commit comments