diff --git a/core/src/index.ts b/core/src/index.ts index 242f18fca..4f6855122 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -57,6 +57,21 @@ export { export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js'; export * from './integrations/agent_registry/agent_registry.js'; +export {GcsAdminToolset} from './integrations/gcs/admin_toolset.js'; +export type {GcsAdminToolsetOptions} from './integrations/gcs/admin_toolset.js'; +export { + GCS_DEFAULT_SCOPES, + GcsCredentialsConfig, +} from './integrations/gcs/credentials.js'; +export type {GcsCredentialsConfigOptions} from './integrations/gcs/credentials.js'; +export {GcsToolset} from './integrations/gcs/storage_toolset.js'; +export type {GcsToolsetOptions} from './integrations/gcs/toolset_base.js'; +export { + GCS_TOOL_NAME_PREFIX, + GcsCapability, + GcsToolStatus, +} from './integrations/gcs/types.js'; +export type {GcsToolResult, GcsToolSettings} from './integrations/gcs/types.js'; export * from './telemetry/google_cloud.js'; export * from './telemetry/setup.js'; export * from './tools/mcp/load_mcp_resource_tool.js'; diff --git a/core/src/integrations/gcs/admin_tool.ts b/core/src/integrations/gcs/admin_tool.ts new file mode 100644 index 000000000..19920eca4 --- /dev/null +++ b/core/src/integrations/gcs/admin_tool.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BucketMetadata, Storage} from '@google-cloud/storage'; +import {z} from 'zod'; + +import {BaseTool} from '../../tools/base_tool.js'; +import {FunctionTool} from '../../tools/function_tool.js'; +import {GcsClientProvider} from './client.js'; +import {listResult, pageOptions, toErrorResult} from './helpers.js'; +import {GCS_TOOL_NAME_PREFIX, GcsToolResult, GcsToolStatus} from './types.js'; + +const projectIdSchema = z.string().describe('The Google Cloud project id.'); + +async function listBuckets( + storage: Storage, + args: {project_id: string; page_size?: number; page_token?: string}, +): Promise { + try { + const [buckets, nextQuery] = await storage.getBuckets({ + project: args.project_id, + ...pageOptions(args.page_size, args.page_token), + }); + return listResult( + buckets.map((bucket) => bucket.name), + nextQuery, + args.page_size, + ); + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function createBucket( + storage: Storage, + args: {bucket_name: string; location?: string}, +): Promise { + try { + const [bucket] = await storage.createBucket( + args.bucket_name, + args.location !== undefined ? {location: args.location} : {}, + ); + return { + status: GcsToolStatus.SUCCESS, + results: `Bucket ${bucket.name} created successfully.`, + }; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function updateBucket( + storage: Storage, + args: { + bucket_name: string; + versioning_enabled?: boolean; + uniform_bucket_level_access_enabled?: boolean; + }, +): Promise { + try { + const metadata: BucketMetadata = { + ...(args.versioning_enabled !== undefined + ? {versioning: {enabled: args.versioning_enabled}} + : {}), + ...(args.uniform_bucket_level_access_enabled !== undefined + ? { + iamConfiguration: { + uniformBucketLevelAccess: { + enabled: args.uniform_bucket_level_access_enabled, + }, + }, + } + : {}), + }; + if (Object.keys(metadata).length > 0) { + await storage.bucket(args.bucket_name).setMetadata(metadata); + } + return { + status: GcsToolStatus.SUCCESS, + results: `Bucket ${args.bucket_name} updated successfully.`, + }; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function deleteBucket( + storage: Storage, + args: {bucket_name: string}, +): Promise { + try { + await storage.bucket(args.bucket_name).delete(); + return { + status: GcsToolStatus.SUCCESS, + results: `Bucket ${args.bucket_name} deleted successfully.`, + }; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +/** Bucket administration tools that only read from Cloud Storage. */ +export function createAdminReadTools(getClient: GcsClientProvider): BaseTool[] { + return [ + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_list_buckets`, + description: 'List GCS bucket names in a Google Cloud project.', + parameters: z.object({ + project_id: projectIdSchema, + page_size: z + .number() + .int() + .min(1) + .optional() + .describe( + 'The maximum number of buckets to return in a single page.', + ), + page_token: z + .string() + .optional() + .describe( + 'A page token, received from a previous list_buckets call.', + ), + }), + execute: (args) => listBuckets(getClient(args.project_id), args), + }), + ]; +} + +/** Bucket administration tools that mutate Cloud Storage. */ +export function createAdminWriteTools( + getClient: GcsClientProvider, +): BaseTool[] { + return [ + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_create_bucket`, + description: 'Create a new GCS bucket.', + parameters: z.object({ + project_id: projectIdSchema, + bucket_name: z + .string() + .describe('The name of the GCS bucket to create.'), + location: z.string().optional().describe('The location of the bucket.'), + }), + execute: (args) => createBucket(getClient(args.project_id), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_update_bucket`, + description: 'Update properties of a GCS bucket.', + parameters: z.object({ + bucket_name: z + .string() + .describe('The name of the GCS bucket to update.'), + versioning_enabled: z + .boolean() + .optional() + .describe('Whether to enable versioning for the bucket.'), + uniform_bucket_level_access_enabled: z + .boolean() + .optional() + .describe('Whether to enable uniform bucket-level access.'), + }), + execute: (args) => updateBucket(getClient(), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_delete_bucket`, + description: 'Delete a GCS bucket.', + parameters: z.object({ + bucket_name: z + .string() + .describe('The name of the GCS bucket to delete.'), + }), + execute: (args) => deleteBucket(getClient(), args), + }), + ]; +} diff --git a/core/src/integrations/gcs/admin_toolset.ts b/core/src/integrations/gcs/admin_toolset.ts new file mode 100644 index 000000000..1a9dccc52 --- /dev/null +++ b/core/src/integrations/gcs/admin_toolset.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {experimental} from '../../utils/experimental.js'; +import {createAdminReadTools, createAdminWriteTools} from './admin_tool.js'; +import {GcsToolsetBase, GcsToolsetOptions} from './toolset_base.js'; + +/** Options for {@link GcsAdminToolset}. */ +export type GcsAdminToolsetOptions = GcsToolsetOptions; + +/** + * Toolset for Cloud Storage bucket administration (Experimental). + * + * The tool names are `gcs_list_buckets` and, with the + * `GcsCapability.READ_WRITE` capability, `gcs_create_bucket`, + * `gcs_update_bucket` and `gcs_delete_bucket`. + * + * This toolset is deliberately separate from `GcsToolset`: granting it hands + * an agent bucket-level privileges, including bucket deletion. + */ +@experimental +export class GcsAdminToolset extends GcsToolsetBase { + constructor(options: GcsAdminToolsetOptions = {}) { + super(options, {read: createAdminReadTools, write: createAdminWriteTools}); + } +} diff --git a/core/src/integrations/gcs/client.ts b/core/src/integrations/gcs/client.ts new file mode 100644 index 000000000..1e2a955a4 --- /dev/null +++ b/core/src/integrations/gcs/client.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Storage} from '@google-cloud/storage'; + +import {version} from '../../version.js'; +import {GcsCredentialsConfig} from './credentials.js'; + +/** User agent reported by every Cloud Storage client the tools build. */ +const USER_AGENT = `adk-gcs-tool google-adk/${version}`; + +/** Supplies the Cloud Storage client a tool should operate through. */ +export type GcsClientProvider = (project?: string) => Storage; + +/** + * Builds a Cloud Storage client. Without a credentials config the client is + * built from Application Default Credentials. + */ +export function createGcsClient( + credentialsConfig?: GcsCredentialsConfig, + project?: string, +): Storage { + return new Storage({ + ...(credentialsConfig + ? credentialsConfig.toStorageOptions(project) + : {...(project ? {projectId: project} : {})}), + userAgent: USER_AGENT, + }); +} diff --git a/core/src/integrations/gcs/credentials.ts b/core/src/integrations/gcs/credentials.ts new file mode 100644 index 000000000..a30128fce --- /dev/null +++ b/core/src/integrations/gcs/credentials.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {StorageOptions} from '@google-cloud/storage'; + +import {experimental} from '../../utils/experimental.js'; + +/** Scopes requested when none are configured explicitly. */ +export const GCS_DEFAULT_SCOPES = [ + 'https://www.googleapis.com/auth/devstorage.full_control', +]; + +/** Options for {@link GcsCredentialsConfig}. */ +export interface GcsCredentialsConfigOptions { + /** + * Ready-made options for the Cloud Storage client, and the place to pass an + * already-authenticated client as `{authClient}`. That client is used for + * every end user, so only set it when it is allowed to reach every end + * user's data. Mutually exclusive with `clientId`, `clientSecret` and + * `scopes`. + */ + storageOptions?: StorageOptions; + /** The OAuth client id to use. Requires `clientSecret`. */ + clientId?: string; + /** The OAuth client secret to use. Requires `clientId`. */ + clientSecret?: string; + /** The scopes to request. Defaults to {@link GCS_DEFAULT_SCOPES}. */ + scopes?: string[]; + /** The Google Cloud project the client operates against. */ + projectId?: string; +} + +/** + * Credentials configuration for the GCS toolsets (Experimental). + * + * Known limitation: an OAuth client id and secret alone carry no access + * token. adk-python mints one by driving the interactive OAuth consent flow, + * which adk-js does not implement yet, so requests made through the + * `clientId`/`clientSecret` path fail at request time and surface as a normal + * `{status: 'ERROR'}` tool result. Pass an authenticated client through + * `storageOptions`, or pass no credentials config at all and rely on + * Application Default Credentials. + */ +@experimental +export class GcsCredentialsConfig { + readonly storageOptions?: StorageOptions; + readonly clientId?: string; + readonly clientSecret?: string; + readonly scopes: string[]; + readonly projectId?: string; + + constructor(options: GcsCredentialsConfigOptions) { + if ( + options.storageOptions && + (options.clientId || options.clientSecret || options.scopes) + ) { + throw new Error( + 'If storageOptions are provided, clientId, clientSecret and scopes must not be provided.', + ); + } + if ( + !options.storageOptions && + !(options.clientId && options.clientSecret) + ) { + throw new Error( + 'Must provide either storageOptions, or both clientId and clientSecret.', + ); + } + + this.storageOptions = options.storageOptions; + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.scopes = options.scopes ?? GCS_DEFAULT_SCOPES; + this.projectId = options.projectId; + } + + /** + * Builds the options used to construct a Cloud Storage client. + * + * @param project Overrides the configured `projectId` when supplied. + */ + toStorageOptions(project?: string): StorageOptions { + const projectId = project ?? this.projectId; + return { + ...(this.storageOptions ?? { + clientOptions: { + clientId: this.clientId, + clientSecret: this.clientSecret, + }, + scopes: this.scopes, + }), + ...(projectId ? {projectId} : {}), + }; + } +} diff --git a/core/src/integrations/gcs/helpers.ts b/core/src/integrations/gcs/helpers.ts new file mode 100644 index 000000000..603ddf3d8 --- /dev/null +++ b/core/src/integrations/gcs/helpers.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GcsCapability, + GcsToolResult, + GcsToolSettings, + GcsToolStatus, +} from './types.js'; + +/** + * Resolves the access a settings object grants, defaulting to read-only when + * no capabilities were configured. + */ +export function resolveAccess(settings?: GcsToolSettings): { + read: boolean; + write: boolean; +} { + const capabilities = settings?.capabilities ?? [GcsCapability.READ_ONLY]; + const write = capabilities.includes(GcsCapability.READ_WRITE); + return {read: write || capabilities.includes(GcsCapability.READ_ONLY), write}; +} + +/** Converts a caught value into the error result every GCS tool returns. */ +export function toErrorResult(error: unknown): GcsToolResult { + return { + status: GcsToolStatus.ERROR, + error_details: error instanceof Error ? error.message : String(error), + }; +} + +/** Whether a caught value is a Cloud Storage 404. */ +export function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 404 + ); +} + +/** + * Query options that make a Cloud Storage list call return exactly one page, + * mirroring adk-python's single `next(pages)` read. + */ +export function pageOptions(pageSize?: number, pageToken?: string) { + return { + ...(pageSize !== undefined + ? {maxResults: pageSize, autoPaginate: false} + : {}), + ...(pageToken !== undefined ? {pageToken} : {}), + }; +} + +/** + * Reads the page token out of the `nextQuery` value returned by the Cloud + * Storage list APIs, which type it as `{}`. + */ +function nextPageToken(query: unknown): string | undefined { + if (typeof query !== 'object' || query === null || !('pageToken' in query)) { + return undefined; + } + return typeof query.pageToken === 'string' ? query.pageToken : undefined; +} + +/** + * Builds the result of a list call, carrying the next page token only when + * paging was requested and the API returned one. + */ +export function listResult( + names: string[], + nextQuery: unknown, + pageSize?: number, +): GcsToolResult { + const token = pageSize !== undefined ? nextPageToken(nextQuery) : undefined; + return { + status: GcsToolStatus.SUCCESS, + results: names, + ...(token ? {next_page_token: token} : {}), + }; +} + +/** + * Decodes downloaded object bytes as UTF-8 text, falling back to base64 when + * the payload is not valid UTF-8 so that it stays JSON-serializable. + */ +export function decodeObjectData(bytes: Buffer): { + content: string; + encoding: 'text' | 'base64'; +} { + try { + return { + content: new TextDecoder('utf-8', {fatal: true}).decode(bytes), + encoding: 'text', + }; + } catch { + return {content: bytes.toString('base64'), encoding: 'base64'}; + } +} diff --git a/core/src/integrations/gcs/storage_tool.ts b/core/src/integrations/gcs/storage_tool.ts new file mode 100644 index 000000000..194768c08 --- /dev/null +++ b/core/src/integrations/gcs/storage_tool.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {File, Storage} from '@google-cloud/storage'; +import {z} from 'zod'; + +import {BaseTool} from '../../tools/base_tool.js'; +import {FunctionTool} from '../../tools/function_tool.js'; +import {GcsClientProvider} from './client.js'; +import { + decodeObjectData, + isNotFoundError, + listResult, + pageOptions, + toErrorResult, +} from './helpers.js'; +import {GCS_TOOL_NAME_PREFIX, GcsToolResult, GcsToolStatus} from './types.js'; + +const bucketNameSchema = z.string().describe('The name of the GCS bucket.'); +const objectNameSchema = z.string().describe('The name of the GCS object.'); +const generationSchema = z + .number() + .int() + .optional() + .describe('If present, selects a specific generation of this object.'); +const pageSizeSchema = z + .number() + .int() + .min(1) + .optional() + .describe('The maximum number of objects to return in a single page.'); +const pageTokenSchema = z + .string() + .optional() + .describe('A page token, received from a previous list_objects call.'); + +/** Arguments identifying a single, optionally versioned, object. */ +interface ObjectArgs { + bucket_name: string; + object_name: string; + generation?: number; +} + +function objectHandle(storage: Storage, args: ObjectArgs): File { + return storage + .bucket(args.bucket_name) + .file( + args.object_name, + args.generation !== undefined ? {generation: args.generation} : undefined, + ); +} + +function objectErrorResult(error: unknown, args: ObjectArgs): GcsToolResult { + return isNotFoundError(error) + ? { + status: GcsToolStatus.ERROR, + error_details: `Object ${args.object_name} not found in bucket ${args.bucket_name}`, + } + : toErrorResult(error); +} + +async function getBucket( + storage: Storage, + args: {bucket_name: string}, +): Promise { + try { + const [metadata] = await storage.bucket(args.bucket_name).getMetadata(); + return {status: GcsToolStatus.SUCCESS, results: metadata}; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function listObjects( + storage: Storage, + args: { + bucket_name: string; + prefix?: string; + page_size?: number; + page_token?: string; + }, +): Promise { + try { + const [files, nextQuery] = await storage.bucket(args.bucket_name).getFiles({ + ...(args.prefix !== undefined ? {prefix: args.prefix} : {}), + ...pageOptions(args.page_size, args.page_token), + }); + return listResult( + files.map((file) => file.name), + nextQuery, + args.page_size, + ); + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function getObjectMetadata( + storage: Storage, + args: ObjectArgs, +): Promise { + try { + const [metadata] = await objectHandle(storage, args).getMetadata(); + return {status: GcsToolStatus.SUCCESS, results: metadata}; + } catch (error: unknown) { + return objectErrorResult(error, args); + } +} + +async function getObjectData( + storage: Storage, + args: ObjectArgs & {destination_file_path?: string}, +): Promise { + try { + const file = objectHandle(storage, args); + + if (args.destination_file_path !== undefined) { + await file.download({destination: args.destination_file_path}); + return { + status: GcsToolStatus.SUCCESS, + results: `Object ${args.object_name} downloaded successfully to ${args.destination_file_path}.`, + }; + } + + const [bytes] = await file.download(); + const {content, encoding} = decodeObjectData(bytes); + return {status: GcsToolStatus.SUCCESS, results: content, encoding}; + } catch (error: unknown) { + return objectErrorResult(error, args); + } +} + +async function createObject( + storage: Storage, + args: { + bucket_name: string; + object_name: string; + data?: string; + source_file_path?: string; + }, +): Promise { + try { + const bucket = storage.bucket(args.bucket_name); + if (args.source_file_path !== undefined) { + await bucket.upload(args.source_file_path, { + destination: args.object_name, + }); + } else if (args.data !== undefined) { + await bucket.file(args.object_name).save(args.data); + } else { + return { + status: GcsToolStatus.ERROR, + error_details: "Either 'data' or 'source_file_path' must be provided.", + }; + } + + return { + status: GcsToolStatus.SUCCESS, + results: `Object ${args.object_name} created successfully in bucket ${args.bucket_name}.`, + }; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +async function deleteObjects( + storage: Storage, + args: {bucket_name: string; object_names: string[]}, +): Promise { + try { + const bucket = storage.bucket(args.bucket_name); + await Promise.all( + args.object_names.map((name) => bucket.file(name).delete()), + ); + return { + status: GcsToolStatus.SUCCESS, + results: `Objects [${args.object_names.join(', ')}] deleted successfully from bucket ${args.bucket_name}.`, + }; + } catch (error: unknown) { + return toErrorResult(error); + } +} + +/** Object tools that only read from Cloud Storage. */ +export function createStorageReadTools( + getClient: GcsClientProvider, +): BaseTool[] { + return [ + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_get_bucket`, + description: 'Get metadata information about a GCS bucket.', + parameters: z.object({bucket_name: bucketNameSchema}), + execute: (args) => getBucket(getClient(), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_get_object_data`, + description: 'Get the content/data of a GCS object (blob).', + parameters: z.object({ + bucket_name: bucketNameSchema, + object_name: objectNameSchema, + generation: generationSchema, + destination_file_path: z + .string() + .optional() + .describe( + 'If present, the downloaded object is written to this path on the local filesystem of the machine running the agent, instead of being returned.', + ), + }), + execute: (args) => getObjectData(getClient(), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_get_object_metadata`, + description: 'Get metadata information about a GCS object (blob).', + parameters: z.object({ + bucket_name: bucketNameSchema, + object_name: objectNameSchema, + generation: generationSchema, + }), + execute: (args) => getObjectMetadata(getClient(), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_list_objects`, + description: 'List object names in a GCS bucket.', + parameters: z.object({ + bucket_name: bucketNameSchema, + prefix: z + .string() + .optional() + .describe( + 'Filter results to objects whose names begin with this prefix.', + ), + page_size: pageSizeSchema, + page_token: pageTokenSchema, + }), + execute: (args) => listObjects(getClient(), args), + }), + ]; +} + +/** Object tools that mutate Cloud Storage. */ +export function createStorageWriteTools( + getClient: GcsClientProvider, +): BaseTool[] { + return [ + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_create_object`, + description: + 'Create a new object (blob) in a GCS bucket from provided data or a local file.', + parameters: z.object({ + bucket_name: bucketNameSchema, + object_name: z + .string() + .describe('The name of the GCS object to create.'), + data: z + .string() + .optional() + .describe('The content to write to the object.'), + source_file_path: z + .string() + .optional() + .describe( + 'The path of the file to upload, read from the local filesystem of the machine running the agent.', + ), + }), + execute: (args) => createObject(getClient(), args), + }), + new FunctionTool({ + name: `${GCS_TOOL_NAME_PREFIX}_delete_objects`, + description: + 'Delete multiple objects (blobs) from a GCS bucket. Note: a GCS bucket must be empty before it can be deleted. Use this tool to delete all objects if you intend to delete the bucket.', + parameters: z.object({ + bucket_name: bucketNameSchema, + object_names: z + .array(z.string()) + .describe('List of object names to delete.'), + }), + execute: (args) => deleteObjects(getClient(), args), + }), + ]; +} diff --git a/core/src/integrations/gcs/storage_toolset.ts b/core/src/integrations/gcs/storage_toolset.ts new file mode 100644 index 000000000..b62707a3f --- /dev/null +++ b/core/src/integrations/gcs/storage_toolset.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {experimental} from '../../utils/experimental.js'; +import { + createStorageReadTools, + createStorageWriteTools, +} from './storage_tool.js'; +import {GcsToolsetBase, GcsToolsetOptions} from './toolset_base.js'; + +/** + * Toolset for interacting with objects in Cloud Storage (Experimental). + * + * The tool names are `gcs_get_bucket`, `gcs_get_object_data`, + * `gcs_get_object_metadata`, `gcs_list_objects` and, with the + * `GcsCapability.READ_WRITE` capability, `gcs_create_object` and + * `gcs_delete_objects`. + * + * Bucket administration lives in `GcsAdminToolset` so that an agent can be + * granted object access without being granted the ability to delete a + * bucket. + */ +@experimental +export class GcsToolset extends GcsToolsetBase { + constructor(options: GcsToolsetOptions = {}) { + super(options, { + read: createStorageReadTools, + write: createStorageWriteTools, + }); + } +} diff --git a/core/src/integrations/gcs/toolset_base.ts b/core/src/integrations/gcs/toolset_base.ts new file mode 100644 index 000000000..e1cc1215b --- /dev/null +++ b/core/src/integrations/gcs/toolset_base.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Storage} from '@google-cloud/storage'; + +import {ReadonlyContext} from '../../agents/readonly_context.js'; +import {BaseTool} from '../../tools/base_tool.js'; +import {BaseToolset, ToolPredicate} from '../../tools/base_toolset.js'; +import {createGcsClient, GcsClientProvider} from './client.js'; +import {GcsCredentialsConfig} from './credentials.js'; +import {resolveAccess} from './helpers.js'; +import {GCS_TOOL_NAME_PREFIX, GcsToolSettings} from './types.js'; + +/** Options shared by the GCS toolsets. */ +export interface GcsToolsetOptions { + /** Selects which of the toolset's tools are exposed to the model. */ + toolFilter?: ToolPredicate | string[]; + /** Auth for the Cloud Storage client. Defaults to ADC when omitted. */ + credentialsConfig?: GcsCredentialsConfig; + /** Capability gating. Defaults to read-only. */ + toolSettings?: GcsToolSettings; +} + +/** The tools a GCS toolset may expose, split by the access they need. */ +export interface GcsToolFactories { + read: (getClient: GcsClientProvider) => BaseTool[]; + write: (getClient: GcsClientProvider) => BaseTool[]; +} + +/** + * Cap on memoised clients. Project ids reach the provider from + * model-supplied tool arguments, so the cache is bounded and dropped + * wholesale once it overflows. + */ +const MAX_CACHED_CLIENTS = 16; + +/** + * Capability gating, client memoisation and tool filtering shared by + * `GcsToolset` and `GcsAdminToolset`. Which tools each of them may expose is + * the whole point of keeping them apart, so that stays with the subclass. + */ +export abstract class GcsToolsetBase extends BaseToolset { + private readonly clients = new Map(); + + /** Memoised per project so repeated tool calls reuse one client. */ + private readonly getClient: GcsClientProvider = (project) => { + const key = project ?? ''; + const cached = this.clients.get(key); + if (cached) { + return cached; + } + + const client = createGcsClient(this.options.credentialsConfig, project); + if (this.clients.size >= MAX_CACHED_CLIENTS) { + this.clients.clear(); + } + this.clients.set(key, client); + return client; + }; + + constructor( + private readonly options: GcsToolsetOptions, + private readonly toolFactories: GcsToolFactories, + ) { + super(options.toolFilter || [], GCS_TOOL_NAME_PREFIX); + } + + override async getTools(context?: ReadonlyContext): Promise { + const access = resolveAccess(this.options.toolSettings); + const tools = [ + ...(access.read ? this.toolFactories.read(this.getClient) : []), + ...(access.write ? this.toolFactories.write(this.getClient) : []), + ]; + + return tools.filter((tool) => { + if (Array.isArray(this.toolFilter) && this.toolFilter.length > 0) { + return this.toolFilter.includes(tool.name); + } + return context ? this.isToolSelected(tool, context) : true; + }); + } + + override async close(): Promise { + // The Cloud Storage client holds no persistent connection to release. + } +} diff --git a/core/src/integrations/gcs/types.ts b/core/src/integrations/gcs/types.ts new file mode 100644 index 000000000..81c06e131 --- /dev/null +++ b/core/src/integrations/gcs/types.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Prefix applied to every GCS tool name exposed to the model. */ +export const GCS_TOOL_NAME_PREFIX = 'gcs'; + +/** Outcome of a GCS tool invocation. */ +export enum GcsToolStatus { + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', +} + +/** + * Result payload returned to the model by every GCS tool. + * + * The keys are snake_case and the status values are SCREAMING_CASE because + * this object crosses the model boundary as the function response, and it + * must stay identical to the payload adk-python returns. + */ +export interface GcsToolResult { + status: GcsToolStatus; + results?: unknown; + error_details?: string; + next_page_token?: string; + encoding?: 'text' | 'base64'; +} + +/** Type of operations a GCS toolset is allowed to expose. */ +export enum GcsCapability { + /** Only read operations are allowed. */ + READ_ONLY = 'read_only', + /** Both read and write operations are allowed. */ + READ_WRITE = 'read_write', +} + +/** Settings for GCS tools. */ +export interface GcsToolSettings { + /** + * Allowed capabilities for GCS tools. Defaults to + * `[GcsCapability.READ_ONLY]` when omitted, so tools allow only read + * operations. This behaviour may change in future versions. + */ + capabilities?: GcsCapability[]; +} diff --git a/core/test/integrations/gcs/admin_tool_test.ts b/core/test/integrations/gcs/admin_tool_test.ts new file mode 100644 index 000000000..759b0ab9e --- /dev/null +++ b/core/test/integrations/gcs/admin_tool_test.ts @@ -0,0 +1,265 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Context, GcsAdminToolset, GcsToolStatus} from '@google/adk'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {createToolContext, getTool, READ_WRITE} from './test_utils.js'; + +const {StorageMock, fakes} = vi.hoisted(() => { + const bucket = {setMetadata: vi.fn(), delete: vi.fn()}; + const storage = { + bucket: vi.fn(() => bucket), + getBuckets: vi.fn(), + createBucket: vi.fn(), + }; + return {StorageMock: vi.fn(() => storage), fakes: {bucket, storage}}; +}); + +vi.mock('@google-cloud/storage', () => ({Storage: StorageMock})); + +describe('GCS admin tools', () => { + let toolset: GcsAdminToolset; + let toolContext: Context; + + beforeEach(async () => { + vi.clearAllMocks(); + fakes.storage.getBuckets.mockResolvedValue([[{name: 'test-bucket'}]]); + fakes.storage.createBucket.mockResolvedValue([{name: 'test-bucket'}]); + fakes.bucket.setMetadata.mockResolvedValue(undefined); + fakes.bucket.delete.mockResolvedValue(undefined); + + toolset = new GcsAdminToolset({toolSettings: READ_WRITE}); + toolContext = await createToolContext(); + }); + + describe('gcs_list_buckets', () => { + it('lists bucket names without pagination', async () => { + const tool = await getTool(toolset, 'gcs_list_buckets'); + + const result = await tool.runAsync({ + args: {project_id: 'test-project'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-bucket'], + }); + expect(fakes.storage.getBuckets).toHaveBeenCalledWith({ + project: 'test-project', + }); + }); + + it('returns the next page token when a page size is given', async () => { + fakes.storage.getBuckets.mockResolvedValue([ + [{name: 'test-bucket'}], + {pageToken: 'next-page-token'}, + ]); + const tool = await getTool(toolset, 'gcs_list_buckets'); + + const result = await tool.runAsync({ + args: {project_id: 'test-project', page_size: 1, page_token: 'token'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-bucket'], + next_page_token: 'next-page-token', + }); + expect(fakes.storage.getBuckets).toHaveBeenCalledWith({ + project: 'test-project', + maxResults: 1, + pageToken: 'token', + autoPaginate: false, + }); + }); + + it('reports a failed request as an error result', async () => { + fakes.storage.getBuckets.mockRejectedValue(new Error('list failed')); + const tool = await getTool(toolset, 'gcs_list_buckets'); + + const result = await tool.runAsync({ + args: {project_id: 'test-project'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'list failed', + }); + }); + }); + + describe('gcs_create_bucket', () => { + it('creates a bucket without a location', async () => { + const tool = await getTool(toolset, 'gcs_create_bucket'); + + const result = await tool.runAsync({ + args: {project_id: 'test-project', bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: 'Bucket test-bucket created successfully.', + }); + expect(fakes.storage.createBucket).toHaveBeenCalledWith( + 'test-bucket', + {}, + ); + }); + + it('creates a bucket in the requested location', async () => { + const tool = await getTool(toolset, 'gcs_create_bucket'); + + await tool.runAsync({ + args: { + project_id: 'test-project', + bucket_name: 'test-bucket', + location: 'US', + }, + toolContext, + }); + + expect(fakes.storage.createBucket).toHaveBeenCalledWith('test-bucket', { + location: 'US', + }); + }); + + it('reports a failed request as an error result', async () => { + fakes.storage.createBucket.mockRejectedValue(new Error('create failed')); + const tool = await getTool(toolset, 'gcs_create_bucket'); + + const result = await tool.runAsync({ + args: {project_id: 'test-project', bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'create failed', + }); + }); + }); + + describe('gcs_update_bucket', () => { + it('enables versioning', async () => { + const tool = await getTool(toolset, 'gcs_update_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', versioning_enabled: true}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: 'Bucket test-bucket updated successfully.', + }); + expect(fakes.bucket.setMetadata).toHaveBeenCalledWith({ + versioning: {enabled: true}, + }); + }); + + it('enables uniform bucket-level access', async () => { + const tool = await getTool(toolset, 'gcs_update_bucket'); + + await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + uniform_bucket_level_access_enabled: true, + }, + toolContext, + }); + + expect(fakes.bucket.setMetadata).toHaveBeenCalledWith({ + iamConfiguration: {uniformBucketLevelAccess: {enabled: true}}, + }); + }); + + it('applies both fields in a single patch', async () => { + const tool = await getTool(toolset, 'gcs_update_bucket'); + + await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + versioning_enabled: false, + uniform_bucket_level_access_enabled: false, + }, + toolContext, + }); + + expect(fakes.bucket.setMetadata).toHaveBeenCalledTimes(1); + expect(fakes.bucket.setMetadata).toHaveBeenCalledWith({ + versioning: {enabled: false}, + iamConfiguration: {uniformBucketLevelAccess: {enabled: false}}, + }); + }); + + it('issues no patch when no updatable field is supplied', async () => { + const tool = await getTool(toolset, 'gcs_update_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: 'Bucket test-bucket updated successfully.', + }); + expect(fakes.bucket.setMetadata).not.toHaveBeenCalled(); + }); + + it('reports a failed patch as an error result', async () => { + fakes.bucket.setMetadata.mockRejectedValue(new Error('patch failed')); + const tool = await getTool(toolset, 'gcs_update_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', versioning_enabled: true}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'patch failed', + }); + }); + }); + + describe('gcs_delete_bucket', () => { + it('deletes the bucket', async () => { + const tool = await getTool(toolset, 'gcs_delete_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: 'Bucket test-bucket deleted successfully.', + }); + expect(fakes.storage.bucket).toHaveBeenCalledWith('test-bucket'); + expect(fakes.bucket.delete).toHaveBeenCalledTimes(1); + }); + + it('reports a failed delete as an error result', async () => { + fakes.bucket.delete.mockRejectedValue(new Error('delete failed')); + const tool = await getTool(toolset, 'gcs_delete_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'delete failed', + }); + }); + }); +}); diff --git a/core/test/integrations/gcs/admin_toolset_test.ts b/core/test/integrations/gcs/admin_toolset_test.ts new file mode 100644 index 000000000..dee5901ba --- /dev/null +++ b/core/test/integrations/gcs/admin_toolset_test.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseTool, + GCS_TOOL_NAME_PREFIX, + GcsAdminToolset, + isFunctionTool, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; +import { + ADMIN_READ_TOOL_NAMES, + ADMIN_WRITE_TOOL_NAMES, + createToolContext, + expectParameters, + NO_CAPABILITIES, + READ_ONLY, + READ_WRITE, + STORAGE_READ_TOOL_NAMES, + STORAGE_WRITE_TOOL_NAMES, + toolNames, +} from './test_utils.js'; + +/** The parameter contract the model sees, mirroring adk-python. */ +const EXPECTED_PARAMETERS = { + gcs_list_buckets: { + declared: ['project_id', 'page_size', 'page_token'], + required: ['project_id'], + }, + gcs_create_bucket: { + declared: ['project_id', 'bucket_name', 'location'], + required: ['project_id', 'bucket_name'], + }, + gcs_update_bucket: { + declared: [ + 'bucket_name', + 'versioning_enabled', + 'uniform_bucket_level_access_enabled', + ], + required: ['bucket_name'], + }, + gcs_delete_bucket: {declared: ['bucket_name'], required: ['bucket_name']}, +}; + +describe('GcsAdminToolset', () => { + it('prefixes tool names with the GCS prefix', async () => { + const toolset = new GcsAdminToolset(); + expect(toolset.prefix).toBe(GCS_TOOL_NAME_PREFIX); + + const names = await toolNames(toolset); + expect( + names.every((name) => name.startsWith(`${GCS_TOOL_NAME_PREFIX}_`)), + ).toBe(true); + }); + + it('exposes only list_buckets by default', async () => { + expect(await toolNames(new GcsAdminToolset())).toEqual( + ADMIN_READ_TOOL_NAMES, + ); + }); + + it('exposes only list_buckets with the read-only capability', async () => { + const toolset = new GcsAdminToolset({toolSettings: READ_ONLY}); + expect(await toolNames(toolset)).toEqual(ADMIN_READ_TOOL_NAMES); + }); + + it('adds the bucket mutation tools with the read-write capability', async () => { + const toolset = new GcsAdminToolset({toolSettings: READ_WRITE}); + expect(await toolNames(toolset)).toEqual( + [...ADMIN_READ_TOOL_NAMES, ...ADMIN_WRITE_TOOL_NAMES].sort(), + ); + }); + + it('exposes no tools without any capability', async () => { + const toolset = new GcsAdminToolset({toolSettings: NO_CAPABILITIES}); + expect(await toolset.getTools()).toEqual([]); + }); + + it('never exposes an object-level tool', async () => { + for (const toolSettings of [READ_ONLY, READ_WRITE, NO_CAPABILITIES]) { + const names = await toolNames(new GcsAdminToolset({toolSettings})); + for (const objectTool of [ + ...STORAGE_READ_TOOL_NAMES, + ...STORAGE_WRITE_TOOL_NAMES, + ]) { + expect(names).not.toContain(objectTool); + } + } + }); + + it('returns function tools', async () => { + const tools = await new GcsAdminToolset({ + toolSettings: READ_WRITE, + }).getTools(); + expect(tools.every((tool) => isFunctionTool(tool))).toBe(true); + }); + + it('declares the adk-python parameter names to the model', async () => { + const tools = await new GcsAdminToolset({ + toolSettings: READ_WRITE, + }).getTools(); + expectParameters(tools, EXPECTED_PARAMETERS); + }); + + it('applies an array tool filter to the prefixed names', async () => { + const toolset = new GcsAdminToolset({ + toolFilter: ['gcs_delete_bucket'], + toolSettings: READ_WRITE, + }); + expect(await toolNames(toolset)).toEqual(['gcs_delete_bucket']); + }); + + it('applies a predicate tool filter when a context is supplied', async () => { + const toolset = new GcsAdminToolset({ + toolFilter: (tool: BaseTool) => tool.name === 'gcs_create_bucket', + toolSettings: READ_WRITE, + }); + + expect(await toolNames(toolset, await createToolContext())).toEqual([ + 'gcs_create_bucket', + ]); + }); + + it('closes without error', async () => { + await expect(new GcsAdminToolset().close()).resolves.toBeUndefined(); + }); +}); diff --git a/core/test/integrations/gcs/client_test.ts b/core/test/integrations/gcs/client_test.ts new file mode 100644 index 000000000..0abda896b --- /dev/null +++ b/core/test/integrations/gcs/client_test.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + GcsAdminToolset, + GcsCredentialsConfig, + GcsToolset, +} from '@google/adk'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {createToolContext, getTool} from './test_utils.js'; + +const {StorageMock, fakes} = vi.hoisted(() => { + const bucket = {getMetadata: vi.fn()}; + const storage = {bucket: vi.fn(() => bucket), getBuckets: vi.fn()}; + return {StorageMock: vi.fn(() => storage), fakes: {bucket, storage}}; +}); + +vi.mock('@google-cloud/storage', () => ({Storage: StorageMock})); + +const USER_AGENT_PATTERN = /^adk-gcs-tool google-adk\/\d/; + +describe('Cloud Storage client provisioning', () => { + let toolContext: Context; + + /** Runs `gcs_get_bucket`, which builds a client without a project. */ + async function callGetBucket(toolset: GcsToolset): Promise { + const tool = await getTool(toolset, 'gcs_get_bucket'); + await tool.runAsync({args: {bucket_name: 'test-bucket'}, toolContext}); + } + + /** Runs `gcs_list_buckets`, which builds a client for the given project. */ + async function callListBuckets( + toolset: GcsAdminToolset, + projectId: string, + ): Promise { + const tool = await getTool(toolset, 'gcs_list_buckets'); + await tool.runAsync({args: {project_id: projectId}, toolContext}); + } + + beforeEach(async () => { + vi.clearAllMocks(); + fakes.bucket.getMetadata.mockResolvedValue([{}]); + fakes.storage.getBuckets.mockResolvedValue([[]]); + toolContext = await createToolContext(); + }); + + it('builds an Application Default Credentials client with the ADK user agent', async () => { + await callGetBucket(new GcsToolset()); + + expect(StorageMock).toHaveBeenCalledTimes(1); + expect(StorageMock).toHaveBeenCalledWith({ + userAgent: expect.stringMatching(USER_AGENT_PATTERN), + }); + }); + + it('builds the client from the credentials config', async () => { + const credentialsConfig = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + projectId: 'configured-project', + }); + + await callGetBucket(new GcsToolset({credentialsConfig})); + + expect(StorageMock).toHaveBeenCalledTimes(1); + expect(StorageMock).toHaveBeenCalledWith({ + clientOptions: {clientId: 'abc', clientSecret: 'def'}, + scopes: credentialsConfig.scopes, + projectId: 'configured-project', + userAgent: expect.stringMatching(USER_AGENT_PATTERN), + }); + }); + + it('reuses one client for repeated calls on the same toolset', async () => { + const credentialsConfig = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + }); + const toolset = new GcsToolset({credentialsConfig}); + + await callGetBucket(toolset); + await callGetBucket(toolset); + + expect(StorageMock).toHaveBeenCalledTimes(1); + }); + + it('builds a separate client per project', async () => { + const credentialsConfig = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + }); + const toolset = new GcsToolset({credentialsConfig}); + const adminToolset = new GcsAdminToolset({credentialsConfig}); + + await callGetBucket(toolset); + await callListBuckets(adminToolset, 'project-one'); + await callListBuckets(adminToolset, 'project-two'); + await callListBuckets(adminToolset, 'project-one'); + + expect(StorageMock).toHaveBeenCalledTimes(3); + expect(StorageMock).toHaveBeenCalledWith( + expect.objectContaining({projectId: 'project-one'}), + ); + expect(StorageMock).toHaveBeenCalledWith( + expect.objectContaining({projectId: 'project-two'}), + ); + }); + + it('drops the cache once it overflows', async () => { + const adminToolset = new GcsAdminToolset({ + credentialsConfig: new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + }), + }); + + for (let i = 0; i < 16; i++) { + await callListBuckets(adminToolset, `project-${i}`); + } + expect(StorageMock).toHaveBeenCalledTimes(16); + + // The 17th project evicts the whole cache, so the first one is rebuilt. + await callListBuckets(adminToolset, 'project-16'); + await callListBuckets(adminToolset, 'project-0'); + + expect(StorageMock).toHaveBeenCalledTimes(18); + }); +}); diff --git a/core/test/integrations/gcs/credentials_test.ts b/core/test/integrations/gcs/credentials_test.ts new file mode 100644 index 000000000..921f13de1 --- /dev/null +++ b/core/test/integrations/gcs/credentials_test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {GCS_DEFAULT_SCOPES, GcsCredentialsConfig} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +describe('GcsCredentialsConfig', () => { + it('defaults the scopes for a client id and secret', () => { + const config = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + }); + + expect(config.clientId).toBe('abc'); + expect(config.clientSecret).toBe('def'); + expect(config.scopes).toEqual(GCS_DEFAULT_SCOPES); + expect(config.storageOptions).toBeUndefined(); + }); + + it('keeps explicitly configured scopes', () => { + const config = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + scopes: ['https://www.googleapis.com/auth/devstorage.read_only'], + }); + + expect(config.scopes).toEqual([ + 'https://www.googleapis.com/auth/devstorage.read_only', + ]); + }); + + it('keeps ready-made storage options and leaves the OAuth fields unset', () => { + const config = new GcsCredentialsConfig({ + storageOptions: {apiEndpoint: 'https://storage.example'}, + }); + + expect(config.storageOptions).toEqual({ + apiEndpoint: 'https://storage.example', + }); + expect(config.clientId).toBeUndefined(); + expect(config.clientSecret).toBeUndefined(); + }); + + it('rejects an empty configuration', () => { + expect(() => new GcsCredentialsConfig({})).toThrow( + 'Must provide either storageOptions, or both clientId and clientSecret.', + ); + }); + + it('rejects a client id without a client secret', () => { + expect(() => new GcsCredentialsConfig({clientId: 'abc'})).toThrow( + 'Must provide either storageOptions, or both clientId and clientSecret.', + ); + }); + + it('rejects a client secret without a client id', () => { + expect(() => new GcsCredentialsConfig({clientSecret: 'def'})).toThrow( + 'Must provide either storageOptions, or both clientId and clientSecret.', + ); + }); + + it('rejects storage options combined with OAuth client credentials', () => { + expect( + () => + new GcsCredentialsConfig({ + storageOptions: {}, + clientId: 'abc', + clientSecret: 'def', + }), + ).toThrow( + 'If storageOptions are provided, clientId, clientSecret and scopes must not be provided.', + ); + }); + + it('rejects storage options combined with scopes', () => { + expect( + () => + new GcsCredentialsConfig({ + storageOptions: {}, + scopes: ['https://www.googleapis.com/auth/devstorage.read_only'], + }), + ).toThrow( + 'If storageOptions are provided, clientId, clientSecret and scopes must not be provided.', + ); + }); + + describe('toStorageOptions', () => { + it('passes ready-made storage options through', () => { + const config = new GcsCredentialsConfig({ + storageOptions: {apiEndpoint: 'https://storage.example'}, + }); + + expect(config.toStorageOptions()).toEqual({ + apiEndpoint: 'https://storage.example', + }); + }); + + it('turns a client id and secret into client options and scopes', () => { + const config = new GcsCredentialsConfig({ + clientId: 'abc', + clientSecret: 'def', + }); + + expect(config.toStorageOptions()).toEqual({ + clientOptions: {clientId: 'abc', clientSecret: 'def'}, + scopes: GCS_DEFAULT_SCOPES, + }); + }); + + it('uses the configured project when no override is given', () => { + const config = new GcsCredentialsConfig({ + storageOptions: {}, + projectId: 'configured-project', + }); + + expect(config.toStorageOptions()).toEqual({ + projectId: 'configured-project', + }); + }); + + it('lets the call site override the configured project', () => { + const config = new GcsCredentialsConfig({ + storageOptions: {projectId: 'options-project'}, + projectId: 'configured-project', + }); + + expect(config.toStorageOptions('call-site-project')).toEqual({ + projectId: 'call-site-project', + }); + }); + }); +}); diff --git a/core/test/integrations/gcs/storage_tool_test.ts b/core/test/integrations/gcs/storage_tool_test.ts new file mode 100644 index 000000000..46a6fb7fa --- /dev/null +++ b/core/test/integrations/gcs/storage_tool_test.ts @@ -0,0 +1,482 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Context, GcsToolset, GcsToolStatus} from '@google/adk'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {createToolContext, getTool, READ_WRITE} from './test_utils.js'; + +const {StorageMock, fakes} = vi.hoisted(() => { + const file = { + getMetadata: vi.fn(), + download: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }; + const bucket = { + getMetadata: vi.fn(), + getFiles: vi.fn(), + file: vi.fn(() => file), + upload: vi.fn(), + }; + const storage = {bucket: vi.fn(() => bucket)}; + return {StorageMock: vi.fn(() => storage), fakes: {file, bucket, storage}}; +}); + +vi.mock('@google-cloud/storage', () => ({Storage: StorageMock})); + +const OBJECT_METADATA = { + kind: 'storage#object', + name: 'test-object', + bucket: 'test-bucket', + size: '1024', + contentType: 'text/plain', +}; + +/** An error shaped like the one `@google-cloud/storage` raises on a 404. */ +function apiError(code: number, message: string): Error & {code: number} { + return Object.assign(new Error(message), {code}); +} + +describe('GCS storage tools', () => { + let toolset: GcsToolset; + let toolContext: Context; + + beforeEach(async () => { + vi.clearAllMocks(); + fakes.bucket.getMetadata.mockResolvedValue([{name: 'test-bucket'}]); + fakes.bucket.getFiles.mockResolvedValue([[]]); + fakes.bucket.upload.mockResolvedValue(undefined); + fakes.file.getMetadata.mockResolvedValue([OBJECT_METADATA]); + fakes.file.download.mockResolvedValue([Buffer.from('content')]); + fakes.file.save.mockResolvedValue(undefined); + fakes.file.delete.mockResolvedValue(undefined); + + toolset = new GcsToolset({toolSettings: READ_WRITE}); + toolContext = await createToolContext(); + }); + + describe('gcs_get_bucket', () => { + it('returns the bucket metadata', async () => { + const metadata = { + kind: 'storage#bucket', + name: 'test-bucket', + location: 'US', + versioning: {enabled: true}, + }; + fakes.bucket.getMetadata.mockResolvedValue([metadata]); + const tool = await getTool(toolset, 'gcs_get_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: metadata, + }); + expect(fakes.storage.bucket).toHaveBeenCalledWith('test-bucket'); + }); + + it('reports a failed request as an error result', async () => { + fakes.bucket.getMetadata.mockRejectedValue(new Error('boom')); + const tool = await getTool(toolset, 'gcs_get_bucket'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'boom', + }); + }); + }); + + describe('gcs_list_objects', () => { + it('lists object names without pagination', async () => { + fakes.bucket.getFiles.mockResolvedValue([[{name: 'test-object'}]]); + const tool = await getTool(toolset, 'gcs_list_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-object'], + }); + expect(fakes.bucket.getFiles).toHaveBeenCalledWith({}); + }); + + it('passes the prefix through', async () => { + const tool = await getTool(toolset, 'gcs_list_objects'); + + await tool.runAsync({ + args: {bucket_name: 'test-bucket', prefix: 'logs/'}, + toolContext, + }); + + expect(fakes.bucket.getFiles).toHaveBeenCalledWith({prefix: 'logs/'}); + }); + + it('returns the next page token when a page size is given', async () => { + fakes.bucket.getFiles.mockResolvedValue([ + [{name: 'test-object'}], + {pageToken: 'next-page-token'}, + ]); + const tool = await getTool(toolset, 'gcs_list_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', page_size: 1, page_token: 'token'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-object'], + next_page_token: 'next-page-token', + }); + expect(fakes.bucket.getFiles).toHaveBeenCalledWith({ + maxResults: 1, + pageToken: 'token', + autoPaginate: false, + }); + }); + + it('omits the next page token on the last page', async () => { + fakes.bucket.getFiles.mockResolvedValue([ + [{name: 'test-object'}], + {pageToken: undefined}, + ]); + const tool = await getTool(toolset, 'gcs_list_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', page_size: 1}, + toolContext, + }); + + expect(result).toStrictEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-object'], + }); + }); + + it('omits the next page token when the client returns no next query', async () => { + fakes.bucket.getFiles.mockResolvedValue([[{name: 'test-object'}]]); + const tool = await getTool(toolset, 'gcs_list_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', page_size: 1}, + toolContext, + }); + + expect(result).toStrictEqual({ + status: GcsToolStatus.SUCCESS, + results: ['test-object'], + }); + }); + + it('reports a failed request as an error result', async () => { + fakes.bucket.getFiles.mockRejectedValue(new Error('list failed')); + const tool = await getTool(toolset, 'gcs_list_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'list failed', + }); + }); + }); + + describe('gcs_get_object_metadata', () => { + it('returns the object metadata and selects the requested generation', async () => { + const tool = await getTool(toolset, 'gcs_get_object_metadata'); + + const result = await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + generation: 1, + }, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: OBJECT_METADATA, + }); + expect(fakes.bucket.file).toHaveBeenCalledWith('test-object', { + generation: 1, + }); + }); + + it('omits the generation when none is requested', async () => { + const tool = await getTool(toolset, 'gcs_get_object_metadata'); + + await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(fakes.bucket.file).toHaveBeenCalledWith('test-object', undefined); + }); + + it('reports a missing object as not found', async () => { + fakes.file.getMetadata.mockRejectedValue(apiError(404, 'No such object')); + const tool = await getTool(toolset, 'gcs_get_object_metadata'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'missing'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'Object missing not found in bucket test-bucket', + }); + }); + + it('keeps a non-404 failure as a plain error result', async () => { + fakes.file.getMetadata.mockRejectedValue(apiError(500, 'server error')); + const tool = await getTool(toolset, 'gcs_get_object_metadata'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'server error', + }); + }); + }); + + describe('gcs_get_object_data', () => { + it('returns UTF-8 payloads as text', async () => { + const tool = await getTool(toolset, 'gcs_get_object_data'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: 'content', + encoding: 'text', + }); + }); + + it('selects the requested generation', async () => { + const tool = await getTool(toolset, 'gcs_get_object_data'); + + await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + generation: 7, + }, + toolContext, + }); + + expect(fakes.bucket.file).toHaveBeenCalledWith('test-object', { + generation: 7, + }); + }); + + it('returns binary payloads as base64', async () => { + fakes.file.download.mockResolvedValue([Buffer.from([0xff, 0xff])]); + const tool = await getTool(toolset, 'gcs_get_object_data'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: '//8=', + encoding: 'base64', + }); + }); + + it('downloads to a local path when one is given', async () => { + const tool = await getTool(toolset, 'gcs_get_object_data'); + + const result = await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + destination_file_path: 'path/to/download.txt', + }, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: + 'Object test-object downloaded successfully to path/to/download.txt.', + }); + expect(fakes.file.download).toHaveBeenCalledWith({ + destination: 'path/to/download.txt', + }); + }); + + it('reports a missing object as not found', async () => { + fakes.file.download.mockRejectedValue(apiError(404, 'No such object')); + const tool = await getTool(toolset, 'gcs_get_object_data'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'missing'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'Object missing not found in bucket test-bucket', + }); + }); + + it('stringifies a rejection that is not an Error', async () => { + fakes.file.download.mockRejectedValue('download exploded'); + const tool = await getTool(toolset, 'gcs_get_object_data'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'download exploded', + }); + }); + }); + + describe('gcs_create_object', () => { + it('uploads inline data', async () => { + const tool = await getTool(toolset, 'gcs_create_object'); + + const result = await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + data: 'data', + }, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: + 'Object test-object created successfully in bucket test-bucket.', + }); + expect(fakes.file.save).toHaveBeenCalledWith('data'); + expect(fakes.bucket.upload).not.toHaveBeenCalled(); + }); + + it('uploads a local file', async () => { + const tool = await getTool(toolset, 'gcs_create_object'); + + const result = await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + source_file_path: 'path/to/file.txt', + }, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: + 'Object test-object created successfully in bucket test-bucket.', + }); + expect(fakes.bucket.upload).toHaveBeenCalledWith('path/to/file.txt', { + destination: 'test-object', + }); + expect(fakes.file.save).not.toHaveBeenCalled(); + }); + + it('requires either data or a source file path', async () => { + const tool = await getTool(toolset, 'gcs_create_object'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_name: 'test-object'}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: "Either 'data' or 'source_file_path' must be provided.", + }); + expect(fakes.file.save).not.toHaveBeenCalled(); + expect(fakes.bucket.upload).not.toHaveBeenCalled(); + }); + + it('reports a failed upload as an error result', async () => { + fakes.file.save.mockRejectedValue(new Error('upload failed')); + const tool = await getTool(toolset, 'gcs_create_object'); + + const result = await tool.runAsync({ + args: { + bucket_name: 'test-bucket', + object_name: 'test-object', + data: 'data', + }, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'upload failed', + }); + }); + }); + + describe('gcs_delete_objects', () => { + it('deletes every requested object', async () => { + const tool = await getTool(toolset, 'gcs_delete_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_names: ['first', 'second']}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.SUCCESS, + results: + 'Objects [first, second] deleted successfully from bucket test-bucket.', + }); + expect(fakes.bucket.file).toHaveBeenCalledWith('first'); + expect(fakes.bucket.file).toHaveBeenCalledWith('second'); + expect(fakes.file.delete).toHaveBeenCalledTimes(2); + }); + + it('reports a failed delete as an error result', async () => { + fakes.file.delete.mockRejectedValue(new Error('delete failed')); + const tool = await getTool(toolset, 'gcs_delete_objects'); + + const result = await tool.runAsync({ + args: {bucket_name: 'test-bucket', object_names: ['first']}, + toolContext, + }); + + expect(result).toEqual({ + status: GcsToolStatus.ERROR, + error_details: 'delete failed', + }); + }); + }); +}); diff --git a/core/test/integrations/gcs/storage_toolset_test.ts b/core/test/integrations/gcs/storage_toolset_test.ts new file mode 100644 index 000000000..59668c1cb --- /dev/null +++ b/core/test/integrations/gcs/storage_toolset_test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseTool, + GCS_TOOL_NAME_PREFIX, + GcsToolset, + isFunctionTool, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; +import { + ADMIN_READ_TOOL_NAMES, + ADMIN_WRITE_TOOL_NAMES, + createToolContext, + expectParameters, + NO_CAPABILITIES, + READ_ONLY, + READ_WRITE, + STORAGE_READ_TOOL_NAMES, + STORAGE_WRITE_TOOL_NAMES, + toolNames, +} from './test_utils.js'; + +/** The parameter contract the model sees, mirroring adk-python. */ +const EXPECTED_PARAMETERS = { + gcs_get_bucket: {declared: ['bucket_name'], required: ['bucket_name']}, + gcs_get_object_data: { + declared: [ + 'bucket_name', + 'object_name', + 'generation', + 'destination_file_path', + ], + required: ['bucket_name', 'object_name'], + }, + gcs_get_object_metadata: { + declared: ['bucket_name', 'object_name', 'generation'], + required: ['bucket_name', 'object_name'], + }, + gcs_list_objects: { + declared: ['bucket_name', 'prefix', 'page_size', 'page_token'], + required: ['bucket_name'], + }, + gcs_create_object: { + declared: ['bucket_name', 'object_name', 'data', 'source_file_path'], + required: ['bucket_name', 'object_name'], + }, + gcs_delete_objects: { + declared: ['bucket_name', 'object_names'], + required: ['bucket_name', 'object_names'], + }, +}; + +describe('GcsToolset', () => { + it('prefixes tool names with the GCS prefix', async () => { + const toolset = new GcsToolset(); + expect(toolset.prefix).toBe(GCS_TOOL_NAME_PREFIX); + + const names = await toolNames(toolset); + expect( + names.every((name) => name.startsWith(`${GCS_TOOL_NAME_PREFIX}_`)), + ).toBe(true); + }); + + it('exposes only the read tools by default', async () => { + expect(await toolNames(new GcsToolset())).toEqual( + [...STORAGE_READ_TOOL_NAMES].sort(), + ); + }); + + it('exposes only the read tools with the read-only capability', async () => { + const toolset = new GcsToolset({toolSettings: READ_ONLY}); + expect(await toolNames(toolset)).toEqual( + [...STORAGE_READ_TOOL_NAMES].sort(), + ); + }); + + it('adds the write tools with the read-write capability', async () => { + const toolset = new GcsToolset({toolSettings: READ_WRITE}); + expect(await toolNames(toolset)).toEqual( + [...STORAGE_READ_TOOL_NAMES, ...STORAGE_WRITE_TOOL_NAMES].sort(), + ); + }); + + it('exposes no tools without any capability', async () => { + const toolset = new GcsToolset({toolSettings: NO_CAPABILITIES}); + expect(await toolset.getTools()).toEqual([]); + }); + + it('never exposes a bucket-level tool', async () => { + for (const toolSettings of [READ_ONLY, READ_WRITE, NO_CAPABILITIES]) { + const names = await toolNames(new GcsToolset({toolSettings})); + for (const adminTool of [ + ...ADMIN_READ_TOOL_NAMES, + ...ADMIN_WRITE_TOOL_NAMES, + ]) { + expect(names).not.toContain(adminTool); + } + } + }); + + it('returns function tools', async () => { + const tools = await new GcsToolset({toolSettings: READ_WRITE}).getTools(); + expect(tools.every((tool) => isFunctionTool(tool))).toBe(true); + }); + + it('declares the adk-python parameter names to the model', async () => { + const tools = await new GcsToolset({toolSettings: READ_WRITE}).getTools(); + expectParameters(tools, EXPECTED_PARAMETERS); + }); + + it('applies an array tool filter to the prefixed names', async () => { + const one = new GcsToolset({toolFilter: ['gcs_get_bucket']}); + expect(await toolNames(one)).toEqual(['gcs_get_bucket']); + + const two = new GcsToolset({ + toolFilter: ['gcs_list_objects', 'gcs_get_object_metadata'], + }); + expect(await toolNames(two)).toEqual([ + 'gcs_get_object_metadata', + 'gcs_list_objects', + ]); + }); + + it('applies a predicate tool filter when a context is supplied', async () => { + const toolset = new GcsToolset({ + toolFilter: (tool: BaseTool) => tool.name === 'gcs_list_objects', + }); + + expect(await toolNames(toolset, await createToolContext())).toEqual([ + 'gcs_list_objects', + ]); + }); + + it('keeps every tool when a predicate filter has no context', async () => { + const toolset = new GcsToolset({ + toolFilter: (tool: BaseTool) => tool.name === 'gcs_list_objects', + }); + + expect(await toolNames(toolset)).toEqual( + [...STORAGE_READ_TOOL_NAMES].sort(), + ); + }); + + it('closes without error', async () => { + await expect(new GcsToolset().close()).resolves.toBeUndefined(); + }); +}); diff --git a/core/test/integrations/gcs/test_utils.ts b/core/test/integrations/gcs/test_utils.ts new file mode 100644 index 000000000..5d5ea1f9a --- /dev/null +++ b/core/test/integrations/gcs/test_utils.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseTool, + BaseToolset, + Context, + GcsCapability, + InMemorySessionService, + InvocationContext, + LlmAgent, + PluginManager, + ReadonlyContext, +} from '@google/adk'; +import {expect} from 'vitest'; + +export const STORAGE_READ_TOOL_NAMES = [ + 'gcs_get_bucket', + 'gcs_get_object_data', + 'gcs_get_object_metadata', + 'gcs_list_objects', +]; +export const STORAGE_WRITE_TOOL_NAMES = [ + 'gcs_create_object', + 'gcs_delete_objects', +]; +export const ADMIN_READ_TOOL_NAMES = ['gcs_list_buckets']; +export const ADMIN_WRITE_TOOL_NAMES = [ + 'gcs_create_bucket', + 'gcs_update_bucket', + 'gcs_delete_bucket', +]; + +export const READ_WRITE = {capabilities: [GcsCapability.READ_WRITE]}; +export const READ_ONLY = {capabilities: [GcsCapability.READ_ONLY]}; +export const NO_CAPABILITIES = {capabilities: []}; + +/** Builds the tool context every `runAsync` call requires. */ +export async function createToolContext(): Promise { + const session = await new InMemorySessionService().createSession({ + appName: 'gcs-test-app', + userId: 'gcs-test-user', + }); + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'gcs-test-invocation', + agent: new LlmAgent({name: 'gcs_test_agent'}), + session, + pluginManager: new PluginManager(), + }), + }); +} + +/** Returns the named tool of a toolset, failing the test when it is absent. */ +export async function getTool( + toolset: BaseToolset, + name: string, +): Promise { + const tool = (await toolset.getTools()).find((it) => it.name === name); + if (!tool) { + expect.fail(`Toolset does not expose a tool named ${name}.`); + } + return tool; +} + +/** The sorted names of the tools a toolset exposes. */ +export async function toolNames( + toolset: BaseToolset, + context?: ReadonlyContext, +): Promise { + return (await toolset.getTools(context)).map((tool) => tool.name).sort(); +} + +/** + * Asserts that the tools declare exactly the given parameters, in order, with + * the given ones required. + */ +export function expectParameters( + tools: BaseTool[], + expected: Record, +): void { + expect(tools.map((tool) => tool.name).sort()).toEqual( + Object.keys(expected).sort(), + ); + for (const tool of tools) { + const parameters = tool._getDeclaration()?.parameters; + expect(Object.keys(parameters?.properties ?? {})).toEqual( + expected[tool.name].declared, + ); + expect(parameters?.required ?? []).toEqual(expected[tool.name].required); + } +}