Skip to content

Commit 209dd57

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add JSON cycle detection
This chanve enhances the JsonFormatter within the ADK core to detect and handle circular references in JSON structures during smart truncation, preventing infinite recursion. PiperOrigin-RevId: 904554270
1 parent 4009905 commit 209dd57

2 files changed

Lines changed: 60 additions & 24 deletions

File tree

core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import com.google.genai.types.FunctionCall;
3131
import com.google.genai.types.Part;
3232
import java.util.ArrayList;
33+
import java.util.Collections;
34+
import java.util.IdentityHashMap;
3335
import java.util.List;
3436
import java.util.Map;
3537
import java.util.Optional;
@@ -304,8 +306,13 @@ static TruncationResult smartTruncate(Object obj, int maxLength) {
304306
if (obj == null) {
305307
return TruncationResult.create(mapper.nullNode(), false);
306308
}
309+
if (obj instanceof JsonNode jsonNode) {
310+
return recursiveSmartTruncate(
311+
jsonNode, maxLength, Collections.newSetFromMap(new IdentityHashMap<>()));
312+
}
307313
try {
308-
return recursiveSmartTruncate(mapper.valueToTree(obj), maxLength);
314+
return recursiveSmartTruncate(
315+
mapper.valueToTree(obj), maxLength, Collections.newSetFromMap(new IdentityHashMap<>()));
309316
} catch (IllegalArgumentException e) {
310317
// Fallback for types that mapper can't handle directly as a tree
311318
return truncateWithStatus(String.valueOf(obj), maxLength);
@@ -324,33 +331,47 @@ static JsonNode convertToJsonNode(Object obj) {
324331
}
325332
}
326333

327-
private static TruncationResult recursiveSmartTruncate(JsonNode node, int maxLength) {
328-
boolean isTruncated = false;
329-
if (node.isTextual()) {
330-
String text = node.asText();
331-
if (text.length() > maxLength) {
332-
return TruncationResult.create(mapper.valueToTree(truncate(text, maxLength)), true);
334+
private static TruncationResult recursiveSmartTruncate(
335+
JsonNode node, int maxLength, Set<JsonNode> visited) {
336+
if (node.isContainerNode()) {
337+
if (visited.contains(node)) {
338+
return TruncationResult.create(mapper.valueToTree("[CYCLE DETECTED]"), true);
333339
}
334-
return TruncationResult.create(node, false);
335-
} else if (node.isObject()) {
336-
ObjectNode newNode = mapper.createObjectNode();
337-
Set<Map.Entry<String, JsonNode>> properties = node.properties();
338-
for (Map.Entry<String, JsonNode> entry : properties) {
339-
TruncationResult res = recursiveSmartTruncate(entry.getValue(), maxLength);
340-
newNode.set(entry.getKey(), res.node());
341-
isTruncated = isTruncated || res.isTruncated();
340+
visited.add(node);
341+
}
342+
343+
try {
344+
boolean isTruncated = false;
345+
if (node.isTextual()) {
346+
String text = node.asText();
347+
if (text.length() > maxLength) {
348+
return TruncationResult.create(mapper.valueToTree(truncate(text, maxLength)), true);
349+
}
350+
return TruncationResult.create(node, false);
351+
} else if (node.isObject()) {
352+
ObjectNode newNode = mapper.createObjectNode();
353+
Set<Map.Entry<String, JsonNode>> properties = node.properties();
354+
for (Map.Entry<String, JsonNode> entry : properties) {
355+
TruncationResult res = recursiveSmartTruncate(entry.getValue(), maxLength, visited);
356+
newNode.set(entry.getKey(), res.node());
357+
isTruncated = isTruncated || res.isTruncated();
358+
}
359+
return TruncationResult.create(newNode, isTruncated);
360+
} else if (node.isArray()) {
361+
ArrayNode newNode = mapper.createArrayNode();
362+
for (JsonNode element : node) {
363+
TruncationResult res = recursiveSmartTruncate(element, maxLength, visited);
364+
newNode.add(res.node());
365+
isTruncated = isTruncated || res.isTruncated();
366+
}
367+
return TruncationResult.create(newNode, isTruncated);
342368
}
343-
return TruncationResult.create(newNode, isTruncated);
344-
} else if (node.isArray()) {
345-
ArrayNode newNode = mapper.createArrayNode();
346-
for (JsonNode element : node) {
347-
TruncationResult res = recursiveSmartTruncate(element, maxLength);
348-
newNode.add(res.node());
349-
isTruncated = isTruncated || res.isTruncated();
369+
return TruncationResult.create(node, false);
370+
} finally {
371+
if (node.isContainerNode()) {
372+
visited.remove(node);
350373
}
351-
return TruncationResult.create(newNode, isTruncated);
352374
}
353-
return TruncationResult.create(node, false);
354375
}
355376

356377
private static TruncationResult truncateWithStatus(String s, int maxLength) {

core/src/test/java/com/google/adk/plugins/agentanalytics/JsonFormatterTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import static org.junit.Assert.assertTrue;
2222

2323
import com.fasterxml.jackson.databind.JsonNode;
24+
import com.fasterxml.jackson.databind.ObjectMapper;
2425
import com.fasterxml.jackson.databind.node.ArrayNode;
26+
import com.fasterxml.jackson.databind.node.ObjectNode;
2527
import com.google.adk.models.LlmRequest;
2628
import com.google.common.collect.ImmutableList;
2729
import com.google.common.collect.ImmutableMap;
@@ -142,4 +144,17 @@ public void parse_list_truncatesElements() {
142144
assertEquals("short", arrayNode.get(0).asText());
143145
assertEquals("this is a ...[truncated]", arrayNode.get(1).asText());
144146
}
147+
148+
@Test
149+
public void smartTruncate_withCycle_detectsCycle() {
150+
ObjectMapper mapper = new ObjectMapper();
151+
ObjectNode node = mapper.createObjectNode();
152+
node.set("child", node);
153+
154+
// Verify that smartTruncate handles circular JsonNode structures by detecting the cycle.
155+
JsonFormatter.TruncationResult result = JsonFormatter.smartTruncate(node, 100);
156+
157+
assertTrue(result.isTruncated());
158+
assertEquals("[CYCLE DETECTED]", result.node().get("child").asText());
159+
}
145160
}

0 commit comments

Comments
 (0)