diff --git a/core/src/main/java/com/google/adk/plugins/DebugLoggingPlugin.java b/core/src/main/java/com/google/adk/plugins/DebugLoggingPlugin.java new file mode 100644 index 000000000..1d44749c7 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/DebugLoggingPlugin.java @@ -0,0 +1,730 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.plugins; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.APPEND; +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.WRITE; +import static java.util.Collections.newSetFromMap; +import static java.util.function.Predicate.not; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.adk.sessions.Session; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.ToolContext; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.genai.types.Blob; +import com.google.genai.types.CodeExecutionResult; +import com.google.genai.types.Content; +import com.google.genai.types.ExecutableCode; +import com.google.genai.types.FileData; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionResponse; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A plugin that captures complete debug information to a YAML file. + * + *

It records LLM requests and responses, tool calls and results, the events yielded by the + * runner, and, by default, the session state at the end of each invocation. Each invocation is + * appended to the file as a separate YAML document. Unlike {@link LoggingPlugin}, which prints + * truncated summaries to the console, this plugin captures the data in full. + * + *

Caution: the file holds raw prompts, tool arguments and session state. Where the file + * system supports POSIX permissions, the plugin creates it readable and writable by its owner only. + * + *

Example: + * + *

{@code
+ * Runner runner =
+ *     new InMemoryRunner(
+ *         agent,
+ *         APP_NAME,
+ *         ImmutableList.of(
+ *             DebugLoggingPlugin.builder().outputPath(Path.of("adk_debug.yaml")).build()));
+ * }
+ */ +public final class DebugLoggingPlugin extends BasePlugin { + + private static final Logger logger = LoggerFactory.getLogger(DebugLoggingPlugin.class); + + private static final String DEFAULT_NAME = "debug_logging_plugin"; + private static final String DEFAULT_OUTPUT_PATH = "adk_debug.yaml"; + private static final String NO_STATE_FOR_ENTRY = + "[{}] No debug state for invocation {}, skipping entry"; + private static final String NO_STATE_FOR_WRITE = + "[{}] No debug state for invocation {}, skipping write"; + private static final String WROTE_DEBUG_DATA = "[{}] Wrote debug data for invocation {} to {}"; + private static final String FAILED_TO_WRITE = "[{}] Failed to write debug data for invocation {}"; + private static final String BYTES_MARKER = ""; + private static final String UNSERIALIZABLE_MARKER = ""; + private static final String CIRCULAR_REFERENCE = ""; + private static final String UNKNOWN_ERROR_TYPE = "Unknown"; + private static final ImmutableSet APPEND_OPTIONS = + ImmutableSet.of(CREATE, WRITE, APPEND); + private static final FileAttribute> OWNER_ONLY = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + private static final ObjectMapper YAML = + new ObjectMapper( + YAMLFactory.builder().enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE).build()); + + private final Path outputPath; + private final boolean includeSessionState; + private final boolean includeSystemInstruction; + private final ConcurrentMap invocationStates = + new ConcurrentHashMap<>(); + private final Object writeLock = new Object(); + + /** Creates a plugin that writes {@code adk_debug.yaml} in the working directory. */ + public DebugLoggingPlugin() { + this(builder()); + } + + private DebugLoggingPlugin(Builder builder) { + super(builder.name); + this.outputPath = builder.outputPath; + this.includeSessionState = builder.includeSessionState; + this.includeSystemInstruction = builder.includeSystemInstruction; + } + + /** Returns a builder with the same defaults as {@link #DebugLoggingPlugin()}. */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link DebugLoggingPlugin}. */ + public static final class Builder { + private Path outputPath = Path.of(DEFAULT_OUTPUT_PATH); + private boolean includeSessionState = true; + private boolean includeSystemInstruction = true; + private String name = DEFAULT_NAME; + + private Builder() {} + + /** Sets the file the YAML documents are appended to; missing parent directories are created. */ + @CanIgnoreReturnValue + public Builder outputPath(Path outputPath) { + this.outputPath = checkNotNull(outputPath); + return this; + } + + /** Sets whether a session state snapshot is recorded at the end of each invocation. */ + @CanIgnoreReturnValue + public Builder includeSessionState(boolean includeSessionState) { + this.includeSessionState = includeSessionState; + return this; + } + + /** Sets whether system instructions are recorded in full, rather than only their presence. */ + @CanIgnoreReturnValue + public Builder includeSystemInstruction(boolean includeSystemInstruction) { + this.includeSystemInstruction = includeSystemInstruction; + return this; + } + + /** Sets the plugin instance name. */ + @CanIgnoreReturnValue + public Builder name(String name) { + this.name = checkNotNull(name); + return this; + } + + public DebugLoggingPlugin build() { + return new DebugLoggingPlugin(this); + } + } + + @Override + public Maybe onUserMessageCallback( + InvocationContext invocationContext, Content userMessage) { + // The runner invokes this hook before beforeRunCallback, so the state may not exist yet. + ensureState(invocationContext); + addEntry( + invocationContext, + "user_message", + null, + dataOf(field("content", serializeContent(userMessage)))); + return Maybe.empty(); + } + + @Override + public Maybe beforeRunCallback(InvocationContext invocationContext) { + ensureState(invocationContext); + addEntry( + invocationContext, + "invocation_start", + invocationContext.agent().name(), + dataOf(field("branch", invocationContext.branch()))); + return Maybe.empty(); + } + + @Override + public Maybe onEventCallback(InvocationContext invocationContext, Event event) { + addEntry(invocationContext, "event", event.author(), serializeEvent(event)); + return Maybe.empty(); + } + + @Override + public Completable afterRunCallback(InvocationContext invocationContext) { + return Completable.fromAction(() -> flush(invocationContext)); + } + + @Override + public Maybe beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) { + addEntry( + callbackContext.invocationContext(), + "agent_start", + callbackContext.agentName(), + dataOf(field("branch", callbackContext.branch()))); + return Maybe.empty(); + } + + @Override + public Maybe afterAgentCallback(BaseAgent agent, CallbackContext callbackContext) { + addEntry( + callbackContext.invocationContext(), + "agent_end", + callbackContext.agentName(), + ImmutableMap.of()); + return Maybe.empty(); + } + + @Override + public Maybe beforeModelCallback( + CallbackContext callbackContext, LlmRequest.Builder llmRequest) { + addEntry( + callbackContext.invocationContext(), + "llm_request", + callbackContext.agentName(), + serializeRequest(llmRequest.build())); + return Maybe.empty(); + } + + @Override + public Maybe afterModelCallback( + CallbackContext callbackContext, LlmResponse llmResponse) { + addEntry( + callbackContext.invocationContext(), + "llm_response", + callbackContext.agentName(), + serializeResponse(llmResponse)); + return Maybe.empty(); + } + + @Override + public Maybe onModelErrorCallback( + CallbackContext callbackContext, LlmRequest.Builder llmRequest, Throwable error) { + addEntry( + callbackContext.invocationContext(), + "llm_error", + callbackContext.agentName(), + dataOf( + field("error_type", errorType(error)), + field("error_message", error.getMessage()), + field("model", llmRequest.build().model()))); + return Maybe.empty(); + } + + @Override + public Maybe> beforeToolCallback( + BaseTool tool, Map toolArgs, ToolContext toolContext) { + addEntry( + toolContext.invocationContext(), + "tool_call", + toolContext.agentName(), + dataOf( + field("tool_name", tool.name()), + field("function_call_id", toolContext.functionCallId()), + field("args", safeSerializeMap(toolArgs)))); + return Maybe.empty(); + } + + @Override + public Maybe> afterToolCallback( + BaseTool tool, + Map toolArgs, + ToolContext toolContext, + Map result) { + addEntry( + toolContext.invocationContext(), + "tool_response", + toolContext.agentName(), + dataOf( + field("tool_name", tool.name()), + field("function_call_id", toolContext.functionCallId()), + field("result", safeSerializeMap(result)))); + return Maybe.empty(); + } + + @Override + public Maybe> onToolErrorCallback( + BaseTool tool, Map toolArgs, ToolContext toolContext, Throwable error) { + addEntry( + toolContext.invocationContext(), + "tool_error", + toolContext.agentName(), + dataOf( + field("tool_name", tool.name()), + field("function_call_id", toolContext.functionCallId()), + field("args", safeSerializeMap(toolArgs)), + field("error_type", errorType(error)), + field("error_message", error.getMessage()))); + return Maybe.empty(); + } + + /** Records the closing entries and appends the invocation; its state is dropped either way. */ + private void flush(InvocationContext context) { + String invocationId = context.invocationId(); + InvocationDebugState state = invocationStates.get(invocationId); + if (state == null) { + logger.warn(NO_STATE_FOR_WRITE, getName(), invocationId); + return; + } + try { + if (includeSessionState) { + addEntry(context, "session_state_snapshot", null, serializeSessionState(context.session())); + } + addEntry(context, "invocation_end", null, ImmutableMap.of()); + write(state); + } finally { + invocationStates.remove(invocationId); + } + } + + private void write(InvocationDebugState state) { + synchronized (writeLock) { + try { + append(YAML.writeValueAsString(serializeState(state))); + logger.debug(WROTE_DEBUG_DATA, getName(), state.invocationId(), outputPath); + } catch (IOException | RuntimeException e) { + logger.error(FAILED_TO_WRITE, getName(), state.invocationId(), e); + } + } + } + + private void append(String document) throws IOException { + Path parent = outputPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try (OutputStream out = Channels.newOutputStream(openForAppend())) { + out.write(document.getBytes(UTF_8)); + } + } + + /** + * Opens the file for appending; a file this creates is owner-only where the file system supports + * POSIX permissions. + */ + private SeekableByteChannel openForAppend() throws IOException { + return outputPath.getFileSystem().supportedFileAttributeViews().contains("posix") + ? Files.newByteChannel(outputPath, APPEND_OPTIONS, OWNER_ONLY) + : Files.newByteChannel(outputPath, APPEND_OPTIONS); + } + + private static String errorType(Throwable error) { + String simpleName = error.getClass().getSimpleName(); + return simpleName.isEmpty() ? UNKNOWN_ERROR_TYPE : simpleName; + } + + private void ensureState(InvocationContext context) { + invocationStates.computeIfAbsent(context.invocationId(), unused -> newState(context)); + } + + private static InvocationDebugState newState(InvocationContext context) { + Session session = context.session(); + return new InvocationDebugState( + context.invocationId(), + session.id(), + session.appName(), + session.userId(), + timestamp(context), + new ConcurrentLinkedQueue<>()); + } + + private void addEntry( + InvocationContext context, + String entryType, + @Nullable String agentName, + ImmutableMap data) { + String invocationId = context.invocationId(); + InvocationDebugState state = invocationStates.get(invocationId); + if (state == null) { + logger.warn(NO_STATE_FOR_ENTRY, getName(), invocationId); + return; + } + state + .entries() + .add(new DebugEntry(timestamp(context), entryType, invocationId, agentName, data)); + } + + private static String timestamp(InvocationContext context) { + return context.now().toString(); + } + + private static ImmutableMap serializeState(InvocationDebugState state) { + return dataOf( + field("invocation_id", state.invocationId()), + field("session_id", state.sessionId()), + field("app_name", state.appName()), + field("user_id", state.userId()), + field("start_time", state.startTime()), + field("entries", serializeEntries(state.entries()))); + } + + private static ImmutableList> serializeEntries( + Collection entries) { + return entries.stream().map(DebugLoggingPlugin::serializeEntry).collect(toImmutableList()); + } + + private static ImmutableMap serializeEntry(DebugEntry entry) { + return dataOf( + field("timestamp", entry.timestamp()), + field("entry_type", entry.entryType()), + field("invocation_id", entry.invocationId()), + field("agent_name", entry.agentName()), + field("data", nonEmpty(entry.data()))); + } + + private static ImmutableMap serializeSessionState(Session session) { + return dataOf( + field("state", safeSerializeMap(session.state())), + field("event_count", session.immutableEvents().size())); + } + + private static ImmutableMap serializeEvent(Event event) { + return dataOf( + field("event_id", event.id()), + field("author", event.author()), + field("content", event.content().map(DebugLoggingPlugin::serializeContent)), + field("is_final_response", event.finalResponse()), + field("partial", event.partial()), + field("turn_complete", event.turnComplete()), + field("branch", event.branch()), + field("actions", serializeActions(event.actions())), + field("usage_metadata", event.usageMetadata().map(DebugLoggingPlugin::serializeEventUsage)), + field("has_grounding_metadata", event.groundingMetadata().map(unused -> true)), + field("error_code", event.errorCode().map(String::valueOf)), + field("error_message", event.errorMessage()), + field( + "long_running_tool_ids", + event.longRunningToolIds().filter(not(Set::isEmpty)).map(ImmutableList::copyOf))); + } + + private static Optional> serializeActions(EventActions actions) { + return nonEmpty( + dataOf( + field( + "state_delta", + nonEmpty(actions.stateDelta()).map(DebugLoggingPlugin::safeSerializeMap)), + field("artifact_delta", nonEmpty(actions.artifactDelta()).map(ImmutableMap::copyOf)), + field("transfer_to_agent", actions.transferToAgent()), + field("escalate", actions.escalate().filter(Boolean::booleanValue)), + field( + "requested_auth_configs", nonEmpty(actions.requestedAuthConfigs()).map(Map::size)), + field( + "requested_tool_confirmations", + nonEmpty(actions.requestedToolConfirmations()).map(Map::size)))); + } + + private ImmutableMap serializeRequest(LlmRequest request) { + return dataOf( + field("model", request.model()), + field("content_count", request.contents().size()), + field("contents", serializeContents(request.contents())), + field( + "tools", + request.config().map(DebugLoggingPlugin::toolNames).filter(not(List::isEmpty))), + field("config", request.config().flatMap(this::serializeConfig))); + } + + private static ImmutableList toolNames(GenerateContentConfig config) { + return config.tools().orElse(ImmutableList.of()).stream() + .flatMap(tool -> tool.functionDeclarations().orElse(ImmutableList.of()).stream()) + .flatMap(declaration -> declaration.name().stream()) + .collect(toImmutableList()); + } + + private Optional> serializeConfig(GenerateContentConfig config) { + return nonEmpty( + dataOf( + systemInstruction(config), + field("temperature", config.temperature()), + field("top_p", config.topP()), + field("top_k", config.topK()), + field("max_output_tokens", config.maxOutputTokens()), + field("response_mime_type", config.responseMimeType()), + field("has_response_schema", config.responseSchema().map(unused -> true)))); + } + + /** The system instruction, or only a note that one is set when the switch is off. */ + private Map.Entry systemInstruction(GenerateContentConfig config) { + Optional instruction = config.systemInstruction(); + return includeSystemInstruction + ? field("system_instruction", instruction.map(DebugLoggingPlugin::serializeContent)) + : field("has_system_instruction", instruction.map(unused -> true)); + } + + private static ImmutableMap serializeResponse(LlmResponse response) { + return dataOf( + field("content", response.content().map(DebugLoggingPlugin::serializeContent)), + field("partial", response.partial()), + field("turn_complete", response.turnComplete()), + field("error_code", response.errorCode().map(String::valueOf)), + field("error_message", response.errorMessage()), + field( + "usage_metadata", + response.usageMetadata().map(DebugLoggingPlugin::serializeResponseUsage)), + field("has_grounding_metadata", response.groundingMetadata().map(unused -> true)), + field("finish_reason", response.finishReason().map(String::valueOf)), + field("model_version", response.modelVersion())); + } + + private static ImmutableMap serializeEventUsage( + GenerateContentResponseUsageMetadata usage) { + return dataOf( + field("prompt_token_count", usage.promptTokenCount()), + field("candidates_token_count", usage.candidatesTokenCount()), + field("total_token_count", usage.totalTokenCount())); + } + + private static ImmutableMap serializeResponseUsage( + GenerateContentResponseUsageMetadata usage) { + return dataOf( + field("prompt_token_count", usage.promptTokenCount()), + field("candidates_token_count", usage.candidatesTokenCount()), + field("total_token_count", usage.totalTokenCount()), + field("cached_content_token_count", usage.cachedContentTokenCount())); + } + + private static ImmutableList> serializeContents( + List contents) { + return contents.stream().map(DebugLoggingPlugin::serializeContent).collect(toImmutableList()); + } + + private static ImmutableMap serializeContent(Content content) { + return dataOf( + field("role", content.role()), + field( + "parts", + content.parts().orElse(ImmutableList.of()).stream() + .map(DebugLoggingPlugin::serializePart) + .filter(not(Map::isEmpty)) + .collect(toImmutableList()))); + } + + private static ImmutableMap serializePart(Part part) { + return dataOf( + field("text", part.text().filter(not(String::isEmpty))), + field("function_call", part.functionCall().map(DebugLoggingPlugin::serializeCall)), + field( + "function_response", + part.functionResponse().map(DebugLoggingPlugin::serializeFunctionResponse)), + field("inline_data", part.inlineData().map(DebugLoggingPlugin::serializeInlineData)), + field("file_data", part.fileData().map(DebugLoggingPlugin::serializeFileData)), + field( + "code_execution_result", + part.codeExecutionResult().map(DebugLoggingPlugin::serializeCodeExecutionResult)), + field( + "executable_code", + part.executableCode().map(DebugLoggingPlugin::serializeExecutableCode))); + } + + private static ImmutableMap serializeCall(FunctionCall call) { + return dataOf( + field("id", call.id()), + field("name", call.name()), + field("args", call.args().map(DebugLoggingPlugin::safeSerializeMap))); + } + + private static ImmutableMap serializeFunctionResponse(FunctionResponse response) { + return dataOf( + field("id", response.id()), + field("name", response.name()), + field("response", response.response().map(DebugLoggingPlugin::safeSerializeMap))); + } + + private static ImmutableMap serializeInlineData(Blob blob) { + // The bytes are omitted to keep the file size manageable. + return dataOf( + field("mime_type", blob.mimeType()), + field("display_name", blob.displayName()), + field("_data_omitted", true)); + } + + private static ImmutableMap serializeFileData(FileData fileData) { + return dataOf(field("file_uri", fileData.fileUri()), field("mime_type", fileData.mimeType())); + } + + private static ImmutableMap serializeCodeExecutionResult( + CodeExecutionResult result) { + return dataOf( + field("outcome", result.outcome().map(String::valueOf)), field("output", result.output())); + } + + private static ImmutableMap serializeExecutableCode(ExecutableCode code) { + return dataOf( + field("language", code.language().map(String::valueOf)), field("code", code.code())); + } + + private static @Nullable Object safeSerialize(@Nullable Object value, Set path) { + if (value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean) { + return value; + } + if (value instanceof byte[] bytes) { + return BYTES_MARKER.formatted(bytes.length); + } + if (value instanceof Map || value instanceof Collection || value instanceof Object[]) { + return copyContainer(value, path); + } + return describe(value); + } + + /** + * Copies a map, collection or array, writing {@link #CIRCULAR_REFERENCE} for a container that + * contains itself. + * + * @param path the containers currently being copied, compared by identity + */ + private static Object copyContainer(Object container, Set path) { + if (!path.add(container)) { + return CIRCULAR_REFERENCE; + } + Object copy = + container instanceof Map map + ? safeSerializeMap(map, path) + : safeSerializeList(items(container), path); + path.remove(container); + return copy; + } + + private static Collection items(Object container) { + return container instanceof Object[] array ? Arrays.asList(array) : (Collection) container; + } + + private static Map safeSerializeMap(Map map) { + Set path = newSetFromMap(new IdentityHashMap<>()); + path.add(map); + return safeSerializeMap(map, path); + } + + private static Map safeSerializeMap(Map map, Set path) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, value) -> copy.put(String.valueOf(key), safeSerialize(value, path))); + return copy; + } + + private static List safeSerializeList(Collection values, Set path) { + List copy = new ArrayList<>(); + values.forEach(value -> copy.add(safeSerialize(value, path))); + return copy; + } + + private static String describe(Object value) { + try { + return String.valueOf(value); + } catch (RuntimeException e) { + return UNSERIALIZABLE_MARKER; + } + } + + /** Builds an ordered map that omits null values and empty Optionals, unwrapping present ones. */ + @SafeVarargs + private static ImmutableMap dataOf(Map.Entry... fields) { + Map data = new LinkedHashMap<>(); + for (Map.Entry field : fields) { + unwrap(field.getValue()).ifPresent(value -> data.put(field.getKey(), value)); + } + return ImmutableMap.copyOf(data); + } + + private static Map.Entry field(String key, @Nullable Object value) { + return new AbstractMap.SimpleImmutableEntry<>(key, value); + } + + private static Optional unwrap(@Nullable Object value) { + return value instanceof Optional optional ? optional : Optional.ofNullable(value); + } + + private static > Optional nonEmpty(M map) { + return Optional.of(map).filter(not(Map::isEmpty)); + } + + private record DebugEntry( + String timestamp, + String entryType, + String invocationId, + @Nullable String agentName, + ImmutableMap data) {} + + private record InvocationDebugState( + String invocationId, + String sessionId, + @Nullable String appName, + @Nullable String userId, + String startTime, + Queue entries) {} +} diff --git a/core/src/test/java/com/google/adk/plugins/DebugLoggingPluginTest.java b/core/src/test/java/com/google/adk/plugins/DebugLoggingPluginTest.java new file mode 100644 index 000000000..61a599bce --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/DebugLoggingPluginTest.java @@ -0,0 +1,1257 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.plugins; + +import static com.google.adk.testing.TestUtils.createFunctionCallLlmResponse; +import static com.google.adk.testing.TestUtils.createInvocationContext; +import static com.google.adk.testing.TestUtils.createSubAgent; +import static com.google.adk.testing.TestUtils.createTestAgent; +import static com.google.adk.testing.TestUtils.createTestAgentBuilder; +import static com.google.adk.testing.TestUtils.createTestLlm; +import static com.google.adk.testing.TestUtils.createTextLlmResponse; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.nCopies; +import static org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.adk.agents.LlmAgent; +import com.google.adk.apps.App; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.events.ToolConfirmation; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.FunctionTool; +import com.google.adk.tools.ToolContext; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import com.google.genai.types.Blob; +import com.google.genai.types.CodeExecutionResult; +import com.google.genai.types.Content; +import com.google.genai.types.ExecutableCode; +import com.google.genai.types.FileData; +import com.google.genai.types.FinishReason; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionDeclaration; +import com.google.genai.types.FunctionResponse; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GoogleSearch; +import com.google.genai.types.GroundingMetadata; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import com.google.genai.types.Tool; +import io.reactivex.rxjava3.core.Flowable; +import java.io.File; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Instant; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Drives the plugin's hooks and reads the written YAML back. */ +@RunWith(JUnit4.class) +public class DebugLoggingPluginTest { + + private static final String INVOCATION_ID = "invocation_id"; + private static final String AGENT_NAME = "agent_name"; + private static final String APP_NAME = "app_name"; + private static final String USER_ID = "user_id"; + private static final String QUOTED = "\"%s\""; + private static final Instant NOW = Instant.parse("2026-10-23T18:30:00Z"); + private static final int WRITERS = 8; + private static final String INVOCATION_ID_FORMAT = "inv-%d"; + private static final int RACE_TRIALS = 10; + private static final String TRIAL_PATH = "race-%d/adk_debug.yaml"; + private static final String CIRCULAR = ""; + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private final BaseAgent agent = createSubAgent(AGENT_NAME); + private final BaseTool tool = new LookupOrderTool(); + + private final Session session = + Session.builder("session_id").appName(APP_NAME).userId(USER_ID).build(); + private final Content userMessage = + Content.builder() + .role("user") + .parts(ImmutableList.of(Part.builder().text("hello").build())) + .build(); + private final LlmRequest.Builder llmRequest = + LlmRequest.builder().model("gemini-2.0-flash").contents(ImmutableList.of()); + private final LlmResponse llmResponse = LlmResponse.builder().build(); + private final Event event = + Event.builder() + .id("event_id") + .author(AGENT_NAME) + .actions(EventActions.builder().build()) + .build(); + private final ImmutableMap toolArgs = ImmutableMap.of("query", "socks"); + private final ImmutableMap toolResult = ImmutableMap.of("status", "ok"); + private final Throwable error = new IllegalStateException("boom"); + + private Path tracePath; + private InvocationContext invocationContext; + private CallbackContext callbackContext; + private ToolContext toolContext; + + @Before + public void setUp() throws Exception { + // The directory does not exist yet, so every test that writes the file also covers its + // creation. + tracePath = tempFolder.getRoot().toPath().resolve("traces/adk_debug.yaml"); + + useSession(session); + } + + @Test + public void everyHook_recordsItsEntry_inTheOrderTheyFired() throws Exception { + runEveryHook(plugin()); + + assertThat(entryTypes()) + .containsExactly( + "user_message", + "invocation_start", + "agent_start", + "llm_request", + "llm_response", + "llm_error", + "tool_call", + "tool_response", + "tool_error", + "event", + "agent_end", + "session_state_snapshot", + "invocation_end") + .inOrder(); + } + + @Test + public void userMessage_arrivingBeforeTheRunStarts_isKeptRatherThanDropped() throws Exception { + DebugLoggingPlugin plugin = plugin(); + + plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryTypes()).containsAtLeast("user_message", "invocation_start").inOrder(); + assertThat(entryData("user_message").toString()).contains("hello"); + } + + @Test + public void userMessage_arrivingAfterTheRunStarts_isRecorded() throws Exception { + DebugLoggingPlugin plugin = plugin(); + + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryTypes()).containsAtLeast("invocation_start", "user_message").inOrder(); + assertThat(entryData("user_message")).containsExactly("content", textContent("user", "hello")); + } + + @Test + public void everyHook_returnsEmpty_soNothingIsAltered() { + DebugLoggingPlugin plugin = plugin(); + + assertThat(plugin.beforeRunCallback(invocationContext).blockingGet()).isNull(); + assertThat(plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet()).isNull(); + assertThat(plugin.beforeAgentCallback(agent, callbackContext).blockingGet()).isNull(); + assertThat(plugin.afterAgentCallback(agent, callbackContext).blockingGet()).isNull(); + assertThat(plugin.beforeModelCallback(callbackContext, llmRequest).blockingGet()).isNull(); + assertThat(plugin.afterModelCallback(callbackContext, llmResponse).blockingGet()).isNull(); + assertThat(plugin.onModelErrorCallback(callbackContext, llmRequest, error).blockingGet()) + .isNull(); + assertThat(plugin.beforeToolCallback(tool, toolArgs, toolContext).blockingGet()).isNull(); + assertThat(plugin.afterToolCallback(tool, toolArgs, toolContext, toolResult).blockingGet()) + .isNull(); + assertThat(plugin.onToolErrorCallback(tool, toolArgs, toolContext, error).blockingGet()) + .isNull(); + assertThat(plugin.onEventCallback(invocationContext, event).blockingGet()).isNull(); + } + + @Test + public void toolCall_carriesTheToolNameCallIdAndArguments() throws Exception { + runEveryHook(plugin()); + + Map toolCall = entryData("tool_call"); + assertThat(toolCall.get("tool_name")).isEqualTo("lookup_order"); + assertThat(toolCall.get("function_call_id")).isEqualTo("call-1"); + assertThat(toolCall.get("args")).isEqualTo(ImmutableMap.of("query", "socks")); + } + + @Test + public void toolResponseAndToolError_carryTheirFullData() throws Exception { + runEveryHook(plugin()); + + assertThat(entryData("tool_response")) + .containsExactly( + "tool_name", "lookup_order", + "function_call_id", "call-1", + "result", ImmutableMap.of("status", "ok")) + .inOrder(); + assertThat(entryData("tool_error")) + .containsExactly( + "tool_name", "lookup_order", + "function_call_id", "call-1", + "args", ImmutableMap.of("query", "socks"), + "error_type", "IllegalStateException", + "error_message", "boom") + .inOrder(); + } + + @Test + public void llmError_recordsTheExceptionTypeAndModel() throws Exception { + runEveryHook(plugin()); + + Map llmError = entryData("llm_error"); + assertThat(llmError.get("error_type")).isEqualTo("IllegalStateException"); + assertThat(llmError.get("error_message")).isEqualTo("boom"); + assertThat(llmError.get("model")).isEqualTo("gemini-2.0-flash"); + } + + @Test + public void includeSessionState_whenOff_omitsTheSnapshot() throws Exception { + runEveryHook( + DebugLoggingPlugin.builder().outputPath(tracePath).includeSessionState(false).build()); + + assertThat(entryTypes()).doesNotContain("session_state_snapshot"); + assertThat(entryTypes()).contains("invocation_end"); + } + + @Test + public void includeSystemInstruction_whenOff_notesItsPresenceWithoutTheText() throws Exception { + llmRequest.config( + GenerateContentConfig.builder() + .systemInstruction( + Content.builder() + .parts(ImmutableList.of(Part.builder().text("be terse").build())) + .build()) + .build()); + + runEveryHook( + DebugLoggingPlugin.builder().outputPath(tracePath).includeSystemInstruction(false).build()); + + Map config = (Map) entryData("llm_request").get("config"); + assertThat(config.get("has_system_instruction")).isEqualTo(true); + assertThat(Files.readString(tracePath)).doesNotContain("be terse"); + } + + @Test + public void includeSystemInstruction_whenOn_recordsTheTextAndTheGenerationSettings() + throws Exception { + llmRequest.config( + GenerateContentConfig.builder() + .systemInstruction( + Content.builder() + .parts(ImmutableList.of(Part.builder().text("be terse").build())) + .build()) + .temperature(0.5f) + .topP(0.25f) + .topK(40f) + .maxOutputTokens(256) + .responseMimeType("application/json") + .responseSchema(Schema.builder().type("OBJECT").build()) + .build()); + + runEveryHook(plugin()); + + assertThat(entryData("llm_request").get("config")) + .isEqualTo( + ImmutableMap.of( + "system_instruction", + ImmutableMap.of("parts", ImmutableList.of(ImmutableMap.of("text", "be terse"))), + "temperature", + 0.5, + "top_p", + 0.25, + "top_k", + 40.0, + "max_output_tokens", + 256, + "response_mime_type", + "application/json", + "has_response_schema", + true)); + } + + @Test + public void hooksWithoutAnInvocation_areDroppedRatherThanThrown() throws Exception { + DebugLoggingPlugin plugin = plugin(); + + plugin.onEventCallback(invocationContext, event).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(Files.exists(tracePath)).isFalse(); + } + + @Test + public void afterRun_dropsTheInvocation_soASecondRunWritesNothingMore() throws Exception { + DebugLoggingPlugin plugin = plugin(); + runEveryHook(plugin); + + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(readDocuments()).hasSize(1); + } + + @Test + public void defaultName_isDebugLoggingPlugin() { + assertThat(new DebugLoggingPlugin().getName()).isEqualTo("debug_logging_plugin"); + } + + @Test + public void builder_rejectsANullPathOrName() { + assertThrows(NullPointerException.class, () -> DebugLoggingPlugin.builder().outputPath(null)); + assertThrows(NullPointerException.class, () -> DebugLoggingPlugin.builder().name(null)); + } + + @Test + public void entries_writeTheirKeysInOrder_andOmitAnAbsentAgentName() throws Exception { + runEveryHook(plugin()); + + assertThat(entry("tool_call").keySet()) + .containsExactly("timestamp", "entry_type", "invocation_id", "agent_name", "data") + .inOrder(); + assertThat(entry("user_message").keySet()) + .containsExactly("timestamp", "entry_type", "invocation_id", "data") + .inOrder(); + } + + @Test + public void sessionWithoutAppNameOrUserId_omitsThoseHeaderKeys() throws Exception { + useSession(Session.builder("session_id").build()); + + runEveryHook(plugin()); + + assertThat(readDocuments().get(0).keySet()) + .containsExactly("invocation_id", "session_id", "start_time", "entries") + .inOrder(); + } + + @Test + public void llmRequest_withoutToolNamesOrSettings_omitsToolsAndConfig() throws Exception { + llmRequest + .contents(ImmutableList.of(Content.builder().role("user").build())) + .config( + GenerateContentConfig.builder() + .tools( + ImmutableList.of( + Tool.builder().googleSearch(GoogleSearch.builder().build()).build(), + Tool.builder() + .functionDeclarations( + ImmutableList.of( + FunctionDeclaration.builder().description("unnamed").build())) + .build())) + .build()); + + runEveryHook(plugin()); + + assertThat(entryData("llm_request")) + .containsExactly( + "model", + "gemini-2.0-flash", + "content_count", + 1, + "contents", + ImmutableList.of(ImmutableMap.of("role", "user", "parts", ImmutableList.of()))) + .inOrder(); + } + + @Test + public void event_withAnEmptyLongRunningToolIdSet_omitsTheKey() throws Exception { + runEvent(event.toBuilder().longRunningToolIds(ImmutableSet.of()).build()); + + assertThat(entryData("event")).doesNotContainKey("long_running_tool_ids"); + } + + @Test + public void bareFileNameOnAPosixFileSystem_isCreatedOwnerOnly() throws Exception { + try (FileSystem unix = + Jimfs.newFileSystem( + Configuration.unix().toBuilder() + .setAttributeViews("basic", "owner", "posix", "unix") + .build())) { + Path bareName = unix.getPath("adk_debug.yaml"); + + runEveryHook(DebugLoggingPlugin.builder().outputPath(bareName).build()); + + assertThat(Files.getPosixFilePermissions(bareName)) + .containsExactly(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + assertThat(Files.readString(bareName)).startsWith("---"); + } + } + + @Test + public void builderName_setsThePluginName() { + assertThat(DebugLoggingPlugin.builder().name("trace").build().getName()).isEqualTo("trace"); + } + + @Test + public void multiLineText_isWrittenAsALiteralBlock() throws Exception { + ImmutableMap result = ImmutableMap.of("summary", "line one\nline two"); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, result).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(Files.readString(tracePath)).contains("summary: |-"); + assertThat(entryData("tool_response").get("result")).isEqualTo(result); + } + + @Test + public void unwritablePath_doesNotThrow() throws Exception { + File blocker = tempFolder.newFile("not-a-directory"); + DebugLoggingPlugin plugin = + DebugLoggingPlugin.builder() + .outputPath(blocker.toPath().resolve("nested/adk_debug.yaml")) + .build(); + + runEveryHook(plugin); + + assertThat(blocker.length()).isEqualTo(0); + } + + @Test + public void concurrentInvocations_eachAppendOneIntactDocument() throws Exception { + ImmutableList invocationIds = invocationIds(WRITERS); + + runConcurrently(ImmutableList.copyOf(nCopies(WRITERS, plugin())), invocationIds); + + assertThat(readDocuments().stream().map(document -> document.get("invocation_id"))) + .containsExactlyElementsIn(invocationIds); + } + + @Test + public void twoPluginsOnOnePath_eachAppendTheirDocument() throws Exception { + ImmutableList invocationIds = invocationIds(2); + for (int trial = 0; trial < RACE_TRIALS; trial++) { + tracePath = tempFolder.getRoot().toPath().resolve(TRIAL_PATH.formatted(trial)); + + runConcurrently(ImmutableList.of(plugin(), plugin()), invocationIds); + + assertThat(readDocuments().stream().map(document -> document.get("invocation_id"))) + .containsExactlyElementsIn(invocationIds); + } + } + + @Test + public void restrictsOutputFileToOwner() throws Exception { + runEveryHook(plugin()); + + PosixFileAttributeView posixView = + Files.getFileAttributeView(tracePath, PosixFileAttributeView.class); + // POSIX permissions are only asserted on filesystems that support them. + if (posixView != null) { + assertThat(posixView.readAttributes().permissions()) + .containsExactly(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + } + } + + @Test + public void scalarLikeStrings_areQuotedAndReadBackAsStrings() throws Exception { + ImmutableMap result = + ImmutableMap.of( + "inf", + ".inf", + "hex", + "0x1F", + "underscored", + "1_000", + "date", + "2026-01-01", + "int", + "42"); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, result).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryData("tool_response").get("result")).isEqualTo(result); + String text = Files.readString(tracePath); + for (Object value : result.values()) { + assertThat(text).contains(QUOTED.formatted(value)); + } + } + + @Test + public void runAsync_withThePluginRegistered_completesOnTheCallersThread() { + LlmAgent rootAgent = createTestAgent(createTestLlm(createTextLlmResponse("done"))); + AtomicReference completionThread = new AtomicReference<>(); + + runAsync(rootAgent) + .doOnComplete(() -> completionThread.set(Thread.currentThread())) + .blockingSubscribe(); + + assertThat(completionThread.get()).isSameInstanceAs(Thread.currentThread()); + } + + @Test + public void runnerTurnWithAToolCall_isTracedEndToEnd() throws Exception { + LlmAgent rootAgent = + createTestAgentBuilder( + createTestLlm( + createFunctionCallLlmResponse("call-1", "alpha", ImmutableMap.of()), + createTextLlmResponse("done"))) + .tools(ImmutableList.of(FunctionTool.create(DebugLoggingPluginTest.class, "alpha"))) + .build(); + Runner runner = runner(rootAgent); + Session runSession = runner.sessionService().createSession(APP_NAME, USER_ID).blockingGet(); + + runner.runAsync(USER_ID, runSession.id(), userMessage).blockingSubscribe(); + + assertThat(entryTypes()) + .containsExactly( + "user_message", + "invocation_start", + "agent_start", + "llm_request", + "llm_response", + "event", + "tool_call", + "tool_response", + "event", + "llm_request", + "llm_response", + "event", + "agent_end", + "session_state_snapshot", + "invocation_end") + .inOrder(); + assertThat(entryData("llm_request").get("contents")) + .isEqualTo(ImmutableList.of(textContent("user", "hello"))); + assertThat(entryData("tool_call")) + .containsAtLeast("tool_name", "alpha", "function_call_id", "call-1"); + Session stored = + runner + .sessionService() + .getSession(APP_NAME, USER_ID, runSession.id(), Optional.empty()) + .blockingGet(); + assertThat(entryData("session_state_snapshot").get("event_count")) + .isEqualTo(stored.immutableEvents().size()); + } + + @Test + public void llmRequestTools_areRecordedInDeclarationOrder() throws Exception { + LlmAgent rootAgent = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("done"))) + .tools( + ImmutableList.of( + FunctionTool.create(DebugLoggingPluginTest.class, "alpha"), + FunctionTool.create(DebugLoggingPluginTest.class, "beta"), + FunctionTool.create(DebugLoggingPluginTest.class, "gamma"))) + .build(); + + runAsync(rootAgent).blockingSubscribe(); + + assertThat(entryData("llm_request").get("tools")) + .isEqualTo(ImmutableList.of("alpha", "beta", "gamma")); + } + + @Test + public void eventUsageMetadata_isWrittenBeforeGroundingMetadata() throws Exception { + runEvent( + event.toBuilder() + .usageMetadata( + GenerateContentResponseUsageMetadata.builder().promptTokenCount(1).build()) + .groundingMetadata(GroundingMetadata.builder().build()) + .build()); + + assertThat(entryData("event").keySet()) + .containsExactly( + "event_id", "author", "is_final_response", "usage_metadata", "has_grounding_metadata") + .inOrder(); + } + + @Test + public void toolResult_nullValue_isWrittenAsNull() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("discount", null); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, result).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + Map written = (Map) entryData("tool_response").get("result"); + assertThat(written).containsExactly("discount", null); + } + + @Test + public void timestamps_areUtcInstantsFromTheInvocationsInstantSource() throws Exception { + runEveryHook(plugin()); + + Map document = readDocuments().get(0); + assertThat(document.get("start_time")).isEqualTo(NOW.toString()); + List entries = (List) document.get("entries"); + for (Object entry : entries) { + assertThat(((Map) entry).get("timestamp")).isEqualTo(NOW.toString()); + } + } + + @Test + public void escalate_whenFalse_isOmittedWithItsEmptyActionsBlock() throws Exception { + runEvent(eventWithActions(EventActions.builder().escalate(false).build())); + + assertThat(entryData("event")).doesNotContainKey("actions"); + } + + @Test + public void escalate_whenTrue_isRecorded() throws Exception { + runEvent(eventWithActions(EventActions.builder().escalate(true).build())); + + Map actions = (Map) entryData("event").get("actions"); + assertThat(actions).containsExactly("escalate", true); + } + + @Test + public void functionCallAndResponse_withEmptyMaps_keepArgsAndResponse() throws Exception { + Content content = + Content.builder() + .role("model") + .parts( + ImmutableList.of( + Part.builder() + .functionCall( + FunctionCall.builder() + .id("call-1") + .name("lookup_order") + .args(ImmutableMap.of()) + .build()) + .build(), + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("call-1") + .name("lookup_order") + .response(ImmutableMap.of()) + .build()) + .build())) + .build(); + + runEvent(event.toBuilder().content(content).build()); + + List parts = (List) ((Map) entryData("event").get("content")).get("parts"); + Map functionCall = (Map) ((Map) parts.get(0)).get("function_call"); + Map functionResponse = (Map) ((Map) parts.get(1)).get("function_response"); + assertThat(functionCall.get("args")).isEqualTo(ImmutableMap.of()); + assertThat(functionResponse.get("response")).isEqualTo(ImmutableMap.of()); + } + + @Test + public void contentParts_recordEachKindAndOmitTheBytes() throws Exception { + byte[] bytes = "secret".getBytes(UTF_8); + Content content = + Content.builder() + .role("model") + .parts( + ImmutableList.of( + Part.builder().text("").build(), + Part.builder() + .inlineData(Blob.builder().mimeType("image/png").data(bytes).build()) + .build(), + Part.builder() + .fileData( + FileData.builder() + .fileUri("gs://bucket/report.pdf") + .mimeType("application/pdf") + .build()) + .build(), + Part.builder() + .codeExecutionResult( + CodeExecutionResult.builder().outcome("OUTCOME_OK").output("4").build()) + .build(), + Part.builder() + .executableCode( + ExecutableCode.builder() + .language("PYTHON") + .code("print(2 + 2)") + .build()) + .build())) + .build(); + + runEvent(event.toBuilder().content(content).build()); + + assertThat(((Map) entryData("event").get("content")).get("parts")) + .isEqualTo( + ImmutableList.of( + ImmutableMap.of( + "inline_data", + ImmutableMap.of("mime_type", "image/png", "_data_omitted", true)), + ImmutableMap.of( + "file_data", + ImmutableMap.of( + "file_uri", "gs://bucket/report.pdf", "mime_type", "application/pdf")), + ImmutableMap.of( + "code_execution_result", + ImmutableMap.of("outcome", "OUTCOME_OK", "output", "4")), + ImmutableMap.of( + "executable_code", + ImmutableMap.of("language", "PYTHON", "code", "print(2 + 2)")))); + assertThat(Files.readString(tracePath)) + .doesNotContain(Base64.getEncoder().encodeToString(bytes)); + } + + @Test + public void llmResponse_recordsItsFlagsErrorFinishReasonAndModelVersion() throws Exception { + LlmResponse response = + LlmResponse.builder() + .partial(true) + .turnComplete(true) + .errorCode(new FinishReason(FinishReason.Known.SAFETY)) + .errorMessage("blocked") + .groundingMetadata(GroundingMetadata.builder().build()) + .finishReason(new FinishReason(FinishReason.Known.STOP)) + .modelVersion("gemini-2.0-flash-001") + .build(); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterModelCallback(callbackContext, response).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryData("llm_response")) + .containsExactly( + "partial", true, + "turn_complete", true, + "error_code", "SAFETY", + "error_message", "blocked", + "has_grounding_metadata", true, + "finish_reason", "STOP", + "model_version", "gemini-2.0-flash-001") + .inOrder(); + } + + @Test + public void event_recordsItsFlagsBranchErrorAndLongRunningToolIds() throws Exception { + runEvent( + event.toBuilder() + .partial(true) + .turnComplete(true) + .branch("root.child") + .errorCode(new FinishReason(FinishReason.Known.SAFETY)) + .errorMessage("blocked") + .longRunningToolIds(ImmutableSet.of("call-1")) + .build()); + + assertThat(entryData("event")) + .containsAtLeast( + "partial", + true, + "turn_complete", + true, + "branch", + "root.child", + "error_code", + "SAFETY", + "error_message", + "blocked", + "long_running_tool_ids", + ImmutableList.of("call-1")) + .inOrder(); + } + + @Test + public void eventActions_recordDeltasTransferAndAuthConfigCount() throws Exception { + runEvent( + eventWithActions( + EventActions.builder() + .stateDelta(ImmutableMap.of("cart", "socks")) + .artifactDelta(ImmutableMap.of("report.pdf", 1)) + .transferToAgent("billing") + .requestedAuthConfigs( + ImmutableMap.of( + "call-1", + ImmutableMap.of("scheme", "oauth"), + "call-2", + ImmutableMap.of("scheme", "apiKey"))) + .build())); + + assertThat((Map) entryData("event").get("actions")) + .containsExactly( + "state_delta", + ImmutableMap.of("cart", "socks"), + "artifact_delta", + ImmutableMap.of("report.pdf", 1), + "transfer_to_agent", + "billing", + "requested_auth_configs", + 2) + .inOrder(); + } + + @Test + public void toolValues_areCopiedIntoPlainYamlValues() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("bytes", new byte[3]); + result.put("nested", ImmutableMap.of("inner", ImmutableMap.of("key", "value"))); + result.put("numberKeys", ImmutableMap.of(7, "seven")); + result.put("list", Arrays.asList("a", null)); + result.put("array", new Object[] {1, "b"}); + result.put("flag", true); + result.put("ratio", 2.5); + result.put("object", new Printable()); + result.put("unprintable", new Unprintable()); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, result).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat((Map) entryData("tool_response").get("result")) + .containsExactly( + "bytes", + "", + "nested", + ImmutableMap.of("inner", ImmutableMap.of("key", "value")), + "numberKeys", + ImmutableMap.of("7", "seven"), + "list", + Arrays.asList("a", null), + "array", + ImmutableList.of(1, "b"), + "flag", + true, + "ratio", + 2.5, + "object", + "printable", + "unprintable", + "") + .inOrder(); + } + + @Test + public void selfReferencingToolValues_areWrittenAsCircularReferences() throws Exception { + Map branching = new LinkedHashMap<>(); + branching.put("left", branching); + branching.put("right", branching); + List list = new ArrayList<>(); + list.add(list); + Object[] array = new Object[1]; + array[0] = array; + Map result = new LinkedHashMap<>(); + result.put("self", result); + result.put("map", branching); + result.put("list", list); + result.put("array", array); + + runToolResponse(result); + + assertThat(entryData("tool_response").get("result")) + .isEqualTo( + ImmutableMap.of( + "self", + CIRCULAR, + "map", + ImmutableMap.of("left", CIRCULAR, "right", CIRCULAR), + "list", + ImmutableList.of(CIRCULAR), + "array", + ImmutableList.of(CIRCULAR))); + } + + @Test + public void sharedToolValue_isWrittenInFullEachTime() throws Exception { + ImmutableMap shared = ImmutableMap.of("sku", "socks"); + + runToolResponse(ImmutableMap.of("first", shared, "second", ImmutableList.of(shared))); + + assertThat(entryData("tool_response").get("result")) + .isEqualTo(ImmutableMap.of("first", shared, "second", ImmutableList.of(shared))); + } + + @Test + public void nonAsciiText_isWrittenUnescaped() throws Exception { + String text = "café 東京 ✓"; + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin + .afterToolCallback(tool, toolArgs, toolContext, ImmutableMap.of("text", text)) + .blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(Files.readString(tracePath)).contains(QUOTED.formatted(text)); + } + + @Test + public void usageMetadata_responseHasFourCountsAndEventHasThree() throws Exception { + GenerateContentResponseUsageMetadata usage = + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(1) + .candidatesTokenCount(2) + .totalTokenCount(3) + .cachedContentTokenCount(4) + .build(); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin + .afterModelCallback(callbackContext, LlmResponse.builder().usageMetadata(usage).build()) + .blockingGet(); + plugin + .onEventCallback(invocationContext, event.toBuilder().usageMetadata(usage).build()) + .blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryData("llm_response").get("usage_metadata")) + .isEqualTo( + ImmutableMap.of( + "prompt_token_count", 1, + "candidates_token_count", 2, + "total_token_count", 3, + "cached_content_token_count", 4)); + assertThat(entryData("event").get("usage_metadata")) + .isEqualTo( + ImmutableMap.of( + "prompt_token_count", 1, "candidates_token_count", 2, "total_token_count", 3)); + } + + @Test + public void capturesLlmRequestAndResponse() throws Exception { + LlmRequest.Builder request = + LlmRequest.builder() + .model("dummy-model") + .contents( + ImmutableList.of( + Content.builder().role("user").parts(Part.fromText("prompt text")).build())); + LlmResponse response = + LlmResponse.builder() + .content(Content.builder().role("model").parts(Part.fromText("response text")).build()) + .build(); + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.beforeModelCallback(callbackContext, request).blockingGet(); + plugin.afterModelCallback(callbackContext, response).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + Map requestData = entryData("llm_request"); + assertThat(requestData.get("model")).isEqualTo("dummy-model"); + assertThat(requestData.get("contents")) + .isEqualTo(ImmutableList.of(textContent("user", "prompt text"))); + assertThat(entryData("llm_response").get("content")) + .isEqualTo(textContent("model", "response text")); + } + + @Test + public void writesInvocationDocumentToFile() throws Exception { + runEveryHook(plugin()); + + Map document = readDocuments().get(0); + assertThat(document.keySet()) + .containsExactly( + "invocation_id", "session_id", "app_name", "user_id", "start_time", "entries") + .inOrder(); + assertThat(document) + .containsAtLeast( + "invocation_id", INVOCATION_ID, + "session_id", "session_id", + "app_name", APP_NAME, + "user_id", USER_ID); + assertThat(entryTypes().get(1)).isEqualTo("invocation_start"); + assertThat(entry("invocation_start").get("agent_name")).isEqualTo(AGENT_NAME); + assertThat(Iterables.getLast(entryTypes())).isEqualTo("invocation_end"); + assertThat(Files.readString(tracePath)).startsWith("---"); + } + + @Test + public void sessionStateSnapshot_carriesTheStateAndEventCount() throws Exception { + Session withState = + Session.builder("session_id") + .appName(APP_NAME) + .userId(USER_ID) + .state(ImmutableMap.of("cart", "socks")) + .events(ImmutableList.of(event, event.toBuilder().id("event_2").build())) + .build(); + useSession(withState); + + runEveryHook(plugin()); + + assertThat(entryData("session_state_snapshot")) + .containsExactly("state", ImmutableMap.of("cart", "socks"), "event_count", 2); + } + + @Test + public void runAndAgentStart_recordTheBranch() throws Exception { + invocationContext = invocationContext.toBuilder().branch("root.child").build(); + callbackContext = new CallbackContext(invocationContext, /* eventActions= */ null); + DebugLoggingPlugin plugin = plugin(); + + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.beforeAgentCallback(agent, callbackContext).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryData("invocation_start")).containsExactly("branch", "root.child"); + assertThat(entryData("agent_start")).containsExactly("branch", "root.child"); + } + + @Test + public void entriesWithoutData_omitTheDataKey() throws Exception { + runEveryHook(plugin()); + + assertThat(entry("invocation_start")).doesNotContainKey("data"); + assertThat(entry("agent_start")).doesNotContainKey("data"); + assertThat(entry("agent_end")).doesNotContainKey("data"); + assertThat(entry("invocation_end")).doesNotContainKey("data"); + } + + @Test + public void anonymousErrorClass_isRecordedAsUnknown() throws Exception { + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin + .onModelErrorCallback(callbackContext, llmRequest, new RuntimeException() {}) + .blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(entryData("llm_error").get("error_type")).isEqualTo("Unknown"); + } + + @Test + public void requestedToolConfirmations_areRecordedAsACount() throws Exception { + ToolConfirmation confirmation = ToolConfirmation.builder().hint("Ship it?").build(); + runEvent( + eventWithActions( + EventActions.builder() + .requestedToolConfirmations( + ImmutableMap.of("call-1", confirmation, "call-2", confirmation)) + .build())); + + Map actions = (Map) entryData("event").get("actions"); + assertThat(actions).containsExactly("requested_tool_confirmations", 2); + } + + private DebugLoggingPlugin plugin() { + return DebugLoggingPlugin.builder().outputPath(tracePath).build(); + } + + /** Drives every hook once, user message first. */ + private void runEveryHook(DebugLoggingPlugin plugin) { + plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.beforeAgentCallback(agent, callbackContext).blockingGet(); + plugin.beforeModelCallback(callbackContext, llmRequest).blockingGet(); + plugin.afterModelCallback(callbackContext, llmResponse).blockingGet(); + plugin.onModelErrorCallback(callbackContext, llmRequest, error).blockingGet(); + plugin.beforeToolCallback(tool, toolArgs, toolContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, toolResult).blockingGet(); + plugin.onToolErrorCallback(tool, toolArgs, toolContext, error).blockingGet(); + plugin.onEventCallback(invocationContext, event).blockingGet(); + plugin.afterAgentCallback(agent, callbackContext).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + } + + private ImmutableList> readDocuments() throws Exception { + ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + try (MappingIterator> documents = + yaml.readerFor(Map.class).readValues(tracePath.toFile())) { + return documents.readAll().stream().map(ImmutableMap::copyOf).collect(toImmutableList()); + } + } + + private ImmutableList entryTypes() throws Exception { + List entries = (List) readDocuments().get(0).get("entries"); + return entries.stream() + .map(entry -> (String) ((Map) entry).get("entry_type")) + .collect(toImmutableList()); + } + + private static ImmutableList invocationIds(int count) { + return IntStream.range(0, count) + .mapToObj(INVOCATION_ID_FORMAT::formatted) + .collect(toImmutableList()); + } + + /** Runs invocation {@code i} through {@code plugins.get(i)}, all released at once. */ + private void runConcurrently( + ImmutableList plugins, ImmutableList invocationIds) + throws Exception { + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(plugins.size()); + ExecutorService pool = Executors.newFixedThreadPool(plugins.size()); + try { + for (int i = 0; i < plugins.size(); i++) { + DebugLoggingPlugin plugin = plugins.get(i); + InvocationContext context = invocation(invocationIds.get(i)); + pool.execute(() -> awaitThenRun(plugin, context, start, done)); + } + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + } + + /** Points every context at a real invocation of {@code invocationSession}. */ + private void useSession(Session invocationSession) { + invocationContext = invocation(INVOCATION_ID, invocationSession); + callbackContext = new CallbackContext(invocationContext, /* eventActions= */ null); + toolContext = ToolContext.builder(invocationContext).functionCallId("call-1").build(); + } + + private InvocationContext invocation(String invocationId) { + return invocation(invocationId, session); + } + + private InvocationContext invocation(String invocationId, Session invocationSession) { + return createInvocationContext(agent, new InMemorySessionService(), invocationSession) + .toBuilder() + .invocationId(invocationId) + .instantSource(InstantSource.fixed(NOW)) + .build(); + } + + private static void awaitThenRun( + DebugLoggingPlugin plugin, + InvocationContext context, + CountDownLatch start, + CountDownLatch done) { + try { + start.await(); + plugin.beforeRunCallback(context).blockingGet(); + plugin.afterRunCallback(context).blockingAwait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + } + + public static Map alpha() { + return ImmutableMap.of(); + } + + public static Map beta() { + return ImmutableMap.of(); + } + + public static Map gamma() { + return ImmutableMap.of(); + } + + /** One turn through a real {@link Runner} with the plugin registered. */ + private Flowable runAsync(LlmAgent rootAgent) { + Runner runner = runner(rootAgent); + Session runSession = runner.sessionService().createSession(APP_NAME, USER_ID).blockingGet(); + return runner.runAsync(USER_ID, runSession.id(), userMessage); + } + + private Runner runner(LlmAgent rootAgent) { + return Runner.builder() + .app( + App.builder() + .name(APP_NAME) + .rootAgent(rootAgent) + .plugins(ImmutableList.of(plugin())) + .build()) + .build(); + } + + /** A tool that is never run; the hooks only read its name. */ + private static final class LookupOrderTool extends BaseTool { + LookupOrderTool() { + super("lookup_order", "Looks up an order"); + } + } + + private static final class Printable { + @Override + public String toString() { + return "printable"; + } + } + + private static final class Unprintable { + @Override + public String toString() { + throw new IllegalStateException("no text"); + } + } + + private void runToolResponse(Map result) { + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.afterToolCallback(tool, toolArgs, toolContext, result).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + } + + private void runEvent(Event runEvent) { + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + plugin.onEventCallback(invocationContext, runEvent).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + } + + private Event eventWithActions(EventActions actions) { + return event.toBuilder().actions(actions).build(); + } + + private static ImmutableMap textContent(String role, String text) { + return ImmutableMap.of("role", role, "parts", ImmutableList.of(ImmutableMap.of("text", text))); + } + + /** The data of the first entry of that type. */ + private ImmutableMap entryData(String entryType) throws Exception { + return ImmutableMap.copyOf((Map) entry(entryType).get("data")); + } + + /** + * The first entry of that type, found by type rather than index so that a new entry cannot + * repoint an assertion. + */ + private ImmutableMap entry(String entryType) throws Exception { + List entries = (List) readDocuments().get(0).get("entries"); + return entries.stream() + .map(entry -> (Map) entry) + .filter(entry -> entryType.equals(entry.get("entry_type"))) + .findFirst() + .map(ImmutableMap::copyOf) + .orElseThrow(); + } +}