Skip to content

feat(plugins): add ContextFilterPlugin for context windowing and filtering - #856

Open
Varun-S10 wants to merge 4 commits into
google:mainfrom
Varun-S10:feat/context-filter-plugin
Open

Varun-S10 wants to merge 4 commits into
google:mainfrom
Varun-S10:feat/context-filter-plugin

Conversation

@Varun-S10

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

  • Closes: N/A
  • Related: N/A

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

Problem:
In multi-turn conversations, the chat history sent to the LLM grows with every turn. This causes three issues:

  1. High token usage, higher costs, slower responses, and hitting model context limits.
  2. If developers manually trim messages, they risk keeping a tool response while cutting off its preceding tool call. When this happens, Gemini fails immediately with an orphaned function response error.
  3. Private user data (like emails or personal info) is sent to the model without any automatic sanitization hook.

ADK-Python already has ContextFilterPlugin to handle this, but ADK-JS was missing this feature.

Solution:
We port ContextFilterPlugin to ADK-JS:

  1. Invocation Windowing: Automatically keeps only the last N user interactions and removes older turns once a threshold is reached.
  2. Orphan Protection: Scans backwards when trimming so that tool calls and their matching tool responses always stay paired, preventing Gemini API crashes.
  3. Custom Filtering: Allows developers to attach sync or async filter functions to redact private data or modify messages before sending them to the model.
  4. Safe Error Handling: Catches any errors during filtering and preserves original messages so the agent never crashes.

Testing Plan

Unit Tests:

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

Summary of results:

  • Added 20 new unit and integration tests in context_filter_plugin_test.ts covering windowing, consecutive user turns, threshold bypass, sync/async custom filters, PII masking, orphan response protection, error resilience, and multi-turn runner integration.
  • All 20 new tests passed.
  • All 119 existing plugin tests passed with zero regressions.

Manual End-to-End (E2E) Tests:

  • Created a standalone test script outside ADK-JS verifying 28 test cases across exports, windowing, tool pair safety, custom filtering, and multi-turn runner simulation. All 28 checks passed.
  • Ran all repository checks: npm run ts:check, npm run lint, npm run format:check, npm run docs:check, npm run build, and bash scripts/check_license.sh. All passed with zero errors or warnings.

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

This is a non-breaking, opt-in feature that ports ContextFilterPlugin from Python ADK to achieve parity in ADK-JS. Existing agents and runners that do not use the plugin remain completely unaffected.

@Varun-S10 Varun-S10 added the needs review [Status] The PR/issue is awaiting review from the maintainer label Sep 4, 2026

@AmaadMartin AmaadMartin left a comment

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.

Verified the port against the Python reference (src/google/adk/plugins/context_filter_plugin.py) at the head SHA; the windowing and orphan-pair logic match. Two items block: the helpers are exported as public API but are private in the reference, and isContextFilterPlugin uses instanceof where this repo uses brand-symbol guards. CI note: run-tests (windows-latest) failed while macOS and the aggregate run-tests passed, which matches the known windows flake in files this PR does not touch.

