Skip to content

Commit 5ad8485

Browse files
tzolovilayaperumalg
authored andcommitted
Add conversationHistoryEnabled option to ToolCallAdvisor
Introduce a configurable option to control how conversation history is managed during tool calling iterations in ToolCallAdvisor. Resolves compatibility issues when using ToolCallAdvisor together with ChatMemory advisors that manage their own conversation history. When conversationHistoryEnabled=true (default), the advisor maintains full conversation history internally during tool call iterations. When set to false, only the last tool response message is passed to the next iteration, which is useful when integrating with a Chat Memory advisor that already manages conversation history. - Add conversationHistoryEnabled field and builder method to ToolCallAdvisor - Add doGetNextInstructionsForToolCall() protected method for extensibility - Add unit tests for the new configuration option - Update documentation in advisors-recursive.adoc and tools.adoc Signed-off-by: Christian Tzolov <[email protected]>
1 parent 77035ae commit 5ad8485

File tree

4 files changed

+230
-6
lines changed

4 files changed

+230
-6
lines changed

spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.ai.chat.client.advisor;
1818

19+
import java.util.List;
20+
1921
import reactor.core.publisher.Flux;
2022

2123
import org.springframework.ai.chat.client.ChatClientRequest;
@@ -25,6 +27,7 @@
2527
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
2628
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
2729
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
30+
import org.springframework.ai.chat.messages.Message;
2831
import org.springframework.ai.chat.model.ChatResponse;
2932
import org.springframework.ai.chat.prompt.Prompt;
3033
import org.springframework.ai.model.tool.ToolCallingChatOptions;
@@ -57,13 +60,21 @@ public class ToolCallAdvisor implements CallAdvisor, StreamAdvisor {
5760
*/
5861
private final int advisorOrder;
5962

63+
private final boolean conversationHistoryEnabled;
64+
6065
protected ToolCallAdvisor(ToolCallingManager toolCallingManager, int advisorOrder) {
66+
this(toolCallingManager, advisorOrder, true);
67+
}
68+
69+
protected ToolCallAdvisor(ToolCallingManager toolCallingManager, int advisorOrder,
70+
boolean conversationHistoryEnabled) {
6171
Assert.notNull(toolCallingManager, "toolCallingManager must not be null");
6272
Assert.isTrue(advisorOrder > BaseAdvisor.HIGHEST_PRECEDENCE && advisorOrder < BaseAdvisor.LOWEST_PRECEDENCE,
6373
"advisorOrder must be between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE");
6474

6575
this.toolCallingManager = toolCallingManager;
6676
this.advisorOrder = advisorOrder;
77+
this.conversationHistoryEnabled = conversationHistoryEnabled;
6778
}
6879

6980
@Override
@@ -144,7 +155,8 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd
144155
break;
145156
}
146157

147-
instructions = toolExecutionResult.conversationHistory();
158+
instructions = this.doGetNextInstructionsForToolCall(processedChatClientRequest, chatClientResponse,
159+
toolExecutionResult);
148160
}
149161

150162
}
@@ -153,6 +165,17 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd
153165
return this.doFinalizeLoop(chatClientResponse, callAdvisorChain);
154166
}
155167

