This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new d906b608 [integration][gemini] Synthesize a tool-call id when the API
omits functionCall.id (#960)
d906b608 is described below
commit d906b608fe702d398d8a0395eb2d6b5380668b85
Author: Edson <[email protected]>
AuthorDate: Tue Aug 4 02:14:41 2026 -0400
[integration][gemini] Synthesize a tool-call id when the API omits
functionCall.id (#960)
---
.../gemini/GeminiChatModelConnection.java | 16 +++++-
.../gemini/GeminiChatModelConnectionTest.java | 65 ++++++++++++++++++++++
2 files changed, 80 insertions(+), 1 deletion(-)
diff --git
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
index b0fb30df..e445fa27 100644
---
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
+++
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
@@ -48,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.UUID;
import java.util.stream.Collectors;
/**
@@ -430,7 +431,9 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
FunctionCall.Builder fcBuilder =
FunctionCall.builder().name(functionName).args(argsMap);
Object originalId = call.get("original_id");
- if (originalId != null) {
+ // A synthetic id exists only for runtime correlation (the API omitted
the native id);
+ // echoing a fabricated id back to Gemini would claim the model
produced it.
+ if (originalId != null &&
!Boolean.TRUE.equals(call.get("synthetic_id"))) {
fcBuilder.id(originalId.toString());
}
@@ -504,6 +507,17 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
if (id != null) {
toolCall.put("id", id);
toolCall.put("original_id", id);
+ } else {
+ // The Gemini Developer API frequently omits functionCall.id.
Downstream correlation
+ // still needs one: ToolCallAction keys its result maps on `id`
(two id-less parallel
+ // calls would otherwise collide on the literal "null") and only
propagates
+ // `original_id` as the TOOL message's externalId, which is how
the follow-up turn
+ // recovers the function name for Gemini's functionResponse part.
Synthesize an id for
+ // the runtime round-trip and mark it so it is never echoed back
to the API.
+ String syntheticId = UUID.randomUUID().toString();
+ toolCall.put("id", syntheticId);
+ toolCall.put("original_id", syntheticId);
+ toolCall.put("synthetic_id", Boolean.TRUE);
}
toolCall.put("type", "function");
toolCall.put("function", functionMap);
diff --git
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
index abc616f6..41dbdbe2 100644
---
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
+++
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
@@ -223,6 +223,71 @@ class GeminiChatModelConnectionTest {
assertThat(toolCall).doesNotContainKey("thought_signature");
}
+ @Test
+ @DisplayName(
+ "convertFunctionCall synthesizes a unique id when the API omits
functionCall.id, so"
+ + " parallel id-less calls cannot collide")
+ void testConvertFunctionCallWithoutIdSynthesizesUniqueIds() {
+ // The Gemini Developer API frequently returns functionCall parts with
no id.
+ FunctionCall first =
FunctionCall.builder().name("get_weather").args(Map.of()).build();
+ FunctionCall second =
FunctionCall.builder().name("get_time").args(Map.of()).build();
+
+ GeminiChatModelConnection conn = connection();
+ Map<String, Object> firstCall = conn.convertFunctionCall(first, null);
+ Map<String, Object> secondCall = conn.convertFunctionCall(second,
null);
+
+ assertThat(firstCall.get("id")).isNotNull();
+
assertThat(firstCall.get("original_id")).isEqualTo(firstCall.get("id"));
+ assertThat(firstCall).containsEntry("synthetic_id", Boolean.TRUE);
+ // ToolCallAction keys success/responses/error on `id`; distinct ids
are what prevent two
+ // parallel id-less calls from overwriting each other.
+ assertThat(firstCall.get("id")).isNotEqualTo(secondCall.get("id"));
+ }
+
+ @Test
+ @DisplayName("A synthetic id is never echoed back to the Gemini API on
replay")
+ void testSyntheticIdNotEchoedToGemini() {
+ FunctionCall fc =
FunctionCall.builder().name("get_weather").args(Map.of()).build();
+
+ GeminiChatModelConnection conn = connection();
+ Map<String, Object> toolCall = conn.convertFunctionCall(fc, null);
+ Part part = conn.convertToolCallToPart(toolCall);
+
+ FunctionCall replayed = part.functionCall().orElseThrow();
+ assertThat(replayed.id()).isEmpty();
+ assertThat(replayed.name()).hasValue("get_weather");
+ }
+
+ @Test
+ @DisplayName(
+ "Second turn resolves the function name via the synthetic id (full
id-less round"
+ + " trip)")
+ void testSyntheticIdResolvesFunctionNameOnSecondTurn() {
+ FunctionCall fc =
FunctionCall.builder().name("get_weather").args(Map.of()).build();
+
+ GeminiChatModelConnection conn = connection();
+ Map<String, Object> toolCall = conn.convertFunctionCall(fc, null);
+ String syntheticId = (String) toolCall.get("original_id");
+
+ // Assistant turn carrying the id-less tool call, exactly as
convertResponse builds it.
+ ChatMessage assistant = ChatMessage.assistant("");
+ assistant.setToolCalls(List.of(toolCall));
+
+ // Runtime contract: ToolCallAction copies `original_id` into the TOOL
message's
+ // `externalId`. Before the fix, no id existed, externalId was never
set, and this
+ // second-turn conversion threw "Tool message must carry the function
name".
+ ChatMessage tool = ChatMessage.tool("sunny, 22C");
+ tool.getExtraArgs().put("externalId", syntheticId);
+
+ Map<String, String> idToName =
+
GeminiChatModelConnection.buildToolCallIdToNameMap(List.of(assistant, tool));
+ Content content = conn.convertToContent(tool, idToName);
+
+ Part part = content.parts().orElseThrow().get(0);
+ assertThat(part.functionResponse()).isPresent();
+
assertThat(part.functionResponse().orElseThrow().name()).hasValue("get_weather");
+ }
+
@Test
@DisplayName("Tool-call round-trip preserves name, args and
thoughtSignature")
void testToolCallRoundTrip() {