diff --git a/core/src/main/java/com/google/adk/agents/InvocationContext.java b/core/src/main/java/com/google/adk/agents/InvocationContext.java index f3a3cf3b0..be80054e5 100644 --- a/core/src/main/java/com/google/adk/agents/InvocationContext.java +++ b/core/src/main/java/com/google/adk/agents/InvocationContext.java @@ -18,6 +18,7 @@ import static com.google.common.base.Strings.isNullOrEmpty; +import com.google.adk.apps.ResumabilityConfig; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.memory.BaseMemoryService; import com.google.adk.models.LlmCallsLimitExceededException; @@ -36,6 +37,7 @@ import org.jspecify.annotations.Nullable; /** The context for an agent invocation. */ +@SuppressWarnings("deprecation") // Plumbs the deprecated ResumabilityConfig. public class InvocationContext { private final BaseSessionService sessionService; @@ -50,6 +52,7 @@ public class InvocationContext { private final RunConfig runConfig; @Nullable private final EventsCompactionConfig eventsCompactionConfig; @Nullable private final ContextCacheConfig contextCacheConfig; + private final @Nullable ResumabilityConfig resumabilityConfig; private final InvocationCostManager invocationCostManager; private final Map callbackContextData; @@ -73,6 +76,7 @@ protected InvocationContext(Builder builder) { this.endInvocation = builder.endInvocation; this.eventsCompactionConfig = builder.eventsCompactionConfig; this.contextCacheConfig = builder.contextCacheConfig; + this.resumabilityConfig = builder.resumabilityConfig; this.invocationCostManager = builder.invocationCostManager; // Don't copy the callback context data. This should be the same instance for the full // invocation invocation so that Plugins can access the same data it during the invocation @@ -217,6 +221,14 @@ public Optional contextCacheConfig() { return Optional.ofNullable(contextCacheConfig); } + /** + * Returns whether the current invocation is resumable. Mirrors Python ADK v1's {@code + * InvocationContext.is_resumable}. + */ + public boolean isResumable() { + return resumabilityConfig != null && resumabilityConfig.isResumable(); + } + private static class InvocationCostManager { private int numberOfLlmCalls = 0; @@ -270,6 +282,7 @@ private Builder(InvocationContext context) { this.endInvocation = context.endInvocation; this.eventsCompactionConfig = context.eventsCompactionConfig; this.contextCacheConfig = context.contextCacheConfig; + this.resumabilityConfig = context.resumabilityConfig; this.invocationCostManager = context.invocationCostManager; // Don't copy the callback context data. This should be the same instance for the full // invocation invocation so that Plugins can access the same data it during the invocation @@ -292,6 +305,7 @@ private Builder(InvocationContext context) { private boolean endInvocation = false; @Nullable private EventsCompactionConfig eventsCompactionConfig; @Nullable private ContextCacheConfig contextCacheConfig; + private @Nullable ResumabilityConfig resumabilityConfig; private InvocationCostManager invocationCostManager = new InvocationCostManager(); private Map callbackContextData = new ConcurrentHashMap<>(); @@ -463,6 +477,18 @@ public Builder contextCacheConfig(@Nullable ContextCacheConfig contextCacheConfi return this; } + /** + * Sets the resumability configuration for the invocation. + * + * @param resumabilityConfig the resumability configuration. + * @return this builder instance for chaining. + */ + @CanIgnoreReturnValue + public Builder resumabilityConfig(@Nullable ResumabilityConfig resumabilityConfig) { + this.resumabilityConfig = resumabilityConfig; + return this; + } + /** * Sets the callback context data for the invocation. * @@ -530,6 +556,7 @@ public boolean equals(Object o) { && Objects.equals(runConfig, that.runConfig) && Objects.equals(eventsCompactionConfig, that.eventsCompactionConfig) && Objects.equals(contextCacheConfig, that.contextCacheConfig) + && Objects.equals(resumabilityConfig, that.resumabilityConfig) && Objects.equals(invocationCostManager, that.invocationCostManager) && Objects.equals(callbackContextData, that.callbackContextData); } @@ -552,6 +579,7 @@ public int hashCode() { endInvocation, eventsCompactionConfig, contextCacheConfig, + resumabilityConfig, invocationCostManager, callbackContextData); } diff --git a/core/src/main/java/com/google/adk/agents/LoopAgent.java b/core/src/main/java/com/google/adk/agents/LoopAgent.java index c12387231..19fd4c497 100644 --- a/core/src/main/java/com/google/adk/agents/LoopAgent.java +++ b/core/src/main/java/com/google/adk/agents/LoopAgent.java @@ -21,6 +21,8 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.reactivex.rxjava3.core.Flowable; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -140,9 +142,35 @@ protected Flowable runAsyncImpl(InvocationContext invocationContext) { return Flowable.empty(); } + if (!invocationContext.isResumable()) { + return Flowable.fromIterable(subAgents) + .concatMap(subAgent -> subAgent.runAsync(invocationContext)) + .repeat(maxIterations != null ? maxIterations : Integer.MAX_VALUE) + .takeUntil(LoopAgent::hasEscalateAction); + } + + // Resumable: stop looping once a sub-agent emits a pending long-running call (e.g. HITL), + // matching Python ADK v1 and avoiding a runaway loop. The current sub-agent still finishes; + // resuming into the paused iteration needs persisted state (future work). + AtomicBoolean paused = new AtomicBoolean(false); + AtomicInteger timesLooped = new AtomicInteger(0); return Flowable.fromIterable(subAgents) - .concatMap(subAgent -> subAgent.runAsync(invocationContext)) - .repeat(maxIterations != null ? maxIterations : Integer.MAX_VALUE) + .concatMap( + subAgent -> + paused.get() + ? Flowable.empty() + : subAgent + .runAsync(invocationContext) + .doOnNext( + event -> { + if (WorkflowAgentResumption.hasPendingLongRunningCall(event)) { + paused.set(true); + } + })) + .repeatUntil( + () -> + paused.get() + || (maxIterations != null && timesLooped.incrementAndGet() >= maxIterations)) .takeUntil(LoopAgent::hasEscalateAction); } diff --git a/core/src/main/java/com/google/adk/agents/SequentialAgent.java b/core/src/main/java/com/google/adk/agents/SequentialAgent.java index 95ca50d16..963c3d109 100644 --- a/core/src/main/java/com/google/adk/agents/SequentialAgent.java +++ b/core/src/main/java/com/google/adk/agents/SequentialAgent.java @@ -19,6 +19,7 @@ import com.google.adk.events.Event; import io.reactivex.rxjava3.core.Flowable; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,13 +90,40 @@ public static Builder builder() { /** * Runs sub-agents sequentially. * + *

