diff --git a/core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java b/core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java index 26f436f29..6d5246edc 100644 --- a/core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java +++ b/core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java @@ -16,6 +16,8 @@ package com.google.adk.plugins.agentanalytics; +import static java.util.Collections.newSetFromMap; + import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,14 +32,17 @@ import com.google.genai.types.FunctionCall; import com.google.genai.types.Part; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.logging.Logger; import org.jspecify.annotations.Nullable; /** Utility for parsing, formatting and truncating content for BigQuery logging. */ final class JsonFormatter { + private static final Logger logger = Logger.getLogger(JsonFormatter.class.getName()); private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); @AutoValue @@ -304,11 +309,15 @@ static TruncationResult smartTruncate(Object obj, int maxLength) { if (obj == null) { return TruncationResult.create(mapper.nullNode(), false); } + if (obj instanceof JsonNode jsonNode) { + return recursiveSmartTruncate(jsonNode, maxLength, newSetFromMap(new IdentityHashMap<>())); + } try { - return recursiveSmartTruncate(mapper.valueToTree(obj), maxLength); + return recursiveSmartTruncate( + mapper.valueToTree(obj), maxLength, newSetFromMap(new IdentityHashMap<>())); } catch (IllegalArgumentException e) { // Fallback for types that mapper can't handle directly as a tree - return truncateWithStatus(String.valueOf(obj), maxLength); + return truncateWithStatus(safeToString(obj), maxLength); } } @@ -320,37 +329,63 @@ static JsonNode convertToJsonNode(Object obj) { return mapper.valueToTree(obj); } catch (IllegalArgumentException e) { // Fallback for types that mapper can't handle directly as a tree - return mapper.valueToTree(String.valueOf(obj)); + return mapper.valueToTree(safeToString(obj)); } } - private static TruncationResult recursiveSmartTruncate(JsonNode node, int maxLength) { - boolean isTruncated = false; - if (node.isTextual()) { - String text = node.asText(); - if (text.length() > maxLength) { - return TruncationResult.create(mapper.valueToTree(truncate(text, maxLength)), true); + static String safeToString(Object obj) { + try { + return String.valueOf(obj); + } catch (StackOverflowError e) { + logger.warning("StackOverflowError when converting object to string"); + return "[STACK OVERFLOW ERROR CONVERTING TO STRING]"; + } catch (RuntimeException e) { + logger.warning("RuntimeException when converting object to string"); + return "[ERROR CONVERTING TO STRING]"; + } + } + + private static TruncationResult recursiveSmartTruncate( + JsonNode node, int maxLength, Set visited) { + if (node.isContainerNode()) { + if (visited.contains(node)) { + return TruncationResult.create(mapper.valueToTree("[CYCLE DETECTED]"), true); } - return TruncationResult.create(node, false); - } else if (node.isObject()) { - ObjectNode newNode = mapper.createObjectNode(); - Set> properties = node.properties(); - for (Map.Entry entry : properties) { - TruncationResult res = recursiveSmartTruncate(entry.getValue(), maxLength); - newNode.set(entry.getKey(), res.node()); - isTruncated = isTruncated || res.isTruncated(); + visited.add(node); + } + + try { + boolean isTruncated = false; + if (node.isTextual()) { + String text = node.asText(); + if (text.length() > maxLength) { + return TruncationResult.create(mapper.valueToTree(truncate(text, maxLength)), true); + } + return TruncationResult.create(node, false); + } else if (node.isObject()) { + ObjectNode newNode = mapper.createObjectNode(); + Set> properties = node.properties(); + for (Map.Entry entry : properties) { + TruncationResult res = recursiveSmartTruncate(entry.getValue(), maxLength, visited); + newNode.set(entry.getKey(), res.node()); + isTruncated = isTruncated || res.isTruncated(); + } + return TruncationResult.create(newNode, isTruncated); + } else if (node.isArray()) { + ArrayNode newNode = mapper.createArrayNode(); + for (JsonNode element : node) { + TruncationResult res = recursiveSmartTruncate(element, maxLength, visited); + newNode.add(res.node()); + isTruncated = isTruncated || res.isTruncated(); + } + return TruncationResult.create(newNode, isTruncated); } - return TruncationResult.create(newNode, isTruncated); - } else if (node.isArray()) { - ArrayNode newNode = mapper.createArrayNode(); - for (JsonNode element : node) { - TruncationResult res = recursiveSmartTruncate(element, maxLength); - newNode.add(res.node()); - isTruncated = isTruncated || res.isTruncated(); + return TruncationResult.create(node, false); + } finally { + if (node.isContainerNode()) { + visited.remove(node); } - return TruncationResult.create(newNode, isTruncated); } - return TruncationResult.create(node, false); } private static TruncationResult truncateWithStatus(String s, int maxLength) { diff --git a/core/src/test/java/com/google/adk/plugins/agentanalytics/JsonFormatterTest.java b/core/src/test/java/com/google/adk/plugins/agentanalytics/JsonFormatterTest.java index 739f3a7c3..08291ac7a 100644 --- a/core/src/test/java/com/google/adk/plugins/agentanalytics/JsonFormatterTest.java +++ b/core/src/test/java/com/google/adk/plugins/agentanalytics/JsonFormatterTest.java @@ -21,7 +21,9 @@ import static org.junit.Assert.assertTrue; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.adk.models.LlmRequest; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -142,4 +144,39 @@ public void parse_list_truncatesElements() { assertEquals("short", arrayNode.get(0).asText()); assertEquals("this is a ...[truncated]", arrayNode.get(1).asText()); } + + @Test + public void smartTruncate_withCycle_detectsCycle() { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.set("child", node); + + // Verify that smartTruncate handles circular JsonNode structures by detecting the cycle. + JsonFormatter.TruncationResult result = JsonFormatter.smartTruncate(node, 100); + + assertTrue(result.isTruncated()); + assertEquals("[CYCLE DETECTED]", result.node().get("child").asText()); + } + + @Test + public void smartTruncate_withToStringStackOverflow_handlesGracefully() { + Object recursiveObj = + new Object() { + @Override + public String toString() { + return String.valueOf(this); + } + }; + + // Verify both direct safeToString and via smartTruncate + assertEquals( + "[STACK OVERFLOW ERROR CONVERTING TO STRING]", JsonFormatter.safeToString(recursiveObj)); + + JsonFormatter.TruncationResult result = JsonFormatter.smartTruncate(recursiveObj, 100); + assertTrue(result.node().isTextual()); + // Note: This expectation depends on whether mapper.valueToTree(recursiveObj) + // throws IllegalArgumentException or StackOverflowError. + // If it throws StackOverflowError and we don't catch it in smartTruncate, this test will fail. + assertEquals("[STACK OVERFLOW ERROR CONVERTING TO STRING]", result.node().asText()); + } }