Skip to content

Commit e5d0bd9

Browse files
committed
[gemini] Fix parallel function calls in HLI tool loop
Gemini can return several functionCall parts in one model turn, with the thought signature only on the first part. The HLI stored each call as its own TOOL_CALL/TOOL_RETURN pair and the API client replayed them as separate single-call model turns, which Gemini 3.x rejects with 400 INVALID_ARGUMENT (missing thought_signature). Batch parallel calls into a single TOOL_CALL message (JSON array) and their results into a single TOOL_RETURN (JSON array of strings), which also satisfies the conversation rule that a TOOL_RETURN must directly follow its TOOL_CALL. The API client rebuilds them as one model turn with all functionCall parts and one user turn with all functionResponse parts, now echoing the per-call id so equal-named parallel calls pair unambiguously. Includes unit tests for the JSON batch (de-)serialization helpers. Signed-off-by: Christian Heldt <snaut@tutanota.com>
1 parent 32b6263 commit e5d0bd9

5 files changed

Lines changed: 267 additions & 40 deletions

File tree

bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiClient.java

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import com.fasterxml.jackson.annotation.JsonInclude.Include;
6060
import com.fasterxml.jackson.core.JsonProcessingException;
6161
import com.fasterxml.jackson.databind.ObjectMapper;
62+
import com.google.gson.JsonSyntaxException;
6263

6364
/**
6465
* The {@link GeminiApiClient} class encapsulates all HTTP/REST API communications with the Google Gemini API.
@@ -136,7 +137,8 @@ public GeminiResponse sendPrompt(String model, List<Conversation.Message> histor
136137
GeminiContent systemInstruction = createSystemInstruction(systemMessage);
137138

138139
List<GeminiContent> contents = new ArrayList<>();
139-
Queue<String> pendingToolCallNames = new LinkedList<>();
140+
Queue<GeminiFunctionCall> pendingToolCalls = new LinkedList<>();
141+
Queue<Integer> pendingToolCallCounts = new LinkedList<>();
140142

141143
for (Conversation.Message msg : history) {
142144
switch (msg.role()) {
@@ -151,35 +153,72 @@ public GeminiResponse sendPrompt(String model, List<Conversation.Message> histor
151153
break;
152154
}
153155
case TOOL_CALL: {
154-
GeminiLLMToolCall toolCall = GeminiLLMToolCall.fromJson(msg.content());
155-
String name = toolCall.tool.replaceAll("[^a-zA-Z0-9_-]", "_");
156-
pendingToolCallNames.add(name);
157-
GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params, toolCall.id);
158-
GeminiPart part = new GeminiPart(null, fc, null, null, toolCall.thoughtSignature);
159-
contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
156+
// Content may hold a single tool call (JSON object) or a batch of parallel tool calls
157+
// (JSON array). All calls of a batch belong to one model turn and must be replayed as
158+
// parts of a single ROLE_MODEL content, otherwise Gemini rejects the request
159+
// (e.g. missing thought_signature on split-off calls).
160+
List<GeminiLLMToolCall> toolCalls = GeminiLLMToolCall.listFromJson(msg.content());
161+
List<GeminiPart> callParts = new ArrayList<>();
162+
for (GeminiLLMToolCall toolCall : toolCalls) {
163+
String name = toolCall.tool.replaceAll("[^a-zA-Z0-9_-]", "_");
164+
GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params, toolCall.id);
165+
pendingToolCalls.add(fc);
166+
callParts.add(new GeminiPart(null, fc, null, null, toolCall.thoughtSignature));
167+
}
168+
if (callParts.isEmpty()) {
169+
break;
170+
}
171+
pendingToolCallCounts.add(callParts.size());
172+
contents.add(new GeminiContent(ROLE_MODEL, callParts));
160173
break;
161174
}
162175
case TOOL_RETURN: {
163-
String name = pendingToolCallNames.poll();
164-
if (name == null) {
176+
Integer batchSize = pendingToolCallCounts.poll();
177+
if (batchSize == null) {
165178
logger.trace("skipping orphaned TOOL_RETURN");
166179
break; // TOOL_RETURN without preceding TOOL_CALL - ignore
167180
}
168-
GeminiFunctionResponse fr = new GeminiFunctionResponse(name, Map.of("result", msg.content()));
169-
GeminiPart part = new GeminiPart(null, null, fr, null, null);
181+
List<String> results;
182+
if (batchSize > 1) {
183+
try {
184+
results = GeminiLLMToolCall.resultsFromJson(msg.content());
185+
} catch (JsonSyntaxException e) {
186+
results = List.of(msg.content());
187+
}
188+
} else {
189+
results = List.of(msg.content());
190+
}
191+
192+
List<GeminiPart> returnParts = new ArrayList<>();
193+
for (String result : results) {
194+
GeminiFunctionCall call = pendingToolCalls.poll();
195+
if (call == null) {
196+
break;
197+
}
198+
GeminiFunctionResponse fr = new GeminiFunctionResponse(call.name(), Map.of("result", result),
199+
call.id());
200+
returnParts.add(new GeminiPart(null, null, fr, null, null));
201+
}
202+
// keep the pending queue aligned if fewer results than calls were recorded
203+
for (int i = results.size(); i < batchSize; i++) {
204+
pendingToolCalls.poll();
205+
}
206+
if (returnParts.isEmpty()) {
207+
break;
208+
}
170209

171210
// Consolidate consecutive TOOL_RETURNs into the same ROLE_USER content
172211
if (!contents.isEmpty() && ROLE_USER.equals(contents.getLast().role())) {
173212
GeminiContent lastContent = contents.getLast();
174213
List<GeminiPart> lastParts = lastContent.parts();
175214
if (lastParts != null && !lastParts.isEmpty()
176215
&& lastParts.getFirst().functionResponse() != null) {
177-
lastParts.add(part);
216+
lastParts.addAll(returnParts);
178217
} else {
179-
contents.add(new GeminiContent(ROLE_USER, new ArrayList<>(List.of(part))));
218+
contents.add(new GeminiContent(ROLE_USER, returnParts));
180219
}
181220
} else {
182-
contents.add(new GeminiContent(ROLE_USER, new ArrayList<>(List.of(part))));
221+
contents.add(new GeminiContent(ROLE_USER, returnParts));
183222
}
184223
break;
185224
}

bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiLLMToolCall.java

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
*/
1313
package org.openhab.binding.gemini.internal.api;
1414