When resumability is enabled, on resume execution fast-forwards to the sub-agent being + * resumed (completed ones are not re-run) and pauses on a pending long-running call; when + * disabled, sub-agents simply run in order (matches Python ADK v1 with resumability off). + * Temporary, event-based. + * * @param invocationContext Invocation context. * @return Flowable emitting events from sub-agents. */ @Override protected Flowable runAsyncImpl(InvocationContext invocationContext) { - return Flowable.fromIterable(subAgents()) - .concatMap(subAgent -> subAgent.runAsync(invocationContext)); + List subAgents = subAgents(); + if (subAgents.isEmpty()) { + return Flowable.empty(); + } + if (!invocationContext.isResumable()) { + return Flowable.fromIterable(subAgents) + .concatMap(subAgent -> subAgent.runAsync(invocationContext)); + } + int startIndex = + WorkflowAgentResumption.resumeSubAgentIndex(invocationContext, subAgents).orElse(0); + AtomicBoolean paused = new AtomicBoolean(false); + return Flowable.fromIterable(subAgents.subList(startIndex, subAgents.size())) + .concatMap( + subAgent -> + paused.get() + ? Flowable.empty() + : subAgent + .runAsync(invocationContext) + .doOnNext( + event -> { + if (WorkflowAgentResumption.hasPendingLongRunningCall(event)) { + paused.set(true); + } + })); } /** diff --git a/core/src/main/java/com/google/adk/agents/WorkflowAgentResumption.java b/core/src/main/java/com/google/adk/agents/WorkflowAgentResumption.java new file mode 100644 index 000000000..2bff47803 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/WorkflowAgentResumption.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 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 com.google.adk.events.Event; +import com.google.adk.flows.llmflows.Functions; +import java.util.List; +import java.util.Optional; + +/** + * Helpers for resuming workflow agents from session events. Temporary until session resumption + * (persisted agent state) is available. + */ +final class WorkflowAgentResumption { + + /** + * Index of the direct sub-agent whose subtree authored the call the latest event resumes, or + * empty when not resuming into this workflow. + */ + static Optional resumeSubAgentIndex( + InvocationContext invocationContext, List subAgents) { + Optional author = + Functions.findMatchingFunctionCallEvent(invocationContext.session().events()) + .map(Event::author); + if (author.isEmpty()) { + return Optional.empty(); + } + for (int i = 0; i < subAgents.size(); i++) { + // findAgent matches the sub-agent itself or a descendant. + if (subAgents.get(i).findAgent(author.get()).isPresent()) { + return Optional.of(i); + } + } + return Optional.empty(); + } + + /** + * Whether the event emits a long-running call still awaiting a response (e.g. a HITL request). + */ + static boolean hasPendingLongRunningCall(Event event) { + return Functions.hasPendingLongRunningCall(event); + } + + private WorkflowAgentResumption() {} +} diff --git a/core/src/main/java/com/google/adk/apps/App.java b/core/src/main/java/com/google/adk/apps/App.java index 500c29253..3133357bd 100644 --- a/core/src/main/java/com/google/adk/apps/App.java +++ b/core/src/main/java/com/google/adk/apps/App.java @@ -34,6 +34,7 @@ * and communication across all agents in the hierarchy. The {@code plugins} are application-wide * components that provide shared capabilities and services to the entire system. */ +@SuppressWarnings("deprecation") // Plumbs the deprecated ResumabilityConfig. public class App { private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]*"); @@ -42,18 +43,21 @@ public class App { private final ImmutableList plugins; private final @Nullable EventsCompactionConfig eventsCompactionConfig; private final @Nullable ContextCacheConfig contextCacheConfig; + private final @Nullable ResumabilityConfig resumabilityConfig; private App( String name, BaseAgent rootAgent, List plugins, @Nullable EventsCompactionConfig eventsCompactionConfig, - @Nullable ContextCacheConfig contextCacheConfig) { + @Nullable ContextCacheConfig contextCacheConfig, + @Nullable ResumabilityConfig resumabilityConfig) { this.name = name; this.rootAgent = rootAgent; this.plugins = ImmutableList.copyOf(plugins); this.eventsCompactionConfig = eventsCompactionConfig; this.contextCacheConfig = contextCacheConfig; + this.resumabilityConfig = resumabilityConfig; } public String name() { @@ -78,6 +82,10 @@ public ContextCacheConfig contextCacheConfig() { return contextCacheConfig; } + public @Nullable ResumabilityConfig resumabilityConfig() { + return resumabilityConfig; + } + /** Builder for {@link App}. */ public static class Builder { private String name; @@ -85,6 +93,7 @@ public static class Builder { private List plugins = ImmutableList.of(); @Nullable private EventsCompactionConfig eventsCompactionConfig; @Nullable private ContextCacheConfig contextCacheConfig; + private @Nullable ResumabilityConfig resumabilityConfig; @CanIgnoreReturnValue public Builder name(String name) { @@ -122,6 +131,19 @@ public Builder contextCacheConfig(ContextCacheConfig contextCacheConfig) { return this; } + /** + * Sets the app resumability config. + * + * @deprecated See {@link ResumabilityConfig}: partial feature, full resumability not yet + * available. + */ + @CanIgnoreReturnValue + @Deprecated + public Builder resumabilityConfig(ResumabilityConfig resumabilityConfig) { + this.resumabilityConfig = resumabilityConfig; + return this; + } + public App build() { if (name == null) { throw new IllegalStateException("App name must be provided."); @@ -130,7 +152,8 @@ public App build() { throw new IllegalStateException("Root agent must be provided."); } validateAppName(name); - return new App(name, rootAgent, plugins, eventsCompactionConfig, contextCacheConfig); + return new App( + name, rootAgent, plugins, eventsCompactionConfig, contextCacheConfig, resumabilityConfig); } } diff --git a/core/src/main/java/com/google/adk/apps/ResumabilityConfig.java b/core/src/main/java/com/google/adk/apps/ResumabilityConfig.java new file mode 100644 index 000000000..bb1c87f44 --- /dev/null +++ b/core/src/main/java/com/google/adk/apps/ResumabilityConfig.java @@ -0,0 +1,50 @@ +/* + * 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.apps; + +import com.google.auto.value.AutoValue; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** + * App resumability config, mirroring Python ADK v1's {@code ResumabilityConfig}: pause on a + * long-running call and resume from the last event. Applies to all agents in the app. + * + * @deprecated Partial feature: only event-reconstruction-based pause/resume for {@code + * SequentialAgent} is implemented. Full session resumability (persisted agent state, durable + * resume, other workflow agents) is not yet available. Forward-compatible: the same config will + * drive full resumability once it lands. + */ +@Deprecated +@AutoValue +public abstract class ResumabilityConfig { + + /** Whether the app supports agent resumption. */ + public abstract boolean isResumable(); + + public static Builder builder() { + return new AutoValue_ResumabilityConfig.Builder().resumable(false); + } + + /** Builder for {@link ResumabilityConfig}. */ + @AutoValue.Builder + public abstract static class Builder { + @CanIgnoreReturnValue + public abstract Builder resumable(boolean isResumable); + + public abstract ResumabilityConfig build(); + } +} diff --git a/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java b/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java index 3b28761a1..8f30f5946 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java @@ -521,6 +521,13 @@ private Flowable run( "Ending flow execution based on final response, endInvocation action or" + " empty event list."); return Flowable.empty(); + } else if (invocationContext.isResumable() + && Functions.hasPendingLongRunningCall(eventList)) { + // When resumable, a pending long-running call (e.g. HITL) pauses the flow + // instead of calling the model again, matching Python ADK v1 and avoiding a + // runaway re-issue loop. The disabled path is unchanged. + logger.debug("Pausing flow execution on a pending long-running call."); + return Flowable.empty(); } else { logger.debug("Continuing to next step of the flow."); // Wait until the Runner has persisted this step's events so the next step's diff --git a/core/src/main/java/com/google/adk/flows/llmflows/Functions.java b/core/src/main/java/com/google/adk/flows/llmflows/Functions.java index 8c60ebf76..f841da065 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/Functions.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/Functions.java @@ -37,6 +37,8 @@ import com.google.adk.tools.ToolContext; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; import com.google.genai.types.Content; import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionResponse; @@ -424,6 +426,64 @@ public static Set getLongRunningFunctionCalls( return longRunningFunctionCalls; } + /** + * Returns the most recent function-call event whose call id matches a function response in the + * last event, or empty. Mirrors Python ADK's {@code find_matching_function_call}. + */ + public static Optional findMatchingFunctionCallEvent(List events) { + if (events.isEmpty()) { + return Optional.empty(); + } + Set responseIds = new HashSet<>(); + for (FunctionResponse functionResponse : Iterables.getLast(events).functionResponses()) { + functionResponse.id().ifPresent(responseIds::add); + } + if (responseIds.isEmpty()) { + return Optional.empty(); + } + for (int i = events.size() - 2; i >= 0; i--) { + Event event = events.get(i); + for (FunctionCall functionCall : event.functionCalls()) { + if (functionCall.id().isPresent() && responseIds.contains(functionCall.id().get())) { + return Optional.of(event); + } + } + } + return Optional.empty(); + } + + /** + * Returns whether the event emits a long-running function call still awaiting a response (e.g. a + * HITL request). Mirrors Python ADK v1's {@code should_pause_invocation}. + */ + public static boolean hasPendingLongRunningCall(Event event) { + Set longRunningToolIds = event.longRunningToolIds().orElse(ImmutableSet.of()); + if (longRunningToolIds.isEmpty()) { + return false; + } + for (FunctionCall functionCall : event.functionCalls()) { + if (functionCall.id().isPresent() && longRunningToolIds.contains(functionCall.id().get())) { + return true; + } + } + return false; + } + + /** + * Returns whether the last one or two events hold a pending long-running call, meaning a + * resumable flow should pause instead of calling the model again. Mirrors Python ADK v1's + * flow-level pause check on {@code events[-1]} and {@code events[-2]}. + */ + static boolean hasPendingLongRunningCall(List events) { + int from = Math.max(0, events.size() - 2); + for (int i = events.size() - 1; i >= from; i--) { + if (hasPendingLongRunningCall(events.get(i))) { + return true; + } + } + return false; + } + private static Maybe postProcessFunctionResult( Maybe> maybeFunctionResult, InvocationContext invocationContext, diff --git a/core/src/main/java/com/google/adk/runner/Runner.java b/core/src/main/java/com/google/adk/runner/Runner.java index 043f56fa3..eb5e4d1f2 100644 --- a/core/src/main/java/com/google/adk/runner/Runner.java +++ b/core/src/main/java/com/google/adk/runner/Runner.java @@ -16,8 +16,6 @@ package com.google.adk.runner; -import static com.google.common.collect.ImmutableSet.toImmutableSet; - import com.google.adk.agents.ActiveStreamingTool; import com.google.adk.agents.BaseAgent; import com.google.adk.agents.ContextCacheConfig; @@ -25,11 +23,14 @@ import com.google.adk.agents.LiveRequestQueue; import com.google.adk.agents.LlmAgent; import com.google.adk.agents.RunConfig; +import com.google.adk.agents.SequentialAgent; import com.google.adk.apps.App; +import com.google.adk.apps.ResumabilityConfig; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.artifacts.InMemoryArtifactService; import com.google.adk.events.Event; import com.google.adk.events.EventActions; +import com.google.adk.flows.llmflows.Functions; import com.google.adk.flows.llmflows.PersistBarrier; import com.google.adk.memory.BaseMemoryService; import com.google.adk.models.Model; @@ -48,8 +49,6 @@ import com.google.adk.utils.CollectionUtils; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Iterables; import com.google.common.collect.MapMaker; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.genai.types.AudioTranscriptionConfig; @@ -69,13 +68,13 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.jspecify.annotations.Nullable; /** The main class for the GenAI Agents runner. */ +@SuppressWarnings("deprecation") // Plumbs the deprecated ResumabilityConfig. public class Runner { private final BaseAgent agent; private final String appName; @@ -85,6 +84,7 @@ public class Runner { private final PluginManager pluginManager; @Nullable private final EventsCompactionConfig eventsCompactionConfig; @Nullable private final ContextCacheConfig contextCacheConfig; + private final @Nullable ResumabilityConfig resumabilityConfig; private final ConcurrentMap activeSessionCompletables = new MapMaker().weakValues().makeMap(); @@ -157,6 +157,7 @@ public Runner build() { List buildPlugins; EventsCompactionConfig buildEventsCompactionConfig; ContextCacheConfig buildContextCacheConfig; + ResumabilityConfig buildResumabilityConfig; if (this.app != null) { if (this.agent != null) { @@ -170,12 +171,14 @@ public Runner build() { buildAppName = this.appName == null ? this.app.name() : this.appName; buildEventsCompactionConfig = this.app.eventsCompactionConfig(); buildContextCacheConfig = this.app.contextCacheConfig(); + buildResumabilityConfig = this.app.resumabilityConfig(); } else { buildAgent = this.agent; buildAppName = this.appName; buildPlugins = this.plugins; buildEventsCompactionConfig = null; buildContextCacheConfig = null; + buildResumabilityConfig = null; } if (buildAgent == null) { @@ -198,7 +201,8 @@ public Runner build() { memoryService, buildPlugins, buildEventsCompactionConfig, - buildContextCacheConfig); + buildContextCacheConfig, + buildResumabilityConfig); } } @@ -252,6 +256,34 @@ protected Runner( List plugins, @Nullable EventsCompactionConfig eventsCompactionConfig, @Nullable ContextCacheConfig contextCacheConfig) { + this( + agent, + appName, + artifactService, + sessionService, + memoryService, + plugins, + eventsCompactionConfig, + contextCacheConfig, + /* resumabilityConfig= */ null); + } + + /** + * Creates a new {@code Runner} with a resumability config. + * + * @deprecated Use {@link Runner.Builder} instead. + */ + @Deprecated + protected Runner( + BaseAgent agent, + String appName, + BaseArtifactService artifactService, + BaseSessionService sessionService, + @Nullable BaseMemoryService memoryService, + List plugins, + @Nullable EventsCompactionConfig eventsCompactionConfig, + @Nullable ContextCacheConfig contextCacheConfig, + @Nullable ResumabilityConfig resumabilityConfig) { this.agent = agent; this.appName = appName; this.artifactService = artifactService; @@ -260,6 +292,7 @@ protected Runner( this.pluginManager = new PluginManager(plugins); this.eventsCompactionConfig = createEventsCompactionConfig(agent, eventsCompactionConfig); this.contextCacheConfig = contextCacheConfig; + this.resumabilityConfig = resumabilityConfig; } /** @@ -669,6 +702,7 @@ private InvocationContext.Builder newInvocationContextBuilder(Session session) { .session(session) .eventsCompactionConfig(this.eventsCompactionConfig) .contextCacheConfig(this.contextCacheConfig) + .resumabilityConfig(this.resumabilityConfig) .agent(this.findAgentToRun(session, rootAgent)); } @@ -782,13 +816,24 @@ private boolean isTransferableAcrossAgentTree(BaseAgent agentToRun) { return true; } + /** Returns whether resumability is enabled for this runner's app. */ + private boolean isResumable() { + return resumabilityConfig != null && resumabilityConfig.isResumable(); + } + /** Returns the agent that should handle the next request based on session history. */ private BaseAgent findAgentToRun(Session session, BaseAgent rootAgent) { - // Route function responses back to the originating function-call author so HITL tool - // confirmations resume the sub-agent even through non-LlmAgent ancestors. - Optional functionCallAuthor = findFunctionCallAuthor(session, rootAgent); + // Route a function response to its call's author; when resumable, re-enter via the author's + // top-most SequentialAgent ancestor so the sequence can advance past it (else route straight to + // it, matching Python ADK v1 with resumability off). Temporary, event-based. + Optional functionCallAuthor = + Functions.findMatchingFunctionCallEvent(session.events()) + .filter(event -> event.author() != null) + .flatMap(event -> rootAgent.findAgent(event.author())); if (functionCallAuthor.isPresent()) { - return functionCallAuthor.get(); + return isResumable() + ? topmostSequentialAncestor(functionCallAuthor.get()) + : functionCallAuthor.get(); } List events = new ArrayList<>(session.events()); @@ -822,36 +867,19 @@ private BaseAgent findAgentToRun(Session session, BaseAgent rootAgent) { } /** - * If the last event is a function response, returns the agent that emitted the matching function - * call (by id), or empty if no match is found in the agent tree. + * Returns the top-most ancestor reachable from {@code agent} through {@link SequentialAgent} + * parents, or {@code agent} itself otherwise. Only SequentialAgent is resume-aware; other + * workflow agents are left to resume their paused sub-agent directly (via the function-call + * author). */ - private static Optional findFunctionCallAuthor(Session session, BaseAgent rootAgent) { - List events = session.events(); - if (events.isEmpty()) { - return Optional.empty(); - } - ImmutableSet functionResponseIds = - Iterables.getLast(events).functionResponses().stream() - .map(fr -> fr.id().orElse(null)) - .filter(Objects::nonNull) - .collect(toImmutableSet()); - - // Iterate in reverse to prefer the most recent matching call, mirroring Python ADK's - // find_event_by_function_call_id. Function call IDs are unique in normal flows, so this - // is defense-in-depth and not covered by mutation testing. - List precedingEvents = new ArrayList<>(events.subList(0, events.size() - 1)); - Collections.reverse(precedingEvents); - for (Event event : precedingEvents) { - boolean matches = - event.functionCalls().stream() - .map(fc -> fc.id().orElse(null)) - .filter(Objects::nonNull) - .anyMatch(functionResponseIds::contains); - if (matches && event.author() != null) { - return rootAgent.findAgent(event.author()); - } + private static BaseAgent topmostSequentialAncestor(BaseAgent agent) { + BaseAgent result = agent; + BaseAgent parent = agent.parentAgent(); + while (parent instanceof SequentialAgent) { + result = parent; + parent = parent.parentAgent(); } - return Optional.empty(); + return result; } private void addActiveStreamingTools(InvocationContext invocationContext, List tools) { diff --git a/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java b/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java index 715af6b4e..6bbd9e55b 100644 --- a/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java +++ b/core/src/test/java/com/google/adk/agents/SequentialAgentTest.java @@ -26,10 +26,15 @@ import static com.google.common.truth.Truth.assertThat; import com.google.adk.events.Event; +import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.sessions.Session; import com.google.adk.testing.TestBaseAgent; import com.google.adk.testing.TestLlm; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.genai.types.Content; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionResponse; import com.google.genai.types.Part; import java.util.List; import org.junit.Test; @@ -204,4 +209,79 @@ public void runLive_propagatesInvocationContextToSubAgents() { assertThat(capturedContext.agent()).isEqualTo(subAgent); assertThat(subAgent.getInvocationCount()).isEqualTo(1); } + + // orElse(0) masks the exact index end to end, so assert the helper directly. + @Test + public void resumeSubAgentIndex_authorIsFirstSubAgent_returnsZero() { + TestBaseAgent first = createSubAgent("first_agent"); + TestBaseAgent second = createSubAgent("second_agent"); + SequentialAgent root = + SequentialAgent.builder().name("root").subAgents(ImmutableList.of(first, second)).build(); + + InvocationContext context = contextResumingCall(root, "first_agent"); + + assertThat(WorkflowAgentResumption.resumeSubAgentIndex(context, root.subAgents())).hasValue(0); + } + + @Test + public void resumeSubAgentIndex_authorNestedInLaterSubAgent_returnsThatSubAgentIndex() { + TestBaseAgent first = createSubAgent("first_agent"); + TestBaseAgent nested = createSubAgent("nested_agent"); + SequentialAgent branch = + SequentialAgent.builder().name("branch_agent").subAgents(ImmutableList.of(nested)).build(); + SequentialAgent root = + SequentialAgent.builder().name("root").subAgents(ImmutableList.of(first, branch)).build(); + + InvocationContext context = contextResumingCall(root, "nested_agent"); + + assertThat(WorkflowAgentResumption.resumeSubAgentIndex(context, root.subAgents())).hasValue(1); + } + + @Test + public void resumeSubAgentIndex_noMatchingAuthor_returnsEmpty() { + TestBaseAgent first = createSubAgent("first_agent"); + TestBaseAgent second = createSubAgent("second_agent"); + SequentialAgent root = + SequentialAgent.builder().name("root").subAgents(ImmutableList.of(first, second)).build(); + + InvocationContext context = contextResumingCall(root, "unknown_agent"); + + assertThat(WorkflowAgentResumption.resumeSubAgentIndex(context, root.subAgents())).isEmpty(); + } + + // Session ending with a function response that resumes a call authored by callAuthor. + private static InvocationContext contextResumingCall(BaseAgent rootAgent, String callAuthor) { + InMemorySessionService sessionService = new InMemorySessionService(); + Session session = sessionService.createSession("test_app", "test-user").blockingGet(); + Event callEvent = + Event.builder() + .id("call_event") + .invocationId("invocationId") + .author(callAuthor) + .content( + Content.fromParts( + Part.builder() + .functionCall(FunctionCall.builder().id("call_id").name("tool").build()) + .build())) + .build(); + Event responseEvent = + Event.builder() + .id("response_event") + .invocationId("invocationId") + .author("user") + .content( + Content.fromParts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("call_id") + .name("tool") + .response(ImmutableMap.of()) + .build()) + .build())) + .build(); + var unusedCall = sessionService.appendEvent(session, callEvent).blockingGet(); + var unusedResponse = sessionService.appendEvent(session, responseEvent).blockingGet(); + return createInvocationContext(rootAgent, sessionService, session); + } } diff --git a/core/src/test/java/com/google/adk/flows/llmflows/FunctionsTest.java b/core/src/test/java/com/google/adk/flows/llmflows/FunctionsTest.java index b2ffc4443..8e8555114 100644 --- a/core/src/test/java/com/google/adk/flows/llmflows/FunctionsTest.java +++ b/core/src/test/java/com/google/adk/flows/llmflows/FunctionsTest.java @@ -30,6 +30,7 @@ import com.google.adk.tools.ToolContext; 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.FunctionCall; import com.google.genai.types.FunctionDeclaration; @@ -532,6 +533,63 @@ public void handleFunctionCalls_parallelSubscribe_blockingTool_singleTool() { .build()); } + @Test + public void hasPendingLongRunningCall_eventWithLongRunningCall_returnsTrue() { + assertThat(Functions.hasPendingLongRunningCall(longRunningCallEvent("call1"))).isTrue(); + } + + @Test + public void hasPendingLongRunningCall_eventWithoutLongRunningIds_returnsFalse() { + assertThat(Functions.hasPendingLongRunningCall(functionCallEvent("call1", null))).isFalse(); + } + + @Test + public void hasPendingLongRunningCall_callIdNotMarkedLongRunning_returnsFalse() { + assertThat(Functions.hasPendingLongRunningCall(functionCallEvent("call1", "other_id"))) + .isFalse(); + } + + @Test + public void hasPendingLongRunningCall_list_callInSecondToLastEvent_returnsTrue() { + ImmutableList events = + ImmutableList.of(createEvent("first"), longRunningCallEvent("call1"), createEvent("last")); + assertThat(Functions.hasPendingLongRunningCall(events)).isTrue(); + } + + @Test + public void hasPendingLongRunningCall_list_callOlderThanLastTwoEvents_returnsFalse() { + ImmutableList events = + ImmutableList.of(longRunningCallEvent("call1"), createEvent("middle"), createEvent("last")); + assertThat(Functions.hasPendingLongRunningCall(events)).isFalse(); + } + + @Test + public void hasPendingLongRunningCall_emptyList_returnsFalse() { + assertThat(Functions.hasPendingLongRunningCall(ImmutableList.of())).isFalse(); + } + + private static Event longRunningCallEvent(String callId) { + return functionCallEvent(callId, callId); + } + + // Event with a function call; longRunningId, when non-null, is marked long-running. + private static Event functionCallEvent(String callId, String longRunningId) { + Event.Builder builder = + Event.builder() + .id("event_" + callId) + .invocationId("invocation1") + .author("agent") + .content( + Content.fromParts( + Part.builder() + .functionCall(FunctionCall.builder().id(callId).name("tool").build()) + .build())); + if (longRunningId != null) { + builder.longRunningToolIds(ImmutableSet.of(longRunningId)); + } + return builder.build(); + } + /** * Asserts that {@code toolCount} blocking tools in PARALLEL_SUBSCRIBE mode run faster than * sequential, since each tool is subscribed on a worker thread. diff --git a/core/src/test/java/com/google/adk/runner/RunnerTest.java b/core/src/test/java/com/google/adk/runner/RunnerTest.java index ec89e4e4d..5b1e7b7f0 100644 --- a/core/src/test/java/com/google/adk/runner/RunnerTest.java +++ b/core/src/test/java/com/google/adk/runner/RunnerTest.java @@ -43,9 +43,12 @@ import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LiveRequestQueue; import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.LoopAgent; +import com.google.adk.agents.ParallelAgent; import com.google.adk.agents.RunConfig; import com.google.adk.agents.SequentialAgent; import com.google.adk.apps.App; +import com.google.adk.apps.ResumabilityConfig; import com.google.adk.artifacts.BaseArtifactService; import com.google.adk.events.Event; import com.google.adk.flows.llmflows.Functions; @@ -2048,6 +2051,407 @@ public void runAsync_withToolConfirmation_inSequentialAgentSubAgent_resumesSubAg .inOrder(); } + // OSS HITL: after an adk_request_confirmation resumes sub-agent B in a SequentialAgent(A, B, C), + // the workflow must advance to C without re-running the already completed A. + @Test + @SuppressWarnings("deprecation") // Resumability flag is intentionally deprecated (partial). + public void runAsync_withToolConfirmation_inSequentialAgent_runsLaterSubAgentsAfterResume() { + LlmAgent agentA = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent A done"))) + .name("a_agent") + .build(); + // With resumability on, B pauses right after requesting confirmation (no extra model call), so + // a + // single follow-up response covers the resume. + TestLlm bTestLlm = + createTestLlm( + createFunctionCallLlmResponse( + "tool_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("Response after user confirmed.")); + LlmAgent agentB = + createTestAgentBuilder(bTestLlm) + .name("b_agent") + .tools(FunctionTool.create(Tools.class, "echoTool", /* requireConfirmation= */ true)) + .build(); + LlmAgent agentC = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent C done"))) + .name("c_agent") + .build(); + SequentialAgent workflowAgent = + SequentialAgent.builder() + .name("workflow_agent") + .subAgents(ImmutableList.of(agentA, agentB, agentC)) + .build(); + Runner runner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent(workflowAgent) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build()) + .build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List eventsBeforeConfirmation = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // Turn 1: A runs, B pauses for confirmation, and C must not run yet. + assertThat(simplifyEvents(eventsBeforeConfirmation)).contains("a_agent: agent A done"); + assertThat(simplifyEvents(eventsBeforeConfirmation)).doesNotContain("c_agent: agent C done"); + + FunctionCall askUserConfirmationFunctionCall = + Iterables.getOnlyElement( + eventsBeforeConfirmation.stream() + .map(Functions::getAskUserConfirmationFunctionCalls) + .filter(functionCalls -> !functionCalls.isEmpty()) + .findFirst() + .get()); + List eventsAfterConfirmation = + runner + .runAsync( + "user", + session.id(), + Content.fromParts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id(askUserConfirmationFunctionCall.id().get()) + .name(askUserConfirmationFunctionCall.name().get()) + .response(ImmutableMap.of("confirmed", true))) + .build())) + .toList() + .blockingGet(); + + // Turn 2: B resumes and executes the tool, then C runs. A is not re-run. + assertThat(simplifyEvents(eventsAfterConfirmation)) + .containsExactly( + "b_agent: FunctionResponse(name=echoTool, response={message=hello})", + "b_agent: Response after user confirmed.", + "c_agent: agent C done") + .inOrder(); + } + + // Long-running-call HITL: a pending long-running function call (not the confirmation flow) pauses + // SequentialAgent(A, B, C) after B; on resume B continues and C runs, without re-running A. + @Test + @SuppressWarnings("deprecation") // Resumability flag is intentionally deprecated (partial). + public void runAsync_withLongRunningCall_inSequentialAgent_runsLaterSubAgentsAfterResume() { + LlmAgent agentA = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent A done"))) + .name("a_agent") + .build(); + // With resumability on, B pauses right after the long-running call (no extra model call), so a + // single follow-up response covers the resume. + TestLlm bTestLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("agent B resumed")); + LlmAgent agentB = + createTestAgentBuilder(bTestLlm) + .name("b_agent") + .tools( + FunctionTool.create( + Tools.class, + "echoTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + LlmAgent agentC = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent C done"))) + .name("c_agent") + .build(); + SequentialAgent workflowAgent = + SequentialAgent.builder() + .name("workflow_agent") + .subAgents(ImmutableList.of(agentA, agentB, agentC)) + .build(); + Runner runner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent(workflowAgent) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build()) + .build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List eventsBeforeResume = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // Turn 1: A runs, B issues the long-running call and pauses; C must not run yet. B must not + // make + // a further model call after the pending call. + assertThat(simplifyEvents(eventsBeforeResume)).contains("a_agent: agent A done"); + assertThat(simplifyEvents(eventsBeforeResume)).doesNotContain("b_agent: agent B resumed"); + assertThat(simplifyEvents(eventsBeforeResume)).doesNotContain("c_agent: agent C done"); + + List eventsAfterResume = + runner + .runAsync( + "user", + session.id(), + Content.fromParts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("lro_call_id") + .name("echoTool") + .response(ImmutableMap.of("message", "hello"))) + .build())) + .toList() + .blockingGet(); + + // Turn 2: B resumes from the long-running response, then C runs. A is not re-run. + assertThat(simplifyEvents(eventsAfterResume)) + .containsExactly("b_agent: agent B resumed", "c_agent: agent C done") + .inOrder(); + } + + // Regression: a pending long-running call must pause the LLM flow after a single model call when + // resumability is on. Before the flow-level pause, the flow kept re-calling the model (re-issuing + // the call), burning tokens. The scripted model would re-issue the call if the flow did not + // pause; + // we assert exactly one model call was made and the later responses were never consumed. + @Test + @SuppressWarnings("deprecation") // Resumability flag is intentionally deprecated (partial). + public void runAsync_withLongRunningCall_resumable_pausesAfterSingleModelCall() { + TestLlm testLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello")), + // Extra responses the flow must NOT consume; reaching them means it looped. + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("should not be reached")); + LlmAgent agent = + createTestAgentBuilder(testLlm) + .name("agent") + .tools( + FunctionTool.create( + Tools.class, + "echoTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + Runner runner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent(agent) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build()) + .build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // The flow paused after the single long-running call instead of re-calling the model. + assertThat(testLlm.getRequests()).hasSize(1); + assertThat(simplifyEvents(events)).doesNotContain("agent: should not be reached"); + } + + // Gating: with resumability OFF (default) the flow does NOT pause on a long-running call; it + // keeps + // calling the model as before. Pairs with the resumable test above. + @Test + public void runAsync_withLongRunningCall_resumabilityDisabled_doesNotPause() { + TestLlm testLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("after pending call")); + LlmAgent agent = + createTestAgentBuilder(testLlm) + .name("agent") + .tools( + FunctionTool.create( + Tools.class, + "echoTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + Runner runner = + Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // No pause: the flow made a second model call and surfaced its response. + assertThat(testLlm.getRequests()).hasSize(2); + assertThat(simplifyEvents(events)).contains("agent: after pending call"); + } + + // A pending long-running call must stop a resumable LoopAgent after the current iteration rather + // than looping again (re-calling the model every iteration), matching Python ADK v1. + @Test + @SuppressWarnings("deprecation") // Resumability flag is intentionally deprecated (partial). + public void runAsync_loopAgentWithLongRunningSubAgent_resumable_stopsAfterFirstIteration() { + AtomicInteger calls = new AtomicInteger(); + TestLlm loopLlm = + createTestLlm( + () -> + calls.incrementAndGet() <= 5 + ? Flowable.just( + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello"))) + : Flowable.just(createTextLlmResponse("stop"))); + LlmAgent inner = + createTestAgentBuilder(loopLlm) + .name("inner") + .tools( + FunctionTool.create( + Tools.class, + "echoTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + LoopAgent loop = + LoopAgent.builder() + .name("loop") + .subAgents(ImmutableList.of(inner)) + .maxIterations(3) + .build(); + Runner runner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent(loop) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build()) + .build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List unused = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // Paused after the first iteration: one model call, not maxIterations. + assertThat(loopLlm.getRequests()).hasSize(1); + } + + // In a resumable ParallelAgent, a pending long-running call pauses only its own branch (via the + // flow); other branches still complete. ParallelAgent needs no special handling, matching Python + // ADK v1 (cancelling siblings would diverge). + @Test + @SuppressWarnings("deprecation") // Resumability flag is intentionally deprecated (partial). + public void runAsync_parallelAgentWithLongRunningBranch_resumable_otherBranchCompletes() { + TestLlm longRunningLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("unexpected")); + LlmAgent longRunningBranch = + createTestAgentBuilder(longRunningLlm) + .name("long_running_branch") + .tools( + FunctionTool.create( + Tools.class, + "echoTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + LlmAgent plainBranch = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("plain branch done"))) + .name("plain_branch") + .build(); + ParallelAgent parallel = + ParallelAgent.builder() + .name("parallel") + .subAgents(ImmutableList.of(longRunningBranch, plainBranch)) + .build(); + Runner runner = + Runner.builder() + .app( + App.builder() + .name("test") + .rootAgent(parallel) + .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build()) + .build()) + .build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // The long-running branch paused after one model call; the other branch still completed. + assertThat(longRunningLlm.getRequests()).hasSize(1); + assertThat(simplifyEvents(events)).contains("plain_branch: plain branch done"); + } + + // Resumability disabled (default): a SequentialAgent(A, B, C) does not pause on B's HITL call, so + // all sub-agents run in the same turn — matching Python ADK v1 with resumability disabled. + @Test + public void + runAsync_withToolConfirmation_inSequentialAgent_resumabilityDisabled_runsAllSubAgents() { + LlmAgent agentA = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent A done"))) + .name("a_agent") + .build(); + TestLlm bTestLlm = + createTestLlm( + createFunctionCallLlmResponse( + "tool_call_id", "echoTool", ImmutableMap.of("message", "hello")), + createTextLlmResponse("Response after observing tool needs confirmation.")); + LlmAgent agentB = + createTestAgentBuilder(bTestLlm) + .name("b_agent") + .tools(FunctionTool.create(Tools.class, "echoTool", /* requireConfirmation= */ true)) + .build(); + LlmAgent agentC = + createTestAgentBuilder(createTestLlm(createTextLlmResponse("agent C done"))) + .name("c_agent") + .build(); + SequentialAgent workflowAgent = + SequentialAgent.builder() + .name("workflow_agent") + .subAgents(ImmutableList.of(agentA, agentB, agentC)) + .build(); + Runner runner = + Runner.builder().app(App.builder().name("test").rootAgent(workflowAgent).build()).build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + assertThat(simplifyEvents(events)).contains("a_agent: agent A done"); + assertThat(simplifyEvents(events)).contains("c_agent: agent C done"); + } + + // ResumabilityConfig is off by default and reflects the configured value. + @Test + @SuppressWarnings("deprecation") // ResumabilityConfig is intentionally deprecated (partial). + public void resumabilityConfig_defaultsToNotResumable() { + assertThat(ResumabilityConfig.builder().build().isResumable()).isFalse(); + assertThat(ResumabilityConfig.builder().resumable(true).build().isResumable()).isTrue(); + } + // Orphan function responses (id not matching any prior call) should fall back to the root agent. @Test public void runAsync_withFunctionResponseNotMatchingAnyCall_fallsBackToRootAgent() {