From 52f2be71c5aa703cb611bddd2cd299df86bf4d10 Mon Sep 17 00:00:00 2001 From: Damian Momot Date: Thu, 22 Oct 2026 05:08:16 -0700 Subject: [PATCH] feat: add NodeInfo.name, runId, and parentRunId, and Event.nodeName PiperOrigin-RevId: 1004687606 --- .../java/com/google/adk/events/Event.java | 15 +++++ .../com/google/adk/workflow/NodeInfo.java | 49 +++++++++++++++ .../java/com/google/adk/events/EventTest.java | 30 ++++++++++ .../com/google/adk/workflow/NodeInfoTest.java | 59 +++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/core/src/main/java/com/google/adk/events/Event.java b/core/src/main/java/com/google/adk/events/Event.java index eb266f459..c9261a641 100644 --- a/core/src/main/java/com/google/adk/events/Event.java +++ b/core/src/main/java/com/google/adk/events/Event.java @@ -419,6 +419,21 @@ && functionResponses().isEmpty() && !hasTrailingCodeExecutionResult(); } + /** + * Returns the name of the workflow node that emitted this event, from {@link NodeInfo#name()}. + * Empty outside a workflow and on an event that carries any agent state or ends an agent. ADK + * Python's {@code Event.node_name} returns an empty string instead and ignores an empty agent + * state. + */ + @Experimental + @JsonIgnore + public final Optional nodeName() { + if (actions().agentState().isPresent() || actions().endOfAgent()) { + return Optional.empty(); + } + return nodeInfo().flatMap(NodeInfo::name); + } + /** * Converts the event content into a readable string. * diff --git a/core/src/main/java/com/google/adk/workflow/NodeInfo.java b/core/src/main/java/com/google/adk/workflow/NodeInfo.java index 9016f6a79..231bf23b4 100644 --- a/core/src/main/java/com/google/adk/workflow/NodeInfo.java +++ b/core/src/main/java/com/google/adk/workflow/NodeInfo.java @@ -17,6 +17,7 @@ package com.google.adk.workflow; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; @@ -27,6 +28,7 @@ import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.List; +import java.util.Optional; /** * The identity of the workflow-node activation that emitted an {@link com.google.adk.events.Event}. @@ -64,6 +66,53 @@ public abstract class NodeInfo { @JsonInclude(JsonInclude.Include.NON_DEFAULT) public abstract boolean messageAsOutput(); + /** + * Returns the emitting node's name: the last segment of {@link #path()} without its run ID, such + * as {@code a} for {@code wf@1/a@2}, or empty when the path is empty. ADK Python's {@code + * NodeInfo.name} returns an empty string instead. + */ + @JsonIgnore + public final Optional name() { + if (path().isEmpty()) { + return Optional.empty(); + } + String segment = lastSegment(path()); + int at = segment.lastIndexOf('@'); + return Optional.of(at < 0 ? segment : segment.substring(0, at)); + } + + /** + * Returns the emitting node's run ID: the part after {@code @} in the last segment of {@link + * #path()}, such as {@code 2} for {@code wf@1/a@2}, or empty when the segment has none. ADK + * Python's {@code NodeInfo.run_id} returns an empty string instead. + */ + @JsonIgnore + public final Optional runId() { + return runIdOf(lastSegment(path())); + } + + /** + * Returns the run ID of the emitting node's parent: the part after {@code @} in the + * second-to-last segment of {@link #path()}, such as {@code 1} for {@code wf@1/a@2}. Empty when + * the path has no parent segment or that segment has no run ID, as in ADK Python's {@code + * NodeInfo.parent_run_id}. + */ + @JsonIgnore + public final Optional parentRunId() { + String path = path(); + int lastSlash = path.lastIndexOf('/'); + return lastSlash < 0 ? Optional.empty() : runIdOf(lastSegment(path.substring(0, lastSlash))); + } + + private static String lastSegment(String path) { + return path.substring(path.lastIndexOf('/') + 1); + } + + private static Optional runIdOf(String segment) { + int at = segment.lastIndexOf('@'); + return at < 0 ? Optional.empty() : Optional.of(segment.substring(at + 1)); + } + public static Builder builder() { return new AutoValue_NodeInfo.Builder() .path("") diff --git a/core/src/test/java/com/google/adk/events/EventTest.java b/core/src/test/java/com/google/adk/events/EventTest.java index 7eb3f82e0..53358bfbd 100644 --- a/core/src/test/java/com/google/adk/events/EventTest.java +++ b/core/src/test/java/com/google/adk/events/EventTest.java @@ -275,6 +275,36 @@ public void json_roundTripsAnEmptyNodeInfo() { assertThat(Event.fromJson(event.toJson())).isEqualTo(event); } + @Test + public void nodeName_isTheEmittingNodesName() { + Event event = EVENT.toBuilder().nodeInfo(NodeInfo.builder().path("wf@1/a@2").build()).build(); + + assertThat(event.nodeName()).hasValue("a"); + } + + @Test + public void nodeName_emptyOutsideAWorkflow() { + assertThat(EVENT.nodeName()).isEmpty(); + assertThat(EVENT.toBuilder().nodeInfo(NodeInfo.builder().build()).build().nodeName()).isEmpty(); + } + + @Test + public void nodeName_emptyForAgentStateAndEndOfAgentEvents() { + Event endOfAgent = + EVENT.toBuilder() + .nodeInfo(NODE_INFO) + .actions(EventActions.builder().endOfAgent(true).build()) + .build(); + Event agentState = + EVENT.toBuilder() + .nodeInfo(NODE_INFO) + .actions(EventActions.builder().agentState(ImmutableMap.of()).build()) + .build(); + + assertThat(endOfAgent.nodeName()).isEmpty(); + assertThat(agentState.nodeName()).isEmpty(); + } + @Test public void event_builder_with_transcriptions_works() { Transcription inputTranscription = diff --git a/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java b/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java index a3ca70c27..043b62639 100644 --- a/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java +++ b/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java @@ -20,6 +20,7 @@ import com.google.adk.JsonBaseModel; import com.google.common.collect.ImmutableList; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -63,4 +64,62 @@ public void json_omitsUnsetOutputForAndFalseMessageAsOutput() { assertThat(json).doesNotContain("outputFor"); assertThat(json).doesNotContain("messageAsOutput"); } + + @Test + public void json_omitsPathHelpers() { + Map json = JsonBaseModel.getMapper().convertValue(NODE_INFO, Map.class); + + assertThat(json.keySet()).containsExactly("path", "outputFor", "messageAsOutput"); + } + + @Test + public void name_isTheLastSegmentWithoutItsRunId() { + assertThat(nodeInfo("wf@1/a@2/b@3").name()).hasValue("b"); + assertThat(nodeInfo("wf@1/b").name()).hasValue("b"); + assertThat(nodeInfo("wf@1").name()).hasValue("wf"); + } + + @Test + public void runId_isTheLastSegmentsRunId() { + assertThat(nodeInfo("wf@1/a@2/b@3").runId()).hasValue("3"); + } + + @Test + public void runId_emptyWithoutARunId() { + assertThat(nodeInfo("wf@1/b").runId()).isEmpty(); + } + + @Test + public void parentRunId_isTheSecondToLastSegmentsRunId() { + assertThat(nodeInfo("wf@1/a@2/b@3").parentRunId()).hasValue("2"); + assertThat(nodeInfo("wf@1/b").parentRunId()).hasValue("1"); + } + + @Test + public void parentRunId_emptyWithoutAParentRunId() { + assertThat(nodeInfo("wf@1").parentRunId()).isEmpty(); + assertThat(nodeInfo("wf/b@1").parentRunId()).isEmpty(); + } + + @Test + public void pathHelpers_splitEachSegmentAtItsLastAt() { + NodeInfo nodeInfo = nodeInfo("w@f@1/a@b@2"); + + assertThat(nodeInfo.name()).hasValue("a@b"); + assertThat(nodeInfo.runId()).hasValue("2"); + assertThat(nodeInfo.parentRunId()).hasValue("1"); + } + + @Test + public void pathHelpers_emptyForAnEmptyPath() { + NodeInfo nodeInfo = NodeInfo.builder().build(); + + assertThat(nodeInfo.name()).isEmpty(); + assertThat(nodeInfo.runId()).isEmpty(); + assertThat(nodeInfo.parentRunId()).isEmpty(); + } + + private static NodeInfo nodeInfo(String path) { + return NodeInfo.builder().path(path).build(); + } }