From 93959a75f4656403d50d39f92f095d98e70341ce Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:48:36 -0700 Subject: [PATCH 1/8] Docs: document minimum supported Node.js version in README (#526) * docs: document minimum supported Node.js version in README Add a short prerequisite note under the Installation section stating that ADK for TypeScript requires Node.js 18 or newer, so new users know which Node.js runtime they need before running npm install @google/adk. The version reflects the mandated fallback: no engines.node field is declared in any package.json in the repo. * docs: reference current Node.js LTS instead of a fixed version Node.js 18 is EOL and any hard-coded minimum version goes stale over time. Reword the installation prerequisite to point readers at the current Node.js LTS releases, which stays accurate without future edits. Addresses PR review feedback on #526. --------- Co-authored-by: Amaad Martin --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aa42b2d64..414eed3f5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ web runtimes. ## 🚀 Installation +> **Prerequisite:** ADK for TypeScript requires a current Node.js LTS release. + ```bash npm install @google/adk npm install -D @google/adk-devtools From fe0ad34d023c9dfa0367d5eb766ad59ecd4504ab Mon Sep 17 00:00:00 2001 From: Sahil Saini Date: Wed, 29 Jul 2026 03:23:49 +0530 Subject: [PATCH 2/8] fix(tools): use the statically configured credential in OpenAPI tools (#536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ToolAuthHandler` accepted an `authCredential` and then never read it. When no auth response was present it went straight to `requestCredential()`, so a credential handed to `OpenAPIToolset`/`RestApiTool` at construction time was ignored and the tool returned `{pending: true}` on every call. For `apiKey`, `http` and `serviceAccount` schemes nothing ever resolves that request — no user interaction is involved — so the tool could never complete. Fall back to the configured credential when there is no auth response, which mirrors `_get_auth_response() or self.auth_credential` in adk-python. Also narrow what gets written to session state. The credential store exists to avoid repeating work that either cannot be repeated (an auth response is readable once) or is expensive (an exchange costs a round trip). A static credential that needed no exchange is neither, so it is no longer persisted — that would only copy the developer's secret into the session store. --- .../openapi_spec_parser/tool_auth_handler.ts | 40 +++++--- .../openapi_tool/tool_auth_handler_test.ts | 98 ++++++++++++++++++- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts index 2b8e05d9e..dc2650a75 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -86,23 +86,37 @@ export class ToolAuthHandler { credentialKey: this.credentialKey || 'default_openapi_key', }; - const credential = this.context.getAuthResponse(authConfig); - if (credential) { - const exchanger = new AutoAuthCredentialExchanger(); - const result = await exchanger.exchange({ - authScheme: this.authScheme, - authCredential: credential, - }); + // A credential returned by an auth response was supplied interactively by + // the client. Otherwise fall back to the credential the tool was + // configured with: schemes such as `apiKey`, `http` and `serviceAccount` + // need no user interaction, so requesting one would strand the tool in + // `pending` forever. + const authResponseCredential = this.context.getAuthResponse(authConfig); + const credential = authResponseCredential ?? this.authCredential; + + if (!credential) { + // No credential to work with, so ask the client for one. + this.context.requestCredential(authConfig); + + return {state: 'pending'}; + } + const exchanger = new AutoAuthCredentialExchanger(); + const result = await exchanger.exchange({ + authScheme: this.authScheme, + authCredential: credential, + }); + + // Only cache what cannot cheaply be obtained again: an auth response is + // readable once, and an exchange costs a round trip. A statically + // configured credential that needed no exchange is already available on + // every invocation, so persisting it to session state would only copy a + // secret into the session store for nothing. + if (authResponseCredential || result.wasExchanged) { const key = store.getCredentialKey(this.authScheme); store.storeCredential(key, result.credential); - - return {state: 'done', authCredential: result.credential}; } - // If credential is not available, request it - this.context.requestCredential(authConfig); - - return {state: 'pending'}; + return {state: 'done', authCredential: result.credential}; } } diff --git a/core/test/tools/openapi_tool/tool_auth_handler_test.ts b/core/test/tools/openapi_tool/tool_auth_handler_test.ts index a493e7971..2a718e4a5 100644 --- a/core/test/tools/openapi_tool/tool_auth_handler_test.ts +++ b/core/test/tools/openapi_tool/tool_auth_handler_test.ts @@ -4,9 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {AuthCredentialTypes, Context, ToolAuthHandler} from '@google/adk'; +import { + AuthCredential, + AuthCredentialTypes, + Context, + ToolAuthHandler, +} from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; import {State} from '../../../src/sessions/state.js'; +import {AutoAuthCredentialExchanger} from '../../../src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.js'; // Mock AutoAuthCredentialExchanger vi.mock( @@ -147,4 +153,94 @@ describe('ToolAuthHandler', () => { // The cached credential was reused; no second exchange was triggered. expect(secondContext.getAuthResponse).not.toHaveBeenCalled(); }); + + it('uses the credential the tool was configured with instead of requesting one', async () => { + const mockContext = { + state: new State(), + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + {type: 'apiKey', name: 'X-API-Key', in: 'header'}, + {authType: AuthCredentialTypes.API_KEY, apiKey: 'static-key'}, + ).prepareAuthCredentials(); + + // Schemes like apiKey need no user interaction, so asking the client for a + // credential would leave the tool stuck in `pending` forever. + expect(result.state).toBe('done'); + expect(mockContext.requestCredential).not.toHaveBeenCalled(); + }); + + it('does not copy a static credential that needed no exchange into session state', async () => { + const staticCredential: AuthCredential = { + authType: AuthCredentialTypes.API_KEY, + apiKey: 'static-key', + }; + // The real exchanger has no exchanger registered for apiKey/http, so it + // hands the credential straight back. + vi.mocked(AutoAuthCredentialExchanger).mockImplementationOnce( + () => + ({ + exchange: vi.fn().mockResolvedValue({ + credential: staticCredential, + wasExchanged: false, + }), + }) as unknown as AutoAuthCredentialExchanger, + ); + + const state = new State(); + const mockContext = { + state, + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + {type: 'apiKey', name: 'X-API-Key', in: 'header'}, + staticCredential, + ).prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect(result.authCredential?.apiKey).toBe('static-key'); + // It is readable from the tool on every invocation, so persisting it would + // only write the secret into the session store for nothing. + expect(state.get('apiKey_existing_exchanged_credential')).toBeUndefined(); + expect(state.hasDelta()).toBe(false); + }); + + it('caches a static credential that did require an exchange', async () => { + const state = new State(); + const mockContext = { + state, + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const result = await new ToolAuthHandler( + mockContext, + { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token', + scopes: {}, + }, + }, + }, + { + authType: AuthCredentialTypes.OAUTH2, + oauth2: {clientId: 'client-id', clientSecret: 'client-secret'}, + }, + ).prepareAuthCredentials(); + + expect(result.state).toBe('done'); + // An exchange costs a round trip, so its result is worth persisting. + const stored = state.get<{http?: {credentials: {token: string}}}>( + 'oauth2_existing_exchanged_credential', + ); + expect(stored?.http?.credentials.token).toBe('exchanged-token'); + }); }); From 0bfc44a8fff3a2e726a1f5e371323080e174ee08 Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:55:05 -0700 Subject: [PATCH 3/8] Feat: add LoadMcpResourceTool (read MCP server resources) for adk-python parity (#542) * Feat: add LoadMcpResourceTool and MCPToolset resource access Port adk-python's LoadMcpResourceTool to adk-js for cross-language parity. - Add listResources/getResourceInfo/readResource to MCPToolset, following the existing create -> try -> closeSession-in-finally session idiom. - Add LoadMcpResourceTool (mirrors the in-repo LoadArtifactsTool idiom): declares load_mcp_resource({resource_names}), and processLlmRequest injects resolved resource contents (text + base64 binary, no decode step) into the LlmRequest. - Export the tool from core/src/index.ts (@google/adk public API). * test: cover LoadMcpResourceTool and MCPToolset resource access Add full unit coverage (100% line + branch of the new code): - load_mcp_resource_tool_test.ts: init, declaration, runAsync (incl. default), list injection (incl. empty + swallowed list errors), text/binary/unknown content, base64 blob passthrough + default mime type, swallowed read errors, and all no-op guard paths (non-matching/absent function response, missing parts). - mcp_toolset_test.ts: listResources/getResourceInfo/readResource happy paths and error paths (unknown name, missing URI), plus session-cleanup assertions for success and failure (closeSession in finally, no leaked sessions). * test(e2e): exercise LoadMcpResourceTool against a real MCP server Add a no-mock end-to-end test that spawns a real MCP server over stdio (mcp_resource_server.mjs, exposing a text and a binary resource) and drives the real MCPToolset + LoadMcpResourceTool: listing/resolving/reading resources and injecting their contents (text + base64 binary) into an LlmRequest. --------- Co-authored-by: Amaad Martin --- core/src/index.ts | 1 + core/src/tools/mcp/load_mcp_resource_tool.ts | 143 +++++++++ core/src/tools/mcp/mcp_toolset.ts | 79 ++++- .../tools/mcp/load_mcp_resource_tool_test.ts | 273 ++++++++++++++++++ core/test/tools/mcp/mcp_toolset_test.ts | 184 ++++++++++++ .../tools/mcp/load_mcp_resource_e2e_test.ts | 115 ++++++++ tests/e2e/tools/mcp/mcp_resource_server.mjs | 49 ++++ 7 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 core/src/tools/mcp/load_mcp_resource_tool.ts create mode 100644 core/test/tools/mcp/load_mcp_resource_tool_test.ts create mode 100644 tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts create mode 100644 tests/e2e/tools/mcp/mcp_resource_server.mjs diff --git a/core/src/index.ts b/core/src/index.ts index b40796390..21479d8e1 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -55,6 +55,7 @@ export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js'; export * from './integrations/agent_registry/agent_registry.js'; export * from './telemetry/google_cloud.js'; export * from './telemetry/setup.js'; +export * from './tools/mcp/load_mcp_resource_tool.js'; export * from './tools/mcp/mcp_session_manager.js'; export * from './tools/mcp/mcp_tool.js'; export * from './tools/mcp/mcp_toolset.js'; diff --git a/core/src/tools/mcp/load_mcp_resource_tool.ts b/core/src/tools/mcp/load_mcp_resource_tool.ts new file mode 100644 index 000000000..9354f6c56 --- /dev/null +++ b/core/src/tools/mcp/load_mcp_resource_tool.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FunctionDeclaration, Part, Type} from '@google/genai'; +import { + BlobResourceContents, + TextResourceContents, +} from '@modelcontextprotocol/sdk/types.js'; + +import {appendInstructions, LlmRequest} from '../../models/llm_request.js'; +import {logger} from '../../utils/logger.js'; +import { + BaseTool, + RunAsyncToolRequest, + ToolProcessLlmRequest, +} from '../base_tool.js'; + +import {MCPToolset} from './mcp_toolset.js'; + +/** + * A tool that loads MCP server resources and adds them to the session. + * + * This mirrors the in-repo `LoadArtifactsTool` idiom: the model calls + * `load_mcp_resource` with the names of the resources it wants, and on the next + * turn {@link processLlmRequest} reads those resources over the MCP session and + * appends their contents (text and base64-encoded binary) to the request + * context. + */ +export class LoadMcpResourceTool extends BaseTool { + private readonly mcpToolset: MCPToolset; + + constructor(mcpToolset: MCPToolset) { + super({ + name: 'load_mcp_resource', + description: `Loads resources from the MCP server.\n\nNOTE: Call when you need access to resources.`, + }); + this.mcpToolset = mcpToolset; + } + + override _getDeclaration(): FunctionDeclaration { + return { + name: this.name, + description: this.description, + parameters: { + type: Type.OBJECT, + properties: { + resource_names: { + type: Type.ARRAY, + items: { + type: Type.STRING, + }, + description: 'The names of the MCP resources to load.', + }, + }, + }, + }; + } + + override async runAsync({args}: RunAsyncToolRequest): Promise { + const resourceNames = (args['resource_names'] as string[]) || []; + return { + resource_names: resourceNames, + status: + 'resource contents temporarily inserted and removed. to access these resources, call load_mcp_resource tool again.', + }; + } + + override async processLlmRequest( + request: ToolProcessLlmRequest, + ): Promise { + await super.processLlmRequest(request); + await this.appendResourcesToLlmRequest(request.llmRequest); + } + + private async appendResourcesToLlmRequest( + llmRequest: LlmRequest, + ): Promise { + try { + const availableResourceNames = await this.mcpToolset.listResources(); + if (availableResourceNames.length > 0) { + appendInstructions(llmRequest, [ + `You have a list of MCP resources:\n${JSON.stringify( + availableResourceNames, + )}\n\nWhen the user asks questions about any of the resources, you should call the\n\`load_mcp_resource\` function to load the resource. Always call load_mcp_resource\nbefore answering questions related to the resources.`, + ]); + } + } catch (e) { + logger.warn(`Failed to list MCP resources: ${e}`); + } + + const lastContent = llmRequest.contents.at(-1); + const functionResponse = lastContent?.parts?.[0]?.functionResponse; + if (!functionResponse || functionResponse.name !== this.name) { + return; + } + + const response = + (functionResponse.response as Record) || {}; + const requestedResourceNames = + (response['resource_names'] as string[]) || []; + + for (const resourceName of requestedResourceNames) { + try { + const resourceContents = + await this.mcpToolset.readResource(resourceName); + for (const content of resourceContents) { + llmRequest.contents.push({ + role: 'user', + parts: [ + {text: `Resource ${resourceName} is:`}, + this.mcpContentToPart(content, resourceName), + ], + }); + } + } catch (e) { + logger.warn(`Failed to read MCP resource '${resourceName}': ${e}`); + } + } + } + + private mcpContentToPart( + content: TextResourceContents | BlobResourceContents, + resourceName: string, + ): Part { + if ('text' in content) { + return {text: content.text}; + } + if ('blob' in content) { + // The MCP SDK blob and Part.inlineData.data are both base64 strings, so + // the blob is assigned directly with no decode/re-encode step. + return { + inlineData: { + data: content.blob, + mimeType: content.mimeType ?? 'application/octet-stream', + }, + }; + } + return {text: `[Unknown content type for ${resourceName}]`}; + } +} diff --git a/core/src/tools/mcp/mcp_toolset.ts b/core/src/tools/mcp/mcp_toolset.ts index 76f6da73c..f37f577e2 100644 --- a/core/src/tools/mcp/mcp_toolset.ts +++ b/core/src/tools/mcp/mcp_toolset.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ListToolsResult} from '@modelcontextprotocol/sdk/types.js'; +import { + BlobResourceContents, + ListResourcesResult, + ListToolsResult, + ReadResourceResult, + Resource, + TextResourceContents, +} from '@modelcontextprotocol/sdk/types.js'; import {ReadonlyContext} from '../../agents/readonly_context.js'; import {logger} from '../../utils/logger.js'; @@ -104,6 +111,76 @@ export class MCPToolset extends BaseToolset { return tools; } + /** + * Lists the names of the resources advertised by the MCP server. + * + * @return The resource names available on the server. + */ + async listResources(): Promise { + const session = await this.mcpSessionManager.createSession(); + try { + const result = (await session.listResources()) as ListResourcesResult; + return result.resources.map((resource) => resource.name); + } finally { + await this.mcpSessionManager.closeSession(session); + } + } + + /** + * Returns metadata for the resource whose name matches `name`. + * + * @param name The advertised name of the resource. + * @return The matching MCP `Resource`. + * @throws If no resource with the given name is advertised by the server. + */ + async getResourceInfo(name: string): Promise { + const session = await this.mcpSessionManager.createSession(); + let result: ListResourcesResult; + try { + result = (await session.listResources()) as ListResourcesResult; + } finally { + await this.mcpSessionManager.closeSession(session); + } + + const resource = result.resources.find( + (candidate) => candidate.name === name, + ); + if (!resource) { + throw new Error(`Resource with name '${name}' not found.`); + } + return resource; + } + + /** + * Reads the contents of the named resource from the MCP server. + * + * The resource name is resolved to a URI via {@link getResourceInfo} before + * reading. Binary contents are returned base64-encoded, exactly as provided + * by the server (never decoded and re-encoded). + * + * @param name The advertised name of the resource to read. + * @return The resource contents (text and/or base64-encoded binary). + * @throws If the resource is unknown or has no URI. + */ + async readResource( + name: string, + ): Promise> { + const resourceInfo = await this.getResourceInfo(name); + if (!resourceInfo.uri) { + throw new Error(`Resource '${name}' has no URI.`); + } + + const session = await this.mcpSessionManager.createSession(); + try { + const result = (await session.readResource({ + uri: resourceInfo.uri, + })) as ReadResourceResult; + return result.contents; + } finally { + await this.mcpSessionManager.closeSession(session); + } + } + async close(): Promise { const sessions = this.mcpSessionManager.getActiveSessions(); await Promise.allSettled( diff --git a/core/test/tools/mcp/load_mcp_resource_tool_test.ts b/core/test/tools/mcp/load_mcp_resource_tool_test.ts new file mode 100644 index 000000000..6d3039052 --- /dev/null +++ b/core/test/tools/mcp/load_mcp_resource_tool_test.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + LlmRequest, + LoadMcpResourceTool, + MCPToolset, + RunAsyncToolRequest, +} from '@google/adk'; +import {Content, Type} from '@google/genai'; +import {beforeEach, describe, expect, it, Mock, vi} from 'vitest'; + +/** + * Builds a {@link LoadMcpResourceTool} backed by a minimal mock toolset. Only + * `listResources`/`readResource` are exercised by the tool, so those are the + * only methods stubbed. + */ +function setup() { + const listResources = vi.fn().mockResolvedValue([] as string[]); + const readResource = vi.fn().mockResolvedValue([]); + const toolset = {listResources, readResource} as unknown as MCPToolset; + const tool = new LoadMcpResourceTool(toolset); + return {tool, listResources, readResource}; +} + +/** A throwaway tool context; the tool never reads from it. */ +const toolContext = {} as unknown as Context; + +/** Builds a bare LlmRequest suitable for `processLlmRequest`. */ +function makeLlmRequest(contents: Content[] = []): LlmRequest { + return {contents, toolsDict: {}} as unknown as LlmRequest; +} + +/** Builds a content whose first part is a `load_mcp_resource` function response. */ +function functionResponseContent(response: Record): Content { + return { + role: 'user', + parts: [{functionResponse: {name: 'load_mcp_resource', response}}], + }; +} + +describe('LoadMcpResourceTool', () => { + let listResources: Mock; + let readResource: Mock; + let tool: LoadMcpResourceTool; + + beforeEach(() => { + ({tool, listResources, readResource} = setup()); + }); + + it('initializes with the load_mcp_resource name', () => { + expect(tool.name).toBe('load_mcp_resource'); + }); + + describe('_getDeclaration', () => { + it('declares a resource_names array-of-strings parameter', () => { + const declaration = tool._getDeclaration(); + + expect(declaration.name).toBe('load_mcp_resource'); + const resourceNames = + declaration.parameters?.properties?.['resource_names']; + expect(resourceNames?.type).toBe(Type.ARRAY); + expect(resourceNames?.items?.type).toBe(Type.STRING); + }); + }); + + describe('runAsync', () => { + it('echoes the requested resource names with a status', async () => { + const result = (await tool.runAsync({ + args: {resource_names: ['res1', 'res2']}, + toolContext, + })) as {resource_names: string[]; status: string}; + + expect(result.resource_names).toEqual(['res1', 'res2']); + expect(result.status).toContain('temporarily inserted'); + }); + + it('defaults resource_names to an empty array when absent', async () => { + const result = (await tool.runAsync({ + args: {}, + toolContext, + } as RunAsyncToolRequest)) as {resource_names: string[]}; + + expect(result.resource_names).toEqual([]); + }); + }); + + describe('processLlmRequest', () => { + it('injects the resource list into the system instruction', async () => { + listResources.mockResolvedValue(['res1', 'res2']); + const llmRequest = makeLlmRequest([]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toContain('res1'); + expect(llmRequest.config?.systemInstruction).toContain('res2'); + }); + + it('does not inject instructions when there are no resources', async () => { + listResources.mockResolvedValue([]); + const llmRequest = makeLlmRequest([]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); + + it('swallows list errors and still processes function responses', async () => { + listResources.mockRejectedValue(new Error('list failed')); + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await expect( + tool.processLlmRequest({toolContext, llmRequest}), + ).resolves.toBeUndefined(); + + expect(llmRequest.contents).toHaveLength(2); + }); + + it('appends text resource content', async () => { + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(readResource).toHaveBeenCalledWith('res1'); + expect(llmRequest.contents).toHaveLength(2); + const appended = llmRequest.contents[1]; + expect(appended.role).toBe('user'); + expect(appended.parts?.[0].text).toBe('Resource res1 is:'); + expect(appended.parts?.[1].text).toBe('hello content'); + }); + + it('appends binary resource content without decoding the base64 blob', async () => { + const blob = Buffer.from('binary data').toString('base64'); + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'image/png', blob}, + ]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const part = llmRequest.contents[1].parts?.[1]; + expect(part?.inlineData?.data).toBe(blob); + expect(part?.inlineData?.mimeType).toBe('image/png'); + }); + + it('defaults the mime type for binary content that lacks one', async () => { + const blob = Buffer.from('binary data').toString('base64'); + readResource.mockResolvedValue([{uri: 'file:///res1', blob}]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const part = llmRequest.contents[1].parts?.[1]; + expect(part?.inlineData?.mimeType).toBe('application/octet-stream'); + }); + + it('renders a placeholder for unknown content types', async () => { + readResource.mockResolvedValue([{uri: 'file:///res1'}]); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents[1].parts?.[1].text).toContain( + 'Unknown content type', + ); + }); + + it('swallows read errors and appends nothing for that resource', async () => { + readResource.mockRejectedValue(new Error('read failed')); + const llmRequest = makeLlmRequest([ + functionResponseContent({resource_names: ['res1']}), + ]); + + await expect( + tool.processLlmRequest({toolContext, llmRequest}), + ).resolves.toBeUndefined(); + + expect(llmRequest.contents).toHaveLength(1); + }); + + it('does nothing when the last content is not a matching function response', async () => { + const llmRequest = makeLlmRequest([ + { + role: 'user', + parts: [{functionResponse: {name: 'other_tool', response: {}}}], + }, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the last content has no parts', async () => { + const llmRequest = makeLlmRequest([{role: 'user'}]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the last content has an empty parts array', async () => { + const llmRequest = makeLlmRequest([{role: 'user', parts: []}]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('does nothing when the first part is not a function response', async () => { + const llmRequest = makeLlmRequest([ + {role: 'user', parts: [{text: 'just text'}]}, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('reads nothing when the function response omits resource_names', async () => { + const llmRequest = makeLlmRequest([ + { + role: 'user', + parts: [{functionResponse: {name: 'load_mcp_resource'}}], + }, + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(1); + expect(readResource).not.toHaveBeenCalled(); + }); + + it('appends content after a preceding conversation turn', async () => { + readResource.mockResolvedValue([ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello content'}, + ]); + const llmRequest = makeLlmRequest([ + {role: 'user', parts: [{text: 'earlier message'}]}, + functionResponseContent({resource_names: ['res1']}), + ]); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.contents).toHaveLength(3); + expect(llmRequest.contents[2].parts?.[1].text).toBe('hello content'); + }); + }); +}); diff --git a/core/test/tools/mcp/mcp_toolset_test.ts b/core/test/tools/mcp/mcp_toolset_test.ts index f875572d9..200ef8f8b 100644 --- a/core/test/tools/mcp/mcp_toolset_test.ts +++ b/core/test/tools/mcp/mcp_toolset_test.ts @@ -25,10 +25,24 @@ vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { {name: 'other-tool', description: 'Another tool', inputSchema: {}}, ], }), + listResources: vi.fn().mockResolvedValue({ + resources: [ + {uri: 'file:///res1', name: 'res1'}, + {uri: 'file:///res2', name: 'res2'}, + ], + }), + readResource: vi.fn().mockResolvedValue({ + contents: [ + {uri: 'file:///res1', mimeType: 'text/plain', text: 'hello'}, + ], + }), })), }; }); +/** A client method stub that resolves to nothing (connect/close). */ +const noop = () => vi.fn().mockResolvedValue(undefined); + vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { return { StdioClientTransport: vi.fn(), @@ -149,4 +163,174 @@ describe('MCPToolset', () => { expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength(0); }); }); + + describe('resources', () => { + it('listResources returns the mapped resource names', async () => { + const toolset = new MCPToolset(stdioParams); + + const names = await toolset.listResources(); + + expect(names).toEqual(['res1', 'res2']); + }); + + it('getResourceInfo returns the matching resource', async () => { + const toolset = new MCPToolset(stdioParams); + + const info = await toolset.getResourceInfo('res1'); + + expect(info.name).toBe('res1'); + expect(info.uri).toBe('file:///res1'); + }); + + it('getResourceInfo rejects when the name is unknown', async () => { + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.getResourceInfo('nope')).rejects.toThrow( + "Resource with name 'nope' not found.", + ); + }); + + it('readResource resolves the URI and returns the contents', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + const readResource = vi.fn().mockResolvedValue({ + contents: [{uri: 'file:///res1', text: 'hello'}], + }); + vi.mocked(Client) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: 'file:///res1', name: 'res1'}], + }), + }) as unknown as Client, + ) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + readResource, + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const contents = await toolset.readResource('res1'); + + expect(readResource).toHaveBeenCalledWith({uri: 'file:///res1'}); + expect(contents).toEqual([{uri: 'file:///res1', text: 'hello'}]); + }); + + it('readResource rejects when the name is unknown', async () => { + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.readResource('nope')).rejects.toThrow( + "Resource with name 'nope' not found.", + ); + }); + + it('readResource rejects when the resolved resource has no URI', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client).mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: '', name: 'res1'}], + }), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + + await expect(toolset.readResource('res1')).rejects.toThrow( + "Resource 'res1' has no URI.", + ); + }); + + describe('cleanup', () => { + it('closes the session after listResources succeeds', async () => { + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await toolset.listResources(); + + expect(spy).toHaveBeenCalledOnce(); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes the session even if the client listResources rejects', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client).mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockRejectedValue(new Error('list boom')), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await expect(toolset.listResources()).rejects.toThrow('list boom'); + expect(spy).toHaveBeenCalledOnce(); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes both sessions after readResource succeeds', async () => { + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await toolset.readResource('res1'); + + expect(spy).toHaveBeenCalledTimes(2); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + + it('closes both sessions even if the client readResource rejects', async () => { + const {Client} = + await import('@modelcontextprotocol/sdk/client/index.js'); + vi.mocked(Client) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + listResources: vi.fn().mockResolvedValue({ + resources: [{uri: 'file:///res1', name: 'res1'}], + }), + }) as unknown as Client, + ) + .mockImplementationOnce( + () => + ({ + connect: noop(), + close: noop(), + readResource: vi.fn().mockRejectedValue(new Error('read boom')), + }) as unknown as Client, + ); + + const toolset = new MCPToolset(stdioParams); + const spy = vi.spyOn(toolset['mcpSessionManager'], 'closeSession'); + + await expect(toolset.readResource('res1')).rejects.toThrow('read boom'); + expect(spy).toHaveBeenCalledTimes(2); + expect(toolset['mcpSessionManager'].getActiveSessions()).toHaveLength( + 0, + ); + }); + }); + }); }); diff --git a/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts b/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts new file mode 100644 index 000000000..c7b628921 --- /dev/null +++ b/tests/e2e/tools/mcp/load_mcp_resource_e2e_test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + LlmRequest, + LoadMcpResourceTool, + MCPToolset, +} from '@google/adk'; +import {fileURLToPath} from 'node:url'; +import {afterEach, describe, expect, it} from 'vitest'; + +/** + * End-to-end test with NO mocks: a real `MCPToolset` talks to a real MCP server + * (spawned as a stdio child process, see `mcp_resource_server.mjs`) that exposes + * a text and a binary resource. This proves the resource path works against an + * actual MCP server, not just against test doubles. + */ + +const SERVER_PATH = fileURLToPath( + new URL('./mcp_resource_server.mjs', import.meta.url), +); + +/** A throwaway tool context; the tool never reads from it. */ +const toolContext = {} as unknown as Context; + +function createToolset(): MCPToolset { + return new MCPToolset({ + type: 'StdioConnectionParams', + serverParams: {command: process.execPath, args: [SERVER_PATH]}, + }); +} + +function functionResponseRequest(resourceNames: string[]): LlmRequest { + return { + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'load_mcp_resource', + response: {resource_names: resourceNames}, + }, + }, + ], + }, + ], + toolsDict: {}, + } as unknown as LlmRequest; +} + +describe('LoadMcpResourceTool (e2e, real MCP server over stdio)', () => { + let toolset: MCPToolset; + + afterEach(async () => { + await toolset?.close(); + }); + + it('lists, resolves, and reads real MCP resources', async () => { + toolset = createToolset(); + + const names = await toolset.listResources(); + expect(names).toEqual(expect.arrayContaining(['readme', 'logo'])); + + const info = await toolset.getResourceInfo('readme'); + expect(info.uri).toBe('file:///readme.txt'); + + const textContents = await toolset.readResource('readme'); + expect(textContents[0]).toMatchObject({text: 'hello from mcp resource'}); + + const binaryContents = await toolset.readResource('logo'); + expect(binaryContents[0]).toMatchObject({ + blob: Buffer.from('binary-logo-bytes').toString('base64'), + mimeType: 'image/png', + }); + }); + + it('rejects when reading an unknown resource', async () => { + toolset = createToolset(); + + await expect(toolset.readResource('does-not-exist')).rejects.toThrow( + 'not found', + ); + }); + + it('injects real resource contents into the LlmRequest via the tool', async () => { + toolset = createToolset(); + const tool = new LoadMcpResourceTool(toolset); + const llmRequest = functionResponseRequest(['readme', 'logo']); + + await tool.processLlmRequest({toolContext, llmRequest}); + + // The server advertises the resources, so the guidance is injected. + expect(llmRequest.config?.systemInstruction).toContain('readme'); + + // The original function-response turn plus one appended turn per resource. + expect(llmRequest.contents).toHaveLength(3); + + const textTurn = llmRequest.contents[1]; + expect(textTurn.role).toBe('user'); + expect(textTurn.parts?.[0].text).toBe('Resource readme is:'); + expect(textTurn.parts?.[1].text).toBe('hello from mcp resource'); + + const binaryTurn = llmRequest.contents[2]; + expect(binaryTurn.parts?.[0].text).toBe('Resource logo is:'); + expect(binaryTurn.parts?.[1].inlineData?.mimeType).toBe('image/png'); + expect(binaryTurn.parts?.[1].inlineData?.data).toBe( + Buffer.from('binary-logo-bytes').toString('base64'), + ); + }); +}); diff --git a/tests/e2e/tools/mcp/mcp_resource_server.mjs b/tests/e2e/tools/mcp/mcp_resource_server.mjs new file mode 100644 index 000000000..cdf6b7ce8 --- /dev/null +++ b/tests/e2e/tools/mcp/mcp_resource_server.mjs @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A minimal, real MCP server exposing two resources (one text, one binary) over + * stdio. It is spawned as a child process by the LoadMcpResourceTool e2e test to + * exercise the resource path end-to-end with no mocks. + */ + +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({name: 'e2e-resource-server', version: '1.0.0'}); + +server.registerResource( + 'readme', + 'file:///readme.txt', + {mimeType: 'text/plain'}, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'text/plain', + text: 'hello from mcp resource', + }, + ], + }), +); + +server.registerResource( + 'logo', + 'file:///logo.png', + {mimeType: 'image/png'}, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'image/png', + blob: Buffer.from('binary-logo-bytes').toString('base64'), + }, + ], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); From 49c34a8cc7ded990e152e236cf975b45ad2529ea Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:58:18 -0700 Subject: [PATCH 4/8] Feat: Add ExampleTool for few-shot examples (adk-python parity) (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tools): add ExampleTool for few-shot examples Port adk-python's ExampleTool to adk-js. The tool accepts a static Example[] or a BaseExampleProvider and, on each outgoing LLM request, appends a few-shot block (built via buildExampleSi from the latest user query) to the system instruction. It is never declared to the model (mirrors PreloadMemoryTool) and is a no-op when no user text is present. Exported from the public @google/adk API. * test(tools): cover ExampleTool unit and end-to-end paths Add Vitest coverage for ExampleTool: static list and provider paths, model-style passthrough, no-op branches (missing user content, empty parts, text-less first part), runAsync throwing, and the public export. Includes an end-to-end block that drives processLlmRequest through a real Context/InvocationContext (no mocks). 100% line/branch coverage of the new tool. * refactor(tools): apply simplicity audit feedback Use a constructor parameter property for `examples` (repo convention), and drop the redundant provider end-to-end test whose only unique aspect was a spy — keeping the no-mock e2e block strictly mock-free. The provider selection path stays fully covered by the unit tests; the tool retains 100% line/branch coverage. --------- Co-authored-by: Amaad Martin --- core/src/common.ts | 1 + core/src/tools/example_tool.ts | 52 +++++++ core/test/tools/example_tool_test.ts | 200 +++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 core/src/tools/example_tool.ts create mode 100644 core/test/tools/example_tool_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index 1e4696b26..b87d0b936 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -236,6 +236,7 @@ export type { export {BaseToolset, isBaseToolset} from './tools/base_toolset.js'; export type {ToolPredicate} from './tools/base_toolset.js'; export {ConsolidateContextTool} from './tools/consolidate_context_tool.js'; +export {ExampleTool} from './tools/example_tool.js'; export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js'; export {FunctionTool, isFunctionTool} from './tools/function_tool.js'; export type { diff --git a/core/src/tools/example_tool.ts b/core/src/tools/example_tool.ts new file mode 100644 index 000000000..14128c4cb --- /dev/null +++ b/core/src/tools/example_tool.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BaseExampleProvider} from '../examples/base_example_provider.js'; +import {Example} from '../examples/example.js'; +import {buildExampleSi} from '../examples/example_util.js'; +import {appendInstructions} from '../models/llm_request.js'; + +import { + BaseTool, + RunAsyncToolRequest, + ToolProcessLlmRequest, +} from './base_tool.js'; + +/** + * A tool that adds (few-shot) examples to the LLM request. + * + * This tool is executed for each LLM request and is never called by the model; + * it only mutates the outgoing request by appending few-shot instructions built + * from the latest user query. + */ +export class ExampleTool extends BaseTool { + constructor(readonly examples: Example[] | BaseExampleProvider) { + super({ + // Name and description are not used because this tool only changes + // llmRequest. + name: 'example_tool', + description: 'example tool', + }); + } + + override async runAsync(_request: RunAsyncToolRequest): Promise { + // Should not be called by model because it's not declared in LLM tools list. + throw new Error('ExampleTool should not be called by model'); + } + + override async processLlmRequest({ + toolContext, + llmRequest, + }: ToolProcessLlmRequest): Promise { + const parts = toolContext.userContent?.parts; + if (!parts || !parts[0]?.text) { + return; + } + appendInstructions(llmRequest, [ + buildExampleSi(this.examples, parts[0].text, llmRequest.model), + ]); + } +} diff --git a/core/test/tools/example_tool_test.ts b/core/test/tools/example_tool_test.ts new file mode 100644 index 000000000..963dbf940 --- /dev/null +++ b/core/test/tools/example_tool_test.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it, vi} from 'vitest'; + +import { + BaseAgent, + BaseExampleProvider, + Context, + createSession, + Example, + ExampleTool, + InvocationContext, + LlmRequest, + PluginManager, +} from '@google/adk'; +import {Content} from '@google/genai'; + +const SIMPLE_EXAMPLE: Example = { + input: {parts: [{text: 'What is 2+2?'}]}, + output: [{role: 'model', parts: [{text: '4'}]}], +}; + +const FUNCTION_CALL_EXAMPLE: Example = { + input: {parts: [{text: 'Search for cats'}]}, + output: [ + { + role: 'model', + parts: [{functionCall: {name: 'search', args: {query: 'cats'}}}], + }, + {role: 'model', parts: [{text: 'Found cats!'}]}, + ], +}; + +class FixedExampleProvider extends BaseExampleProvider { + constructor(private readonly examples: Example[]) { + super(); + } + override getExamples(_query: string): Example[] { + return this.examples; + } +} + +/** + * Builds a `toolContext` stub exposing only the `userContent` used by + * ExampleTool, cast to `Context` (mirrors `StubToolContext` in + * `preload_memory_tool_test.ts`). + */ +function makeToolContext(userContent: unknown): Context { + return {userContent} as unknown as Context; +} + +function makeLlmRequest(model?: string): LlmRequest { + return { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + config: {}, + model, + }; +} + +describe('ExampleTool', () => { + it('appends few-shot instructions from a static list of examples', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeToolContext({ + role: 'user', + parts: [{text: 'What is 2+2?'}], + }); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const instruction = llmRequest.config?.systemInstruction; + expect(instruction).toBeDefined(); + expect(instruction).toContain(''); + expect(instruction).toContain('What is 2+2?'); + expect(instruction).toContain('4'); + }); + + it('appends instructions from a BaseExampleProvider and threads the query', async () => { + const provider = new FixedExampleProvider([SIMPLE_EXAMPLE]); + const getExamplesSpy = vi.spyOn(provider, 'getExamples'); + const tool = new ExampleTool(provider); + const toolContext = makeToolContext({ + role: 'user', + parts: [{text: 'What is 2+2?'}], + }); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(getExamplesSpy).toHaveBeenCalledWith('What is 2+2?'); + expect(llmRequest.config?.systemInstruction).toContain('What is 2+2?'); + }); + + it('forwards llmRequest.model to buildExampleSi (function-call fence style)', async () => { + const tool = new ExampleTool([FUNCTION_CALL_EXAMPLE]); + const toolContext = makeToolContext({ + role: 'user', + parts: [{text: 'Search for cats'}], + }); + const llmRequest = makeLlmRequest('gemini-1.5-pro'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toContain('```tool_code'); + }); + + it('is a no-op when userContent is undefined', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeToolContext(undefined); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); + + it('is a no-op when userContent has no parts', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeToolContext({role: 'user', parts: []}); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); + + it('is a no-op when the first part has no text', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeToolContext({role: 'user', parts: [{}]}); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); + + it('throws in runAsync because it is not meant to be called by the model', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeToolContext(undefined); + + await expect(tool.runAsync({args: {}, toolContext})).rejects.toThrow( + 'ExampleTool should not be called by model', + ); + }); + + it('is importable from @google/adk (public export)', () => { + expect(new ExampleTool([])).toBeInstanceOf(ExampleTool); + }); +}); + +/** + * Builds a real `Context` backed by a real `InvocationContext`/`Session` (no + * stubs), so the tool is exercised against genuine ADK plumbing exactly as the + * agent request loop invokes it (llm_agent.ts). + */ +function makeRealContext(userContent?: Content): Context { + const session = createSession({id: 'test-session', appName: 'test-app'}); + const invocationContext = new InvocationContext({ + invocationId: 'test-invocation', + agent: {} as BaseAgent, + session, + pluginManager: new PluginManager([]), + userContent, + }); + return new Context({invocationContext}); +} + +describe('ExampleTool (end-to-end with real framework objects)', () => { + it('appends the few-shot block when driven through a real Context', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeRealContext({ + role: 'user', + parts: [{text: 'What is 2+2?'}], + }); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + const instruction = llmRequest.config?.systemInstruction; + expect(instruction).toContain(''); + expect(instruction).toContain('What is 2+2?'); + expect(instruction).toContain('4'); + }); + + it('is a no-op when the real invocation has no user content', async () => { + const tool = new ExampleTool([SIMPLE_EXAMPLE]); + const toolContext = makeRealContext(undefined); + const llmRequest = makeLlmRequest('gemini-2.0-flash'); + + await tool.processLlmRequest({toolContext, llmRequest}); + + expect(llmRequest.config?.systemInstruction).toBeUndefined(); + }); +}); From 0815a8c7010d6ceb4934ceaf762b95ad1d9c6941 Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:03:57 -0700 Subject: [PATCH 5/8] Feat: support clone() for RoutedAgent (Part 2/2) (#556) * feat(agents): support clone() for RoutedAgent RoutedAgent derives its routing targets from config.agents rather than subAgents, so the inherited BaseAgent.clone() rebuilt the agent from the already-parented originals and threw "already has a parent agent". Add a RoutedAgent.clone() override that deep-clones the routing targets (via a private cloneRoutingTargets helper) and passes them through the agents override, so super.clone() rebuilds the constructor with fresh, detached copies that are re-parented onto the clone. The array-vs-record shape and record keys are preserved so the clone routes identically, and parent-override rejection plus the detached-root guarantee are still enforced by the base implementation. Remove the now-obsolete "documented limitation" test (and its unused RoutedAgent import) from base_agent_test; positive coverage lives in routed_agent_test. * test(agents): cover RoutedAgent.clone() Add a clone describe suite exercising the new override and the cloneRoutingTargets helper: array and record forms, deep-clone and re-parenting of targets, originals left untouched, functional routing on the clone (record form), verbatim agents override, non-agents overrides, and parentAgent-override rejection. Includes a no-mock end-to-end case that clones a RoutedAgent whose targets are real LlmAgents. --------- Co-authored-by: Amaad Martin --- core/src/agents/routed_agent.ts | 50 +++++++- core/test/agents/base_agent_test.ts | 14 --- core/test/agents/routed_agent_test.ts | 162 ++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 19 deletions(-) diff --git a/core/src/agents/routed_agent.ts b/core/src/agents/routed_agent.ts index 18e0d46bf..02a68672d 100644 --- a/core/src/agents/routed_agent.ts +++ b/core/src/agents/routed_agent.ts @@ -61,11 +61,10 @@ export interface RoutedAgentConfig extends BaseAgentConfig { * Routing is strictly limited to the agents passed in the config. * * @remarks - * The inherited {@link BaseAgent.clone} does not support `RoutedAgent`: the - * constructor derives its routing targets from `config.agents` (not - * `subAgents`), so a rebuilt clone re-reads the already-parented originals and - * throws. Cloning a `RoutedAgent` is tracked as a follow-up to - * google/adk-js#534. + * Cloning is supported: {@link RoutedAgent.clone} deep-clones the routing + * targets from `config.agents` and re-parents the fresh copies onto the clone, + * so the clone is a detached root that routes identically to the original while + * leaving the original agent tree untouched. */ @experimental export class RoutedAgent extends BaseAgent { @@ -94,6 +93,30 @@ export class RoutedAgent extends BaseAgent { this.router = config.router; } + /** + * Creates a detached copy of this routed agent. + * + * The inherited {@link BaseAgent.clone} only deep-clones `subAgents`, but a + * `RoutedAgent` derives its routing targets from `config.agents`. Rebuilding + * through the base clone alone would re-read the already-parented original + * agents and throw "already has a parent agent". This override deep-clones the + * routing targets (unless the caller overrides `agents`) so the rebuilt + * constructor re-parents fresh, detached copies. The array-vs-record shape + * and, for records, the keys are preserved so the router keeps selecting the + * same targets. + * + * @param overrides Config fields to override on the clone. Overriding + * `parentAgent` is rejected by the base implementation. + * @returns A new detached `RoutedAgent` of the same concrete class. + */ + override clone(overrides?: Partial): this { + const nextOverrides: Partial = {...overrides}; + if (!('agents' in nextOverrides)) { + nextOverrides.agents = cloneRoutingTargets(this.config.agents); + } + return super.clone(nextOverrides); + } + /** * Runs the selected agent via text-based conversation. */ @@ -116,3 +139,20 @@ export class RoutedAgent extends BaseAgent { ); } } + +/** + * Deep-clones a RoutedAgent's routing targets, preserving whether they were + * supplied as an array or a keyed record so the rebuilt constructor derives the + * same routing map. Each clone is detached (no parent), so the constructor can + * re-parent it without conflict. + */ +function cloneRoutingTargets( + agents: Readonly> | BaseAgent[], +): Readonly> | BaseAgent[] { + if (Array.isArray(agents)) { + return agents.map((agent) => agent.clone()); + } + return Object.fromEntries( + Object.entries(agents).map(([key, agent]) => [key, agent.clone()]), + ); +} diff --git a/core/test/agents/base_agent_test.ts b/core/test/agents/base_agent_test.ts index 563a08a54..b139a1b77 100644 --- a/core/test/agents/base_agent_test.ts +++ b/core/test/agents/base_agent_test.ts @@ -13,7 +13,6 @@ import { InvocationContext, LlmAgent, PluginManager, - RoutedAgent, Session, createEvent, } from '@google/adk'; @@ -300,18 +299,5 @@ describe('BaseAgent', () => { expect(clone.name).toBe('mock'); expect(clone.description).toBe('a mock'); }); - - it('does not support cloning a RoutedAgent (documented limitation)', () => { - const target = new LlmAgent({name: 'target'}); - const routed = new RoutedAgent({ - name: 'router', - agents: [target], - router: () => 'target', - }); - - // The constructor re-derives routing targets from the already-parented - // originals, so the rebuilt clone throws. Tracked as a follow-up. - expect(() => routed.clone()).toThrow('already has a parent agent'); - }); }); }); diff --git a/core/test/agents/routed_agent_test.ts b/core/test/agents/routed_agent_test.ts index 8330d02e0..4df282afe 100644 --- a/core/test/agents/routed_agent_test.ts +++ b/core/test/agents/routed_agent_test.ts @@ -6,9 +6,11 @@ import { BaseAgent, + BaseAgentConfig, Event, InvocationContext, InvocationContextParams, + LlmAgent, RoutedAgent, Session, createEvent, @@ -475,6 +477,166 @@ describe('RoutedAgent', () => { expect(result.value?.author).toBe('agent-success'); expect(routerCalls).toBe(2); }); + + describe('clone', () => { + // A clone-compatible mock: unlike the top-level `MockAgent` (whose + // constructor takes a bare `name`), this accepts a config object, so + // `BaseAgent.clone()` can rebuild it via `new ctor(config)`. It yields a + // deterministic event so routing can be verified without a model. + class CloneableAgent extends BaseAgent { + constructor(config: BaseAgentConfig) { + super(config); + } + + protected async *runAsyncImpl( + context: InvocationContext, + ): AsyncGenerator { + yield createEvent({ + invocationId: context.invocationId, + author: this.name, + branch: context.branch, + content: { + role: 'model', + parts: [{text: `Response from ${this.name}`}], + }, + }); + } + + protected async *runLiveImpl( + _context: InvocationContext, + ): AsyncGenerator {} + } + + it('deep-clones and re-parents array-form routing targets (detached root)', () => { + const agentA = new CloneableAgent({name: 'agent-a'}); + const agentB = new CloneableAgent({name: 'agent-b'}); + const original = new RoutedAgent({ + name: 'router', + agents: [agentA, agentB], + router: () => 'agent-a', + }); + + const clone = original.clone(); + + expect(clone).not.toBe(original); + expect(clone).toBeInstanceOf(RoutedAgent); + expect(clone.parentAgent).toBeUndefined(); + expect(clone.subAgents).toHaveLength(2); + clone.subAgents.forEach((sub, i) => { + expect(sub).not.toBe(original.subAgents[i]); + expect(sub).toBeInstanceOf(CloneableAgent); + expect(sub.name).toBe(original.subAgents[i].name); + expect(sub.parentAgent).toBe(clone); + }); + expect(clone.subAgents.map((s) => s.name)).toEqual([ + 'agent-a', + 'agent-b', + ]); + + // The originals are left untouched: still parented to the original router. + expect(agentA.parentAgent).toBe(original); + expect(agentB.parentAgent).toBe(original); + expect(original.subAgents).toHaveLength(2); + expect(original.subAgents[0]).toBe(agentA); + expect(original.subAgents[1]).toBe(agentB); + }); + + it('preserves record keys and routes to the cloned target for the selected key', async () => { + const primary = new CloneableAgent({name: 'primary-agent'}); + const fallback = new CloneableAgent({name: 'fallback-agent'}); + const original = new RoutedAgent({ + name: 'router', + agents: {primary, fallback}, + router: () => 'primary', + }); + + const clone = original.clone(); + + // The routing map keeps the record keys and maps them to cloned targets. + const routingMap = clone['agents'] as Readonly>; + expect(Object.keys(routingMap)).toEqual(['primary', 'fallback']); + expect(routingMap['primary'].name).toBe('primary-agent'); + expect(routingMap['primary']).not.toBe(primary); + expect(routingMap['primary'].parentAgent).toBe(clone); + + // Functionally routes to the cloned primary target. + const context = createTestContext({agent: clone}); + const result = await clone['runAsyncImpl'](context).next(); + expect(result.value?.author).toBe('primary-agent'); + }); + + it('uses an `agents` override verbatim without cloning it', async () => { + const target = new CloneableAgent({name: 'target'}); + const original = new RoutedAgent({ + name: 'router', + agents: [target], + router: () => 'replacement', + }); + const replacement = new CloneableAgent({name: 'replacement'}); + + const clone = original.clone({agents: [replacement]}); + + expect(clone.subAgents).toHaveLength(1); + expect(clone.subAgents[0]).toBe(replacement); + expect(replacement.parentAgent).toBe(clone); + + const context = createTestContext({agent: clone}); + const result = await clone['runAsyncImpl'](context).next(); + expect(result.value?.author).toBe('replacement'); + }); + + it('applies non-`agents` overrides while still cloning targets', () => { + const agentA = new CloneableAgent({name: 'agent-a'}); + const original = new RoutedAgent({ + name: 'router', + agents: [agentA], + router: () => 'agent-a', + }); + + const clone = original.clone({name: 'router2'}); + + expect(clone.name).toBe('router2'); + expect(original.name).toBe('router'); + expect(clone.subAgents).toHaveLength(1); + expect(clone.subAgents[0]).not.toBe(agentA); + expect(clone.subAgents[0].name).toBe('agent-a'); + expect(clone.subAgents[0].parentAgent).toBe(clone); + }); + + it('rejects a `parentAgent` override', () => { + const agentA = new CloneableAgent({name: 'agent-a'}); + const original = new RoutedAgent({ + name: 'router', + agents: [agentA], + router: () => 'agent-a', + }); + const someParent = new CloneableAgent({name: 'some-parent'}); + + expect(() => original.clone({parentAgent: someParent})).toThrow( + 'Cannot update `parentAgent` field in clone.', + ); + }); + + it('clones a RoutedAgent whose targets are real LlmAgents (array form)', () => { + const target = new LlmAgent({name: 'target'}); + const original = new RoutedAgent({ + name: 'router', + agents: [target], + router: () => 'target', + }); + + const clone = original.clone(); + + expect(clone).not.toBe(original); + expect(clone).toBeInstanceOf(RoutedAgent); + expect(clone.parentAgent).toBeUndefined(); + expect(clone.subAgents[0]).toBeInstanceOf(LlmAgent); + expect(clone.subAgents[0]).not.toBe(target); + expect(clone.subAgents[0].name).toBe('target'); + expect(clone.subAgents[0].parentAgent).toBe(clone); + expect(target.parentAgent).toBe(original); + }); + }); }); describe('isRoutedAgent', () => { From 9fa7e6e93a6816aec919ca76771a74ff2709ec35 Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:04:34 -0700 Subject: [PATCH 6/8] Feat: Add SSRF-safe load_web_page tool (adk-python parity) (#524) * Feat(tools): add SSRF-safe load_web_page tool for adk-python parity Ports the adk-python load_web_page tool to adk-js. Fetches a URL and returns its extracted, readable text, hardened against SSRF: - only http/https schemes are fetched - localhost-style hostnames and hosts resolving to non-global IPs (private, loopback, link-local, shared/CGNAT, reserved, multicast, IPv4-mapped IPv6) are rejected before any connection - redirects are never followed (redirect: 'manual') - a configurable timeout (default 30s) bounds every request - expected failures return the parity string "Failed to fetch url: " instead of throwing Exposes loadWebPage(), the LOAD_WEB_PAGE FunctionTool, and the LoadWebPageOptions type via the @google/adk public API. * Refactor(tools): inline single-use failure prefix in load_web_page Addresses simplicity-audit feedback: the FAILURE_PREFIX constant had a single caller, so its literal is inlined into failedToFetchMessage, which remains the sole formatter of the parity failure string. --------- Co-authored-by: Amaad Martin --- core/src/common.ts | 2 + core/src/tools/load_web_page.ts | 316 ++++++++++++++++++++++ core/test/tools/load_web_page_test.ts | 376 ++++++++++++++++++++++++++ 3 files changed, 694 insertions(+) create mode 100644 core/src/tools/load_web_page.ts create mode 100644 core/test/tools/load_web_page_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index b87d0b936..4650424e3 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -255,6 +255,8 @@ export { LoadArtifactsTool, } from './tools/load_artifacts_tool.js'; export {LOAD_MEMORY, LoadMemoryTool} from './tools/load_memory_tool.js'; +export {LOAD_WEB_PAGE, loadWebPage} from './tools/load_web_page.js'; +export type {LoadWebPageOptions} from './tools/load_web_page.js'; export {LongRunningFunctionTool} from './tools/long_running_tool.js'; export { PRELOAD_MEMORY, diff --git a/core/src/tools/load_web_page.ts b/core/src/tools/load_web_page.ts new file mode 100644 index 000000000..053b11fa9 --- /dev/null +++ b/core/src/tools/load_web_page.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {lookup} from 'node:dns/promises'; +import {isIP} from 'node:net'; + +import {z} from 'zod'; + +import {FunctionTool} from './function_tool.js'; + +/** Options for {@link loadWebPage}. */ +export interface LoadWebPageOptions { + /** Request timeout in milliseconds. Defaults to 30_000 (30s). */ + timeoutMs?: number; +} + +/** URL schemes that are allowed to be fetched (WHATWG `URL.protocol` form). */ +const ALLOWED_SCHEMES = new Set(['http:', 'https:']); + +/** Default request timeout in milliseconds. */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * IPv4 ranges that are not globally routable and therefore blocked to defeat + * SSRF. Mirrors the non-global ranges rejected by Python's + * `ipaddress.is_global`. + */ +const BLOCKED_IPV4_CIDRS = [ + '0.0.0.0/8', // "this host on this network" + '10.0.0.0/8', // private + '100.64.0.0/10', // shared address space / CGNAT + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link-local (includes GCP metadata 169.254.169.254) + '172.16.0.0/12', // private + '192.0.0.0/24', // IETF protocol assignments + '192.0.2.0/24', // TEST-NET-1 (documentation) + '192.88.99.0/24', // 6to4 relay anycast (deprecated) + '192.168.0.0/16', // private + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // TEST-NET-2 (documentation) + '203.0.113.0/24', // TEST-NET-3 (documentation) + '224.0.0.0/4', // multicast + '240.0.0.0/4', // reserved / future use (includes 255.255.255.255) +].map(parseIpv4Cidr); + +/** + * IPv6 ranges that are not globally routable and therefore blocked. The + * IPv4-mapped range `::ffff:0:0/96` is handled separately by extracting the + * embedded IPv4 address and re-checking it with the IPv4 rules. + */ +const BLOCKED_IPV6_CIDRS = [ + '::/128', // unspecified + '::1/128', // loopback + '64:ff9b:1::/48', // local NAT64 + '100::/64', // discard-only + '2001:db8::/32', // documentation + 'fc00::/7', // unique-local (ULA, private) + 'fe80::/10', // link-local + 'ff00::/8', // multicast +].map(parseIpv6Cidr); + +/** Builds the parity failure message for a URL. */ +function failedToFetchMessage(url: string): string { + return `Failed to fetch url: ${url}`; +} + +/** + * Returns `true` for `localhost` and any `*.localhost` name (case-insensitive, + * ignoring a trailing dot), matching the Python `_is_blocked_hostname` helper. + */ +function isBlockedHostname(hostname: string): boolean { + const normalized = hostname.replace(/\.+$/, '').toLowerCase(); + return normalized === 'localhost' || normalized.endsWith('.localhost'); +} + +/** Strips the surrounding brackets from an IPv6 URL hostname (`[::1]` → `::1`). */ +function normalizeHost(hostname: string): string { + return hostname.startsWith('[') ? hostname.slice(1, -1) : hostname; +} + +/** Parses a dotted-quad IPv4 string into its four octets, or `null`. */ +function parseIpv4(address: string): number[] | null { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address); + if (!match) { + return null; + } + const octets = match.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) { + return null; + } + return octets; +} + +/** Expands a valid IPv6 address string into its eight 16-bit hextets, or `null`. */ +function parseIpv6(address: string): number[] | null { + if (isIP(address) !== 6) { + return null; + } + const [head, tail] = address.split('::'); + const highGroups = head ? expandHextets(head) : []; + const lowGroups = tail ? expandHextets(tail) : []; + const compressed = address.includes('::') + ? new Array(8 - highGroups.length - lowGroups.length).fill(0) + : []; + return [...highGroups, ...compressed, ...lowGroups]; +} + +/** + * Converts a colon-separated IPv6 fragment into hextets, expanding a trailing + * embedded IPv4 group (e.g. the `1.2.3.4` in `::ffff:1.2.3.4`) into two hextets. + */ +function expandHextets(fragment: string): number[] { + const hextets: number[] = []; + for (const group of fragment.split(':')) { + if (group.includes('.')) { + const octets = parseIpv4(group)!; + hextets.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]); + } else { + hextets.push(parseInt(group, 16)); + } + } + return hextets; +} + +/** Packs four IPv4 octets into an unsigned 32-bit integer. */ +function ipv4ToInt(octets: number[]): number { + return ( + ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0 + ); +} + +/** Packs eight IPv6 hextets into a 128-bit BigInt. */ +function hextetsToBigInt(hextets: number[]): bigint { + let value = 0n; + for (const hextet of hextets) { + value = (value << 16n) | BigInt(hextet); + } + return value; +} + +/** Precomputes the network address and mask for an IPv4 CIDR string. */ +function parseIpv4Cidr(cidr: string): {base: number; mask: number} { + const [address, prefix] = cidr.split('/'); + const mask = (0xffffffff << (32 - Number(prefix))) >>> 0; + return {base: (ipv4ToInt(parseIpv4(address)!) & mask) >>> 0, mask}; +} + +/** Precomputes the network address and prefix length for an IPv6 CIDR string. */ +function parseIpv6Cidr(cidr: string): {base: bigint; prefix: number} { + const [address, prefix] = cidr.split('/'); + return {base: hextetsToBigInt(parseIpv6(address)!), prefix: Number(prefix)}; +} + +/** Returns `true` if the IPv4 octets fall within any blocked range. */ +function isBlockedIpv4(octets: number[]): boolean { + const value = ipv4ToInt(octets); + return BLOCKED_IPV4_CIDRS.some( + ({base, mask}) => (value & mask) >>> 0 === base, + ); +} + +/** Returns `true` if the IPv6 hextets fall within any blocked range. */ +function isBlockedIpv6(hextets: number[]): boolean { + const value = hextetsToBigInt(hextets); + // IPv4-mapped (::ffff:0:0/96): re-check the embedded IPv4 address. + if (value >> 32n === 0xffffn) { + return isBlockedIpv4([ + Number((value >> 24n) & 0xffn), + Number((value >> 16n) & 0xffn), + Number((value >> 8n) & 0xffn), + Number(value & 0xffn), + ]); + } + return BLOCKED_IPV6_CIDRS.some( + ({base, prefix}) => + value >> BigInt(128 - prefix) === base >> BigInt(128 - prefix), + ); +} + +/** + * Returns `true` when `address` is not globally routable (private, loopback, + * link-local, shared, reserved, multicast, ...). Unparseable input fails + * closed (blocked). + */ +function isBlockedAddress(address: string): boolean { + const octets = parseIpv4(address); + if (octets) { + return isBlockedIpv4(octets); + } + const hextets = parseIpv6(address); + if (hextets) { + return isBlockedIpv6(hextets); + } + return true; +} + +/** + * Resolves `hostname` to a de-duplicated list of IP addresses. IP literals are + * returned as-is; hostnames are resolved via DNS. Throws when resolution + * yields no address. + */ +async function resolveHostAddresses(hostname: string): Promise { + if (isIP(hostname) !== 0) { + return [hostname]; + } + const records = await lookup(hostname, {all: true}); + const addresses = [...new Set(records.map((record) => record.address))]; + if (addresses.length === 0) { + throw new Error(`Unable to resolve host: ${hostname}`); + } + return addresses; +} + +/** + * Validates the URL's scheme and hostname up front (before any network access). + * Throws for malformed URLs, disallowed schemes, and blocked hostnames. + */ +function assertUrlAllowed(url: string): URL { + const parsed = new URL(url); + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + throw new Error(`Unsupported url scheme: ${url}`); + } + if (isBlockedHostname(parsed.hostname)) { + throw new Error(`Blocked host: ${parsed.hostname}`); + } + return parsed; +} + +/** Resolves the host and throws if any resolved address is not globally routable. */ +async function validateResolvedAddresses(hostname: string): Promise { + const addresses = await resolveHostAddresses(hostname); + if (addresses.some(isBlockedAddress)) { + throw new Error(`Blocked host: ${hostname}`); + } +} + +/** Decodes the small set of HTML entities that survive tag stripping. */ +function decodeHtmlEntities(text: string): string { + return text + .replace(/ /g, ' ') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** + * Extracts readable text from an HTML document. Removes `' + + '' + + '

Fish & chips are quite tasty today

', + ), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Fish & chips are quite tasty today'); + expect(result).not.toContain('secret'); + expect(result).not.toContain('color:red'); + expect(result).not.toContain('comment'); + }); + + it('allows a global IPv6 literal target', async () => { + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://[2606:4700:4700::1111]/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('allows a global IPv6 address resolved via DNS (full form)', async () => { + resolveTo('2606:4700:4700:0:0:0:0:1111'); + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://ipv6.example/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('allows an IPv4-mapped IPv6 address pointing at a public IP', async () => { + resolveTo('::ffff:93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

The quick brown fox jumped over here

'), + ); + + const result = await loadWebPage('http://mapped-public.example/'); + + expect(result).toBe('The quick brown fox jumped over here'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('returns an empty string when no line has enough words', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue(htmlResponse('

too short

')); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe(''); + }); + }); + + describe('response and transport failures', () => { + it.each([301, 302, 404, 500])( + 'returns the failure string for non-200 status %i', + async (status) => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

ignored body here

', status), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }, + ); + + it('returns the failure string when the request times out', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockRejectedValue( + new DOMException('The operation timed out.', 'TimeoutError'), + ); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }); + + it('returns the failure string on a network error', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockRejectedValue(new TypeError('network failure')); + + const result = await loadWebPage('https://example.com/'); + + expect(result).toBe('Failed to fetch url: https://example.com/'); + }); + }); + + describe('timeout configuration', () => { + it('uses the 30s default and honors an override', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

enough words to be kept here

'), + ); + + await loadWebPage('https://example.com/'); + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(30_000); + + await loadWebPage('https://example.com/', {timeoutMs: 5000}); + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(5000); + }); + + it('falls back to the default when options omit timeoutMs', async () => { + resolveTo('93.184.216.34'); + fetchMock.mockResolvedValue( + htmlResponse('

enough words to be kept here

'), + ); + + await loadWebPage('https://example.com/', {}); + + expect(vi.mocked(AbortSignal.timeout)).toHaveBeenLastCalledWith(30_000); + }); + }); +}); + +describe('LOAD_WEB_PAGE tool', () => { + it('is a FunctionTool exposing a load_web_page declaration', () => { + expect(LOAD_WEB_PAGE).toBeInstanceOf(FunctionTool); + + const declaration = LOAD_WEB_PAGE._getDeclaration(); + expect(declaration?.name).toBe('load_web_page'); + expect(declaration?.parameters?.properties?.['url']).toBeDefined(); + }); + + it('runs through the tool interface and returns the parity failure string', async () => { + const result = await LOAD_WEB_PAGE.runAsync({ + args: {url: 'file:///etc/passwd'}, + toolContext: {} as never, + }); + + expect(result).toBe('Failed to fetch url: file:///etc/passwd'); + }); +}); From d9a9692dd3d00803943b08fcba934a6bff5de6d2 Mon Sep 17 00:00:00 2001 From: Amaad Martin <57241464+AmaadMartin@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:41:42 -0700 Subject: [PATCH 7/8] Feat: Add EnterpriseWebSearchTool for Gemini web grounding (adk-python parity) (#525) * feat(tools): add EnterpriseWebSearchTool for Gemini web grounding Ports adk-python's EnterpriseWebSearchTool to adk-js, closing a cross-language parity gap. The tool is a Gemini 2+ built-in grounding source that appends {enterpriseWebSearch: {}} to the outgoing LlmRequest config; it performs no client-side execution. Mirrors the google_maps_grounding_tool idiom (extracted applyEnterpriseWebSearch function + ADK_DISABLE_GEMINI_MODEL_ID_CHECK escape hatch). Exported from the public API via common.ts. * test(tools): add unit tests for EnterpriseWebSearchTool Covers every branch of applyEnterpriseWebSearch (100% line/branch): model-unset guard, Gemini 2+ (plain + path form), config initialization, Gemini 1.x with/without other tools, non-Gemini rejection, and the ADK_DISABLE_GEMINI_MODEL_ID_CHECK escape hatch, plus the runAsync no-op and the exported singleton. Mock-free; imports via the @google/adk public entry point. --------- Co-authored-by: Amaad Martin --- core/src/common.ts | 4 + core/src/tools/enterprise_web_search_tool.ts | 84 ++++++++++ .../tools/enterprise_web_search_tool_test.ts | 146 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 core/src/tools/enterprise_web_search_tool.ts create mode 100644 core/test/tools/enterprise_web_search_tool_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index 4650424e3..d7f737184 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -236,6 +236,10 @@ export type { export {BaseToolset, isBaseToolset} from './tools/base_toolset.js'; export type {ToolPredicate} from './tools/base_toolset.js'; export {ConsolidateContextTool} from './tools/consolidate_context_tool.js'; +export { + ENTERPRISE_WEB_SEARCH, + EnterpriseWebSearchTool, +} from './tools/enterprise_web_search_tool.js'; export {ExampleTool} from './tools/example_tool.js'; export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js'; export {FunctionTool, isFunctionTool} from './tools/function_tool.js'; diff --git a/core/src/tools/enterprise_web_search_tool.ts b/core/src/tools/enterprise_web_search_tool.ts new file mode 100644 index 000000000..c50f2e1a6 --- /dev/null +++ b/core/src/tools/enterprise_web_search_tool.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {GenerateContentConfig} from '@google/genai'; + +import {LlmRequest} from '../models/llm_request.js'; +import { + isGemini1Model, + isGeminiModel, + isGeminiModelIdCheckDisabled, +} from '../utils/model_name.js'; + +import {BaseTool, ToolProcessLlmRequest} from './base_tool.js'; + +/** + * Appends the Enterprise Web Search built-in tool to the LLM request when the + * target model supports it. + * + * NOTE: This is NOT Vertex AI Search (formerly "Enterprise Search"). See + * https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/web-grounding-enterprise + */ +export function applyEnterpriseWebSearch(llmRequest: LlmRequest): void { + if (!llmRequest.model) { + return; + } + + const modelCheckDisabled = isGeminiModelIdCheckDisabled(); + llmRequest.config = llmRequest.config || ({} as GenerateContentConfig); + llmRequest.config.tools = llmRequest.config.tools || []; + + if (isGeminiModel(llmRequest.model) || modelCheckDisabled) { + if ( + isGemini1Model(llmRequest.model) && + llmRequest.config.tools.length > 0 + ) { + throw new Error( + 'Enterprise Web Search tool cannot be used with other tools in Gemini 1.x.', + ); + } + + llmRequest.config.tools.push({enterpriseWebSearch: {}}); + + return; + } + + throw new Error( + `Enterprise Web Search tool is not supported for model ${llmRequest.model}`, + ); +} + +/** + * A Gemini 2+ built-in tool that grounds responses on public web data via + * Vertex AI Search with Enterprise (Sec4) compliance. + * + * This tool operates internally within the model and does not require or + * perform local code execution. + */ +export class EnterpriseWebSearchTool extends BaseTool { + constructor() { + super({ + name: 'enterprise_web_search', + description: 'Enterprise Web Search Tool', + }); + } + + runAsync(): Promise { + // This is a built-in tool on server side, it's triggered by setting the + // corresponding request parameters. + return Promise.resolve(); + } + + override async processLlmRequest({ + llmRequest, + }: ToolProcessLlmRequest): Promise { + applyEnterpriseWebSearch(llmRequest); + } +} + +/** + * A global instance of {@link EnterpriseWebSearchTool}. + */ +export const ENTERPRISE_WEB_SEARCH = new EnterpriseWebSearchTool(); diff --git a/core/test/tools/enterprise_web_search_tool_test.ts b/core/test/tools/enterprise_web_search_tool_test.ts new file mode 100644 index 000000000..aa07a26ad --- /dev/null +++ b/core/test/tools/enterprise_web_search_tool_test.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ENTERPRISE_WEB_SEARCH, + EnterpriseWebSearchTool, + LlmRequest, +} from '@google/adk'; +import {Tool} from '@google/genai'; +import {describe, expect, it} from 'vitest'; + +function makeRequest(model?: string, tools: Tool[] = []): LlmRequest { + return { + model, + config: {tools}, + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + } as unknown as LlmRequest; +} + +describe('EnterpriseWebSearchTool', () => { + describe('processLlmRequest', () => { + it('returns early when model is not set', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest(undefined); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + + expect(req.config?.tools).toEqual([]); + }); + + it('adds enterpriseWebSearch for Gemini 2+ model', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest('gemini-2.0-flash'); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + + expect(req.config!.tools).toEqual([{enterpriseWebSearch: {}}]); + }); + + it('adds enterpriseWebSearch for path-form Gemini 2+ model', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest( + 'projects/test-project/locations/global/publishers/google/models/gemini-2.5-flash', + ); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + + expect(req.config!.tools).toEqual([{enterpriseWebSearch: {}}]); + }); + + it('initializes config.tools when config is absent', async () => { + const tool = new EnterpriseWebSearchTool(); + const req: LlmRequest = { + model: 'gemini-2.0-flash', + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + } as unknown as LlmRequest; + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + + expect(req.config!.tools).toEqual([{enterpriseWebSearch: {}}]); + }); + + it('adds enterpriseWebSearch for Gemini 1.x model with no other tools', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest('gemini-1.5-pro'); + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + + expect(req.config!.tools).toEqual([{enterpriseWebSearch: {}}]); + }); + + it('throws when Gemini 1.x model already has other tools', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest('gemini-1.5-flash', [{googleSearch: {}}]); + await expect( + tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }), + ).rejects.toThrow( + 'Enterprise Web Search tool cannot be used with other tools in Gemini 1.x.', + ); + }); + + it('throws for unsupported (non-Gemini) model', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest('gpt-4o'); + await expect( + tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }), + ).rejects.toThrow( + 'Enterprise Web Search tool is not supported for model gpt-4o', + ); + }); + + it('adds enterpriseWebSearch for non-Gemini model when check is disabled', async () => { + const tool = new EnterpriseWebSearchTool(); + const req = makeRequest('internal-model-v1'); + + const originalValue = process.env.ADK_DISABLE_GEMINI_MODEL_ID_CHECK; + process.env.ADK_DISABLE_GEMINI_MODEL_ID_CHECK = 'true'; + + try { + await tool.processLlmRequest({ + llmRequest: req, + toolContext: {} as never, + }); + expect(req.config!.tools).toEqual([{enterpriseWebSearch: {}}]); + } finally { + if (originalValue === undefined) { + delete process.env.ADK_DISABLE_GEMINI_MODEL_ID_CHECK; + } else { + process.env.ADK_DISABLE_GEMINI_MODEL_ID_CHECK = originalValue; + } + } + }); + + it('runAsync returns resolved promise', async () => { + const tool = new EnterpriseWebSearchTool(); + await expect(tool.runAsync()).resolves.toBeUndefined(); + }); + }); + + it('has a global instance ENTERPRISE_WEB_SEARCH', () => { + expect(ENTERPRISE_WEB_SEARCH).toBeInstanceOf(EnterpriseWebSearchTool); + }); +}); From add74fd5bbb951b6e94dc9788103f48ede7dc93f Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 04:30:26 -0700 Subject: [PATCH 8/8] chore: cache npm downloads in the cross-language workflow The Cross-Language Tests job invokes actions/setup-node with no inputs, so npm's cache directory is never restored or saved and every run on the macos-latest runner does a full cold resolve and download of the workspace dependency tree. setup-node's automatic caching cannot engage here: it only turns itself on when package.json declares packageManager or devEngines.packageManager, and this repo declares neither. Setting cache: npm explicitly is therefore required. The root package-lock.json is auto-discovered by setup-node, so cache-dependency-path is unnecessary. --- .github/workflows/cross-language-integration.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cross-language-integration.yml b/.github/workflows/cross-language-integration.yml index 9ba2a2d7b..161545fd4 100644 --- a/.github/workflows/cross-language-integration.yml +++ b/.github/workflows/cross-language-integration.yml @@ -16,6 +16,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@v6 + with: + cache: npm - name: Setup Go uses: actions/setup-go@v5