Skip to content

Feat: port ApplicationIntegrationToolset and IntegrationConnectorTool (Part 2/2) - #579

Open
AmaadMartin wants to merge 5 commits into
feat/application-integration-toolset-part1from
feat/application-integration-toolset-part2
Open

Feat: port ApplicationIntegrationToolset and IntegrationConnectorTool (Part 2/2)#579
AmaadMartin wants to merge 5 commits into
feat/application-integration-toolset-part1from
feat/application-integration-toolset-part2

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:
    Problem: adk-python ships a first-class Google Cloud Application Integration / Integration Connectors toolset; adk-js has nothing equivalent. Part 1 of this stack added the API clients that generate the OpenAPI spec. On their own they produce a spec but no tools.

Solution: Part 2 of 2 — add the toolset and the tool that turn that spec into ADK tools. Stacked on feat/application-integration-toolset-part1; review that PR first.

import {ApplicationIntegrationToolset, LlmAgent} from '@google/adk';

// Integration mode: publish an integration's API triggers as tools.
const integrationToolset = new ApplicationIntegrationToolset({
  project: 'test-project',
  location: 'us-central1',
  integration: 'test-integration',
  triggers: ['api_trigger/test_trigger'],
});

// Connection mode: publish connector entity operations and actions.
const connectorToolset = new ApplicationIntegrationToolset({
  project: 'test-project',
  location: 'us-central1',
  connection: 'test-connection',
  entityOperations: {Issues: ['LIST', 'GET'], Projects: []}, // [] = all supported
  actions: ['ExecuteCustomQuery'],
  toolNamePrefix: 'jira',
  toolInstructions: 'Use this to manage Jira issues.',
});

const agent = new LlmAgent({
  name: 'ops_agent',
  model: 'gemini-2.0-flash',
  tools: [integrationToolset, connectorToolset],
});

This part adds:

  • core/src/tools/application_integration_tool/application_integration_toolset.tsApplicationIntegrationToolset. Validates the mode, fetches the spec, and produces either RestApiTools (via OpenAPIToolset) or IntegrationConnectorTools.
  • core/src/tools/application_integration_tool/integration_connector_tool.tsIntegrationConnectorTool plus the exported pure function filterConnectorParameters.

Part 1 additionally fixes prepareRequestParams so a generated path's #<operation>_<entity> fragment is dropped rather than percent-encoded into the query string; without it every connector call here would target triggerId=api_trigger/ExecuteConnection%23list_Issues, a trigger that does not exist. The chain test in this PR asserts the corrected triggerId, that the request URL has no fragment, and that it contains no %23.

