Skip to content

Commit 32c03f1

Browse files
committed
fix(core): stabilize ToolUseBlock hashCode for Gemini thoughtSignature
1 parent a94bbb0 commit 32c03f1

7 files changed

Lines changed: 160 additions & 20 deletions

File tree

agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiMessageConverter.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,22 @@ public List<Content> convertMessages(List<Msg> msgs) {
122122
// Build Part with FunctionCall and optional thought signature
123123
Part.Builder partBuilder = Part.builder().functionCall(functionCall);
124124

125-
// Check for thought signature in metadata
125+
// Check for thought signature in metadata (always stored as Base64 String,
126+
// see ToolUseBlock#normalizeMetadata)
126127
Map<String, Object> metadata = tub.getMetadata();
127128
if (metadata != null
128129
&& metadata.containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE)) {
129130
Object signature = metadata.get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
130-
if (signature instanceof byte[]) {
131-
partBuilder.thoughtSignature((byte[]) signature);
131+
if (signature instanceof String encodedSignature
132+
&& !encodedSignature.isEmpty()) {
133+
try {
134+
partBuilder.thoughtSignature(
135+
Base64.getDecoder().decode(encodedSignature));
136+
} catch (IllegalArgumentException e) {
137+
log.warn(
138+
"Invalid Base64 thought signature in ToolUseBlock metadata,"
139+
+ " skipping");
140+
}
132141
}
133142
}
134143

agentscope-core/src/main/java/io/agentscope/core/formatter/gemini/GeminiResponseParser.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.time.Duration;
3333
import java.time.Instant;
3434
import java.util.ArrayList;
35+
import java.util.Base64;
3536
import java.util.HashMap;
3637
import java.util.List;
3738
import java.util.Map;
@@ -199,7 +200,9 @@ protected void parseToolCall(
199200
Map<String, Object> metadata = null;
200201
if (thoughtSignature != null) {
201202
metadata = new HashMap<>();
202-
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature);
203+
metadata.put(
204+
ToolUseBlock.METADATA_THOUGHT_SIGNATURE,
205+
Base64.getEncoder().encodeToString(thoughtSignature));
203206
}
204207

205208
blocks.add(

agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.fasterxml.jackson.annotation.JsonCreator;
1919
import com.fasterxml.jackson.annotation.JsonProperty;
20+
import java.util.Base64;
2021
import java.util.Collections;
2122
import java.util.HashMap;
2223
import java.util.Map;
@@ -34,7 +35,7 @@
3435
*/
3536
public final class ToolUseBlock extends ContentBlock {
3637

37-
/** Metadata key for Gemini thought signature (byte[] value). */
38+
/** Metadata key for provider thought signatures (String value). */
3839
public static final String METADATA_THOUGHT_SIGNATURE = "thoughtSignature";
3940

4041
private final String id;
@@ -94,7 +95,25 @@ public ToolUseBlock(
9495
this.metadata =
9596
metadata == null
9697
? Collections.emptyMap()
97-
: Collections.unmodifiableMap(new HashMap<>(metadata));
98+
: Collections.unmodifiableMap(normalizeMetadata(metadata));
99+
}
100+
101+
/**
102+
* Normalizes metadata values to ensure stable {@link #equals(Object)} and {@link #hashCode()}
103+
* across serialization round-trips.
104+
*
105+
* <p>Specifically, a {@code byte[]} stored under {@link #METADATA_THOUGHT_SIGNATURE} is
106+
* converted to a Base64-encoded {@code String}, because {@code byte[]} relies on identity
107+
* equality and would otherwise cause two semantically equal blocks to hash differently after
108+
* deserialization.
109+
*/
110+
private static Map<String, Object> normalizeMetadata(Map<String, Object> metadata) {
111+
Map<String, Object> copy = new HashMap<>(metadata);
112+
Object signature = copy.get(METADATA_THOUGHT_SIGNATURE);
113+
if (signature instanceof byte[] bytes) {
114+
copy.put(METADATA_THOUGHT_SIGNATURE, Base64.getEncoder().encodeToString(bytes));
115+
}
116+
return copy;
98117
}
99118

100119
/**
@@ -136,7 +155,7 @@ public String getContent() {
136155
/**
137156
* Gets the provider-specific metadata.
138157
*
139-
* <p>For Gemini, this may contain the thought signature under the key
158+
* <p>For Gemini, this may contain a Base64-encoded thought signature under the key
140159
* {@link #METADATA_THOUGHT_SIGNATURE}.
141160
*
142161
* @return The metadata map, or an empty map if not set
@@ -233,7 +252,7 @@ public Builder content(String content) {
233252
* Sets the provider-specific metadata.
234253
*
235254
* <p>For Gemini, use {@link ToolUseBlock#METADATA_THOUGHT_SIGNATURE} as the key
236-
* to store thought signatures.
255+
* to store Base64-encoded thought signatures.
237256
*
238257
* @param metadata The metadata map
239258
* @return This builder for chaining

agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulatorTest.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
*/
1616
package io.agentscope.core.agent.accumulator;
1717

18-
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
1918
import static org.junit.jupiter.api.Assertions.assertEquals;
2019
import static org.junit.jupiter.api.Assertions.assertNotNull;
2120
import static org.junit.jupiter.api.Assertions.assertNull;
2221
import static org.junit.jupiter.api.Assertions.assertTrue;
2322

2423
import io.agentscope.core.message.ToolUseBlock;
24+
import java.util.Base64;
2525
import java.util.HashMap;
2626
import java.util.List;
2727
import java.util.Map;
@@ -46,7 +46,7 @@ void setUp() {
4646
@DisplayName("Should accumulate metadata from tool call chunks")
4747
void testAccumulateMetadata() {
4848
// First chunk with thoughtSignature
49-
byte[] signature = "test-thought-signature".getBytes();
49+
String signature = Base64.getEncoder().encodeToString("test-thought-signature".getBytes());
5050
Map<String, Object> metadata = new HashMap<>();
5151
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature);
5252

@@ -77,9 +77,8 @@ void testAccumulateMetadata() {
7777
// Verify metadata is preserved
7878
assertNotNull(toolCall.getMetadata());
7979
assertTrue(toolCall.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
80-
assertArrayEquals(
81-
signature,
82-
(byte[]) toolCall.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
80+
assertEquals(
81+
signature, toolCall.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
8382
}
8483

8584
@Test
@@ -107,7 +106,7 @@ void testAccumulateWithoutMetadata() {
107106
@DisplayName("Should handle parallel tool calls with different metadata")
108107
void testParallelToolCallsWithMetadata() {
109108
// First tool call with metadata
110-
byte[] sig1 = "sig-1".getBytes();
109+
String sig1 = Base64.getEncoder().encodeToString("sig-1".getBytes());
111110
Map<String, Object> metadata1 = new HashMap<>();
112111
metadata1.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, sig1);
113112

@@ -139,6 +138,7 @@ void testParallelToolCallsWithMetadata() {
139138
result.stream().filter(t -> "call_a".equals(t.getId())).findFirst().orElse(null);
140139
assertNotNull(resultA);
141140
assertTrue(resultA.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
141+
assertEquals(sig1, resultA.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
142142

143143
// Second call should not have metadata
144144
ToolUseBlock resultB =

agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiMessageConverterTest.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
1919
import static org.junit.jupiter.api.Assertions.assertEquals;
2020
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2122
import static org.junit.jupiter.api.Assertions.assertNotNull;
2223
import static org.junit.jupiter.api.Assertions.assertTrue;
2324

@@ -770,8 +771,9 @@ void testConvertToolUseBlockWithThoughtSignature() {
770771
input.put("query", "test");
771772

772773
byte[] thoughtSignature = "test-signature".getBytes();
774+
String encodedSignature = Base64.getEncoder().encodeToString(thoughtSignature);
773775
Map<String, Object> metadata = new HashMap<>();
774-
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature);
776+
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encodedSignature);
775777

776778
ToolUseBlock toolUseBlock =
777779
ToolUseBlock.builder()
@@ -804,6 +806,43 @@ void testConvertToolUseBlockWithThoughtSignature() {
804806
assertArrayEquals(thoughtSignature, part.thoughtSignature().get());
805807
}
806808

809+
@Test
810+
@DisplayName("Should normalize legacy byte[] thoughtSignature metadata to Base64 String")
811+
void testConvertToolUseBlockWithLegacyByteArrayThoughtSignature() {
812+
Map<String, Object> input = new HashMap<>();
813+
input.put("query", "test");
814+
815+
byte[] thoughtSignature = "legacy-signature".getBytes();
816+
Map<String, Object> metadata = new HashMap<>();
817+
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, thoughtSignature);
818+
819+
ToolUseBlock toolUseBlock =
820+
ToolUseBlock.builder()
821+
.id("call_with_legacy_sig")
822+
.name("search")
823+
.input(input)
824+
.metadata(metadata)
825+
.build();
826+
827+
// Constructor normalizes byte[] to Base64 String so equals/hashCode stay stable
828+
Object stored = toolUseBlock.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
829+
assertInstanceOf(String.class, stored);
830+
assertEquals(Base64.getEncoder().encodeToString(thoughtSignature), stored);
831+
832+
Msg msg =
833+
Msg.builder()
834+
.name("assistant")
835+
.content(List.of(toolUseBlock))
836+
.role(MsgRole.ASSISTANT)
837+
.build();
838+
839+
List<Content> result = converter.convertMessages(List.of(msg));
840+
841+
Part part = result.get(0).parts().get().get(0);
842+
assertTrue(part.thoughtSignature().isPresent());
843+
assertArrayEquals(thoughtSignature, part.thoughtSignature().get());
844+
}
845+
807846
@Test
808847
@DisplayName("Should convert ToolUseBlock without thoughtSignature")
809848
void testConvertToolUseBlockWithoutThoughtSignature() {
@@ -842,8 +881,9 @@ void testThoughtSignatureRoundTrip() {
842881
input.put("location", "Tokyo");
843882

844883
byte[] signature = "gemini3-thought-sig-abc123".getBytes();
884+
String encodedSignature = Base64.getEncoder().encodeToString(signature);
845885
Map<String, Object> metadata = new HashMap<>();
846-
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, signature);
886+
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encodedSignature);
847887

848888
// Simulate assistant message with tool call (as would be constructed from parsed response)
849889
ToolUseBlock toolUseBlock =

agentscope-core/src/test/java/io/agentscope/core/formatter/gemini/GeminiResponseParserTest.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
*/
1616
package io.agentscope.core.formatter.gemini;
1717

18-
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
1918
import static org.junit.jupiter.api.Assertions.assertEquals;
2019
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2120
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -34,6 +33,7 @@
3433
import io.agentscope.core.model.ChatResponse;
3534
import io.agentscope.core.model.ChatUsage;
3635
import java.time.Instant;
36+
import java.util.Base64;
3737
import java.util.HashMap;
3838
import java.util.List;
3939
import java.util.Map;
@@ -353,9 +353,9 @@ void testParseToolCallWithThoughtSignature() {
353353
// Verify thought signature is stored in metadata
354354
assertNotNull(toolUse.getMetadata());
355355
assertTrue(toolUse.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
356-
byte[] extractedSig =
357-
(byte[]) toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
358-
assertArrayEquals(thoughtSignature, extractedSig);
356+
String extractedSig =
357+
(String) toolUse.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
358+
assertEquals(Base64.getEncoder().encodeToString(thoughtSignature), extractedSig);
359359
}
360360

361361
@Test
@@ -432,6 +432,9 @@ void testParseParallelFunctionCallsWithThoughtSignature() {
432432
ToolUseBlock toolUse1 = (ToolUseBlock) chatResponse.getContent().get(0);
433433
assertEquals("call-1", toolUse1.getId());
434434
assertTrue(toolUse1.getMetadata().containsKey(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
435+
assertEquals(
436+
Base64.getEncoder().encodeToString(thoughtSignature),
437+
toolUse1.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE));
435438

436439
// Second tool call should not have signature
437440
ToolUseBlock toolUse2 = (ToolUseBlock) chatResponse.getContent().get(1);

agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717

1818
import static org.junit.jupiter.api.Assertions.assertEquals;
1919
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2021
import static org.junit.jupiter.api.Assertions.assertNotNull;
2122
import static org.junit.jupiter.api.Assertions.assertTrue;
2223

2324
import com.fasterxml.jackson.core.JsonProcessingException;
2425
import com.fasterxml.jackson.databind.ObjectMapper;
26+
import java.util.Base64;
27+
import java.util.HashMap;
2528
import java.util.Map;
2629
import org.junit.jupiter.api.Test;
2730

@@ -185,6 +188,69 @@ void testRoundTripSerialization() throws JsonProcessingException {
185188
assertEquals(original.getMetadata(), deserialized.getMetadata());
186189
}
187190

191+
@Test
192+
void testThoughtSignatureStringRoundTripKeepsValueHashCode() throws JsonProcessingException {
193+
ToolUseBlock original =
194+
ToolUseBlock.builder()
195+
.id("tool-gemini")
196+
.name("search")
197+
.input(Map.of("query", "agent"))
198+
.metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, "dGVzdA=="))
199+
.build();
200+
201+
String json = objectMapper.writeValueAsString(original);
202+
ToolUseBlock deserialized = objectMapper.readValue(json, ToolUseBlock.class);
203+
204+
assertEquals(original, deserialized);
205+
assertEquals(original.hashCode(), deserialized.hashCode());
206+
}
207+
208+
@Test
209+
void testConstructorNormalizesByteArrayThoughtSignatureToBase64String() {
210+
byte[] rawSignature = "raw-thought-signature".getBytes();
211+
Map<String, Object> metadata = new HashMap<>();
212+
metadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, rawSignature);
213+
214+
ToolUseBlock block =
215+
ToolUseBlock.builder()
216+
.id("tool-bytes")
217+
.name("search")
218+
.input(Map.of("query", "agent"))
219+
.metadata(metadata)
220+
.build();
221+
222+
Object stored = block.getMetadata().get(ToolUseBlock.METADATA_THOUGHT_SIGNATURE);
223+
assertInstanceOf(String.class, stored);
224+
assertEquals(Base64.getEncoder().encodeToString(rawSignature), stored);
225+
}
226+
227+
@Test
228+
void testByteArrayAndBase64StringMetadataProduceEqualBlocks() {
229+
byte[] rawSignature = "same-signature".getBytes();
230+
String encoded = Base64.getEncoder().encodeToString(rawSignature);
231+
232+
Map<String, Object> bytesMetadata = new HashMap<>();
233+
bytesMetadata.put(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, rawSignature);
234+
ToolUseBlock fromBytes =
235+
ToolUseBlock.builder()
236+
.id("tool-id")
237+
.name("search")
238+
.input(Map.of("query", "agent"))
239+
.metadata(bytesMetadata)
240+
.build();
241+
242+
ToolUseBlock fromString =
243+
ToolUseBlock.builder()
244+
.id("tool-id")
245+
.name("search")
246+
.input(Map.of("query", "agent"))
247+
.metadata(Map.of(ToolUseBlock.METADATA_THOUGHT_SIGNATURE, encoded))
248+
.build();
249+
250+
assertEquals(fromString, fromBytes);
251+
assertEquals(fromString.hashCode(), fromBytes.hashCode());
252+
}
253+
188254
@Test
189255
void testInputMapIsUnmodifiable() {
190256
Map<String, Object> inputMap = Map.of("key1", "value1", "key2", "value2");

0 commit comments

Comments
 (0)