Every tool an ADK Java agent can call, whether generated from a method, wrapped around another agent, loaded from an MCP server or written by hand, is an instance of one abstract class: com.google.adk.tools.BaseTool. Most of the time you never see it, because FunctionTool builds one from an annotated method. You meet it the moment you need a schema that reflection cannot express, a tool whose catalogue comes from configuration, a tool that changes the model request rather than declaring a function, or a set of tools that depends on who is asking.
This page explains the contract member by member: what each method defaults to, who calls it and when, and what breaks when you get it wrong. It then builds a complete hand-written tool and a toolset, traces one call end to end, and closes with failure modes and a checklist. Signatures were checked against the google/adk-java source on its main branch on 2026-10-04; if you pin an older release, compare the signatures there before copying code.
What this page owns
Neighbouring pages cover the adjacent ground, and this one does not repeat them. Writing a custom function tool walks through FunctionTool.create step by step. Tool composition patterns covers decorators over BaseTool and the trap of delegating processLlmRequest. Tool dispatch mechanics follows a function call through the runtime, and the tools architecture page covers schema generation from Java types. This page owns the contract those pages build on.
The contract, member by member
BaseTool is an abstract class, not an interface. It has two protected constructors, BaseTool(String name, String description) and BaseTool(String name, String description, boolean isLongRunning), and none of its methods is abstract: every one has a default, which is why a subclass that forgets one compiles and fails later.
| Member | Default | Who calls it, and when |
|---|---|---|
name() | constructor value | Request building and dispatch; the key in the tool map |
description() | constructor value | Your declaration usually reuses it for the model |
longRunning() | false | Framework and clients, to treat the first response as interim |
declaration() | Optional.empty() | Default processLlmRequest, once per model request |
processLlmRequest(LlmRequest.Builder, ToolContext) | add declaration, register this | Flow, before each model call |
runAsync(Map<String, Object>, ToolContext) | throws UnsupportedOperationException | Dispatch, once per function call |
customMetadata() | empty map | Free-form metadata for your own code |
Two defaults deserve attention. The default processLlmRequest begins with if declaration() is empty, return. Only after that check does it call appendTools(ImmutableList.of(this)), which puts the tool in the name-keyed map used to dispatch calls, and merge the declaration into the request's function declarations. A tool without a declaration is therefore invisible to dispatch. That is correct for tools that work by editing the request, and a silent bug for a function tool that forgot to override declaration(). Second, the default runAsync throws synchronously instead of returning a failed Single, so a missing override surfaces as an exception thrown from the dispatch call rather than as an error result.
BaseTool also has generic runAsync overloads that take a typed argument object and an ObjectMapper. They are conveniences for calling a tool from your own code; the model path uses the Map form, and that is the one to override.
The life of a tool across one model call
The sequence matters because each step runs at a different time. Construction happens once, when you build the agent. canonicalTools runs while each model request is assembled, expanding toolsets with the current read-only context. processLlmRequest runs for every tool on every model call, so it must be cheap: build the declaration once, in the constructor, not inside declaration(). runAsync runs only when the model actually calls the tool, possibly several times per turn and possibly concurrently with other tools from the same response.
A hand-written tool
Here is a complete tool written directly against BaseTool. It declares its own schema, validates arguments, keeps blocking I/O off the calling thread and returns errors as data.
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.ToolContext;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.Schema;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import java.util.*;
public final class OrderStatusTool extends BaseTool {
private final OrderStore store;
private final FunctionDeclaration decl; // built once, reused per request
public OrderStatusTool(OrderStore store) {
super("get_order_status",
"Look up the current status of one order by its ID. "
+ "Use when the user asks where an order is or when it will arrive.");
this.store = store;
this.decl = FunctionDeclaration.builder()
.name(name())
.description(description())
.parameters(Schema.builder()
.type("OBJECT")
.properties(Map.of(
"order_id", Schema.builder().type("STRING")
.description("Order ID, for example A-1042").build(),
"include_items", Schema.builder().type("BOOLEAN")
.description("Also return line items. Defaults to false.").build()))
.required(List.of("order_id"))
.build())
.build();
}
@Override
public Optional<FunctionDeclaration> declaration() {
return Optional.of(decl);
}
@Override
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
return Single.defer(() -> {
Object raw = args.get("order_id");
if (!(raw instanceof String id) || !id.matches("[A-Z]-\\d{1,8}")) {
return Single.just(error("invalid_argument",
"order_id must look like A-1042", false));
}
boolean items = Boolean.TRUE.equals(args.get("include_items"));
return Single.fromCallable(() -> lookup(id, items, ctx))
.subscribeOn(Schedulers.io()); // JDBC or HTTP lives here
})
.onErrorReturn(e -> error("unavailable",
"order lookup failed: " + e.getClass().getSimpleName(), true));
}
private Map<String, Object> lookup(String id, boolean items, ToolContext ctx) {
Optional<Order> order = store.find(id);
if (order.isEmpty()) {
return error("not_found", "no order " + id + " for this account", false);
}
ctx.state().put("last_order_id", id); // visible to later turns
Map<String, Object> out = new HashMap<>(); // HashMap: values may be null
out.put("status", "ok");
out.put("order_id", id);
out.put("state", order.get().state());
out.put("eta", order.get().eta().map(Object::toString).orElse(null));
if (items) out.put("items", order.get().itemSummaries());
return out;
}
private static Map<String, Object> error(String code, String msg, boolean retryable) {
return Map.of("status", "error", "code", code, "message", msg, "retryable", retryable);
}
}Each choice maps to a rule. Single.defer means nothing runs until subscription, and any exception thrown while validating becomes an error the onErrorReturn converts into a result map. The model receives not_found or invalid_argument with a message it can act on, and retryable tells it whether calling again could help. Arguments arrive as parsed JSON, so never cast blindly: a number may arrive as Integer, Long or Double depending on its value and the parser, and a missing optional field is simply absent.
What ToolContext gives a tool
ToolContext extends CallbackContext, which extends ReadonlyContext, so a tool sees the invocation from three layers. The parts you will use most:
functionCallId(): the ID of the call being answered, anOptional<String>. Log it and use it as an idempotency key when the tool has side effects.state(): the session state as a delta-awareState; writes are recorded with the tool's event and persisted by the session service.requestConfirmation(String hint, Object payload)andtoolConfirmation(): ask a human to confirm an action and read the answer on the next call.searchMemory(String query)and the artifact methods inherited from CallbackContext: reach long-term memory and files attached to the session.userId(),sessionId()andagentName(): identity for authorisation and auditing. Authorise against these, never against an ID the model supplied in arguments.
Long-running tools and request-editing tools
The third constructor argument marks a tool as long-running, and longRunning() reports it. The flag is a contract, not a mechanism: it does not make runAsync asynchronous, extend a timeout or poll anything. It says that the first response is interim. Design the response that way: return quickly with a job ID and a state such as pending, and arrange for the final outcome to reach the agent later, typically by your client sending it back in a later message once the job completes. A tool that blocks for minutes inside runAsync is not long-running in this sense; it is just slow, and it holds the turn open.
The other override worth knowing is processLlmRequest itself. Some tools contribute no function at all; they edit the outgoing request, for example to enable a model-side capability or to add instructions. Such a tool returns an empty declaration and overrides processLlmRequest to change the builder. If you override it in a function tool, call super.processLlmRequest(builder, ctx) first, or the tool will never be registered for dispatch.
Toolsets
When the tools an agent should see depend on context, implement BaseToolset. It is an interface extending AutoCloseable with one required method, getTools(ReadonlyContext), a close() for releasing resources, an optional processLlmRequest, and a default isToolSelected helper that accepts a ToolPredicate or a list of names as a filter.
import com.google.adk.agents.ReadonlyContext;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.BaseToolset;
import io.reactivex.rxjava3.core.Flowable;
import java.util.List;
public final class OrderToolset implements BaseToolset {
private final List<BaseTool> readTools; // e.g. OrderStatusTool
private final List<BaseTool> writeTools; // e.g. CancelOrderTool
private final AutoCloseable pool; // shared connection pool
public OrderToolset(List<BaseTool> readTools, List<BaseTool> writeTools, AutoCloseable pool) {
this.readTools = readTools; this.writeTools = writeTools; this.pool = pool;
}
@Override
public Flowable<BaseTool> getTools(ReadonlyContext ctx) {
boolean agent = ctx != null && "support_agent".equals(ctx.state().get("role"));
return agent
? Flowable.fromIterable(readTools).concatWith(Flowable.fromIterable(writeTools))
: Flowable.fromIterable(readTools);
}
@Override
public void close() throws Exception {
pool.close();
}
}
// Registration: tools(...) accepts BaseTool and BaseToolset instances together.
LlmAgent agent = LlmAgent.builder()
.name("orders")
.model("gemini-2.5-flash")
.instruction("Help customers with their orders.")
.tools(new OrderToolset(List.of(status), List.of(cancel), pool))
.build();Treat a null context as the least privileged case, because the expansion method accepts one. Hiding a tool from the model is a usability control, not a security boundary: the write tools should still check authorisation inside runAsync against the context identity.
Worked example: one call, end to end
Trace one call through the tool above. A user asks where order A-1042 is. While the request is built, the toolset yields the read tools for this customer, and OrderStatusTool's inherited processLlmRequest finds a declaration, registers the tool under get_order_status, and merges its schema into the request's function declarations. The model replies with a function call named get_order_status, arguments {"order_id": "A-1042"} and a call ID.
Dispatch looks the name up in the map, finds the same instance, and subscribes to runAsync. Validation passes, the lookup runs on an I/O thread, state records the order ID, and the Single emits {"status": "ok", "order_id": "A-1042", "state": "SHIPPED", "eta": "2026-10-06"}. The runtime wraps that map in a function response tied to the call ID and calls the model again, which answers the user in prose. Had the user typed a malformed ID, the model would have seen invalid_argument with a format hint and could ask for a correction instead of guessing.
Failure modes
These are the ways a hand-written tool fails, roughly in order of how often they appear.
- Declaration missing.
declaration()not overridden or returns empty; the model never sees the tool and nothing errors. Assert in a unit test that it is present. - runAsync not overridden. The default throws
UnsupportedOperationExceptionat dispatch. Same test catches it. - Name collisions. Two tools with one name share one map key, and only one of them runs. Keep names unique per agent, and stick to letters, digits and underscores so every model provider accepts them.
- Blocking on the caller thread. JDBC or HTTP called directly inside
runAsyncstalls the flow and other tools. UsesubscribeOn(Schedulers.io())or a dedicated scheduler. - Nulls in Map.of.
Map.ofrejects null values with aNullPointerException; build results that may contain nulls with a HashMap. - Throwing instead of returning. An exception escapes as a framework error the model cannot reason about. Convert expected failures to result maps; reserve exceptions for bugs.
- Schema and code disagree. The schema says integer, the code reads a String. Generate the declaration and the validation from one definition, or test both together.
- Expensive declaration. Rebuilding the schema in
declaration()runs on every model call. Build it once.
Trade-offs
| Option | Gives you | Costs you | Use when |
|---|---|---|---|
| FunctionTool from a method | Schema from reflection, least code | Less control over schema | Most tools |
| Hand-written BaseTool | Exact schema, custom request handling | More code to test | Dynamic schemas, request editing |
| BaseToolset | Per-request tool lists, shared resources | Indirection | Role or tenant scoped catalogues |
| Decorator over a tool | Cross-cutting behaviour per tool | The delegation trap | Timeouts, caches, redaction |
Start with FunctionTool, and drop to BaseTool only when its schema or behaviour is what you need to control. For testing tools without a live model, the BaseLlm interface page explains how to put a scripted model behind an agent so the whole path above runs in a unit test.
What to do next
- List your tools and mark which need a hand-written BaseTool; keep the rest as FunctionTool.
- For each hand-written tool, build the FunctionDeclaration once in the constructor and override declaration() and runAsync(Map, ToolContext).
- Wrap runAsync in Single.defer, validate every argument, move I/O to an I/O scheduler, and return errors as maps with a code, a message and a retryable flag.
- Use functionCallId() as an idempotency key for side effects and authorise against userId(), not arguments.
- Add a unit test asserting declaration().isPresent() and a valid schema for every tool, and an agent-level test with a scripted model.
- Move context-dependent catalogues into a BaseToolset that treats a null context as least privileged and closes its resources.
- Mark long-running tools only when their first response is truly interim, and design the follow-up path before shipping.