Skip to content

Commit 06edf99

Browse files
committed
fix: allow long-running tools to omit function responses
1 parent 67b602f commit 06edf99

6 files changed

Lines changed: 110 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,8 +586,7 @@ private static Maybe<Map<String, Object>> maybeInvokeAfterToolCall(
586586

587587
private static Maybe<Map<String, Object>> callTool(
588588
BaseTool tool, Map<String, Object> args, ToolContext toolContext, Context parentContext) {
589-
return tool.runAsync(args, toolContext)
590-
.toMaybe()
589+
return tool.runMaybeAsync(args, toolContext)
591590
.doOnSubscribe(
592591
d ->
593592
Tracing.traceToolCall(

core/src/main/java/com/google/adk/tools/BaseTool.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.google.genai.types.LiveConnectConfig;
3535
import com.google.genai.types.Tool;
3636
import io.reactivex.rxjava3.core.Completable;
37+
import io.reactivex.rxjava3.core.Maybe;
3738
import io.reactivex.rxjava3.core.Single;
3839
import java.util.HashMap;
3940
import java.util.Map;
@@ -48,6 +49,8 @@ public abstract class BaseTool {
4849
private final String name;
4950
private final String description;
5051
private final boolean isLongRunning;
52+
private final boolean overridesRunAsync;
53+
private final boolean overridesRunMaybeAsync;
5154
private final HashMap<String, Object> customMetadata;
5255

5356
protected BaseTool(@Nonnull String name, @Nonnull String description) {
@@ -58,6 +61,8 @@ protected BaseTool(@Nonnull String name, @Nonnull String description, boolean is
5861
this.name = name;
5962
this.description = description;
6063
this.isLongRunning = isLongRunning;
64+
overridesRunAsync = overridesMethod("runAsync");
65+
overridesRunMaybeAsync = overridesMethod("runMaybeAsync");
6166
customMetadata = new HashMap<>();
6267
}
6368

@@ -90,6 +95,24 @@ public void setCustomMetadata(String key, Object value) {
9095

9196
/** Calls a tool. */
9297
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
98+
if (overridesRunMaybeAsync) {
99+
return runMaybeAsync(args, toolContext).defaultIfEmpty(ImmutableMap.<String, Object>of());
100+
}
101+
throw new UnsupportedOperationException("This method is not implemented.");
102+
}
103+
104+
/**
105+
* Calls a tool and optionally returns a function response.
106+
*
107+
* <p>Override this method for long-running tools that may end the current invocation without
108+
* emitting a function response event. This default implementation delegates to {@link
109+
* #runAsync(Map, ToolContext)} for backwards compatibility.
110+
*/
111+
public Maybe<Map<String, Object>> runMaybeAsync(
112+
Map<String, Object> args, ToolContext toolContext) {
113+
if (overridesRunAsync) {
114+
return runAsync(args, toolContext).toMaybe();
115+
}
93116
throw new UnsupportedOperationException("This method is not implemented.");
94117
}
95118

@@ -180,6 +203,15 @@ private static ImmutableList<Tool> findToolsWithoutFunctionDeclarations(LlmReque
180203
.orElse(ImmutableList.of());
181204
}
182205

206+
private boolean overridesMethod(String methodName) {
207+
try {
208+
return getClass().getMethod(methodName, Map.class, ToolContext.class).getDeclaringClass()
209+
!= BaseTool.class;
210+
} catch (NoSuchMethodException e) {
211+
throw new IllegalStateException("Missing tool method: " + methodName, e);
212+
}
213+
}
214+
183215
/**
184216
* Creates a tool instance from a config.
185217
*

core/src/main/java/com/google/adk/tools/FunctionTool.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ public boolean isStreaming() {
245245

246246
@Override
247247
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
248+
return runMaybeAsync(args, toolContext).defaultIfEmpty(ImmutableMap.<String, Object>of());
249+
}
250+
251+
@Override
252+
public Maybe<Map<String, Object>> runMaybeAsync(
253+
Map<String, Object> args, ToolContext toolContext) {
248254
try {
249255
if (requireConfirmation) {
250256
if (toolContext.toolConfirmation().isEmpty()) {
@@ -253,17 +259,20 @@ public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContex
253259
"Please approve or reject the tool call %s() by responding with a"
254260
+ " FunctionResponse with an expected ToolConfirmation payload.",
255261
name()));
256-
return Single.just(
262+
return Maybe.just(
257263
ImmutableMap.of(
258264
"error", "This tool call requires confirmation, please approve or reject."));
259265
} else if (!toolContext.toolConfirmation().get().confirmed()) {
260-
return Single.just(ImmutableMap.of("error", "This tool call is rejected."));
266+
return Maybe.just(ImmutableMap.of("error", "This tool call is rejected."));
261267
}
262268
}
263-
return this.call(args, toolContext).defaultIfEmpty(ImmutableMap.of());
269+
Maybe<Map<String, Object>> functionResult = this.call(args, toolContext);
270+
return longRunning()
271+
? functionResult
272+
: functionResult.switchIfEmpty(Maybe.just(ImmutableMap.<String, Object>of()));
264273
} catch (Exception e) {
265274
logger.error("Exception occurred while calling function tool: " + func.getName(), e);
266-
return Single.just(
275+
return Maybe.just(
267276
ImmutableMap.of("status", "error", "message", "An internal error occurred."));
268277
}
269278
}

core/src/test/java/com/google/adk/flows/llmflows/FunctionsTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@
2727
import com.google.adk.agents.RunConfig.ToolExecutionMode;
2828
import com.google.adk.events.Event;
2929
import com.google.adk.testing.TestUtils;
30+
import com.google.adk.tools.BaseTool;
31+
import com.google.adk.tools.ToolContext;
3032
import com.google.common.collect.ImmutableList;
3133
import com.google.common.collect.ImmutableMap;
3234
import com.google.genai.types.Content;
3335
import com.google.genai.types.FunctionCall;
3436
import com.google.genai.types.FunctionResponse;
3537
import com.google.genai.types.Part;
38+
import io.reactivex.rxjava3.core.Maybe;
39+
import java.util.Map;
3640
import org.junit.Test;
3741
import org.junit.runner.RunWith;
3842
import org.junit.runners.JUnit4;
@@ -146,6 +150,39 @@ public void handleFunctionCalls_singleFunctionCall() {
146150
.build());
147151
}
148152

153+
@Test
154+
public void handleFunctionCalls_longRunningToolWithEmptyResponse() {
155+
InvocationContext invocationContext = createInvocationContext(createRootAgent());
156+
Event event =
157+
createEvent("event").toBuilder()
158+
.content(
159+
Content.fromParts(
160+
Part.fromText("..."),
161+
Part.builder()
162+
.functionCall(
163+
FunctionCall.builder()
164+
.id("function_call_id")
165+
.name("empty_tool")
166+
.args(ImmutableMap.of())
167+
.build())
168+
.build()))
169+
.build();
170+
BaseTool tool =
171+
new BaseTool("empty_tool", "Long-running tool without an immediate response", true) {
172+
@Override
173+
public Maybe<Map<String, Object>> runMaybeAsync(
174+
Map<String, Object> args, ToolContext toolContext) {
175+
return Maybe.empty();
176+
}
177+
};
178+
179+
Event functionResponseEvent =
180+
Functions.handleFunctionCalls(invocationContext, event, ImmutableMap.of("empty_tool", tool))
181+
.blockingGet();
182+
183+
assertThat(functionResponseEvent).isNull();
184+
}
185+
149186
@Test
150187
public void handleFunctionCalls_multipleFunctionCalls_parallel() {
151188
InvocationContext invocationContext =

core/src/test/java/com/google/adk/tools/BaseToolTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.google.genai.types.ToolCodeExecution;
1717
import com.google.genai.types.UrlContext;
1818
import io.reactivex.rxjava3.core.Completable;
19+
import io.reactivex.rxjava3.core.Maybe;
1920
import io.reactivex.rxjava3.core.Single;
2021
import java.util.Map;
2122
import java.util.Optional;
@@ -117,6 +118,20 @@ public Single<Map<String, Object>> runAsync(
117118
.build());
118119
}
119120

121+
@Test
122+
public void runAsync_withOnlyRunMaybeAsyncOverride_returnsEmptyMap() {
123+
BaseTool tool =
124+
new BaseTool("test_tool", "test_description", /* isLongRunning= */ true) {
125+
@Override
126+
public Maybe<Map<String, Object>> runMaybeAsync(
127+
Map<String, Object> args, ToolContext toolContext) {
128+
return Maybe.empty();
129+
}
130+
};
131+
132+
assertThat(tool.runAsync(Map.of(), /* toolContext= */ null).blockingGet()).isEmpty();
133+
}
134+
120135
@Test
121136
public void processLlmRequestWithGoogleSearchToolAddsToolToConfig() {
122137
FunctionDeclaration functionDeclaration =

core/src/test/java/com/google/adk/tools/FunctionToolTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,14 @@ public void call_withMaybeMapReturnType() throws Exception {
551551
assertThat(result).containsExactly("key", "value");
552552
}
553553

554+
@Test
555+
public void runMaybeAsync_longRunningWithEmptyMaybeReturnType_returnsEmpty() throws Exception {
556+
Method method = Functions.class.getMethod("returnsEmptyMaybeMap");
557+
FunctionTool tool = new FunctionTool(null, method, /* isLongRunning= */ true);
558+
559+
assertThat(tool.runMaybeAsync(new HashMap<>(), null).blockingGet()).isNull();
560+
}
561+
554562
@Test
555563
public void create_withSingleMapReturnType() {
556564
FunctionTool tool = FunctionTool.create(Functions.class, "returnsSingleMap");
@@ -804,6 +812,10 @@ public static Maybe<Map<String, Object>> returnsMaybeMap() {
804812
return Maybe.just(ImmutableMap.of("key", "value"));
805813
}
806814

815+
public static Maybe<Map<String, Object>> returnsEmptyMaybeMap() {
816+
return Maybe.empty();
817+
}
818+
807819
public static Maybe<String> returnsMaybeString() {
808820
return Maybe.just("not supported");
809821
}

0 commit comments

Comments
 (0)