Skip to content

Commit e8dd200

Browse files
damianmomotgooglecopybara-github
authored andcommitted
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: 942089477
1 parent d1b1d92 commit e8dd200

5 files changed

Lines changed: 232 additions & 38 deletions

File tree

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

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -611,41 +611,47 @@ protected BaseLlmFlow determineLlmFlow() {
611611
}
612612

613613
private void maybeSaveOutputToState(Event event) {
614-
if (outputKey().isPresent() && event.finalResponse() && event.content().isPresent()) {
615-
// Concatenate text from all parts, excluding thoughts.
616-
Object output;
617-
String rawResult =
618-
event.content().flatMap(Content::parts).orElseGet(ImmutableList::of).stream()
619-
.filter(part -> !isThought(part))
620-
.map(part -> part.text().orElse(""))
621-
.collect(joining());
622-
623-
Optional<Schema> outputSchema = outputSchema();
624-
if (outputSchema.isPresent()) {
625-
try {
626-
Map<String, Object> validatedMap =
627-
SchemaUtils.validateOutputSchema(rawResult, outputSchema.get());
628-
output = validatedMap;
629-
} catch (JsonProcessingException e) {
630-
logger.error(
631-
"LlmAgent output for outputKey '{}' was not valid JSON, despite an outputSchema being"
632-
+ " present. Saving raw output to state.",
633-
outputKey().get(),
634-
e);
635-
output = rawResult;
636-
} catch (IllegalArgumentException e) {
637-
logger.error(
638-
"LlmAgent output for outputKey '{}' did not match the outputSchema. Saving raw output"
639-
+ " to state.",
640-
outputKey().get(),
641-
e);
642-
output = rawResult;
643-
}
644-
} else {
645-
output = rawResult;
614+
if (outputKey().isEmpty() || !event.finalResponse() || event.content().isEmpty()) {
615+
return;
616+
}
617+
List<Part> parts = event.content().flatMap(Content::parts).orElseGet(ImmutableList::of);
618+
619+
// Skip events with no non-thought text part (e.g. a function-call-only long-running call, or a
620+
// function-response-only event) so an output value already in state is not overwritten with an
621+
// empty string. Mirrors ADK Python's output_key handling.
622+
boolean hasTextPart =
623+
parts.stream().anyMatch(part -> !isThought(part) && part.text().isPresent());
624+
if (!hasTextPart) {
625+
return;
626+
}
627+
628+
// Concatenate text from all parts, excluding thoughts.
629+
String rawResult =
630+
parts.stream()
631+
.filter(part -> !isThought(part))
632+
.map(part -> part.text().orElse(""))
633+
.collect(joining());
634+
635+
Object output = rawResult;
636+
Optional<Schema> outputSchema = outputSchema();
637+
if (outputSchema.isPresent()) {
638+
try {
639+
output = SchemaUtils.validateOutputSchema(rawResult, outputSchema.get());
640+
} catch (JsonProcessingException e) {
641+
logger.error(
642+
"LlmAgent output for outputKey '{}' was not valid JSON, despite an outputSchema being"
643+
+ " present. Saving raw output to state.",
644+
outputKey().get(),
645+
e);
646+
} catch (IllegalArgumentException e) {
647+
logger.error(
648+
"LlmAgent output for outputKey '{}' did not match the outputSchema. Saving raw output"
649+
+ " to state.",
650+
outputKey().get(),
651+
e);
646652
}
647-
event.actions().stateDelta().put(outputKey().get(), output);
648653
}
654+
event.actions().stateDelta().put(outputKey().get(), output);
649655
}
650656

651657
private static boolean isThought(Part part) {

core/src/main/java/com/google/adk/events/Event.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,21 @@ public final boolean hasTrailingCodeExecutionResult() {
336336
.isPresent();
337337
}
338338

339+
/**
340+
* Returns whether this event carries a pending long-running tool call (e.g. a human-in-the-loop
341+
* request) whose result is deferred until the caller supplies it later.
342+
*/
343+
@JsonIgnore
344+
public final boolean hasPendingLongRunningToolCall() {
345+
return longRunningToolIds().map(ids -> !ids.isEmpty()).orElse(false);
346+
}
347+
339348
/** Returns true if this is a final response. */
340349
@JsonIgnore
341350
public final boolean finalResponse() {
342-
if (actions().skipSummarization().orElse(false)) {
351+
// A pending long-running tool call ends the invocation: control returns to the caller, who
352+
// supplies the deferred response later. This mirrors Python ADK's is_final_response.
353+
if (actions().skipSummarization().orElse(false) || hasPendingLongRunningToolCall()) {
343354
return true;
344355
}
345356
return functionCalls().isEmpty()

core/src/main/java/com/google/adk/flows/llmflows/Functions.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,14 @@ private static Maybe<Event> processFunctionResult(
540540
.flatMapMaybe(
541541
finalOptionalResult -> {
542542
Map<String, Object> finalFunctionResult = finalOptionalResult.orElse(null);
543-
if (tool.longRunning() && finalFunctionResult == null) {
543+
boolean hasNoResult =
544+
finalFunctionResult == null || finalFunctionResult.isEmpty();
545+
if (tool.longRunning() && hasNoResult) {
546+
// A long-running tool with no result yet defers its response, so skip the
547+
// function-response event to avoid re-invoking the model with a
548+
// placeholder. The empty-map case is included because FunctionTool
549+
// coerces
550+
// an absent return into an empty map.
544551
return Maybe.empty();
545552
}
546553
Event event =

core/src/test/java/com/google/adk/events/EventTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,13 +300,12 @@ public void finalResponse_isTrueForEventWithTextContent() {
300300
.invocationId("i1")
301301
.author("agent")
302302
.content(Content.fromParts(Part.fromText("hello")))
303-
.longRunningToolIds(ImmutableSet.of("tool1"))
304303
.build();
305304
assertThat(event.finalResponse()).isTrue();
306305
}
307306

308307
@Test
309-
public void finalResponse_isFalseForEventWithToolCallAndLongRunningToolId() {
308+
public void finalResponse_isTrueForEventWithToolCallAndLongRunningToolId() {
310309
Event event =
311310
Event.builder()
312311
.id("e1")
@@ -315,7 +314,8 @@ public void finalResponse_isFalseForEventWithToolCallAndLongRunningToolId() {
315314
.content(Content.fromParts(Part.fromFunctionCall("tool", ImmutableMap.of("k", "v"))))
316315
.longRunningToolIds(ImmutableSet.of("tool1"))
317316
.build();
318-
assertThat(event.finalResponse()).isFalse();
317+
// A pending long-running tool call ends the invocation, so the event is a final response.
318+
assertThat(event.finalResponse()).isTrue();
319319
}
320320

321321
@Test

core/src/test/java/com/google/adk/runner/RunnerTest.java

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2301,6 +2301,159 @@ public void runAsync_withLongRunningCall_resumabilityDisabled_doesNotPause() {
23012301
assertThat(simplifyEvents(events)).contains("agent: after pending call");
23022302
}
23032303

2304+
// A long-running tool awaiting an external result (real HITL, e.g. human input) returns nothing
2305+
// yet. The invocation must end after the single model call rather than re-invoking the model with
2306+
// a placeholder response and looping until the call limit. Matches Python ADK v1: the function
2307+
// response is skipped and the long-running call event is treated as final.
2308+
@Test
2309+
public void runAsync_withLongRunningCall_noImmediateResult_endsAfterSingleModelCall() {
2310+
TestLlm testLlm =
2311+
createTestLlm(
2312+
createFunctionCallLlmResponse(
2313+
"lro_call_id", "pendingTool", ImmutableMap.of("message", "hello")),
2314+
// Extra response the flow must NOT consume; reaching it means it looped.
2315+
createTextLlmResponse("should not be reached"));
2316+
LlmAgent agent =
2317+
createTestAgentBuilder(testLlm)
2318+
.name("agent")
2319+
.tools(
2320+
FunctionTool.create(
2321+
Tools.class,
2322+
"pendingTool",
2323+
/* requireConfirmation= */ false,
2324+
/* isLongRunning= */ true))
2325+
.build();
2326+
Runner runner =
2327+
Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build();
2328+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
2329+
2330+
List<Event> events =
2331+
runner
2332+
.runAsync("user", session.id(), Content.fromParts(Part.fromText("from user")))
2333+
.toList()
2334+
.blockingGet();
2335+
2336+
// Ended after the single long-running call: no function response, no second model call.
2337+
assertThat(testLlm.getRequests()).hasSize(1);
2338+
assertThat(simplifyEvents(events)).doesNotContain("agent: should not be reached");
2339+
}
2340+
2341+
// The long-running call event is now a final response, but it carries no text. An agent with an
2342+
// outputKey must not overwrite that key with an empty string. Matches ADK Python's output_key
2343+
// guard, which skips final events that have no text part.
2344+
@Test
2345+
public void runAsync_withLongRunningCall_andOutputKey_doesNotWriteEmptyOutput() {
2346+
TestLlm testLlm =
2347+
createTestLlm(
2348+
createFunctionCallLlmResponse(
2349+
"lro_call_id", "pendingTool", ImmutableMap.of("message", "hello")));
2350+
LlmAgent agent =
2351+
createTestAgentBuilder(testLlm)
2352+
.name("agent")
2353+
.outputKey("result")
2354+
.tools(
2355+
FunctionTool.create(
2356+
Tools.class,
2357+
"pendingTool",
2358+
/* requireConfirmation= */ false,
2359+
/* isLongRunning= */ true))
2360+
.build();
2361+
Runner runner =
2362+
Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build();
2363+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
2364+
2365+
List<Event> events =
2366+
runner
2367+
.runAsync("user", session.id(), Content.fromParts(Part.fromText("from user")))
2368+
.toList()
2369+
.blockingGet();
2370+
2371+
assertThat(events).hasSize(1);
2372+
assertThat(events.get(0).actions().stateDelta()).doesNotContainKey("result");
2373+
}
2374+
2375+
// Mirrors ADK Python's test_functions_long_running.test_async_function: a long-running tool that
2376+
// reports a non-empty "pending" status drives a multi-turn lifecycle. The initial pending result
2377+
// is summarized, then the caller injects progress/result function responses over later turns,
2378+
// each summarized by the model, and the tool executes exactly once across the whole lifecycle.
2379+
@Test
2380+
public void runAsync_longRunningCall_multiTurnLifecycle_executesToolOnce() {
2381+
Tools.pendingProgressToolCalls.set(0);
2382+
TestLlm testLlm =
2383+
createTestLlm(
2384+
createFunctionCallLlmResponse(
2385+
"lro_call_id", "pendingProgressTool", ImmutableMap.of("message", "hi")),
2386+
createTextLlmResponse("response1"),
2387+
createTextLlmResponse("response2"),
2388+
createTextLlmResponse("response3"),
2389+
createTextLlmResponse("response4"));
2390+
LlmAgent agent =
2391+
createTestAgentBuilder(testLlm)
2392+
.name("agent")
2393+
.tools(
2394+
FunctionTool.create(
2395+
Tools.class,
2396+
"pendingProgressTool",
2397+
/* requireConfirmation= */ false,
2398+
/* isLongRunning= */ true))
2399+
.build();
2400+
Runner runner =
2401+
Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build();
2402+
Session session = runner.sessionService().createSession("test", "user").blockingGet();
2403+
2404+
// Turn 1: the model calls the long-running tool; the pending result is summarized.
2405+
List<Event> turn1 =
2406+
runner
2407+
.runAsync("user", session.id(), Content.fromParts(Part.fromText("test1")))
2408+
.toList()
2409+
.blockingGet();
2410+
assertThat(testLlm.getRequests()).hasSize(2);
2411+
assertThat(turn1.get(0).longRunningToolIds().get())
2412+
.contains(turn1.get(0).functionCalls().get(0).id().get());
2413+
assertThat(simplifyEvents(turn1))
2414+
.containsExactly(
2415+
"agent: FunctionCall(name=pendingProgressTool, args={message=hi})",
2416+
"agent: FunctionResponse(name=pendingProgressTool, response={status=pending})",
2417+
"agent: response1")
2418+
.inOrder();
2419+
assertThat(Tools.pendingProgressToolCalls.get()).isEqualTo(1);
2420+
2421+
// Turn 2: the caller injects a progress update; the model summarizes, tool not re-run.
2422+
assertThat(simplifyEvents(resumeWithStatus(runner, session, "still waiting")))
2423+
.containsExactly("agent: response2");
2424+
assertThat(testLlm.getRequests()).hasSize(3);
2425+
2426+
// Turn 3: the caller injects the result.
2427+
assertThat(simplifyEvents(resumeWithStatus(runner, session, "done")))
2428+
.containsExactly("agent: response3");
2429+
assertThat(testLlm.getRequests()).hasSize(4);
2430+
2431+
// Turn 4: a further result is still accepted and summarized.
2432+
assertThat(simplifyEvents(resumeWithStatus(runner, session, "done again")))
2433+
.containsExactly("agent: response4");
2434+
assertThat(testLlm.getRequests()).hasSize(5);
2435+
2436+
// The tool executed exactly once across the whole lifecycle.
2437+
assertThat(Tools.pendingProgressToolCalls.get()).isEqualTo(1);
2438+
}
2439+
2440+
private static List<Event> resumeWithStatus(Runner runner, Session session, String status) {
2441+
return runner
2442+
.runAsync(
2443+
"user",
2444+
session.id(),
2445+
Content.fromParts(
2446+
Part.builder()
2447+
.functionResponse(
2448+
FunctionResponse.builder()
2449+
.id("lro_call_id")
2450+
.name("pendingProgressTool")
2451+
.response(ImmutableMap.of("status", status)))
2452+
.build()))
2453+
.toList()
2454+
.blockingGet();
2455+
}
2456+
23042457
// A pending long-running call must stop a resumable LoopAgent after the current iteration rather
23052458
// than looping again (re-calling the model every iteration), matching Python ADK v1.
23062459
@Test
@@ -2564,6 +2717,23 @@ private Tools() {}
25642717
public static ImmutableMap<String, Object> echoTool(String message) {
25652718
return ImmutableMap.of("message", message);
25662719
}
2720+
2721+
// A long-running tool awaiting an external result has nothing to return yet; FunctionTool
2722+
// coerces the absent return into an empty response.
2723+
@SuppressWarnings("unused") // Invoked reflectively by FunctionTool.
2724+
public static @Nullable ImmutableMap<String, Object> pendingTool(String message) {
2725+
return null;
2726+
}
2727+
2728+
static final AtomicInteger pendingProgressToolCalls = new AtomicInteger(0);
2729+
2730+
// A long-running tool that reports progress: it returns a non-empty "pending" status on the
2731+
// initial call. Counts executions so a test can assert it runs exactly once across turns.
2732+
@SuppressWarnings("unused") // Invoked reflectively by FunctionTool.
2733+
public static ImmutableMap<String, Object> pendingProgressTool(String message) {
2734+
pendingProgressToolCalls.incrementAndGet();
2735+
return ImmutableMap.of("status", "pending");
2736+
}
25672737
}
25682738

25692739
@Test

0 commit comments

Comments
 (0)