Skip to content

feat(plugins): port ReflectAndRetryToolPlugin from adk-python - #1391

Open
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/reflect-and-retry-tool-plugin
Open

svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/reflect-and-retry-tool-plugin

Conversation

@svetanis

@svetanis svetanis commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

When a tool fails, adk-java gives the model no way to learn what went wrong and try again. A tool
that emits an error ends the run on the first failure, even when the failure is one the model could
have corrected — malformed arguments, the wrong tool, an unmet precondition. adk-python ships
ReflectAndRetryToolPlugin for exactly this; adk-java has no equivalent.

Solution:

A port of that plugin. One new class, com.google.adk.plugins.ReflectAndRetryToolPlugin, and its
test, no existing file modified and no existing API changed — registration uses the surface
that is already there:

Runner runner =
    new InMemoryRunner(
        agent,
        "my-app",
        ImmutableList.of(ReflectAndRetryToolPlugin.builder().maxRetries(3).build()));
New file Concern
ReflectAndRetryToolPlugin.java the hooks, the retry decision, the guidance messages, the per-tool counters and the builder
ReflectAndRetryToolPluginTest.java 53 tests

The two tool hooks it overrides are already declared on Plugin, and both already return the type
the plugin needs — Maybe<Map<String, Object>>, matching Python's Optional[dict[str, Any]]:

// a result that carries an error
default Maybe<Map<String, Object>> afterToolCallback(
    BaseTool tool, Map<String, Object> toolArgs, ToolContext toolContext, Map<String, Object> result)

// a tool that signals one
default Maybe<Map<String, Object>> onToolErrorCallback(
    BaseTool tool, Map<String, Object> toolArgs, ToolContext toolContext, Throwable error)

onToolErrorCallback counts the failure and, under the limit, substitutes a structured response —
error type, error details, retry count, and guidance telling the model to analyze its arguments and
not repeat the identical call. Past the limit it either propagates the original error or returns a
final "stop using this tool" message, per a builder setting. afterToolCallback resets that tool's
counter on success, routes an extracted error into the same logic, and passes the plugin's own
reflection responses straight through — reflecting on a reflection would count one tool failure
twice, and there is a test for it. afterRunCallback and onRunErrorCallback drop an invocation's
counts when it ends.

Ported from adk-python/src/google/adk/plugins/reflect_retry_tool_plugin.py. The
guidance strings are upstream's, including a grammatical slip in item 5 — copied rather than
corrected so the two implementations do not drift.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.
mvn -pl core test -Dtest=ReflectAndRetryToolPluginTest
Tests run: 53, Failures: 0, Errors: 0, Skipped: 0

Fifty-three tests, all new — this is a new class, so there is no before/after state to contrast:

Test Pins
onToolError_withinRetryLimit_returnsReflectionGuidance the response shape
onToolError_guidanceCarriesToolNameAndArguments guidance content
onToolError_countsConsecutiveFailures the retry_count sequence
onToolError_pastRetryLimit_completesEmptySoTheOriginalErrorPropagates the original error, not a wrapper
onToolError_pastRetryLimit_whenNotThrowing_returnsGiveUpMessage (+ well-past twin) the flag's other branch
maxRetriesZero_completesEmptyImmediately (+ not-throwing twin) retrying disabled
builder_* (6) defaults, name, rejection of negative / null settings
afterTool_onSuccess_resetsThatToolsCounter success resets
afterTool_successOfOneTool_doesNotForgiveAnother / onToolError_countsEachToolSeparately per-tool isolation
afterTool_ownReflectionResponse_isPassedThroughUncounted no double counting
invocationScope_countsAreIsolatedPerInvocation (+ 1) INVOCATION scope
globalScope_countsSurviveAcrossInvocations (+ 1) GLOBAL scope
{afterRun,onRunError}_clearsTheEndedInvocationsCounts / _leavesOtherInvocationsCountsAlone, globalScope_{afterRun,onRunError}_keepsCounts (6) run-end cleanup of the ended invocation only
resultErrorExtractor_* / defaultExtractor_* (7) Throwable, map and text errors, and past the limit
onToolError_errorWithoutMessage_reportsEmptyDetails (+ past-limit twin) a message-less exception renders as "", as in Python
onToolError_when* (5) unserializable, nested, array, self-referencing and repeated arguments
*NothingUntilSubscribed (4) nothing happens before subscription
onToolError_inParallel_countsEachFailureExactlyOnce atomicity under contention
Runner-level (6) through Runner + TestLlm: a Single.error tool, reset after success, the run failing past the limit, a throwing FunctionTool with and without an extractor, an unknown tool name

Manual End-to-End (E2E) Tests:

