Skip to content

Commit 78deb8b

Browse files
committed
fix(showcase): spring-ai HITL features — frontend tool detection + multi-turn history
StreamingToolAgent now classifies tool calls as frontend vs backend by comparing input.tools() (CopilotKit-injected frontend tools) against the registered toolCallbacks (backend tools). Frontend-only tool calls emit TOOL_CALL_START/ARGS/END without TOOL_CALL_RESULT so the CopilotKit runtime's processAgentResult detects the missing result and executes the frontend handler (useHumanInTheLoop, useFrontendTool). On re-invocation (when the runtime sends back the tool result), the agent now sends the full AG-UI message history to aimock/LLM via convertMessages() so the fixture matcher sees the tool result and returns a follow-up text response instead of repeating the tool call. Fixes 3 HITL D5 features: hitl-text-input, hitl-approve-deny, hitl-steps. Spring-ai now passes 6/11 D5 features locally (up from 3). The remaining 5 failures (tool-rendering, shared-state-read/write, subagents, mcp-apps) are pre-existing and unrelated to StreamingToolAgent — they involve separate controllers, missing aimock fixtures, external MCP servers, and CopilotKit frontend rendering issues.
1 parent 1a2f388 commit 78deb8b

1 file changed

Lines changed: 190 additions & 21 deletions

File tree

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/StreamingToolAgent.java

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@
55
import com.agui.core.agent.RunAgentInput;
66
import com.agui.core.event.BaseEvent;
77
import com.agui.core.exception.AGUIException;
8+
import com.agui.core.function.FunctionCall;
89
import com.agui.core.message.AssistantMessage;
10+
import com.agui.core.message.BaseMessage;
911
import com.agui.core.message.Role;
12+
import com.agui.core.message.ToolMessage;
1013
import com.agui.core.state.State;
14+
import com.agui.core.tool.Tool;
15+
import com.agui.core.tool.ToolCall;
1116
import com.agui.server.LocalAgent;
1217
import org.springframework.ai.chat.client.ChatClient;
1318
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
1419
import org.springframework.ai.chat.memory.ChatMemory;
20+
import org.springframework.ai.chat.messages.Message;
21+
import org.springframework.ai.chat.messages.ToolResponseMessage;
1522
import org.springframework.ai.chat.model.ChatModel;
1623
import org.springframework.ai.chat.model.ChatResponse;
1724
import org.springframework.ai.chat.prompt.Prompt;
@@ -22,11 +29,13 @@
2229
import org.slf4j.LoggerFactory;
2330

2431
import java.util.ArrayList;
32+
import java.util.HashSet;
2533
import java.util.List;
34+
import java.util.Set;
2635
import java.util.UUID;
36+
import java.util.concurrent.CopyOnWriteArrayList;
2737
import java.util.concurrent.CountDownLatch;
2838
import java.util.concurrent.TimeUnit;
29-
import java.util.concurrent.atomic.AtomicBoolean;
3039
import java.util.concurrent.atomic.AtomicReference;
3140

3241
import 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

Comments
 (0)