15+
import java.util.List;
1516
import java.util.Map;
17+
import java.util.Objects;
1618

1719
import org.eclipse.jdt.annotation.NonNullByDefault;
1820
import org.eclipse.jdt.annotation.Nullable;
@@ -43,9 +45,56 @@ public static GeminiLLMToolCall fromJson(String json) throws JsonSyntaxException
4345
if (call == null) {
4446
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall is null.");
4547
}
46-
if (call.tool == null || call.params == null) {
48+
// Gson bypasses the constructor, so fields declared non-null can still be null after
49+
// deserialization; Objects.isNull avoids the "redundant null check" compiler warning
50+
if (Objects.isNull(call.tool) || Objects.isNull(call.params)) {
4751
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall has null tool or params.");
4852
}
4953
return call;
5054
}
55+
56+
/**
57+
* Serializes a batch of parallel tool calls into a single JSON array string.
58+
*/
59+
public static String toJsonList(List<GeminiLLMToolCall> calls) {
60+
return GSON.toJson(calls);
61+
}
62+
63+
/**
64+
* Deserializes either a single tool call (JSON object, the legacy format) or a batch of
65+
* parallel tool calls (JSON array) into a list.
66+
*/
67+
public static List<GeminiLLMToolCall> listFromJson(String json) throws JsonSyntaxException {
68+
if (!json.trim().startsWith("[")) {
69+
return List.of(fromJson(json));
70+
}
71+
GeminiLLMToolCall[] calls = GSON.fromJson(json, GeminiLLMToolCall[].class);
72+
if (calls == null) {
73+
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall list is null.");
74+
}
75+
for (GeminiLLMToolCall call : calls) {
76+
if (call == null || Objects.isNull(call.tool) || Objects.isNull(call.params)) {
77+
throw new JsonSyntaxException("Deserialized GeminiLLMToolCall has null tool or params.");
78+
}
79+
}
80+
return List.of(calls);
81+
}
82+
83+
/**
84+
* Serializes the results of a batch of parallel tool calls into a JSON array string.
85+
*/
86+
public static String resultsToJson(List<String> results) {
87+
return GSON.toJson(results);
88+
}
89+
90+
/**
91+
* Deserializes the results of a batch of parallel tool calls from a JSON array string.
92+
*/
93+
public static List<String> resultsFromJson(String json) throws JsonSyntaxException {
94+
String[] results = GSON.fromJson(json, String[].class);
95+
if (results == null) {
96+
throw new JsonSyntaxException("Deserialized tool call results list is null.");
97+
}
98+
return List.of(results);
99+
}
51100
}

bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionResponse.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@
2727
*/
2828
@JsonIgnoreProperties(ignoreUnknown = true)
2929
@NonNullByDefault
30-
public record GeminiFunctionResponse(@Nullable String name, @Nullable Map<String, Object> response) {
30+
public record GeminiFunctionResponse(@Nullable String name, @Nullable Map<String, Object> response,
31+
@Nullable String id) {
3132
}

bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/hli/GeminiHLIService.java

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MODEL;
1717
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_SYSTEM_MESSAGE;
1818

19+
import java.util.ArrayList;
1920
import java.util.Collection;
2021
import java.util.HashMap;
2122
import java.util.List;
@@ -246,41 +247,55 @@ public String interpret(Locale locale, InterpreterContext interpreterContext) th
246247
boolean hasToolCall = false;
247248
StringBuilder textBuilder = new StringBuilder();
248249

250+
// Parallel function calls arrive as multiple functionCall parts of a single model turn.
251+
// They are batched into a single TOOL_CALL / TOOL_RETURN message pair (JSON arrays), so the
252+
// API client can replay them as one model turn followed by one user turn — Gemini rejects
253+
// interleaved single-call replays — while respecting the conversation rule that a
254+
// TOOL_RETURN must directly follow its TOOL_CALL.
255+
List<GeminiLLMToolCall> toolCalls = new ArrayList<>();
249256
for (GeminiPart part : parts) {
250257
GeminiFunctionCall fc = part.functionCall();
251258
if (fc != null) {
252259
hasToolCall = true;
253260
String toolName = fc.name();
254261
Map<String, Object> args = fc.args();
255-
256-
GeminiLLMToolCall llmToolCall = new GeminiLLMToolCall(toolName != null ? toolName : "",
257-
args != null ? args : new HashMap<>(), fc.id(), part.thoughtSignature());
258-
try {
259-
conversation.addMessage(ConversationRole.TOOL_CALL, llmToolCall.toJson());
260-
} catch (ConversationException e) {
261-
logger.warn("Cannot interpret: Failed to add TOOL_CALL to conversation", e);
262-
var ex = new InterpretationException(getLocalizedMessage(ERROR_KEY_TECHNICAL_PROBLEM,
263-
DEFAULT_ERROR_TECHNICAL_PROBLEM, locale));
264-
ex.initCause(e);
265-
throw ex;
266-
}
267-
268-
String result = executeTool(tools, toolName, args, locale);
269-
270-
try {
271-
conversation.addMessage(ConversationRole.TOOL_RETURN, result);
272-
} catch (ConversationException e) {
273-
logger.warn("Cannot interpret: Failed to add TOOL_RETURN to conversation", e);
274-
var ex = new InterpretationException(getLocalizedMessage(ERROR_KEY_TECHNICAL_PROBLEM,
275-
DEFAULT_ERROR_TECHNICAL_PROBLEM, locale));
276-
ex.initCause(e);
277-
throw ex;
278-
}
262+
toolCalls.add(new GeminiLLMToolCall(toolName != null ? toolName : "",
263+
args != null ? args : new HashMap<>(), fc.id(), part.thoughtSignature()));
279264
} else if (part.text() != null) {
280265
textBuilder.append(part.text());
281266
}
282267
}
283268

269+
if (!toolCalls.isEmpty()) {
270+
String callContent = toolCalls.size() == 1 ? toolCalls.getFirst().toJson()
271+
: GeminiLLMToolCall.toJsonList(toolCalls);
272+
try {
273+
conversation.addMessage(ConversationRole.TOOL_CALL, callContent);
274+
} catch (ConversationException e) {
275+
logger.warn("Cannot interpret: Failed to add TOOL_CALL to conversation", e);
276+
var ex = new InterpretationException(getLocalizedMessage(ERROR_KEY_TECHNICAL_PROBLEM,
277+
DEFAULT_ERROR_TECHNICAL_PROBLEM, locale));
278+
ex.initCause(e);
279+
throw ex;
280+
}
281+
282+
List<String> results = new ArrayList<>();
283+
for (GeminiLLMToolCall toolCall : toolCalls) {
284+
results.add(executeTool(tools, toolCall.tool, toolCall.params, locale));
285+
}
286+
String returnContent = results.size() == 1 ? results.getFirst()
287+
: GeminiLLMToolCall.resultsToJson(results);
288+
try {
289+
conversation.addMessage(ConversationRole.TOOL_RETURN, returnContent);
290+
} catch (ConversationException e) {
291+
logger.warn("Cannot interpret: Failed to add TOOL_RETURN to conversation", e);
292+
var ex = new InterpretationException(getLocalizedMessage(ERROR_KEY_TECHNICAL_PROBLEM,
293+
DEFAULT_ERROR_TECHNICAL_PROBLEM, locale));
294+
ex.initCause(e);
295+
throw ex;
296+
}
297+
}
298+
284299
if (!hasToolCall) {
285300
String finalResponse = textBuilder.toString();
286301
try {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.gemini.internal.api;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertNull;
17+
import static org.junit.jupiter.api.Assertions.assertThrows;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
19+
20+
import java.util.List;
21+
import java.util.Map;
22+
23+
import org.eclipse.jdt.annotation.NonNullByDefault;
24+
import org.junit.jupiter.api.Test;
25+
26+
import com.google.gson.JsonSyntaxException;
27+
28+
/**
29+
* Tests for the JSON (de-)serialization helpers of {@link GeminiLLMToolCall}, especially the
30+
* batch format used to store parallel function calls in a single conversation message.
31+
*
32+
* @author Christian Heldt - Initial contribution
33+
*/
34+
@NonNullByDefault
35+
public class GeminiLLMToolCallTest {
36+
37+
@Test
38+
public void singleCallRoundTripPreservesAllFields() {
39+
GeminiLLMToolCall call = new GeminiLLMToolCall("item-get-state", Map.of("item", "LivingRoom_Lamp"), "call-1",
40+
"signature-abc");
41+
42+
GeminiLLMToolCall deserialized = GeminiLLMToolCall.fromJson(call.toJson());
43+
44+
assertEquals("item-get-state", deserialized.tool);
45+
assertEquals(Map.of("item", "LivingRoom_Lamp"), deserialized.params);
46+
assertEquals("call-1", deserialized.id);
47+
assertEquals("signature-abc", deserialized.thoughtSignature);
48+
}
49+
50+
@Test
51+
public void listFromJsonAcceptsLegacySingleObjectFormat() {
52+
GeminiLLMToolCall call = new GeminiLLMToolCall("item-send-command", Map.of("item", "Lamp", "command", "ON"),
53+
null, null);
54+
55+
List<GeminiLLMToolCall> deserialized = GeminiLLMToolCall.listFromJson(call.toJson());
56+
57+
assertEquals(1, deserialized.size());
58+
assertEquals("item-send-command", deserialized.getFirst().tool);
59+
assertEquals(Map.of("item", "Lamp", "command", "ON"), deserialized.getFirst().params);
60+
assertNull(deserialized.getFirst().id);
61+
assertNull(deserialized.getFirst().thoughtSignature);
62+
}
63+
64+
@Test
65+
public void batchRoundTripPreservesOrderAndFields() {
66+
// Gemini sends the thought signature only on the first part of a parallel call batch
67+
List<GeminiLLMToolCall> calls = List.of(
68+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp1"), "call-1", "signature-abc"),
69+
new GeminiLLMToolCall("item-get-state", Map.of("item", "Lamp2"), "call-2", null));
70+
71+
List<GeminiLLMToolCall> deserialized = GeminiLLMToolCall.listFromJson(GeminiLLMToolCall.toJsonList(calls));
72+
73+
assertEquals(2, deserialized.size());
74+
assertEquals("item-get-state", deserialized.get(0).tool);
75+
assertEquals(Map.of("item", "Lamp1"), deserialized.get(0).params);
76+
assertEquals("call-1", deserialized.get(0).id);
77+
assertEquals("signature-abc", deserialized.get(0).thoughtSignature);
78+
assertEquals("item-get-state", deserialized.get(1).tool);
79+
assertEquals(Map.of("item", "Lamp2"), deserialized.get(1).params);
80+
assertEquals("call-2", deserialized.get(1).id);
81+
assertNull(deserialized.get(1).thoughtSignature);
82+
}
83+
84+
@Test
85+
public void listFromJsonAcceptsLeadingWhitespace() {
86+
String json = " \n [{\"tool\":\"get-date-time\",\"params\":{}}]";
87+
88+
List<GeminiLLMToolCall> deserialized = GeminiLLMToolCall.listFromJson(json);
89+
90+
assertEquals(1, deserialized.size());
91+
assertEquals("get-date-time", deserialized.getFirst().tool);
92+
}
93+
94+
@Test
95+
public void listFromJsonReturnsEmptyListForEmptyArray() {
96+
assertTrue(GeminiLLMToolCall.listFromJson("[]").isEmpty());
97+
}
98+
99+
@Test
100+
public void listFromJsonRejectsInvalidInput() {
101+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.listFromJson("null"));
102+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.listFromJson("[null]"));
103+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.listFromJson("[{}]"));
104+
assertThrows(JsonSyntaxException.class,
105+
() -> GeminiLLMToolCall.listFromJson("[{\"tool\":\"item-get-state\"}]"));
106+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.listFromJson("[{"));
107+
}
108+
109+
@Test
110+
public void resultsRoundTripPreservesOrder() {
111+
List<String> results = List.of("Lamp1 is ON", "Lamp2 is OFF");
112+
113+
assertEquals(results, GeminiLLMToolCall.resultsFromJson(GeminiLLMToolCall.resultsToJson(results)));
114+
}
115+
116+
@Test
117+
public void resultsFromJsonRejectsInvalidInput() {
118+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.resultsFromJson("null"));
119+
// a plain (non-array) tool result must not parse, so the caller can fall back to treating
120+
// it as a single result
121+
assertThrows(JsonSyntaxException.class, () -> GeminiLLMToolCall.resultsFromJson("plain text result"));
122+
}
123+
}

0 commit comments

Comments
 (0)