Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 39 additions & 33 deletions core/src/main/java/com/google/adk/agents/LlmAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schema> outputSchema = outputSchema();
if (outputSchema.isPresent()) {
try {
Map<String, Object> 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<Part> 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<Schema> 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) {
Expand Down
13 changes: 12 additions & 1 deletion core/src/main/java/com/google/adk/events/Event.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,14 @@ private static Maybe<Event> processFunctionResult(
.flatMapMaybe(
finalOptionalResult -> {
Map<String, Object> 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 =
Expand Down
6 changes: 3 additions & 3 deletions core/src/test/java/com/google/adk/events/EventTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
170 changes: 170 additions & 0 deletions core/src/test/java/com/google/adk/runner/RunnerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event> 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<Event> 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<Event> 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<Event> 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
Expand Down Expand Up @@ -2564,6 +2717,23 @@ private Tools() {}
public static ImmutableMap<String, Object> 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<String, Object> 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<String, Object> pendingProgressTool(String message) {
pendingProgressToolCalls.incrementAndGet();
return ImmutableMap.of("status", "pending");
}
}

@Test
Expand Down
Loading