Skip to content

Commit 0d4cdc6

Browse files
committed
fix(showcase): fix llamaindex hitl-text-input D5 test failure
The LlamaIndex AGUIChatWorkflow has three bugs that caused the hitl-text-input test to fail: 1. ToolCallChunkWorkflowEvent emitted without parent_message_id, causing the AG-UI client to create a duplicate assistant message (one from MESSAGES_SNAPSHOT, one from dechunked TOOL_CALL_START). The duplicate produced 8 time-picker buttons instead of 4. 2. _snapshot_messages embeds toolCalls in the MESSAGES_SNAPSHOT AND emits separate TOOL_CALL_CHUNK events, so the client registers the same tool call twice on the same message. 3. ag_ui_message_to_llama_index_message converts AG-UI ToolMessages to ChatMessage(role='user') instead of role='tool', so the second- leg OpenAI API call has no tool-result message and aimock's hasToolResult matcher fails. Fix: subclass AGUIChatWorkflow (FixedAGUIChatWorkflow) that: - Assigns a stable ID to assistant messages and passes it as parent_message_id on ToolCallChunk events - Strips toolCalls from MESSAGES_SNAPSHOT (letting TOOL_CALL_CHUNK be the sole mechanism) - Corrects tool-result message role from 'user' to MessageRole.TOOL so the OpenAI API receives proper tool-result messages
1 parent 2781b7d commit 0d4cdc6

1 file changed

Lines changed: 266 additions & 10 deletions

File tree

showcase/integrations/llamaindex/src/agents/hitl_in_chat_agent.py

