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

feat(plugins): port ReflectAndRetryToolPlugin from adk-python#1391
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. The run
ends 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. Seven new files in a new com.google.adk.plugins.reflectandretry
subpackage, 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(new ReflectAndRetryToolPlugin(3)));
New file Concern
ReflectAndRetryToolPlugin.java the two hooks and the retry decision
ReflectionGuidance.java the two guidance messages and their formatting
ToolFailureTracker.java scoped, concurrent per-tool failure counters
ToolFailureResponse.java the immutable response value + toMap()
TrackingScope.java INVOCATION / GLOBAL
ReflectAndRetryToolPluginTest.java 19 tests
ToolFailureTrackerTest.java 6 tests, including the parallel-failure guarantee

Both 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]]:

// Plugin.java:188 — a result that carries an error
default Maybe<Map<String, Object>> afterToolCallback(
    BaseTool tool, Map<String, Object> toolArgs, ToolContext toolContext, Map<String, Object> result)

// Plugin.java:206 — 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 constructor flag. 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.

Ported from adk-python/src/google/adk/plugins/reflect_retry_tool_plugin.py (382 lines). 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,ToolFailureTrackerTest'
Tests run: 25, Failures: 0, Errors: 0, Skipped: 0

Twenty-five tests, all new — these are new classes, 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_propagatesOriginalError the original error, not a wrapper
onToolError_pastRetryLimit_whenNotThrowing_returnsGiveUpMessage the flag's other branch
maxRetriesZero_propagatesImmediately retrying disabled
negativeMaxRetries_isRejected constructor validation
afterTool_onSuccess_resetsThatToolsCounter success resets
afterTool_successOfOneTool_doesNotForgiveAnother per-tool isolation
afterTool_ownReflectionResponse_isPassedThroughUncounted no double counting
invocationScope_countsAreIsolatedPerInvocation INVOCATION scope
globalScope_countsSurviveAcrossInvocations GLOBAL scope
extractErrorFromResult_whenOverridden_treatsSuccessfulResultAsFailure the extractErrorFromResult override
scopeKey_whenOverridden_outranksTheConfiguredTrackingScope the scopeKey override
onToolError_errorWithoutMessage_reportsEmptyDetails (+ past-limit twin) a message-less exception renders as "", as in Python
maxRetriesZero_whenNotThrowing_returnsGiveUpMessage the give-up message at maxRetries=0
onToolError_whenArgumentsCannotBeSerialized_fallsBackToMapRendering guidance survives unserializable arguments
nullTrackingScope_isRejectedAtConstruction constructor validation
recordFailure_inParallel_countsEachFailureExactlyOnce atomicity under contention
recordFailure_countsEachToolSeparately / ...EachScopeSeparately both halves of the counter key
reset_clearsOnlyThatTool / reset_clearsOnlyThatScope / reset_onUnknownScope_isASilentNoOp reset scoping

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 2 of 2 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.

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

2 participants