|
16 | 16 | process the result. |
17 | 17 |
|
18 | 18 | 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. |
19 | 33 | """ |
20 | 34 |
|
21 | 35 | from __future__ import annotations |
22 | 36 |
|
| 37 | +import json |
23 | 38 | import os |
| 39 | +import re |
| 40 | +import uuid |
| 41 | +from typing import Any, Dict, List, Optional, Union |
24 | 42 |
|
| 43 | +from llama_index.core.llms import ChatMessage, ChatResponse, MessageRole, TextBlock |
25 | 44 | 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 |
26 | 47 | 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 | +) |
27 | 61 | 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 | +) |
28 | 67 |
|
29 | 68 | _openai_kwargs = {} |
30 | 69 | if os.environ.get("OPENAI_BASE_URL"): |
@@ -54,15 +93,232 @@ def _book_call_stub(topic: str, attendee: str) -> str: |
54 | 93 | ) |
55 | 94 |
|
56 | 95 |
|
| 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 | + |
57 | 322 | 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, |
68 | 324 | ) |
0 commit comments