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
23 changes: 17 additions & 6 deletions a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.Callbacks;
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.Resumable;
import com.google.adk.events.Event;
import com.google.adk.utils.AgentEnums.AgentOrigin;
import com.google.common.collect.ImmutableList;
Expand Down Expand Up @@ -75,7 +76,7 @@
* <li>Converting A2A client responses back into ADK format
* </ul>
*/
public class RemoteA2AAgent extends BaseAgent {
public class RemoteA2AAgent extends BaseAgent implements Resumable {

private static final Logger logger = LoggerFactory.getLogger(RemoteA2AAgent.class);
private static final ObjectMapper objectMapper =
Expand All @@ -85,6 +86,7 @@ public class RemoteA2AAgent extends BaseAgent {
private final Client a2aClient;
private String description;
private final boolean streaming;
private final boolean resumable;

// Internal constructor used by builder
private RemoteA2AAgent(Builder builder) {
Expand Down Expand Up @@ -118,6 +120,7 @@ private RemoteA2AAgent(Builder builder) {
this.description = this.agentCard.description();
}
this.streaming = builder.streaming && this.agentCard.capabilities().streaming();
this.resumable = builder.resumable;
}

public static Builder builder() {
Expand All @@ -134,13 +137,20 @@ public static class Builder {
private List<Callbacks.BeforeAgentCallback> beforeAgentCallback;
private List<Callbacks.AfterAgentCallback> afterAgentCallback;
private boolean streaming;
private boolean resumable = true;

@CanIgnoreReturnValue
public Builder streaming(boolean streaming) {
this.streaming = streaming;
return this;
}

@CanIgnoreReturnValue
public Builder resumable(boolean resumable) {
this.resumable = resumable;
return this;
}

@CanIgnoreReturnValue
public Builder name(String name) {
this.name = name;
Expand Down Expand Up @@ -192,6 +202,11 @@ public boolean isStreaming() {
return streaming;
}

@Override
public boolean isResumable() {
return resumable;
}

private Message.Builder newA2AMessage(Message.Role role, List<io.a2a.spec.Part<?>> parts) {
return new Message.Builder().messageId(UUID.randomUUID().toString()).role(role).parts(parts);
}
Expand Down Expand Up @@ -227,8 +242,7 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
return Flowable.create(
emitter -> {
StreamHandler handler =
new StreamHandler(
emitter.serialize(), invocationContext, requestJson, streaming, name());
new StreamHandler(emitter.serialize(), invocationContext, requestJson, name());
ImmutableList<BiConsumer<ClientEvent, AgentCard>> consumers =
ImmutableList.of(handler::handleEvent);
a2aClient.sendMessage(originalMessage, consumers, handler::handleError, null);
Expand All @@ -249,7 +263,6 @@ private static class StreamHandler {
private final FlowableEmitter<Event> emitter;
private final InvocationContext invocationContext;
private final String requestJson;
private final boolean streaming;
private final String agentName;
private boolean done = false;
private final StringBuilder textBuffer = new StringBuilder();
Expand All @@ -259,12 +272,10 @@ private static class StreamHandler {
FlowableEmitter<Event> emitter,
InvocationContext invocationContext,
String requestJson,
boolean streaming,
String agentName) {
this.emitter = emitter;
this.invocationContext = invocationContext;
this.requestJson = requestJson;
this.streaming = streaming;
this.agentName = agentName;
}

Expand Down
12 changes: 12 additions & 0 deletions a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ public void createAgent_streaming_true_returnsStreamingAgent() {
assertThat(agent.isStreaming()).isTrue();
}

@Test
public void createAgent_resumable_default_true() {
RemoteA2AAgent agent = getAgentBuilder().build();
assertThat(agent.isResumable()).isTrue();
}

@Test
public void createAgent_resumable_false() {
RemoteA2AAgent agent = getAgentBuilder().resumable(false).build();
assertThat(agent.isResumable()).isFalse();
}

@Test
public void runAsync_aggregatesPartialEvents() {
RemoteA2AAgent agent = createAgent();
Expand Down
7 changes: 6 additions & 1 deletion core/src/main/java/com/google/adk/agents/LlmAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
import org.slf4j.LoggerFactory;

/** The LLM-based agent. */
public class LlmAgent extends BaseAgent {
public class LlmAgent extends BaseAgent implements Resumable {

private static final Logger logger = LoggerFactory.getLogger(LlmAgent.class);

Expand Down Expand Up @@ -779,6 +779,11 @@ public boolean disallowTransferToParent() {
return disallowTransferToParent;
}

@Override
public boolean isResumable() {
return !disallowTransferToParent();
}

public boolean disallowTransferToPeers() {
return disallowTransferToPeers;
}
Expand Down
21 changes: 21 additions & 0 deletions core/src/main/java/com/google/adk/agents/Resumable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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;

/** Interface for agents that can be resumed from history directly. */
public interface Resumable {
boolean isResumable();
}
9 changes: 2 additions & 7 deletions core/src/main/java/com/google/adk/runner/Runner.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.LiveRequestQueue;
import com.google.adk.agents.LlmAgent;
import com.google.adk.agents.Resumable;
import com.google.adk.agents.RunConfig;
import com.google.adk.apps.App;
import com.google.adk.artifacts.BaseArtifactService;
Expand Down Expand Up @@ -752,13 +753,7 @@ protected Flowable<Event> runLiveImpl(
private boolean isTransferableAcrossAgentTree(BaseAgent agentToRun) {
BaseAgent current = agentToRun;
while (current != null) {
// Agents eligible to transfer must have an LLM-based agent parent.
if (!(current instanceof LlmAgent)) {
return false;
}
// If any agent can't transfer to its parent, the chain is broken.
LlmAgent agent = (LlmAgent) current;
if (agent.disallowTransferToParent()) {
if (!(current instanceof Resumable resumableAgent) || !resumableAgent.isResumable()) {
return false;
}
current = current.parentAgent();
Expand Down
141 changes: 141 additions & 0 deletions core/src/test/java/com/google/adk/runner/RunnerResumabilityTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.google.adk.runner;

import static com.google.adk.testing.TestUtils.createContent;
import static com.google.adk.testing.TestUtils.createLlmResponse;
import static com.google.adk.testing.TestUtils.createTestLlm;
import static com.google.adk.testing.TestUtils.simplifyEvents;
import static com.google.common.truth.Truth.assertThat;

import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.InvocationContext;
import com.google.adk.agents.LlmAgent;
import com.google.adk.agents.Resumable;
import com.google.adk.apps.App;
import com.google.adk.events.Event;
import com.google.adk.sessions.Session;
import com.google.common.collect.ImmutableList;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Flowable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
* Tests for the resumability feature in the {@link Runner}.
*
* <p>This test suite verifies that when a session contains events from a resumable agent, the
* runner correctly bypasses earlier workflow steps (like the root agent) and resumes execution
* directly at the last active resumable agent.
*/
@RunWith(JUnit4.class)
public final class RunnerResumabilityTest {

private static class TestResumableAgent extends BaseAgent implements Resumable {
private final boolean resumable;

public TestResumableAgent(String name, boolean resumable) {
super(name, "", ImmutableList.of(), ImmutableList.of(), ImmutableList.of());
this.resumable = resumable;
}

@Override
public boolean isResumable() {
return resumable;
}

@Override
protected Flowable<Event> runAsyncImpl(InvocationContext context) {
return Flowable.just(
Event.builder()
.id("event-" + name())
.author(name())
.content(
Content.builder()
.parts(
ImmutableList.of(Part.builder().text("response from " + name()).build()))
.build())
.build());
}

@Override
protected Flowable<Event> runLiveImpl(InvocationContext context) {
return runAsyncImpl(context);
}
}

/**
* Verifies that {@link Runner#runAsync} picks up execution from a resumable sub-agent.
*
* <p>This test configures a workflow hierarchy with a root agent and a resumable sub-agent. By
* pre-loading the session state with an initial event authored by the sub-agent, we simulate a
* scenario where execution stopped within the sub-workflow. Calling {@code runAsync} triggers the
* resume behavior, bypassing the default root flow.
*/
@Test
public void runAsync_resumesAtResumableSubAgent() {
TestResumableAgent subAgent = new TestResumableAgent("sub_agent", true);
LlmAgent rootAgent =
LlmAgent.builder()
.name("root_agent")
.model(createTestLlm(createLlmResponse(createContent("from root"))))
.subAgents(ImmutableList.of(subAgent))
.build();

Runner runner =
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();

Session session = runner.sessionService().createSession("test", "user").blockingGet();

Event subAgentEvent =
Event.builder()
.id("initial-event")
.author("sub_agent")
.content(createContent("subagent greeting"))
.build();

var unused = runner.sessionService().appendEvent(session, subAgentEvent).blockingGet();

var events =
runner.runAsync("user", session.id(), createContent("continue")).toList().blockingGet();

assertThat(simplifyEvents(events)).containsExactly("sub_agent: response from sub_agent");
}

/**
* Verifies that {@link Runner#runAsync} does not resume execution from a non-resumable sub-agent.
*
* <p>This test ensures that if the sub-agent is NOT marked as resumable, even if there are
* existing events from it in the session, the runner falls back to regular execution starting
* from the root agent.
*/
@Test
public void runAsync_doesNotResumeAtNonResumableSubAgent() {
TestResumableAgent subAgent = new TestResumableAgent("sub_agent", false);
LlmAgent rootAgent =
LlmAgent.builder()
.name("root_agent")
.model(createTestLlm(createLlmResponse(createContent("from root"))))
.subAgents(ImmutableList.of(subAgent))
.build();

Runner runner =
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();

Session session = runner.sessionService().createSession("test", "user").blockingGet();

Event subAgentEvent =
Event.builder()
.id("initial-event")
.author("sub_agent")
.content(createContent("subagent greeting"))
.build();

var unused = runner.sessionService().appendEvent(session, subAgentEvent).blockingGet();

var events =
runner.runAsync("user", session.id(), createContent("continue")).toList().blockingGet();

assertThat(simplifyEvents(events)).containsExactly("root_agent: from root");
}
}
4 changes: 4 additions & 0 deletions core/src/test/java/com/google/adk/testing/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ public static TestLlm createTestLlm(Supplier<Flowable<LlmResponse>> responsesSup
return new TestLlm(responsesSupplier);
}

public static Content createContent(String text) {
return Content.builder().parts(Part.builder().text(text).build()).build();
}

public static LlmResponse createLlmResponse(Content content) {
return LlmResponse.builder().content(content).build();
}
Expand Down