Lines changed: 266 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,54 @@
1616
process the result.
1717
1818
Mirrors `langgraph-python/src/agents/hitl_in_chat_agent.py`.
19+
20+
NOTE: We subclass AGUIChatWorkflow to fix three upstream library bugs:
21+
22+
1. ToolCallChunkWorkflowEvent is emitted without parent_message_id,
23+
causing the client to create a duplicate assistant message.
24+
25+
2. _snapshot_messages embeds toolCalls in the MESSAGES_SNAPSHOT AND
26+
emits separate TOOL_CALL_CHUNK events for the same calls, so the
27+
client ends up with the tool call registered twice.
28+
29+
3. ag_ui_message_to_llama_index_message converts AG-UI ToolMessages to
30+
ChatMessage(role="user") instead of ChatMessage(role="tool"), so the
31+
OpenAI API call doesn't include a proper tool-result message and
32+
aimock's hasToolResult matcher fails on the second leg.
1933
"""
2034

2135
from __future__ import annotations
2236

37+
import json
2338
import os
39+
import re
40+
import uuid
41+
from typing import Any, Dict, List, Optional, Union
2442

43+
from llama_index.core.llms import ChatMessage, ChatResponse, MessageRole, TextBlock
2544
from llama_index.core.tools import FunctionTool
45+
from llama_index.core.workflow import Context, step
46+
from llama_index.core.workflow.events import StopEvent
2647
from llama_index.llms.openai import OpenAI
48+
from llama_index.protocols.ag_ui.agent import (
49+
AGUIChatWorkflow,
50+
DEFAULT_STATE_PROMPT,
51+
InputEvent,
52+
LoopEvent,
53+
ToolCallEvent,
54+
)
55+
from llama_index.protocols.ag_ui.events import (
56+
MessagesSnapshotWorkflowEvent,
57+
StateSnapshotWorkflowEvent,
58+
TextMessageChunkWorkflowEvent,
59+
ToolCallChunkWorkflowEvent,
60+
)
2761
from llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router
62+
from llama_index.protocols.ag_ui.utils import (
63+
ag_ui_message_to_llama_index_message,
64+
llama_index_message_to_ag_ui_message,
65+
timestamp,
66+
)
2867

2968
_openai_kwargs = {}
3069
if os.environ.get("OPENAI_BASE_URL"):
@@ -54,15 +93,232 @@ def _book_call_stub(topic: str, attendee: str) -> str:
5493
)
5594

5695

96+
def _fix_tool_messages(chat_history: List[ChatMessage]) -> None:
97+
"""Fix tool-result messages that the upstream library incorrectly
98+
converts to role='user'.
99+
100+
The library's ag_ui_message_to_llama_index_message converts AG-UI
101+
ToolMessages to ChatMessage(role='user') because llama-index-core
102+
didn't originally support role='tool'. Modern versions DO support
103+
it (MessageRole.TOOL), and the OpenAI SDK needs role='tool' to send
104+
a proper tool-result message. Without this fix, the OpenAI API
105+
call has no tool-result message and aimock's hasToolResult matcher
106+
fails on the second leg.
107+
"""
108+
for msg in chat_history:
109+
if (
110+
msg.role.value == "user"
111+
and "tool_call_id" in msg.additional_kwargs
112+
):
113+
msg.role = MessageRole.TOOL
114+
115+
116+
class FixedAGUIChatWorkflow(AGUIChatWorkflow):
117+
"""AGUIChatWorkflow that fixes duplicate tool-call rendering and
118+
tool-result message formatting.
119+
120+
See module docstring for the three upstream bugs this addresses.
121+
"""
122+
123+
def _snapshot_messages(
124+
self, ctx: Context, chat_history: List[ChatMessage]
125+
) -> None:
126+
"""Emit MESSAGES_SNAPSHOT without toolCalls on assistant messages.
127+
128+
We create clean copies of assistant messages that strip both
129+
ag_ui_tool_calls metadata AND the <tool_call> XML tags that the
130+
upstream _snapshot_messages would re-extract. This ensures the
131+
MESSAGES_SNAPSHOT contains no toolCalls — they arrive exclusively
132+
via TOOL_CALL_CHUNK events.
133+
"""
134+
cleaned = []
135+
for msg in chat_history:
136+
if msg.role == "assistant":
137+
content = msg.content or ""
138+
content = re.sub(
139+
r"<tool_call>[\s\S]*?</tool_call>", "", content
140+
).strip()
141+
142+
clone = ChatMessage(
143+
role=msg.role,
144+
content=content if content else None,
145+
additional_kwargs={
146+
k: v
147+
for k, v in msg.additional_kwargs.items()
148+
if k != "ag_ui_tool_calls"
149+
},
150+
)
151+
cleaned.append(clone)
152+
else:
153+
cleaned.append(msg)
154+
155+
ag_ui_messages = [
156+
llama_index_message_to_ag_ui_message(m) for m in cleaned
157+
]
158+
159+
ctx.write_event_to_stream(
160+
MessagesSnapshotWorkflowEvent(
161+
timestamp=timestamp(),
162+
messages=ag_ui_messages,
163+
)
164+
)
165+
166+
@step
167+
async def chat(
168+
self, ctx: Context, ev: InputEvent | LoopEvent
169+
) -> Optional[Union[StopEvent, ToolCallEvent]]:
170+
# ------------------------------------------------------------------
171+
# Duplicated from AGUIChatWorkflow.chat with three changes:
172+
# 1. Assign a stable `id` to the assistant response message
173+
# 2. Pass `parent_message_id` on ToolCallChunkWorkflowEvent
174+
# 3. Fix tool-result messages to use role='tool' not 'user'
175+
# ------------------------------------------------------------------
176+
if isinstance(ev, InputEvent):
177+
ag_ui_messages = ev.input_data.messages
178+
chat_history = [
179+
ag_ui_message_to_llama_index_message(m) for m in ag_ui_messages
180+
]
181+
182+
# FIX 3: convert incorrectly-roled tool messages
183+
_fix_tool_messages(chat_history)
184+
185+
state = ev.input_data.state
186+
if isinstance(state, dict):
187+
state.pop("messages", None)
188+
elif isinstance(state, str):
189+
state = json.loads(state)
190+
state.pop("messages", None)
191+
else:
192+
state = self.initial_state.copy()
193+
194+
await ctx.store.set("state", state)
195+
ctx.write_event_to_stream(StateSnapshotWorkflowEvent(snapshot=state))
196+
197+
if state:
198+
for msg in chat_history[::-1]:
199+
if msg.role.value == "user":
200+
msg.content = DEFAULT_STATE_PROMPT.format(
201+
state=str(state), user_input=msg.content
202+
)
203+
break
204+
205+
if self.system_prompt:
206+
if chat_history[0].role.value == "system":
207+
chat_history[0].blocks.append(TextBlock(text=self.system_prompt))
208+
else:
209+
chat_history.insert(
210+
0, ChatMessage(role="system", content=self.system_prompt)
211+
)
212+
213+
await ctx.store.set("chat_history", chat_history)
214+
else:
215+
chat_history = await ctx.store.get("chat_history")
216+
217+
tools = list(self.frontend_tools.values())
218+
tools.extend(list(self.backend_tools.values()))
219+
220+
resp_gen = await self.llm.astream_chat_with_tools(
221+
tools=tools,
222+
chat_history=chat_history,
223+
allow_parallel_tool_calls=True,
224+
)
225+
226+
resp_id = str(uuid.uuid4())
227+
resp = ChatResponse(message=ChatMessage(role="assistant", content=""))
228+
229+
async for resp in resp_gen:
230+
if resp.delta:
231+
ctx.write_event_to_stream(
232+
TextMessageChunkWorkflowEvent(
233+
role="assistant",
234+
delta=resp.delta,
235+
timestamp=timestamp(),
236+
message_id=resp_id,
237+
)
238+
)
239+
240+
# FIX 1: Assign a stable ID to the assistant message so
241+
# MESSAGES_SNAPSHOT and TOOL_CALL events reference the same message.
242+
resp.message.additional_kwargs["id"] = resp_id
243+
244+
chat_history.append(resp.message)
245+
self._snapshot_messages(ctx, [*chat_history])
246+
await ctx.store.set("chat_history", chat_history)
247+
248+
tool_calls = self.llm.get_tool_calls_from_response(
249+
resp, error_on_no_tool_call=False
250+
)
251+
if tool_calls:
252+
await ctx.store.set("num_tool_calls", len(tool_calls))
253+
frontend_tool_calls = [
254+
tool_call
255+
for tool_call in tool_calls
256+
if tool_call.tool_name in self.frontend_tools
257+
]
258+
backend_tool_calls = [
259+
tool_call
260+
for tool_call in tool_calls
261+
if tool_call.tool_name in self.backend_tools
262+
]
263+
264+
for tool_call in backend_tool_calls:
265+
ctx.send_event(
266+
ToolCallEvent(
267+
tool_call_id=tool_call.tool_id,
268+
tool_name=tool_call.tool_name,
269+
tool_kwargs=tool_call.tool_kwargs,
270+
)
271+
)
272+
273+
ctx.write_event_to_stream(
274+
ToolCallChunkWorkflowEvent(
275+
tool_call_id=tool_call.tool_id,
276+
tool_call_name=tool_call.tool_name,
277+
delta=json.dumps(tool_call.tool_kwargs),
278+
# FIX 2: attach to the assistant message
279+
parent_message_id=resp_id,
280+
)
281+
)
282+
283+
for tool_call in frontend_tool_calls:
284+
ctx.send_event(
285+
ToolCallEvent(
286+
tool_call_id=tool_call.tool_id,
287+
tool_name=tool_call.tool_name,
288+
tool_kwargs=tool_call.tool_kwargs,
289+
)
290+
)
291+
292+
ctx.write_event_to_stream(
293+
ToolCallChunkWorkflowEvent(
294+
tool_call_id=tool_call.tool_id,
295+
tool_call_name=tool_call.tool_name,
296+
delta=json.dumps(tool_call.tool_kwargs),
297+
# FIX 2: attach to the assistant message
298+
parent_message_id=resp_id,
299+
)
300+
)
301+
302+
return None
303+
304+
return StopEvent()
305+
306+
307+
async def _workflow_factory():
308+
return FixedAGUIChatWorkflow(
309+
llm=OpenAI(model="gpt-4o-mini", **_openai_kwargs),
310+
frontend_tools=[_book_call_tool],
311+
backend_tools=[],
312+
system_prompt=(
313+
"You help users book an onboarding call with the sales team. "
314+
"When they ask to book a call, call the frontend-provided "
315+
"`book_call` tool with a short topic and the user's name. "
316+
"Keep any chat reply to one short sentence."
317+
),
318+
initial_state={},
319+
)
320+
321+
57322
hitl_in_chat_router = get_ag_ui_workflow_router(
58-
llm=OpenAI(model="gpt-4o-mini", **_openai_kwargs),
59-
frontend_tools=[_book_call_tool],
60-
backend_tools=[],
61-
system_prompt=(
62-
"You help users book an onboarding call with the sales team. "
63-
"When they ask to book a call, call the frontend-provided "
64-
"`book_call` tool with a short topic and the user's name. "
65-
"Keep any chat reply to one short sentence."
66-
),
67-
initial_state={},
323+
workflow_factory=_workflow_factory,
68324
)

0 commit comments

Comments
 (0)