Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,24 @@
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@google-cloud/eventarc-publishing": "^4.3.0",
"@mikro-orm/sqlite": "^6.6.6",
"@types/adm-zip": "^0.5.8",
"@types/express": "^4.17.25",
"@types/lodash-es": "^4.17.12",
"openapi-types": "^12.1.3"
},
"peerDependencies": {
"@google-cloud/eventarc-publishing": "^4.3.0",
"@mikro-orm/mariadb": "^6.6.6",
"@mikro-orm/mssql": "^6.6.6",
"@mikro-orm/mysql": "^6.6.6",
"@mikro-orm/postgresql": "^6.6.6",
"@mikro-orm/sqlite": "^6.6.6"
},
"peerDependenciesMeta": {
"@google-cloud/eventarc-publishing": {
"optional": true
}
}
}
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js';

export * from './integrations/agent_registry/agent_registry.js';
export * from './integrations/eventarc/eventarc_toolset.js';
export * from './telemetry/google_cloud.js';
export * from './telemetry/setup.js';
export * from './tools/mcp/load_mcp_resource_tool.js';
Expand Down
217 changes: 217 additions & 0 deletions core/src/integrations/eventarc/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type {PublisherClient} from '@google-cloud/eventarc-publishing';
import {AuthClient} from 'google-auth-library';
import {createHash} from 'node:crypto';

import {logger} from '../../utils/logger.js';
import {version} from '../../version.js';
import {EventarcCredentialsConfig, resolveScopes} from './config.js';

/** Constructor of the optional Eventarc publishing client. */
export type PublisherClientCtor = typeof PublisherClient;

/** Maximum number of publisher clients kept alive at the same time. */
export const CACHE_MAX_SIZE = 10;

/** Time after which a cached publisher client is rebuilt. */
export const CACHE_TTL_MS = 30 * 60 * 1000;

/**
* Error reported when the optional Eventarc publishing SDK cannot be loaded.
*
* `message_tool` turns this into the model-facing `ERROR` result, mirroring the
* `ImportError` guard of the Python reference.
*/
export const EVENTARC_SDK_MISSING_ERROR =
'@google-cloud/eventarc-publishing is not installed';

/**
* Reported to the API as `x-goog-api-client`. Matches the Python reference's
* `adk-eventarc-tool google-adk/<version>` user agent.
*/
const USER_AGENT_LIB_NAME = 'adk-eventarc-tool google-adk';

/** Identity used when no auth client is supplied. */
const DEFAULT_CREDENTIAL_ID = 'default';

/** Options selecting which cached publisher client to operate on. */
export interface PublisherClientOptions {
credentialsConfig?: EventarcCredentialsConfig;
projectId?: string;
}

interface CacheEntry {
client: PublisherClient;
expiresAt: number;
}

/**
* Insertion-ordered LRU cache. A `Map` preserves insertion order, so deleting
* and re-inserting a key moves it to the most-recently-used end and
* `keys().next()` yields the least-recently-used one.
*/
const publisherClientCache = new Map<string, CacheEntry>();

const anonymousCredentialIds = new WeakMap<AuthClient, string>();
let anonymousCredentialCount = 0;

/**
* Loads the optional `@google-cloud/eventarc-publishing` SDK.
*
* @throws An error carrying {@link EVENTARC_SDK_MISSING_ERROR} when the package
* is not installed.
*/
export async function loadPublisherClientCtor(): Promise<PublisherClientCtor> {
try {
const sdk = await import('@google-cloud/eventarc-publishing');
return sdk.PublisherClient;
} catch {
throw new Error(EVENTARC_SDK_MISSING_ERROR);
}
}

/**
* Returns a cached publisher client, constructing one when the cache misses or
* the cached entry has expired.
*
* Clients dropped by expiry or LRU eviction are closed before returning.
*/
export async function getPublisherClient(
options: PublisherClientOptions,
): Promise<PublisherClient> {
const publisherClientCtor = await loadPublisherClientCtor();
const cacheKey = buildCacheKey(options);
const now = Date.now();
const staleClients: PublisherClient[] = [];

const cached = publisherClientCache.get(cacheKey);
if (cached) {
publisherClientCache.delete(cacheKey);
if (cached.expiresAt > now) {
publisherClientCache.set(cacheKey, cached);
return cached.client;
}
staleClients.push(cached.client);
}

const client = new publisherClientCtor({
authClient: options.credentialsConfig?.authClient,
scopes: resolveScopes(options.credentialsConfig),
projectId: options.projectId,
libName: USER_AGENT_LIB_NAME,
libVersion: version,
});

if (publisherClientCache.size >= CACHE_MAX_SIZE) {
const evictedKey = publisherClientCache.keys().next().value;
if (evictedKey !== undefined) {
const evicted = publisherClientCache.get(evictedKey);
publisherClientCache.delete(evictedKey);
if (evicted) {
staleClients.push(evicted.client);
}
}
}

publisherClientCache.set(cacheKey, {client, expiresAt: now + CACHE_TTL_MS});

await Promise.all(staleClients.map(closeClient));
return client;
}

/**
* Drops the cached publisher client for the given options and closes it, so
* that the next publish rebuilds the underlying channel.
*/
export async function removePublisherClient(
options: PublisherClientOptions,
): Promise<void> {
const cacheKey = buildCacheKey(options);
const entry = publisherClientCache.get(cacheKey);
if (!entry) {
return;
}
publisherClientCache.delete(cacheKey);
await closeClient(entry.client);
}

/** Closes and drops every cached publisher client. */
export async function cleanupPublisherClients(): Promise<void> {
const entries = [...publisherClientCache.values()];
publisherClientCache.clear();
await Promise.all(entries.map((entry) => closeClient(entry.client)));
}

