Feat: Port ReflectAndRetryToolPlugin from adk-python to adk-js - #551
Feat: Port ReflectAndRetryToolPlugin from adk-python to adk-js#551AmaadMartin wants to merge 5 commits into
Conversation
Add an experimental BasePlugin that intercepts tool errors and returns structured reflection guidance to the model so it can retry, up to a configurable maxRetries, with per-tool failure counts scoped per-invocation (default) or globally. Wire-format constants are compatible with adk-python. Export ReflectAndRetryToolPlugin, TrackingScope, ToolFailureResponse, and REFLECT_AND_RETRY_RESPONSE_TYPE from the public entry point.
Cover every branch of the plugin (100% line/branch), porting the adk-python behavioral spec, plus a no-mock end-to-end test driving InMemoryRunner with a real FunctionTool and a scripted model to prove a failing tool recovers via reflection guidance.
Fold errorTypeOf/errorDetailsOf into their sole caller buildFailureResponse.
- extractErrorFromResult: widen the hook's result param and return type to `unknown` so an override can return a real `Error` (Error has no implicit index signature, so it was not assignable to Record<string, unknown>) and so error_type/error_details report the real error class. - Close the per-invocation counter leak by dropping the scope in afterRunCallback under INVOCATION tracking. - Make stringifyError total: JSON.stringify throws on circular structures and can return undefined, which would replace the tool's real failure with a TypeError. - afterToolCallback: type `result` as `unknown` to match what the framework actually passes, replacing the untyped defensive guard (and two test casts) with a real narrowing. - Hoist the name/maxRetries defaults to named constants. - Document that retry_count is maxRetries on a terminal response. - Re-export GLOBAL_SCOPE_KEY so it is not a half-public symbol. - Move the runner-driven end-to-end test to tests/e2e/.
| * signature and is therefore assignable to the `Record<string, unknown>` | ||
| * return type of the plugin callbacks. | ||
| */ | ||
| export type ToolFailureResponse = { |
There was a problem hiding this comment.
use interface instead of type
There was a problem hiding this comment.
Done — ToolFailureResponse is now an interface (3f751f3).
One knock-on effect worth flagging: an interface has no implicit index signature, so it is no longer assignable to the Record<string, unknown> return type that BasePlugin.afterToolCallback / onToolErrorCallback require. Rather than cast, the payload is widened at the single boundary through a spread:
function toResponseRecord(response: ToolFailureResponse): Record<string, unknown> {
return {...response};
}handleToolError is now typed ToolFailureResponse (every path returns guidance or throws), so each callback wraps exactly one call. In the tests the ~30 as ToolFailureResponse casts are gone, replaced by an expectFailureResponse helper that validates the five fields and returns them typed — no casts anywhere.
| reflectionGuidance: string, | ||
| ): ToolFailureResponse { | ||
| return { | ||
| response_type: REFLECT_AND_RETRY_RESPONSE_TYPE, |
There was a problem hiding this comment.
can this be camelCased?
There was a problem hiding this comment.
Done — all five payload fields are camelCased (3f751f3): response_type -> responseType, error_type -> errorType, error_details -> errorDetails, retry_count -> retryCount, reflection_guidance -> reflectionGuidance. The marker key the plugin looks for on an incoming result was renamed with them, and the unit + e2e tests were updated.
Flagging the one trade-off so it is a deliberate choice: these names are what the model sees in functionResponse.response, and adk-python emits them in snake_case. Aligning with the repo convention means the two SDKs no longer produce byte-identical reflection payloads. Nothing depends on that today — no cross-language conformance fixture references this plugin, and the shared marker value (REFLECT_AND_RETRY_RESPONSE_TYPE) is unchanged, so behaviour is identical. Happy to switch back if you would rather keep the payload wire-identical with Python.
To keep the rename from silently regressing, I added a test that feeds the plugin its own guidance back as a tool result and asserts the failure counter was not reset — if the key written and the key recognized ever drift apart, the guidance reads as a success and the counter resets. It fails (expected 1 to be 2) with the old response_type lookup restored.
Both changes come from review feedback on the plugin's failure payload. - ToolFailureResponse is now an interface. It no longer carries an implicit index signature, so the plugin callbacks -- which must return Record<string, unknown> per the BasePlugin contract -- widen it through a single spread helper, toResponseRecord, rather than a cast. handleToolError is typed ToolFailureResponse, since every path returns guidance or throws. - The payload fields are camelCase (responseType, errorType, errorDetails, retryCount, reflectionGuidance), following this repository's convention. Only the REFLECT_AND_RETRY_RESPONSE_TYPE marker value stays shared with the Python ADK. Tests keep every existing case: the ~30 `as ToolFailureResponse` casts are replaced by expectFailureResponse, which validates the payload shape and returns it typed. Adds a regression test that feeds the plugin's own guidance back in as a tool result and asserts the failure counter was not reset, which pins the marker key the plugin writes to the key it recognizes.
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:
adk-python ships a
ReflectAndRetryToolPluginthat intercepts tool failures and, instead of aborting the run, feeds structured reflection guidance back to the model so it can retry with a corrected approach (up to a configurable limit). adk-js had no equivalent —core/src/plugins/only hadbase_plugin,logging_plugin,global_instruction_plugin,plugin_manager, andsecurity_plugin— leaving a cross-language parity gap.Solution:
Port
ReflectAndRetryToolPluginto adk-js as aBasePluginsubclass (core/src/plugins/reflect_retry_tool_plugin.ts), exported from the public@google/adkentry point. Behavior mirrors the Python reference:onToolErrorCallback) or an error extracted from an otherwise-successful result (afterToolCallback+ overridableextractErrorFromResult), it returns a structuredToolFailureResponseobject with reflection guidance instead of propagating the error.TrackingScope(INVOCATIONby default, orGLOBAL).maxRetries; once exceeded it either re-throws the original error (default) or returns terminal guidance, perthrowExceptionIfRetryExceeded.response_typemarker so the plugin never re-processes its own guidance as a new error.Key design decisions:
{name?, maxRetries?, throwExceptionIfRetryExceeded?, trackingScope?}) following the existingSecurityPluginconvention, rather than Python's positional args.response_type/GLOBAL_SCOPE_KEYconstant values and thesnake_caseToolFailureResponsefield names match adk-python byte-for-byte, since the object becomes afunctionResponse.responsepayload seen by the model.asyncio.Lockis intentionally not ported (documented in-code).unknownand narrowed (instanceof Error/typeof), neverany.@experimental, matching the Python framing.Testing Plan
Unit Tests:
core/test/plugins/reflect_retry_tool_plugin_test.tsports the adk-python behavioral spec and adds the scope/branch cases required for full coverage: init defaults/custom, negative-maxRetriesvalidation, success/own-response/null/primitive-result handling inafterToolCallback,maxRetries === 0(throw and non-throw, including a non-Errordict error), first/consecutive/independent-per-tool failure counting, cap-exceeded (throw same instance, wrapped dict error, and terminal guidance), success-resets-counter (including a reset that leaves a sibling tool's counter intact), empty-args formatting, non-Errorstring values, custom error extraction + mixed error-type state management,INVOCATIONvsGLOBALscope isolation, and the missing-invocationIdguard. Result: 28 tests passing with 100% line and branch coverage of the new file.Manual End-to-End (E2E) Tests:
An in-file end-to-end test drives a real
InMemoryRunner(no mocks of the plugin or the tool-execution flow) with a realFunctionToolthat throws on its first call and succeeds on the second, plus a deterministic scripted model. It asserts that the intermediatefunctionResponse.responsecarries the reflection payload (error_type,retry_count === 1, guidance text) and that the run recovers and returns the final response.To reproduce locally from the repo root:
npm installnpm run buildnpm run lintnpx vitest run core/test/plugins/reflect_retry_tool_plugin_test.tsnpm run docs:checkChecklist
Additional context
The plugin is marked
@experimental, mirroring how adk-python frames it. The change is purely additive — a single new plugin file plus its barrel exports incore/src/common.ts— so no existing behavior is affected.