Skip to content

Commit 21c8fa1

Browse files
Merge pull request #1534 from innoprej:fix/confirmation-scan-last-user-turn
PiperOrigin-RevId: 994343603
2 parents d03882a + a87ee95 commit 21c8fa1

2 files changed

Lines changed: 102 additions & 12 deletions

File tree

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

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,7 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
8282
ImmutableMap<String, AuthoredFunctionCall> functionCallsById =
8383
functionCallsById(events, agentName);
8484
ImmutableSet<String> confirmationRequestedIds = confirmationRequestedIds(events);
85-
// A tool has been confirmed, but it might already have been executed by a subsequent processor
86-
// or in a subsequent turn: such calls have a function response after the user confirmation
87-
// 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.
85+
// Calls this agent already re-ran since the approval; skipped before validation, not re-logged.
9186
//
9287
// Only responses this agent produced count. A peer event landing after the approval that
9388
// reuses the pending call's ID would otherwise convince this scan the tool had already run,
@@ -167,11 +162,10 @@ public Single<RequestProcessor.RequestProcessingResult> processRequest(
167162

168163
private static Optional<ConfirmationResult> findMostRecentConfirmations(
169164
ImmutableList<Event> events) {
170-
// Search backwards for the most recent user event that contains request confirmation
171-
// function responses.
165+
// As in ADK Python, only the most recent user event can answer a pending confirmation.
172166
for (int i = events.size() - 1; i >= 0; i--) {
173167
Event event = events.get(i);
174-
if (!Objects.equals(event.author(), Role.USER) || event.functionResponses().isEmpty()) {
168+
if (!Objects.equals(event.author(), Role.USER)) {
175169
continue;
176170
}
177171

@@ -186,9 +180,9 @@ private static Optional<ConfirmationResult> findMostRecentConfirmations(
186180
.map(RequestConfirmationLlmRequestProcessor::maybeCreateToolConfirmationEntry)
187181
.flatMap(Optional::stream)
188182
.collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
189-
if (!confirmationsInEvent.isEmpty()) {
190-
return Optional.of(new ConfirmationResult(confirmationsInEvent, i));
191-
}
183+
return confirmationsInEvent.isEmpty()
184+
? Optional.empty()
185+
: Optional.of(new ConfirmationResult(confirmationsInEvent, i));
192186
}
193187
return Optional.empty();
194188
}

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.google.adk.plugins.PluginManager;
3333
import com.google.adk.sessions.InMemorySessionService;
3434
import com.google.adk.sessions.Session;
35+
import com.google.adk.summarizer.LlmEventSummarizer;
3536
import com.google.adk.testing.TestLlm;
3637
import com.google.adk.testing.TestUtils.EchoTool;
3738
import com.google.common.collect.ImmutableList;
@@ -208,6 +209,101 @@ public void runAsync_noEvents_empty() {
208209
.isEmpty();
209210
}
210211

212+
@Test
213+
public void runAsync_compactionAfterApproval_doesNotCallOriginalFunction() {
214+
LlmAgent agent = createAgentWithEchoTool();
215+
// The summarizer's event is user-authored, so it ends the scan as in ADK Python.
216+
Event compactionEvent =
217+
new LlmEventSummarizer(
218+
createTestLlm(createLlmResponse(Content.fromParts(Part.fromText("summary")))))
219+
.summarizeEvents(CONFIRMED_CALL_EVENTS)
220+
.blockingGet();
221+
assertThat(compactionEvent).isNotNull();
222+
assertThat(compactionEvent.author()).isEqualTo("user");
223+
Session session =
224+
Session.builder("session_id")
225+
.events(
226+
ImmutableList.<Event>builder()
227+
.addAll(CONFIRMED_CALL_EVENTS)
228+
.add(compactionEvent)
229+
.build())
230+
.build();
231+
232+
assertThat(resumedEvents(agent, session)).isEmpty();
233+
}
234+
235+
@Test
236+
public void runAsync_stateDeltaOnlyEventAfterApproval_doesNotCallOriginalFunction() {
237+
LlmAgent agent = createAgentWithEchoTool();
238+
// A state-only user event, such as a resume without a message, ends the scan too.
239+
Event stateDeltaEvent =
240+
Event.builder()
241+
.author("user")
242+
.actions(EventActions.builder().stateDelta(ImmutableMap.of("resume_count", 1)).build())
243+
.build();
244+
Session session =
245+
Session.builder("session_id")
246+
.events(
247+
ImmutableList.<Event>builder()
248+
.addAll(CONFIRMED_CALL_EVENTS)
249+
.add(stateDeltaEvent)
250+
.build())
251+
.build();
252+
253+
assertThat(resumedEvents(agent, session)).isEmpty();
254+
}
255+
256+
@Test
257+
public void runAsync_userTextTurnAfterApproval_doesNotCallOriginalFunction() {
258+
LlmAgent agent = createAgentWithEchoTool();
259+
// The approved call never recorded a response; a later text turn must not re-run it.
260+
Event laterUserTextEvent =
261+
Event.builder()
262+
.author("user")
263+
.content(Content.fromParts(Part.fromText("unrelated follow-up question")))
264+
.build();
265+
Session session =
266+
Session.builder("session_id")
267+
.events(
268+
ImmutableList.<Event>builder()
269+
.addAll(CONFIRMED_CALL_EVENTS)
270+
.add(laterUserTextEvent)
271+
.build())
272+
.build();
273+
274+
assertThat(resumedEvents(agent, session)).isEmpty();
275+
}
276+
277+
@Test
278+
public void runAsync_laterUserTurnAnswersOtherFunctionCall_doesNotCallOriginalFunction() {
279+
LlmAgent agent = createAgentWithEchoTool();
280+
// The latest user event answers another function call, so the earlier approval stays unused.
281+
Event otherFunctionResponseEvent =
282+
Event.builder()
283+
.author("user")
284+
.content(
285+
Content.fromParts(
286+
Part.builder()
287+
.functionResponse(
288+
FunctionResponse.builder()
289+
.id("other_fc_id")
290+
.name("other_tool")
291+
.response(ImmutableMap.of("result", "done"))
292+
.build())
293+
.build()))
294+
.build();
295+
Session session =
296+
Session.builder("session_id")
297+
.events(
298+
ImmutableList.<Event>builder()
299+
.addAll(CONFIRMED_CALL_EVENTS)
300+
.add(otherFunctionResponseEvent)
301+
.build())
302+
.build();
303+
304+
assertThat(resumedEvents(agent, session)).isEmpty();
305+
}
306+
211307
@Test
212308
public void runAsync_noUserConfirmationEvent_empty() {
213309
LlmAgent agent = createAgentWithEchoTool();

0 commit comments

Comments
 (0)