Most ADK for Java agents start with a handful of FunctionTools, one per Java method. Then requirements arrive that cut across tools: every network tool needs a timeout, some results must be cached, personal data must be stripped before the model sees it, three back-end calls should run in parallel, and some tools should exist only for some users. Copying that logic into every method does not scale. Tool composition is building those behaviours once, as objects that wrap, combine or select other tools.

This page is about composing tool objects. Deciding where a multi-step chain of calls lives (the model, callbacks, one tool, SequentialAgent or a sub-agent) is covered in Multi-Step Tool Chains in ADK for Java. Here you will see how ADK registers and dispatches a tool, four composition patterns with code, a trap in the decorator pattern that silently disables your wrapper, and a worked agent that uses all four. The API details were checked against the google/adk-java source on 2026-10-02; if you pin an older release, check the signatures there.

Advertisement

How ADK Java registers and dispatches a tool

Every tool extends BaseTool, which has a name(), a description(), an optional declaration() (the function schema the model sees), and runAsync(Map<String, Object> args, ToolContext toolContext), which returns an RxJava Single<Map<String, Object>>. FunctionTool.create(Class, String) builds one from a Java method by reflection, and reads @Schema annotations for parameter names and descriptions.

When the agent builds a model request, each tool's processLlmRequest runs. The default implementation in BaseTool adds declaration() to the request and calls appendTools(this), which stores the tool in a map keyed by name(). When the model replies with a function call, the flow passes that same map, llmRequest.tools(), to the function-call handler, which looks the tool up by the call's name and runs it.

Two consequences follow. Tool names must be unique within an agent, because the map is keyed by name. And the object that registered itself under a name is the object that runs. That second fact decides whether a wrapper works.

How a composed tool reaches the model and how its calls come backLlmAgent.tools(...)tools and toolsetsRoleScopedToolsetgetTools(ReadonlyContext)processLlmRequestdeclaration + appendTools(this)LlmRequest.tools()map: name -> BaseToolModelreturns a function call by nameCache -> Timeout -> Redactdecorators, outermost firstFunctionToolyour Java methodper requestregisterdeclarationstools.get(name)delegateThe object registered under a name is the object that runs.A wrapper that delegates processLlmRequest registers the inner tool, and the wrapper is skipped.
Tools and toolsets are resolved into one name-keyed map when each request is built. Dispatch reads that map, so composition works by controlling which object sits under each name.

Pattern 1: decorators over BaseTool

A decorator is a BaseTool that holds another BaseTool, presents the same name and declaration, and adds behaviour around runAsync. It is the classic wrapper pattern, and it suits per-tool concerns: this tool gets a five-second timeout, that one gets a cache. ADK's agent-level beforeToolCallback and afterToolCallback suit agent-wide concerns instead, such as an audit log for every call, because they see every tool without wrapping each one.

import com.google.adk.tools.BaseTool;
import com.google.genai.types.FunctionDeclaration;
import java.util.Optional;

/** Base for wrappers that add behaviour around a tool that has a function declaration. */
public abstract class ToolDecorator extends BaseTool {
  protected final BaseTool inner;

  protected ToolDecorator(BaseTool inner) {
    super(inner.name(), inner.description(), inner.longRunning());
    if (inner.declaration().isEmpty()) {
      throw new IllegalArgumentException(inner.name() + " has no function declaration");
    }
    this.inner = inner;
  }

  @Override
  public Optional<FunctionDeclaration> declaration() {
    return inner.declaration();               // the model sees exactly the inner tool's schema
  }

  // Do NOT override processLlmRequest to call inner.processLlmRequest(...).
  // The inherited version adds declaration() and registers `this` under name(),
  // so function calls are dispatched to the decorator.
}

The comment at the bottom is the important part. It is tempting to delegate every method to inner, including processLlmRequest. If you do, the inner tool's inherited implementation calls appendTools(this) with this being the inner tool, so the inner tool is registered under the shared name and receives every call. The wrapper compiles, the declaration looks right, the agent works, and the timeout, cache or redaction never runs. A unit test that calls the decorator's runAsync directly will pass, so test through an agent run instead.

