Repository navigation
Conversation
368f767 to
9cb1c70
Compare
|
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. |
119bcb0 to
0ba7656
Compare
0ba7656 to
56152bf
Compare
|
|
||
| /** Turns a thrown tool error into reflection guidance for the model. */ | ||
| @Override | ||
| public Maybe<Map<String, Object>> onToolErrorCallback( |
There was a problem hiding this comment.
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 \ |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
56152bf to
33e9f8b
Compare
|
Thanks for the review. All 13 comments are addressed in a single commit rebased on main:
|
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
ReflectAndRetryToolPluginfor exactly this; adk-java has no equivalent.Solution:
A port of that plugin. One new class,
com.google.adk.plugins.ReflectAndRetryToolPlugin, and itstest, no existing file modified and no existing API changed — registration uses the surface
that is already there:
ReflectAndRetryToolPlugin.javaReflectAndRetryToolPluginTest.javaThe two tool hooks it overrides are already declared on
Plugin, and both already return the typethe plugin needs —
Maybe<Map<String, Object>>, matching Python'sOptional[dict[str, Any]]:onToolErrorCallbackcounts 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.
afterToolCallbackresets that tool'scounter 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.
afterRunCallbackandonRunErrorCallbackdrop an invocation'scounts when it ends.
Ported from
adk-python/src/google/adk/plugins/reflect_retry_tool_plugin.py. Theguidance 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:
Fifty-three tests, all new — this is a new class, so there is no before/after state to contrast:
onToolError_withinRetryLimit_returnsReflectionGuidanceonToolError_guidanceCarriesToolNameAndArgumentsonToolError_countsConsecutiveFailuresretry_countsequenceonToolError_pastRetryLimit_completesEmptySoTheOriginalErrorPropagatesonToolError_pastRetryLimit_whenNotThrowing_returnsGiveUpMessage(+ well-past twin)maxRetriesZero_completesEmptyImmediately(+ not-throwing twin)builder_*(6)afterTool_onSuccess_resetsThatToolsCounterafterTool_successOfOneTool_doesNotForgiveAnother/onToolError_countsEachToolSeparatelyafterTool_ownReflectionResponse_isPassedThroughUncountedinvocationScope_countsAreIsolatedPerInvocation(+ 1)INVOCATIONscopeglobalScope_countsSurviveAcrossInvocations(+ 1)GLOBALscope{afterRun,onRunError}_clearsTheEndedInvocationsCounts/_leavesOtherInvocationsCountsAlone,globalScope_{afterRun,onRunError}_keepsCounts(6)resultErrorExtractor_*/defaultExtractor_*(7)Throwable, map and text errors, and past the limitonToolError_errorWithoutMessage_reportsEmptyDetails(+ past-limit twin)"", as in PythononToolError_when*(5)*NothingUntilSubscribed(4)onToolError_inParallel_countsEachFailureExactlyOnceRunner+TestLlm: aSingle.errortool, reset after success, the run failing past the limit, a throwingFunctionToolwith and without an extractor, an unknown tool nameManual End-to-End (E2E) Tests:
Two scenarios run against
gemini-2.5-flashthrough anInMemoryRunner, each alongside a control —the same model, prompt and tools with the plugin unregistered:
Checklist