55import com .agui .core .agent .RunAgentInput ;
66import com .agui .core .event .BaseEvent ;
77import com .agui .core .exception .AGUIException ;
8+ import com .agui .core .function .FunctionCall ;
89import com .agui .core .message .AssistantMessage ;
10+ import com .agui .core .message .BaseMessage ;
911import com .agui .core .message .Role ;
12+ import com .agui .core .message .ToolMessage ;
1013import com .agui .core .state .State ;
14+ import com .agui .core .tool .Tool ;
15+ import com .agui .core .tool .ToolCall ;
1116import com .agui .server .LocalAgent ;
1217import org .springframework .ai .chat .client .ChatClient ;
1318import org .springframework .ai .chat .client .advisor .PromptChatMemoryAdvisor ;
1419import org .springframework .ai .chat .memory .ChatMemory ;
20+ import org .springframework .ai .chat .messages .Message ;
21+ import org .springframework .ai .chat .messages .ToolResponseMessage ;
1522import org .springframework .ai .chat .model .ChatModel ;
1623import org .springframework .ai .chat .model .ChatResponse ;
1724import org .springframework .ai .chat .prompt .Prompt ;
2229import org .slf4j .LoggerFactory ;
2330
2431import java .util .ArrayList ;
32+ import java .util .HashSet ;
2533import java .util .List ;
34+ import java .util .Set ;
2635import java .util .UUID ;
36+ import java .util .concurrent .CopyOnWriteArrayList ;
2737import java .util .concurrent .CountDownLatch ;
2838import java .util .concurrent .TimeUnit ;
29- import java .util .concurrent .atomic .AtomicBoolean ;
3039import java .util .concurrent .atomic .AtomicReference ;
3140
3241import static com .agui .server .EventFactory .runErrorEvent ;
@@ -121,17 +130,57 @@ protected void run(RunAgentInput input, AgentSubscriber subscriber) {
121130 try {
122131 // Phase 1: Stream WITHOUT tool callbacks to detect whether tools
123132 // are needed. Text chunks are emitted in real time.
124- boolean toolCallsDetected = streamFirstTurn (
133+ List < DetectedToolCall > detectedToolCalls = streamFirstTurn (
125134 input , userContent , messageId , assistantMessage , subscriber );
126135
127- if (toolCallsDetected ) {
128- // Phase 2: Tools needed. Discard the streamed text (it was
129- // just the model's tool-call request, not a final answer).
130- // Re-invoke with .call() + tool callbacks so Spring AI's
131- // internal loop handles execution.
132- assistantMessage .setContent ("" );
133- callWithTools (input , userContent , messageId ,
134- assistantMessage , deferredEvents , subscriber );
136+ if (!detectedToolCalls .isEmpty ()) {
137+ // Classify tool calls as frontend vs backend.
138+ Set <String > backendToolNames = getBackendToolNames ();
139+ Set <String > frontendToolNames = getFrontendToolNames (input );
140+
141+ boolean hasFrontendToolCalls = detectedToolCalls .stream ()
142+ .anyMatch (tc -> !backendToolNames .contains (tc .name ())
143+ && frontendToolNames .contains (tc .name ()));
144+ boolean hasBackendToolCalls = detectedToolCalls .stream ()
145+ .anyMatch (tc -> backendToolNames .contains (tc .name ()));
146+
147+ if (hasFrontendToolCalls && !hasBackendToolCalls ) {
148+ // All tool calls are frontend tools (HITL, useFrontendTool).
149+ // Emit TOOL_CALL_START/ARGS/END events WITHOUT TOOL_CALL_RESULT
150+ // so the CopilotKit runtime's processAgentResult detects the
151+ // missing result and executes the frontend tool handler.
152+ // The runtime will then re-invoke the agent with the tool result.
153+ for (DetectedToolCall dtc : detectedToolCalls ) {
154+ String toolCallId = dtc .id () != null ? dtc .id ()
155+ : UUID .randomUUID ().toString ();
156+
157+ // AG-UI tool call envelope: start, args, end (NO result)
158+ deferredEvents .add (toolCallStartEvent (messageId , dtc .name (), toolCallId ));
159+ deferredEvents .add (toolCallArgsEvent (
160+ dtc .arguments () != null ? dtc .arguments () : "{}" , toolCallId ));
161+ deferredEvents .add (toolCallEndEvent (toolCallId ));
162+
163+ // Attach to assistant message so the runtime sees it
164+ FunctionCall fc = new FunctionCall (dtc .name (),
165+ dtc .arguments () != null ? dtc .arguments () : "{}" );
166+ ToolCall call = new ToolCall (toolCallId , "function" , fc );
167+ if (assistantMessage .getToolCalls () == null ) {
168+ assistantMessage .setToolCalls (new ArrayList <>());
169+ }
170+ assistantMessage .getToolCalls ().add (call );
171+ subscriber .onNewToolCall (call );
172+ }
173+ // Clear any streamed text (it was the model's tool-call
174+ // request preamble, not a final answer).
175+ assistantMessage .setContent ("" );
176+ } else {
177+ // Backend tools needed (or mixed). Discard the streamed
178+ // text and re-invoke with .call() + tool callbacks so
179+ // Spring AI's internal loop handles execution.
180+ assistantMessage .setContent ("" );
181+ callWithTools (input , userContent , messageId ,
182+ assistantMessage , deferredEvents , subscriber );
183+ }
135184 }
136185 } catch (Exception e ) {
137186 log .error ("Agent run failed" , e );
@@ -154,18 +203,23 @@ protected void run(RunAgentInput input, AgentSubscriber subscriber) {
154203 new AgentSubscriberParams (input .messages (), state , this , input ));
155204 }
156205
206+ /** Captured tool call from the streaming phase. */
207+ private record DetectedToolCall (String id , String name , String arguments ) {}
208+
157209 /**
158210 * Streams the first model turn WITHOUT tool callbacks. Text chunks are
159- * emitted as AG-UI events in real time. Returns true if the model
160- * requested tool calls (meaning we need a follow-up .call()).
211+ * emitted as AG-UI events in real time. Returns a list of detected tool
212+ * calls (empty if none). Each entry captures the tool call id, name, and
213+ * arguments so the caller can decide whether to handle them as frontend
214+ * tools or fall back to Phase 2.
161215 */
162- private boolean streamFirstTurn (
216+ private List < DetectedToolCall > streamFirstTurn (
163217 RunAgentInput input , String userContent , String messageId ,
164218 AssistantMessage assistantMessage , AgentSubscriber subscriber )
165219 throws InterruptedException {
166220
167221 StringBuilder textAccumulator = new StringBuilder ();
168- AtomicBoolean sawToolCalls = new AtomicBoolean ( false );
222+ CopyOnWriteArrayList < DetectedToolCall > detectedToolCalls = new CopyOnWriteArrayList <>( );
169223 AtomicReference <Throwable > streamError = new AtomicReference <>();
170224 CountDownLatch latch = new CountDownLatch (1 );
171225
@@ -181,7 +235,11 @@ private boolean streamFirstTurn(
181235 .subscribe (
182236 evt -> {
183237 if (evt .hasToolCalls ()) {
184- sawToolCalls .set (true );
238+ var tcs = evt .getResult ().getOutput ().getToolCalls ();
239+ for (var tc : tcs ) {
240+ detectedToolCalls .add (new DetectedToolCall (
241+ tc .id (), tc .name (), tc .arguments ()));
242+ }
185243 }
186244 String content = evt .getResult ().getOutput ().getText ();
187245 if (StringUtils .hasText (content )) {
@@ -208,7 +266,32 @@ private boolean streamFirstTurn(
208266 }
209267
210268 assistantMessage .setContent (textAccumulator .toString ());
211- return sawToolCalls .get ();
269+ return new ArrayList <>(detectedToolCalls );
270+ }
271+
272+ /** Returns the set of tool names registered as backend tool callbacks. */
273+ private Set <String > getBackendToolNames () {
274+ Set <String > names = new HashSet <>();
275+ for (ToolCallback cb : toolCallbacks ) {
276+ names .add (cb .getToolDefinition ().name ());
277+ }
278+ return names ;
279+ }
280+
281+ /**
282+ * Returns the set of tool names injected by the CopilotKit runtime
283+ * (frontend tools). These are tools registered on the frontend via
284+ * useHumanInTheLoop, useFrontendTool, etc.
285+ */
286+ private Set <String > getFrontendToolNames (RunAgentInput input ) {
287+ Set <String > names = new HashSet <>();
288+ List <Tool > tools = input .tools ();
289+ if (tools != null ) {
290+ for (Tool tool : tools ) {
291+ names .add (tool .name ());
292+ }
293+ }
294+ return names ;
212295 }
213296
214297 /**
@@ -253,8 +336,15 @@ private void callWithTools(
253336 }
254337
255338 /**
256- * Builds a base ChatClient request with system prompt and memory
257- * but WITHOUT tool callbacks.
339+ * Builds a base ChatClient request with system prompt and the full
340+ * conversation history converted from AG-UI messages to Spring AI messages.
341+ *
342+ * <p>Including the full history (not just the latest user message) is
343+ * essential for multi-turn conversations, especially HITL flows where
344+ * the CopilotKit runtime re-invokes the agent with tool result messages.
345+ * Without the full history, the LLM (or aimock fixture matcher) would
346+ * not see the tool result and would repeat the tool call instead of
347+ * producing a follow-up text response.
258348 *
259349 * @param disableInternalToolExecution when {@code true}, sets
260350 * {@code internalToolExecutionEnabled=false} on the request
@@ -268,9 +358,27 @@ private void callWithTools(
268358 private ChatClient .ChatClientRequestSpec buildBaseRequest (
269359 RunAgentInput input , String userContent ,
270360 boolean disableInternalToolExecution ) {
271- ChatClient .ChatClientRequestSpec request = chatClient .prompt (
272- Prompt .builder ().content (userContent ).build ())
273- .system (systemMessage );
361+
362+ // Check if the INPUT messages (not the persistent singleton messages)
363+ // contain tool results. If so, we need to send the full conversation
364+ // history so aimock (and the LLM) can see the tool result and produce
365+ // a follow-up text response instead of repeating the tool call. This
366+ // is essential for HITL re-invocation where the CopilotKit runtime
367+ // sends back the tool result from the frontend handler.
368+ List <? extends BaseMessage > inputMessages = input .messages ();
369+ boolean hasToolResults = inputMessages != null && inputMessages .stream ()
370+ .anyMatch (m -> m != null && m .getRole () == Role .tool );
371+
372+ ChatClient .ChatClientRequestSpec request ;
373+ if (hasToolResults ) {
374+ List <Message > springMessages = convertMessages (inputMessages );
375+ request = chatClient .prompt (new Prompt (springMessages ))
376+ .system (systemMessage );
377+ } else {
378+ request = chatClient .prompt (
379+ Prompt .builder ().content (userContent ).build ())
380+ .system (systemMessage );
381+ }
274382
275383 if (disableInternalToolExecution ) {
276384 request = request .options (
@@ -288,6 +396,67 @@ private ChatClient.ChatClientRequestSpec buildBaseRequest(
288396 return request ;
289397 }
290398
399+ /**
400+ * Converts AG-UI messages to Spring AI messages. This preserves the full
401+ * conversation history including assistant messages with tool calls and
402+ * tool result messages, which is essential for aimock fixture matching
403+ * (hasToolResult) and for LLMs to understand the conversation context.
404+ */
405+ private List <Message > convertMessages (List <? extends BaseMessage > aguiMessages ) {
406+ List <Message > result = new ArrayList <>();
407+ if (aguiMessages == null ) return result ;
408+
409+ for (BaseMessage msg : aguiMessages ) {
410+ if (msg == null ) continue ;
411+ Role role = msg .getRole ();
412+ if (role == null ) continue ;
413+
414+ switch (role ) {
415+ case user -> {
416+ String content = msg .getContent ();
417+ if (StringUtils .hasText (content )) {
418+ result .add (new org .springframework .ai .chat .messages .UserMessage (content ));
419+ }
420+ }
421+ case assistant -> {
422+ if (msg instanceof AssistantMessage am ) {
423+ List <org .springframework .ai .chat .messages .AssistantMessage .ToolCall > springToolCalls
424+ = new ArrayList <>();
425+ if (am .getToolCalls () != null ) {
426+ for (ToolCall tc : am .getToolCalls ()) {
427+ springToolCalls .add (
428+ new org .springframework .ai .chat .messages .AssistantMessage .ToolCall (
429+ tc .id (),
430+ tc .type () != null ? tc .type () : "function" ,
431+ tc .function () != null ? tc .function ().name () : "" ,
432+ tc .function () != null ? tc .function ().arguments () : "{}" ));
433+ }
434+ }
435+ String content = am .getContent () != null ? am .getContent () : "" ;
436+ result .add (new org .springframework .ai .chat .messages .AssistantMessage (
437+ content , java .util .Map .of (), springToolCalls ));
438+ }
439+ }
440+ case tool -> {
441+ if (msg instanceof ToolMessage tm ) {
442+ String toolCallId = tm .getToolCallId ();
443+ String content = tm .getContent () != null ? tm .getContent () : "" ;
444+ // Spring AI uses ToolResponseMessage with ToolResponse entries
445+ var response = new ToolResponseMessage .ToolResponse (
446+ toolCallId != null ? toolCallId : "" ,
447+ "" , // name not available on ToolMessage
448+ content );
449+ result .add (new ToolResponseMessage (List .of (response ), java .util .Map .of ()));
450+ }
451+ }
452+ default -> {
453+ // system, developer messages — skip (system is set separately)
454+ }
455+ }
456+ }
457+ return result ;
458+ }
459+
291460 /**
292461 * Wraps a Spring AI ToolCallback to emit AG-UI tool call events when
293462 * the tool is invoked during .call()'s internal tool execution loop.
0 commit comments