Skip to content

Commit 67df2de

Browse files
committed
fix(showcase): streaming tool execution for spring-ai D5
Spring AI's ChatClient.stream() does NOT auto-execute tool callbacks (only .call() has a built-in tool execution loop). The stock AG-UI SpringAIAgent uses .stream() exclusively, so tools are never invoked and the CopilotKit runtime re-invokes the agent infinitely. Replace SpringAIAgent with a custom StreamingToolAgent that: - Phase 1: streams text via .stream() for real-time UX delivery - Phase 2: if tool calls detected, re-invokes via .call() with tool callbacks so Spring AI's internal loop handles execution - Emits proper AG-UI tool call events via wrapper callbacks Also fix Jackson deserialization crashes from CopilotKit runtime messages with unrecognised roles (activity, reasoning) by disabling FAIL_ON_INVALID_SUBTYPE, with null-filtering at controller boundaries to prevent NPEs in LocalAgent.combineMessages(). Affects all spring-ai endpoints: agentic_chat, agent-config, a2ui-fixed-schema, subagents, shared-state-read-write.
1 parent b2c8209 commit 67df2de

9 files changed

Lines changed: 552 additions & 114 deletions

File tree

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

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.agui.server.spring.AgUiParameters;
44
import com.agui.server.spring.AgUiService;
5-
import com.agui.spring.ai.SpringAIAgent;
65
import com.copilotkit.showcase.springai.tools.DisplayFlightTool;
76
import org.springframework.ai.chat.memory.ChatMemory;
87
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
@@ -39,39 +38,36 @@ public A2uiFixedSchemaController(AgUiService agUiService, ChatModel chatModel) {
3938

4039
@PostMapping("/a2ui-fixed-schema/run")
4140
public ResponseEntity<SseEmitter> run(@RequestBody AgUiParameters params) {
42-
SpringAIAgent agent = buildAgent();
41+
MessageListFilter.filterNulls(params);
42+
StreamingToolAgent agent = buildAgent();
4343
SseEmitter emitter = agUiService.runAgent(agent, params);
4444
return ResponseEntity.ok()
4545
.cacheControl(CacheControl.noCache())
4646
.body(emitter);
4747
}
4848

49-
private SpringAIAgent buildAgent() {
49+
private StreamingToolAgent buildAgent() {
5050
ChatMemory memory = MessageWindowChatMemory.builder()
5151
.chatMemoryRepository(new InMemoryChatMemoryRepository())
5252
.maxMessages(10)
5353
.build();
54-
try {
55-
return SpringAIAgent.builder()
56-
.agentId("a2ui-fixed-schema")
57-
.chatModel(chatModel)
58-
.chatMemory(memory)
59-
.systemMessage("""
60-
You are a flight-search assistant. When the user asks about a flight,
61-
call the display_flight tool with the origin airport code, destination
62-
airport code, airline, and price string (e.g. "$289"). Use 3-letter
63-
airport codes. After calling the tool, reply with a brief confirmation
64-
in plain text.
65-
""")
66-
.toolCallback(
67-
FunctionToolCallback.builder("display_flight", new DisplayFlightTool())
68-
.description("Display a flight card for the given trip")
69-
.inputType(DisplayFlightTool.Request.class)
70-
.build()
71-
)
72-
.build();
73-
} catch (Exception e) {
74-
throw new RuntimeException("Failed to build a2ui-fixed-schema agent", e);
75-
}
54+
return StreamingToolAgent.builder()
55+
.agentId("a2ui-fixed-schema")
56+
.chatModel(chatModel)
57+
.chatMemory(memory)
58+
.systemMessage("""
59+
You are a flight-search assistant. When the user asks about a flight,
60+
call the display_flight tool with the origin airport code, destination
61+
airport code, airline, and price string (e.g. "$289"). Use 3-letter
62+
airport codes. After calling the tool, reply with a brief confirmation
63+
in plain text.
64+
""")
65+
.toolCallback(
66+
FunctionToolCallback.builder("display_flight", new DisplayFlightTool())
67+
.description("Display a flight card for the given trip")
68+
.inputType(DisplayFlightTool.Request.class)
69+
.build()
70+
)
71+
.build();
7672
}
7773
}
Lines changed: 75 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.copilotkit.showcase.springai;
22

3-
import com.agui.spring.ai.SpringAIAgent;
43
import com.copilotkit.showcase.springai.model.SalesTodo;
54
import com.copilotkit.showcase.springai.tools.WeatherRequest;
65
import com.copilotkit.showcase.springai.tools.WeatherTool;
@@ -18,7 +17,6 @@
1817
import org.springframework.context.annotation.Bean;
1918
import org.springframework.context.annotation.Configuration;
2019

21-
import java.util.ArrayList;
2220
import java.util.List;
2321

2422
@Configuration
@@ -32,82 +30,86 @@ public ChatMemory chatMemory() {
3230
.build();
3331
}
3432

33+
/**
34+
* Main agentic chat agent. Uses {@link StreamingToolAgent} which streams
35+
* text via {@code .stream()} for real-time delivery and falls back to
36+
* {@code .call()} when tool execution is needed. This replaces the stock
37+
* AG-UI {@code SpringAIAgent} which used {@code .stream()} exclusively
38+
* and never executed tool callbacks (Spring AI streaming does not
39+
* auto-execute tools).
40+
*/
3541
@Bean
36-
public SpringAIAgent agent(ChatModel chatModel, ChatMemory chatMemory) {
42+
public StreamingToolAgent agent(ChatModel chatModel, ChatMemory chatMemory) {
3743
// Shared mutable todos list between get/manage tools
3844
var salesTodosTool = new GetSalesTodosTool();
3945
List<SalesTodo> sharedTodos = salesTodosTool.getTodos();
4046
var manageSalesTodosTool = new ManageSalesTodosTool(sharedTodos);
4147

42-
try {
43-
return SpringAIAgent.builder()
44-
.agentId("agentic_chat")
45-
.chatModel(chatModel)
46-
.chatMemory(chatMemory)
47-
.systemMessage("""
48-
You are a helpful assistant for the CopilotKit showcase.
49-
You can check the weather using the get_weather tool.
50-
You can query financial/business data using the query_data tool.
51-
You can schedule meetings using the schedule_meeting tool.
52-
You can manage the sales pipeline using get_sales_todos and manage_sales_todos tools.
53-
You can search for flights using the search_flights tool.
54-
You can generate dynamic UI using the generate_a2ui tool.
55-
For other tools (change_background, generate_haiku, generate_task_steps,
56-
pieChart, barChart, scheduleTime, toggleTheme),
57-
these are provided by the frontend — use them when relevant to the user's request.
58-
When asked to plan or create steps, use the generate_task_steps tool.
59-
When asked about weather, use the get_weather tool.
60-
When asked about data, charts, or analytics, use the query_data tool.
61-
When asked to schedule a meeting, use the schedule_meeting tool.
62-
When asked about the sales pipeline or deals, use get_sales_todos first.
63-
When asked to search for flights, use the search_flights tool.
64-
Keep responses concise and helpful.
65-
""")
66-
.toolCallback(
67-
FunctionToolCallback.builder("get_weather", new WeatherTool())
68-
.description("Get current weather for a location")
69-
.inputType(WeatherRequest.class)
70-
.build()
71-
)
72-
.toolCallback(
73-
FunctionToolCallback.builder("query_data", new QueryDataTool())
74-
.description("Query financial data for charts and analytics")
75-
.inputType(QueryDataTool.Request.class)
76-
.build()
77-
)
78-
.toolCallback(
79-
FunctionToolCallback.builder("schedule_meeting", new ScheduleMeetingTool())
80-
.description("Schedule a meeting with a given reason and duration")
81-
.inputType(ScheduleMeetingTool.Request.class)
82-
.build()
83-
)
84-
.toolCallback(
85-
FunctionToolCallback.builder("get_sales_todos", salesTodosTool)
86-
.description("Get the current sales pipeline todos")
87-
.inputType(GetSalesTodosTool.Request.class)
88-
.build()
89-
)
90-
.toolCallback(
91-
FunctionToolCallback.builder("manage_sales_todos", manageSalesTodosTool)
92-
.description("Update the sales pipeline with a new set of todos")
93-
.inputType(ManageSalesTodosTool.Request.class)
94-
.build()
95-
)
96-
.toolCallback(
97-
FunctionToolCallback.builder("search_flights", new SearchFlightsTool())
98-
.description("Search for available flights between two cities")
99-
.inputType(SearchFlightsTool.Request.class)
100-
.build()
101-
)
102-
.toolCallback(
103-
FunctionToolCallback.builder("generate_a2ui", new GenerateA2uiTool(chatModel))
104-
.description("Generate dynamic A2UI components using a secondary LLM call")
105-
.inputType(GenerateA2uiTool.Request.class)
106-
.build()
107-
)
108-
.build();
109-
} catch (Exception e) {
110-
throw new RuntimeException("Failed to build SpringAIAgent", e);
111-
}
48+
return StreamingToolAgent.builder()
49+
.agentId("agentic_chat")
50+
.chatModel(chatModel)
51+
.chatMemory(chatMemory)
52+
.systemMessage("""
53+
You are a helpful assistant for the CopilotKit showcase.
54+
You can check the weather using the get_weather tool.
55+
You can query financial/business data using the query_data tool.
56+
You can schedule meetings using the schedule_meeting tool.
57+
You can manage the sales pipeline using get_sales_todos and manage_sales_todos tools.
58+
You can search for flights using the search_flights tool.
59+
You can generate dynamic UI using the generate_a2ui tool.
60+
For other tools (change_background, generate_haiku, generate_task_steps,
61+
pieChart, barChart, scheduleTime, toggleTheme),
62+
these are provided by the frontend — use them when relevant to the user's request.
63+
When asked to plan or create steps, use the generate_task_steps tool.
64+
When asked about weather, use the get_weather tool.
65+
When asked about data, charts, or analytics, use the query_data tool.
66+
When asked to schedule a meeting, use the schedule_meeting tool.
67+
When asked about the sales pipeline or deals, use get_sales_todos first.
68+
When asked to search for flights, use the search_flights tool.
69+
Keep responses concise and helpful.
70+
""")
71+
.toolCallback(
72+
FunctionToolCallback.builder("get_weather", new WeatherTool())
73+
.description("Get current weather for a location")
74+
.inputType(WeatherRequest.class)
75+
.build()
76+
)
77+
.toolCallback(
78+
FunctionToolCallback.builder("query_data", new QueryDataTool())
79+
.description("Query financial data for charts and analytics")
80+
.inputType(QueryDataTool.Request.class)
81+
.build()
82+
)
83+
.toolCallback(
84+
FunctionToolCallback.builder("schedule_meeting", new ScheduleMeetingTool())
85+
.description("Schedule a meeting with a given reason and duration")
86+
.inputType(ScheduleMeetingTool.Request.class)
87+
.build()
88+
)
89+
.toolCallback(
90+
FunctionToolCallback.builder("get_sales_todos", salesTodosTool)
91+
.description("Get the current sales pipeline todos")
92+
.inputType(GetSalesTodosTool.Request.class)
93+
.build()
94+
)
95+
.toolCallback(
96+
FunctionToolCallback.builder("manage_sales_todos", manageSalesTodosTool)
97+
.description("Update the sales pipeline with a new set of todos")
98+
.inputType(ManageSalesTodosTool.Request.class)
99+
.build()
100+
)
101+
.toolCallback(
102+
FunctionToolCallback.builder("search_flights", new SearchFlightsTool())
103+
.description("Search for available flights between two cities")
104+
.inputType(SearchFlightsTool.Request.class)
105+
.build()
106+
)
107+
.toolCallback(
108+
FunctionToolCallback.builder("generate_a2ui", new GenerateA2uiTool(chatModel))
109+
.description("Generate dynamic A2UI components using a secondary LLM call")
110+
.inputType(GenerateA2uiTool.Request.class)
111+
.build()
112+
)
113+
.build();
112114
}
113115
}

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.agui.server.spring.AgUiParameters;
44
import com.agui.server.spring.AgUiService;
5-
import com.agui.spring.ai.SpringAIAgent;
65
import com.fasterxml.jackson.databind.JsonNode;
76
import com.fasterxml.jackson.databind.ObjectMapper;
87
import org.springframework.ai.chat.memory.ChatMemory;
@@ -72,7 +71,8 @@ public ResponseEntity<SseEmitter> run(@RequestBody String rawBody) throws Except
7271
String systemPrompt = buildSystemPrompt(tone, expertise, length);
7372

7473
AgUiParameters params = objectMapper.readValue(rawBody, AgUiParameters.class);
75-
SpringAIAgent perRequestAgent = buildAgent(systemPrompt);
74+
MessageListFilter.filterNulls(params);
75+
StreamingToolAgent perRequestAgent = buildAgent(systemPrompt);
7676

7777
SseEmitter emitter = agUiService.runAgent(perRequestAgent, params);
7878
return ResponseEntity.ok()
@@ -84,21 +84,17 @@ private static String normalize(String value, Set<String> allowed, String fallba
8484
return value != null && allowed.contains(value) ? value : fallback;
8585
}
8686

87-
private SpringAIAgent buildAgent(String systemPrompt) {
87+
private StreamingToolAgent buildAgent(String systemPrompt) {
8888
ChatMemory memory = MessageWindowChatMemory.builder()
8989
.chatMemoryRepository(new InMemoryChatMemoryRepository())
9090
.maxMessages(10)
9191
.build();
92-
try {
93-
return SpringAIAgent.builder()
94-
.agentId("agent-config-demo")
95-
.chatModel(chatModel)
96-
.chatMemory(memory)
97-
.systemMessage(systemPrompt)
98-
.build();
99-
} catch (Exception e) {
100-
throw new RuntimeException("Failed to build agent-config agent", e);
101-
}
92+
return StreamingToolAgent.builder()
93+
.agentId("agent-config-demo")
94+
.chatModel(chatModel)
95+
.chatMemory(memory)
96+
.systemMessage(systemPrompt)
97+
.build();
10298
}
10399

104100
private static String buildSystemPrompt(String tone, String expertise, String length) {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.agui.server.spring.AgUiParameters;
44
import com.agui.server.spring.AgUiService;
5-
import com.agui.spring.ai.SpringAIAgent;
65
import org.springframework.beans.factory.annotation.Autowired;
76
import org.springframework.http.CacheControl;
87
import org.springframework.http.ResponseEntity;
@@ -15,16 +14,17 @@
1514
public class AgentController {
1615

1716
private final AgUiService agUiService;
18-
private final SpringAIAgent agent;
17+
private final StreamingToolAgent agent;
1918

2019
@Autowired
21-
public AgentController(AgUiService agUiService, SpringAIAgent agent) {
20+
public AgentController(AgUiService agUiService, StreamingToolAgent agent) {
2221
this.agUiService = agUiService;
2322
this.agent = agent;
2423
}
2524

2625
@PostMapping("/")
2726
public ResponseEntity<SseEmitter> run(@RequestBody AgUiParameters params) {
27+
MessageListFilter.filterNulls(params);
2828
SseEmitter emitter = agUiService.runAgent(agent, params);
2929
return ResponseEntity.ok()
3030
.cacheControl(CacheControl.noCache())
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.copilotkit.showcase.springai;
2+
3+
import com.fasterxml.jackson.databind.DeserializationFeature;
4+
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
/**
9+
* Jackson configuration for tolerant deserialization of AG-UI messages.
10+
*
11+
* <p>The CopilotKit runtime forwards the full conversation history to the
12+
* agent, including messages with roles that the AG-UI Java SDK does not
13+
* recognise (e.g. "activity", "reasoning"). The AG-UI {@code MessageMixin}
14+
* uses {@code @JsonTypeInfo(property = "role")} with a closed set of
15+
* {@code @JsonSubTypes}; an unrecognised role causes
16+
* {@code InvalidTypeIdException} during deserialization, crashing the
17+
* request before the agent code even runs.
18+
*
19+
* <p>We disable two features:
20+
* <ul>
21+
* <li>{@code FAIL_ON_UNKNOWN_PROPERTIES} — tolerates extra JSON fields
22+
* that don't map to a Java field.</li>
23+
* <li>{@code FAIL_ON_INVALID_SUBTYPE} — when the {@code role} type-id
24+
* doesn't match any registered {@code @JsonSubTypes} name, Jackson
25+
* returns {@code null} for that list element instead of throwing.
26+
* Downstream code must null-check message lists (see
27+
* {@link MessageListFilter}).</li>
28+
* </ul>
29+
*/
30+
@Configuration
31+
public class JacksonConfig {
32+
33+
@Bean
34+
public Jackson2ObjectMapperBuilderCustomizer tolerantObjectMapperCustomizer() {
35+
return builder -> builder.featuresToDisable(
36+
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
37+
DeserializationFeature.FAIL_ON_INVALID_SUBTYPE
38+
);
39+
}
40+
}

0 commit comments

Comments
 (0)