When a tool fails inside an ADK Java agent, the failure goes to the model before it goes anywhere else. Whatever map the tool returns becomes a function response, and the model reads it as evidence for its next step. A good error lets the model correct an argument, wait, or tell the user plainly what went wrong. A bad one makes it retry in a loop, invent an explanation, or show the user a stack trace.
So wrapping tool errors is interface design, not logging. This article defines an error envelope with a code and a next action, builds it inside your own tools and around tools you do not control, adds a failure budget so the model cannot loop, then covers redaction and tests. For how a whole invocation fails, including model errors and recovery at the caller, read ADK Java Error Recovery first. Framework details were checked against google/adk-java main on 2026-10-04.
Why a tool error is model input
The model has no stack trace, no metrics and no access to your code. Everything it knows about a failure is the text in the response, which gives four requirements. The error must be classifiable: a bad argument (fixable) must look different from a missing record (report it) and an outage (do not keep retrying). It must be actionable, naming a next step, because models follow an explicit instruction far better than an exception class name. It must be safe, since function responses are stored in the session and may be echoed to the user. And it must be bounded: a model told "retryable" will retry, and if nothing counts those retries a stuck service becomes a stuck conversation that burns calls until maxLlmCalls ends it.
Three paths from failure to function response
- Path A, a deliberate error map. Your method catches the problem and returns a map. Only here do you control the content.
- Path B, a synchronous throw inside a FunctionTool.
FunctionTool.runAsynccatchesException, logs it and returns{"status": "error", "message": "An internal error occurred."}. The invocation continues, but every detail is gone. - Path C, an asynchronous failure. A
SingleorMaybereturned by your method fails later, outside that catch, as do tools that extendBaseTooldirectly, such as MCP or OpenAPI adapters. These errors go to the tool error callbacks; if none returns a map, the invocation stream fails.
On success, a non-Map return value is wrapped as {"result": value}. Without a convention the model sees three or four shapes; the goal is one.
One envelope for every tool
Use one envelope for every tool, with failures under an error key and success under output. The Vertex AI FunctionResponse documentation describes this convention, treating the whole response as output when neither key is present. ADK does not enforce it and other providers just see JSON, but it costs nothing.
{"status": "ok", "output": {"meetingId": "m-7781", "start": "2026-10-06T10:00:00Z"}}
{"status": "error",
"error": {"code": "INVALID_ARGUMENT",
"field": "date",
"message": "date must be an ISO-8601 calendar date such as 2026-10-06",
"action": "fix_arguments",
"retryable": true}}| code | action | retryable | What the model should do |
|---|---|---|---|
| INVALID_ARGUMENT | fix_arguments | true | Change the named field and call again |
| NOT_FOUND | ask_user | false | Report it or ask for a different identifier |
| CONFLICT | reread_then_retry | true | Fetch current state, then try again |
| RATE_LIMITED / UNAVAILABLE | retry_later | true, budgeted | One more attempt at most, then tell the user |
| PERMISSION_DENIED | stop_and_tell_user | false | Explain that the action is not allowed |
| INTERNAL | stop_and_tell_user | false | Apologise; do not invent a cause |
Keep the code list short and closed. The message is an instruction for the model, never an exception string. The action field does most of the work, turning a classification into a next step; describe the actions once in the agent instruction.
Mapping failures inside your own tools
For tools you write, map failures inside the method, where you still know what went wrong. A small exception type carries the code, and a helper turns any body into an envelope. The helper catches everything, so a FunctionTool never falls through to path B.
public final class ToolError extends RuntimeException {
public enum Code { INVALID_ARGUMENT, NOT_FOUND, CONFLICT, RATE_LIMITED,
UNAVAILABLE, PERMISSION_DENIED, INTERNAL }
private final Code code;
private final String field; // nullable
public ToolError(Code code, String modelMessage, String field) {
super(modelMessage);
this.code = code;
this.field = field;
}
public Code code() { return code; }
public String field() { return field; }
}
public final class Envelopes {
private static final Logger log = LoggerFactory.getLogger(Envelopes.class);
public static Map<String, Object> guard(String tool, Supplier<Object> body) {
try {
Map<String, Object> ok = new LinkedHashMap<>(); // tolerates a null output
ok.put("status", "ok");
ok.put("output", body.get());
return ok;
} catch (ToolError e) {
return error(e.code(), e.getMessage(), e.field());
} catch (RuntimeException e) {
String ref = UUID.randomUUID().toString().substring(0, 8);
log.error("tool {} failed, ref {}", tool, ref, e); // full detail stays here
return error(ToolError.Code.INTERNAL,
"The tool failed unexpectedly (ref " + ref + "). Tell the user; do not retry.", null);
}
}
public static Map<String, Object> error(ToolError.Code code, String msg, String field) {
Map<String, Object> e = new LinkedHashMap<>();
e.put("code", code.name());
if (field != null) e.put("field", field);
e.put("message", msg);
e.put("action", actionFor(code));
e.put("retryable", code != ToolError.Code.NOT_FOUND
&& code != ToolError.Code.PERMISSION_DENIED
&& code != ToolError.Code.INTERNAL);
return Map.of("status", "error", "error", e);
}
}The reference id links the conversation to your logs without revealing anything. A tool method then reads as business logic:
public static Map<String, Object> bookMeeting(
@Schema(name = "date", description = "ISO-8601 date, e.g. 2026-10-06") String date,
@Schema(name = "slot", description = "24h start time, e.g. 10:00") String slot) {
return Envelopes.guard("book_meeting", () -> {
LocalDate d;
try { d = LocalDate.parse(date); }
catch (DateTimeParseException e) {
throw new ToolError(ToolError.Code.INVALID_ARGUMENT,
"date must be an ISO-8601 calendar date such as 2026-10-06", "date");
}
try { return calendar.book(d, LocalTime.parse(slot)); }
catch (CalendarBusyException e) {
throw new ToolError(ToolError.Code.CONFLICT,
"That slot was just taken. List free slots, then try another.", "slot");
}
});
}
A decorator for tools you do not own
MCP tools, generated OpenAPI tools and older throwing FunctionTools need a decorator that extends BaseTool. It must delegate declaration() so the model still sees the schema, the long-running flag, and processLlmRequest, which some tools use to change the model request; forgetting it breaks them silently.
public final class GuardedTool extends BaseTool {
private final BaseTool inner;
private final Function<Throwable, Map<String, Object>> mapper;
private final int maxConsecutiveFailures;
public GuardedTool(BaseTool inner, Function<Throwable, Map<String, Object>> mapper, int max) {
super(inner.name(), inner.description(), inner.longRunning());
this.inner = inner;
this.mapper = mapper;
this.maxConsecutiveFailures = max;
}
@Override public Optional<FunctionDeclaration> declaration() { return inner.declaration(); }
@Override
public Completable processLlmRequest(LlmRequest.Builder b, ToolContext ctx) {
return inner.processLlmRequest(b, ctx);
}
@Override
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
return Single.defer(() -> inner.runAsync(args, ctx)) // catches sync throws too
.map(r -> isGenericError(r) ? Envelopes.error(ToolError.Code.INTERNAL,
"The tool failed unexpectedly. Tell the user; do not retry.", null)
: r.containsKey("status") ? r : Map.of("status", "ok", "output", r))
.onErrorReturn(err -> mapper.apply(rootCause(err)))
.map(r -> budget(r, ctx));
}
private static boolean isGenericError(Map<String, Object> r) {
return "error".equals(r.get("status"))
&& "An internal error occurred.".equals(r.get("message"));
}
}The mapper holds transport knowledge: timeouts and HTTP 503 become UNAVAILABLE, 429 RATE_LIMITED, 404 NOT_FOUND, 409 CONFLICT, 401 and 403 PERMISSION_DENIED, anything else INTERNAL with a reference id. Unwrap the cause chain first, because reactive operators wrap exceptions. Matching the generic message string is fragile, so pin your ADK version and keep a test that fails if the text changes. Keep an onToolErrorCallbackSync backstop (see the callback architecture article) that logs loudly, because reaching it means a tool was not wrapped.
A failure budget so the model stops
The budget step counts consecutive failures per tool in session state and overrides the advice once the limit is reached. CallbackContext.state(), which ToolContext inherits, returns the session's delta-aware State, so the counter survives across turns and is reset by the next success.
private Map<String, Object> budget(Map<String, Object> r, ToolContext ctx) {
String key = "tool_failures:" + name();
if (!"error".equals(r.get("status"))) {
ctx.state().put(key, 0);
return r;
}
Map<String, Object> e = new LinkedHashMap<>((Map<String, Object>) r.get("error"));
if (!"retry_later".equals(e.get("action"))) return r; // only outages are counted
int n = ((Number) ctx.state().getOrDefault(key, 0)).intValue() + 1;
ctx.state().put(key, n);
if (n < maxConsecutiveFailures) return r;
e.put("retryable", false);
e.put("action", "stop_and_tell_user");
e.put("message", e.get("message") + " This tool has failed " + n
+ " times in a row; stop calling it in this conversation.");
return Map.of("status", "error", "error", e);
}Two suits remote tools: one retry covers a blip, and a second failure usually means an outage longer than a chat turn. Bad arguments are not counted; repeated ones signal a schema or instruction problem that belongs in metrics. Retries inside the tool, invisible to the model, are a separate layer covered in tool timeout handling.
Redaction: what must never reach the model
Everything in an envelope travels to a model and possibly a user. Never forward library exception messages: JDBC errors carry SQL and table names, HTTP client errors carry internal hosts and tokenised query strings. Write the message yourself, from the code. Cap it around 300 characters so a 2 MB HTML error page cannot fill the context window. And treat external error bodies as data: one containing "ignore previous instructions" is a prompt injection, so quote it only when needed and mark it untrusted.
Worked example: booking a meeting
A user asks a scheduling agent to "book next Tuesday at ten". Here is the sequence of function responses the model sees, with the budget set to 2.
- The model calls
book_meeting(date="next Tuesday", slot="10:00")and receives INVALID_ARGUMENT,field="date", fix_arguments. Not retryable in the budget's sense, so not counted. It retries with"2026-10-06". - The calendar returns HTTP 503, mapped to UNAVAILABLE, retry_later. Counter: 1. The model tries once more and gets another 503. Counter: 2, the limit, so the envelope becomes stop_and_tell_user.
- The model tells the user the calendar is unavailable and nothing was booked, and stops calling the tool.
- Later the user says "try again"; the call succeeds and the counter resets to 0.
Without an explicit action, a common reaction to a 503 is several immediate retries, costing model calls and loading a service that is already struggling.
Testing and observing the contract
Test the contract, not just the code. Unit-test the mapper with one case per code, a wrapped cause and an unknown exception. Add a contract test that checks every tool's error maps for required keys, a code in the closed set, the size cap, and no forbidden patterns such as Exception, at com. or jdbc:. And run one end-to-end test per agent with a failing fake tool, asserting from session events that the model stopped at the budget. The tool-level test is cheap:
@Test
void badDateIsAFixableArgumentError() {
Map<String, Object> r = CalendarTools.bookMeeting("next Tuesday", "10:00");
assertEquals("error", r.get("status"));
Map<?, ?> e = (Map<?, ?>) r.get("error");
assertEquals("INVALID_ARGUMENT", e.get("code"));
assertEquals("date", e.get("field"));
assertEquals("fix_arguments", e.get("action"));
assertFalse(e.get("message").toString().contains("DateTimeParseException"));
}In production, count errors per tool and code. INTERNAL above baseline means a bug, PERMISSION_DENIED an identity problem, and rising budget exhaustion a failing dependency.
Failure modes
- Silent generic errors. A throwing FunctionTool yields "An internal error occurred." and the model apologises vaguely. Guard every method and alert on that message.
- Errors that look like success. A tool returns an empty list when the service failed, and the model says there are no meetings. Never translate failure into an empty result.
- Leaked internals. SQL, host names or tokens end up in the reply. Write messages yourself and run the contract test.
- A decorator that drops behaviour. A missing
declaration()orprocessLlmRequestdelegate hides the tool or breaks it. Test that wrapped and unwrapped declarations match. - Escalating what the model could fix. An unconverted async bad-argument error fails the whole invocation for something the model would fix in one turn.
Trade-offs
Mapping inside the method gives the best messages but must be repeated per tool; the decorator is generic but knows only what the exception says. Use both. A closed code set is easy for models to learn and dashboards to count but loses detail, so keep detail in logs under the reference id. Session budgets are simple but per conversation; protecting a shared dependency from many users at once needs a circuit breaker. And escalating is still right when the model cannot help, such as a misconfigured credential: the caller should get a stream error, as the A2A error propagation article shows for remote agents.
What to do next
- Write down your envelope: the status values, a closed list of about six codes, the action vocabulary and the message size cap.
- Add a
ToolErrortype and aguardhelper, and move every FunctionTool method body inside it. - Wrap every tool you do not own in a
GuardedToolthat delegates the declaration, the long-running flag andprocessLlmRequest. - Add a consecutive-failure budget of 2 for remote tools, counting only retry_later errors (rate limits and outages).
- Mention the action names once in the agent instruction, with one sentence on what each means.
- Keep an
onToolErrorCallbackSyncbackstop that logs at error level and returns an INTERNAL envelope. - Write the contract test that checks every error map for required keys, the code set, size and forbidden patterns.
- Add per-tool, per-code metrics and alerts for INTERNAL, PERMISSION_DENIED and budget exhaustion.