Two scenarios run against gemini-2.5-flash through an InMemoryRunner, each alongside a control —
the same model, prompt and tools with the plugin unregistered:

Scenario With the plugin Without it
Tool rejects the model's argument format corrected in 3 of 3 exercised attempts died on the first failure, no second turn
Tool fails until a different tool has run recovered in 3 of 3, across three unrelated domains —

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

@svetanis
svetanis force-pushed the feature/reflect-and-retry-tool-plugin branch from 368f767 to 9cb1c70 Compare August 3, 2026 01:11
@hemasekhar-p hemasekhar-p self-assigned this Aug 3, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @svetanis, thank you for your contribution! We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you.

@svetanis
svetanis force-pushed the feature/reflect-and-retry-tool-plugin branch 2 times, most recently from 119bcb0 to 0ba7656 Compare August 15, 2026 06:10
@svetanis
svetanis force-pushed the feature/reflect-and-retry-tool-plugin branch from 0ba7656 to 56152bf Compare September 2, 2026 02:16

/** Turns a thrown tool error into reflection guidance for the model. */
@Override
public Maybe<Map<String, Object>> onToolErrorCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: This never fires when a FunctionTool method throws. FunctionTool.runAsync catches the exception and returns {"status": "error", "message": "An internal error occurred."}, which afterToolCallback treats as a success, resetting the counter. I reproduced this in InMemoryRunner and a live Gemini run.

Should we at least document that only Single.error/Maybe.error failures reach this plugin ?

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 \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: Item 5 cannot trigger in ADK Java. Functions.handleFunctionCalls logs "Tool not found" and drops calls to unknown tools, so onToolErrorCallback never sees a hallucinated name. Python routes them to on_tool_error_callback with a placeholder tool (item 5 was added upstream for this case), as does ADK Kotlin. Suggest a flow change in a separate PR, or calling out this gap in the Javadoc.

