Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions core/src/main/java/com/google/adk/agents/BaseAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ public ImmutableList<? extends AfterAgentCallback> canonicalAfterAgentCallbacks(
*/
private InvocationContext createInvocationContext(InvocationContext parentContext) {
InvocationContext.Builder builder = parentContext.toBuilder();
builder.agent(this);
// An agent's context runs that agent, not the node that its parent context ran.
builder.agent(this).node(null);
// Check for branch to be truthy (not None, not empty string),
parentContext
.branch()
Expand Down Expand Up @@ -489,13 +490,21 @@ private Event checkpointEvent(InvocationContext context, EventActions actions) {

/**
* Runs this agent as a workflow node through its {@link #runAsync} lifecycle, including its
* callbacks. The node input is not used.
* callbacks. The node input is not used. Each event's author is set with {@link
* com.google.adk.workflow.NodeExecutionState#eventAuthor(String)}, so the agent's events keep
* their own authors instead of an inherited author or the node's name.
*/
@Experimental
@Override
public Flowable<Event> runNode(
com.google.adk.agents.Context context, @Nullable Object nodeInput) {
return runAsync(context.invocationContext());
return runAsync(context.invocationContext())
.doOnNext(
event -> {
if (!isNullOrEmpty(event.author())) {
context.requireNodeState().eventAuthor(event.author());
}
});
}

/**
Expand Down
44 changes: 42 additions & 2 deletions core/src/main/java/com/google/adk/agents/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@

package com.google.adk.agents;

import com.google.adk.annotations.Experimental;
import com.google.adk.artifacts.ListArtifactsResponse;
import com.google.adk.events.EventActions;
import com.google.adk.events.ToolConfirmation;
import com.google.adk.memory.SearchMemoryResponse;
import com.google.adk.sessions.State;
import com.google.adk.workflow.NodeExecutionState;
import com.google.common.base.Preconditions;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Completable;
Expand All @@ -31,12 +33,13 @@
import org.jspecify.annotations.Nullable;

/**
* The context passed to callbacks and tools during an invocation.
* The context passed to callbacks, tools, and workflow nodes during an invocation.
*
* <p>Agent and model callbacks receive a {@link CallbackContext}, and tools and tool callbacks a
* {@link com.google.adk.tools.ToolContext}; both extend this class, so a helper that serves both,
* or a function tool's {@code toolContext} parameter, can take a {@code Context}. {@link
* #requestConfirmation()} needs a function call ID, which only a tool call has.
* #requestConfirmation()} needs a function call ID, which only a tool call has, and {@link
* #requireNodeState()} needs the context of a workflow node.
*/
public class Context extends ReadonlyContext {

Expand All @@ -45,6 +48,7 @@ public class Context extends ReadonlyContext {

private final State state;
private final @Nullable String eventId;
private final @Nullable NodeExecutionState nodeState;
private Optional<String> functionCallId = Optional.empty();
private Optional<ToolConfirmation> toolConfirmation = Optional.empty();

Expand All @@ -59,10 +63,32 @@ public class Context extends ReadonlyContext {
InvocationContext invocationContext,
@Nullable EventActions eventActions,
@Nullable String eventId) {
this(invocationContext, eventActions, eventId, /* nodeState= */ null);
}

/**
* Creates the context of the workflow node run whose state is {@code nodeState}. Only the
* workflow runtime creates a node state.
*/
@Experimental
public Context(InvocationContext invocationContext, NodeExecutionState nodeState) {
this(
invocationContext,
/* eventActions= */ null,
/* eventId= */ null,
Preconditions.checkNotNull(nodeState));
}

private Context(
InvocationContext invocationContext,
@Nullable EventActions eventActions,
@Nullable String eventId,
@Nullable NodeExecutionState nodeState) {
super(invocationContext);
this.eventActions = eventActions != null ? eventActions : EventActions.builder().build();
this.state = new State(invocationContext.session().state(), this.eventActions.stateDelta());
this.eventId = eventId;
this.nodeState = nodeState;
}

/** Returns the delta-aware state of the current context. */
Expand Down Expand Up @@ -223,4 +249,18 @@ public Single<SearchMemoryResponse> searchMemory(String query) {
.searchMemory(
invocationContext.session().appName(), invocationContext.session().userId(), query);
}

/**
* Returns the state of the workflow node run this context belongs to.
*
* @throws UnsupportedOperationException if this is not the context of a workflow node.
*/
@Experimental
public final NodeExecutionState requireNodeState() {
if (nodeState == null) {
throw new UnsupportedOperationException(
"Only the context of a workflow node has a node state.");
}
return nodeState;
}
}
30 changes: 30 additions & 0 deletions core/src/main/java/com/google/adk/agents/InvocationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.Session;
import com.google.adk.summarizer.EventsCompactionConfig;
import com.google.adk.workflow.Node;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
Expand Down Expand Up @@ -84,6 +85,7 @@ public class InvocationContext {

@Nullable private String branch;
private BaseAgent agent;
private final @Nullable Node node;
private boolean endInvocation;

protected InvocationContext(Builder builder) {
Expand All @@ -97,6 +99,7 @@ protected InvocationContext(Builder builder) {
// Resolved here so subclasses that construct from a builder without build() also get an id.
this.invocationId = builder.resolveInvocationId();
this.agent = builder.agent;
this.node = builder.node;
this.session = builder.session;
this.userContent = builder.userContent;
this.runConfig = builder.runConfig;
Expand Down Expand Up @@ -182,6 +185,15 @@ public BaseAgent agent() {
return agent;
}

/**
* Returns the workflow node this invocation runs, or empty when it runs an agent. When it runs a
* node, {@link #agent()} returns a {@link NodeViewAgent} that stands in for it.
*/
@Experimental
public Optional<Node> node() {
return Optional.ofNullable(node);
}

/** Returns the session associated with this invocation. */
public Session session() {
return session;
Expand Down Expand Up @@ -653,6 +665,7 @@ private Builder(InvocationContext context) {
this.invocationId = context.invocationId;
this.invocationIdSet = true;
this.agent = context.agent;
this.node = context.node;
this.session = context.session;
this.userContent = context.userContent;
this.runConfig = context.runConfig;
Expand Down Expand Up @@ -684,6 +697,7 @@ private Builder(InvocationContext context) {
// Distinguishes "never set" (an id is minted from the UuidProvider) from an explicit null.
private boolean invocationIdSet;
private BaseAgent agent;
private @Nullable Node node;
private Session session;
@Nullable private Content userContent = null;
private RunConfig runConfig = RunConfig.builder().build();
Expand Down Expand Up @@ -796,6 +810,17 @@ public Builder agent(BaseAgent agent) {
return this;
}

/**
* Sets the workflow node the invocation runs, or null when it runs an agent. When the
* invocation runs a node, its agent is a {@link NodeViewAgent} that stands in for the node.
*/
@CanIgnoreReturnValue
@Experimental
public Builder node(@Nullable Node node) {
this.node = node;
return this;
}

/**
* Sets the session associated with this invocation.
*
Expand Down Expand Up @@ -968,6 +993,9 @@ private static void validate(Builder builder) {
if (builder.agent == null) {
throw new IllegalStateException("Agent must be set.");
}
if (builder.agent instanceof NodeViewAgent && builder.node == null) {
throw new IllegalStateException("A node view agent requires the node it stands in for.");
}
if (builder.session == null) {
throw new IllegalStateException("Session must be set.");
}
Expand All @@ -994,6 +1022,7 @@ public boolean equals(Object o) {
&& Objects.equals(branch, that.branch)
&& Objects.equals(invocationId, that.invocationId)
&& Objects.equals(agent, that.agent)
&& Objects.equals(node, that.node)
&& Objects.equals(session, that.session)
&& Objects.equals(userContent, that.userContent)
&& Objects.equals(runConfig, that.runConfig)
Expand All @@ -1019,6 +1048,7 @@ public int hashCode() {
branch,
invocationId,
agent,
node,
session,
userContent,
runConfig,
Expand Down
72 changes: 72 additions & 0 deletions core/src/main/java/com/google/adk/agents/NodeViewAgent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.agents;

import static com.google.common.base.Preconditions.checkArgument;

import com.google.adk.annotations.Experimental;
import com.google.adk.events.Event;
import com.google.adk.workflow.Node;
import io.reactivex.rxjava3.core.Flowable;

/**
* A read-only agent that stands in for a non-agent workflow node so code reading the agent of an
* app, runner, or invocation keeps working when they run a node. It never runs; the runner runs the
* node itself.
*/
@Experimental
public final class NodeViewAgent extends BaseAgent {

private final Node node;

/**
* Creates an agent that stands in for {@code node}, using its name and description.
*
* @throws IllegalArgumentException if {@code node} is an agent, or if its name is not a valid
* agent name.
*/
public NodeViewAgent(Node node) {
super(
node.name(),
node.description(),
/* subAgents= */ null,
/* beforeAgentCallback= */ null,
/* afterAgentCallback= */ null);
checkArgument(!(node instanceof BaseAgent), "An agent needs no node view.");
this.node = node;
}

/** Returns the node this agent stands in for. */
public Node node() {
return node;
}

@Override
protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
return Flowable.error(neverRuns());
}

@Override
protected Flowable<Event> runLiveImpl(InvocationContext invocationContext) {
return Flowable.error(neverRuns());
}

private UnsupportedOperationException neverRuns() {
return new UnsupportedOperationException(
"A node view agent never runs; it stands in for node '" + name() + "'.");
}
}
Loading
Loading