feat: Enhance tool call handling with support for multiple tools in conversation

This commit is contained in:
extio-de
2026-04-15 21:51:20 +02:00
parent a0d248d605
commit b8f47f2745
6 changed files with 109 additions and 35 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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) {
@@ -93,5 +110,12 @@ public final class Conversation {
this(type, text, List.of(), null); this(type, text, List.of(), null);
} }
} }
public record ToolResult(String text, String toolCallId) {
public ToolResult {
text = text == null ? "" : text;
}
}
} }

View File

@@ -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();
} }

View File

@@ -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
@@ -277,6 +304,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(() -> {