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.

Advertisement

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.

Java agent tools — declaration + schema + invocation + error contractthe interface between LLM and codeTool declarationannotation + JSON schemaTool registrydiscoverable + versionedModel invokestool_use eventExecutorargument validationTimeout + retrysafe async executionError contracttyped errors backIdempotencysafe to re-runStreaming resultfor long opsGovernancepolicy + approvalObservabilityinvoke count + latencyOps — evolution + deprecation + drillsguardreturnsafestreamgovernwatchwatchevolveevolve
Java agent tools pipeline with governance and observability.
Advertisement

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:

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

DecisionDefaultWhy
Parameter namesExplicit, plus -parameters in the buildReflection alone yields arg0; the model then guesses positionally
Optional numeric or booleanBoxed type, null means absentPrimitives cannot distinguish unset from 0 or false
Free-text field with a closed setEnum, case-insensitive bindingPuts the legal values in the schema instead of the description
Map<String, Object> parameterSplit into separate toolsAn untyped schema is unvalidatable and invites arbitrary keys
Out-of-range read parameterClamp, and report the clampKeeps the turn moving without a wrong claim to the user
Out-of-range write parameterReject with the constraint restatedA clamped amount is reported to the user as the requested amount
Any tool with a side effectSession-scoped content-hash dedupe keyThe model re-issues calls for reasons the executor never sees
Batch tool resultPer-item status plus a retryable flagA single collapsed status forces a full-batch retry
Multi-system sequenceExpiring reservation before explicit compensationCompensating calls fail too; expiry survives a crashed process
Every tool declarationGolden-file schema snapshot testContract drift is invisible to every other test in the suite
A tool is a contract with an unreliable caller, and every advanced technique here follows from taking that literally. The model sees only the generated schema, so Java-specific leaks - erased generics, primitive defaults, enum casing, dates as strings - are contract bugs even though they compile cleanly. Validate structurally and then domain-wise at the deserialization boundary, and return failures as structured data naming the field and the constraint, because a model that can read the error corrects itself in one turn while a thrown exception produces an identical retry. Assume at-least-once delivery from the agent loop rather than only from the network: the model re-issues calls when context is evicted or a turn is replayed, so side-effecting tools need a session-scoped content-hash dedupe key written in the same transaction as the effect. Report partial batch results per item with a retryable flag, prefer expiring reservations over compensating calls that can themselves fail, and pin the whole contract with a golden-file schema snapshot - it is the only test that catches the declaration drifting away from the code. For timeouts and cancellation see timing out ADK Java tools, for the call boundary see authorization at the agent boundary, and for isolation see sandboxing ADK agents.