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

Reply via email to