async function closeClient(client: PublisherClient): Promise<void> {
try {
await client.close();
} catch (error: unknown) {
logger.warn('Failed to close the Eventarc publisher client', error);
}
}

function buildCacheKey(options: PublisherClientOptions): string {
return [
options.projectId ?? '',
resolveScopes(options.credentialsConfig).join(','),
credentialId(options.credentialsConfig?.authClient),
].join('|');
}

/**
* Derives a stable identity for an auth client so that equivalent credentials
* share a channel.
*
* Only the identities `google-auth-library` actually exposes are recognised;
* anything else falls back to a per-object identifier, which is correct but
* does not deduplicate.
*/
function credentialId(authClient?: AuthClient): string {
if (!authClient) {
return DEFAULT_CREDENTIAL_ID;
}

const email = readStringField(authClient, 'email');
if (email) {
return email;
}

const targetPrincipal = readStringField(authClient, 'targetPrincipal');
if (targetPrincipal) {
return `Impersonated:${targetPrincipal}`;
}

const audience = readStringField(authClient, 'audience');
if (audience) {
return `ExternalAccount:${audience}`;
}

const refreshToken = readStringField(authClient.credentials, 'refresh_token');
if (refreshToken) {
const digest = createHash('sha256').update(refreshToken).digest('hex');
return `UserCredentials:${digest}`;
}

return anonymousCredentialId(authClient);
}

function readStringField(source: object, field: string): string | undefined {
const value: unknown = Reflect.get(source, field);
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function anonymousCredentialId(authClient: AuthClient): string {
const existing = anonymousCredentialIds.get(authClient);
if (existing !== undefined) {
return existing;
}
anonymousCredentialCount += 1;
const id = `AuthClient:${anonymousCredentialCount}`;
anonymousCredentialIds.set(authClient, id);
return id;
}
58 changes: 58 additions & 0 deletions core/src/integrations/eventarc/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {AuthClient} from 'google-auth-library';

/**
* Default publish timeout in milliseconds.
*
* Matches the 15 second default of the Python `EventarcToolConfig`.
*/
export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000;

/** OAuth scope requested when the credentials config does not declare any. */
export const CLOUD_PLATFORM_SCOPE =
'https://www.googleapis.com/auth/cloud-platform';

/**
* CloudEvents requires extension attribute names to consist of lower-case
* letters and digits only.
*/
export const CUSTOM_ATTRIBUTE_KEY_PATTERN = /^[a-z0-9]+$/;

/** Configuration for the Eventarc tools. */
export interface EventarcToolConfig {
/** Project ID used for telemetry and API calls. */
projectId?: string;

/**
* Timeout in milliseconds for publishing a message. Defaults to
* {@link DEFAULT_PUBLISH_TIMEOUT_MS}.
*/
publishTimeoutMs?: number;
}

/** Configuration for the Google Cloud credentials used to publish. */
export interface EventarcCredentialsConfig {
/**
* Pre-constructed auth client. When omitted, Application Default Credentials
* are resolved by the publisher client.
*/
authClient?: AuthClient;

/** OAuth scopes. Defaults to `[CLOUD_PLATFORM_SCOPE]`. */
scopes?: string[];
}

/** Returns the publish timeout in milliseconds, applying the default. */
export function resolvePublishTimeoutMs(config?: EventarcToolConfig): number {
return config?.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
}

/** Returns the OAuth scopes to request, applying the default. */
export function resolveScopes(config?: EventarcCredentialsConfig): string[] {
return config?.scopes ?? [CLOUD_PLATFORM_SCOPE];
}
70 changes: 70 additions & 0 deletions core/src/integrations/eventarc/eventarc_toolset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {ReadonlyContext} from '../../agents/readonly_context.js';
import {BaseTool} from '../../tools/base_tool.js';
import {BaseToolset, ToolPredicate} from '../../tools/base_toolset.js';
import {experimental} from '../../utils/experimental.js';
import {cleanupPublisherClients} from './client.js';
import {EventarcCredentialsConfig, EventarcToolConfig} from './config.js';
import {createPublishMessageTool} from './message_tool.js';

export {
CLOUD_PLATFORM_SCOPE,
DEFAULT_PUBLISH_TIMEOUT_MS,
type EventarcCredentialsConfig,
type EventarcToolConfig,
} from './config.js';
export {
publishMessage,
type PublishMessageOptions,
type PublishMessageResult,
} from './message_tool.js';

/** Arguments accepted by the {@link EventarcToolset} constructor. */
export interface EventarcToolsetOptions {
toolConfig?: EventarcToolConfig;
credentialsConfig?: EventarcCredentialsConfig;
toolFilter?: ToolPredicate | string[];
prefix?: string;
}

/**
* Toolset for publishing CloudEvents to Google Cloud Eventarc Advanced.
*
* Exposes the generic `publish_message` tool, which lets the model supply
* every CloudEvent attribute itself.
*/
@experimental
export class EventarcToolset extends BaseToolset {
readonly toolConfig: EventarcToolConfig;
readonly credentialsConfig: EventarcCredentialsConfig;

private readonly tools: BaseTool[];

constructor(options: EventarcToolsetOptions = {}) {
super(options.toolFilter ?? [], options.prefix);
this.toolConfig = options.toolConfig ?? {};
this.credentialsConfig = options.credentialsConfig ?? {};
this.tools = [
createPublishMessageTool({
toolConfig: this.toolConfig,
credentialsConfig: this.credentialsConfig,
}),
];
}

override async getTools(context?: ReadonlyContext): Promise<BaseTool[]> {
if (!context) {
return [...this.tools];
}
return this.tools.filter((tool) => this.isToolSelected(tool, context));
}

override async close(): Promise<void> {
return cleanupPublisherClients();
}
}
Loading
Loading