Skip to content

Feat: port the GCS toolsets (GCSToolset / GCSAdminToolset) from adk-python - #528

Open
AmaadMartin wants to merge 4 commits into
mainfrom
feat/gcs-toolset-port
Open

Feat: port the GCS toolsets (GCSToolset / GCSAdminToolset) from adk-python#528
AmaadMartin wants to merge 4 commits into
mainfrom
feat/gcs-toolset-port

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 2, 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: An adk-js agent has no way to talk to Google Cloud Storage as a tool. adk-python ships two BaseToolset subclasses under src/google/adk/integrations/gcs/ that give an agent GCS access, deliberately split along a privilege boundary. adk-js has no equivalent.

Solution: Port the two toolsets to core/src/integrations/gcs/, built on FunctionTool:

  • GcsToolset — object operations: gcs_get_bucket, gcs_list_objects, gcs_get_object_metadata, gcs_get_object_data and, with GcsCapability.READ_WRITE, gcs_create_object and gcs_delete_objects.
  • GcsAdminToolset — bucket administration: gcs_list_buckets and, with READ_WRITE, gcs_create_bucket, gcs_update_bucket, gcs_delete_bucket.

The two classes stay separate on purpose: that is what lets an operator grant an agent read/write access to objects without also granting it the ability to delete a bucket. They share GcsToolsetBase (capability gating, client memoisation, tool filtering) and differ only in which tools they may expose, which is the decision the split exists to make. Capability gating (GcsToolSettings) defaults to read-only, so new GcsToolset({}) returns exactly four tools and no mutation is possible. No new dependency: @google-cloud/storage is already a core dependency.

import {
  GcsAdminToolset,
  GcsCapability,
  GcsToolset,
  LlmAgent,
} from '@google/adk';

const readWrite = new GcsToolset({
  toolSettings: {capabilities: [GcsCapability.READ_WRITE]},
});
const agent = new LlmAgent({
  name: 'storage_agent',
  model: 'gemini-2.5-flash',
  tools: [readWrite], // GcsAdminToolset deliberately NOT granted
});

Collision check. gh pr list --repo <fork> --state open --limit 1000 filtered for gcs|storage|bucket|blob returned three PRs; gh pr diff --name-only on each shows none of them touches core/src/integrations/ or adds GCS tools. The closest is #387 ("make @google-cloud/storage and the OTel GCP exporters optional peer dependencies"), which is adjacent but disjoint — it changes how gcs_artifact_service.ts / telemetry load the package and does not land this feature. This PR branches from main; if #387 merges first, the GCS toolsets would need the same lazy-load treatment as a follow-up.