Comment on lines +236 to +239
export function isContextFilterPlugin(
plugin: unknown,
): plugin is ContextFilterPlugin {
return plugin instanceof ContextFilterPlugin;

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.

Not a nit. isContextFilterPlugin uses instanceof. This check fails when two copies of the package share one runtime. This repo uses brand-symbol guards instead (see isBaseLlm in core/src/models/base_llm.ts:24).

This guard also has no caller in the repo and no counterpart in the Python reference. Please remove it. If a caller needs it later, add a brand symbol like isBaseLlm.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Completely removed isContextFilterPlugin (along with its exports and unit tests) since it has no callers in the codebase and no counterpart in the Python reference.

Comment thread core/src/common.ts
Comment on lines +231 to +240
export {
ContextFilterPlugin,
adjustSplitIndexToAvoidOrphanedFunctionResponses,
getInvocationStartIndices,
isContextFilterPlugin,
isFunctionResponseContent,
isHumanUserContent,
type ContextFilterPluginOptions,
type CustomFilterFunction,
} from './plugins/context_filter_plugin.js';

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.

Not a nit. Only ContextFilterPlugin and the two types belong in the public API. The four helpers and isContextFilterPlugin are private in the Python reference (_-prefixed).

Please drop these five names from common.ts:

adjustSplitIndexToAvoidOrphanedFunctionResponses,
getInvocationStartIndices,
isContextFilterPlugin,
isFunctionResponseContent,
isHumanUserContent,

The test imports them from the module path directly, so this does not break the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Dropped all five names from common.ts. Only ContextFilterPlugin, type ContextFilterPluginOptions, and type CustomFilterFunction remain exported in the public API.

Comment on lines +160 to +177
constructor(
options?: ContextFilterPluginOptions | number,
customFilter?: CustomFilterFunction,
name = 'context_filter_plugin',
removeAmount = 1,
) {
if (typeof options === 'object' && options !== null) {
super(options.name ?? 'context_filter_plugin');
this.numInvocationsToKeep = options.numInvocationsToKeep;
this.customFilter = options.customFilter;
this.removeAmount = options.removeAmount ?? 1;
} else {
super(name);
this.numInvocationsToKeep = options;
this.customFilter = customFilter;
this.removeAmount = removeAmount;
}

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.

Not a nit. The constructor accepts an options object or four positional arguments. The Python reference has only keyword arguments, which map to the options object. The positional form has no caller except the test.

Please keep the options object only and drop the positional overload and its branch. This also removes the runtime typeof check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Dropped the positional constructor overload and runtime typeof check. The constructor now takes only options: ContextFilterPluginOptions = {}.

}

if (this.removeAmount < 1) {
throw new Error('remove_amount must be at least 1');

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.

Nit. The error says remove_amount, but the JS field is removeAmount. Use the JS name so the message matches the API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Updated the error message to 'removeAmount must be at least 1' to match the JS property name.

Comment on lines +59 to +67
return (
parts !== undefined &&
parts !== null &&
Array.isArray(parts) &&
parts.some(
(part) =>
part.functionResponse !== undefined && part.functionResponse !== null,
)
);

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.

Nit, optional. parts is typed Part[] | undefined, so the !== null and Array.isArray checks are dead. The Python reference checks only parts is not None.

return parts?.some((part) => part.functionResponse != null) ?? false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Simplified _isFunctionResponseContent using optional chaining to:

return content.parts?.some((part) => part.functionResponse != null) ?? false;

@Varun-S10

Copy link
Copy Markdown
Contributor Author

All suggested changes have been updated and tested. Could you please take another look? @AmaadMartin

@AmaadMartin AmaadMartin left a comment

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.

Approve. All five earlier findings are fixed at the head source: the instanceof guard is removed, common.ts exports only ContextFilterPlugin and its two types, the constructor takes only the options object, the error message uses removeAmount, and the dead null checks are gone. The windowing and orphan-pairing logic match the adk-python reference. The tests exercise the behavior through the public plugin API. CI was still running at review time; I decided on the code.

@AmaadMartin AmaadMartin left a comment

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.

Re-review at c6bc608. All five earlier findings are fixed in the source: the instanceof guard is gone, common.ts exports only ContextFilterPlugin and the two types, the constructor takes an options object only, the error text uses removeAmount, and the dead parts checks are removed. No suppressions, no instanceof, no console.log. CI: the PR's own context_filter_plugin_test.ts passed 17/17; the red run-tests jobs failed only on known flakes in untouched files (unsafe_local_code_executor_test.ts, app_loader_test.ts, agent_dirname_test.ts, build_setup_test.ts, skills/script_js). Approving on the code.

@AmaadMartin AmaadMartin left a comment

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.

Re-review at c6bc608. All five earlier findings are fixed: the instanceof guard is gone, common.ts exports only the plugin and its two types, the constructor takes one options object, the error message uses removeAmount, and the dead parts checks are gone. The port matches the adk-python reference field for field. CI: run-tests failed on ubuntu and windows in integration tests this PR does not touch (app_loader, build_setup, skills); this is a known flake, macos passed, and I approve on the code.


if (
this.numInvocationsToKeep !== undefined &&
this.numInvocationsToKeep !== null &&

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.

Nit, optional. numInvocationsToKeep is typed number | undefined, so !== null is dead code. The !== undefined and > 0 checks are enough. This is the same dead null check you removed from _isFunctionResponseContent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Removed the redundant !== null check on numInvocationsToKeep so only !== undefined && > 0 remain. Could you please check it again? @AmaadMartin

@AmaadMartin AmaadMartin left a comment

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.

Approve. I verified all earlier findings against the source at head 741f6aa:

  • The instanceof guard is removed.
  • common.ts exports only ContextFilterPlugin and its two types.
  • The constructor takes the options object only.
  • The error message uses removeAmount.
  • The dead !== null checks are gone.

I found no new type suppressions, no instanceof, and no missing exports. CI was green at review time.

@AmaadMartin AmaadMartin left a comment

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.

Re-review at head 741f6aa. All six prior findings are fixed in the source. The instanceof guard is gone, common.ts exports only ContextFilterPlugin, ContextFilterPluginOptions and CustomFilterFunction, the positional constructor overload is dropped, and the dead null checks and the error message are corrected. No new any, suppression, instanceof or console.log. CI was green at review time. Approving.

* @param contents Full conversation contents in chronological order.
* @returns A list of indices where each index marks the beginning of an invocation.
*/
function _getInvocationStartIndices(contents: Content[]): number[] {

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.

please avoid using _ prefix for private functions/methods/properties

llmRequest: LlmRequest;
}): Promise<LlmResponse | undefined> {
try {
if (!params.llmRequest.contents) {

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.

we should check on length as well

const neededCallIds = new Set<string>();
for (let i = contents.length - 1; i >= 0; i--) {
const parts = contents[i].parts;
if (parts) {

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.

parts && parts.length

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review [Status] The PR/issue is awaiting review from the maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants