feat: Enhance tool call handling with support for multiple tools in conversation
This commit is contained in:
37
README.md
37
README.md
@@ -382,24 +382,32 @@ final var weatherTool = new ToolDefinition(
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
final var toolCallData = ToolCallData.required(List.of(weatherTool));
|
final var trafficTool = new ToolDefinition(
|
||||||
|
"get_traffic",
|
||||||
|
"Gets the current traffic for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
final var toolCallData = ToolCallData.required(List.of(weatherTool, trafficTool)).withParallelToolCalls(true);
|
||||||
final var completion = client.conversation(ModelCategory.MEDIUM, conversation, toolCallData, false);
|
final var completion = client.conversation(ModelCategory.MEDIUM, conversation, toolCallData, false);
|
||||||
|
|
||||||
if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
|
if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
|
||||||
final var toolCall = completion.toolCalls().getFirst();
|
conversation.appendToolCallRound(completion, List.of(
|
||||||
|
new Conversation.ToolResult(
|
||||||
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.ASSISTANT, "", completion.toolCalls(), null));
|
ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(),
|
||||||
conversation.addTurn(new Conversation.Turn(
|
completion.toolCalls().getFirst().id()
|
||||||
Conversation.TurnType.TOOL,
|
),
|
||||||
ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(),
|
new Conversation.ToolResult(
|
||||||
null,
|
ToolParameters.create().add("traffic", "Berlin traffic is congested").json(),
|
||||||
toolCall.id()
|
completion.toolCalls().get(1).id()
|
||||||
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
final var finalCompletion = client.conversation(
|
final var finalCompletion = client.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
conversation,
|
conversation,
|
||||||
ToolCallData.auto(List.of(weatherTool)),
|
ToolCallData.auto(List.of(weatherTool, trafficTool)),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -408,8 +416,13 @@ if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
|
|||||||
`ToolCallData` helpers:
|
`ToolCallData` helpers:
|
||||||
|
|
||||||
- `ToolCallData.auto(...)`: model may decide whether to call a tool
|
- `ToolCallData.auto(...)`: model may decide whether to call a tool
|
||||||
- `ToolCallData.required(...)`: model must choose one of the provided tools
|
- `ToolCallData.required(...)`: model must call one or more of the provided tools
|
||||||
- `ToolCallData.force(...)`: model must call a specific named tool
|
- `ToolCallData.force(...)`: model must call a specific named tool
|
||||||
|
- `toolCallData.withParallelToolCalls(true)`: asks OpenAI-compatible backends for multiple independent tool calls in one turn when the server and model support `parallel_tool_calls`
|
||||||
|
|
||||||
|
If a model returns multiple tool calls, append one assistant turn containing the returned `toolCalls()`, then append one tool turn per returned `tool_call_id` before requesting the follow-up completion.
|
||||||
|
|
||||||
|
`Conversation.appendToolCallRound(...)` wraps that handoff into one call by appending the assistant turn with the returned tool calls and then one tool turn per provided `Conversation.ToolResult`.
|
||||||
|
|
||||||
The returned `Completion` now exposes `toolCalls()` in addition to `response()`, `reasoning()`, `finishReason()`, and `statistics()`.
|
The returned `Completion` now exposes `toolCalls()` in addition to `response()`, `reasoning()`, `finishReason()`, and `statistics()`.
|
||||||
|
|
||||||
@@ -422,7 +435,7 @@ Both streaming and non-streaming execution paths are supported.
|
|||||||
- Each `Chunk` exposes both `content()` and `reasoningContent()`.
|
- Each `Chunk` exposes both `content()` and `reasoningContent()`.
|
||||||
- Final `Completion` objects expose `response()`, `reasoning()`, `finishReason()`, `statistics()`, and `toolCalls()`.
|
- Final `Completion` objects expose `response()`, `reasoning()`, `finishReason()`, `statistics()`, and `toolCalls()`.
|
||||||
|
|
||||||
If a chat completion decides to call a tool, `finishReason()` is `CompletionFinishReason.TOOL_CALLS` and `toolCalls()` contains the requested function calls. Streaming chat completions also aggregate tool calls into the final `Completion`.
|
If a chat completion decides to call a tool, `finishReason()` is `CompletionFinishReason.TOOL_CALLS` and `toolCalls()` contains the requested function calls. Streaming chat completions also aggregate tool calls into the final `Completion`, including multi-tool responses when `parallel_tool_calls` is enabled and supported by the backend.
|
||||||
|
|
||||||
This lets you stream visible output while still capturing final structured accounting data.
|
This lets you stream visible output while still capturing final structured accounting data.
|
||||||
|
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -4,7 +4,7 @@
|
|||||||
<artifactId>lm-lib</artifactId>
|
<artifactId>lm-lib</artifactId>
|
||||||
<groupId>de.extio</groupId>
|
<groupId>de.extio</groupId>
|
||||||
<name>lm-lib</name>
|
<name>lm-lib</name>
|
||||||
<version>2.5.6</version>
|
<version>2.5.7</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
|
|||||||
@@ -307,10 +307,9 @@ public interface BaseAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void appendToolCallResults(final Conversation conversation, final Completion completion, final List<ToolCallingAgentResponseHandler.ToolCallResult> toolCallResults) {
|
private void appendToolCallResults(final Conversation conversation, final Completion completion, final List<ToolCallingAgentResponseHandler.ToolCallResult> toolCallResults) {
|
||||||
conversation.addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response() == null ? "" : completion.response(), completion.toolCalls(), null));
|
conversation.appendToolCallRound(completion, toolCallResults.stream()
|
||||||
for (final var toolCallResult : toolCallResults) {
|
.map(toolCallResult -> new Conversation.ToolResult(toolCallResult.output(), toolCallResult.toolCallId()))
|
||||||
conversation.addTurn(new Conversation.Turn(TurnType.TOOL, toolCallResult.output(), null, toolCallResult.toolCallId()));
|
.toList());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Split> applyTemplate(final AgentContext context) {
|
private List<Split> applyTemplate(final AgentContext context) {
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ public final class Conversation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void appendToolCallRound(final Completion completion, final List<ToolResult> toolResults) {
|
||||||
|
if (completion == null) {
|
||||||
|
throw new IllegalArgumentException("completion must not be null");
|
||||||
|
}
|
||||||
|
this.appendToolCallRound(completion.response(), completion.toolCalls(), toolResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void appendToolCallRound(final String assistantText, final List<ToolCall> toolCalls, final List<ToolResult> toolResults) {
|
||||||
|
this.addTurn(new Turn(TurnType.ASSISTANT, assistantText == null ? "" : assistantText, toolCalls, null));
|
||||||
|
if (toolResults == null || toolResults.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final var toolResult : toolResults) {
|
||||||
|
this.addTurn(new Turn(TurnType.TOOL, toolResult.text(), null, toolResult.toolCallId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void rewindTurn() {
|
public void rewindTurn() {
|
||||||
synchronized (this.conversation) {
|
synchronized (this.conversation) {
|
||||||
if (this.conversation.size() > 1) {
|
if (this.conversation.size() > 1) {
|
||||||
@@ -94,4 +111,11 @@ public final class Conversation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ToolResult(String text, String toolCallId) {
|
||||||
|
|
||||||
|
public ToolResult {
|
||||||
|
text = text == null ? "" : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ public record ToolCallData(List<ToolDefinition> tools, String toolChoice, String
|
|||||||
return new ToolCallData(List.copyOf(tools), null, forcedToolName, null);
|
return new ToolCallData(List.copyOf(tools), null, forcedToolName, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ToolCallData withParallelToolCalls(final boolean parallelToolCalls) {
|
||||||
|
return new ToolCallData(this.tools, this.toolChoice, this.forcedToolName, parallelToolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasTools() {
|
public boolean hasTools() {
|
||||||
return !this.tools.isEmpty();
|
return !this.tools.isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import de.extio.lmlib.client.CompletionFinishReason;
|
|||||||
import de.extio.lmlib.client.Conversation;
|
import de.extio.lmlib.client.Conversation;
|
||||||
import de.extio.lmlib.client.Conversation.Turn;
|
import de.extio.lmlib.client.Conversation.Turn;
|
||||||
import de.extio.lmlib.client.Conversation.TurnType;
|
import de.extio.lmlib.client.Conversation.TurnType;
|
||||||
|
import de.extio.lmlib.client.ToolCall;
|
||||||
import de.extio.lmlib.client.ToolCallData;
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
import de.extio.lmlib.client.ToolDefinition;
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
import de.extio.lmlib.client.ToolParameters;
|
import de.extio.lmlib.client.ToolParameters;
|
||||||
@@ -153,15 +154,20 @@ public class CompletionClientTest {
|
|||||||
@Test
|
@Test
|
||||||
void toolCallingChatCompletion() throws Exception {
|
void toolCallingChatCompletion() throws Exception {
|
||||||
final var modelProfile = this.modelProfileService.getModelProfile("profile.model.chatcompletion");
|
final var modelProfile = this.modelProfileService.getModelProfile("profile.model.chatcompletion");
|
||||||
final var toolDefinition = new ToolDefinition(
|
final var weatherToolDefinition = new ToolDefinition(
|
||||||
"get_weather",
|
"get_weather",
|
||||||
"Gets the current weather for a given location.",
|
"Gets the current weather for a given location.",
|
||||||
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
true);
|
true);
|
||||||
|
final var trafficToolDefinition = new ToolDefinition(
|
||||||
|
"get_traffic",
|
||||||
|
"Gets the current traffic for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true);
|
||||||
final var conversation = Conversation.create(
|
final var conversation = Conversation.create(
|
||||||
"You are a helpful assistant. Use the provided tool when the user asks for weather information. After receiving tool results, answer using the forecast from the tool output.",
|
"You are a helpful assistant. Use the provided tools when the user asks for location information. After receiving tool results, answer the question from the tool output.",
|
||||||
"What is the weather in Berlin, Germany? Use the tool.");
|
"What is the weather and how is the traffic in Berlin, Germany? Use the tool.");
|
||||||
final var toolCallData = ToolCallData.required(List.of(toolDefinition));
|
final var toolCallData = ToolCallData.required(List.of(weatherToolDefinition, trafficToolDefinition)).withParallelToolCalls(true);
|
||||||
final var completion = this.chatCompletionclient.conversation(
|
final var completion = this.chatCompletionclient.conversation(
|
||||||
modelProfile,
|
modelProfile,
|
||||||
conversation,
|
conversation,
|
||||||
@@ -172,16 +178,22 @@ public class CompletionClientTest {
|
|||||||
assertFalse(this.textCompletionClient.supportsToolCalling());
|
assertFalse(this.textCompletionClient.supportsToolCalling());
|
||||||
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
||||||
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
||||||
assertFalse(completion.toolCalls().isEmpty());
|
assertEquals(2, completion.toolCalls().size());
|
||||||
assertEquals("get_weather", completion.toolCalls().getFirst().name());
|
final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
|
||||||
assertTrue(completion.toolCalls().getFirst().arguments().contains("Berlin"));
|
final var trafficToolCall = this.findToolCall(completion.toolCalls(), "get_traffic");
|
||||||
|
assertTrue(weatherToolCall.arguments().contains("Berlin"));
|
||||||
|
|
||||||
conversation.addTurn(new Turn(TurnType.ASSISTANT, "", completion.toolCalls(), null));
|
conversation.appendToolCallRound(completion, List.of(
|
||||||
conversation.addTurn(new Turn(TurnType.TOOL, ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(), null, completion.toolCalls().getFirst().id()));
|
new Conversation.ToolResult(ToolParameters.create()
|
||||||
|
.add("forecast", "Berlin is rainy and 21C")
|
||||||
|
.json(), weatherToolCall.id()),
|
||||||
|
new Conversation.ToolResult(ToolParameters.create()
|
||||||
|
.add("traffic", "Berlin Autobahn is congested")
|
||||||
|
.json(), trafficToolCall.id())));
|
||||||
|
|
||||||
final var finalCompletion = this.chatCompletionclient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(toolDefinition)), false);
|
final var finalCompletion = this.chatCompletionclient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(weatherToolDefinition, trafficToolDefinition)), false);
|
||||||
assertTrue(finalCompletion.finishReason() == CompletionFinishReason.DONE);
|
assertTrue(finalCompletion.finishReason() == CompletionFinishReason.DONE);
|
||||||
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", finalCompletion.response(), modelProfile, this.clientService));
|
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C and that traffic in Berlin is congested?", finalCompletion.response(), modelProfile, this.clientService));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -197,11 +209,12 @@ public class CompletionClientTest {
|
|||||||
"Gets the current CPU utilization.",
|
"Gets the current CPU utilization.",
|
||||||
ToolParameters.create(Map.of("cpu", "CPU identifier, for example CPU0. Use CPU for overall utilization.")),
|
ToolParameters.create(Map.of("cpu", "CPU identifier, for example CPU0. Use CPU for overall utilization.")),
|
||||||
true);
|
true);
|
||||||
|
final var conversation = Conversation.create(
|
||||||
|
"You are a helpful assistant. Use the provided tools when applicable. If multiple independent tools are needed, call them in the same turn.",
|
||||||
|
"What is the weather and how high is the current CPU utilization in Berlin, Germany? Use the tools.");
|
||||||
final var completion = this.chatCompletionclient.streamConversation(
|
final var completion = this.chatCompletionclient.streamConversation(
|
||||||
modelProfile,
|
modelProfile,
|
||||||
Conversation.create(
|
conversation,
|
||||||
"You are a helpful assistant. Use the provided tool when applicable.",
|
|
||||||
"How high is the current CPU utilization?"),
|
|
||||||
chunk -> {
|
chunk -> {
|
||||||
if (chunk.reasoningContent() != null) {
|
if (chunk.reasoningContent() != null) {
|
||||||
System.out.print(chunk.reasoningContent());
|
System.out.print(chunk.reasoningContent());
|
||||||
@@ -210,16 +223,30 @@ public class CompletionClientTest {
|
|||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ToolCallData.required(List.of(getWeather, getCpu)),
|
ToolCallData.required(List.of(getWeather, getCpu)).withParallelToolCalls(true),
|
||||||
false);
|
false);
|
||||||
|
|
||||||
assertTrue(this.chatCompletionclient.supportsToolCalling());
|
assertTrue(this.chatCompletionclient.supportsToolCalling());
|
||||||
assertFalse(this.textCompletionClient.supportsToolCalling());
|
assertFalse(this.textCompletionClient.supportsToolCalling());
|
||||||
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
||||||
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
||||||
assertFalse(completion.toolCalls().isEmpty());
|
assertEquals(2, completion.toolCalls().size());
|
||||||
assertEquals("get_cpu", completion.toolCalls().getFirst().name());
|
final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
|
||||||
assertTrue(completion.toolCalls().getFirst().arguments().contains("CPU"));
|
final var cpuToolCall = this.findToolCall(completion.toolCalls(), "get_cpu");
|
||||||
|
assertTrue(weatherToolCall.arguments().contains("Berlin"));
|
||||||
|
assertTrue(cpuToolCall.arguments().contains("CPU"));
|
||||||
|
|
||||||
|
conversation.appendToolCallRound(completion, List.of(
|
||||||
|
new Conversation.ToolResult(ToolParameters.create()
|
||||||
|
.add("forecast", "Berlin is rainy and 21C")
|
||||||
|
.json(), weatherToolCall.id()),
|
||||||
|
new Conversation.ToolResult(ToolParameters.create()
|
||||||
|
.add("cpu", "CPU is at 37 percent utilization")
|
||||||
|
.json(), cpuToolCall.id())));
|
||||||
|
|
||||||
|
final var finalCompletion = this.chatCompletionclient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(getWeather, getCpu)), false);
|
||||||
|
assertTrue(finalCompletion.finishReason() == CompletionFinishReason.DONE);
|
||||||
|
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C and that CPU utilization is 37 percent?", finalCompletion.response(), modelProfile, this.clientService));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Disabled
|
@Disabled
|
||||||
@@ -278,6 +305,13 @@ public class CompletionClientTest {
|
|||||||
LOGGER.info("Duration: " + java.time.Duration.between(start, LocalDateTime.now()));
|
LOGGER.info("Duration: " + java.time.Duration.between(start, LocalDateTime.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ToolCall findToolCall(final List<ToolCall> toolCalls, final String toolName) {
|
||||||
|
return toolCalls.stream()
|
||||||
|
.filter(toolCall -> toolName.equals(toolCall.name()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Missing tool call: " + toolName));
|
||||||
|
}
|
||||||
|
|
||||||
private Tasks createTasks() {
|
private Tasks createTasks() {
|
||||||
return new Tasks(() -> {
|
return new Tasks(() -> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user