168+
protected List<Message> doGetNextInstructionsForToolCall(ChatClientRequest chatClientRequest,
169+
ChatClientResponse chatClientResponse, ToolExecutionResult toolExecutionResult) {
170+
171+
if (!this.conversationHistoryEnabled) {
172+
return List.of(toolExecutionResult.conversationHistory()
173+
.get(toolExecutionResult.conversationHistory().size() - 1));
174+
}
175+
176+
return toolExecutionResult.conversationHistory();
177+
}
178+
156179
protected ChatClientResponse doFinalizeLoop(ChatClientResponse chatClientResponse,
157180
CallAdvisorChain callAdvisorChain) {
158181
return chatClientResponse;
@@ -199,6 +222,8 @@ public static class Builder<T extends Builder<T>> {
199222

200223
private int advisorOrder = BaseAdvisor.HIGHEST_PRECEDENCE + 300;
201224

225+
private boolean conversationHistoryEnabled = true;
226+
202227
protected Builder() {
203228
}
204229

@@ -233,6 +258,17 @@ public T advisorOrder(int advisorOrder) {
233258
return self();
234259
}
235260

261+
/**
262+
* Sets whether internal conversation history is enabled. If false, you need a
263+
* ChatMemory Advisor registered next in the chain.
264+
* @param conversationHistoryEnabled true to enable, false to disable
265+
* @return this Builder instance for method chaining
266+
*/
267+
public T conversationHistoryEnabled(boolean conversationHistoryEnabled) {
268+
this.conversationHistoryEnabled = conversationHistoryEnabled;
269+
return self();
270+
}
271+
236272
/**
237273
* Returns the configured ToolCallingManager.
238274
* @return the ToolCallingManager instance
@@ -257,7 +293,7 @@ protected int getAdvisorOrder() {
257293
* is out of valid range
258294
*/
259295
public ToolCallAdvisor build() {
260-
return new ToolCallAdvisor(this.toolCallingManager, this.advisorOrder);
296+
return new ToolCallAdvisor(this.toolCallingManager, this.advisorOrder, this.conversationHistoryEnabled);
261297
}
262298

263299
}

spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisorTests.java

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,79 @@ void testBuilderGetters() {
390390
assertThat(builder.getAdvisorOrder()).isEqualTo(customOrder);
391391
}
392392

393+
@Test
394+
void testConversationHistoryEnabledDefaultValue() {
395+
ToolCallAdvisor advisor = ToolCallAdvisor.builder().toolCallingManager(this.toolCallingManager).build();
396+
397+
// By default, conversationHistoryEnabled should be true
398+
// Verify via the tool call iteration behavior - with history enabled, the full
399+
// conversation history is used
400+
ChatClientRequest request = createMockRequest(true);
401+
ChatClientResponse responseWithToolCall = createMockResponse(true);
402+
ChatClientResponse finalResponse = createMockResponse(false);
403+
404+
int[] callCount = { 0 };
405+
CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> {
406+
callCount[0]++;
407+
return callCount[0] == 1 ? responseWithToolCall : finalResponse;
408+
});
409+
410+
CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
411+
.pushAll(List.of(advisor, terminalAdvisor))
412+
.build();
413+
414+
// Mock tool execution result with multiple messages in history
415+
List<Message> conversationHistory = List.of(new UserMessage("test"),
416+
AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build());
417+
ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder()
418+
.conversationHistory(conversationHistory)
419+
.build();
420+
when(this.toolCallingManager.executeToolCalls(any(Prompt.class), any(ChatResponse.class)))
421+
.thenReturn(toolExecutionResult);
422+
423+
ChatClientResponse result = advisor.adviseCall(request, realChain);
424+
425+
assertThat(result).isEqualTo(finalResponse);
426+
}
427+
428+
@Test
429+
void testConversationHistoryEnabledSetToFalse() {
430+
ToolCallAdvisor advisor = ToolCallAdvisor.builder()
431+
.toolCallingManager(this.toolCallingManager)
432+
.conversationHistoryEnabled(false)
433+
.build();
434+
435+
ChatClientRequest request = createMockRequest(true);
436+
ChatClientResponse responseWithToolCall = createMockResponse(true);
437+
ChatClientResponse finalResponse = createMockResponse(false);
438+
439+
int[] callCount = { 0 };
440+
CallAdvisor terminalAdvisor = new TerminalCallAdvisor((req, chain) -> {
441+
callCount[0]++;
442+
return callCount[0] == 1 ? responseWithToolCall : finalResponse;
443+
});
444+
445+
CallAdvisorChain realChain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
446+
.pushAll(List.of(advisor, terminalAdvisor))
447+
.build();
448+
449+
// Mock tool execution result with multiple messages in history
450+
List<Message> conversationHistory = List.of(new UserMessage("test"),
451+
AssistantMessage.builder().content("").build(), ToolResponseMessage.builder().build());
452+
ToolExecutionResult toolExecutionResult = ToolExecutionResult.builder()
453+
.conversationHistory(conversationHistory)
454+
.build();
455+
when(this.toolCallingManager.executeToolCalls(any(Prompt.class), any(ChatResponse.class)))
456+
.thenReturn(toolExecutionResult);
457+
458+
ChatClientResponse result = advisor.adviseCall(request, realChain);
459+
460+
assertThat(result).isEqualTo(finalResponse);
461+
// With conversationHistoryEnabled=false, only the last message from history is
462+
// used
463+
verify(this.toolCallingManager, times(1)).executeToolCalls(any(Prompt.class), any(ChatResponse.class));
464+
}
465+
393466
@Test
394467
void testExtendedAdvisorWithCustomHooks() {
395468
int[] hookCallCounts = { 0, 0, 0 }; // initializeLoop, beforeCall, afterCall
@@ -476,12 +549,13 @@ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) {
476549
List<Message> instructions = List.of(new UserMessage("test message"));
477550

478551
ChatOptions options = null;
552+
ToolCallingChatOptions copiedOptions = null;
553+
479554
if (withToolCallingOptions) {
480555
ToolCallingChatOptions toolOptions = mock(ToolCallingChatOptions.class,
481556
Mockito.withSettings().strictness(Strictness.LENIENT));
482557
// Create a separate mock for the copy that tracks the internal state
483-
ToolCallingChatOptions copiedOptions = mock(ToolCallingChatOptions.class,
484-
Mockito.withSettings().strictness(Strictness.LENIENT));
558+
copiedOptions = mock(ToolCallingChatOptions.class, Mockito.withSettings().strictness(Strictness.LENIENT));
485559

486560
// Use a holder to track the state
487561
boolean[] internalToolExecutionEnabled = { true };
@@ -501,12 +575,30 @@ private ChatClientRequest createMockRequest(boolean withToolCallingOptions) {
501575
return null;
502576
}).when(copiedOptions).setInternalToolExecutionEnabled(org.mockito.ArgumentMatchers.anyBoolean());
503577

578+
// copiedOptions.copy() should also return itself for subsequent copies
579+
when(copiedOptions.copy()).thenReturn(copiedOptions);
580+
504581
options = toolOptions;
505582
}
506583

