diff --git a/README.md b/README.md
index 15df9f6..551cf16 100644
--- a/README.md
+++ b/README.md
@@ -382,24 +382,32 @@ final var weatherTool = new ToolDefinition(
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);
if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
- final var toolCall = completion.toolCalls().getFirst();
-
- conversation.addTurn(new Conversation.Turn(Conversation.TurnType.ASSISTANT, "", completion.toolCalls(), null));
- conversation.addTurn(new Conversation.Turn(
- Conversation.TurnType.TOOL,
- ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(),
- null,
- toolCall.id()
+ conversation.appendToolCallRound(completion, List.of(
+ new Conversation.ToolResult(
+ ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(),
+ completion.toolCalls().getFirst().id()
+ ),
+ new Conversation.ToolResult(
+ ToolParameters.create().add("traffic", "Berlin traffic is congested").json(),
+ completion.toolCalls().get(1).id()
+ )
));
final var finalCompletion = client.conversation(
ModelCategory.MEDIUM,
conversation,
- ToolCallData.auto(List.of(weatherTool)),
+ ToolCallData.auto(List.of(weatherTool, trafficTool)),
false
);
}
@@ -408,8 +416,13 @@ if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
`ToolCallData` helpers:
- `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.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()`.
@@ -422,7 +435,7 @@ Both streaming and non-streaming execution paths are supported.
- Each `Chunk` exposes both `content()` and `reasoningContent()`.
- 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.
diff --git a/pom.xml b/pom.xml
index 5ae9169..b013fb6 100755
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
lm-lib
de.extio
lm-lib
- 2.5.6
+ 2.5.7
25
diff --git a/src/main/java/de/extio/lmlib/agent/BaseAgent.java b/src/main/java/de/extio/lmlib/agent/BaseAgent.java
index d2df5f3..e3189d4 100644
--- a/src/main/java/de/extio/lmlib/agent/BaseAgent.java
+++ b/src/main/java/de/extio/lmlib/agent/BaseAgent.java
@@ -307,10 +307,9 @@ public interface BaseAgent {
}
private void appendToolCallResults(final Conversation conversation, final Completion completion, final List toolCallResults) {
- conversation.addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response() == null ? "" : completion.response(), completion.toolCalls(), null));
- for (final var toolCallResult : toolCallResults) {
- conversation.addTurn(new Conversation.Turn(TurnType.TOOL, toolCallResult.output(), null, toolCallResult.toolCallId()));
- }
+ conversation.appendToolCallRound(completion, toolCallResults.stream()
+ .map(toolCallResult -> new Conversation.ToolResult(toolCallResult.output(), toolCallResult.toolCallId()))
+ .toList());
}
private List applyTemplate(final AgentContext context) {
diff --git a/src/main/java/de/extio/lmlib/client/Conversation.java b/src/main/java/de/extio/lmlib/client/Conversation.java
index d81e0f1..dfd8c48 100755
--- a/src/main/java/de/extio/lmlib/client/Conversation.java
+++ b/src/main/java/de/extio/lmlib/client/Conversation.java
@@ -55,6 +55,23 @@ public final class Conversation {
}
}
+ public void appendToolCallRound(final Completion completion, final List 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 toolCalls, final List 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() {
synchronized (this.conversation) {
if (this.conversation.size() > 1) {
@@ -93,5 +110,12 @@ public final class Conversation {
this(type, text, List.of(), null);
}
}
+
+ public record ToolResult(String text, String toolCallId) {
+
+ public ToolResult {
+ text = text == null ? "" : text;
+ }
+ }
}
diff --git a/src/main/java/de/extio/lmlib/client/ToolCallData.java b/src/main/java/de/extio/lmlib/client/ToolCallData.java
index 908bbd5..7cb2feb 100644
--- a/src/main/java/de/extio/lmlib/client/ToolCallData.java
+++ b/src/main/java/de/extio/lmlib/client/ToolCallData.java
@@ -27,6 +27,10 @@ public record ToolCallData(List tools, String toolChoice, String
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() {
return !this.tools.isEmpty();
}
diff --git a/src/test/java/de/extio/lmlib/client/oai/CompletionClientTest.java b/src/test/java/de/extio/lmlib/client/oai/CompletionClientTest.java
index 24cc03a..9ba6c58 100755
--- a/src/test/java/de/extio/lmlib/client/oai/CompletionClientTest.java
+++ b/src/test/java/de/extio/lmlib/client/oai/CompletionClientTest.java
@@ -27,6 +27,7 @@ import de.extio.lmlib.client.CompletionFinishReason;
import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.Conversation.TurnType;
+import de.extio.lmlib.client.ToolCall;
import de.extio.lmlib.client.ToolCallData;
import de.extio.lmlib.client.ToolDefinition;
import de.extio.lmlib.client.ToolParameters;
@@ -153,15 +154,20 @@ public class CompletionClientTest {
@Test
void toolCallingChatCompletion() throws Exception {
final var modelProfile = this.modelProfileService.getModelProfile("profile.model.chatcompletion");
- final var toolDefinition = new ToolDefinition(
+ final var weatherToolDefinition = new ToolDefinition(
"get_weather",
"Gets the current weather for a given location.",
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
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(
- "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.",
- "What is the weather in Berlin, Germany? Use the tool.");
- final var toolCallData = ToolCallData.required(List.of(toolDefinition));
+ "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 and how is the traffic in Berlin, Germany? Use the tool.");
+ final var toolCallData = ToolCallData.required(List.of(weatherToolDefinition, trafficToolDefinition)).withParallelToolCalls(true);
final var completion = this.chatCompletionclient.conversation(
modelProfile,
conversation,
@@ -172,16 +178,22 @@ public class CompletionClientTest {
assertFalse(this.textCompletionClient.supportsToolCalling());
assertTrue(this.clientService.supportsToolCalling(modelProfile));
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
- assertFalse(completion.toolCalls().isEmpty());
- assertEquals("get_weather", completion.toolCalls().getFirst().name());
- assertTrue(completion.toolCalls().getFirst().arguments().contains("Berlin"));
+ assertEquals(2, completion.toolCalls().size());
+ final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
+ final var trafficToolCall = this.findToolCall(completion.toolCalls(), "get_traffic");
+ assertTrue(weatherToolCall.arguments().contains("Berlin"));
- conversation.addTurn(new Turn(TurnType.ASSISTANT, "", completion.toolCalls(), null));
- conversation.addTurn(new Turn(TurnType.TOOL, ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(), null, completion.toolCalls().getFirst().id()));
+ 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("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(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
@@ -197,11 +209,12 @@ public class CompletionClientTest {
"Gets the current CPU utilization.",
ToolParameters.create(Map.of("cpu", "CPU identifier, for example CPU0. Use CPU for overall utilization.")),
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(
modelProfile,
- Conversation.create(
- "You are a helpful assistant. Use the provided tool when applicable.",
- "How high is the current CPU utilization?"),
+ conversation,
chunk -> {
if (chunk.reasoningContent() != null) {
System.out.print(chunk.reasoningContent());
@@ -210,16 +223,30 @@ public class CompletionClientTest {
System.out.print(chunk.content());
}
},
- ToolCallData.required(List.of(getWeather, getCpu)),
+ ToolCallData.required(List.of(getWeather, getCpu)).withParallelToolCalls(true),
false);
assertTrue(this.chatCompletionclient.supportsToolCalling());
assertFalse(this.textCompletionClient.supportsToolCalling());
assertTrue(this.clientService.supportsToolCalling(modelProfile));
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
- assertFalse(completion.toolCalls().isEmpty());
- assertEquals("get_cpu", completion.toolCalls().getFirst().name());
- assertTrue(completion.toolCalls().getFirst().arguments().contains("CPU"));
+ assertEquals(2, completion.toolCalls().size());
+ final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
+ 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
@@ -277,6 +304,13 @@ public class CompletionClientTest {
LOGGER.info("Duration: " + java.time.Duration.between(start, LocalDateTime.now()));
}
+
+ private ToolCall findToolCall(final List toolCalls, final String toolName) {
+ return toolCalls.stream()
+ .filter(toolCall -> toolName.equals(toolCall.name()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("Missing tool call: " + toolName));
+ }
private Tasks createTasks() {
return new Tasks(() -> {