Skip to content

Feat: Port ReflectAndRetryToolPlugin from adk-python to adk-js - #551

Open
AmaadMartin wants to merge 5 commits into
google:mainfrom
AmaadMartin:feat/reflect-retry-tool-plugin
Open

Feat: Port ReflectAndRetryToolPlugin from adk-python to adk-js#551
AmaadMartin wants to merge 5 commits into
google:mainfrom
AmaadMartin:feat/reflect-retry-tool-plugin

Conversation

@AmaadMartin

Copy link
Copy Markdown
Collaborator

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):

  • No existing issue; see the description below.

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

Problem:
adk-python ships a ReflectAndRetryToolPlugin that 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 had base_plugin, logging_plugin, global_instruction_plugin, plugin_manager, and security_plugin — leaving a cross-language parity gap.

Solution:
Port ReflectAndRetryToolPlugin to adk-js as a BasePlugin subclass (core/src/plugins/reflect_retry_tool_plugin.ts), exported from the public @google/adk entry point. Behavior mirrors the Python reference:

  • On a thrown tool error (onToolErrorCallback) or an error extracted from an otherwise-successful result (afterToolCallback + overridable extractErrorFromResult), it returns a structured ToolFailureResponse object with reflection guidance instead of propagating the error.
  • Consecutive failures are counted per tool name within a configurable TrackingScope (INVOCATION by default, or GLOBAL).
  • Retries are capped at maxRetries; once exceeded it either re-throws the original error (default) or returns terminal guidance, per throwExceptionIfRetryExceeded.
  • A tool's counter resets when that tool later succeeds, without affecting other tools.
  • Every response carries a stable response_type marker so the plugin never re-processes its own guidance as a new error.

Key design decisions:

  • Options-object constructor ({name?, maxRetries?, throwExceptionIfRetryExceeded?, trackingScope?}) following the existing SecurityPlugin convention, rather than Python's positional args.
  • Wire-format parity: response_type/GLOBAL_SCOPE_KEY constant values and the snake_case ToolFailureResponse field names match adk-python byte-for-byte, since the object becomes a functionResponse.response payload seen by the model.
  • No lock: the failure tracker's read-modify-write is synchronous, so on JS's single-threaded event loop it is already atomic with respect to concurrent tool calls — Python's asyncio.Lock is intentionally not ported (documented in-code).
  • Strong typing: error/result values are typed unknown and narrowed (instanceof Error / typeof), never any.
  • Marked @experimental, matching the Python framing.
  • Purely additive: one new file plus barrel exports; no existing behavior changes.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

core/test/plugins/reflect_retry_tool_plugin_test.ts ports the adk-python behavioral spec and adds the scope/branch cases required for full coverage: init defaults/custom, negative-maxRetries validation, success/own-response/null/primitive-result handling in afterToolCallback, maxRetries === 0 (throw and non-throw, including a non-Error dict 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-Error string values, custom error extraction + mixed error-type state management, INVOCATION vs GLOBAL scope isolation, and the missing-invocationId guard. 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 real FunctionTool that throws on its first call and succeeds on the second, plus a deterministic scripted model. It asserts that the intermediate functionResponse.response carries 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 install
  • npm run build
  • npm run lint
  • npx vitest run core/test/plugins/reflect_retry_tool_plugin_test.ts
  • npm run docs:check

Checklist

  • I have read the CONTRIBUTING.md document.
  • 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.

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 in core/src/common.ts — so no existing behavior is affected.

Amaad Martin added 4 commits July 28, 2026 13:55
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 = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

use interface instead of type

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can this be camelCased?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants