Skip to content

Commit 4073d3d

Browse files
ranst91tylerslaton
authored andcommitted
fix: deduplicate ToolMessages and tighten adjacent-only validation for Bedrock Converse API
1 parent 7a78f3f commit 4073d3d

1 file changed

Lines changed: 83 additions & 10 deletions

File tree

sdk-python/copilotkit/copilotkit_lg_middleware.py

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)