Skip to content

Commit b7e43a5

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: share one run per Context.runNode run ID
PiperOrigin-RevId: 1004727068
1 parent b0bdd98 commit b7e43a5

12 files changed

Lines changed: 2366 additions & 119 deletions

File tree

‎core/src/main/java/com/google/adk/agents/Context.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import io.reactivex.rxjava3.core.Maybe;
3636
import io.reactivex.rxjava3.core.Single;
3737
import java.util.List;
38+
import java.util.Map;
3839
import java.util.Optional;
3940
import org.jspecify.annotations.Nullable;
4041

@@ -321,6 +322,18 @@ public final void routes(List<? extends Route> routes) {
321322
requireNodeState().selectRoutes(routes);
322323
}
323324

325+
/**
326+
* Returns the user's answers to the long-running calls that this workflow node or its descendants
327+
* made in an earlier turn, keyed by interrupt ID. A node that reruns on resume reads these
328+
* answers. A null answer has a null value in the map.
329+
*
330+
* @throws IllegalStateException if this is not the context of a workflow node.
331+
*/
332+
@Experimental
333+
public final Map<String, @Nullable Object> resumeInputs() {
334+
return requireNodeState().resumeInputs();
335+
}
336+
324337
/**
325338
* Runs {@code node} as a child of this workflow node without an input, as {@link #runNode(Node,
326339
* Object, RunNodeConfig)} does with the default {@link RunNodeConfig}.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import com.google.adk.agents.Callbacks.OnToolErrorCallbackBase;
4444
import com.google.adk.agents.Callbacks.OnToolErrorCallbackSync;
4545
import com.google.adk.agents.ConfigAgentUtils.ConfigurationException;
46+
import com.google.adk.annotations.Experimental;
4647
import com.google.adk.codeexecutors.BaseCodeExecutor;
4748
import com.google.adk.events.Event;
4849
import com.google.adk.flows.llmflows.AutoFlow;
@@ -1082,6 +1083,16 @@ public Optional<Schema> outputSchema() {
10821083
return outputSchema;
10831084
}
10841085

1086+
/**
1087+
* Returns {@code true}, so that a workflow resuming this agent as a node runs it again and its
1088+
* model sees the answers to its long-running calls.
1089+
*/
1090+
@Experimental
1091+
@Override
1092+
public boolean rerunOnResume() {
1093+
return true;
1094+
}
1095+
10851096
public Optional<Executor> executor() {
10861097
return executor;
10871098
}

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

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,6 @@ public class Runner {
111111
private final UuidProvider uuidProvider;
112112
private final ConcurrentMap<String, Completable> activeSessionCompletables =
113113
new MapMaker().weakValues().makeMap();
114-
private static final String NODE_ROOT_RESUME_UNSUPPORTED =
115-
"Resuming an invocation of a workflow node root is not supported.";
116114

117115
/** Builder for {@link Runner}. */
118116
public static class Builder {
@@ -668,7 +666,8 @@ public Flowable<Event> runAsync(String userId, String sessionId, Content newMess
668666
* Runs the agent, resuming an existing invocation instead of starting a new one. The invocation
669667
* is resolved from {@code invocationId}, or from a function response carried by {@code
670668
* newMessage}. Agent checkpoints are rehydrated from history and an invocation whose active agent
671-
* already finished resolves to a no-op.
669+
* already finished resolves to a no-op, while a workflow node root rebuilds its progress from the
670+
* invocation's events even when the app is not resumable.
672671
*
673672
* @param userId the user id of the session.
674673
* @param sessionId the session id.
@@ -679,7 +678,7 @@ public Flowable<Event> runAsync(String userId, String sessionId, Content newMess
679678
* @param stateDelta optional state updates to merge into the session for this run.
680679
* @return the events generated while resuming, or an empty stream when there is nothing to
681680
* resume.
682-
* @throws IllegalStateException if the app is not resumable.
681+
* @throws IllegalStateException if the app is not resumable and its root is an agent.
683682
* @throws IllegalArgumentException if the invocation cannot be resolved.
684683
*/
685684
@Experimental
@@ -691,7 +690,7 @@ public Flowable<Event> runAsync(
691690
RunConfig runConfig,
692691
@Nullable Map<String, Object> stateDelta) {
693692
checkState(
694-
isResumable(),
693+
isResumable() || node != null,
695694
"Resuming an invocation requires an App configured with a resumable ResumabilityConfig.");
696695
Flowable<Event> result =
697696
Flowable.defer(
@@ -782,26 +781,25 @@ protected Flowable<Event> runAsyncImpl(
782781
Preconditions.checkNotNull(session, "session cannot be null");
783782
Preconditions.checkNotNull(newMessage, "newMessage cannot be null");
784783
Preconditions.checkNotNull(runConfig, "runConfig cannot be null");
785-
if (isResumable()) {
784+
// A node root rebuilds its progress from its invocation's events, so it resumes on any app.
785+
if (isResumable() || node != null) {
786786
return runResumableFromSession(
787787
session, /* providedInvocationId= */ null, newMessage, runConfig, stateDelta);
788788
}
789789
return runNewInvocation(session, newMessage, runConfig, stateDelta);
790790
}
791791

792-
/** Starts a brand-new invocation for {@code newMessage} (the default, non-resume flow). */
792+
/**
793+
* Starts an invocation for {@code newMessage}; a node root joins the one a plugin's answers
794+
* resolve to.
795+
*/
793796
private Flowable<Event> runNewInvocation(
794797
Session session,
795798
Content newMessage,
796799
RunConfig runConfig,
797800
@Nullable Map<String, Object> stateDelta) {
798801
return Flowable.defer(
799802
() -> {
800-
// Rejected before any callback or append; defer turns the throw into an error signal.
801-
if (node != null) {
802-
checkNoTextWithFunctionResponses(newMessage);
803-
checkNoAnswerToAnEarlierInvocation(session, newMessage);
804-
}
805803
Context capturedContext = Context.current();
806804
BaseAgent rootAgent = this.agent;
807805
String invocationId = InvocationContext.newInvocationContextId(this.uuidProvider);
@@ -825,18 +823,21 @@ private Flowable<Event> runNewInvocation(
825823
.onUserMessageCallback(initialContext, newMessage)
826824
.compose(Tracing.<Content>withContext(capturedContext))
827825
.defaultIfEmpty(newMessage)
828-
.flatMap(
829-
content ->
830-
createUserMessageEvent(
831-
session,
832-
content,
833-
initialContext,
834-
runConfig.saveInputBlobsAsArtifacts(),
835-
stateDelta))
836826
.flatMapPublisher(
837-
userEvent ->
838-
runAgentForUserEvent(initialContext, session, userEvent, rootAgent)
839-
.compose(Tracing.<Event>withContext(capturedContext)))
827+
content -> {
828+
InvocationContext context =
829+
resolveNodeRootContext(session, initialContext, content);
830+
return createUserMessageEvent(
831+
session,
832+
content,
833+
context,
834+
runConfig.saveInputBlobsAsArtifacts(),
835+
stateDelta)
836+
.flatMapPublisher(
837+
userEvent ->
838+
runAgentForUserEvent(context, session, userEvent, rootAgent)
839+
.compose(Tracing.<Event>withContext(capturedContext)));
840+
})
840841
.doOnError(throwable -> runOnRunError(initialContext, throwable));
841842
})
842843
.doOnError(
@@ -893,7 +894,11 @@ private Flowable<Event> runAgentWithUpdatedSession(
893894
initialContext.toBuilder()
894895
.session(updatedSession)
895896
.agent(agentToRun)
896-
.userContent(event.content().orElseGet(Content::fromParts))
897+
.userContent(
898+
userContentFor(
899+
updatedSession,
900+
initialContext.invocationId(),
901+
event.content().orElseGet(Content::fromParts)))
897902
.branch(routedAgentParentBranch(updatedSession, agentToRun, rootAgent))
898903
.build();
899904

@@ -1006,7 +1011,7 @@ private void runOnRunError(InvocationContext context, Throwable throwable) {
10061011
/**
10071012
* Resumes an existing invocation when one resolves from {@code providedInvocationId} or a
10081013
* function response in {@code rawMessage}; otherwise starts a new invocation. Requires
1009-
* resumability.
1014+
* resumability or a workflow node root.
10101015
*/
10111016
private Flowable<Event> runResumableFromSession(
10121017
Session session,
@@ -1043,11 +1048,6 @@ private Flowable<Event> runResumableFromSession(
10431048
return Flowable.<Event>error(
10441049
new IllegalArgumentException("Session has no events to resume."));
10451050
}
1046-
if (node != null) {
1047-
// Without replay, resuming would run the node again and repeat its side effects.
1048-
return Flowable.<Event>error(
1049-
new UnsupportedOperationException(NODE_ROOT_RESUME_UNSUPPORTED));
1050-
}
10511051
return resumeCore(session, resolvedInvocationId, newMessage, runConfig, stateDelta);
10521052
});
10531053
}
@@ -1067,50 +1067,52 @@ private Flowable<Event> resumeCore(
10671067
() -> {
10681068
Context capturedContext = Context.current();
10691069

1070-
// Not new input: Python drops it and its state delta rather than adding a second prompt.
1070+
// Not new input: Python drops newMessage, and outside the node path stateDelta too.
10711071
boolean supersededByOriginalMessage =
10721072
newMessage != null
10731073
&& !carriesFunctionResponse(newMessage)
10741074
&& findOriginalUserMessage(session, resolvedInvocationId).isPresent();
1075+
Content message = supersededByOriginalMessage ? null : newMessage;
1076+
Map<String, Object> delta =
1077+
supersededByOriginalMessage && node == null ? null : stateDelta;
10751078

10761079
// Merged eagerly so the on-user-message callback sees it.
1077-
if (!supersededByOriginalMessage && stateDelta != null && !stateDelta.isEmpty()) {
1078-
stateDelta.forEach((key, value) -> session.state().put(key, value));
1080+
if (delta != null && !delta.isEmpty()) {
1081+
delta.forEach((key, value) -> session.state().put(key, value));
10791082
}
10801083

10811084
// For the pre-run plugin callbacks; the agent is resolved later, in runResumedAgent.
10821085
InvocationContext initialContext =
10831086
newInvocationContextBuilder(session)
10841087
.invocationId(resolvedInvocationId)
10851088
.runConfig(runConfig)
1086-
.userContent(
1087-
newMessage != null
1088-
? newMessage
1089-
: originalUserMessage(session, resolvedInvocationId))
1089+
.userContent(userContentFor(session, resolvedInvocationId, newMessage))
10901090
.build();
10911091

10921092
Flowable<Event> events;
1093-
if (supersededByOriginalMessage) {
1094-
events =
1095-
runResumedAgent(session, resolvedInvocationId, /* userContent= */ null, runConfig);
1096-
} else if (newMessage != null) {
1093+
if (message != null) {
10971094
// Inherit the branch of the call it answers, so routing and rehydration see it.
10981095
String branch =
1099-
matchingFunctionCallEvent(session, newMessage).flatMap(Event::branch).orElse(null);
1096+
matchingFunctionCallEvent(session, message).flatMap(Event::branch).orElse(null);
11001097
events =
11011098
this.pluginManager
1102-
.onUserMessageCallback(initialContext, newMessage)
1099+
.onUserMessageCallback(initialContext, message)
11031100
.compose(Tracing.<Content>withContext(capturedContext))
1104-
.defaultIfEmpty(newMessage)
1101+
.defaultIfEmpty(message)
11051102
.flatMap(
1106-
content ->
1107-
appendNewMessageToSession(
1108-
session,
1109-
content,
1110-
initialContext,
1111-
runConfig.saveInputBlobsAsArtifacts(),
1112-
stateDelta,
1113-
branch))
1103+
content -> {
1104+
// A plugin's message must not mix answers and text either.
1105+
if (node != null) {
1106+
checkNoTextWithFunctionResponses(content);
1107+
}
1108+
return appendNewMessageToSession(
1109+
session,
1110+
content,
1111+
initialContext,
1112+
runConfig.saveInputBlobsAsArtifacts(),
1113+
delta,
1114+
branch);
1115+
})
11141116
// Persist first: an unpersisted response leaves the call looking unanswered.
11151117
.flatMap(userEvent -> this.sessionService.appendEvent(session, userEvent))
11161118
.flatMapPublisher(
@@ -1120,7 +1122,7 @@ private Flowable<Event> resumeCore(
11201122
resolvedInvocationId,
11211123
userEvent.content().orElse(null),
11221124
runConfig));
1123-
} else if (stateDelta != null && !stateDelta.isEmpty()) {
1125+
} else if (delta != null && !delta.isEmpty()) {
11241126
// No message to carry the delta, so persist a content-less event, as Python does.
11251127
Event stateDeltaEvent =
11261128
Event.builder()
@@ -1129,9 +1131,7 @@ private Flowable<Event> resumeCore(
11291131
.invocationId(resolvedInvocationId)
11301132
.author(Role.USER)
11311133
.actions(
1132-
EventActions.builder()
1133-
.stateDelta(new ConcurrentHashMap<>(stateDelta))
1134-
.build())
1134+
EventActions.builder().stateDelta(new ConcurrentHashMap<>(delta)).build())
11351135
.build();
11361136
events =
11371137
this.sessionService
@@ -1168,10 +1168,7 @@ private Flowable<Event> runResumedAgent(
11681168
newInvocationContextBuilder(session, rootAgent)
11691169
.invocationId(resolvedInvocationId)
11701170
.runConfig(runConfig)
1171-
.userContent(
1172-
userContent != null
1173-
? userContent
1174-
: originalUserMessage(session, resolvedInvocationId))
1171+
.userContent(userContentFor(session, resolvedInvocationId, userContent))
11751172
.build();
11761173
// Rehydrate before routing, as Python does, so a root that checkpointed re-enters itself.
11771174
rootContext.populateInvocationAgentStates();
@@ -1340,18 +1337,24 @@ private static void checkNoTextWithFunctionResponses(@Nullable Content message)
13401337
}
13411338

13421339
/**
1343-
* For a node root, rejects a message whose function responses answer calls from an earlier
1344-
* invocation: without replay, running the node again would repeat its side effects. As in Python
1345-
* and Kotlin, responses without an id are ignored, so they start a new invocation.
1340+
* Returns {@code context} moved to the invocation whose calls {@code message} answers when the
1341+
* root is a workflow node, or unchanged otherwise. A plugin may replace the user's message with
1342+
* {@code message}, so it is checked again.
13461343
*
1347-
* @throws IllegalArgumentException if a response id matches no call, or the ids span invocations.
1348-
* @throws UnsupportedOperationException if the responses answer an earlier invocation.
1344+
* @throws IllegalArgumentException if {@code message} mixes answers and text, or its answers do
1345+
* not resolve to one invocation.
13491346
*/
1350-
private static void checkNoAnswerToAnEarlierInvocation(Session session, Content message) {
1351-
validateResumeMessage(session, message, /* requireFirstId= */ false);
1352-
if (matchingFunctionCallEvent(session, message).isPresent()) {
1353-
throw new UnsupportedOperationException(NODE_ROOT_RESUME_UNSUPPORTED);
1347+
private InvocationContext resolveNodeRootContext(
1348+
Session session, InvocationContext context, Content message) {
1349+
if (node == null) {
1350+
return context;
13541351
}
1352+
checkNoTextWithFunctionResponses(message);
1353+
validateResumeMessage(session, message, /* requireFirstId= */ false);
1354+
String answeredInvocationId = resolveInvocationId(session, message, /* invocationId= */ null);
1355+
return answeredInvocationId == null
1356+
? context
1357+
: context.toBuilder().invocationId(answeredInvocationId).build();
13551358
}
13561359

13571360
/**
@@ -1427,6 +1430,23 @@ private static Content originalUserMessage(Session session, String invocationId)
14271430
"No user message to resume the requested invocation from."));
14281431
}
14291432

1433+
/**
1434+
* Returns the user content for a run of {@code invocationId}: {@code message}, or the message
1435+
* that started the invocation when there is none. As in Python, a workflow node root also gets
1436+
* that message when {@code message} answers calls.
1437+
*
1438+
* @throws IllegalArgumentException if there is no message and the invocation has no user message
1439+
*/
1440+
private Content userContentFor(Session session, String invocationId, @Nullable Content message) {
1441+
if (message == null) {
1442+
return originalUserMessage(session, invocationId);
1443+
}
1444+
if (node != null && carriesFunctionResponse(message)) {
1445+
return findOriginalUserMessage(session, invocationId).orElse(message);
1446+
}
1447+
return message;
1448+
}
1449+
14301450
private static boolean carriesFunctionResponse(Content message) {
14311451
return message.parts().stream()
14321452
.flatMap(List::stream)

0 commit comments

Comments
 (0)