From 6dd459457c917f83c0667480b7fc443794f63da8 Mon Sep 17 00:00:00 2001 From: Damian Momot Date: Fri, 3 Jul 2026 08:08:02 -0700 Subject: [PATCH] fix(flows): end invocation on a deferred long-running tool call A long-running tool (e.g. a human-in-the-loop request) that returns no immediate result defers its response until the caller supplies it later. The framework built an empty function-response for such a call and re-invoked the model with that placeholder, so the model proceeded on an empty result -- fabricating a completion, or in multi-tool agents re-issuing calls in a runaway loop until the LLM call limit. - Skip the function-response event when a long-running tool returns null or an empty result, so no placeholder is sent back to the model. - Treat an event that carries pending long-running tool-call ids as a final response, so the invocation ends and returns control to the caller. - Guard LlmAgent output-key writes so a text-less final event (such as the long-running call itself) no longer overwrites the output key with an empty string. This brings Event.finalResponse() to parity with ADK Python's Event.is_final_response, which returns true when long_running_tool_ids is set (in both the 1.x and 2.0 branches), and the output-key guard mirrors ADK Python's output_key handling. PiperOrigin-RevId: 942146191 --- .../java/com/google/adk/agents/LlmAgent.java | 72 ++++---- .../java/com/google/adk/events/Event.java | 13 +- .../google/adk/flows/llmflows/Functions.java | 9 +- .../java/com/google/adk/events/EventTest.java | 6 +- .../com/google/adk/runner/RunnerTest.java | 170 ++++++++++++++++++ 5 files changed, 232 insertions(+), 38 deletions(-) diff --git a/core/src/main/java/com/google/adk/agents/LlmAgent.java b/core/src/main/java/com/google/adk/agents/LlmAgent.java index 98bba4606..fa754e0c0 100644 --- a/core/src/main/java/com/google/adk/agents/LlmAgent.java +++ b/core/src/main/java/com/google/adk/agents/LlmAgent.java @@ -611,41 +611,47 @@ protected BaseLlmFlow determineLlmFlow() { } private void maybeSaveOutputToState(Event event) { - if (outputKey().isPresent() && event.finalResponse() && event.content().isPresent()) { - // Concatenate text from all parts, excluding thoughts. - Object output; - String rawResult = - event.content().flatMap(Content::parts).orElseGet(ImmutableList::of).stream() - .filter(part -> !isThought(part)) - .map(part -> part.text().orElse("")) - .collect(joining()); - - Optional outputSchema = outputSchema(); - if (outputSchema.isPresent()) { - try { - Map validatedMap = - SchemaUtils.validateOutputSchema(rawResult, outputSchema.get()); - output = validatedMap; - } catch (JsonProcessingException e) { - logger.error( - "LlmAgent output for outputKey '{}' was not valid JSON, despite an outputSchema being" - + " present. Saving raw output to state.", - outputKey().get(), - e); - output = rawResult; - } catch (IllegalArgumentException e) { - logger.error( - "LlmAgent output for outputKey '{}' did not match the outputSchema. Saving raw output" - + " to state.", - outputKey().get(), - e); - output = rawResult; - } - } else { - output = rawResult; + if (outputKey().isEmpty() || !event.finalResponse() || event.content().isEmpty()) { + return; + } + List parts = event.content().flatMap(Content::parts).orElseGet(ImmutableList::of); + + // Skip events with no non-thought text part (e.g. a function-call-only long-running call, or a + // function-response-only event) so an output value already in state is not overwritten with an + // empty string. Mirrors ADK Python's output_key handling. + boolean hasTextPart = + parts.stream().anyMatch(part -> !isThought(part) && part.text().isPresent()); + if (!hasTextPart) { + return; + } + + // Concatenate text from all parts, excluding thoughts. + String rawResult = + parts.stream() + .filter(part -> !isThought(part)) + .map(part -> part.text().orElse("")) + .collect(joining()); + + Object output = rawResult; + Optional outputSchema = outputSchema(); + if (outputSchema.isPresent()) { + try { + output = SchemaUtils.validateOutputSchema(rawResult, outputSchema.get()); + } catch (JsonProcessingException e) { + logger.error( + "LlmAgent output for outputKey '{}' was not valid JSON, despite an outputSchema being" + + " present. Saving raw output to state.", + outputKey().get(), + e); + } catch (IllegalArgumentException e) { + logger.error( + "LlmAgent output for outputKey '{}' did not match the outputSchema. Saving raw output" + + " to state.", + outputKey().get(), + e); } - event.actions().stateDelta().put(outputKey().get(), output); } + event.actions().stateDelta().put(outputKey().get(), output); } private static boolean isThought(Part part) { diff --git a/core/src/main/java/com/google/adk/events/Event.java b/core/src/main/java/com/google/adk/events/Event.java index 28f675df9..c62df0985 100644 --- a/core/src/main/java/com/google/adk/events/Event.java +++ b/core/src/main/java/com/google/adk/events/Event.java @@ -336,10 +336,21 @@ public final boolean hasTrailingCodeExecutionResult() { .isPresent(); } + /** + * Returns whether this event carries a pending long-running tool call (e.g. a human-in-the-loop + * request) whose result is deferred until the caller supplies it later. + */ + @JsonIgnore + public final boolean hasPendingLongRunningToolCall() { + return longRunningToolIds().map(ids -> !ids.isEmpty()).orElse(false); + } + /** Returns true if this is a final response. */ @JsonIgnore public final boolean finalResponse() { - if (actions().skipSummarization().orElse(false)) { + // A pending long-running tool call ends the invocation: control returns to the caller, who + // supplies the deferred response later. This mirrors Python ADK's is_final_response. + if (actions().skipSummarization().orElse(false) || hasPendingLongRunningToolCall()) { return true; } return functionCalls().isEmpty() diff --git a/core/src/main/java/com/google/adk/flows/llmflows/Functions.java b/core/src/main/java/com/google/adk/flows/llmflows/Functions.java index f841da065..f531e214d 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/Functions.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/Functions.java @@ -540,7 +540,14 @@ private static Maybe processFunctionResult( .flatMapMaybe( finalOptionalResult -> { Map finalFunctionResult = finalOptionalResult.orElse(null); - if (tool.longRunning() && finalFunctionResult == null) { + boolean hasNoResult = + finalFunctionResult == null || finalFunctionResult.isEmpty(); + if (tool.longRunning() && hasNoResult) { + // A long-running tool with no result yet defers its response, so skip the + // function-response event to avoid re-invoking the model with a + // placeholder. The empty-map case is included because FunctionTool + // coerces + // an absent return into an empty map. return Maybe.empty(); } Event event = diff --git a/core/src/test/java/com/google/adk/events/EventTest.java b/core/src/test/java/com/google/adk/events/EventTest.java index 38186c682..da8aa5eb5 100644 --- a/core/src/test/java/com/google/adk/events/EventTest.java +++ b/core/src/test/java/com/google/adk/events/EventTest.java @@ -300,13 +300,12 @@ public void finalResponse_isTrueForEventWithTextContent() { .invocationId("i1") .author("agent") .content(Content.fromParts(Part.fromText("hello"))) - .longRunningToolIds(ImmutableSet.of("tool1")) .build(); assertThat(event.finalResponse()).isTrue(); } @Test - public void finalResponse_isFalseForEventWithToolCallAndLongRunningToolId() { + public void finalResponse_isTrueForEventWithToolCallAndLongRunningToolId() { Event event = Event.builder() .id("e1") @@ -315,7 +314,8 @@ public void finalResponse_isFalseForEventWithToolCallAndLongRunningToolId() { .content(Content.fromParts(Part.fromFunctionCall("tool", ImmutableMap.of("k", "v")))) .longRunningToolIds(ImmutableSet.of("tool1")) .build(); - assertThat(event.finalResponse()).isFalse(); + // A pending long-running tool call ends the invocation, so the event is a final response. + assertThat(event.finalResponse()).isTrue(); } @Test diff --git a/core/src/test/java/com/google/adk/runner/RunnerTest.java b/core/src/test/java/com/google/adk/runner/RunnerTest.java index ae1e0ee74..5b1f516d9 100644 --- a/core/src/test/java/com/google/adk/runner/RunnerTest.java +++ b/core/src/test/java/com/google/adk/runner/RunnerTest.java @@ -2301,6 +2301,159 @@ public void runAsync_withLongRunningCall_resumabilityDisabled_doesNotPause() { assertThat(simplifyEvents(events)).contains("agent: after pending call"); } + // A long-running tool awaiting an external result (real HITL, e.g. human input) returns nothing + // yet. The invocation must end after the single model call rather than re-invoking the model with + // a placeholder response and looping until the call limit. Matches Python ADK v1: the function + // response is skipped and the long-running call event is treated as final. + @Test + public void runAsync_withLongRunningCall_noImmediateResult_endsAfterSingleModelCall() { + TestLlm testLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "pendingTool", ImmutableMap.of("message", "hello")), + // Extra response the flow must NOT consume; reaching it means it looped. + createTextLlmResponse("should not be reached")); + LlmAgent agent = + createTestAgentBuilder(testLlm) + .name("agent") + .tools( + FunctionTool.create( + Tools.class, + "pendingTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + Runner runner = + Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + // Ended after the single long-running call: no function response, no second model call. + assertThat(testLlm.getRequests()).hasSize(1); + assertThat(simplifyEvents(events)).doesNotContain("agent: should not be reached"); + } + + // The long-running call event is now a final response, but it carries no text. An agent with an + // outputKey must not overwrite that key with an empty string. Matches ADK Python's output_key + // guard, which skips final events that have no text part. + @Test + public void runAsync_withLongRunningCall_andOutputKey_doesNotWriteEmptyOutput() { + TestLlm testLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "pendingTool", ImmutableMap.of("message", "hello"))); + LlmAgent agent = + createTestAgentBuilder(testLlm) + .name("agent") + .outputKey("result") + .tools( + FunctionTool.create( + Tools.class, + "pendingTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + Runner runner = + Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + List events = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("from user"))) + .toList() + .blockingGet(); + + assertThat(events).hasSize(1); + assertThat(events.get(0).actions().stateDelta()).doesNotContainKey("result"); + } + + // Mirrors ADK Python's test_functions_long_running.test_async_function: a long-running tool that + // reports a non-empty "pending" status drives a multi-turn lifecycle. The initial pending result + // is summarized, then the caller injects progress/result function responses over later turns, + // each summarized by the model, and the tool executes exactly once across the whole lifecycle. + @Test + public void runAsync_longRunningCall_multiTurnLifecycle_executesToolOnce() { + Tools.pendingProgressToolCalls.set(0); + TestLlm testLlm = + createTestLlm( + createFunctionCallLlmResponse( + "lro_call_id", "pendingProgressTool", ImmutableMap.of("message", "hi")), + createTextLlmResponse("response1"), + createTextLlmResponse("response2"), + createTextLlmResponse("response3"), + createTextLlmResponse("response4")); + LlmAgent agent = + createTestAgentBuilder(testLlm) + .name("agent") + .tools( + FunctionTool.create( + Tools.class, + "pendingProgressTool", + /* requireConfirmation= */ false, + /* isLongRunning= */ true)) + .build(); + Runner runner = + Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build(); + Session session = runner.sessionService().createSession("test", "user").blockingGet(); + + // Turn 1: the model calls the long-running tool; the pending result is summarized. + List turn1 = + runner + .runAsync("user", session.id(), Content.fromParts(Part.fromText("test1"))) + .toList() + .blockingGet(); + assertThat(testLlm.getRequests()).hasSize(2); + assertThat(turn1.get(0).longRunningToolIds().get()) + .contains(turn1.get(0).functionCalls().get(0).id().get()); + assertThat(simplifyEvents(turn1)) + .containsExactly( + "agent: FunctionCall(name=pendingProgressTool, args={message=hi})", + "agent: FunctionResponse(name=pendingProgressTool, response={status=pending})", + "agent: response1") + .inOrder(); + assertThat(Tools.pendingProgressToolCalls.get()).isEqualTo(1); + + // Turn 2: the caller injects a progress update; the model summarizes, tool not re-run. + assertThat(simplifyEvents(resumeWithStatus(runner, session, "still waiting"))) + .containsExactly("agent: response2"); + assertThat(testLlm.getRequests()).hasSize(3); + + // Turn 3: the caller injects the result. + assertThat(simplifyEvents(resumeWithStatus(runner, session, "done"))) + .containsExactly("agent: response3"); + assertThat(testLlm.getRequests()).hasSize(4); + + // Turn 4: a further result is still accepted and summarized. + assertThat(simplifyEvents(resumeWithStatus(runner, session, "done again"))) + .containsExactly("agent: response4"); + assertThat(testLlm.getRequests()).hasSize(5); + + // The tool executed exactly once across the whole lifecycle. + assertThat(Tools.pendingProgressToolCalls.get()).isEqualTo(1); + } + + private static List resumeWithStatus(Runner runner, Session session, String status) { + return runner + .runAsync( + "user", + session.id(), + Content.fromParts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("lro_call_id") + .name("pendingProgressTool") + .response(ImmutableMap.of("status", status))) + .build())) + .toList() + .blockingGet(); + } + // A pending long-running call must stop a resumable LoopAgent after the current iteration rather // than looping again (re-calling the model every iteration), matching Python ADK v1. @Test @@ -2564,6 +2717,23 @@ private Tools() {} public static ImmutableMap echoTool(String message) { return ImmutableMap.of("message", message); } + + // A long-running tool awaiting an external result has nothing to return yet; FunctionTool + // coerces the absent return into an empty response. + @SuppressWarnings("unused") // Invoked reflectively by FunctionTool. + public static @Nullable ImmutableMap pendingTool(String message) { + return null; + } + + static final AtomicInteger pendingProgressToolCalls = new AtomicInteger(0); + + // A long-running tool that reports progress: it returns a non-empty "pending" status on the + // initial call. Counts executions so a test can assert it runs exactly once across turns. + @SuppressWarnings("unused") // Invoked reflectively by FunctionTool. + public static ImmutableMap pendingProgressTool(String message) { + pendingProgressToolCalls.incrementAndGet(); + return ImmutableMap.of("status", "pending"); + } } @Test