@@ -677,8 +677,16 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
677677 () -> {
678678 // Resumed after a transfer: continue the sub-agent instead of re-invoking the model.
679679 if (invocationContext .agentStates ().containsKey (name ())) {
680- Optional <BaseAgent > resumeTarget = findSubAgentToResume (invocationContext );
681- if (resumeTarget .isPresent ()) {
680+ Optional <String > resumeTargetName = findTransferToResume (invocationContext );
681+ if (resumeTargetName .isPresent ()) {
682+ Optional <BaseAgent > resumeTarget = declaredTransferTarget (resumeTargetName .get ());
683+ // End this agent without running anything if the recorded target is undeclared
684+ // (rejected) or already ended after this agent's latest transfer (e.g. on the
685+ // target's own rejected transfer).
686+ if (resumeTarget .isEmpty ()
687+ || endedAfterLatestTransfer (invocationContext , resumeTarget .get ())) {
688+ return endOfAgentAndRecord (invocationContext );
689+ }
682690 AtomicBoolean resumePaused = new AtomicBoolean (false );
683691 return resumeTarget
684692 .get ()
@@ -710,6 +718,7 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
710718 paused .set (true );
711719 }
712720 })
721+ .onErrorResumeNext (error -> endIfOwnRejectedTransfer (invocationContext , error ))
713722 .concatWith (
714723 Flowable .defer (
715724 () -> {
@@ -724,6 +733,20 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
724733 });
725734 }
726735
736+ /**
737+ * Emits an end-of-agent marker before {@code error} when it is a {@link
738+ * TransferRejectedException} from this agent, so a resume does not re-run it. Any other error,
739+ * including a sub-agent's rejection, passes through and leaves this agent open.
740+ */
741+ private Flowable <Event > endIfOwnRejectedTransfer (
742+ InvocationContext invocationContext , Throwable error ) {
743+ if (error instanceof TransferRejectedException rejected
744+ && rejected .agentName ().equals (name ())) {
745+ return endOfAgentAndRecord (invocationContext ).concatWith (Flowable .error (error ));
746+ }
747+ return Flowable .error (error );
748+ }
749+
727750 /**
728751 * Returns the name of the agent this agent transferred to in {@code event}, or empty when {@code
729752 * event} is not such a transfer. A {@code transfer_to_agent} function response is required, as in
@@ -740,41 +763,56 @@ private Optional<String> transferTargetNameFrom(Event event) {
740763 }
741764
742765 /**
743- * Resolves a transfer target recorded in history back to its agent. A live transfer fails in
744- * {@code BaseLlmFlow} when the target is gone; a resumed one has no such check, and returning
745- * empty here would silently re-run this agent instead of the sub-agent it handed off to .
766+ * Returns whether {@code target} ended after this agent's latest transfer, as it does on its own
767+ * rejected transfer. An end marker from before that transfer, such as a past loop iteration, does
768+ * not count .
746769 */
747- private BaseAgent resolveTransferTarget (String targetName ) {
748- // Declared targets first, as in Python, so a same-named agent on an unrelated branch is not
749- // resumed into; then the same tree-wide search the live transfer path uses.
750- for (BaseAgent target : transferTargets ()) {
751- if (target .name ().equals (targetName )) {
752- return target ;
770+ private boolean endedAfterLatestTransfer (InvocationContext context , BaseAgent target ) {
771+ // All branches: under a ParallelAgent, the target writes its marker on a descendant branch.
772+ for (Event event :
773+ context .events (/* currentInvocation= */ true , /* currentBranch= */ false ).reverse ()) {
774+ if (transferTargetNameFrom (event ).isPresent ()) {
775+ return false ;
776+ }
777+ if (isEndMarker (event , target .name ())) {
778+ return true ;
753779 }
754780 }
755- return rootAgent ()
756- .findAgent (targetName )
757- .orElseThrow (
758- () ->
759- new IllegalStateException (
760- "Cannot resume agent "
761- + name ()
762- + ": the agent it transferred to is no longer in the agent tree." ));
781+ return false ;
763782 }
764783
765784 /**
766- * When this agent is being resumed, returns the sub-agent it had transferred to (so the resume
767- * continues that sub-agent), or empty when this agent should continue itself.
785+ * Returns whether {@code event} is {@code author}'s end-of-agent marker, which has no content.
768786 */
769- private Optional <BaseAgent > findSubAgentToResume (InvocationContext context ) {
787+ private static boolean isEndMarker (Event event , String author ) {
788+ return author .equals (event .author ())
789+ && event .actions ().endOfAgent ()
790+ && event .content ().isEmpty ();
791+ }
792+
793+ /**
794+ * Returns the declared target named {@code targetName}, or empty when it is not one, so a
795+ * same-named agent elsewhere in the tree is never resumed in its place.
796+ */
797+ private Optional <BaseAgent > declaredTransferTarget (String targetName ) {
798+ return transferTargets ().stream ()
799+ .filter (target -> target .name ().equals (targetName ))
800+ .findFirst ();
801+ }
802+
803+ /**
804+ * When this agent is being resumed, returns the name of the agent it had transferred to, or empty
805+ * when this agent should continue itself. The name need not be a declared target.
806+ */
807+ private Optional <String > findTransferToResume (InvocationContext context ) {
770808 ImmutableList <Event > events =
771809 context .events (/* currentInvocation= */ true , /* currentBranch= */ true );
772810 if (events .isEmpty ()) {
773811 return Optional .empty ();
774812 }
775813 Event lastEvent = Iterables .getLast (events );
776814 if (name ().equals (lastEvent .author ())) {
777- return transferTargetNameFrom (lastEvent ). map ( this :: resolveTransferTarget ) ;
815+ return transferTargetNameFrom (lastEvent );
778816 }
779817 if (Objects .equals (lastEvent .author (), Role .USER ) && !lastEvent .functionResponses ().isEmpty ()) {
780818 // IAE (not ISE): an unresolvable resume surfaces through Runner.runAsync's IAE contract.
@@ -793,9 +831,14 @@ private Optional<BaseAgent> findSubAgentToResume(InvocationContext context) {
793831 }
794832 // A user event answering no call falls through; Python fails the resume instead.
795833 for (int i = events .size () - 2 ; i >= 0 ; i --) {
796- Optional <String > targetName = transferTargetNameFrom (events .get (i ));
834+ Event event = events .get (i );
835+ // Transfers before this agent's own end marker belong to a finished run, such as a past loop.
836+ if (isEndMarker (event , name ())) {
837+ break ;
838+ }
839+ Optional <String > targetName = transferTargetNameFrom (event );
797840 if (targetName .isPresent ()) {
798- return Optional . of ( resolveTransferTarget ( targetName . get ())) ;
841+ return targetName ;
799842 }
800843 }
801844 return Optional .empty ();
0 commit comments