A model does not call your Java method. It emits a name and a JSON object, and something in between has to turn that into a real invocation on a real thread and turn whatever comes back into something the model can read on the next pass. That something is the dispatcher, and it is where most agent bugs that look like model bugs actually live: a name that resolves to nothing, an argument that binds to a default instead of a value, three calls that raced each other through the same session state, an exception that reached the model as a stack trace and taught it to retry forever. This page walks that span end to end.
What this page owns, and where the neighbours start
Dispatch is one stage of a larger circuit, and the rest of the circuit is already documented. Anatomy of the execution loop owns the pass structure: how a payload is assembled, where the model call sits, and the branch that decides whether there is another iteration. ADK runtime architecture owns the Runner and the shape of a whole invocation. Java agent tools architecture owns the other side of the boundary: how a Java signature becomes a JSON Schema, and the erasure and boxing traps that make a generated declaration lie. Timing out ADK Java tools safely owns deadlines, interruption, and cleanup, so this page mentions timeouts only where they change what the dispatcher does with the result.
What is left is the span in between, and it is narrower than the loop and wider than any single tool: a function call arrives as data, and by the time this stage is done a function response is sitting in the session waiting to be replayed into the next model payload. Everything below is about that span. Not how to design a tool -- that is ADK tool design patterns -- and not how to organise a catalogue of them, which is its own subject. Only the mechanics of one call being resolved, bound, run, and converted.
The framing that makes the rest of the page cohere: dispatch is a translation layer between two type systems that do not trust each other. On one side is a model that invents arguments. On the other is a JVM that will happily throw. Every mechanism below exists to keep one from corrupting the other.
The dispatch unit: one function call, and the id that ties it back
The dispatcher does not receive a request. It receives a part of a model response: a function call carrying a name, an arguments object, and -- this is the part people skip -- an identifier. That triple is the entire input. There is no method handle in it, no type information, no reference to any Java object. Everything the runtime is about to do it does by looking things up from a string.
The identifier is what correlates the eventual result back to the request that produced it. It matters the moment a single model response contains more than one call, because at that point matching by tool name is ambiguous: a model that asks for lookup_order twice with different arguments produces two calls with the same name and different ids. Any code you write that pairs results to requests -- a progress UI, a trace exporter, an approval gate that holds one call while others proceed -- must key on the id. Keying on the name works in every test with one tool and fails the first time a model batches.
The corollary is that the id is not yours to invent. It arrives with the call, it travels with the response part, and providers reject a response whose id does not match an outstanding call. If you are synthesising a function response by hand -- replaying a persisted turn, injecting a cached answer, resuming after a human approval -- you must carry the original id through whatever storage sat in the middle. A resumed turn that lost its ids is a turn the provider will refuse, and the error surfaces as a confusing 400 from the model call on the next pass, nowhere near the code that dropped it.
Resolving a name at dispatch time
Resolution is a lookup by exact string against the set of tools this agent declared, and it is worth being precise about which set that is. The declarations shipped in the request came from the agent that ran this pass -- the tools on the LlmAgent that was active, not every tool wired into the process. When a router or a transfer has moved control to a different agent, the names that resolve change with it, which is why a tool that works from one entry point returns an unknown-tool error from another. Agent router architecture covers the routing hop; the consequence here is simply that the resolution namespace is per-agent and per-pass.
Three things share that namespace and can collide with your tools. Transfer and control pseudo-tools, injected by the framework so the model can hand off or escalate. Built-in provider-side capabilities, which are declared like tools but never dispatched locally. And agents exposed as tools, which resolve to a nested invocation rather than a method. If you name a tool something generic like search or transfer, you are competing for a slot in that namespace, and the failure is silent: the model picks the wrong one and the trace looks like a reasoning error.
A miss is not an exception. The model asked for a name that does not exist -- almost always because it hallucinated a plausible one, or because a declaration was removed while a conversation was mid-flight -- and the correct handling is to convert that into a function response saying so and let the loop continue. Throwing kills a turn that the model could have recovered from in one pass. The one refinement worth adding is a hint: returning the list of names that do exist turns a guess-again into a corrected call, usually immediately.
From a JSON object to Java arguments
The arguments arrive as an untyped map. Binding them to your method parameters runs through a JSON binder, and that binder is the single most under-instrumented component in the whole path. It is where a string "12" becomes an int, where an absent key becomes null or a primitive zero, where an unknown key is either dropped or fatal depending on configuration you probably never set.
Two rules keep this boundary honest. First, take nullable boxed types and apply defaults in the method body rather than relying on the binder, because a primitive cannot represent absent and a zeroed limit or amount is legal and catastrophic. Second, do the semantic check yourself and return the failure as data. Schema validation proves the shape; it does not prove that the id exists, that the range is sane, or that the two dates are in order. Java agent tools architecture goes deep on the schema-generation traps that make binding fail upstream -- erased generics, missing -parameters, bare maps -- so treat this as the runtime-side half of that story.
// Binding is the boundary. Fail as data, never as a throw.
Map<String, Object> lookupOrder(Map<String, Object> args, ToolContext ctx) {
Integer limit = (Integer) args.get("limit"); // boxed: null means absent
if (limit == null) limit = 20; // documented default, visible here
if (limit < 1 || limit > 100) {
return Map.of("status", "invalid_argument",
"field", "limit",
"expected", "integer between 1 and 100");
}
...
}The asymmetry to internalise: a binding failure that throws costs a turn, while a binding failure that returns costs one extra pass and usually succeeds. The model reads the second one and fixes its own arguments.
Several calls in one response: order, concurrency, and shared state
A model can request several tools at once, and when it does they arrive together inside a single event rather than as separate ones -- a fact the execution loop page establishes and this page starts from. What the dispatcher does with those calls is the question here, and the answer has real consequences.
Running them sequentially is the conservative default: predictable ordering, no concurrency hazards, and a latency bill that is the sum of the parts. Running them concurrently turns that sum into a maximum, which is the whole point when a model asks for three independent lookups. The cost is that concurrency across a batch is a decision you are making about tools the model happened to name together -- and the model has no concept of whether they are independent. It will cheerfully batch a read and a write against the same record.
The hazard is shared session state. If two tools in one batch both stage a state change, last-writer-wins applies and nothing warns you. Two mitigations are worth the effort. Keep concurrent tools state-free, doing their mutation through a return value the loop applies afterwards rather than through the context mid-flight. And bound the fan-out: a model that asks for eleven calls in one response should not open eleven connections, so cap the batch and run the remainder in the next pass. Ordering of the emitted responses is a separate matter -- the results must be assembled in a stable order regardless of completion order, because the next payload replays them as history and a payload that reorders itself between retries defeats prompt caching for no benefit.
What the invocation carries besides arguments
A tool that only sees its arguments is a tool that cannot do anything conversational. The second parameter -- the tool context -- is the seam through which the rest of the invocation reaches the method body, and taking it costs nothing when unused.
Four things travel on it and each answers a question arguments cannot. Identity and scope: which invocation, which session, which user, so a tool can assert the tenant it is operating on rather than trusting an argument the model produced. Session state, readable and writable, with writes recorded as a delta rather than applied in place. Service handles for artifacts and memory, so a tool that produces a large binary result stores it and returns a reference. And the actions object, which is how a tool signals something to the loop rather than to the model -- the clearest example being the flag that suppresses the extra model round that would otherwise summarise the result.
The rule that catches Java teams specifically: nothing arrives by ambient context. A tool body does not run on the thread that accepted the HTTP request, so a ThreadLocal-backed security context, an MDC logging context, or a transaction bound to the inbound thread is empty or wrong inside a tool. Whatever identity matters must be captured at invocation time, put on the context explicitly, and read from there -- see authorization at the agent boundary for the policy half. The threading reason it is empty is the next section.
The interception points around the call
Two hooks bracket the invocation, and they are the correct home for everything that is policy rather than logic. The before-hook sees the resolved tool and the bound arguments, and its return value is a control signal: return nothing and the tool runs; return a result and the tool is skipped entirely, with your value taking its place as the function response. That single property is what makes it the mechanism behind three different features that look unrelated.
A cache is a before-hook that returns a stored value on a hit. A policy gate is a before-hook that returns a denial for arguments outside what this user may touch, so the tool never runs and the model reads a refusal it can explain. A dry-run mode is a before-hook that returns a plausible synthetic result for every write tool, which is how you exercise an agent against production data without side effects. All three are the same mechanism seen from different angles; if you find yourself adding an if (config.dryRun) inside a tool body, you are reimplementing this hook badly.
The after-hook sees the result and can replace it. Its honest uses are narrow: redaction before the value reaches the model, truncation of an oversized payload, and normalising an error shape. It is a bad place for retries, because a retry here is invisible to both the loop and the model, so a tool that took four attempts reports as one slow call. The ADK Java callback article and the callback architecture page own the full hook lattice; what matters here is that both hooks run on the dispatch path and their latency is charged to the tool.
Which thread the tool body actually runs on
The runtime is reactive: agent methods return RxJava types, and the event stream a caller subscribes to is a Flowable. That single design choice determines where your tool body executes, and getting it wrong is the most common production incident in this whole path.
A tool that blocks -- a JDBC query, a synchronous HTTP client, a file read -- occupies its thread for the full duration. If that thread belongs to a small computation-oriented scheduler, a handful of concurrent turns exhausts the pool and the symptom is not an error but a queue: latency climbs, nothing logs, and CPU sits idle. Blocking work belongs on a scheduler sized for blocking work, declared explicitly at the tool rather than inherited from whatever thread happened to deliver the call. On a modern JVM the virtual-thread route makes the blocking style cheap again, which is the pragmatic answer for tools that are mostly waiting on I/O.
Two consequences follow that are easy to miss. Cancellation is cooperative: when a subscriber goes away or a deadline fires, the runtime signals, and a tool sitting in an uninterruptible loop keeps running and keeps holding its connection -- the timeout article is the full treatment. And per-tool isolation is not free: one slow downstream will starve every other tool sharing its pool unless you give it its own, which is the argument for bulkheads at the dispatch boundary rather than one global executor for everything.
Turning a Java exception into something the model can read
Whatever the tool body does, the dispatcher must produce a function response. There is no other exit. So every exception has to be converted, and the shape of that conversion is a prompt-engineering decision disguised as error handling -- because the converted value goes straight into the model context and drives the next pass.
Three failure classes deserve different shapes. An argument problem is the model's mistake and should say exactly which field and what was expected, because that is repairable in one pass. A downstream failure is nobody's mistake and should say whether retrying is worthwhile; a model told "retryable": false will explain the problem to the user instead of hammering, and a model told nothing will hammer. An internal defect -- a null dereference in your own code -- should be logged in full and reported to the model as an opaque failure with an incident id, because the model cannot fix your bug and does not need your package names.
The anti-pattern is passing the exception message through. A stack trace in the model context is tokens you pay for, an information leak that names your internals, and an invitation for the model to invent a workaround around a class it should not know exists. The related trap is the retry storm: an error that reads as transient with no signal to the contrary produces a model that calls the same tool with the same arguments until something intervenes. Since nothing in the loop is time-based, the intervention has to be yours -- an iteration ceiling, or a repeated-failure detector that flips the response to terminal after the second identical call. Idempotency is what makes those retries safe when they do happen.
The response part, its size, and what the next pass sees
A successful call ends as a function-response part appended to the session, carrying the correlating id and a structured value. From that moment the result is history: every subsequent pass in the turn rebuilds its payload from the accumulated events, so the tool result is not sent once -- it is re-sent on every remaining iteration of the turn.
That fact prices result size, and the price is worse than it looks. A tool that returns a 200-row result set on pass two of a six-pass turn ships those rows five more times. The habit that fixes it is to return a summary plus a handle: the counts and the top few records inline, the full payload written to the artifact service, and a reference the model can pass to a follow-up tool if it actually needs the detail. Most of the time it does not, because the model wanted an answer rather than a dataset.
Two behaviours are worth knowing at this seam. By default the runtime will typically send the result back through the model so it can be narrated in natural language, which costs a round; a tool whose output is already the final answer -- a rendered receipt, a preformatted table -- should set the skip-summarization signal on the context and save it. And a tool that suspends rather than completes, waiting for a human approval or a long external job, leaves the invocation open with the response part not yet written, which is precisely why the correlating id has to survive whatever persistence sits in the gap. The streaming article covers what the subscriber sees while that gap is open.
Instrumenting one dispatch, and the failures worth a runbook entry
Dispatch deserves its own span, distinct from both the enclosing pass and the tool body. The attributes that pay for themselves: tool name, the correlating call id, argument byte size, result byte size, outcome class, and whether a before-hook short-circuited the call. With those six you can answer the questions that actually come up -- which tool is slow, which tool is called with arguments that keep failing validation, which tool returns payloads large enough to be inflating every subsequent pass. ADK Java observability owns the trace-tree shape; the point here is only that tool latency and dispatch latency are different numbers, and the difference is your hooks, your binder, and your queue wait.
Four failure modes recur often enough to write down. Unknown tool loops: the model asks for a name that does not exist, gets a bare error with no list of alternatives, and guesses again -- fix by returning the valid names. Silent default binding: a primitive parameter turns an absent argument into zero and the tool does something plausible and wrong -- fix with boxed types and explicit defaults. Pool starvation: blocking tools on a computation scheduler, visible as rising latency with idle CPU -- fix by placing blocking work on its own scheduler or on virtual threads. Context re-transmission: an oversized result replayed on every later pass, visible as token cost that grows superlinearly in turn length -- fix by summarising and offloading.
All four share a diagnostic signature: they present as bad model behaviour and they are not. That is the case for instrumenting this stage specifically. Without a dispatch span you will spend the investigation editing the prompt.