@@ -130,6 +130,64 @@ def get_content_from_message(message: dict) -> str | None:
130130 return None
131131
132132
133+ def reconcile_tool_pairs (messages : list [dict ]) -> list [dict ]:
134+ """Drop unpaired tool_use / tool_result from a reconstructed conversation.
135+
136+ Stored output can be incomplete — a tool result may be missing (e.g. the
137+ knowledge base was updated mid-chat, or the call was interrupted), or a
138+ tool call may be missing while its result survived. Strict providers
139+ (Anthropic, AWS Bedrock Converse) reject either direction of mismatch.
140+
141+ Well-formed output is unaffected: every id pairs, so nothing is stripped.
142+ """
143+ completed_tool_call_ids = {
144+ message ['tool_call_id' ]
145+ for message in messages
146+ if message .get ('role' ) == 'tool' and message .get ('tool_call_id' )
147+ }
148+ requested_tool_call_ids = {
149+ tool_call ['id' ]
150+ for message in messages
151+ for tool_call in message .get ('tool_calls' ) or ()
152+ if message .get ('role' ) == 'assistant' and tool_call .get ('id' )
153+ }
154+
155+ reconciled_messages = []
156+ for message in messages :
157+ role = message .get ('role' )
158+
159+ # Orphan tool result — no assistant ever claimed this call_id.
160+ if role == 'tool' and message .get ('tool_call_id' ) not in requested_tool_call_ids :
161+ continue
162+
163+ # Non-assistant or no tool_calls — pass through unchanged.
164+ if role != 'assistant' or not message .get ('tool_calls' ):
165+ reconciled_messages .append (message )
166+ continue
167+
168+ # Keep only tool_calls whose id received a tool-role response.
169+ valid_tool_calls = [
170+ tool_call
171+ for tool_call in message ['tool_calls' ]
172+ if tool_call .get ('id' ) in completed_tool_call_ids
173+ ]
174+
175+ if valid_tool_calls :
176+ reconciled_messages .append ({** message , 'tool_calls' : valid_tool_calls })
177+ continue
178+
179+ # All tool_calls were orphans — keep the message only if it
180+ # carries meaningful text or reasoning content.
181+ content = message .get ('content' , '' )
182+ has_meaningful_content = content .strip () if isinstance (content , str ) else content
183+ if has_meaningful_content or message .get ('reasoning_content' ):
184+ reconciled_messages .append (
185+ {key : value for key , value in message .items () if key != 'tool_calls' }
186+ )
187+
188+ return reconciled_messages
189+
190+
133191def convert_output_to_messages (
134192 output : list ,
135193 raw : bool = False ,
@@ -296,7 +354,7 @@ def flush_pending():
296354 # Flush remaining content/tool_calls
297355 flush_pending ()
298356
299- return messages
357+ return reconcile_tool_pairs ( messages )
300358
301359
302360def get_last_user_message (messages : list [dict ]) -> str | None :
0 commit comments