Skip to content

Commit 9837e1b

Browse files
kvmiloscopybara-github
authored andcommitted
fix: restrict turn-based transfer_to_agent to declared targets
- Rejected transfers throw the new `TransferRejectedException` (extends `IllegalStateException`). PiperOrigin-RevId: 1003929844
1 parent db30553 commit 9837e1b

5 files changed

Lines changed: 674 additions & 56 deletions

File tree

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

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
17+
package com.google.adk.agents;
18+
19+
/**
20+
* Thrown when an {@link LlmAgent} calls {@code transfer_to_agent} with an agent it may not transfer
21+
* to: itself, an agent other than its sub-agents, parent, and peers, or one its {@code
22+
* disallowTransferToParent} or {@code disallowTransferToPeers} setting excludes.
23+
*/
24+
public final class TransferRejectedException extends IllegalStateException {
25+
26+
private final String agentName;
27+
28+
/** Creates an exception for a transfer rejected from {@code agentName} (the calling agent). */
29+
public TransferRejectedException(String agentName, String message) {
30+
super(message);
31+
this.agentName = agentName;
32+
}
33+
34+
/** Returns the name of the agent whose transfer was rejected. */
35+
public String agentName() {
36+
return agentName;
37+
}
38+
}

‎core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java‎

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.google.adk.agents.LlmAgent;
2828
import com.google.adk.agents.ReadonlyContext;
2929
import com.google.adk.agents.RunConfig.StreamingMode;
30+
import com.google.adk.agents.TransferRejectedException;
3031
import com.google.adk.events.Event;
3132
import com.google.adk.flows.BaseFlow;
3233
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
@@ -424,9 +425,9 @@ private Single<LlmResponse> handleAfterModelCallback(
424425
*
425426
* <p>Handles early termination, LLM call limits, and agent transfer if needed.
426427
*
427-
* @return A {@link Flowable} of {@link Event} objects from this step.
428+
* @return A {@link Flowable} of {@link Event} objects from this step, or an error with {@link
429+
* TransferRejectedException} if a transfer names an agent this agent may not transfer to.
428430
* @throws LlmCallsLimitExceededException if the agent exceeds allowed LLM invocations.
429-
* @throws IllegalStateException if a transfer agent is specified but not found.
430431
*/
431432
private Flowable<Event> runOneStep(Context spanContext, InvocationContext context) {
432433
AtomicReference<LlmRequest> llmRequestRef = new AtomicReference<>(LlmRequest.builder().build());
@@ -802,34 +803,17 @@ private Flowable<Event> followTransfer(
802803
return self;
803804
}
804805
String agentToTransfer = event.actions().transferToAgent().get();
805-
Optional<BaseAgent> nextAgent = context.agent().rootAgent().findAgent(agentToTransfer);
806-
if (nextAgent.isEmpty()) {
807-
logger.error("Agent not found: {}", agentToTransfer);
808-
return self.concatWith(
809-
Flowable.error(new IllegalStateException("Agent not found: " + agentToTransfer)));
810-
}
811-
BaseAgent target;
812-
// Resumable only: restricting the default flow too would move behavior for existing callers.
813-
if (context.isResumable() && context.agent() instanceof LlmAgent llmAgent) {
814-
Optional<BaseAgent> declared = declaredTransferTarget(llmAgent, agentToTransfer);
815-
if (declared.isEmpty()) {
816-
return self.concatWith(
817-
Flowable.error(
818-
new IllegalStateException(
819-
"Agent "
820-
+ llmAgent.name()
821-
+ " is not allowed to transfer to the requested agent.")));
822-
}
823-
target = declared.get();
824-
} else {
825-
target = nextAgent.get();
806+
LlmAgent agent = (LlmAgent) context.agent();
807+
Optional<BaseAgent> target = declaredTransferTarget(agent, agentToTransfer);
808+
if (target.isEmpty()) {
809+
return self.concatWith(Flowable.error(transferRejected(agent, agentToTransfer)));
826810
}
827811
// Otherwise the target can build its request from a session missing the transfer event.
828812
return self.concatWith(
829813
PersistBarrier.awaitPersisted(context, ImmutableList.of(event))
830814
.andThen(
831815
// Not around the barrier: andThen subscribes the target on the append's thread.
832-
Flowable.defer(() -> target.runAsync(context))
816+
Flowable.defer(() -> target.get().runAsync(context))
833817
.compose(Tracing.withContext(spanContext))));
834818
}
835819

@@ -840,13 +824,34 @@ private Flowable<Event> followTransfer(
840824
*/
841825
private static Optional<BaseAgent> declaredTransferTarget(LlmAgent from, String name) {
842826
if (name.equals(from.name())) {
843-
return Optional.of(from);
827+
return Optional.empty();
844828
}
845829
return AgentTransfer.transferTargets(from).stream()
846830
.filter(candidate -> candidate.name().equals(name))
847831
.findFirst();
848832
}
849833

834+
/**
835+
* Returns the error for a transfer {@link #declaredTransferTarget} rejected. The message names
836+
* only {@code from}, since {@code name} is model output.
837+
*/
838+
private static TransferRejectedException transferRejected(LlmAgent from, String name) {
839+
if (name.equals(from.name())) {
840+
return new TransferRejectedException(
841+
from.name(), "Agent '" + from.name() + "' cannot transfer to itself.");
842+
}
843+
if (from.rootAgent().findAgent(name).isEmpty()) {
844+
return new TransferRejectedException(
845+
from.name(),
846+
"Agent '"
847+
+ from.name()
848+
+ "' requested a transfer to an agent that is not in the agent tree.");
849+
}
850+
return new TransferRejectedException(
851+
from.name(),
852+
"Agent '" + from.name() + "' is not allowed to transfer to the requested agent.");
853+
}
854+
850855
/**
851856
* Runs {@code callEvent}'s function calls and emits what follows from them: the tool-confirmation
852857
* request, the function response, and any structured final response. Shared by the normal

0 commit comments

Comments
 (0)