diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java new file mode 100644 index 000000000..606844323 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java @@ -0,0 +1,201 @@ +/* + * 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.debuglogging; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.function.Predicate.not; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +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.Part; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * One {@link Content} as it appears in the trace — port of adk-python's {@code _serialize_content}. + * + *

The factory requires a content; callers holding an {@code Optional} use {@link + * Optional#map} rather than passing null. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record ContentTrace(Optional role, ImmutableList parts) { + + /** Always produced — an empty part list is still worth recording, as in the original. */ + static ContentTrace from(Content content) { + return new ContentTrace(content.role(), traceParts(content)); + } + + private static ImmutableList traceParts(Content content) { + return content.parts().orElseGet(ImmutableList::of).stream() + .map(PartTrace::from) + .flatMap(Optional::stream) + .collect(toImmutableList()); + } + + /** + * One {@link Part} as it appears in the trace — the declared shape of the per-part dict that + * adk-python builds in {@code _serialize_content}. + * + *

The record is the schema: the field names are the trace's keys, and a part kind + * that is not declared here cannot appear in the output. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record PartTrace( + Optional text, + @JsonProperty("function_call") Optional functionCall, + @JsonProperty("function_response") Optional functionResponse, + @JsonProperty("inline_data") Optional inlineData, + @JsonProperty("file_data") Optional fileData, + @JsonProperty("code_execution_result") Optional codeExecutionResult, + @JsonProperty("executable_code") Optional executableCode) { + + /** + * The part's trace form, or empty when it carries nothing worth recording — adk-python's {@code + * if part_data:} guard. + */ + static Optional from(Part part) { + PartTrace trace = + new PartTrace( + part.text().filter(not(String::isEmpty)), + part.functionCall().map(FunctionCallTrace::from), + part.functionResponse().map(FunctionResponseTrace::from), + part.inlineData().map(InlineDataTrace::from), + part.fileData().map(FileDataTrace::from), + part.codeExecutionResult().map(CodeExecutionResultTrace::from), + part.executableCode().map(ExecutableCodeTrace::from)); + return trace.isEmpty() ? Optional.empty() : Optional.of(trace); + } + + /** Whether every declared field is absent, so the part would serialize to {@code {}}. */ + @JsonIgnore + boolean isEmpty() { + return Stream.of( + text, + functionCall, + functionResponse, + inlineData, + fileData, + codeExecutionResult, + executableCode) + .allMatch(Optional::isEmpty); + } + + /** + * A tool call the model requested. + * + *

{@code args} stays untyped because it genuinely is: a tool author chooses the argument + * shape, so there is no schema to declare. It goes through {@link SafeSerializer} for the same + * reason adk-python routes it through {@code _safe_serialize}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record FunctionCallTrace( + Optional id, + Optional name, + @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableMap args) { + + static FunctionCallTrace from(FunctionCall call) { + return new FunctionCallTrace( + call.id(), + call.name(), + call.args().map(SafeSerializer::serializeMap).orElseGet(ImmutableMap::of)); + } + } + + /** + * What a tool returned. + * + *

{@code response} is untyped for the same reason as {@link FunctionCallTrace#args()}, and + * is contained by {@link SafeSerializer}, which is not optional here: the stock mapper throws + * on a perfectly legal tool result. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record FunctionResponseTrace( + Optional id, + Optional name, + @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableMap response) { + + static FunctionResponseTrace from(FunctionResponse functionResponse) { + return new FunctionResponseTrace( + functionResponse.id(), + functionResponse.name(), + functionResponse + .response() + .map(SafeSerializer::serializeMap) + .orElseGet(ImmutableMap::of)); + } + } + + /** + * What an inline blob is — never what it contains. + * + *

This record has no field for the bytes, which is the point. adk-python drops them by + * choosing not to copy them into a dict, commenting "Omit actual data to keep file size + * manageable"; here the omission is structural, so a later edit cannot reintroduce a base64 + * wall by accident. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record InlineDataTrace( + @JsonProperty("mime_type") Optional mimeType, + @JsonProperty("display_name") Optional displayName, + @JsonProperty("_data_omitted") boolean dataOmitted) { + + static InlineDataTrace from(Blob blob) { + return new InlineDataTrace(blob.mimeType(), blob.displayName(), true); + } + } + + /** A file reference carried by a part. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record FileDataTrace( + @JsonProperty("file_uri") Optional fileUri, + @JsonProperty("mime_type") Optional mimeType) { + + static FileDataTrace from(FileData fileData) { + return new FileDataTrace(fileData.fileUri(), fileData.mimeType()); + } + } + + /** The outcome of an executed code block. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record CodeExecutionResultTrace(Optional outcome, Optional output) { + + static CodeExecutionResultTrace from(CodeExecutionResult result) { + return new CodeExecutionResultTrace(result.outcome().map(String::valueOf), result.output()); + } + } + + /** A code block the model asked to run. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record ExecutableCodeTrace(Optional language, Optional code) { + + static ExecutableCodeTrace from(ExecutableCode executableCode) { + return new ExecutableCodeTrace( + executableCode.language().map(String::valueOf), executableCode.code()); + } + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java new file mode 100644 index 000000000..4c76cdd4c --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java @@ -0,0 +1,87 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.common.base.Preconditions; +import java.util.Optional; + +/** + * One line of the trace — port of adk-python's {@code _DebugEntry}. + * + *

Everything but {@code data} is the same for every entry; {@code data} is what {@link + * TracePayload} models. Note that {@code agentName} is an entry field rather than payload, which is + * why {@code agent_end} can carry {@link TracePayload.MarkerTrace} and still name an agent. + * + *

Component order is the serialized order, and matches upstream's field declaration. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record DebugEntry( + String timestamp, + @JsonProperty("entry_type") Type entryType, + @JsonProperty("invocation_id") String invocationId, + @JsonProperty("agent_name") Optional agentName, + TracePayload data) { + + /** + * These components come from a caller's arguments rather than from an ADK object, so they are + * worth guarding: a null here would not fail until the YAML write, on a different thread, long + * after the hook that caused it returned. + */ + DebugEntry { + Preconditions.checkNotNull(timestamp); + Preconditions.checkNotNull(entryType); + Preconditions.checkNotNull(invocationId); + Preconditions.checkNotNull(agentName); + Preconditions.checkNotNull(data); + } + + /** + * The kinds of entry, with the wire names upstream writes as bare strings. + * + *

An enum rather than a {@code String}: a typo in one of these would produce a + * plausible-looking file that silently does not match adk-python's. + */ + enum Type { + INVOCATION_START("invocation_start"), + USER_MESSAGE("user_message"), + AGENT_START("agent_start"), + AGENT_END("agent_end"), + LLM_REQUEST("llm_request"), + LLM_RESPONSE("llm_response"), + LLM_ERROR("llm_error"), + TOOL_CALL("tool_call"), + TOOL_RESPONSE("tool_response"), + TOOL_ERROR("tool_error"), + EVENT("event"), + SESSION_STATE_SNAPSHOT("session_state_snapshot"), + INVOCATION_END("invocation_end"); + + private final String wireName; + + Type(String wireName) { + this.wireName = wireName; + } + + @JsonValue + String wireName() { + return wireName; + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java new file mode 100644 index 000000000..d5b36e49c --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java @@ -0,0 +1,280 @@ +/* + * 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.debuglogging; + +import static com.google.common.base.Preconditions.checkNotNull; + +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.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.plugins.debuglogging.DebugEntry.Type; +import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace; +import com.google.adk.plugins.debuglogging.TracePayload.LlmErrorTrace; +import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace; +import com.google.adk.plugins.debuglogging.TracePayload.SessionStateTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolCallTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolErrorTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace; +import com.google.adk.plugins.debuglogging.TracePayload.UserMessageTrace; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.ToolContext; +import com.google.genai.types.Content; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.nio.file.Path; +import java.time.Clock; +import java.util.Map; + +/** + * Captures a complete record of an invocation to a YAML file for debugging. + * + *

Each invocation becomes one YAML document appended to the output file, holding the user + * message, every LLM request and response, every tool call and result, every event yielded by the + * runner, and — optionally — the session state as the invocation ended. + * + *

Port of adk-python's {@code DebugLoggingPlugin}. Refer to: + * https://github.com/google/adk-python/blob/main/src/google/adk/plugins/debug_logging_plugin.py + * + *

Example: + * + *

{@code
+ * Runner runner =
+ *     new InMemoryRunner(
+ *         agent, APP_NAME, ImmutableList.of(new DebugLoggingPlugin(Path.of("adk_debug.yaml"))));
+ * }
+ * + *

Every hook is observe-only. None returns a value that changes the run: the plugin + * cannot rewrite a request, substitute a tool result, or swallow an error. Failures inside it are + * logged, never thrown. + * + *

Traces contain whatever the model and the tools said, so treat the output file as sensitive: + * it is meant to be read, and pasted into a bug report, by someone entitled to see the + * conversation. Two things are deliberately never written — the bytes of inline data, and the + * contents of requested auth configs, of which only a count is recorded. + */ +public class DebugLoggingPlugin extends BasePlugin { + + private static final String DEFAULT_NAME = "debug_logging_plugin"; + private static final String DEFAULT_OUTPUT_PATH = "adk_debug.yaml"; + + private final boolean includeSessionState; + private final boolean includeSystemInstruction; + private final DebugTraceRecorder recorder = new DebugTraceRecorder(Clock.systemDefaultZone()); + private final DebugYamlWriter writer; + + /** Writes {@code adk_debug.yaml} in the working directory, recording everything. */ + public DebugLoggingPlugin() { + this(DEFAULT_NAME, Path.of(DEFAULT_OUTPUT_PATH), true, true); + } + + /** As above, at a path of your choosing. */ + public DebugLoggingPlugin(Path outputPath) { + this(DEFAULT_NAME, outputPath, true, true); + } + + /** + * @param name plugin instance identifier + * @param outputPath file the YAML documents are appended to; missing parent directories are + * created + * @param includeSessionState whether to append a session state snapshot when an invocation ends + * @param includeSystemInstruction whether to record system instructions in full, rather than + * noting only that one was present + * @throws NullPointerException if {@code outputPath} is null + */ + public DebugLoggingPlugin( + String name, Path outputPath, boolean includeSessionState, boolean includeSystemInstruction) { + super(name); + this.writer = new DebugYamlWriter(checkNotNull(outputPath, "outputPath cannot be null")); + this.includeSessionState = includeSessionState; + this.includeSystemInstruction = includeSystemInstruction; + } + + @Override + public Maybe beforeRunCallback(InvocationContext invocationContext) { + startInvocation(invocationContext); + return Maybe.empty(); + } + + /** + * Records the message that started the invocation. + * + *

This hook runs before {@link #beforeRunCallback}, so it opens the invocation rather + * than assuming one is already open — see {@link DebugTraceRecorder#start}. + */ + @Override + public Maybe onUserMessageCallback( + InvocationContext invocationContext, Content userMessage) { + openInvocation(invocationContext); + recorder.record( + invocationContext.invocationId(), Type.USER_MESSAGE, UserMessageTrace.from(userMessage)); + return Maybe.empty(); + } + + @Override + public Maybe beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) { + recordBranch(callbackContext, Type.AGENT_START); + return Maybe.empty(); + } + + @Override + public Maybe afterAgentCallback(BaseAgent agent, CallbackContext callbackContext) { + recorder.record( + callbackContext.invocationId(), + Type.AGENT_END, + callbackContext.agentName(), + MarkerTrace.INSTANCE); + return Maybe.empty(); + } + + @Override + public Maybe beforeModelCallback( + CallbackContext callbackContext, LlmRequest.Builder llmRequest) { + recorder.record( + callbackContext.invocationId(), + Type.LLM_REQUEST, + callbackContext.agentName(), + LlmRequestTrace.from(llmRequest.build(), includeSystemInstruction)); + return Maybe.empty(); + } + + @Override + public Maybe afterModelCallback( + CallbackContext callbackContext, LlmResponse llmResponse) { + recorder.record( + callbackContext.invocationId(), + Type.LLM_RESPONSE, + callbackContext.agentName(), + LlmResponseTrace.from(llmResponse)); + return Maybe.empty(); + } + + @Override + public Maybe onModelErrorCallback( + CallbackContext callbackContext, LlmRequest.Builder llmRequest, Throwable error) { + recorder.record( + callbackContext.invocationId(), + Type.LLM_ERROR, + callbackContext.agentName(), + LlmErrorTrace.from(error, llmRequest.build())); + return Maybe.empty(); + } + + @Override + public Maybe> beforeToolCallback( + BaseTool tool, Map toolArgs, ToolContext toolContext) { + recorder.record( + toolContext.invocationId(), + Type.TOOL_CALL, + toolContext.agentName(), + ToolCallTrace.of(tool.name(), toolContext.functionCallId().orElse(null), toolArgs)); + return Maybe.empty(); + } + + @Override + public Maybe> afterToolCallback( + BaseTool tool, + Map toolArgs, + ToolContext toolContext, + Map result) { + recorder.record( + toolContext.invocationId(), + Type.TOOL_RESPONSE, + toolContext.agentName(), + ToolResponseTrace.of(tool.name(), toolContext.functionCallId().orElse(null), result)); + return Maybe.empty(); + } + + @Override + public Maybe> onToolErrorCallback( + BaseTool tool, Map toolArgs, ToolContext toolContext, Throwable error) { + recorder.record( + toolContext.invocationId(), + Type.TOOL_ERROR, + toolContext.agentName(), + ToolErrorTrace.of(tool.name(), toolContext.functionCallId().orElse(null), toolArgs, error)); + return Maybe.empty(); + } + + @Override + public Maybe onEventCallback(InvocationContext invocationContext, Event event) { + recorder.record( + invocationContext.invocationId(), Type.EVENT, event.author(), EventTrace.from(event)); + return Maybe.empty(); + } + + /** + * Writes the invocation out. + * + *

{@code afterRunCallback} returns a {@link Completable}, so the file write runs on {@link + * Schedulers#io()} rather than on whichever thread finished the run. + */ + @Override + public Completable afterRunCallback(InvocationContext invocationContext) { + return Completable.fromAction(() -> flush(invocationContext)).subscribeOn(Schedulers.io()); + } + + /** Opens the invocation if this is the first hook to reach it; harmless if it is not. */ + private void openInvocation(InvocationContext context) { + recorder.start(InvocationDebugState.of(context, recorder.now())); + } + + private void startInvocation(InvocationContext context) { + openInvocation(context); + recorder.record( + context.invocationId(), + Type.INVOCATION_START, + context.agent().name(), + new BranchTrace(context.branch())); + } + + private void recordBranch(CallbackContext callbackContext, Type type) { + recorder.record( + callbackContext.invocationId(), + type, + callbackContext.agentName(), + new BranchTrace(callbackContext.branch())); + } + + private void flush(InvocationContext context) { + recorder.forWrite(context.invocationId()).ifPresent(state -> closeAndWrite(context, state)); + } + + /** + * The closing entries go in before the write, and the state is dropped afterwards whatever + * happens — upstream's {@code finally} in {@code after_run_callback}. Without it a failed write + * would leak one invocation's entries for the lifetime of the plugin. + */ + private void closeAndWrite(InvocationContext context, InvocationDebugState state) { + try { + if (includeSessionState) { + recorder.record( + context.invocationId(), + Type.SESSION_STATE_SNAPSHOT, + SessionStateTrace.from(context.session())); + } + recorder.record(context.invocationId(), Type.INVOCATION_END, MarkerTrace.INSTANCE); + writer.append(state); + } finally { + recorder.finish(context.invocationId()); + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java new file mode 100644 index 000000000..230b3665b --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java @@ -0,0 +1,139 @@ +/* + * 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.debuglogging; + +import com.google.common.base.Preconditions; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The live invocations, and the timestamped entries filed into them — upstream's {@code + * _invocation_states} dict together with {@code _add_entry}. + * + *

The two belong to one another: an entry is only ever created in order to be filed, and it is + * filed against a state that is looked up in the same breath. It is also the only class that reads + * the clock — the header and the first entry are stamped from the same source, so they cannot + * disagree about when the run began. + * + *

It also answers "what if there is no state for this id?". Upstream warns and carries on, with + * two different messages depending on whether an entry or a write asked; both are preserved, so a + * trace of adk-java's logs reads the same as adk-python's. Neither path throws: a missing state is + * logged and the run continues. + * + *

The map is concurrent because ADK runs invocations in parallel and every hook may arrive on a + * different thread. + */ +final class DebugTraceRecorder { + + private static final Logger logger = LoggerFactory.getLogger(DebugTraceRecorder.class); + + 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"; + + /** + * Upstream writes {@code datetime.now().isoformat()}, so the timestamp is local time with no + * offset. The pattern is stated rather than left to {@link LocalDateTime#toString()}, which drops + * zero seconds and prints a varying number of fractional digits — a trace reads better when every + * line is the same width and sorts lexicographically. + */ + private static final DateTimeFormatter TIMESTAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"); + + private final ConcurrentMap states = new ConcurrentHashMap<>(); + private final Clock clock; + + DebugTraceRecorder(Clock clock) { + this.clock = Preconditions.checkNotNull(clock); + } + + /** + * Registers {@code state} unless this invocation already has one. + * + *

"Unless" is load-bearing. {@code Runner.runAsync} invokes {@code onUserMessageCallback} + * before {@code beforeRunCallback}, so either hook may be the first to reach an + * invocation. Both call this, the second finds the state already open, and the user message — the + * first thing anyone reading a debug trace looks for — is recorded rather than filed against an + * invocation that does not exist yet. + * + *

The state is still created once, still keyed by invocation id, and still holds the same + * header. This is the port's one behavioral difference from adk-python, which opens the state in + * the later hook only. + */ + void start(InvocationDebugState state) { + states.putIfAbsent(state.invocationId(), state); + } + + /** For the entry types upstream records without an agent name. */ + void record(String invocationId, DebugEntry.Type type, TracePayload data) { + record(invocationId, type, null, data); + } + + /** + * Nullable so the overload above can omit the agent name, and a plain {@code String} rather than + * an {@code Optional} because adk-java's {@code illegal-optional-check} profile bans {@code + * Optional} as a parameter. + */ + void record( + String invocationId, DebugEntry.Type type, @Nullable String agentName, TracePayload data) { + DebugEntry entry = + new DebugEntry(now(), type, invocationId, Optional.ofNullable(agentName), data); + forEntry(invocationId).ifPresent(state -> state.add(entry)); + } + + /** The state an entry belongs to, or empty — with upstream's warning — if it is gone. */ + Optional forEntry(String invocationId) { + return lookup(invocationId, NO_STATE_FOR_ENTRY); + } + + /** + * The state to write out, left in place so that the closing entries can still be filed under it. + * + *

Upstream removes the state in a {@code finally} after writing, not before, + * precisely because {@code session_state_snapshot} and {@code invocation_end} are recorded in + * between. {@link #finish} is that {@code finally}. + */ + Optional forWrite(String invocationId) { + return lookup(invocationId, NO_STATE_FOR_WRITE); + } + + /** Drops the invocation, whether or not the write succeeded. */ + void finish(String invocationId) { + states.remove(invocationId); + } + + /** The same clock the entries use, for an invocation's {@code start_time}. */ + String now() { + return LocalDateTime.now(clock).format(TIMESTAMP); + } + + private Optional lookup(String invocationId, String warning) { + Optional state = Optional.ofNullable(states.get(invocationId)); + if (state.isEmpty()) { + logger.warn(warning, invocationId); + } + return state; + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java new file mode 100644 index 000000000..61c9530d2 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java @@ -0,0 +1,135 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.google.common.base.Preconditions; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Appends one invocation to the trace file as a YAML document — the write block in adk-python's + * {@code after_run_callback}. + * + *

Two differences from the original, both forced by the JVM: + * + *

+ * + *

The document separator comes from Jackson, which writes {@code ---} at the start of every + * document by default — so, unlike upstream, this class does not write one itself. Writing both + * would produce two separators per invocation and a file that reads as if documents were missing. + * + *

Nothing here throws: a failed write is logged and the run continues. + */ +final class DebugYamlWriter { + + private static final Logger logger = LoggerFactory.getLogger(DebugYamlWriter.class); + + private static final String WROTE_TRACE = "Wrote debug data for invocation {} to {}"; + private static final String FAILED_TO_WRITE = "Failed to write debug data for invocation {}"; + + private final Path outputPath; + private final ObjectMapper mapper = configure(new ObjectMapper(yamlFactory())); + + /** + * Applies the trace settings to {@code mapper} and returns it, for JSON or YAML alike — the tests + * assert on the JSON form, this class writes the YAML one, and both must agree about which keys + * exist. + * + *

Deliberately not {@code JsonBaseModel.getMapper()}: that instance is shared across + * all of ADK and is configured with {@code Include.ALWAYS}, so changing it to suit a debug trace + * would change everyone's serialization. The trace records here are ours, so they get their own + * mapper. + * + *

{@link Jdk8Module} makes {@code Optional} fields serialize as their contents, and {@code + * NON_ABSENT} omits the empty ones — together reproducing pydantic's {@code exclude_none=True} + * without a stripping pass. + */ + static T configure(T mapper) { + mapper.registerModule(new Jdk8Module()); + mapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT); + return mapper; + } + + /** + * Jackson quotes every scalar by default; PyYAML quotes only what it must, so a mapper left at + * its defaults writes a file that is correct but harder to read. Three features fix that without + * changing what the file means: + * + *

+ */ + private static YAMLFactory yamlFactory() { + return new YAMLFactory() + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) + .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE); + } + + DebugYamlWriter(Path outputPath) { + this.outputPath = Preconditions.checkNotNull(outputPath); + } + + /** Appends {@code state} as one document, or logs why it could not be. */ + synchronized void append(InvocationDebugState state) { + try { + write(mapper.writeValueAsString(state)); + logger.debug(WROTE_TRACE, state.invocationId(), outputPath); + } catch (IOException | RuntimeException e) { + logger.error(FAILED_TO_WRITE, state.invocationId(), e); + } + } + + private void write(String document) throws IOException { + Path parent = outputPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString( + outputPath, + document, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java new file mode 100644 index 000000000..a182aa659 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java @@ -0,0 +1,123 @@ +/* + * 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.debuglogging; + +import static java.util.function.Predicate.not; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import java.util.Optional; + +/** + * One {@link Event} as it appears in the trace — port of adk-python's {@code on_event_callback}. + * + *

Grounding metadata is recorded as a bare {@code has_grounding_metadata: true} rather than + * copied, as upstream does — the payload is large and adds nothing to a readable trace. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record EventTrace( + @JsonProperty("event_id") String eventId, + String author, + Optional content, + @JsonProperty("is_final_response") boolean isFinalResponse, + Optional partial, + @JsonProperty("turn_complete") Optional turnComplete, + Optional branch, + Optional actions, + @JsonProperty("has_grounding_metadata") Optional hasGroundingMetadata, + @JsonProperty("usage_metadata") Optional usageMetadata, + @JsonProperty("error_code") Optional errorCode, + @JsonProperty("error_message") Optional errorMessage, + @JsonProperty("long_running_tool_ids") @JsonInclude(JsonInclude.Include.NON_EMPTY) + ImmutableList longRunningToolIds) + implements TracePayload { + + static EventTrace from(Event event) { + return new EventTrace( + event.id(), + event.author(), + event.content().map(ContentTrace::from), + event.finalResponse(), + event.partial(), + event.turnComplete(), + event.branch(), + EventActionsTrace.from(event.actions()), + event.groundingMetadata().map(unused -> Boolean.TRUE), + event.usageMetadata().map(UsageTrace::fromEvent), + event.errorCode().map(String::valueOf), + event.errorMessage(), + event.longRunningToolIds().map(ImmutableList::copyOf).orElseGet(ImmutableList::of)); + } + + /** + * What an event's {@link EventActions} contributed, as it appears in the trace. + * + *

Two details are load-bearing and both come from upstream's {@code on_event_callback}: + * + *

+ * + *

{@link EventActions} hands out its live maps, so every one of them is copied rather + * than referenced — a trace must not change after it was taken. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record EventActionsTrace( + @JsonProperty("state_delta") @JsonInclude(JsonInclude.Include.NON_EMPTY) + ImmutableMap stateDelta, + @JsonProperty("artifact_delta") @JsonInclude(JsonInclude.Include.NON_EMPTY) + ImmutableMap artifactDelta, + @JsonProperty("transfer_to_agent") Optional transferToAgent, + Optional escalate, + @JsonProperty("requested_auth_configs") Optional requestedAuthConfigs) { + + /** Empty when the event requested nothing, matching upstream's {@code if actions_data:}. */ + static Optional from(EventActions actions) { + EventActionsTrace trace = + new EventActionsTrace( + SafeSerializer.serializeMap(actions.stateDelta()), + ImmutableMap.copyOf(actions.artifactDelta()), + actions.transferToAgent(), + actions.escalate(), + countOf(actions.requestedAuthConfigs())); + return trace.isEmpty() ? Optional.empty() : Optional.of(trace); + } + + /** How many were requested — never which, since the map is free-form and auth-related. */ + private static Optional countOf(Map requested) { + return Optional.of(requested).filter(not(Map::isEmpty)).map(Map::size); + } + + @JsonIgnore + boolean isEmpty() { + return stateDelta.isEmpty() + && artifactDelta.isEmpty() + && transferToAgent.isEmpty() + && escalate.isEmpty() + && requestedAuthConfigs.isEmpty(); + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java b/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java new file mode 100644 index 000000000..42e761a86 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java @@ -0,0 +1,115 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.google.adk.agents.InvocationContext; +import com.google.adk.sessions.Session; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.jspecify.annotations.Nullable; + +/** + * One invocation's header and the entries recorded under it — port of adk-python's {@code + * _InvocationDebugState}. This is the YAML document that gets written when the invocation ends. + * + *

Mutable, deliberately: entries accumulate for the length of a run. Two things follow from + * that, both required rather than stylistic: + * + *

+ * + *

The app name and user id are optional even though upstream types them as required. {@link + * Session} validates only its id, so a hand-built session can carry neither — and a debug plugin + * that threw because the session it was observing was underpopulated would break the run + * for the sake of a trace. Absent means the key is simply not written. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonPropertyOrder({"invocation_id", "session_id", "app_name", "user_id", "start_time", "entries"}) +final class InvocationDebugState { + + private final String invocationId; + private final String sessionId; + private final Optional appName; + private final Optional userId; + private final String startTime; + private final ConcurrentLinkedQueue entries = new ConcurrentLinkedQueue<>(); + + private InvocationDebugState( + String invocationId, + String sessionId, + @Nullable String appName, + @Nullable String userId, + String startTime) { + this.invocationId = Preconditions.checkNotNull(invocationId); + this.sessionId = Preconditions.checkNotNull(sessionId); + this.appName = Optional.ofNullable(appName); + this.userId = Optional.ofNullable(userId); + this.startTime = Preconditions.checkNotNull(startTime); + } + + /** The header adk-python fills in {@code before_run_callback}. */ + static InvocationDebugState of(InvocationContext context, String startTime) { + Session session = context.session(); + return new InvocationDebugState( + context.invocationId(), session.id(), session.appName(), context.userId(), startTime); + } + + void add(DebugEntry entry) { + entries.add(entry); + } + + @JsonProperty("invocation_id") + String invocationId() { + return invocationId; + } + + @JsonProperty("session_id") + String sessionId() { + return sessionId; + } + + @JsonProperty("app_name") + Optional appName() { + return appName; + } + + @JsonProperty("user_id") + Optional userId() { + return userId; + } + + @JsonProperty("start_time") + String startTime() { + return startTime; + } + + /** A snapshot, in the order the hooks fired. */ + @JsonProperty("entries") + ImmutableList entries() { + return ImmutableList.copyOf(entries); + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java new file mode 100644 index 000000000..a187bfe8f --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java @@ -0,0 +1,126 @@ +/* + * 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.debuglogging; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.adk.models.LlmRequest; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.GenerateContentConfig; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * One {@link LlmRequest} as it appears in the trace — port of adk-python's {@code + * before_model_callback}. + * + *

Only tool names are recorded, as upstream does: the declarations are large, repeat on + * every turn, and say nothing about the turn being traced. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record LlmRequestTrace( + Optional model, + @JsonProperty("content_count") int contentCount, + ImmutableList contents, + @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableList tools, + Optional config) + implements TracePayload { + + static LlmRequestTrace from(LlmRequest request, boolean includeSystemInstruction) { + return new LlmRequestTrace( + request.model(), + request.contents().size(), + request.contents().stream().map(ContentTrace::from).collect(toImmutableList()), + toolNames(request), + request.config().flatMap(config -> configTrace(config, includeSystemInstruction))); + } + + private static ImmutableList toolNames(LlmRequest request) { + return ImmutableList.copyOf(request.tools().keySet()); + } + + private static Optional configTrace( + GenerateContentConfig config, boolean includeSystemInstruction) { + return GenerateContentConfigTrace.from(config, includeSystemInstruction); + } + + /** + * The generation settings, as they appear in the trace. + * + *

{@link GenerateContentConfig#systemInstruction()} is {@code Optional}, never a + * string, so the off-switch emits {@code has_system_instruction} and never upstream's {@code + * system_instruction_length}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record GenerateContentConfigTrace( + @JsonProperty("system_instruction") Optional systemInstruction, + @JsonProperty("has_system_instruction") Optional hasSystemInstruction, + Optional temperature, + @JsonProperty("top_p") Optional topP, + @JsonProperty("top_k") Optional topK, + @JsonProperty("max_output_tokens") Optional maxOutputTokens, + @JsonProperty("response_mime_type") Optional responseMimeType, + @JsonProperty("has_response_schema") Optional hasResponseSchema) { + + /** Empty when nothing was configured, matching upstream's {@code if config_data:}. */ + static Optional from( + GenerateContentConfig config, boolean includeSystemInstruction) { + GenerateContentConfigTrace trace = + new GenerateContentConfigTrace( + includedInstruction(config, includeSystemInstruction), + summarizedInstruction(config, includeSystemInstruction), + config.temperature(), + config.topP(), + config.topK(), + config.maxOutputTokens(), + config.responseMimeType(), + config.responseSchema().map(unused -> Boolean.TRUE)); + return trace.isEmpty() ? Optional.empty() : Optional.of(trace); + } + + private static Optional includedInstruction( + GenerateContentConfig config, boolean includeSystemInstruction) { + return includeSystemInstruction + ? config.systemInstruction().map(ContentTrace::from) + : Optional.empty(); + } + + private static Optional summarizedInstruction( + GenerateContentConfig config, boolean includeSystemInstruction) { + return includeSystemInstruction + ? Optional.empty() + : config.systemInstruction().map(unused -> Boolean.TRUE); + } + + @JsonIgnore + boolean isEmpty() { + return Stream.of( + systemInstruction, + hasSystemInstruction, + temperature, + topP, + topK, + maxOutputTokens, + responseMimeType, + hasResponseSchema) + .allMatch(Optional::isEmpty); + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java new file mode 100644 index 000000000..293a2b1a3 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java @@ -0,0 +1,56 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.adk.models.LlmResponse; +import java.util.Optional; + +/** + * One {@link LlmResponse} as it appears in the trace — port of adk-python's {@code + * after_model_callback}. + * + *

Grounding metadata is reduced to a boolean, as upstream does. Token counts use {@link + * UsageTrace#fromResponse}, which carries the cached-content count an event's copy omits. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record LlmResponseTrace( + Optional content, + Optional partial, + @JsonProperty("turn_complete") Optional turnComplete, + @JsonProperty("error_code") Optional errorCode, + @JsonProperty("error_message") Optional errorMessage, + @JsonProperty("usage_metadata") Optional usageMetadata, + @JsonProperty("has_grounding_metadata") Optional hasGroundingMetadata, + @JsonProperty("finish_reason") Optional finishReason, + @JsonProperty("model_version") Optional modelVersion) + implements TracePayload { + + static LlmResponseTrace from(LlmResponse response) { + return new LlmResponseTrace( + response.content().map(ContentTrace::from), + response.partial(), + response.turnComplete(), + response.errorCode().map(String::valueOf), + response.errorMessage(), + response.usageMetadata().map(UsageTrace::fromResponse), + response.groundingMetadata().map(unused -> Boolean.TRUE), + response.finishReason().map(String::valueOf), + response.modelVersion()); + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java b/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java new file mode 100644 index 000000000..8c8c16566 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java @@ -0,0 +1,157 @@ +/* + * 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.debuglogging; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Collections.newSetFromMap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Arrays; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Converts an arbitrary value into something a YAML writer can render. + * + *

Port of adk-python's {@code DebugLoggingPlugin._safe_serialize}. Tool arguments and tool + * results are declared {@code Map}, so the values inside them are whatever a tool + * author chose to return — this class is the guarantee that a debug plugin never throws while + * trying to describe them. + * + *

Deliberate differences from the original: + * + *

+ */ +final class SafeSerializer { + + private static final String BYTES_MARKER = ""; + private static final String NULL_MARKER = ""; + private static final String CYCLE_MARKER = ""; + private static final String UNSERIALIZABLE_MARKER = ""; + + private SafeSerializer() {} + + /** + * The YAML-safe form of a map whose outer shape ADK or genai already fixes as {@code Map} — tool arguments, tool results, a state delta, session state. + * + *

The only entry point. Callers that must omit the key when the map is empty declare + * {@code @JsonInclude(NON_EMPTY)} on the field and let Jackson decide. + * + *

Only the map's values stay {@code Object}, and only because the declaring interface + * says they are. + */ + static ImmutableMap serializeMap(Map values) { + if (values.isEmpty()) { + return ImmutableMap.of(); + } + Set ancestors = newSetFromMap(new IdentityHashMap<>()); + ancestors.add(values); + return mapEntries(values, ancestors); + } + + private static Object serializeNonNull(Object value, Set ancestors) { + if (isScalar(value)) { + return value; + } + if (value instanceof byte[] bytes) { + return BYTES_MARKER.formatted(bytes.length); + } + if (!isContainer(value)) { + return describe(value); + } + return serializeGuarded(value, ancestors); + } + + /** + * Descends into a container only if it is not already an ancestor of itself. + * + *

Membership is removed on the way out, so a value legitimately appearing twice in a + * tree is serialized twice — only a genuine cycle is cut. + */ + private static Object serializeGuarded(Object container, Set ancestors) { + if (!ancestors.add(container)) { + return CYCLE_MARKER; + } + try { + return serializeContainer(container, ancestors); + } finally { + ancestors.remove(container); + } + } + + private static Object serializeContainer(Object container, Set ancestors) { + if (container instanceof Map map) { + return mapEntries(map, ancestors); + } + if (container instanceof Collection collection) { + return serializeCollection(collection, ancestors); + } + return serializeCollection(Arrays.asList((Object[]) container), ancestors); + } + + private static ImmutableList serializeCollection( + Collection values, Set ancestors) { + return values.stream() + .map(element -> serializeElement(element, ancestors)) + .collect(toImmutableList()); + } + + private static ImmutableMap mapEntries(Map values, Set ancestors) { + ImmutableMap.Builder entries = ImmutableMap.builder(); + for (Map.Entry entry : values.entrySet()) { + entries.put(String.valueOf(entry.getKey()), serializeElement(entry.getValue(), ancestors)); + } + return entries.buildKeepingLast(); + } + + /** A nested value, where absence has to be represented rather than omitted. */ + private static Object serializeElement(Object value, Set ancestors) { + return value == null ? NULL_MARKER : serializeNonNull(value, ancestors); + } + + private static boolean isScalar(Object value) { + return value instanceof String || value instanceof Number || value instanceof Boolean; + } + + private static boolean isContainer(Object value) { + return value instanceof Map || value instanceof Collection || value instanceof Object[]; + } + + /** Last resort, mirroring the original's {@code str(obj)} with its exception guard. */ + private static Object describe(Object value) { + try { + return String.valueOf(value); + } catch (RuntimeException e) { + return UNSERIALIZABLE_MARKER; + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java b/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java new file mode 100644 index 000000000..435e3ab37 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java @@ -0,0 +1,197 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.adk.models.LlmRequest; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import java.util.Map; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * The {@code data} of one debug entry. + * + *

adk-python passes {@code **kwargs} into a free-form {@code dict[str, Any]} in {@code + * _add_entry}. Every entry type here has a known shape instead, so the union is sealed: a new type + * cannot be added without declaring what it carries. + * + *

{@link BranchTrace} serves both {@code invocation_start} and {@code agent_start}, and {@link + * MarkerTrace} serves {@code agent_end} and {@code invocation_end}, which upstream records with no + * payload at all. + */ +sealed interface TracePayload + permits EventTrace, + LlmRequestTrace, + LlmResponseTrace, + TracePayload.BranchTrace, + TracePayload.LlmErrorTrace, + TracePayload.MarkerTrace, + TracePayload.SessionStateTrace, + TracePayload.ToolCallTrace, + TracePayload.ToolErrorTrace, + TracePayload.ToolResponseTrace, + TracePayload.UserMessageTrace { + + /** + * Which agent branch an entry belongs to — the payload of {@code invocation_start} and {@code + * agent_start}. + * + *

A root invocation has no branch, and upstream's {@code exclude_none=True} then leaves the + * key out; {@code NON_ABSENT} does the same here, so the entry records {@code data: {}}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record BranchTrace(Optional branch) implements TracePayload {} + + /** + * The payload of an entry that has none — {@code agent_end} and {@code invocation_end}. + * + *

This is upstream's shape, not an omission: it records the latter as literally {@code + * self._add_entry(invocation_id, "invocation_end")}. The entry still carries its timestamp, type, + * invocation id and (for {@code agent_end}) agent name — for these two types the timestamp + * is the information. + * + *

A singleton, because every instance is identical. + */ + record MarkerTrace() implements TracePayload { + + static final MarkerTrace INSTANCE = new MarkerTrace(); + + /** + * Renders as {@code data: {}}, matching upstream's default-empty dict. + * + *

Jackson has no properties to discover on a component-less record, so the empty mapping is + * stated rather than inferred — without this the writer would reject the entry. + */ + @JsonValue + ImmutableMap fields() { + return ImmutableMap.of(); + } + } + + /** The message that opened the invocation — {@code user_message}. */ + record UserMessageTrace(ContentTrace content) implements TracePayload { + + static UserMessageTrace from(Content userMessage) { + return new UserMessageTrace(ContentTrace.from(userMessage)); + } + } + + /** + * A model call that threw — {@code llm_error}. + * + *

Upstream's {@code str(error)} is always a string; Java's {@link Throwable#getMessage()} may + * be null, which is why the message is optional rather than the literal {@code "null"}. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record LlmErrorTrace( + @JsonProperty("error_type") String errorType, + @JsonProperty("error_message") Optional errorMessage, + Optional model) + implements TracePayload { + + static LlmErrorTrace from(Throwable error, LlmRequest request) { + return new LlmErrorTrace( + error.getClass().getSimpleName(), + Optional.ofNullable(error.getMessage()), + request.model()); + } + } + + /** + * A tool about to run — {@code tool_call}. + * + *

{@code args} is a plain map with no {@code NON_EMPTY}: upstream always passes a dict here, + * so a tool called with no arguments records {@code args: {}} rather than dropping the key. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record ToolCallTrace( + @JsonProperty("tool_name") String toolName, + @JsonProperty("function_call_id") Optional functionCallId, + ImmutableMap args) + implements TracePayload { + + static ToolCallTrace of( + String toolName, @Nullable String functionCallId, Map args) { + return new ToolCallTrace( + toolName, Optional.ofNullable(functionCallId), SafeSerializer.serializeMap(args)); + } + } + + /** What a tool returned — {@code tool_response}. */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record ToolResponseTrace( + @JsonProperty("tool_name") String toolName, + @JsonProperty("function_call_id") Optional functionCallId, + ImmutableMap result) + implements TracePayload { + + static ToolResponseTrace of( + String toolName, @Nullable String functionCallId, Map result) { + return new ToolResponseTrace( + toolName, Optional.ofNullable(functionCallId), SafeSerializer.serializeMap(result)); + } + } + + /** + * A tool that threw — {@code tool_error}. + * + *

Not folded into {@link ToolCallTrace} with two optional fields: the arguments are recorded + * again here on purpose, so a failure is readable without hunting for the matching call entry. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + record ToolErrorTrace( + @JsonProperty("tool_name") String toolName, + @JsonProperty("function_call_id") Optional functionCallId, + ImmutableMap args, + @JsonProperty("error_type") String errorType, + @JsonProperty("error_message") Optional errorMessage) + implements TracePayload { + + static ToolErrorTrace of( + String toolName, + @Nullable String functionCallId, + Map args, + Throwable error) { + return new ToolErrorTrace( + toolName, + Optional.ofNullable(functionCallId), + SafeSerializer.serializeMap(args), + error.getClass().getSimpleName(), + Optional.ofNullable(error.getMessage())); + } + } + + /** + * Session state as the invocation ended — {@code session_state_snapshot}. + * + *

Written only when {@code includeSessionState} is on, matching upstream's guard. + */ + record SessionStateTrace( + ImmutableMap state, @JsonProperty("event_count") int eventCount) + implements TracePayload { + + static SessionStateTrace from(Session session) { + return new SessionStateTrace( + SafeSerializer.serializeMap(session.state()), session.events().size()); + } + } +} diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java new file mode 100644 index 000000000..1db238666 --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java @@ -0,0 +1,57 @@ +/* + * 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.debuglogging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import java.util.Optional; + +/** + * Token counts, as they appear in the trace. + * + *

adk-python records different subsets in the two places: three counts for an event in + * {@code on_event_callback}, and four for a response in {@code after_model_callback}, adding {@code + * cached_content_token_count}. One record with two factories keeps that difference exact without + * duplicating the shape — {@link #fromEvent} leaves the cached count absent, and {@code NON_ABSENT} + * drops it. + */ +@JsonInclude(JsonInclude.Include.NON_ABSENT) +record UsageTrace( + @JsonProperty("prompt_token_count") Optional promptTokenCount, + @JsonProperty("candidates_token_count") Optional candidatesTokenCount, + @JsonProperty("total_token_count") Optional totalTokenCount, + @JsonProperty("cached_content_token_count") Optional cachedContentTokenCount) { + + /** The three counts an event carries. */ + static UsageTrace fromEvent(GenerateContentResponseUsageMetadata usage) { + return new UsageTrace( + usage.promptTokenCount(), + usage.candidatesTokenCount(), + usage.totalTokenCount(), + Optional.empty()); + } + + /** The four counts an LLM response carries. */ + static UsageTrace fromResponse(GenerateContentResponseUsageMetadata usage) { + return new UsageTrace( + usage.promptTokenCount(), + usage.candidatesTokenCount(), + usage.totalTokenCount(), + usage.cachedContentTokenCount()); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java new file mode 100644 index 000000000..62ca210a7 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java @@ -0,0 +1,195 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +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.Language; +import com.google.genai.types.Outcome; +import com.google.genai.types.Part; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Asserts on the serialized trace rather than on an intermediate map, because the record + * schema plus {@link DebugYamlWriter#configure} is what actually decides the document a reviewer + * reads. + */ +@RunWith(JUnit4.class) +public class ContentTraceTest { + + private static final String USER_ROLE = "user"; + private static final String IMAGE_MIME_TYPE = "image/png"; + private static final byte[] IMAGE_BYTES = {1, 2, 3, 4, 5}; + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + + private String serialize(Content content) throws Exception { + return mapper.writeValueAsString(ContentTrace.from(content)); + } + + private static Content contentOf(Part... parts) { + return Content.builder().role(USER_ROLE).parts(ImmutableList.copyOf(parts)).build(); + } + + @Test + public void serialize_textPart_usesTheExpectedKeys() throws Exception { + String json = serialize(contentOf(Part.builder().text("hello").build())); + + assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[{\"text\":\"hello\"}]}"); + } + + /** The headline guarantee: an image is described, never inlined. */ + @Test + public void serialize_inlineData_neverEmitsTheBytes() throws Exception { + Blob blob = + Blob.builder() + .mimeType(IMAGE_MIME_TYPE) + .displayName("screenshot.png") + .data(IMAGE_BYTES) + .build(); + + String json = serialize(contentOf(Part.builder().inlineData(blob).build())); + + assertThat(json).contains("\"mime_type\":\"image/png\""); + assertThat(json).contains("\"display_name\":\"screenshot.png\""); + assertThat(json).contains("\"_data_omitted\":true"); + assertThat(json).doesNotContain("\"data\""); + assertThat(json).doesNotContain("AQIDBAU="); + } + + /** adk-python's {@code if part.text:} is false for "", where a present Optional is not. */ + @Test + public void serialize_emptyText_isDroppedLikeThePythonTruthinessCheck() throws Exception { + String json = serialize(contentOf(Part.builder().text("").build())); + + assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[]}"); + } + + @Test + public void serialize_partCarryingNothing_isDropped() throws Exception { + String json = serialize(contentOf(Part.builder().build())); + + assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[]}"); + } + + @Test + public void serialize_absentRole_omitsTheKeyRatherThanWritingNull() throws Exception { + Content content = Content.builder().parts(ImmutableList.of()).build(); + + assertThat(serialize(content)).isEqualTo("{\"parts\":[]}"); + } + + @Test + public void serialize_functionCall_carriesIdNameAndArgs() throws Exception { + FunctionCall call = + FunctionCall.builder() + .id("call-1") + .name("lookup_order") + .args(ImmutableMap.of("orderId", 7)) + .build(); + + String json = serialize(contentOf(Part.builder().functionCall(call).build())); + + assertThat(json) + .contains( + "\"function_call\":{\"id\":\"call-1\",\"name\":\"lookup_order\"," + + "\"args\":{\"orderId\":7}}"); + } + + /** + * The other half of a tool exchange, and the most common part kind in a real trace after text. + * Its {@code response} goes through {@link SafeSerializer} for the same reason a call's arguments + * do — a tool author chooses the shape. + */ + @Test + public void serialize_functionResponse_carriesIdNameAndResponse() throws Exception { + FunctionResponse response = + FunctionResponse.builder() + .id("call-1") + .name("lookup_order") + .response(ImmutableMap.of("status", "shipped")) + .build(); + + String json = serialize(contentOf(Part.builder().functionResponse(response).build())); + + assertThat(json) + .contains( + "\"function_response\":{\"id\":\"call-1\",\"name\":\"lookup_order\"," + + "\"response\":{\"status\":\"shipped\"}}"); + } + + @Test + public void serialize_fileData_carriesTheUriAndMimeType() throws Exception { + FileData fileData = + FileData.builder().fileUri("gs://bucket/report.pdf").mimeType("application/pdf").build(); + + assertThat(serialize(contentOf(Part.builder().fileData(fileData).build()))) + .contains( + "\"file_data\":{\"file_uri\":\"gs://bucket/report.pdf\"," + + "\"mime_type\":\"application/pdf\"}"); + } + + @Test + public void serialize_codeExecutionResult_carriesOutcomeAndOutput() throws Exception { + CodeExecutionResult result = + CodeExecutionResult.builder().outcome(Outcome.Known.OUTCOME_OK).output("42").build(); + + assertThat(serialize(contentOf(Part.builder().codeExecutionResult(result).build()))) + .contains("\"code_execution_result\":{\"outcome\":\"OUTCOME_OK\",\"output\":\"42\"}"); + } + + @Test + public void serialize_executableCode_carriesLanguageAndCode() throws Exception { + ExecutableCode code = + ExecutableCode.builder().language(Language.Known.PYTHON).code("print(6 * 7)").build(); + + assertThat(serialize(contentOf(Part.builder().executableCode(code).build()))) + .contains("\"executable_code\":{\"language\":\"PYTHON\",\"code\":\"print(6 * 7)\"}"); + } + + @Test + public void from_keepsOnlyThePartsThatSurvive() { + ContentTrace trace = + ContentTrace.from( + contentOf( + Part.builder().text("kept").build(), + Part.builder().text("").build(), + Part.builder().build())); + + assertThat(trace.parts()).hasSize(1); + assertThat(trace.parts().get(0).text()).hasValue("kept"); + } + + @Test + public void from_returnsAnImmutableList() { + ContentTrace trace = ContentTrace.from(contentOf(Part.builder().text("hi").build())); + + assertThat(trace.parts()).isInstanceOf(ImmutableList.class); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java new file mode 100644 index 000000000..73a21c86c --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java @@ -0,0 +1,303 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + +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.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.genai.types.Content; +import com.google.genai.types.Part; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Covers the wiring: that each hook records the entry adk-python records, that none of them changes + * the run, and that a hook arriving without an invocation is dropped rather than thrown. + * + *

Contexts are mocked in the style of {@code LoggingPluginTest}, this package's own precedent + * for a plugin test. Assertions read the written YAML back, so they cover the whole path from hook + * to file rather than the plugin's internal bookkeeping. + */ +@RunWith(JUnit4.class) +public class DebugLoggingPluginTest { + + private static final String INVOCATION_ID = "invocation_id"; + private static final String AGENT_NAME = "agent_name"; + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Mock private InvocationContext invocationContext; + @Mock private BaseAgent agent; + @Mock private CallbackContext callbackContext; + @Mock private BaseTool tool; + @Mock private ToolContext toolContext; + + 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; + + @Before + public void setUp() throws Exception { + tracePath = tempFolder.newFolder("traces").toPath().resolve("adk_debug.yaml"); + + when(invocationContext.invocationId()).thenReturn(INVOCATION_ID); + when(invocationContext.session()).thenReturn(session); + when(invocationContext.agent()).thenReturn(agent); + when(invocationContext.userId()).thenReturn("user_id"); + when(invocationContext.branch()).thenReturn(Optional.empty()); + when(agent.name()).thenReturn(AGENT_NAME); + + when(callbackContext.invocationId()).thenReturn(INVOCATION_ID); + when(callbackContext.agentName()).thenReturn(AGENT_NAME); + when(callbackContext.branch()).thenReturn(Optional.empty()); + + when(toolContext.invocationId()).thenReturn(INVOCATION_ID); + when(toolContext.agentName()).thenReturn(AGENT_NAME); + when(toolContext.functionCallId()).thenReturn(Optional.of("call-1")); + when(tool.name()).thenReturn("lookup_order"); + } + + private DebugLoggingPlugin plugin() { + return new DebugLoggingPlugin("debug_logging_plugin", tracePath, true, true); + } + + /** + * Drives every hook in the order a real run fires them, and closes the invocation. + * + *

{@code onUserMessageCallback} goes first, which is not an arbitrary choice: {@code + * Runner.runAsync} invokes it before {@code beforeRunCallback}. A test that fired them the other + * way round would never exercise the case this plugin handles. + */ + 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 ImmutableList.copyOf(documents.readAll()); + } + } + + 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(ImmutableList.toImmutableList()); + } + + @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(); + } + + /** + * The port's deliberate difference from adk-python in what gets recorded. + * + *

The user message arrives before {@code beforeRunCallback} has opened the invocation, so the + * first hook to arrive opens it. Without that, the entry would be filed against an invocation + * that does not exist yet and dropped. + */ + @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"); + } + + /** The whole safety argument for this plugin: it observes, and cannot alter, a run. */ + @Test + public void everyHook_returnsEmpty_soNothingIsAltered() { + DebugLoggingPlugin plugin = plugin(); + plugin.beforeRunCallback(invocationContext).blockingGet(); + + 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 toolEntries_carryTheToolNameCallIdAndArguments() 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 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(new DebugLoggingPlugin("debug_logging_plugin", tracePath, false, true)); + + assertThat(entryTypes()).doesNotContain("session_state_snapshot"); + assertThat(entryTypes()).contains("invocation_end"); + } + + @Test + public void includeSystemInstruction_whenOff_notesItsPresenceWithoutTheText() throws Exception { + llmRequest.config( + com.google.genai.types.GenerateContentConfig.builder() + .systemInstruction( + Content.builder() + .parts(ImmutableList.of(Part.builder().text("be terse").build())) + .build()) + .build()); + + runEveryHook(new DebugLoggingPlugin("debug_logging_plugin", tracePath, true, false)); + + Map config = (Map) entryData("llm_request").get("config"); + assertThat(config.get("has_system_instruction")).isEqualTo(true); + assertThat(Files.readString(tracePath)).doesNotContain("be terse"); + } + + /** A hook can outlive its invocation; losing the state must cost a log line, not the run. */ + @Test + public void hooksWithoutAnInvocation_areDroppedRatherThanThrown() throws Exception { + DebugLoggingPlugin plugin = plugin(); + + plugin.onEventCallback(invocationContext, event).blockingGet(); + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(Files.exists(tracePath)).isFalse(); + } + + /** Upstream drops the state in a {@code finally}; a second write must find nothing left. */ + @Test + public void afterRun_dropsTheInvocation_soASecondRunWritesNothingMore() throws Exception { + DebugLoggingPlugin plugin = plugin(); + runEveryHook(plugin); + + plugin.afterRunCallback(invocationContext).blockingAwait(); + + assertThat(readDocuments()).hasSize(1); + } + + /** + * The payload of the first entry of that type — by type, never by index, so adding an entry ahead + * of it cannot silently repoint an assertion at a different entry. + */ + private Map entryData(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(entry -> (Map) entry.get("data")) + .orElseThrow(); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java new file mode 100644 index 000000000..5c61144bc --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java @@ -0,0 +1,190 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.agents.InvocationContext; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.plugins.debuglogging.DebugEntry.Type; +import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace; +import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Covers the state layer end to end — the live-invocation map, the per-invocation accumulator, and + * the entries filed into it — since none of the three means anything without the other two. + * + *

The clock is fixed, so the timestamp is asserted exactly rather than pattern-matched. + */ +@RunWith(JUnit4.class) +public class DebugTraceRecorderTest { + + private static final String INVOCATION_ID = "inv-1"; + private static final String AGENT = "root_agent"; + private static final Clock FIXED = + Clock.fixed(Instant.parse("2026-08-02T10:15:30.123Z"), ZoneOffset.UTC); + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + private final DebugTraceRecorder recorder = new DebugTraceRecorder(FIXED); + + private static InvocationContext invocationContext() { + return InvocationContext.builder() + .invocationId(INVOCATION_ID) + .agent(LlmAgent.builder().name(AGENT).build()) + .session(Session.builder("session-1").appName("shop").userId("user-1").build()) + .sessionService(new InMemorySessionService()) + .runConfig(RunConfig.builder().build()) + .build(); + } + + private InvocationDebugState startedInvocation() { + InvocationDebugState state = InvocationDebugState.of(invocationContext(), recorder.now()); + recorder.start(state); + return state; + } + + @Test + public void header_carriesTheInvocationSessionAndStartTime() throws Exception { + String json = mapper.writeValueAsString(startedInvocation()); + + assertThat(json) + .startsWith( + "{\"invocation_id\":\"inv-1\",\"session_id\":\"session-1\",\"app_name\":\"shop\"," + + "\"user_id\":\"user-1\",\"start_time\":\"2026-08-02T10:15:30.123\"," + + "\"entries\":["); + } + + /** + * {@link Session.Builder#build} validates only the id, so a hand-built session can carry neither + * an app name nor a user id. Null-checking them here would throw inside {@code beforeRunCallback} + * and break a run because the plugin was attached. Both are {@link java.util.Optional}, + * and an absent one simply omits its key. + */ + @Test + public void header_sessionWithoutAppNameOrUserId_omitsThoseKeys() throws Exception { + InvocationContext context = + InvocationContext.builder() + .invocationId(INVOCATION_ID) + .agent(LlmAgent.builder().name(AGENT).build()) + .session(Session.builder("session-1").build()) + .sessionService(new InMemorySessionService()) + .runConfig(RunConfig.builder().build()) + .build(); + + String json = mapper.writeValueAsString(InvocationDebugState.of(context, recorder.now())); + + assertThat(json).contains("\"invocation_id\":\"inv-1\""); + assertThat(json).contains("\"session_id\":\"session-1\""); + assertThat(json).doesNotContain("app_name"); + assertThat(json).doesNotContain("user_id"); + } + + @Test + public void record_writesEveryEntryFieldInUpstreamsOrder() throws Exception { + startedInvocation(); + + recorder.record(INVOCATION_ID, Type.AGENT_START, AGENT, new BranchTrace(Optional.of("root"))); + + assertThat(mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow())) + .contains( + "\"entries\":[{\"timestamp\":\"2026-08-02T10:15:30.123\"," + + "\"entry_type\":\"agent_start\",\"invocation_id\":\"inv-1\"," + + "\"agent_name\":\"root_agent\",\"data\":{\"branch\":\"root\"}}]"); + } + + @Test + public void record_withoutAnAgentName_omitsTheKey() throws Exception { + startedInvocation(); + + recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE); + + String json = mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow()); + assertThat(json).contains("\"entry_type\":\"invocation_end\""); + assertThat(json).doesNotContain("agent_name"); + } + + /** {@code agent_end} and {@code invocation_end} carry no payload, and must still write one. */ + @Test + public void record_markerEntry_writesAnEmptyDataMapping() throws Exception { + startedInvocation(); + + recorder.record(INVOCATION_ID, Type.AGENT_END, AGENT, MarkerTrace.INSTANCE); + + assertThat(mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow())) + .contains("\"agent_name\":\"root_agent\",\"data\":{}}"); + } + + @Test + public void record_keepsEntriesInTheOrderTheHooksFired() { + InvocationDebugState state = startedInvocation(); + + recorder.record(INVOCATION_ID, Type.USER_MESSAGE, MarkerTrace.INSTANCE); + recorder.record(INVOCATION_ID, Type.AGENT_START, MarkerTrace.INSTANCE); + recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE); + + assertThat(state.entries().stream().map(DebugEntry::entryType)) + .containsExactly(Type.USER_MESSAGE, Type.AGENT_START, Type.INVOCATION_END) + .inOrder(); + } + + /** A debug plugin that lost a state must log a gap, never break the run it is observing. */ + @Test + public void record_forAnUnknownInvocation_isDroppedRatherThanThrown() { + InvocationDebugState state = startedInvocation(); + + recorder.record("some-other-invocation", Type.EVENT, MarkerTrace.INSTANCE); + + assertThat(state.entries()).isEmpty(); + } + + @Test + public void entries_isASnapshotNotTheLiveQueue() { + InvocationDebugState state = startedInvocation(); + recorder.record(INVOCATION_ID, Type.USER_MESSAGE, MarkerTrace.INSTANCE); + + ImmutableList taken = state.entries(); + recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE); + + assertThat(taken).hasSize(1); + assertThat(state.entries()).hasSize(2); + } + + /** {@code forWrite} leaves the state in place so the closing entries can still be filed. */ + @Test + public void forWrite_doesNotRemoveTheStateUntilFinishIsCalled() { + startedInvocation(); + + assertThat(recorder.forWrite(INVOCATION_ID)).isPresent(); + assertThat(recorder.forWrite(INVOCATION_ID)).isPresent(); + + recorder.finish(INVOCATION_ID); + + assertThat(recorder.forWrite(INVOCATION_ID)).isEmpty(); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java new file mode 100644 index 000000000..e9227d6e0 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java @@ -0,0 +1,264 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.adk.agents.InvocationContext; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.plugins.debuglogging.DebugEntry.Type; +import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +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; + +/** + * Covers the guarantee the writer exists to give: a file of appended invocations that still parses + * as YAML, and a failed write that costs the run nothing. + * + *

Assertions re-parse the file rather than matching its text, because "the trace can be read + * back" is the actual promise — a separator bug or a stray document boundary is invisible to a + * substring check. + */ +@RunWith(JUnit4.class) +public class DebugYamlWriterTest { + + private static final Clock FIXED = + Clock.fixed(Instant.parse("2026-08-02T10:15:30.123Z"), ZoneOffset.UTC); + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private Path tracePath; + private DebugYamlWriter writer; + private DebugTraceRecorder recorder; + + @Before + public void setUp() throws Exception { + tracePath = tempFolder.newFolder("traces").toPath().resolve("adk_debug.yaml"); + writer = new DebugYamlWriter(tracePath); + recorder = new DebugTraceRecorder(FIXED); + } + + private static InvocationContext contextFor(String invocationId) { + return InvocationContext.builder() + .invocationId(invocationId) + .agent(LlmAgent.builder().name("root_agent").build()) + .session(Session.builder("session-1").appName("shop").userId("user-1").build()) + .sessionService(new InMemorySessionService()) + .runConfig(RunConfig.builder().build()) + .build(); + } + + /** One invocation, recorded and closed the way {@code afterRunCallback} will do it. */ + private InvocationDebugState invocation(String invocationId) { + InvocationDebugState state = InvocationDebugState.of(contextFor(invocationId), recorder.now()); + recorder.start(state); + recorder.record(invocationId, Type.USER_MESSAGE, MarkerTrace.INSTANCE); + recorder.record(invocationId, Type.INVOCATION_END, MarkerTrace.INSTANCE); + return state; + } + + private ImmutableList> readDocuments() throws Exception { + ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + try (MappingIterator> documents = + yaml.readerFor(Map.class).readValues(tracePath.toFile())) { + return ImmutableList.copyOf(documents.readAll()); + } + } + + @Test + public void append_twoInvocations_yieldsTwoDocumentsThatBothReparse() throws Exception { + writer.append(invocation("inv-1")); + writer.append(invocation("inv-2")); + + assertThat(readDocuments().stream().map(document -> document.get("invocation_id"))) + .containsExactly("inv-1", "inv-2") + .inOrder(); + } + + /** + * Jackson writes the {@code ---} itself. Upstream writes its own because PyYAML does not, and + * porting that line literally would emit two separators and an empty document between them. + */ + @Test + public void append_writesExactlyOneDocumentSeparatorPerInvocation() throws Exception { + writer.append(invocation("inv-1")); + writer.append(invocation("inv-2")); + + List lines = Files.readAllLines(tracePath, StandardCharsets.UTF_8); + assertThat(lines.stream().filter(line -> line.startsWith("---")).count()).isEqualTo(2); + } + + @Test + public void append_keepsTheEntriesAndTheirTypes() throws Exception { + writer.append(invocation("inv-1")); + + Object entries = readDocuments().get(0).get("entries"); + assertThat(entries).isInstanceOf(List.class); + assertThat(((List) entries).stream().map(entry -> ((Map) entry).get("entry_type"))) + .containsExactly("user_message", "invocation_end") + .inOrder(); + } + + /** + * The risk {@code MINIMIZE_QUOTES} introduces, pinned: a tool that returns the string + * {@code "42"} must not read back as the number 42, or a trace would misreport what a tool said. + */ + @Test + public void append_stringsThatLookLikeScalars_stayQuotedAndReparseAsStrings() throws Exception { + InvocationDebugState state = invocation("inv-1"); + recorder.record( + "inv-1", + Type.TOOL_RESPONSE, + ToolResponseTrace.of( + "lookup_order", + "call-7", + ImmutableMap.of("code", "42", "flag", "true", "missing", "null", "note", "in stock"))); + + writer.append(state); + + Map result = (Map) entryData(readDocuments().get(0), 2).get("result"); + assertThat(result.get("code")).isEqualTo("42"); + assertThat(result.get("flag")).isEqualTo("true"); + assertThat(result.get("missing")).isEqualTo("null"); + assertThat(result.get("note")).isEqualTo("in stock"); + } + + /** + * {@code LITERAL_BLOCK_STYLE} is the reason model output stays readable in a trace: a multi-line + * answer must render as a {@code |-} block, not one line of {@code \n} escapes. The block form is + * asserted in the file text because re-parsing alone cannot tell the two renderings apart. + */ + @Test + public void append_multiLineText_usesALiteralBlockRatherThanEscapes() throws Exception { + InvocationDebugState state = invocation("inv-1"); + recorder.record( + "inv-1", + Type.TOOL_RESPONSE, + ToolResponseTrace.of( + "lookup_order", "call-7", ImmutableMap.of("summary", "line one\nline two"))); + + writer.append(state); + + String file = Files.readString(tracePath, StandardCharsets.UTF_8); + assertThat(file).contains("summary: |-"); + assertThat(file).doesNotContain("line one\\nline two"); + + Map result = (Map) entryData(readDocuments().get(0), 2).get("result"); + assertThat(result.get("summary")).isEqualTo("line one\nline two"); + } + + private static Map entryData(Map document, int index) { + List entries = (List) document.get("entries"); + return (Map) ((Map) entries.get(index)).get("data"); + } + + /** + * The reason {@code append} is {@code synchronized}: the write runs on {@code Schedulers.io()}, + * so invocations that finish together arrive on different threads. Without the lock their + * documents interleave and the file stops parsing — which this asserts by re-reading it, not by + * inspecting the text. + * + *

A latch releases every thread at once so the appends genuinely contend, rather than being + * serialized by the pool starting them one at a time. + */ + @Test + public void append_fromManyThreadsAtOnce_yieldsOneIntactDocumentEach() throws Exception { + int writers = 8; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(writers); + ExecutorService pool = Executors.newFixedThreadPool(writers); + try { + for (int i = 0; i < writers; i++) { + pool.execute(appendTask("inv-" + i, start, done)); + } + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + + ImmutableList> documents = readDocuments(); + assertThat(documents).hasSize(writers); + assertThat(documents.stream().map(document -> document.get("invocation_id"))) + .containsExactly("inv-0", "inv-1", "inv-2", "inv-3", "inv-4", "inv-5", "inv-6", "inv-7"); + } + + /** One writer thread: wait for the starting gun, append, and report completion. */ + private Runnable appendTask(String invocationId, CountDownLatch start, CountDownLatch done) { + InvocationDebugState state = invocation(invocationId); + return () -> awaitThenAppend(state, start, done); + } + + private void awaitThenAppend( + InvocationDebugState state, CountDownLatch start, CountDownLatch done) { + try { + start.await(); + writer.append(state); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + } + + @Test + public void append_createsTheParentDirectoryRatherThanLosingTheTrace() throws Exception { + Path nested = tempFolder.getRoot().toPath().resolve("a/b/c/adk_debug.yaml"); + + new DebugYamlWriter(nested).append(invocation("inv-1")); + + assertThat(Files.exists(nested)).isTrue(); + } + + /** A full disk, a read-only path, a bad configuration: the run must not notice. */ + @Test + public void append_whenThePathCannotBeWritten_logsInsteadOfThrowing() throws Exception { + File blocker = tempFolder.newFile("not-a-directory"); + DebugYamlWriter blocked = + new DebugYamlWriter(blocker.toPath().resolve("nested/adk_debug.yaml")); + + blocked.append(invocation("inv-1")); + + assertThat(blocker.length()).isEqualTo(0); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java new file mode 100644 index 000000000..3d6b3f587 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java @@ -0,0 +1,232 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.genai.types.Content; +import com.google.genai.types.FinishReason; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GroundingMetadata; +import com.google.genai.types.Part; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Covers the event envelope, and the two details in its actions block that carry real risk. */ +@RunWith(JUnit4.class) +public class EventTraceTest { + + private static final String AUTHOR = "assistant"; + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + + private String serialize(Event event) throws Exception { + return mapper.writeValueAsString(EventTrace.from(event)); + } + + private static Event.Builder eventBuilder() { + return Event.builder().id("event-1").author(AUTHOR); + } + + @Test + public void serialize_plainEvent_carriesIdAuthorAndFinality() throws Exception { + String json = serialize(eventBuilder().build()); + + assertThat(json).contains("\"event_id\":\"event-1\""); + assertThat(json).contains("\"author\":\"assistant\""); + assertThat(json).contains("\"is_final_response\":"); + } + + @Test + public void serialize_content_isDelegatedToContentTrace() throws Exception { + Event event = + eventBuilder() + .content( + Content.builder() + .role("model") + .parts(ImmutableList.of(Part.builder().text("hi").build())) + .build()) + .build(); + + assertThat(serialize(event)) + .contains("\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"hi\"}]}"); + } + + @Test + public void serialize_noActions_omitsTheBlockEntirely() throws Exception { + assertThat(serialize(eventBuilder().build())).doesNotContain("actions"); + } + + /** The map is free-form and auth-related; a trace gets pasted into bug reports. */ + @Test + public void serialize_requestedAuthConfigs_recordsTheCountAndNeverTheContent() throws Exception { + Map> authConfigs = new HashMap<>(); + authConfigs.put("my-oauth", ImmutableMap.of("client_secret", "s3cret")); + EventActions actions = EventActions.builder().requestedAuthConfigs(authConfigs).build(); + + String json = serialize(eventBuilder().actions(actions).build()); + + assertThat(json).contains("\"requested_auth_configs\":1"); + assertThat(json).doesNotContain("s3cret"); + assertThat(json).doesNotContain("my-oauth"); + } + + @Test + public void serialize_artifactDelta_keepsTheFilenameToVersionMapping() throws Exception { + Map artifactDelta = new HashMap<>(); + artifactDelta.put("report.pdf", 3); + EventActions actions = EventActions.builder().artifactDelta(artifactDelta).build(); + + assertThat(serialize(eventBuilder().actions(actions).build())) + .contains("\"artifact_delta\":{\"report.pdf\":3}"); + } + + @Test + public void serialize_stateDelta_goesThroughSafeSerializer() throws Exception { + Map stateDelta = new HashMap<>(); + stateDelta.put("counter", 4); + EventActions actions = EventActions.builder().stateDelta(stateDelta).build(); + + assertThat(serialize(eventBuilder().actions(actions).build())) + .contains("\"state_delta\":{\"counter\":4}"); + } + + @Test + public void serialize_transferToAgent_isRecorded() throws Exception { + EventActions actions = EventActions.builder().transferToAgent("billing_agent").build(); + + assertThat(serialize(eventBuilder().actions(actions).build())) + .contains("\"transfer_to_agent\":\"billing_agent\""); + } + + /** The trace is a snapshot: mutating the source afterwards must not change it. */ + @Test + public void from_copiesTheLiveActionMapsRatherThanReferencingThem() { + Map artifactDelta = new HashMap<>(); + artifactDelta.put("first.txt", 1); + EventActions actions = EventActions.builder().artifactDelta(artifactDelta).build(); + + EventTrace trace = EventTrace.from(eventBuilder().actions(actions).build()); + actions.artifactDelta().put("sneaked-in.txt", 9); + + assertThat(trace.actions().orElseThrow().artifactDelta()).containsExactly("first.txt", 1); + } + + @Test + public void serialize_emptyLongRunningToolIds_isDroppedNotEmittedAsAnEmptyList() + throws Exception { + assertThat(serialize(eventBuilder().build())).doesNotContain("long_running_tool_ids"); + } + + @Test + public void serialize_longRunningToolIds_whenPresent_areRecorded() throws Exception { + Event event = eventBuilder().longRunningToolIds(ImmutableSet.of("call-7")).build(); + + assertThat(serialize(event)).contains("\"long_running_tool_ids\":[\"call-7\"]"); + } + + /** The streaming flags: both are tri-state, so the absent case must not read as {@code false}. */ + @Test + public void serialize_streamingFlags_areRecorded() throws Exception { + Event event = eventBuilder().partial(true).turnComplete(false).build(); + + String json = serialize(event); + + assertThat(json).contains("\"partial\":true"); + assertThat(json).contains("\"turn_complete\":false"); + } + + @Test + public void serialize_absentStreamingFlags_omitTheKeysRatherThanWritingFalse() throws Exception { + String json = serialize(eventBuilder().build()); + + assertThat(json).doesNotContain("partial"); + assertThat(json).doesNotContain("turn_complete"); + } + + /** The event's own branch, not {@link TracePayload.BranchTrace}'s — a separate code path. */ + @Test + public void serialize_branch_isRecorded() throws Exception { + Event event = eventBuilder().branch("root.billing_agent").build(); + + assertThat(serialize(event)).contains("\"branch\":\"root.billing_agent\""); + } + + @Test + public void serialize_errorCodeAndMessage_areRecorded() throws Exception { + Event event = + eventBuilder() + .errorCode(new FinishReason(FinishReason.Known.SAFETY)) + .errorMessage("blocked by a safety filter") + .build(); + + String json = serialize(event); + + assertThat(json).contains("\"error_code\":\"SAFETY\""); + assertThat(json).contains("\"error_message\":\"blocked by a safety filter\""); + } + + /** Same reduction the response trace makes: the payload is large and adds nothing to a trace. */ + @Test + public void serialize_groundingMetadata_isReducedToABoolean() throws Exception { + Event event = eventBuilder().groundingMetadata(GroundingMetadata.builder().build()).build(); + + String json = serialize(event); + + assertThat(json).contains("\"has_grounding_metadata\":true"); + assertThat(json).doesNotContain("groundingChunks"); + } + + /** An event carries the three-count subset; the cached count belongs to the response only. */ + @Test + public void serialize_usageMetadata_carriesThreeCountsAndOmitsTheCachedOne() throws Exception { + Event event = + eventBuilder() + .usageMetadata( + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(120) + .candidatesTokenCount(45) + .totalTokenCount(165) + .cachedContentTokenCount(80) + .build()) + .build(); + + String json = serialize(event); + + assertThat(json) + .contains( + "\"usage_metadata\":{\"prompt_token_count\":120,\"candidates_token_count\":45," + + "\"total_token_count\":165}"); + assertThat(json).doesNotContain("cached_content_token_count"); + } + + @Test + public void serialize_escalate_isRecorded() throws Exception { + EventActions actions = EventActions.builder().escalate(true).build(); + + assertThat(serialize(eventBuilder().actions(actions).build())).contains("\"escalate\":true"); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java new file mode 100644 index 000000000..1ab9b0716 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java @@ -0,0 +1,271 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.adk.tools.BaseTool; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.FinishReason; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GroundingMetadata; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import com.google.genai.types.Type; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Covers both halves of the model exchange, and the system-instruction off-switch. */ +@RunWith(JUnit4.class) +public class LlmExchangeTraceTest { + + private static final String MODEL = "gemini-2.0-flash"; + private static final String SECRET_INSTRUCTION = "You are a helpful assistant with a secret."; + private static final String TOOL_DESCRIPTION = "Looks an order up by its id."; + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + + private String serializeRequest(LlmRequest request, boolean includeSystemInstruction) + throws Exception { + return mapper.writeValueAsString(LlmRequestTrace.from(request, includeSystemInstruction)); + } + + private static Content userContent(String text) { + return Content.builder() + .role("user") + .parts(ImmutableList.of(Part.builder().text(text).build())) + .build(); + } + + private static LlmRequest requestWith(GenerateContentConfig config) { + return LlmRequest.builder() + .model(MODEL) + .contents(ImmutableList.of(userContent("hello"))) + .config(config) + .build(); + } + + @Test + public void request_carriesModelAndContentCount() throws Exception { + LlmRequest request = + LlmRequest.builder() + .model(MODEL) + .contents(ImmutableList.of(userContent("a"), userContent("b"))) + .build(); + + String json = serializeRequest(request, true); + + assertThat(json).contains("\"model\":\"gemini-2.0-flash\""); + assertThat(json).contains("\"content_count\":2"); + } + + @Test + public void request_includeSystemInstruction_recordsItAsStructuredContent() throws Exception { + GenerateContentConfig config = + GenerateContentConfig.builder().systemInstruction(userContent(SECRET_INSTRUCTION)).build(); + + String json = serializeRequest(requestWith(config), true); + + assertThat(json).contains("\"system_instruction\":"); + assertThat(json).contains(SECRET_INSTRUCTION); + } + + /** The off-switch exists so a trace can be shared without the prompt in it. */ + @Test + public void request_excludeSystemInstruction_recordsOnlyThatOneWasPresent() throws Exception { + GenerateContentConfig config = + GenerateContentConfig.builder().systemInstruction(userContent(SECRET_INSTRUCTION)).build(); + + String json = serializeRequest(requestWith(config), false); + + assertThat(json).contains("\"has_system_instruction\":true"); + assertThat(json).doesNotContain(SECRET_INSTRUCTION); + assertThat(json).doesNotContain("\"system_instruction\":"); + } + + @Test + public void request_generationSettings_areRecorded() throws Exception { + GenerateContentConfig config = + GenerateContentConfig.builder().temperature(0.25f).maxOutputTokens(512).build(); + + String json = serializeRequest(requestWith(config), true); + + assertThat(json).contains("\"temperature\":0.25"); + assertThat(json).contains("\"max_output_tokens\":512"); + } + + @Test + public void request_emptyConfig_omitsTheBlockEntirely() throws Exception { + LlmRequest request = + LlmRequest.builder().model(MODEL).contents(ImmutableList.of(userContent("hi"))).build(); + + assertThat(serializeRequest(request, true)).doesNotContain("\"config\""); + } + + @Test + public void request_responseMimeType_isRecorded() throws Exception { + GenerateContentConfig config = + GenerateContentConfig.builder().responseMimeType("application/json").build(); + + String json = serializeRequest(requestWith(config), true); + + assertThat(json).contains("\"response_mime_type\":\"application/json\""); + } + + /** + * A response schema can be arbitrarily large and says nothing about the turn being traced, so + * only its presence is recorded, as upstream does. + */ + @Test + public void request_responseSchema_isReducedToABoolean() throws Exception { + GenerateContentConfig config = + GenerateContentConfig.builder() + .responseSchema(Schema.builder().type(Type.Known.OBJECT).build()) + .build(); + + String json = serializeRequest(requestWith(config), true); + + assertThat(json).contains("\"has_response_schema\":true"); + assertThat(json).doesNotContain("\"response_schema\":"); + } + + /** + * Tool declarations repeat on every turn and dwarf everything else in the document, so upstream + * records names only. The record has no field for a declaration, so this is structural — but the + * key name and the {@code NON_EMPTY} inclusion still need pinning. + */ + @Test + public void request_tools_recordTheirNamesOnly() throws Exception { + LlmRequest request = + LlmRequest.builder() + .model(MODEL) + .contents(ImmutableList.of(userContent("hi"))) + .tools(ImmutableMap.of("lookup_order", new NamedTool("lookup_order"))) + .build(); + + String json = serializeRequest(request, true); + + assertThat(json).contains("\"tools\":[\"lookup_order\"]"); + assertThat(json).doesNotContain(TOOL_DESCRIPTION); + } + + @Test + public void request_withNoTools_omitsTheKey() throws Exception { + LlmRequest request = + LlmRequest.builder().model(MODEL).contents(ImmutableList.of(userContent("hi"))).build(); + + assertThat(serializeRequest(request, true)).doesNotContain("\"tools\""); + } + + @Test + public void response_carriesContentAndFinishReason() throws Exception { + LlmResponse response = + LlmResponse.builder() + .content(userContent("answer")) + .finishReason(new FinishReason(FinishReason.Known.STOP)) + .build(); + + String json = mapper.writeValueAsString(LlmResponseTrace.from(response)); + + assertThat(json).contains("\"parts\":[{\"text\":\"answer\"}]"); + assertThat(json).contains("\"finish_reason\":\"STOP\""); + } + + @Test + public void response_error_isRecorded() throws Exception { + LlmResponse response = LlmResponse.builder().errorMessage("quota exceeded").build(); + + String json = mapper.writeValueAsString(LlmResponseTrace.from(response)); + + assertThat(json).contains("\"error_message\":\"quota exceeded\""); + } + + @Test + public void response_emptyResponse_omitsEveryAbsentField() throws Exception { + String json = mapper.writeValueAsString(LlmResponseTrace.from(LlmResponse.builder().build())); + + assertThat(json).isEqualTo("{}"); + } + + /** + * A response's token counts carry one more field than an event's. {@link UsageTraceTest} pins the + * difference at the record; this pins that the response side asks for the four-count factory. + */ + @Test + public void response_usageMetadata_carriesTheCachedContentCount() throws Exception { + LlmResponse response = + LlmResponse.builder() + .usageMetadata( + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(120) + .totalTokenCount(165) + .cachedContentTokenCount(80) + .build()) + .build(); + + assertThat(mapper.writeValueAsString(LlmResponseTrace.from(response))) + .contains( + "\"usage_metadata\":{\"prompt_token_count\":120,\"total_token_count\":165," + + "\"cached_content_token_count\":80}"); + } + + /** Grounding payloads are large and add nothing readable, so only their presence is kept. */ + @Test + public void response_groundingMetadata_isReducedToABoolean() throws Exception { + LlmResponse response = + LlmResponse.builder().groundingMetadata(GroundingMetadata.builder().build()).build(); + + assertThat(mapper.writeValueAsString(LlmResponseTrace.from(response))) + .isEqualTo("{\"has_grounding_metadata\":true}"); + } + + /** The streaming flags and the resolved model version, which a partial response is read for. */ + @Test + public void response_streamingFlagsAndModelVersion_areRecorded() throws Exception { + LlmResponse response = + LlmResponse.builder() + .partial(true) + .turnComplete(false) + .errorCode(new FinishReason(FinishReason.Known.SAFETY)) + .modelVersion("gemini-2.0-flash-001") + .build(); + + String json = mapper.writeValueAsString(LlmResponseTrace.from(response)); + + assertThat(json).contains("\"partial\":true"); + assertThat(json).contains("\"turn_complete\":false"); + assertThat(json).contains("\"error_code\":\"SAFETY\""); + assertThat(json).contains("\"model_version\":\"gemini-2.0-flash-001\""); + } + + /** + * The trace only ever reads {@link BaseTool#name()}, so a named stub is enough — and it keeps + * this test free of a mocking framework the rest of the file does not use. + */ + private static final class NamedTool extends BaseTool { + NamedTool(String name) { + super(name, TOOL_DESCRIPTION); + } + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java new file mode 100644 index 000000000..cc63aa822 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java @@ -0,0 +1,148 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Pins the guarantee {@link SafeSerializer} exists to make: a debug plugin describing an arbitrary + * tool result never throws, and never puts a null into an immutable collection. + * + *

Everything is exercised through {@code serializeMap}, the class's only entry point, so the + * tests cover what production actually calls rather than a convenience overload. + */ +@RunWith(JUnit4.class) +public class SafeSerializerTest { + + private static final String KEY = "v"; + + /** Serializes {@code raw} as a map value and hands back what the trace would show for it. */ + private static Object valueOf(Object raw) { + Map holder = new HashMap<>(); + holder.put(KEY, raw); + return SafeSerializer.serializeMap(holder).get(KEY); + } + + @Test + public void serializeMap_empty_isEmptyNotNull() { + assertThat(SafeSerializer.serializeMap(new HashMap<>())).isEmpty(); + } + + @Test + public void serializeMap_nullValue_becomesTheNullMarker() { + assertThat(valueOf(null)).isEqualTo(""); + } + + @Test + public void serializeMap_primitives_passThrough() { + assertThat(valueOf("text")).isEqualTo("text"); + assertThat(valueOf(42)).isEqualTo(42); + assertThat(valueOf(1.5)).isEqualTo(1.5); + assertThat(valueOf(true)).isEqualTo(true); + } + + @Test + public void serializeMap_bytes_recordLengthNotContent() { + assertThat(valueOf(new byte[] {1, 2, 3, 4, 5})).isEqualTo(""); + } + + @Test + public void serializeMap_nestedStructures_areRecursivelyImmutable() { + Map nested = new HashMap<>(); + nested.put("inner", Arrays.asList("a", "b")); + + Object serialized = valueOf(nested); + + assertThat(serialized).isInstanceOf(ImmutableMap.class); + assertThat(serialized).isEqualTo(ImmutableMap.of("inner", ImmutableList.of("a", "b"))); + } + + @Test + public void serializeMap_nullInsideList_becomesMarkerAndKeepsPosition() { + assertThat(valueOf(Arrays.asList("a", null, "c"))) + .isEqualTo(ImmutableList.of("a", "", "c")); + } + + @Test + public void serializeMap_array_isTreatedAsList() { + assertThat(valueOf(new Object[] {"a", null})).isEqualTo(ImmutableList.of("a", "")); + } + + @Test + public void serializeMap_arbitraryObject_fallsBackToItsDescription() { + assertThat(valueOf(Optional.of("x"))).isEqualTo("Optional[x]"); + } + + @Test + public void serializeMap_objectWhoseToStringThrows_yieldsMarkerInsteadOfPropagating() { + assertThat(valueOf(new ThrowingToString())).isEqualTo(""); + } + + /** + * Without the identity guard this input recurses until the stack overflows. adk-java's own {@code + * JsonFormatter} keeps an identity set for the same reason. + */ + @Test + public void serializeMap_selfReferentialMap_reportsACycleInsteadOfOverflowing() { + Map cyclic = new HashMap<>(); + cyclic.put("name", "root"); + cyclic.put("self", cyclic); + + assertThat(SafeSerializer.serializeMap(cyclic)) + .isEqualTo(ImmutableMap.of("name", "root", "self", "")); + } + + @Test + public void serializeMap_cycleThroughAList_isAlsoCut() { + List cyclic = new ArrayList<>(); + cyclic.add("first"); + cyclic.add(cyclic); + + assertThat(valueOf(cyclic)).isEqualTo(ImmutableList.of("first", "")); + } + + /** A value appearing twice in a tree is not a cycle — the guard must not over-trigger. */ + @Test + public void serializeMap_sameValueTwiceInATree_isSerializedTwice() { + Map shared = new HashMap<>(); + shared.put("k", "v"); + + ImmutableMap expected = ImmutableMap.of("k", "v"); + assertThat(valueOf(Arrays.asList(shared, shared))) + .isEqualTo(ImmutableList.of(expected, expected)); + } + + /** A tool result value that misbehaves exactly where the original's {@code str(obj)} would. */ + private static final class ThrowingToString { + @Override + public String toString() { + throw new IllegalStateException("boom"); + } + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java new file mode 100644 index 000000000..4f0c4999d --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java @@ -0,0 +1,178 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.events.Event; +import com.google.adk.models.LlmRequest; +import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace; +import com.google.adk.plugins.debuglogging.TracePayload.LlmErrorTrace; +import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace; +import com.google.adk.plugins.debuglogging.TracePayload.SessionStateTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolCallTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolErrorTrace; +import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace; +import com.google.adk.plugins.debuglogging.TracePayload.UserMessageTrace; +import com.google.adk.sessions.Session; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Pins the eight payload shapes the state layer adds, on their serialized form rather than on their + * components — the wire keys are what a reader of a trace, or a script comparing adk-java's output + * with adk-python's, actually sees. + */ +@RunWith(JUnit4.class) +public class TracePayloadTest { + + private static final String TOOL = "lookup_order"; + private static final String CALL_ID = "call-7"; + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + + private String serialize(TracePayload payload) throws Exception { + return mapper.writeValueAsString(payload); + } + + @Test + public void userMessage_delegatesToContentTrace() throws Exception { + Content message = + Content.builder() + .role("user") + .parts(ImmutableList.of(Part.builder().text("where is my order?").build())) + .build(); + + assertThat(serialize(UserMessageTrace.from(message))) + .isEqualTo( + "{\"content\":{\"role\":\"user\",\"parts\":[{\"text\":\"where is my order?\"}]}}"); + } + + @Test + public void branch_whenSet_isRecorded() throws Exception { + assertThat(serialize(new BranchTrace(Optional.of("root.researcher")))) + .isEqualTo("{\"branch\":\"root.researcher\"}"); + } + + /** A root invocation has no branch, and upstream's {@code exclude_none} drops the key. */ + @Test + public void branch_whenAbsent_leavesAnEmptyPayload() throws Exception { + assertThat(serialize(new BranchTrace(Optional.empty()))).isEqualTo("{}"); + } + + /** The two marker types must still write {@code data: {}} rather than fail as an empty bean. */ + @Test + public void marker_serializesAsAnEmptyMapping() throws Exception { + assertThat(serialize(MarkerTrace.INSTANCE)).isEqualTo("{}"); + } + + @Test + public void llmError_recordsTypeMessageAndModel() throws Exception { + LlmRequest request = LlmRequest.builder().model("gemini-2.0-flash").build(); + + assertThat(serialize(LlmErrorTrace.from(new IllegalStateException("quota exhausted"), request))) + .isEqualTo( + "{\"error_type\":\"IllegalStateException\",\"error_message\":\"quota exhausted\"," + + "\"model\":\"gemini-2.0-flash\"}"); + } + + /** Python's {@code str(error)} is always a string; a Java throwable may carry no message. */ + @Test + public void llmError_withoutAMessage_omitsTheKeyRatherThanWritingNull() throws Exception { + LlmRequest request = LlmRequest.builder().model("gemini-2.0-flash").build(); + + assertThat(serialize(LlmErrorTrace.from(new IllegalStateException(), request))) + .doesNotContain("error_message"); + } + + @Test + public void toolCall_recordsNameCallIdAndArgs() throws Exception { + ImmutableMap args = ImmutableMap.of("orderId", 42); + + assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, args))) + .isEqualTo( + "{\"tool_name\":\"lookup_order\",\"function_call_id\":\"call-7\"," + + "\"args\":{\"orderId\":42}}"); + } + + /** + * Upstream always passes a dict here, so a no-argument call records {@code args: {}}. This is the + * one place the port deliberately does not use {@code NON_EMPTY}. + */ + @Test + public void toolCall_withNoArguments_stillEmitsTheArgsKey() throws Exception { + assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, new HashMap<>()))).contains("\"args\":{}"); + } + + @Test + public void toolCall_argumentsGoThroughSafeSerializer() throws Exception { + Map args = new HashMap<>(); + args.put("upload", new byte[] {1, 2, 3}); + + assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, args))) + .contains("\"upload\":\"\""); + } + + @Test + public void toolResponse_recordsTheResult() throws Exception { + ImmutableMap result = ImmutableMap.of("status", "shipped"); + + assertThat(serialize(ToolResponseTrace.of(TOOL, CALL_ID, result))) + .isEqualTo( + "{\"tool_name\":\"lookup_order\",\"function_call_id\":\"call-7\"," + + "\"result\":{\"status\":\"shipped\"}}"); + } + + /** The arguments repeat here on purpose: a failure should be readable without its call entry. */ + @Test + public void toolError_carriesTheArgumentsAlongsideTheError() throws Exception { + ImmutableMap args = ImmutableMap.of("orderId", 42); + + String json = + serialize( + ToolErrorTrace.of(TOOL, CALL_ID, args, new IllegalArgumentException("no such id"))); + + assertThat(json).contains("\"args\":{\"orderId\":42}"); + assertThat(json).contains("\"error_type\":\"IllegalArgumentException\""); + assertThat(json).contains("\"error_message\":\"no such id\""); + } + + @Test + public void sessionState_recordsTheStateAndTheEventCount() throws Exception { + Map state = new HashMap<>(); + state.put("cart_size", 2); + Session session = + Session.builder("session-1") + .appName("shop") + .userId("user-1") + .state(state) + .events(ImmutableList.of(Event.builder().id("e1").author("user").build())) + .build(); + + assertThat(serialize(SessionStateTrace.from(session))) + .isEqualTo("{\"state\":{\"cart_size\":2},\"event_count\":1}"); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java new file mode 100644 index 000000000..a632e3150 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java @@ -0,0 +1,76 @@ +/* + * 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.debuglogging; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Pins the only reason this record has two factories instead of one. + * + *

adk-python records different subsets of the same token counts in the two places it + * reports them: three for an event in {@code on_event_callback}, and four for an LLM response in + * {@code after_model_callback}, adding {@code cached_content_token_count}. {@link + * UsageTrace#fromEvent} reproduces that by leaving the cached count absent. + * + *

Both factories are given the same fully-populated metadata, so the only thing that can + * make the two expectations differ is the factory itself. Without this, collapsing the pair into + * one method would silently diverge from adk-python and leave every other test green. + */ +@RunWith(JUnit4.class) +public class UsageTraceTest { + + private static final GenerateContentResponseUsageMetadata USAGE = + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(120) + .candidatesTokenCount(45) + .totalTokenCount(165) + .cachedContentTokenCount(80) + .build(); + + private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper()); + + @Test + public void fromEvent_recordsThreeCountsAndOmitsTheCachedOne() throws Exception { + assertThat(mapper.writeValueAsString(UsageTrace.fromEvent(USAGE))) + .isEqualTo( + "{\"prompt_token_count\":120,\"candidates_token_count\":45," + + "\"total_token_count\":165}"); + } + + @Test + public void fromResponse_addsTheCachedContentCount() throws Exception { + assertThat(mapper.writeValueAsString(UsageTrace.fromResponse(USAGE))) + .isEqualTo( + "{\"prompt_token_count\":120,\"candidates_token_count\":45," + + "\"total_token_count\":165,\"cached_content_token_count\":80}"); + } + + /** Absent counts are omitted rather than written as nulls, in both shapes. */ + @Test + public void fromResponse_withNoCountsAtAll_writesAnEmptyMapping() throws Exception { + GenerateContentResponseUsageMetadata empty = + GenerateContentResponseUsageMetadata.builder().build(); + + assertThat(mapper.writeValueAsString(UsageTrace.fromResponse(empty))).isEqualTo("{}"); + } +}