Collision check. gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 (477 open PRs) plus gh pr diff --name-only on every plausibly adjacent PR (#462, #463, #464, #465, #528, #529) found no open PR touching core/src/tools/application_integration_tool/. #463 also edits core/src/tools/openapi_tool/..., but not tool_auth_handler.ts. The only other overlap is the append-only export block in core/src/common.ts.

Security-relevant behaviour

  • The model cannot redirect a connector call. All seven EXCLUDE_FIELDS (connection_name, service_name, host, entity, operation, action, dynamic_auth_config) are removed from the model-facing declaration, and runAsync writes them after spreading the model's arguments. A test asserts that a model-supplied connection_name: 'attacker-conn' / operation: 'DELETE_ENTITY' is overwritten with the instance values.
  • No credential is logged. Python does logger.info('Running tool: %s with args: %s', ...), and by that point args contains the OAuth access token in dynamic_auth_config. This port logs the tool name and the argument keys at debug, never the values.
  • Caller auth is withheld unless the connection allows it. When authScheme and authCredential are both supplied but the connection's authOverrideEnabled is falsy, the connector tools get neither, and a warning is logged.

Parity notes (adk-python is the reference)

Deliberate divergences:

  • Async initialization. Python does blocking HTTP in __init__; a TypeScript constructor cannot await. The constructor still validates synchronously and throws the byte-identical Invalid request, Either integration or (connection and (entity_operations or actions)) should be provided.; the network I/O runs in a private initialize() memoised behind one promise, so concurrent getTools() callers share a single initialization (a test asserts one spec fetch for two concurrent calls). This is documented on the class.
  • Deliberate scope reduction: no toolset-level authConfig. The approved plan called for a public authConfig field that a caller sets exchangedAuthCredential on before getTools(), with the toolset then returning clones carrying that credential. It is not included, because in adk-js nothing would ever call it: adk-python reaches the config through BaseToolset.get_auth_config() and adk-js has no such hook, so the field could only be driven by hand from a test. At runtime the exchanged credential is already resolved per call by ToolAuthHandler.prepareAuthCredentials, which reads it out of tool-context state. The hook and this path belong together and are queued as one follow-up; shipping the half with no consumer would be dead public API. This also means the stack no longer needs to touch tool_auth_handler.ts at all.
  • No feature flag on the declaration. Python branches on FeatureName.JSON_SCHEMA_FOR_FUNC_DECL between parameters_json_schema and parameters=_to_gemini_schema(...). adk-js has no such feature (core/src/features/feature_registry.ts declares only PROGRESSIVE_SSE_STREAMING) and the sibling RestApiTool._getDeclaration() already returns the raw JSON schema in parameters unconditionally. One code path, matching the sibling.
  • auth_scheme/auth_credential are not | str. Python types them Optional[Union[AuthScheme, str]], but the str arm is never constructed anywhere in adk-python. Typed here as AuthScheme | undefined / AuthCredential | undefined.
  • entityOperations is Record<string, string[]>. Python's ApplicationIntegrationToolset.__init__ annotates it Optional[str] while IntegrationClient annotates it Optional[dict[str, list[str]]] and iterates it with .items(). The dict is the real contract; the stale str annotation is not reproduced.
  • entity/action default to '' rather than None. They are typed string, so an absent x-entity/x-action extension yields ''. Not observable on the wire: neither name is a parameter of the operation it is absent from, so prepareRequestParams drops it either way.
  • A predicate toolFilter is only applied when a ReadonlyContext is passed. Python calls the predicate with None when there is no context; adk-js's ToolPredicate requires one, so with no context every tool is returned. This mirrors OpenAPIToolset.getTools exactly rather than inventing a different rule for this toolset. The string-array filter has no such caveat.
  • Entity, action and connection names are interpolated into URLs unencoded, as in Python. They are developer configuration rather than model input, so this is not a trust-boundary issue, but a name containing & or # would corrupt the request.
  • OPTIONAL_FIELDS keeps the odd 'sortByColumns' entry (its siblings are snake_case). Copied verbatim from integration_connector_tool.py:68; parity wins for field names. Worth knowing that it is inert in both languages — both OpenAPI parsers snake_case the parameter to sort_by_columns, so the entry never matches. Not "fixed" here.

Pre-existing behaviour this port surfaces but does not change: adk-js's OperationParser.getParamName inserts an underscore before every capital without collapsing an existing one, so the generated jira_list_Issues operation ID becomes the tool name jira_list__issues (adk-python's _to_snake_case yields jira_list_issues). That is a difference in the shared OpenAPI parser affecting every openapi_tool consumer, not something this port introduces, so it is left alone and the test asserts the real adk-js name. Filed as separate follow-up work.

No any, no @ts-expect-error, no eslint-disable, no coverage suppression was added.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

npx vitest run --project unit:core core/test/tools/application_integration_tool core/test/utils/service_account_utils_test.ts147 passed (the whole stack), with 100% statements / branches / functions / lines on every file the stack adds.

The tests avoid mocking what can be run for real: integration_connector_tool_test.ts drives a real RestApiTool, a real ToolAuthHandler and a real Context (built from a real InvocationContext/LlmAgent/session), so the pending-auth path and the credential-exchange path are exercised rather than stubbed. application_integration_toolset_integration_test.ts wires the whole chain — real IntegrationClientConnectionsClientOpenApiSpecParserRestApiToolIntegrationConnectorTool — with only globalThis.fetch and google-auth-library stubbed, and asserts that invoking the tool POSTs to /v2/projects/.../integrations/ExecuteConnection:execute with triggerId=api_trigger/ExecuteConnection#list_Issues and a body carrying connectionName, serviceName, host, entity and operation. That is the assertion that catches spec-builder drift end to end.

CI does not run on this PR. The validation workflow is pull_request: branches: [main], and this PR targets feat/application-integration-toolset-part1, so no test job is triggered. It was therefore validated locally on the exact pushed commit b8430c10:

Command Result
npx vitest run --project unit:core core/test/tools/application_integration_tool core/test/utils/service_account_utils_test.ts 10 files, 147 passed
npm run build clean
npx tsc --noEmit -p core/tsconfig.json clean
npx eslint "core/**/*.ts" clean
npx prettier --check "core/**/*.ts" clean
npm run docs:check clean
bash scripts/check_license.sh clean

