Skip to content

Commit ffe433d

Browse files
committed
fix(core): stop re-applying a tool confirmation after a later user turn
RequestConfirmationLlmRequestProcessor scanned past user events that carried no function responses, so an approval answered several turns earlier was matched again on every later LLM call. When the approved call had not left a function response (for example because its execution was aborted), the tool was silently re-executed with its original arguments on the next, unrelated user message. Stop the scan at the most recent user event, as the ADK Python processor does: a user turn without request-confirmation responses means there is nothing left to resume.
1 parent 4092a1f commit ffe433d

2 files changed

Lines changed: 70 additions & 9 deletions

File tree

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
8585
// A tool has been confirmed, but it might already have been executed by a subsequent processor
8686
// or in a subsequent turn: such calls have a function response after the user confirmation
8787
// event. This is applied before the resumability check rather than after, because
88-
// findMostRecentConfirmations re-matches the same stale user event on every later LLM call, so
89-
// a settled confirmation would otherwise be re-examined - and re-logged - for the rest of the
90-
// session.
88+
// findMostRecentConfirmations re-matches the same user event on every later LLM call of the
89+
// invocation that answered it, so a settled confirmation would otherwise be re-examined - and
90+
// re-logged - until the next user turn.
9191
//
9292
// Only responses this agent produced count. A peer event landing after the approval that
9393
// reuses the pending call's ID would otherwise convince this scan the tool had already run,
@@ -167,13 +167,19 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
167167

168168
private static Optional<ConfirmationResult> findMostRecentConfirmations(
169169
ImmutableList<Event> events) {
170-
// Search backwards for the most recent user event that contains request confirmation
171-
// function responses.
170+
// Only the most recent user event can answer a pending confirmation. A later user turn that
171+
// carries no confirmation responses (a plain text message, or responses to other function
172+
// calls) means the approval was already consumed or abandoned; scanning past it would re-apply
173+
// the stale approval on every later LLM call. This mirrors ADK Python's processor, which
174+
// returns as soon as the latest user event has no function responses.
172175
for (int i = events.size() - 1; i >= 0; i--) {
173176
Event event = events.get(i);
174-
if (!Objects.equals(event.author(), Role.USER) || event.functionResponses().isEmpty()) {
177+
if (!Objects.equals(event.author(), Role.USER)) {
175178
continue;
176179
}
180+
if (event.functionResponses().isEmpty()) {
181+
return Optional.empty();
182+
}
177183

178184
ImmutableMap<String, ToolConfirmation> confirmationsInEvent =
179185
event.functionResponses().stream()
@@ -186,9 +192,9 @@ private static Optional<ConfirmationResult> findMostRecentConfirmations(
186192
.map(RequestConfirmationLlmRequestProcessor::maybeCreateToolConfirmationEntry)
187193
.flatMap(Optional::stream)
188194
.collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
189-
if (!confirmationsInEvent.isEmpty()) {
190-
return Optional.of(new ConfirmationResult(confirmationsInEvent, i));
191-
}
195+
return confirmationsInEvent.isEmpty()
196+
? Optional.empty()
197+
: Optional.of(new ConfirmationResult(confirmationsInEvent, i));
192198
}
193199
return Optional.empty();
194200
}

‎core/src/test/java/com/google/adk/flows/llmflows/RequestConfirmationLlmRequestProcessorTest.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,61 @@ public void runAsync_noEvents_empty() {
208208
.isEmpty();
209209
}
210210

211+
@Test
212+
public void runAsync_userTextTurnAfterApproval_doesNotCallOriginalFunction() {
213+
LlmAgent agent = createAgentWithEchoTool();
214+
// The approved call never produced a function response (its execution was aborted) and the
215+
// user has since sent a plain text turn. ADK Python stops the scan at that turn, so the stale
216+
// approval must not be applied again on this later LLM call.
217+
Event laterUserTextEvent =
218+
Event.builder()
219+
.author("user")
220+
.content(Content.fromParts(Part.fromText("unrelated follow-up question")))
221+
.build();
222+
Session session =
223+
Session.builder("session_id")
224+
.events(
225+
ImmutableList.<Event>builder()
226+
.addAll(CONFIRMED_CALL_EVENTS)
227+
.add(laterUserTextEvent)
228+
.build())
229+
.build();
230+
231+
assertThat(resumedEvents(agent, session)).isEmpty();
232+
}
233+
234+
@Test
235+
public void runAsync_laterUserTurnAnswersOtherFunctionCall_doesNotCallOriginalFunction() {
236+
LlmAgent agent = createAgentWithEchoTool();
237+
// The most recent user event answers some other function call, not a confirmation request.
238+
// Only that latest user turn is consulted, as in ADK Python, so the earlier approval is not
239+
// re-applied either.
240+
Event otherFunctionResponseEvent =
241+
Event.builder()
242+
.author("user")
243+
.content(
244+
Content.fromParts(
245+
Part.builder()
246+
.functionResponse(
247+
FunctionResponse.builder()
248+
.id("other_fc_id")
249+
.name("other_tool")
250+
.response(ImmutableMap.of("result", "done"))
251+
.build())
252+
.build()))
253+
.build();
254+
Session session =
255+
Session.builder("session_id")
256+
.events(
257+
ImmutableList.<Event>builder()
258+
.addAll(CONFIRMED_CALL_EVENTS)
259+
.add(otherFunctionResponseEvent)
260+
.build())
261+
.build();
262+
263+
assertThat(resumedEvents(agent, session)).isEmpty();
264+
}
265+
211266
@Test
212267
public void runAsync_noUserConfirmationEvent_empty() {
213268
LlmAgent agent = createAgentWithEchoTool();

0 commit comments

Comments
 (0)