A single function call is easy: the model asks for a tool, ADK runs your Java method, and the model writes an answer. Real agents rarely stop there. They call two lookups at once, feed the results into a third call, need a human to approve a risky step, wait hours for an external process, delegate a subtask to another agent, and must be stopped before they loop forever. Each of those moves is supported by the Agent Development Kit (ADK) for Java, and each has its own failure modes.
This article covers that advanced layer, using one worked example, an expense-claim agent, throughout. It assumes you know the basics of declaring a tool, which are in writing a custom function tool, and the model-side protocol of declarations, call ids and tool config, which is in Gemini function calling in ADK Java. How the runtime resolves and invokes each call is in tool dispatch mechanics. API names below were checked against the ADK Java source on GitHub in October 2026; ADK Java changes quickly, so confirm them against the version in your build.
The loop that every advanced pattern rides on
An ADK invocation is a loop. The runner sends the conversation and the tool declarations to the model. If the response contains function call parts, ADK executes them, appends the function responses to the session as an event, and calls the model again. When the model returns text with no function calls, that event is the final response and the loop ends. Everything in this article is a variation on that loop: several calls in one pass, a call whose input depends on an earlier pass, a call that pauses the loop, or a callback that answers instead of the tool.
Two consequences follow. Every pass is a model call with its own latency and token cost, so the number of passes, not the number of tools, usually dominates cost. And the model, not your code, decides the order of calls, so any ordering requirement must be enforced in the tool or a callback, not only stated in the instruction.
Parallel calls and how ADK executes them
When calls do not depend on each other, the model can return several function call parts in one response. Each carries an id, and each function response must carry the same id so the model can match results to calls. In the source as of this writing, the RunConfig builder has a toolExecutionMode setting with the values NONE (treated as PARALLEL), SEQUENTIAL, PARALLEL and PARALLEL_SUBSCRIBE. SEQUENTIAL runs tools strictly in request order on the caller thread. PARALLEL subscribes to the tool calls eagerly on the caller thread, which overlaps tools that are themselves asynchronous but does not give blocking tools their own threads. PARALLEL_SUBSCRIBE additionally subscribes each tool on a worker thread, which is what gives plain blocking Java methods real concurrency. Check that your ADK version has this setting before relying on it.
Parallel execution makes shared state dangerous. Two tools that both read a session state key, modify it and write it back can lose an update. Give each call its own key, for example suffixed with the function call id, or keep shared state in a store with atomic operations. Do not assume the responses come back in the order the calls were issued; match them by id.
Chained calls: a worked example
In the expense agent, filing a claim needs the employee id and the policy limit, which come from two other tools. The model calls lookupEmployee and getPolicy in parallel in the first pass, reads both results, and calls fileClaim in the second pass. The tools are ordinary static methods:
public class ExpenseTools {
@Schema(description = "Look up an employee by email. Returns employeeId, grade and costCenter.")
public static Map<String, Object> lookupEmployee(
@Schema(name = "email", description = "Work email address") String email) {
Employee e = Directory.byEmail(email);
if (e == null) return Map.of("status", "error", "message", "No employee with email " + email);
return Map.of("status", "ok", "employeeId", e.id(), "grade", e.grade(), "costCenter", e.costCenter());
}
@Schema(description = "Get the expense limit in cents for a grade and category such as travel or meals.")
public static Map<String, Object> getPolicy(
@Schema(name = "grade", description = "Grade code, e.g. G5") String grade,
@Schema(name = "category", description = "travel, meals or equipment") String category) {
return Map.of("status", "ok", "limitCents", Policy.limit(grade, category));
}
@Schema(description = "File an expense claim. Call only after lookupEmployee and getPolicy.")
public static Map<String, Object> fileClaim(
@Schema(name = "employeeId", description = "From lookupEmployee") String employeeId,
@Schema(name = "receiptId", description = "Receipt reference from the user") String receiptId,
@Schema(name = "amountCents", description = "Positive amount in cents") long amountCents,
@Schema(name = "limitCents", description = "From getPolicy") long limitCents,
@Schema(name = "toolContext") ToolContext toolContext) {
if (amountCents > limitCents) { // over policy: a human must approve
Optional<ToolConfirmation> confirmation = toolContext.toolConfirmation();
if (confirmation.isEmpty()) {
toolContext.requestConfirmation(
"Claim of " + amountCents + " cents exceeds the limit of " + limitCents,
Map.of("employeeId", employeeId, "amountCents", amountCents));
return Map.of("status", "pending_approval");
}
if (!confirmation.get().confirmed()) {
return Map.of("status", "rejected", "message", "Approver declined the over-limit claim.");
}
}
String key = employeeId + ":" + receiptId; // business key: a model retry gets a new call id
return ClaimService.file(employeeId, amountCents, key); // idempotent on key
}
}Three details carry the design. Each tool returns a status field, so the model can tell an error from an empty answer. The fileClaim description says which tools to call first, because descriptions are what the model reads when deciding. And fileClaim does not trust the model to have passed a correct limit for the grade: in production it should look the limit up again itself rather than accept limitCents from the model, which is shown as a parameter here only to make the chain visible. Anything the model passes can be wrong, stale or invented.
LlmAgent agent = LlmAgent.builder()
.name("expense_agent")
.model(System.getenv("ADK_MODEL"))
.instruction("""
Help employees file expense claims.
Call lookupEmployee and getPolicy first; they do not depend on each other.
Then call fileClaim once with values from their results.
If a tool returns status=error, explain the problem and stop.""")
.tools(
FunctionTool.create(ExpenseTools.class, "lookupEmployee"),
FunctionTool.create(ExpenseTools.class, "getPolicy"),
FunctionTool.create(ExpenseTools.class, "fileClaim"))
.beforeToolCallback(ToolGuards::beforeTool)
.afterToolCallback(ToolGuards::afterTool)
.build();
RunConfig runConfig = RunConfig.builder()
.maxLlmCalls(8) // hard ceiling on model passes for this invocation
.build();Each extra hop costs a model pass, so when two calls are always made together, merge them into one tool. A getClaimContext tool that returns employee and policy in one result turns a three-pass chain into two. Keep separate tools only where the model genuinely needs to choose.
Human confirmation before side effects
Some calls should not run without a person saying yes. ADK Java offers two ways. The static way is a FunctionTool.create overload with a requireConfirmation flag, for example FunctionTool.create(ExpenseTools.class, "fileClaim", true), which asks for confirmation before every call. The dynamic way, used above, decides inside the tool: when there is no confirmation yet, call toolContext.requestConfirmation(hint, payload) and return; when the tool runs again, toolContext.toolConfirmation() returns an Optional whose confirmed() and payload() carry the decision.
On the client side, the confirmation request arrives as a function call event named adk_request_confirmation. Your user interface shows the hint, and sends back a function response with the same id and name, whose response holds confirmed set to true or false and an optional payload, for instance an approved amount that differs from the requested one. ADK's documentation notes some session-service and resume restrictions for this feature; check the confirmation page for your version before depending on it with a persistent session store.
Use dynamic confirmation for thresholds, as here, so routine claims flow without friction and only exceptions reach a human. Make the hint self-contained: the approver may see it hours later, outside the conversation.
Long-running tools: pause the loop, resume later
Some operations take minutes or days: a manager approval in another system, a batch export, a payment that settles later. Blocking a thread for that long is wrong. LongRunningFunctionTool.create(Class, methodName) wraps a method whose first return value is an initial status, such as a ticket id and pending. ADK marks the call id in the event's longRunningToolIds() and the model can tell the user the work has started. Your application stores the session id and call id, and when the external system finishes it resumes the conversation by sending a function response with the same id and name:
// The tool returned {"status":"pending", "ticketId": ...} and the event listed its call id in
// longRunningToolIds(). Hours later the approval system calls back; resume with the SAME id and name.
FunctionResponse done = FunctionResponse.builder()
.id(savedCallId) // from the original functionCall
.name("askForApproval")
.response(Map.of("status", "approved", "ticketId", ticketId, "approver", approver))
.build();
Content resume = Content.fromParts(Part.builder().functionResponse(done).build());
runner.runAsync(userId, sessionId, resume)
.blockingForEach(event -> log.info("event {} final={}", event.id(), event.finalResponse()));The model then sees the final status as the tool's result and continues. The important property is that nothing is held in memory between the two halves; the session store holds the conversation, and your own table maps tickets to session and call ids. Make the resume path idempotent too: approval systems retry webhooks, and a duplicate resume should be detected by ticket id before it reaches the runner.
Agents as tools, and callbacks as tools
AgentTool.create(agent) wraps another agent so that the parent calls it like a function and receives its final answer as the result. The sub-agent's intermediate steps stay out of the parent's context, which keeps the parent's prompt short and lets the sub-agent have its own instruction and tools. AgentTool.create(agent, true) skips the extra summarisation pass on the result. Use this for bounded subtasks, such as a policy-checking agent, where the parent should stay in charge; use transfer when the other agent should take over the conversation.
Tool callbacks are the other powerful lever. A before-tool callback receives the invocation context, the tool, the arguments and the tool context, and returns a Maybe of a map. Empty means run the tool; a value means skip the tool and use that value as its result. An after-tool callback also receives the response and can replace it. The guard below caches policy lookups and rejects invalid amounts before the tool body runs:
public final class ToolGuards {
private static final Cache<String, Map<String, Object>> POLICY_CACHE =
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(10)).build();
// Signature matches Callbacks.BeforeToolCallback: a non-empty Maybe becomes the tool result.
public static Maybe<Map<String, Object>> beforeTool(
InvocationContext ctx, BaseTool tool, Map<String, Object> args, ToolContext toolContext) {
if (tool.name().equals("getPolicy")) {
Map<String, Object> hit = POLICY_CACHE.getIfPresent(String.valueOf(args));
if (hit != null) return Maybe.just(hit); // skip the body, reuse the answer
}
if (tool.name().equals("fileClaim")) {
Object amount = args.get("amountCents");
if (!(amount instanceof Number n) || n.longValue() <= 0) {
return Maybe.just(Map.of("status", "error", "message", "amountCents must be a positive integer"));
}
}
return Maybe.empty(); // run the tool normally
}
public static Maybe<Map<String, Object>> afterTool(
InvocationContext ctx, BaseTool tool, Map<String, Object> args,
ToolContext toolContext, Object response) {
if (tool.name().equals("getPolicy") && response instanceof Map<?, ?> m && "ok".equals(m.get("status"))) {
@SuppressWarnings("unchecked") Map<String, Object> r = (Map<String, Object>) m;
POLICY_CACHE.put(String.valueOf(args), r);
}
return Maybe.empty(); // keep the original response
}
}Callbacks are the right place for cross-cutting rules: validation, caching, allow-lists by user role, redaction and metrics. Keep them fast and deterministic, because they run on every call. Callbacks are covered in more depth in ADK Java callbacks.
Budgets and loop control
A model that keeps calling tools never produces a final answer. RunConfig's maxLlmCalls caps the number of model passes per invocation; the default in the source is 500, which is far too high for an interactive agent. Set it from the longest legitimate chain plus a small margin, here eight, and handle the exception or error event that ADK raises at the limit by returning a clear message rather than a stack trace. Also count tool calls per name in a callback and refuse after a threshold: repeated identical calls are the usual symptom of a tool whose error message the model cannot act on.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Claim filed twice | Model retried after a timeout; tool not idempotent | Key on business fields; a retry has a new call id |
| Wrong result matched to a call | Responses matched by position, not id | Always match by function call id |
| Lost update in session state | Parallel tools writing the same key | Per-call keys or atomic store operations |
| Agent loops until the call limit | Tool error is vague, so the model retries | Return status plus an actionable message; cap per-tool calls |
| Resume does nothing | Function response id or name differs from the original call | Store and reuse the exact id and name |
| Over-limit claim filed without approval | Limit taken from model arguments | Recompute limits inside the tool |
| Blocking tools still run one at a time | Execution mode subscribes on the caller thread | Use an asynchronous tool or PARALLEL_SUBSCRIBE where available |
What to do next
- Draw your agent's longest legitimate chain of calls and count the model passes; merge tools that are always called together.
- Give every tool a status field and actionable errors, and make side-effecting tools idempotent on a business key, not the call id.
- Add dynamic confirmation for thresholds and test the client's adk_request_confirmation round trip, including a rejection.
- Move any operation longer than a few seconds to a long-running tool, with a ticket table and an idempotent resume path.
- Put validation, caching and per-tool call caps in before and after tool callbacks.
- Set maxLlmCalls to a small number and handle the limit gracefully.
- Check toolExecutionMode and the other APIs used here against your ADK Java version, then load-test with parallel calls.