Deliberate divergences from adk-python (each verified against the referenced Python file):

  1. Class names use Gcs, not GCS. adk-python exports GCSToolset / GCSAdminToolset / GCSCredentialsConfig. Class names never cross the model boundary, so local convention wins: this repo's other Cloud Storage class is GcsArtifactService, and mixing GcsToolResult with GCSToolset inside one feature is worse than either alone. Everything model-facing — tool names, parameter names, response keys, status values — is unchanged.
  2. Tool names are prefixed inside getTools(). adk-python's BaseToolset has a final get_tools_with_prefix() that the agent calls; adk-js's llm_agent.ts calls getTools() directly, so the toolset applies the prefix itself, exactly as MCPToolset does. Observable behaviour is identical: the model sees gcs_get_bucket.
  3. An array toolFilter matches the prefixed name (['gcs_get_bucket']), because adk-js's BaseToolset.isToolSelected compares tool.name. adk-python filters before prefixing. Local convention wins: toolFilter is a construction-time developer API that never crosses the model boundary.
  4. delete_objects formats the name list as [a, b] instead of Python's list repr ['a', 'b'], which is a Python-specific rendering.
  5. update_bucket patches through storage.bucket(name).setMetadata(...) and skips the request entirely when neither updatable field is supplied. adk-python fetches the bucket first and then calls patch(); dropping that fetch removes a round trip, at the cost of not surfacing a missing bucket when there is nothing to change.
  6. delete_objects issues Promise.all(names.map(n => bucket.file(n).delete())). @google-cloud/storage has no delete_blobs equivalent; deleteFiles deletes by query, which is a different contract.
  7. No GoogleTool / OAuth consent flow. adk-python wraps each function in GoogleTool, which resolves OAuth credentials and injects credentials= at call time. adk-js has no such class; auth comes from GcsCredentialsConfig or Application Default Credentials. Porting GoogleTool is out of scope for this change.
  8. ADC is the default. With no credentialsConfig, the client is built as new Storage({userAgent}) so ADC applies. This has no adk-python analogue, but every @google-cloud/* client defaults to ADC and a JS user expects it.

Where the approved plan was wrong, and what shipped instead:

  • The plan specified GCSCredentialsConfig.credentials?: AuthClient | GoogleAuth imported from google-auth-library ^10.3.0. That does not typecheck. The installed @google-cloud/storage@7.17.1 resolves google-auth-library@9.15.1, whose AuthClient declares a gaxios member that the root google-auth-library@10.7.0 AuthClient does not have, so a v10 client is not assignable to StorageOptions['authClient'] (and new OAuth2Client(...) from v10 is not either). Making it compile would have required an unchecked cast. Instead the field is storageOptions?: StorageOptions — the type the Storage client actually accepts, and already part of this repo's public API through GcsArtifactService. A pre-built client is passed as {storageOptions: {authClient}}; the clientId/clientSecret path becomes clientOptions: {clientId, clientSecret}, which hands the credentials to the Storage client's own auth stack rather than to a second copy of the auth library. No as casts and no suppressions anywhere in the diff.
  • The user agent is applied once where the client is constructed rather than inside toStorageOptions(), which also keeps credentials.ts from importing client.ts while client.ts imports it back.
  • The prefix constant is GCS_TOOL_NAME_PREFIX in types.ts, not DEFAULT_GCS_TOOL_NAME_PREFIX in storage_toolset.ts: nothing overrides it, so it is not a default, and keeping it in types.ts lets the tool modules build prefixed names without importing their own toolset.
  • Exports were added to core/src/index.ts only, not core/src/common.ts: common.ts is re-exported by index_web.ts (the browser bundle), and the plan forbids touching index_web.ts. GcsArtifactService, the other @google-cloud/storage consumer, is exported the same way.
  • DEFAULT_GCS_TOOL_SETTINGS is replaced by resolveAccess(settings) in helpers.ts, a single helper both toolsets share instead of an exported mutable default object.
  • Six test files rather than the plan's six-file list one-for-one: each mirrors a source module (client_test.ts, credentials_test.ts, storage_tool_test.ts, admin_tool_test.ts, storage_toolset_test.ts, admin_toolset_test.ts) instead of the plan's gcs_storage_toolset_test.ts / gcs_toolset_test.ts pair, which were two near-identical names for the same two classes.
  • Shipped as one PR rather than a stack. The privilege boundary is the feature, and its regression tests assert across both toolsets, so part 1 would land a half-feature that part 2 then re-specifies. The source is ~850 lines including doc comments; the rest is tests.

Known limitation (also stated in the GcsCredentialsConfig doc comment): an OAuth client id and secret alone carry no access token. adk-python mints one by driving the interactive consent flow inside GoogleTool; adk-js has no equivalent yet, so calls made through that path fail at request time and surface as a normal {status: 'ERROR'} result. The production paths today are a pre-built client via storageOptions, or ADC.

Security notes.

  • Two parameters touch the local filesystem of the machine running the agent and are documented as such in their model-facing .describe() text: gcs_create_object.source_file_path (reads an arbitrary local file and uploads it) and gcs_get_object_data.destination_file_path (writes an arbitrary local path). They are ported because they exist in adk-python; they were not widened (no globs, no directory recursion) and no opt-out knob was added.
  • Nothing logs bucket contents, object data or credentials.
  • The memoised Storage client cache lives on the toolset instance and is capped at 16 entries, dropped wholesale on overflow, because project ids reach it from model-supplied tool arguments.
  • Every tool catches all failures and returns {status: 'ERROR', error_details}; no tool throws.

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/integrations/gcs77 passed in 6 files:

  • storage_tool_test.ts (24) — every object tool, including the 404 → Object X not found in bucket Y mapping, UTF-8 vs base64 payload decoding, generation selection, destination_file_path, source_file_path, "neither data nor source_file_path", pagination arguments, and an error path for a read tool, a write tool and a non-Error rejection.
  • admin_tool_test.ts (13) — every bucket tool, both update_bucket fields, the no-field case that issues no patch, and an error path per tool.
  • storage_toolset_test.ts (12) and admin_toolset_test.ts (11) — capability gating (4/6 and 1/4 tools, 0 with capabilities: []), the privilege-boundary regression (no bucket verb ever reachable from GcsToolset, no object verb ever reachable from GcsAdminToolset, under every setting), array and predicate toolFilter, close(), and a table per toolset pinning the exact snake_case parameter names and required lists the model sees.
  • credentials_test.ts (12) — every validation branch and both toStorageOptions() branches.
  • client_test.ts (5) — user agent, config-derived options, memoisation, one client per project, and cache overflow.

Coverage of the new code, measured with npx vitest run --project unit:core core/test/integrations/gcs --coverage --coverage.include='core/src/integrations/gcs/**': 100% statements, 100% branches, 100% functions, 100% lines across all nine new source files.

Proof the tests can fail. Each mutation was applied to the source, the targeted suite was run, and the source was reverted:

Mutation Result
toolset_base.ts: drop the access.write branch 8 failed — AssertionError: Toolset does not expose a tool named gcs_create_bucket.
helpers.ts: decode with bytes.toString('utf8') instead of the fatal TextDecoder 1 failed — expected { status: 'SUCCESS', …(2) } to deeply equal { status: 'SUCCESS', …(2) } (base64 case)
admin_tool.ts: move delete_bucket into createAdminReadTools 5 failed — expected [ 'gcs_delete_bucket', …(1) ] to deeply equal [ 'gcs_list_buckets' ]
helpers.ts: drop autoPaginate: false from the page options 2 failed — expected "spy" to be called with arguments: [ { maxResults: 1, …(2) } ]
helpers.ts: always attach next_page_token 2 failed — expected { status: 'SUCCESS', …(2) } to strictly equal { status: 'SUCCESS', …(1) }
storage_tool.ts: return the generic error instead of the not-found message 2 failed — expected { status: 'ERROR', …(1) } to deeply equal { status: 'ERROR', …(1) }
storage_tool.ts: rename the page_size parameter to pageSize 2 failed — expected [ 'bucket_name', 'prefix', …(2) ] to deeply equal [ 'bucket_name', 'prefix', …(2) ]
storage_tool.ts: ignore generation in objectHandle 2 failed — expected "spy" to be called with arguments: [ 'test-object', { generation: 1 } ]
admin_tool.ts: always call setMetadata, even with no fields 1 failed — expected "spy" to not be called at all, but actually been called 1 times
toolset_base.ts: remove the client cache hit 2 failed — expected "spy" to be called 1 times, but got 2 times
credentials.ts: accept a client id or a secret instead of both 2 failed — expected [Function] to throw an error

No integration test was added. Every operation needs a live bucket and real credentials, so a test under tests/integration/ would perform network I/O. The tool and client tests mock @google-cloud/storage with vi.hoisted + vi.mock, following core/test/artifacts/gcs_artifact_service_test.ts; nothing in this change touches the network.

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

  1. gcloud auth application-default login and pick a project with a Cloud Storage bucket.
  2. Build an agent with object read/write access only:
    const agent = new LlmAgent({
      name: 'storage_agent',
      model: 'gemini-2.5-flash',
      tools: [
        new GcsToolset({
          toolSettings: {capabilities: [GcsCapability.READ_WRITE]},
        }),
      ],
    });
  3. Ask it to list the objects in the bucket, read one, and create a new one — the calls should succeed against the live bucket.
  4. Ask it to delete the bucket: no gcs_delete_bucket tool is offered, so it cannot.
  5. Add new GcsAdminToolset({toolSettings: {capabilities: [GcsCapability.READ_WRITE]}}) to tools and confirm gcs_list_buckets and gcs_delete_bucket now appear.

Local validation on the pushed commit:

  • npx vitest run --project unit:core core/test/integrations/gcs — 77 passed.
  • npx tsc --noEmit -p core/tsconfig.json — clean.
  • npm run build — clean.
  • npm run lint — clean.
  • npm run format:check — clean.
  • npm run docs:check — clean (all types in public signatures are exported).
  • bash scripts/check_license.sh — clean.
  • npx secretlint on the new files — clean.

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.

Amaad Martin added 4 commits August 2, 2026 08:36
Adds core/src/integrations/gcs with GCSToolset (object operations) and
GCSAdminToolset (bucket administration), kept as two classes so an agent
can be granted object access without bucket-level privileges. Tools are
built on FunctionTool and gated by GCSToolSettings capabilities.
Also replaces GCSCredentialsConfig.credentials (typed from
google-auth-library) with storageOptions: the repo's google-auth-library
10.x AuthClient is not assignable to the 9.15.1 AuthClient that the
installed @google-cloud/storage expects, and StorageOptions is the type
the client actually accepts.
Standardises the acronym on Gcs (matching GcsArtifactService), drops the
redundant gcs_ prefix inside integrations/gcs, mirrors each source module
with a same-named test, collapses the two toolsets onto a shared
GcsToolsetBase, and extracts the duplicated list/paginate and
object-handle blocks. The client cache moves from a module-level WeakMap
onto the toolset instance, keeping the size cap.
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