Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.plugins.reflectandretry;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.adk.plugins.BasePlugin;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.ToolContext;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import io.reactivex.rxjava3.core.Maybe;
import java.util.Map;

/**
* Provides self-healing error recovery for tool failures.
*
* <p>This plugin intercepts tool failures, hands the model structured guidance for reflection and
* correction, and lets it retry up to a configurable limit. Failure counts are tracked per tool
* within a scope, so a success with one tool resets that tool's counter without forgiving
* another's.
*
* <p>Port of adk-python's {@code ReflectAndRetryToolPlugin} ({@code
* plugins/reflect_retry_tool_plugin.py}).
*
* <p>Example:
*
* <pre>{@code
* Runner runner =
* new Runner(
* agent,
* APP_NAME,
* artifactService,
* sessionService,
* ImmutableList.of(new ReflectAndRetryToolPlugin(3)));
* }</pre>
*
* <p>{@link #scopeKey} and {@link #extractErrorFromResult} are {@code protected} because adk-python
* documents both as overridable.
*/
public class ReflectAndRetryToolPlugin extends BasePlugin {

private static final String DEFAULT_NAME = "reflect_retry_tool_plugin";
private static final int DEFAULT_MAX_RETRIES = 3;
private static final String GLOBAL_SCOPE_KEY = "__global_reflect_and_retry_scope__";
private static final String NEGATIVE_RETRIES = "maxRetries must be non-negative, but was %s";

/** The observed failure count when retrying is disabled: the one failure being reported. */
private static final int FIRST_FAILURE = 1;

private final int maxRetries;
private final boolean throwExceptionIfRetryExceeded;
private final TrackingScope trackingScope;
private final ToolFailureTracker failures = new ToolFailureTracker();

/** Three retries, throwing when exceeded, tracked per invocation. */
public ReflectAndRetryToolPlugin() {
this(DEFAULT_NAME, DEFAULT_MAX_RETRIES, true, TrackingScope.INVOCATION);
}

/** As above, with a custom retry limit. */
public ReflectAndRetryToolPlugin(int maxRetries) {
this(DEFAULT_NAME, maxRetries, true, TrackingScope.INVOCATION);
}

/**
* @param name plugin instance identifier
* @param maxRetries maximum consecutive failures before giving up; {@code 0} disables retrying
* @param throwExceptionIfRetryExceeded whether to propagate the final error once the limit is
* reached, rather than returning guidance
* @param trackingScope lifetime of the failure counters
* @throws IllegalArgumentException if {@code maxRetries} is negative
* @throws NullPointerException if {@code trackingScope} is null
*/
public ReflectAndRetryToolPlugin(
String name,
int maxRetries,
boolean throwExceptionIfRetryExceeded,
TrackingScope trackingScope) {
super(name);
checkArgument(maxRetries >= 0, NEGATIVE_RETRIES, maxRetries);
this.maxRetries = maxRetries;
this.throwExceptionIfRetryExceeded = throwExceptionIfRetryExceeded;
this.trackingScope = checkNotNull(trackingScope, "trackingScope cannot be null");
}

/**
* Resets the tool's failure count on success, or routes an error carried inside an otherwise
* successful result into the retry logic.
*
* <p>A result this plugin produced earlier is passed straight through: reflecting on a reflection
* would count a single tool failure twice.
*/
@Override
public Maybe<Map<String, Object>> afterToolCallback(
BaseTool tool,
Map<String, Object> toolArgs,
ToolContext toolContext,
Map<String, Object> result) {
if (ToolFailureResponse.isReflection(result)) {
return Maybe.empty();
}
return extractErrorFromResult(tool, toolArgs, toolContext, result)
.flatMap(error -> handleToolError(tool, toolArgs, toolContext, error))
.switchIfEmpty(Maybe.fromRunnable(() -> resetFailures(tool, toolContext)));
}

/** Turns a thrown tool error into reflection guidance for the model. */
@Override
public Maybe<Map<String, Object>> onToolErrorCallback(
BaseTool tool, Map<String, Object> toolArgs, ToolContext toolContext, Throwable error) {
return handleToolError(tool, toolArgs, toolContext, error);
}

/**
* Detects an error inside a tool result that did <em>not</em> throw — for example {@code
* {"status": "error"}} — so it can drive the same retry logic.
*
* <p>Empty by default, exactly as in adk-python. Override to opt in.
*/
protected Maybe<Throwable> extractErrorFromResult(
BaseTool tool,
Map<String, Object> toolArgs,
ToolContext toolContext,
Map<String, Object> result) {
return Maybe.empty();
}

/**
* The key failure counts are grouped under. Override to track per user or per session instead of
* the configured {@link TrackingScope}.
*/
protected String scopeKey(ToolContext toolContext) {
return switch (trackingScope) {
case INVOCATION -> toolContext.invocationId();
case GLOBAL -> GLOBAL_SCOPE_KEY;
};
}

/**
* Counts the failure and decides between guidance, a final message, or propagating the error.
*
* <p>Never completes empty. {@link #afterToolCallback} treats an empty result as "the tool
* succeeded" and resets the counter, so an empty return here would clear the count of the very
* call that just failed.
*/
private Maybe<Map<String, Object>> handleToolError(
BaseTool tool, Map<String, Object> toolArgs, ToolContext toolContext, Throwable error) {
if (maxRetries == 0) {
return exhausted(tool, toolArgs, error, FIRST_FAILURE);
}
int attempt = failures.recordFailure(scopeKey(toolContext), tool.name());
if (attempt <= maxRetries) {
return Maybe.just(reflection(tool, toolArgs, error, attempt));
}
return exhausted(tool, toolArgs, error, attempt);
}

/**
* Either propagates the final error or hands back the give-up message, per configuration.
*
* <p>{@code failures} is the number of consecutive failures actually observed, which is what both
* the give-up message and the response's {@code retry_count} report. Retrying is disabled at
* {@code maxRetries == 0}, where nothing is counted at all — upstream returns before its counter
* runs ({@code reflect_retry_tool_plugin.py:243-246}) and so does this — so the observed count
* there is the one failure being reported.
*/
private Maybe<Map<String, Object>> exhausted(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int failures) {
return throwExceptionIfRetryExceeded
? Maybe.error(error)
: Maybe.just(retryExceeded(tool, toolArgs, error, failures));
}

private void resetFailures(BaseTool tool, ToolContext toolContext) {
failures.reset(scopeKey(toolContext), tool.name());
}

private ImmutableMap<String, Object> reflection(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int attempt) {
return response(
error, attempt, ReflectionGuidance.forRetry(tool, toolArgs, error, attempt, maxRetries));
}

private ImmutableMap<String, Object> retryExceeded(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int failures) {
return response(
error, failures, ReflectionGuidance.forExhausted(tool, toolArgs, error, failures));
}

private static ImmutableMap<String, Object> response(
Throwable error, int retryCount, String guidance) {
return new ToolFailureResponse(
error.getClass().getSimpleName(),
Strings.nullToEmpty(error.getMessage()),
retryCount,
guidance)
.toMap();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.plugins.reflectandretry;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.adk.JsonBaseModel;
import com.google.adk.tools.BaseTool;
import com.google.common.base.Strings;
import java.util.Map;

/**
* The text {@link ReflectAndRetryToolPlugin} sends the model after a tool fails.
*
* <p>Kept apart from the plugin so that one class decides <em>whether</em> to retry and this one
* decides only <em>what the model is told</em>. Both messages are ports of the f-strings in
* adk-python's {@code reflect_retry_tool_plugin.py} and are reproduced verbatim.
*/
final class ReflectionGuidance {

private static final String ERROR_DETAILS_FORMAT = "%s: %s";

private static final String RETRY =
"""
The call to tool `%s` failed.

**Error Details:**
```
%s
```

**Tool Arguments Used:**
```json
%s
```

**Reflection Guidance:**
This is retry attempt **%d of %d**. Analyze the error and the arguments you provided. Do not \
repeat the exact same call. Consider the following before your next attempt:

1. **Invalid Parameters**: Does the error suggest that one or more arguments are incorrect, \
badly formatted, or missing? Review the tool's schema and your arguments.
2. **State or Preconditions**: Did a previous step fail or not produce the necessary \
state/resource for this tool to succeed?
3. **Alternative Approach**: Is this the right tool for the job? Could another tool or a \
different sequence of steps achieve the goal?
4. **Simplify the Task**: Can you break the problem down into smaller, simpler steps?
5. **Wrong Function Name**: Does the error indicates the tool is not found? Please check \
again and only use available tools.

Formulate a new plan based on your analysis and try a corrected or different approach.""";

private static final String EXHAUSTED =
"""
The tool `%s` has failed consecutively %d times and the retry limit has been exceeded.

**Last Error:**
```
%s
```

**Last Arguments Used:**
```json
%s
```

**Final Instruction:**
**Do not attempt to use the `%s` tool again for this task.** You must now try a different \
approach. Acknowledge the failure and devise a new strategy, potentially using other \
available tools or informing the user that the task cannot be completed.""";

private ReflectionGuidance() {}

/** Asks the model to analyze the failure and try a corrected call. */
static String forRetry(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int attempt, int maxRetries) {
return RETRY.formatted(
tool.name(), errorDetails(error), argsAsJson(toolArgs), attempt, maxRetries);
}

/**
* Tells the model to stop calling the tool and change approach.
*
* <p>{@code failures} is the number of consecutive failures observed. adk-python interpolates its
* configured {@code max_retries} here instead ({@code reflect_retry_tool_plugin.py:357}), which
* under-reports by one at every setting because the give-up fires on the failure *after* the
* limit — and at {@code max_retries=0} tells the model the tool "has failed consecutively 0 times
* and the retry limit has been exceeded". This port reports what actually happened.
*/
static String forExhausted(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int failures) {
return EXHAUSTED.formatted(
tool.name(), failures, errorDetails(error), argsAsJson(toolArgs), tool.name());
}

private static String errorDetails(Throwable error) {
return ERROR_DETAILS_FORMAT.formatted(
error.getClass().getSimpleName(), Strings.nullToEmpty(error.getMessage()));
}

/**
* Pretty-prints the arguments for the guidance message, falling back to the map's own rendering.
*
* <p>Never throws: a serialization failure must not mask the tool failure being reported, and the
* model still needs the echo of the arguments it sent. Mirrors adk-python's {@code
* json.dumps(..., default=str)}.
*/
private static String argsAsJson(Map<String, Object> toolArgs) {
try {
return JsonBaseModel.getMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(toolArgs);
} catch (JsonProcessingException e) {
return String.valueOf(toolArgs);
}
}
}
Loading