From b8f47f274557010f4358562d36797a7af13a7e43 Mon Sep 17 00:00:00 2001 From: extio-de Date: Wed, 15 Apr 2026 21:51:20 +0200 Subject: [PATCH] feat: Enhance tool call handling with support for multiple tools in conversation --- README.md | 37 ++++++---- pom.xml | 2 +- .../java/de/extio/lmlib/agent/BaseAgent.java | 7 +- .../de/extio/lmlib/client/Conversation.java | 24 +++++++ .../de/extio/lmlib/client/ToolCallData.java | 4 ++ .../client/oai/CompletionClientTest.java | 70 ++++++++++++++----- 6 files changed, 109 insertions(+), 35 deletions(-) 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(() -> {