Skip to content

Commit 83d38cd

Browse files
google-genai-botcopybara-github
authored andcommitted
fix: the RemoteA2AAgent does not support resumability and always transfers to the parent
Only LLMAgent supported this via the disallowTransferToParent attribute Add Resumable interface and test for runner resumability. This change introduces the `Resumable` interface, allowing agents to indicate if they can be resumed from a previous state. Also the LLMAgent and RemoteA2A agent has been modified to implement this. Ea A new test case in `RunnerResumabilityTest` demonstrates how the runner can leverage this to resume execution from a resumable sub-agent based on the session history. PiperOrigin-RevId: 898502514
1 parent 4009905 commit 83d38cd

7 files changed

Lines changed: 202 additions & 12 deletions

File tree

a2a/src/main/java/com/google/adk/a2a/agent/RemoteA2AAgent.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.google.adk.agents.BaseAgent;
2828
import com.google.adk.agents.Callbacks;
2929
import com.google.adk.agents.InvocationContext;
30+
import com.google.adk.agents.Resumable;
3031
import com.google.adk.events.Event;
3132
import com.google.adk.utils.AgentEnums.AgentOrigin;
3233
import com.google.common.collect.ImmutableList;
@@ -75,7 +76,7 @@
7576
* <li>Converting A2A client responses back into ADK format
7677
* </ul>
7778
*/
78-
public class RemoteA2AAgent extends BaseAgent {
79+
public class RemoteA2AAgent extends BaseAgent implements Resumable {
7980

8081
private static final Logger logger = LoggerFactory.getLogger(RemoteA2AAgent.class);
8182
private static final ObjectMapper objectMapper =
@@ -85,6 +86,7 @@ public class RemoteA2AAgent extends BaseAgent {
8586
private final Client a2aClient;
8687
private String description;
8788
private final boolean streaming;
89+
private final boolean resumable;
8890

8991
// Internal constructor used by builder
9092
private RemoteA2AAgent(Builder builder) {
@@ -118,6 +120,7 @@ private RemoteA2AAgent(Builder builder) {
118120
this.description = this.agentCard.description();
119121
}
120122
this.streaming = builder.streaming && this.agentCard.capabilities().streaming();
123+
this.resumable = builder.resumable;
121124
}
122125

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

138142
@CanIgnoreReturnValue
139143
public Builder streaming(boolean streaming) {
140144
this.streaming = streaming;
141145
return this;
142146
}
143147

148+
@CanIgnoreReturnValue
149+
public Builder resumable(boolean resumable) {
150+
this.resumable = resumable;
151+
return this;
152+
}
153+
144154
@CanIgnoreReturnValue
145155
public Builder name(String name) {
146156
this.name = name;
@@ -192,6 +202,11 @@ public boolean isStreaming() {
192202
return streaming;
193203
}
194204

205+
@Override
206+
public boolean isResumable() {
207+
return resumable;
208+
}
209+
195210
private Message.Builder newA2AMessage(Message.Role role, List<io.a2a.spec.Part<?>> parts) {
196211
return new Message.Builder().messageId(UUID.randomUUID().toString()).role(role).parts(parts);
197212
}
@@ -249,7 +264,6 @@ private static class StreamHandler {
249264
private final FlowableEmitter<Event> emitter;
250265
private final InvocationContext invocationContext;
251266
private final String requestJson;
252-
private final boolean streaming;
253267
private final String agentName;
254268
private boolean done = false;
255269
private final StringBuilder textBuffer = new StringBuilder();
@@ -259,12 +273,10 @@ private static class StreamHandler {
259273
FlowableEmitter<Event> emitter,
260274
InvocationContext invocationContext,
261275
String requestJson,
262-
boolean streaming,
263276
String agentName) {
264277
this.emitter = emitter;
265278
this.invocationContext = invocationContext;
266279
this.requestJson = requestJson;
267-
this.streaming = streaming;
268280
this.agentName = agentName;
269281
}
270282

a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,18 @@ public void createAgent_streaming_true_returnsStreamingAgent() {
127127
assertThat(agent.isStreaming()).isTrue();
128128
}
129129

130+
@Test
131+
public void createAgent_resumable_default_true() {
132+
RemoteA2AAgent agent = getAgentBuilder().build();
133+
assertThat(agent.isResumable()).isTrue();
134+
}
135+
136+
@Test
137+
public void createAgent_resumable_false() {
138+
RemoteA2AAgent agent = getAgentBuilder().resumable(false).build();
139+
assertThat(agent.isResumable()).isFalse();
140+
}
141+
130142
@Test
131143
public void runAsync_aggregatesPartialEvents() {
132144
RemoteA2AAgent agent = createAgent();

core/src/main/java/com/google/adk/agents/LlmAgent.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
import org.slf4j.LoggerFactory;
7777

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

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

@@ -779,6 +779,11 @@ public boolean disallowTransferToParent() {
779779
return disallowTransferToParent;
780780
}
781781

782+
@Override
783+
public boolean isResumable() {
784+
return !disallowTransferToParent();
785+
}
786+
782787
public boolean disallowTransferToPeers() {
783788
return disallowTransferToPeers;
784789
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.adk.agents;
17+
18+
/** Interface for agents that can be resumed from history directly. */
19+
public interface Resumable {
20+
boolean isResumable();
21+
}

core/src/main/java/com/google/adk/runner/Runner.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.google.adk.agents.InvocationContext;
2323
import com.google.adk.agents.LiveRequestQueue;
2424
import com.google.adk.agents.LlmAgent;
25+
import com.google.adk.agents.Resumable;
2526
import com.google.adk.agents.RunConfig;
2627
import com.google.adk.apps.App;
2728
import com.google.adk.artifacts.BaseArtifactService;
@@ -752,13 +753,7 @@ protected Flowable<Event> runLiveImpl(
752753
private boolean isTransferableAcrossAgentTree(BaseAgent agentToRun) {
753754
BaseAgent current = agentToRun;
754755
while (current != null) {
755-
// Agents eligible to transfer must have an LLM-based agent parent.
756-
if (!(current instanceof LlmAgent)) {
757-
return false;
758-
}
759-
// If any agent can't transfer to its parent, the chain is broken.
760-
LlmAgent agent = (LlmAgent) current;
761-
if (agent.disallowTransferToParent()) {
756+
if (!(current instanceof Resumable resumableAgent) || !resumableAgent.isResumable()) {
762757
return false;
763758
}
764759
current = current.parentAgent();
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.google.adk.runner;
2+
3+
import static com.google.adk.testing.TestUtils.createContent;
4+
import static com.google.adk.testing.TestUtils.createLlmResponse;
5+
import static com.google.adk.testing.TestUtils.createTestLlm;
6+
import static com.google.adk.testing.TestUtils.simplifyEvents;
7+
import static com.google.common.truth.Truth.assertThat;
8+
9+
import com.google.adk.agents.BaseAgent;
10+
import com.google.adk.agents.InvocationContext;
11+
import com.google.adk.agents.LlmAgent;
12+
import com.google.adk.agents.Resumable;
13+
import com.google.adk.apps.App;
14+
import com.google.adk.events.Event;
15+
import com.google.adk.sessions.Session;
16+
import com.google.common.collect.ImmutableList;
17+
import com.google.genai.types.Content;
18+
import com.google.genai.types.Part;
19+
import io.reactivex.rxjava3.core.Flowable;
20+
import org.junit.Test;
21+
import org.junit.runner.RunWith;
22+
import org.junit.runners.JUnit4;
23+
24+
/**
25+
* Tests for the resumability feature in the {@link Runner}.
26+
*
27+
* <p>This test suite verifies that when a session contains events from a resumable agent, the
28+
* runner correctly bypasses earlier workflow steps (like the root agent) and resumes execution
29+
* directly at the last active resumable agent.
30+
*/
31+
@RunWith(JUnit4.class)
32+
public final class RunnerResumabilityTest {
33+
34+
private static class TestResumableAgent extends BaseAgent implements Resumable {
35+
private final boolean resumable;
36+
37+
public TestResumableAgent(String name, boolean resumable) {
38+
super(name, "", ImmutableList.of(), ImmutableList.of(), ImmutableList.of());
39+
this.resumable = resumable;
40+
}
41+
42+
@Override
43+
public boolean isResumable() {
44+
return resumable;
45+
}
46+
47+
@Override
48+
protected Flowable<Event> runAsyncImpl(InvocationContext context) {
49+
return Flowable.just(
50+
Event.builder()
51+
.id("event-" + name())
52+
.author(name())
53+
.content(
54+
Content.builder()
55+
.parts(
56+
ImmutableList.of(Part.builder().text("response from " + name()).build()))
57+
.build())
58+
.build());
59+
}
60+
61+
@Override
62+
protected Flowable<Event> runLiveImpl(InvocationContext context) {
63+
return runAsyncImpl(context);
64+
}
65+
}
66+
67+
/**
68+
* Verifies that {@link Runner#runAsync} picks up execution from a resumable sub-agent.
69+
*
70+
* <p>This test configures a workflow hierarchy with a root agent and a resumable sub-agent. By
71+
* pre-loading the session state with an initial event authored by the sub-agent, we simulate a
72+
* scenario where execution stopped within the sub-workflow. Calling {@code runAsync} triggers the
73+
* resume behavior, bypassing the default root flow.
74+
*/
75+
@Test
76+
public void runAsync_resumesAtResumableSubAgent() {
77+
TestResumableAgent subAgent = new TestResumableAgent("sub_agent", true);
78+
LlmAgent rootAgent =
79+
LlmAgent.builder()
80+
.name("root_agent")
81+
.model(createTestLlm(createLlmResponse(createContent("from root"))))
82+
.subAgents(ImmutableList.of(subAgent))
83+
.build();
84+
85+
Runner runner =
86+
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();
87+
88+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
89+
90+
Event subAgentEvent =
91+
Event.builder()
92+
.id("initial-event")
93+
.author("sub_agent")
94+
.content(createContent("subagent greeting"))
95+
.build();
96+
97+
var unused = runner.sessionService().appendEvent(session, subAgentEvent).blockingGet();
98+
99+
var events =
100+
runner.runAsync("user", session.id(), createContent("continue")).toList().blockingGet();
101+
102+
assertThat(simplifyEvents(events)).containsExactly("sub_agent: response from sub_agent");
103+
}
104+
105+
/**
106+
* Verifies that {@link Runner#runAsync} does not resume execution from a non-resumable sub-agent.
107+
*
108+
* <p>This test ensures that if the sub-agent is NOT marked as resumable, even if there are
109+
* existing events from it in the session, the runner falls back to regular execution starting
110+
* from the root agent.
111+
*/
112+
@Test
113+
public void runAsync_doesNotResumeAtNonResumableSubAgent() {
114+
TestResumableAgent subAgent = new TestResumableAgent("sub_agent", false);
115+
LlmAgent rootAgent =
116+
LlmAgent.builder()
117+
.name("root_agent")
118+
.model(createTestLlm(createLlmResponse(createContent("from root"))))
119+
.subAgents(ImmutableList.of(subAgent))
120+
.build();
121+
122+
Runner runner =
123+
Runner.builder().app(App.builder().name("test").rootAgent(rootAgent).build()).build();
124+
125+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
126+
127+
Event subAgentEvent =
128+
Event.builder()
129+
.id("initial-event")
130+
.author("sub_agent")
131+
.content(createContent("subagent greeting"))
132+
.build();
133+
134+
var unused = runner.sessionService().appendEvent(session, subAgentEvent).blockingGet();
135+
136+
var events =
137+
runner.runAsync("user", session.id(), createContent("continue")).toList().blockingGet();
138+
139+
assertThat(simplifyEvents(events)).containsExactly("root_agent: from root");
140+
}
141+
}

core/src/test/java/com/google/adk/testing/TestUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ public static TestLlm createTestLlm(Supplier<Flowable<LlmResponse>> responsesSup
234234
return new TestLlm(responsesSupplier);
235235
}
236236

237+
public static Content createContent(String text) {
238+
return Content.builder().parts(Part.builder().text(text).build()).build();
239+
}
240+
237241
public static LlmResponse createLlmResponse(Content content) {
238242
return LlmResponse.builder().content(content).build();
239243
}

0 commit comments

Comments
 (0)