Why architecture matters here
Tool failures are architectural. A tool without argument validation crashes on hallucinated types. A tool without idempotency corrupts on retry. A tool without governance runs when it shouldn't. Getting the contract right is what separates a demo from a product.
With the pieces mapped, tools become predictable, safe, and evolvable.
The architecture: every piece explained
The top strip is the declaration + invocation. Tool declaration uses annotations (or manifest files) that describe name, description, and JSON schema for arguments. Tool registry exposes tools per session. Model invokes a tool via the runtime's tool_use event. Executor validates arguments, then calls the actual Java method.
The middle row is the safety machinery. Timeout + retry handles slow or flaky tools. Error contract returns typed errors that the model can reason about. Idempotency keys prevent duplicate side effects. Streaming result lets the model see partial output on long-running tools.
The lower rows are ops. Governance applies policy + approval per tool call. Observability tracks tool invoke counts, latencies, and errors. Ops handles evolution (schema changes with deprecations) and drills.
End-to-end flow
End-to-end: an agent runtime hosts a Java tool @Tool List<Issue> searchIssues(String query, int limit). Schema derived from annotations. Model invokes with (query, 10). Executor validates limit ≤ 100. Governance policy allows. Method runs; returns 10 issues. Result serialized and streamed back to the model. Later a slow tool: executor streams progress events; timeout after 60s; typed error if timed out. Idempotency key ensures retries don't create duplicates on write tools. Observability shows p95 latency and error rates per tool.
What this page owns, and where the neighbours start
Tool engineering in a Java agent runtime fans out into a dozen sub-disciplines, and most of them already have a dedicated write-up on this site. This page is the part that does not: the contract surface between a Java method and a model that will call it with arguments it invented, possibly more than once, possibly with half the batch already applied.
Read the following first if your question is one of theirs, because none of it is repeated below:
- ADK tool architecture and ADK tool design patterns - what a tool is, the calling loop, and the design rules that make a declaration readable to a model.
- Timing out ADK Java tools safely - deadline propagation, interrupt-based cancellation, and cleanup. Timeouts are that article's subject, not this one's.
- Authorization at the agent boundary and tool authentication architecture - who is allowed to call what, and how credentials reach a tool without being handed to the model.
- Sandboxing ADK agents - process isolation, CPU and memory caps, filesystem policy, and egress containment.
- ADK long-running tools - work that outlives a turn, with pause, persist, and resume.
- Agent tool schema versioning - immutable versions and adapter chains for evolving a contract under live traffic.
- ADK Java observability and ADK observability architecture - trace trees, span attributes, and token telemetry for tool spans.
- Agent router architecture and ADK Java streaming - dispatch across agents and the event stream that carries partial results.
What is left, and what the rest of this page covers: turning a Java signature into a schema a model calls correctly, validating and coercing arguments at the deserialization boundary, making calls safe when the model repeats them, degrading correctly on partial failure, and testing all of it.
From Java signature to JSON Schema - what the model actually sees
A tool declaration is generated, not written. The runtime reflects over your method, derives a JSON Schema for the parameters, attaches the description text, and ships that blob to the model as part of the request. Everything the model knows about your tool is in that blob. Your carefully named private helper, your Javadoc on the class, your enum's semantics - none of it travels unless it lands in the generated schema.
This is where Java differs sharply from the Python-first material. Python derives a schema from type hints plus a docstring, and both are one text object the author controls directly. In Java the schema comes from the type system, and the type system was designed for a compiler, not for a language model. Three consequences follow.
Parameter names are not guaranteed to survive
Java discards parameter names at compile time unless the class is compiled with the
-parameters flag. Without it, reflection reports arg0,
arg1, arg2, and the generated schema hands the model three
anonymous slots. The model then guesses positionally and gets it wrong the moment two
parameters share a type. Either enable -parameters in your build, or name
every parameter explicitly in the annotation - do not rely on reflection defaults.
A schema with arg0 in it is a bug that only shows up as bad model
behaviour, never as a stack trace.
Descriptions are the prompt, and they are per-parameter
A method-level description tells the model when to call the tool. A
parameter-level description tells it what to put in the slot, and that is where
most misuse originates. "The account identifier" is not a specification. "The internal
account UUID, as returned by lookupAccount; never the customer-facing
account number" is, and it eliminates an entire class of wrong calls without any
runtime validation. Write the constraint the validator will enforce into the
description too, so the model has a chance to comply before it gets rejected.
Keep the parameter list flat and small
Deeply nested request objects generate deeply nested schemas, and every level of nesting is another place for the model to lose track of which field it was filling. Prefer five flat scalar parameters over one nested object with five fields. If a tool genuinely needs a nested structure, expose the structure as a Java record so the schema generator emits named fields with types rather than a free-form map, and describe each component field individually.
Type erasure, boxing, and the schema traps unique to Java
Several ordinary Java idioms produce a schema that is technically valid and practically useless. These are worth memorising because the compiler will not warn you about any of them.
Erased generics
A parameter typed List<String> is fine when the generic argument is
recoverable from the method signature, which it usually is - erasure removes the type at
runtime but the signature metadata still carries it, and a decent schema generator reads
that. The failure mode is Map<String, Object> and bare
Object. Both collapse to an untyped schema, and an untyped schema is an
invitation: the model puts whatever it likes in there, and you get an unvalidatable bag
of keys at the boundary. If you find yourself accepting a map because "the payload
varies", you have a discriminated union, and you should expose it as several
single-purpose tools instead. That is the god-tool anti-pattern arriving through the type
system rather than through the method body.
Boxed versus primitive
A primitive int limit cannot represent "the model did not supply this".
Deserialization will hand you 0, which is a legal value and semantically
catastrophic for a limit, an offset, or an amount. Use Integer, treat
null as absent, and apply the default explicitly in the method body where
the intent is visible. The same reasoning applies to boolean: a primitive
default of false silently turns "unspecified" into "no", which is the wrong
answer for a flag like includeArchived roughly half the time.
Enums and the case problem
Enums are the single highest-value type in a tool signature, because they turn a
free-text field into a closed set the schema advertises. The trap is casing: Java
convention is SCREAMING_SNAKE, models overwhelmingly emit
lowercase or camelCase, and strict deserialization rejects the
mismatch. Either configure case-insensitive enum binding on the mapper used at the tool
boundary, or declare the enum constants in the casing the schema advertises. Do not
solve this by widening the parameter back to String - that discards the one
piece of information the schema was giving the model.
Dates, money, and other things that are not strings
A parameter typed String date will receive "next Tuesday", "2026-08-07",
"08/07/2026", and "yesterday", because nothing in the schema says otherwise. Type it as
LocalDate so the schema carries a date format, and state the interpretation
of relative dates in the description, or resolve them in a separate tool. Money is
worse: a double amount in a schema invites 19.99 and will
eventually invite 19.990000000000002. Take a
BigDecimal or minor units as a long, and say which in the
description.
Validation and coercion at the deserialization boundary
The model's arguments arrive as JSON and become Java objects somewhere. That somewhere is a security and correctness boundary, and it deserves an explicit layer rather than whatever the default mapper happens to do.
Treat it as two distinct checks. Structural validation asks whether the JSON conforms to the schema you published: required fields present, types assignable, enum members recognised, no unknown properties. Domain validation asks whether the resulting object is a legal request in your system: the limit is within range, the date is not in the past, the account belongs to the caller. Structural failures mean the model misread the schema. Domain failures mean the model read it fine and asked for something you will not do. They deserve different messages back to the model, and different alert thresholds in your dashboards.
Lock the mapper down
A default Jackson configuration is lenient in ways that are helpful for internal APIs
and harmful here. Coercion of "10" into 10, silent truncation
of a floating-point value into an int, and silent acceptance of unknown
properties all convert a model mistake into a plausible-looking successful call. Use a
mapper dedicated to the tool boundary with unknown properties rejected and lossy
numeric coercion disabled, so a wrong call fails loudly at the edge instead of half-way
through a transaction.
Return validation failures as data, not exceptions
A validation failure is not an outage; it is a turn in a conversation. If it escapes as a thrown exception, most runtimes will surface a generic "tool failed" to the model, which then retries the identical call. If instead you return a structured result naming the offending field and the constraint, the model has enough to correct itself on the next turn - and it usually does, in one attempt.
// Boundary result: never throw a validation error at the model.
record ToolResult(String status, Object data, List<FieldError> errors) {}
record FieldError(String field, String problem, String expected) {}
ToolResult validate(SearchArgs a) {
var errs = new ArrayList<FieldError>();
if (a.limit() == null) {
a = a.withLimit(20); // absent -> documented default
} else if (a.limit() < 1 || a.limit() > 100) {
errs.add(new FieldError("limit",
"out of range: " + a.limit(),
"integer between 1 and 100"));
}
if (a.query() == null || a.query().isBlank()) {
errs.add(new FieldError("query", "missing or empty",
"non-empty search string"));
}
return errs.isEmpty()
? new ToolResult("ok", a, List.of())
: new ToolResult("invalid_arguments", null, errs);
}Two details matter in that shape. The expected field restates the
constraint in the same words as the parameter description, so a model that reads the
error is being told the same thing twice rather than something new. And absence is
handled by defaulting, not by an error - rejecting an omitted optional field trains the
model to over-specify every call.
Clamp or reject, but decide once
For a bounded numeric like a page size, silently clamping 5000 to 100 is defensible and keeps the conversation moving. For anything with a side effect - a transfer amount, a delete count, a retention window - clamping is dangerous, because the model reports to the user that it did what it asked for. Rule of thumb: clamp read parameters, reject write parameters, and whenever you clamp, say so in the returned payload so the model can tell the user the result was truncated.
Idempotency when the model is the retry source
Retry-safety in tool design usually means executor retry, and ADK tool design patterns covers that case. The harder case, and the one specific to agent runtimes, is that the model itself re-issues calls, and it does so for reasons your retry policy never sees.
It happens when a result was summarised or evicted out of the context window and the model no longer remembers it succeeded. It happens when a stream broke mid-turn and the turn is replayed from the last checkpoint. It happens when a supervising agent retries a sub-agent whose tool calls already landed. In every one of those, the executor sees a first attempt, the circuit breaker sees a healthy call, and your database sees a second identical write. Nothing in the transport layer is in a position to notice.
The practical consequence: a tool with side effects must assume at-least-once delivery from the loop, not just from the network. That means a dedupe key, stored server-side, checked before the effect.
Deriving the key
There are two sources and they behave differently. The tool-call id assigned by the runtime is unique per invocation, which makes it perfect for transport-level retries and useless for model-level ones - a re-issued call gets a fresh id. A content hash over the canonicalised arguments plus a session or conversation identifier catches the model-level repeat, because the arguments are what the model reconstructs. Use the content hash as the primary key and scope it to the session, so two different users making the same request are not deduplicated against each other.
String dedupeKey(String sessionId, String toolName, Map<String, Object> args) {
// Canonicalise: sorted keys, normalised numbers, no whitespace.
String canonical = Canonical.json(args);
return DigestUtils.sha256Hex(sessionId + '|' + toolName + '|' + canonical);
}
ToolResult transfer(TransferArgs a, ToolContext ctx) {
String key = dedupeKey(ctx.sessionId(), "transfer", a.asMap());
var prior = ledger.findByIdempotencyKey(key); // TTL-bounded store
if (prior != null) {
return new ToolResult("ok", prior.receipt(), List.of()); // replay, no effect
}
var receipt = ledger.applyOnce(key, a); // insert-then-act, one transaction
return new ToolResult("ok", receipt, List.of());
}Two operational notes. The dedupe record must be written in the same transaction as the effect, or a crash between them reopens the window you were closing. And the TTL is a real design decision: too short and a long-running conversation replays an effect after the record expires; too long and a user who legitimately wants to send the same amount twice is blocked. Session-scoped keys with a TTL matching your maximum session lifetime are a reasonable default, with an explicit override parameter for tools where repetition is meaningful.
Make the replay visible
Returning the cached receipt silently is correct for the system and confusing for the user, who may be told "transfer complete" twice. Include a flag in the payload marking the response as a replay of an earlier call, with the original timestamp. The model will generally relay that, and "this was already done at 14:02" is a much better answer than a second confirmation.
Partial failure and compensating actions
Single-item tools have two outcomes and are easy. Batch tools, and tools that touch more than one system, have a third: some of it worked. Handling that badly is the most common way an agent produces a confidently wrong summary.
Scope note: this section is about partial failure inside one tool invocation. The related problem of partial failure across agents - a delegated task that half-completed in another agent's process, and the fact that you cannot roll back across an independent service you do not control - is covered in A2A error handling.
Never collapse a batch into one status
A tool that sends fourteen invitations and returns {"status": "error"}
because the last one bounced has destroyed the information the model needs. The model
will either report total failure - wrong - or retry the whole batch - worse, because
thirteen people get a second invitation. Return per-item results with a stable
identifier, and a summary the model can quote without doing arithmetic.
{
"status": "partial",
"summary": { "total": 14, "succeeded": 13, "failed": 1 },
"results": [
{ "id": "u_2201", "status": "ok" },
{ "id": "u_2202", "status": "failed",
"reason": "mailbox_full", "retryable": true }
],
"retry_hint": "Re-call with ids: [\"u_2202\"]"
}The retryable flag and the explicit retry hint do a lot of work. Without
them the model has to infer from an error string whether a second attempt is worthwhile,
and it infers optimistically - a permanently invalid address will be retried until the
turn budget runs out.
Compensate deliberately, and tell the model you did
When a tool spans several systems - reserve inventory, charge a card, create a shipment - a mid-sequence failure leaves you holding partial state. The saga pattern applies as it always does: each forward step has a compensating step, and the failure path runs the compensations in reverse. The agent-specific rule is that compensation must be reported, not hidden. A tool that rolls back and returns a bare error teaches the model that nothing happened, when in fact a reservation was created and released and the user may see both in an audit log or an email.
Return the sequence: which steps committed, which were compensated, which are still outstanding. Anything that could not be compensated automatically - a charge that needs manual reversal, an external notification already sent - belongs in the result as an explicit item, ideally with a reference the user can quote to support.
Prefer a reservation to a rollback
Compensation is unreliable by construction, because the compensating call can fail too. Where the domain allows it, restructure so the risky effect happens last and everything before it is reversible by expiry rather than by an explicit undo. Reserve with a short TTL and let the reservation lapse if the sequence never completes; that converts a compensation you must execute into one that happens whether or not your process survives. Agents crash, sessions are abandoned mid-turn, and users close the tab - expiry-based cleanup handles all three, and a compensating call handles none of them.
Testing tools in isolation and in-agent
Tool bugs split cleanly into two families, and they need different tests. The method can be wrong, which is ordinary Java testing. Or the method can be right and the declaration wrong, so the model calls it incorrectly - and no unit test of the method will ever catch that.
Snapshot the generated schema
The most valuable test in this whole area is also the cheapest: generate the schema from the annotated method, serialise it deterministically, and assert it against a checked-in golden file. This catches the whole class of accidental contract changes - someone renames a parameter during a refactor, someone adds a field to a record used as an argument type, someone upgrades the schema generator and the enum representation changes. All of those are invisible to every other test you have, and every one of them changes model behaviour in production.
When the snapshot legitimately changes, the diff is the review artefact: a reviewer looks at exactly what the model will now see. Pair it with a rule that a changed golden file requires a decision about versioning, per tool schema versioning.
Test the boundary with the arguments a model would actually send
Unit tests written by the tool's author use well-formed arguments, because the author
knows the contract. Models do not. Build a fixture set from real, ugly cases and run it
through the deserialization and validation layer, asserting on the returned
ToolResult rather than on exceptions:
- a numeric field sent as a quoted string
- an enum in the wrong case
- an optional field omitted entirely, and the same field sent as explicit
null - an out-of-range value just past each boundary
- an extra property the schema never declared
- a date as free text rather than ISO format
The best source for this fixture set is production: log rejected argument payloads and promote the recurring ones into tests. A validation error that fires ten thousand times a day is a description bug, not a model bug, and the log is where you find it.
In-agent tests: a scripted model, not a real one
Isolation tests cannot tell you whether the model chooses this tool when it should, or whether it recovers from your error payload. For that you need the tool in a loop - but running a real model in CI gives you a non-deterministic, slow, expensive test. Use a scripted stand-in that emits a fixed sequence of tool calls, and assert on the resulting event stream: the tool was invoked with the arguments you expect, the error payload came back in a form the loop propagated, the second call carried the corrected field.
Keep a smaller suite that does use a real model, run on a schedule rather than per commit, and score it on selection behaviour: given twenty realistic prompts, how often is the right tool chosen, and how often does a rejected call get corrected within one retry. Those two numbers are the ones that move when someone edits a description, and they are the reason description edits deserve review.
Negative-path coverage for the effects
Finally, test the properties this article has been arguing for, because they are the ones that only fail in rare interleavings: call an idempotent tool twice with identical arguments and assert one effect; fail the third item of a five-item batch and assert the result reports three successes and one failure with the fifth untouched or attempted per your documented semantics; fail a compensating step and assert the outstanding item appears in the payload rather than being swallowed.
Rules of thumb
| Decision | Default | Why |
|---|---|---|
| Parameter names | Explicit, plus -parameters in the build | Reflection alone yields arg0; the model then guesses positionally |
| Optional numeric or boolean | Boxed type, null means absent | Primitives cannot distinguish unset from 0 or false |
| Free-text field with a closed set | Enum, case-insensitive binding | Puts the legal values in the schema instead of the description |
Map<String, Object> parameter | Split into separate tools | An untyped schema is unvalidatable and invites arbitrary keys |
| Out-of-range read parameter | Clamp, and report the clamp | Keeps the turn moving without a wrong claim to the user |
| Out-of-range write parameter | Reject with the constraint restated | A clamped amount is reported to the user as the requested amount |
| Any tool with a side effect | Session-scoped content-hash dedupe key | The model re-issues calls for reasons the executor never sees |
| Batch tool result | Per-item status plus a retryable flag | A single collapsed status forces a full-batch retry |
| Multi-system sequence | Expiring reservation before explicit compensation | Compensating calls fail too; expiry survives a crashed process |
| Every tool declaration | Golden-file schema snapshot test | Contract drift is invisible to every other test in the suite |