Skip to content

Commit 9a3606d

Browse files
mmeclaude
authored andcommitted
fix(agent): resolve followup message failure after frontend tool calls
Fix Bedrock ValidationException ("Expected toolResult blocks") that occurred on follow-up messages after using shared state (Canvas/Todos). The root cause was that _fix_messages_for_bedrock deduplicated ToolMessages by dropping the placeholder (correct position, adjacent to AI message) and keeping the real result (wrong position, appended at end). Bedrock requires toolResult blocks immediately after their corresponding toolUse. Three fixes in copilotkit_lg_middleware.py: - Dedup now replaces placeholder in-place with real result, preserving position - Unanswered tool_call detection uses adjacency check instead of global lookup - Orphan ToolMessages are cleaned up after stripping tool_calls Also adds app mode UI (Chat/App toggle), error handling in agent entrypoint, and None-event filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b74dab0 commit 9a3606d

File tree

4 files changed

+133
-24
lines changed

4 files changed

+133
-24
lines changed

copilotkit/copilotkit_lg_middleware.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,24 +107,24 @@ def _fix_messages_for_bedrock(messages: list) -> list:
107107
and _INTERRUPTED_PAT.match(messages[i].content))
108108
]
109109
interrupted_indices = [i for i in indices if i not in real_indices]
110-
if real_indices:
111-
# Drop all interrupted placeholders; keep real (last wins if >1)
112-
drop_indices.update(interrupted_indices)
113-
drop_indices.update(real_indices[:-1]) # keep only last real
110+
if real_indices and interrupted_indices:
111+
# Replace the first placeholder (correct position, adjacent to AI
112+
# message) with the last real result (likely appended at the end).
113+
# This keeps the tool result in the right position for Bedrock.
114+
messages[interrupted_indices[0]] = messages[real_indices[-1]]
115+
drop_indices.update(interrupted_indices[1:])
116+
drop_indices.update(real_indices) # drop all originals (we moved one)
117+
elif real_indices:
118+
# No placeholders, multiple real — keep only the last
119+
drop_indices.update(real_indices[:-1])
114120
else:
115121
# All interrupted — keep only the last
116122
drop_indices.update(interrupted_indices[:-1])
117123

118124
if drop_indices:
119125
messages[:] = [msg for i, msg in enumerate(messages) if i not in drop_indices]
120126

121-
# Collect all tool_call_ids that have a ToolMessage answer
122-
answered_tc_ids = {
123-
m.tool_call_id for m in messages
124-
if isinstance(m, ToolMessage) and hasattr(m, 'tool_call_id')
125-
}
126-
127-
for msg in messages:
127+
for idx, msg in enumerate(messages):
128128
if not isinstance(msg, AIMessage):
129129
continue
130130

@@ -152,11 +152,23 @@ def _fix_messages_for_bedrock(messages: list) -> list:
152152
if not tool_calls:
153153
continue
154154

155-
# 2. Strip unanswered tool_calls (no matching ToolMessage)
156-
unanswered = [tc for tc in tool_calls if tc.get('id') not in answered_tc_ids]
155+
# 2. Strip unanswered tool_calls — only consider ToolMessages that
156+
# are ADJACENT (immediately following this AIMessage, before the
157+
# next non-ToolMessage). A ToolMessage at the wrong position
158+
# won't satisfy Bedrock's Converse API requirement that toolResult
159+
# blocks appear in the user turn right after the assistant turn.
160+
adjacent_tc_ids: set = set()
161+
j = idx + 1
162+
while j < len(messages) and isinstance(messages[j], ToolMessage):
163+
tc_id = getattr(messages[j], 'tool_call_id', None)
164+
if tc_id:
165+
adjacent_tc_ids.add(tc_id)
166+
j += 1
167+
168+
unanswered = [tc for tc in tool_calls if tc.get('id') not in adjacent_tc_ids]
157169
if unanswered:
158170
unanswered_ids = {tc['id'] for tc in unanswered}
159-
msg.tool_calls = [tc for tc in tool_calls if tc.get('id') in answered_tc_ids]
171+
msg.tool_calls = [tc for tc in tool_calls if tc.get('id') in adjacent_tc_ids]
160172

161173
# Also strip matching content blocks
162174
if isinstance(msg.content, list):
@@ -188,6 +200,22 @@ def _fix_messages_for_bedrock(messages: list) -> list:
188200
elif inp is None:
189201
block['input'] = {}
190202

203+
# 5. Remove orphan ToolMessages whose tool_call_id no longer matches
204+
# any remaining tool_call in any AIMessage. These can be left over
205+
# after stripping unanswered tool_calls above.
206+
remaining_tc_ids: set = set()
207+
for msg in messages:
208+
if isinstance(msg, AIMessage):
209+
for tc in (getattr(msg, 'tool_calls', None) or []):
210+
tc_id = tc.get('id')
211+
if tc_id:
212+
remaining_tc_ids.add(tc_id)
213+
messages[:] = [
214+
msg for msg in messages
215+
if not isinstance(msg, ToolMessage)
216+
or getattr(msg, 'tool_call_id', None) in remaining_tc_ids
217+
]
218+
191219
return messages
192220

193221
async def awrap_model_call(

frontend/src/components/chat/CopilotChatInterface.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,61 @@
22
"use client"
33

44
import { useEffect, useState } from "react"
5-
import { CopilotChat, CopilotKitProvider } from "@copilotkit/react-core/v2"
5+
import { CopilotChat, CopilotKitProvider, useFrontendTool } from "@copilotkit/react-core/v2"
66
import { useAuth as useOidcAuth } from "react-oidc-context"
77
import { loadAwsConfig, type AwsExportsConfig } from "@/lib/runtime-config"
88
import { useExampleSuggestions } from "@/hooks/useExampleSuggestions"
99
import { useCopilotExamples } from "@/hooks/useCopilotExamples"
1010
import { ThemeProvider } from "@/hooks/useTheme"
1111
import { TodoCanvas } from "@/components/canvas/TodoCanvas"
12+
import { ModeToggle } from "@/components/ui/mode-toggle"
1213

1314
const COPILOTKIT_AGENT_ID = "default"
1415

1516
function CopilotChatContent() {
17+
const [mode, setMode] = useState<"chat" | "app">("chat")
18+
1619
useExampleSuggestions()
1720
useCopilotExamples()
1821

22+
useFrontendTool({
23+
name: "enableAppMode",
24+
description: "Enable app mode when working with the todo canvas.",
25+
handler: async () => {
26+
setMode("app")
27+
},
28+
})
29+
30+
useFrontendTool({
31+
name: "enableChatMode",
32+
description: "Enable chat mode",
33+
handler: async () => {
34+
setMode("chat")
35+
},
36+
})
37+
1938
return (
20-
<div className="h-full flex">
21-
{/* Chat pane — takes remaining width */}
22-
<div className="flex-1 min-w-0 [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none">
39+
<div className="h-full flex flex-row">
40+
<ModeToggle mode={mode} onModeChange={setMode} />
41+
<div
42+
className={`max-h-full overflow-y-auto [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none ${
43+
mode === "app"
44+
? "w-1/3 px-6 max-lg:hidden"
45+
: "flex-1 px-4 lg:px-6"
46+
}`}
47+
>
2348
<CopilotChat agentId={COPILOTKIT_AGENT_ID} className="h-full" />
2449
</div>
25-
{/* Canvas pane — always visible, shows empty state when no todos exist */}
26-
<div className="w-2/5 border-l border-gray-200 dark:border-zinc-700">
27-
<TodoCanvas />
50+
<div
51+
className={`h-full overflow-hidden ${
52+
mode === "app"
53+
? "w-2/3 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
54+
: "w-0 border-l-0"
55+
}`}
56+
>
57+
<div className="h-full w-full lg:w-[66.666vw]">
58+
<TodoCanvas />
59+
</div>
2860
</div>
2961
</div>
3062
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
interface ModeToggleProps {
2+
mode: "chat" | "app"
3+
onModeChange: (mode: "chat" | "app") => void
4+
}
5+
6+
export function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
7+
return (
8+
<div className="fixed top-4 right-4 z-50 flex bg-gray-100 dark:bg-zinc-800 rounded-lg p-1 shadow-sm max-lg:top-2 max-lg:right-2 max-lg:scale-90">
9+
<button
10+
onClick={() => onModeChange("chat")}
11+
className={`
12+
px-4 py-2 rounded-md text-sm font-medium transition-all max-lg:px-3 max-lg:py-1.5 max-lg:text-xs
13+
cursor-pointer
14+
${
15+
mode === "chat"
16+
? "bg-white dark:bg-zinc-700 text-gray-900 dark:text-white shadow-sm"
17+
: "text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-white"
18+
}
19+
`}
20+
>
21+
Chat
22+
</button>
23+
<button
24+
onClick={() => onModeChange("app")}
25+
className={`
26+
px-4 py-2 rounded-md text-sm font-medium transition-all max-lg:px-3 max-lg:py-1.5 max-lg:text-xs
27+
cursor-pointer
28+
${
29+
mode === "app"
30+
? "bg-white dark:bg-zinc-700 text-gray-900 dark:text-white shadow-sm"
31+
: "text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-white"
32+
}
33+
`}
34+
>
35+
App Mode
36+
</button>
37+
</div>
38+
)
39+
}

patterns/langgraph-single-agent/langgraph_agent.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import json
88
import os
99
import traceback
10-
from ag_ui.core import RunAgentInput
10+
import logging
11+
12+
from ag_ui.core import RunAgentInput, RunErrorEvent, RunFinishedEvent
1113
from bedrock_agentcore.identity.auth import requires_access_token
1214
from bedrock_agentcore.runtime import BedrockAgentCoreApp, RequestContext
1315
from copilotkit import CopilotKitMiddleware, LangGraphAGUIAgent
@@ -184,8 +186,16 @@ async def invocations(payload: dict, context: RequestContext):
184186
config={"configurable": {"actor_id": actor_id}},
185187
)
186188

187-
async for event in request_agent.run(input_data):
188-
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
189+
try:
190+
async for event in request_agent.run(input_data):
191+
if event is not None:
192+
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
193+
except Exception as exc:
194+
logging.exception("Agent run failed")
195+
yield RunErrorEvent(
196+
message=str(exc) or type(exc).__name__,
197+
code=type(exc).__name__,
198+
).model_dump(mode="json", by_alias=True, exclude_none=True)
189199

190200

191201
if __name__ == "__main__":

0 commit comments

Comments
 (0)