507584
Prompt prompt = new Prompt(instructions, options);
585+
ChatClientRequest originalRequest = ChatClientRequest.builder().prompt(prompt).build();
586+
587+
// Create a mock request that returns a proper copy with the mocked options chain
588+
ChatClientRequest mockRequest = mock(ChatClientRequest.class,
589+
Mockito.withSettings().strictness(Strictness.LENIENT));
590+
when(mockRequest.prompt()).thenReturn(prompt);
591+
when(mockRequest.context()).thenReturn(Map.of());
592+
593+
// When copy() is called, return a new request with the copied options properly
594+
// set up
595+
final ToolCallingChatOptions finalCopiedOptions = copiedOptions;
596+
when(mockRequest.copy()).thenAnswer(invocation -> {
597+
Prompt copiedPrompt = new Prompt(instructions, finalCopiedOptions);
598+
return ChatClientRequest.builder().prompt(copiedPrompt).build();
599+
});
508600

509-
return ChatClientRequest.builder().prompt(prompt).build();
601+
return mockRequest;
510602
}
511603

512604
private ChatClientResponse createMockResponse(boolean hasToolCalls) {
@@ -575,7 +667,7 @@ private static class TestableToolCallAdvisor extends ToolCallAdvisor {
575667
private final int[] hookCallCounts;
576668

577669
TestableToolCallAdvisor(ToolCallingManager toolCallingManager, int advisorOrder, int[] hookCallCounts) {
578-
super(toolCallingManager, advisorOrder);
670+
super(toolCallingManager, advisorOrder, true);
579671
this.hookCallCounts = hookCallCounts;
580672
}
581673

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/advisors-recursive.adoc

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Key features:
3838
* Supports "return direct" functionality - when a tool execution has `returnDirect=true`, it interrupts the tool calling loop and returns the tool execution result directly to the client application instead of sending it back to the LLM
3939
* Uses `callAdvisorChain.copy(this)` to create a sub-chain for recursive calls
4040
* Includes null safety checks to handle cases where the chat response might be null
41+
* Supports configurable conversation history management via `conversationHistoryEnabled`
4142

4243
Example usage:
4344

@@ -53,6 +54,37 @@ var chatClient = ChatClient.builder(chatModel)
5354
.build();
5455
----
5556

57+
==== Conversation History Management
58+
59+
The `ToolCallAdvisor` includes a `conversationHistoryEnabled` configuration option that controls how conversation history is managed during tool calling iterations.
60+
61+
By default (`conversationHistoryEnabled=true`), the advisor maintains the full conversation history internally during tool call iterations. This means each subsequent LLM call in the tool calling loop includes all previous messages (user message, assistant responses, tool responses).
62+
63+
When set to `false`, only the last tool response message is passed to the next iteration. This is useful when:
64+
65+
* You have a Chat Memory Advisor registered next in the chain that already manages conversation history
66+
* You want to reduce token usage by not duplicating history management
67+
* You're integrating with external conversation memory systems
68+
69+
Example with conversation history disabled:
70+
71+
[source,java]
72+
----
73+
var toolCallAdvisor = ToolCallAdvisor.builder()
74+
.toolCallingManager(toolCallingManager)
75+
.conversationHistoryEnabled(false) // Disable internal history - let ChatMemory handle it
76+
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
77+
.build();
78+
79+
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
80+
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 200) // Positioned before ToolCallAdvisor
81+
.build();
82+
83+
var chatClient = ChatClient.builder(chatModel)
84+
.defaultAdvisors(chatMemoryAdvisor, toolCallAdvisor)
85+
.build();
86+
----
87+
5688
==== Return Direct Functionality
5789

5890
The "return direct" feature allows tools to bypass the LLM and return their results directly to the client application. This is useful when:

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/tools.adoc

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,70 @@ public class DefaultToolExecutionEligibilityPredicate implements ToolExecutionEl
11071107

11081108
You can provide your custom implementation of `ToolExecutionEligibilityPredicate` when creating the `ChatModel` bean.
11091109

1110+
=== Advisor-Controlled Tool Execution with ToolCallAdvisor
1111+
1112+
As an alternative to the framework-controlled tool execution, you can use the `ToolCallAdvisor` to implement tool calling as part of the xref:api/chatclient.adoc#_advisors[advisor chain]. This approach provides several advantages:
1113+
1114+
* **Observability**: Other advisors in the chain can intercept and observe each tool call iteration
1115+
* **Integration with Chat Memory**: Works seamlessly with Chat Memory advisors for conversation history management
1116+
* **Extensibility**: The advisor can be extended to customize the tool calling behavior
1117+
1118+
The `ToolCallAdvisor` implements the tool calling loop and disables the model's internal tool execution. When the model requests a tool call, the advisor executes the tool and sends the result back to the model, continuing until no more tool calls are needed.
1119+
1120+
[source,java]
1121+
----
1122+
var toolCallAdvisor = ToolCallAdvisor.builder()
1123+
.toolCallingManager(toolCallingManager)
1124+
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
1125+
.build();
1126+
1127+
var chatClient = ChatClient.builder(chatModel)
1128+
.defaultAdvisors(toolCallAdvisor)
1129+
.build();
1130+
1131+
String response = chatClient.prompt("What day is tomorrow?")
1132+
.tools(new DateTimeTools())
1133+
.call()
1134+
.content();
1135+
----
1136+
1137+
==== Configuration Options
1138+
1139+
The `ToolCallAdvisor.Builder` supports the following configuration options:
1140+
1141+
- `toolCallingManager`: The `ToolCallingManager` instance to use for executing tool calls. If not provided, a default instance is used.
1142+
- `advisorOrder`: The order in which the advisor is applied in the chain. Must be between `BaseAdvisor.HIGHEST_PRECEDENCE` and `BaseAdvisor.LOWEST_PRECEDENCE`.
1143+
- `conversationHistoryEnabled`: Controls whether the advisor maintains conversation history internally during tool call iterations. Default is `true`.
1144+
1145+
==== Conversation History Management
1146+
1147+
By default (`conversationHistoryEnabled=true`), the `ToolCallAdvisor` maintains the full conversation history internally during tool call iterations. Each subsequent LLM call includes all previous messages.
1148+
1149+
When set to `false`, only the last tool response message is passed to the next iteration. This is useful when integrating with a Chat Memory advisor that already manages conversation history:
1150+
1151+
[source,java]
1152+
----
1153+
var toolCallAdvisor = ToolCallAdvisor.builder()
1154+
.toolCallingManager(toolCallingManager)
1155+
.conversationHistoryEnabled(false) // Let ChatMemory handle history
1156+
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
1157+
.build();
1158+
1159+
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
1160+
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 200) // Before ToolCallAdvisor
1161+
.build();
1162+
1163+
var chatClient = ChatClient.builder(chatModel)
1164+
.defaultAdvisors(chatMemoryAdvisor, toolCallAdvisor)
1165+
.build();
1166+
----
1167+
1168+
==== Return Direct
1169+
1170+
The `ToolCallAdvisor` supports the "return direct" feature, allowing tools to bypass the LLM and return results directly to the client. When a tool execution has `returnDirect=true`, the advisor breaks out of the tool calling loop and returns the tool result directly.
1171+
1172+
For more details about `ToolCallAdvisor`, see xref:api/advisors-recursive.adoc#_toolcalladvisor[Recursive Advisors - ToolCallAdvisor].
1173+
11101174
=== User-Controlled Tool Execution
11111175

11121176
There are cases where you'd rather control the tool execution lifecycle yourself. You can do so by setting the `internalToolExecutionEnabled` attribute of `ToolCallingChatOptions` to `false`.

0 commit comments

Comments
 (0)