Part 1 (#578) carries the client half of this stack through the real matrix CI on main, and is fully green on the reviewed commit: run-tests passes on ubuntu-latest, macos-latest and windows-latest, alongside the cross-language job and check-license.

Initialization is retryable. A transient failure on the first getTools() used to leave the memoised promise permanently rejected, so the toolset could never produce a tool again. The memo is now cleared on failure, and the connector tools are published only once all of them are built, so a retry after a partial failure cannot append duplicates. Both behaviours are tested.

Proof the tests can fail. Each mutation was applied to the source, the targeted test file re-run, then the mutation reverted:

Mutation Test outcome
drop 'dynamic_auth_config' from EXCLUDE_FIELDS 1 failed — expected [ 'user_id', …(3) ] to deeply equal [ 'user_id', 'page_size', 'filter' ]
swap the entity/action precedence (action: operation['x-action'] ?? '') 1 failed — expected newConstructor{…} to match object { entity: 'Issues', action: '' }
invert the authOverrideEnabled guard 4 failed — expected newConstructor{…} to match object { authScheme: {…}, …(2) }
filterConnectorParameters filters in place instead of copying 1 failed — expected { properties: { user_id: {} }, …(1) } to deeply equal {…}
memoise the rejected initialization (drop the catch that clears initPromise) 2 failed — retries after a failed initialization instead of staying empty, does not duplicate tools across a retry
write the connector plumbing before spreading the model's args 1 failed — overwrites connector arguments supplied by the model

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

Not runnable in CI — it needs a real GCP project with Application Integration provisioned and a live connector connection. With ADC pointed at such a project:

  1. Create an integration named ExecuteConnection with trigger api_trigger/ExecuteConnection in the same region as the connection.
  2. Construct the toolset in connection mode against a real connection:
    const toolset = new ApplicationIntegrationToolset({
      project: process.env.GOOGLE_CLOUD_PROJECT!,
      location: process.env.GOOGLE_CLOUD_LOCATION!,
      connection: '<your-connection>',
      entityOperations: {Issues: ['LIST']},
      toolNamePrefix: 'jira',
    });
    const tools = await toolset.getTools();
  3. Confirm the tool names match the connection's entities, and that invoking the LIST tool returns a connectorOutputPayload.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

@AmaadMartin
AmaadMartin force-pushed the feat/application-integration-toolset-part2 branch 2 times, most recently from 2be3a73 to 043fcb1 Compare August 3, 2026 17:20
Amaad Martin added 5 commits August 3, 2026 10:39
Turns an Application Integration trigger, or an Integration Connector
connection's entity operations and actions, into ADK tools.

The connector arguments (connection name, service name, host, entity, operation,
action) are stripped from the model-facing declaration and re-applied after the
model's arguments are read, so a model cannot redirect a call. Caller-supplied
auth only reaches the connector when the connection enables auth overrides.

Because a TypeScript constructor cannot await, the network I/O adk-python
performs in __init__ runs on the first getTools() call, memoised so concurrent
callers share one initialization.
connectorAuth() only depends on the connection details, so calling it inside the
per-operation loop re-evaluated it for every tool and repeated the override
warning once per generated operation.
…ejection

A transient failure on the first getTools() left the memoised promise rejected
forever, so the toolset could never produce a tool again. The memo is now
cleared on failure, and the connector tools are published only once all of them
are built so a retry cannot append duplicates.

Also corrects the connector chain test, which asserted the corrupted
triggerId the fragment bug produced.
ApplicationIntegrationToolset exposed a public authConfig whose documented
workflow - set exchangedAuthCredential on it before getTools() - had no caller
anywhere: in adk-python the flow reaches the config through
BaseToolset.get_auth_config(), and adk-js has no such hook. At runtime the
exchanged credential is already resolved per call by
ToolAuthHandler.prepareAuthCredentials, which reads it from tool-context state.

Removing the field also removes the clone-on-read branch in getTools and
cloneWithAuthCredential, and lets the DEFAULT_CREDENTIAL_KEY export in
tool_auth_handler.ts - added solely to feed it - revert entirely, so this stack
no longer touches that file. The three tests that drove the field by hand go
with it; the remaining auth tests still pin that caller-supplied auth reaches
the connector tools only when the connection enables overrides.

The hook and this path will land together in the queued follow-up.

Also folds the toolset's one-use connection copy into a local and imports the
x-* extension type from the module that produces it.
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.

1 participant