The constructor refuses tools without a declaration. Some built-in tools work by overriding processLlmRequest to change the request rather than by declaring a function; wrapping those with this pattern would change their behaviour, so leave them unwrapped. Likewise, in the current source the live (bidirectional streaming) path checks tool instanceof FunctionTool to run streaming function tools, so a wrapped streaming tool loses that handling; leave those unwrapped too.

Three concrete decorators:

import com.google.adk.tools.BaseTool;
import com.google.adk.tools.ToolContext;
import io.reactivex.rxjava3.core.Single;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class TimeoutTool extends ToolDecorator {
  private final Duration limit;
  public TimeoutTool(BaseTool inner, Duration limit) { super(inner); this.limit = limit; }

  @Override
  public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
    return inner.runAsync(args, ctx)
        .timeout(limit.toMillis(), TimeUnit.MILLISECONDS)
        .onErrorReturn(e -> Map.of(
            "status", "error",
            "retryable", e instanceof TimeoutException,
            "error", name() + " did not complete within " + limit.toSeconds() + " s"));
  }
}

public final class RedactTool extends ToolDecorator {
  private final Set<String> hidden;
  public RedactTool(BaseTool inner, Set<String> hidden) { super(inner); this.hidden = hidden; }

  @Override
  public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
    return inner.runAsync(args, ctx).map(result -> {
      Map<String, Object> copy = new LinkedHashMap<>(result);
      copy.keySet().removeAll(hidden);        // top-level keys only; nest if your results nest
      return copy;
    });
  }
}

/** For read-only tools. The key includes the user, so one user never sees another's result. */
public final class CacheTool extends ToolDecorator {
  private record Entry(Map<String, Object> value, Instant expires) {}
  private final Map<String, Entry> cache = new ConcurrentHashMap<>();
  private final Duration ttl;
  public CacheTool(BaseTool inner, Duration ttl) { super(inner); this.ttl = ttl; }

  @Override
  public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
    String key = ctx.userId() + "|" + new TreeMap<>(args);
    Entry hit = cache.get(key);
    if (hit != null && hit.expires().isAfter(Instant.now())) {
      return Single.just(hit.value());
    }
    return inner.runAsync(args, ctx).doOnSuccess(result -> {
      if (!"error".equals(result.get("status"))) {   // never cache failures
        cache.put(key, new Entry(result, Instant.now().plus(ttl)));
      }
    });
  }
}

A few details matter. The timeout stops waiting; it does not stop the work. If the inner tool is a blocking Java method, the thread may keep running after the model has been told the call failed, so tools with side effects need idempotency keys before you put a timeout on them (Timing Out ADK Java Tools Safely goes deeper). The cache key includes ctx.userId() because cached data is usually per user. The cache skips results whose status is error, and in the current source FunctionTool turns an exception thrown by your method into exactly such a map, with a generic message, so thrown exceptions are not cached either.

Advertisement

Order matters when decorators stack

Decorators compose from the outside in, and different orders give different behaviour. Take cache, timeout and redaction around one lookup tool:

Order, outermost firstWhat happens
Cache, Timeout, RedactHits return instantly and skip the timeout; misses are time-limited; the cache stores redacted data. Usually what you want.
Timeout, Cache, RedactSame results, but a hit still passes through the timeout operator. Harmless, slightly wasteful.
Redact, Cache, TimeoutThe cache holds unredacted personal data in memory. Avoid.
Cache, Redact, TimeoutA timeout error map passes through redaction unharmed, but if your redaction assumes a success shape it may fail on error maps.

Write the intended order down next to the code that builds the stack, and add a test that triggers each behaviour through an agent run.

Pattern 2: a router tool

Every tool's declaration is sent with every model request, so twenty narrow tools cost tokens on every turn and give the model twenty names to choose between. When several operations share the same arguments and the same risk level, a single router tool with an operation parameter is often clearer:

import com.google.adk.tools.Annotations.Schema;
import java.util.Map;

public final class AccountTools {
  /** One tool, several read operations. The enum lives in the description and is checked here. */
  public static Map<String, Object> accountInfo(
      @Schema(name = "operation",
              description = "One of: balance, recent_transactions, statements") String operation,
      @Schema(name = "accountId", description = "Account id, e.g. ACC-77") String accountId) {
    return switch (operation) {
      case "balance" -> Accounts.balance(accountId);
      case "recent_transactions" -> Accounts.recent(accountId, 20);
      case "statements" -> Accounts.statements(accountId);
      default -> Map.of("status", "error",
          "error", "Unknown operation '" + operation
              + "'. Use one of: balance, recent_transactions, statements");
    };
  }
}

The model chooses an operation from a short list instead of a tool from a long one, and the unknown-operation error tells it exactly how to recover. Keep routers to read-only or same-risk operations. Merging refund into the same tool as balance means any policy that applies to refunds has to inspect arguments rather than tool names, and per-tool callbacks, metrics and toolset filtering can no longer tell them apart.

Pattern 3: fan-out inside one tool

When an answer needs several independent back-end calls, you can let the model call three tools, which costs three model round-trips if it calls them one at a time, or you can make one tool that calls all three in parallel. FunctionTool accepts methods that return Single or Maybe, so the method can be reactive end to end:

import com.google.adk.tools.Annotations.Schema;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

public final class SnapshotTools {
  /** Three back ends in parallel; one slow or failed source degrades the answer, not the call. */
  public static Single<Map<String, Object>> customerSnapshot(
      @Schema(name = "customerId", description = "Customer id, e.g. C-1042") String customerId) {
    return Single.zip(
        branch("orders", () -> OrdersApi.recent(customerId)),
        branch("tickets", () -> TicketsApi.open(customerId)),
        branch("billing", () -> BillingApi.status(customerId)),
        (orders, tickets, billing) -> {
          Map<String, Object> out = new LinkedHashMap<>();
          out.put("orders", orders);
          out.put("tickets", tickets);
          out.put("billing", billing);
          boolean partial = Stream.of(orders, tickets, billing)
              .anyMatch(m -> "unavailable".equals(m.get("status")));
          out.put("status", partial ? "partial" : "ok");
          return out;
        });
  }

  private static Single<Map<String, Object>> branch(String source, Callable<Map<String, Object>> call) {
    return Single.fromCallable(call)
        .subscribeOn(Schedulers.io())
        .timeout(3, TimeUnit.SECONDS)
        .onErrorReturn(e -> Map.of("status", "unavailable", "source", source));
  }
}

Each branch has its own timeout and its own fallback, so one slow source produces a partial answer instead of a failed call, and the model is told which part is missing. Schedulers.io() keeps the blocking client calls off the caller's thread. Async tool execution in ADK Java covers threading in more detail.

Pattern 4: a toolset that selects tools by context

A BaseToolset returns its tools from getTools(ReadonlyContext), and the agent resolves it when building each request, so the set of tools can depend on session state, the user or the agent. LlmAgent.Builder.tools(Object...) accepts tools and toolsets together.

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.ArrayList;
import java.util.List;

/** Shows write tools only to sessions whose role was set by the server, never by the model. */
public final class RoleScopedToolset implements BaseToolset {
  private final List<BaseTool> readTools;
  private final List<BaseTool> writeTools;

  public RoleScopedToolset(List<BaseTool> readTools, List<BaseTool> writeTools) {
    this.readTools = List.copyOf(readTools);
    this.writeTools = List.copyOf(writeTools);
  }

  @Override
  public Flowable<BaseTool> getTools(ReadonlyContext ctx) {
    List<BaseTool> visible = new ArrayList<>(readTools);
    if ("supervisor".equals(ctx.state().get("role"))) {
      visible.addAll(writeTools);
    }
    return Flowable.fromIterable(visible);
  }

  @Override
  public void close() {}                      // release clients or connections here
}

Hiding a tool is a usability and blast-radius control, not authorization. The role key must be written by server code that authenticated the user, never by a tool the model can call, or the model could promote itself. The write tool itself must still check permission when it runs, because the toolset may later be reused by another agent, a refactor may drop the filter, or the role may be stale. Treat the toolset as the first gate and the in-tool check as the one that counts.

Worked example: a support agent

A support agent answers order and account questions and lets supervisors issue refunds. All four patterns meet in its construction:

import com.google.adk.agents.LlmAgent;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.FunctionTool;
import java.time.Duration;
import java.util.List;
import java.util.Set;

BaseTool lookupOrder = new CacheTool(
    new TimeoutTool(
        new RedactTool(FunctionTool.create(OrderTools.class, "lookupOrder"),
                       Set.of("cardLast4", "billingAddress")),
        Duration.ofSeconds(5)),
    Duration.ofMinutes(2));

BaseTool snapshot = new TimeoutTool(
    FunctionTool.create(SnapshotTools.class, "customerSnapshot"), Duration.ofSeconds(8));

RoleScopedToolset refunds = new RoleScopedToolset(
    List.of(FunctionTool.create(RefundTools.class, "refundStatus")),
    List.of(FunctionTool.create(RefundTools.class, "issueRefund")));

LlmAgent support = LlmAgent.builder()
    .name("support_agent")
    .model(MODEL)
    .instruction("Answer order and account questions. Use customerSnapshot first for "
        + "an overview; use accountInfo with the operation you need.")
    .tools(lookupOrder, snapshot,
           FunctionTool.create(AccountTools.class, "accountInfo"),
           refunds)
    .build();

Trace one turn. A supervisor asks why a refund has not arrived. On the first model request, RoleScopedToolset sees role = supervisor in session state and exposes both refundStatus and issueRefund; a normal agent session would see only the first. The model calls customerSnapshot, which queries orders, tickets and billing in parallel; billing times out after three seconds, so the result is partial with billing marked unavailable, well inside the eight-second outer timeout. The model then calls lookupOrder: the cache misses, the inner method runs in under five seconds, redaction strips the card digits and address, and the redacted result is cached. When the model asks for the same order again two turns later, the cache answers without touching the order service.

Failure modes

  • Bypassed decorator: delegating processLlmRequest registers the inner tool, and the wrapper never runs.
  • Name collisions: two tools or toolsets expose the same name; tools are keyed by name, so one shadows or conflicts with the other.
  • Cross-user cache leak: a cache key without the user id serves one customer's data to another.
  • Timeout without idempotency: the model retries a write that is still running.
  • Router creep: a router absorbs operations of different risk, and policies keyed on tool names stop working.
  • Model-writable role: a toolset filters on state that a tool can set.

What to do next

  1. List your cross-cutting tool concerns and decide for each: agent-wide callback or per-tool decorator.
  2. Build one ToolDecorator base class that inherits processLlmRequest, and add a test that proves the wrapper runs inside a real agent turn.
  3. Write down the decorator order for each tool and test each behaviour.
  4. Merge same-risk read operations into router tools; keep write operations separate.
  5. Replace sequential read calls the model always makes together with one fan-out tool that returns partial results.
  6. Expose sensitive tools through a toolset keyed on server-set state, and keep the permission check inside the tool.
  7. Measure per-tool latency and outcomes as in ADK Java tool observability.
Key takeaway: ADK Java registers each tool under its name when building a request and dispatches function calls through that name-keyed map, so composition is about controlling which object sits under each name. Decorators should inherit processLlmRequest, not delegate it, or the wrapper is silently skipped. Stack decorators in a deliberate order, use router tools for same-risk reads, fan out inside one tool with per-branch timeouts, and use a BaseToolset to scope tools by server-set state without treating that as authorization.