@@ -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