Skip to content

Commit 1ec34b9

Browse files
kvmiloscopybara-github
authored andcommitted
fix: restrict transfer_to_agent to sub-agents, parent, and peers
- Rejected transfers throw `IllegalArgumentException` instead of `IllegalStateException`. PiperOrigin-RevId: 1000022357
1 parent 4721491 commit 1ec34b9

5 files changed

Lines changed: 515 additions & 28 deletions

File tree

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

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,9 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
679679
if (invocationContext.agentStates().containsKey(name())) {
680680
Optional<BaseAgent> resumeTarget = findSubAgentToResume(invocationContext);
681681
if (resumeTarget.isPresent()) {
682+
if (endedAfterLatestTransfer(invocationContext, resumeTarget.get())) {
683+
return endOfAgentAndRecord(invocationContext);
684+
}
682685
AtomicBoolean resumePaused = new AtomicBoolean(false);
683686
return resumeTarget
684687
.get()
@@ -703,13 +706,21 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
703706
Flowable<Event> events =
704707
llmFlow.run(invocationContext).doOnNext(this::maybeSaveOutputToState);
705708
AtomicBoolean paused = new AtomicBoolean(false);
709+
AtomicBoolean lastEventRejectedTransfer = new AtomicBoolean(false);
706710
return events
707711
.doOnNext(
708712
event -> {
709713
if (invocationContext.shouldPauseInvocation(event)) {
710714
paused.set(true);
711715
}
716+
lastEventRejectedTransfer.set(isRejectedTransfer(event));
712717
})
718+
// End this agent before a rejected transfer fails, so a resume does not rerun it.
719+
.onErrorResumeNext(
720+
error ->
721+
lastEventRejectedTransfer.get()
722+
? endOfAgentAndRecord(invocationContext).concatWith(Flowable.error(error))
723+
: Flowable.error(error))
713724
.concatWith(
714725
Flowable.defer(
715726
() -> {
@@ -739,14 +750,58 @@ private Optional<String> transferTargetNameFrom(Event event) {
739750
return event.actions().transferToAgent().filter(target -> !target.equals(name()));
740751
}
741752

753+
/**
754+
* Returns whether {@code event} is a transfer by this agent that {@code BaseLlmFlow} rejects: one
755+
* to itself or to an agent outside {@link #transferTargets()}.
756+
*/
757+
private boolean isRejectedTransfer(Event event) {
758+
return name().equals(event.author())
759+
&& event
760+
.actions()
761+
.transferToAgent()
762+
.filter(
763+
target ->
764+
target.equals(name())
765+
|| transferTargets().stream()
766+
.noneMatch(agent -> agent.name().equals(target)))
767+
.isPresent();
768+
}
769+
770+
/**
771+
* Returns whether {@code target} ended after this agent's latest transfer, as it does on its own
772+
* rejected transfer. An end marker from before that transfer, such as a past loop iteration, does
773+
* not count.
774+
*/
775+
private boolean endedAfterLatestTransfer(InvocationContext context, BaseAgent target) {
776+
// All branches: under a ParallelAgent, the target writes its marker on a descendant branch.
777+
for (Event event :
778+
context.events(/* currentInvocation= */ true, /* currentBranch= */ false).reverse()) {
779+
if (transferTargetNameFrom(event).isPresent()) {
780+
return false;
781+
}
782+
if (isEndMarker(event, target.name())) {
783+
return true;
784+
}
785+
}
786+
return false;
787+
}
788+
789+
/**
790+
* Returns whether {@code event} is {@code author}'s end-of-agent marker, which has no content.
791+
*/
792+
private static boolean isEndMarker(Event event, String author) {
793+
return author.equals(event.author())
794+
&& event.actions().endOfAgent()
795+
&& event.content().isEmpty();
796+
}
797+
742798
/**
743799
* Resolves a transfer target recorded in history back to its agent. A live transfer fails in
744800
* {@code BaseLlmFlow} when the target is gone; a resumed one has no such check, and returning
745801
* empty here would silently re-run this agent instead of the sub-agent it handed off to.
746802
*/
747803
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.
804+
// Check declared targets first, so a same-named agent elsewhere is not resumed instead.
750805
for (BaseAgent target : transferTargets()) {
751806
if (target.name().equals(targetName)) {
752807
return target;
@@ -793,7 +848,12 @@ private Optional<BaseAgent> findSubAgentToResume(InvocationContext context) {
793848
}
794849
// A user event answering no call falls through; Python fails the resume instead.
795850
for (int i = events.size() - 2; i >= 0; i--) {
796-
Optional<String> targetName = transferTargetNameFrom(events.get(i));
851+
Event event = events.get(i);
852+
// Transfers before this agent's own end marker belong to a finished run, such as a past loop.
853+
if (isEndMarker(event, name())) {
854+
break;
855+
}
856+
Optional<String> targetName = transferTargetNameFrom(event);
797857
if (targetName.isPresent()) {
798858
return Optional.of(resolveTransferTarget(targetName.get()));
799859
}

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

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ private Single<LlmResponse> handleAfterModelCallback(
426426
*
427427
* @return A {@link Flowable} of {@link Event} objects from this step.
428428
* @throws LlmCallsLimitExceededException if the agent exceeds allowed LLM invocations.
429-
* @throws IllegalStateException if a transfer agent is specified but not found.
429+
* @throws IllegalArgumentException if a transfer names an agent this agent may not transfer to.
430430
*/
431431
private Flowable<Event> runOneStep(Context spanContext, InvocationContext context) {
432432
AtomicReference<LlmRequest> llmRequestRef = new AtomicReference<>(LlmRequest.builder().build());
@@ -801,34 +801,17 @@ private Flowable<Event> followTransfer(
801801
return self;
802802
}
803803
String agentToTransfer = event.actions().transferToAgent().get();
804-
Optional<BaseAgent> nextAgent = context.agent().rootAgent().findAgent(agentToTransfer);
805-
if (nextAgent.isEmpty()) {
806-
logger.error("Agent not found: {}", agentToTransfer);
807-
return self.concatWith(
808-
Flowable.error(new IllegalStateException("Agent not found: " + agentToTransfer)));
809-
}
810-
BaseAgent target;
811-
// Resumable only: restricting the default flow too would move behavior for existing callers.
812-
if (context.isResumable() && context.agent() instanceof LlmAgent llmAgent) {
813-
Optional<BaseAgent> declared = declaredTransferTarget(llmAgent, agentToTransfer);
814-
if (declared.isEmpty()) {
815-
return self.concatWith(
816-
Flowable.error(
817-
new IllegalStateException(
818-
"Agent "
819-
+ llmAgent.name()
820-
+ " is not allowed to transfer to the requested agent.")));
821-
}
822-
target = declared.get();
823-
} else {
824-
target = nextAgent.get();
804+
LlmAgent agent = (LlmAgent) context.agent();
805+
Optional<BaseAgent> target = declaredTransferTarget(agent, agentToTransfer);
806+
if (target.isEmpty()) {
807+
return self.concatWith(Flowable.error(transferRejected(agent, agentToTransfer)));
825808
}
826809
// Otherwise the target can build its request from a session missing the transfer event.
827810
return self.concatWith(
828811
PersistBarrier.awaitPersisted(context, ImmutableList.of(event))
829812
.andThen(
830813
// Not around the barrier: andThen subscribes the target on the append's thread.
831-
Flowable.defer(() -> target.runAsync(context))
814+
Flowable.defer(() -> target.get().runAsync(context))
832815
.compose(Tracing.withContext(spanContext))));
833816
}
834817

@@ -839,13 +822,31 @@ private Flowable<Event> followTransfer(
839822
*/
840823
private static Optional<BaseAgent> declaredTransferTarget(LlmAgent from, String name) {
841824
if (name.equals(from.name())) {
842-
return Optional.of(from);
825+
return Optional.empty();
843826
}
844827
return AgentTransfer.transferTargets(from).stream()
845828
.filter(candidate -> candidate.name().equals(name))
846829
.findFirst();
847830
}
848831

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

‎core/src/test/java/com/google/adk/agents/LlmAgentTest.java‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,112 @@ public void runAsync_resumeAfterTransfer_userEventWithoutFunctionResponse_resume
835835
assertThat(rootLlm.getRequests()).isEmpty();
836836
}
837837

838+
// The transfer belongs to a run of root that already ended, as in a past loop iteration.
839+
@Test
840+
public void runAsync_resumeAfterThisAgentEnded_ignoresTheEarlierTransfer() {
841+
LlmAgent sub =
842+
createTestAgentBuilder(createTestLlm(createTextLlmResponse("sub should not run")))
843+
.name("sub")
844+
.build();
845+
TestLlm rootLlm = createTestLlm(createTextLlmResponse("root runs again"));
846+
LlmAgent root = createTestAgentBuilder(rootLlm).name("root").subAgents(sub).build();
847+
InvocationContext context = resumableContextWithSeededEvent(root, transferEvent("root", "sub"));
848+
for (Event event :
849+
ImmutableList.of(
850+
Event.builder()
851+
.id("e1")
852+
.invocationId("inv")
853+
.author("root")
854+
.actions(EventActions.builder().endOfAgent(true).build())
855+
.build(),
856+
Event.builder()
857+
.id("u1")
858+
.invocationId("inv")
859+
.author("user")
860+
.content(Content.fromParts(Part.fromText("next")))
861+
.build())) {
862+
var unused = context.sessionService().appendEvent(context.session(), event).blockingGet();
863+
}
864+
context.setAgentState("root", ImmutableMap.of(), /* endOfAgent= */ false);
865+
context.setAgentState("sub", /* agentState= */ null, /* endOfAgent= */ true);
866+
867+
List<Event> events = root.runAsync(context).toList().blockingGet();
868+
869+
assertThat(simplifyEvents(events)).contains("root: root runs again");
870+
assertThat(simplifyEvents(events)).doesNotContain("sub: sub should not run");
871+
}
872+
873+
// The target's end marker is from before root's latest transfer, as in a past loop iteration.
874+
@Test
875+
public void runAsync_resumeTransferAfterTargetEndedEarlier_runsTheTarget() {
876+
TestLlm subLlm = createTestLlm(createTextLlmResponse("sub runs"));
877+
LlmAgent sub = createTestAgentBuilder(subLlm).name("sub").build();
878+
LlmAgent root =
879+
createTestAgentBuilder(createTestLlm(createTextLlmResponse("root should not run")))
880+
.name("root")
881+
.subAgents(sub)
882+
.build();
883+
InvocationContext context = resumableContextWithSeededEvent(root, transferEvent("root", "sub"));
884+
for (Event event :
885+
ImmutableList.of(
886+
Event.builder()
887+
.id("e1")
888+
.invocationId("inv")
889+
.author("sub")
890+
.actions(EventActions.builder().endOfAgent(true).build())
891+
.build(),
892+
Event.builder()
893+
.id("e2")
894+
.invocationId("inv")
895+
.author("root")
896+
.actions(EventActions.builder().endOfAgent(true).build())
897+
.build(),
898+
transferEvent("root", "sub").toBuilder().id("t2").build())) {
899+
var unused = context.sessionService().appendEvent(context.session(), event).blockingGet();
900+
}
901+
context.setAgentState("root", ImmutableMap.of(), /* endOfAgent= */ false);
902+
context.setAgentState("sub", /* agentState= */ null, /* endOfAgent= */ true);
903+
904+
List<Event> events = root.runAsync(context).toList().blockingGet();
905+
906+
assertThat(simplifyEvents(events)).contains("sub: sub runs");
907+
}
908+
909+
// The deprecated setEndInvocation flag on a content event is not an end marker.
910+
@Test
911+
public void runAsync_resumeAfterContentWithEndFlag_stillResumesTheTransfer() {
912+
LlmAgent sub =
913+
createTestAgentBuilder(createTestLlm(createTextLlmResponse("sub resumes")))
914+
.name("sub")
915+
.build();
916+
TestLlm rootLlm = createTestLlm(createTextLlmResponse("root should not run"));
917+
LlmAgent root = createTestAgentBuilder(rootLlm).name("root").subAgents(sub).build();
918+
InvocationContext context = resumableContextWithSeededEvent(root, transferEvent("root", "sub"));
919+
for (Event event :
920+
ImmutableList.of(
921+
Event.builder()
922+
.id("e1")
923+
.invocationId("inv")
924+
.author("root")
925+
.content(Content.fromParts(Part.fromText("ending")))
926+
.actions(EventActions.builder().endOfAgent(true).build())
927+
.build(),
928+
Event.builder()
929+
.id("u1")
930+
.invocationId("inv")
931+
.author("user")
932+
.content(Content.fromParts(Part.fromText("next")))
933+
.build())) {
934+
var unused = context.sessionService().appendEvent(context.session(), event).blockingGet();
935+
}
936+
context.setAgentState("root", ImmutableMap.of(), /* endOfAgent= */ false);
937+
938+
List<Event> events = root.runAsync(context).toList().blockingGet();
939+
940+
assertThat(simplifyEvents(events)).contains("sub: sub resumes");
941+
assertThat(rootLlm.getRequests()).isEmpty();
942+
}
943+
838944
// Mirrors AgentTransferTest.transferTargets_ordersSubAgentsThenParentThenPeers: the flow keeps a
839945
// second copy of this rule that the agents package cannot reach, so both copies are pinned.
840946
@Test

0 commit comments

Comments
 (0)