* @throws IllegalArgumentException if {@code maxRetries} is negative
* @throws NullPointerException if {@code trackingScope} is null
*/
public ReflectAndRetryToolPlugin(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: Public signatures are hard to change later, and this constructor reads poorly at call sites: tests write new ReflectAndRetryToolPlugin("p", 1, false, TrackingScope.INVOCATION), needing a placeholder name and a bare boolean. Customizing also requires subclassing a non-final class whose callbacks can be overridden. ContextFilterPlugin.builder() and the Kotlin port's builder avoid both issues. Suggest a builder (maxRetries, throwExceptionIfRetryExceeded, trackingScope, an error extractor, name) and a final class. Also, this Javadoc lacks a summary fragment, and line 71's "As above" depends on source order.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: With the default INVOCATION scope, entries are never removed when an invocation ends. Every run whose last tool call failed, including the throw-past-the-limit path, leaves a key behind, so a long-lived runner grows without bound (25 such runs left 25 entries in my test). This also contradicts the TrackingScope.INVOCATION Javadoc. Python has the same leak, whereas ADK Kotlin clears it in afterRun/onRunError; here you can override afterRunCallback and onRunErrorCallback, which Runner calls on both paths.

import org.mockito.junit.MockitoRule;

@RunWith(JUnit4.class)
public class ReflectAndRetryToolPluginTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Every test calls the hooks directly on mocks, so none verifies that ADK Java's flow actually delivers failures to the plugin; that is why the FunctionTool and unknown-tool gaps went unnoticed. Suggest a few InMemoryRunner + TestLlm tests with a real FunctionTool (a tool that throws, one returning Single.error, a success resetting the count, and an unknown tool name), asserting on the function response sent to the model. Python has a similar runner-level test.

private Maybe<Map<String, Object>> exhausted(
BaseTool tool, Map<String, Object> toolArgs, Throwable error, int failures) {
return throwExceptionIfRetryExceeded
? Maybe.error(error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin.onToolErrorCallback states that returning empty lets the original error propagate. Returning Maybe.error(error) instead makes PluginManager log the tool's exception as a plugin failure, and later plugins and agent onToolErrorCallbacks are skipped. Python's plugin manager behaves the same when a plugin raises, so this is optional; Kotlin returns Continue and lets the flow rethrow. The afterToolCallback path still needs the error, since empty means success there.

if (maxRetries == 0) {
return exhausted(tool, toolArgs, error, FIRST_FAILURE);
}
int attempt = failures.recordFailure(scopeKey(toolContext), tool.name());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: The count is updated when the Maybe is built rather than on subscription, whereas the reset path is deferred (Maybe.fromRunnable). This is harmless with today's PluginManager, which builds callbacks lazily, but breaks the standard Rx expectation that nothing happens before subscription. Wrapping handleToolError in Maybe.defer fixes this.

* <p>{@link #scopeKey} and {@link #extractErrorFromResult} are {@code protected} because adk-python
* documents both as overridable.
*/
public class ReflectAndRetryToolPlugin extends BasePlugin {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Python 1.x marks this class @experimental, and ADK Java has @Experimental. Given the open API questions (constructor vs builder, the extractErrorFromResult type), annotating the public types would keep them changeable.

* 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Contrary to the Javadoc, this does not mirror json.dumps(..., default=str): Python stringifies only the values it cannot serialize while keeping valid JSON, whereas this falls back to the whole map's toString(), placing non-JSON text inside the json code fence. Jackson's pretty printer also renders "city" : "Phoenix" instead of Python's "city": "Phoenix". Either match Python (for example falling back to String.valueOf per value) or drop the "Mirrors" sentence.

* new InMemoryRunner(agent, APP_NAME, ImmutableList.of(new ReflectAndRetryToolPlugin(3)));
* }</pre>
*
* <p>{@link #scopeKey} and {@link #extractErrorFromResult} are {@code protected} because adk-python

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Several comments explain why the code is shaped this way rather than what it does: this sentence, "Kept apart from..." in ReflectionGuidance and ToolFailureTracker, and the note on upstream's _handle_tool_error in exhausted(). That rationale fits the PR description better, and removing it keeps each doc comment to a few sentences.

@damianmomotgoogle damianmomotgoogle added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed needs review labels Oct 16, 2026
@svetanis
svetanis force-pushed the feature/reflect-and-retry-tool-plugin branch from 56152bf to 33e9f8b Compare October 18, 2026 04:12
@svetanis

Copy link
Copy Markdown
Contributor Author

@damianmomotgoogle ,

Thanks for the review. All 13 comments are addressed in a single commit rebased on main:

  1. FunctionTool throwing: documented in the class Javadoc: FunctionTool returns a thrown exception as a {"status": "error"} result, so such failures reach this plugin only through a ResultErrorExtractor. Covered by functionToolThatThrows_reachesThePluginAsAStatusErrorResult and functionToolThatThrows_withAStatusExtractor_modelReceivesReflectionGuidance.
  2. Item 5: documented in the class Javadoc: calls to a tool name the agent does not have never reach this plugin. Covered by unknownToolName_neverReachesThePlugin. Item 5 keeps upstream's text; the flow is unchanged in this PR.
  3. Public signatures: replaced the constructors with ReflectAndRetryToolPlugin.builder() (maxRetries, throwExceptionIfRetryExceeded, trackingScope, resultErrorExtractor, name). The class is final, its constructor private, and the old constructor Javadoc is gone.
  4. INVOCATION entries never removed: afterRunCallback and onRunErrorCallback now drop the ended invocation's counts; GLOBAL counts are kept. Covered for both hooks by *_clearsTheEndedInvocationsCounts, *_leavesOtherInvocationsCountsAlone and globalScope_*_keepsCounts.
  5. Tests on mocks: added six Runner + TestLlm tests with real tools, asserting on the function response sent to the model: a Single.error tool, a success resetting the count, the run failing past the limit, a throwing FunctionTool with and without a result extractor, and an unknown tool name. The tests no longer use Mockito.
  6. max_retries in the message and retry_count: the give-up message and retry_count now report maxRetries. FIRST_FAILURE and the extra parameter are gone.
  7. Five files and a subpackage: now a single com.google.adk.plugins.ReflectAndRetryToolPlugin with a nested TrackingScope and private helpers. The subpackage is gone.
  8. Requiring a Throwable: replaced with a nested ResultErrorExtractor functional interface on the builder, returning Optional<Object>. For an extracted error that is not a Throwable, error_type is the string "ToolError". Past the limit, when throwExceptionIfRetryExceeded is set, such an error is raised as an IllegalStateException with a generic message.
  9. Returning empty past the limit: onToolErrorCallback now completes empty. The afterToolCallback path still emits the error.
  10. Count updated before subscription: both tool hooks now use Maybe.defer. Covered by onToolError_countsNothingUntilSubscribed and afterTool_resetsNothingUntilSubscribed.
  11. @experimental: added to the class, TrackingScope and ResultErrorExtractor.
  12. json.dumps(..., default=str): a value that cannot be serialized, nested ones included, is now written as its string form, so the block stays valid JSON. The "Mirrors" sentence is removed.
  13. Comments explaining why: removed. Doc comments now describe what the code does.

@hemasekhar-p hemasekhar-p added needs review and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Port ReflectAndRetryToolPlugin from adk-python

4 participants