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
6 changes: 0 additions & 6 deletions core/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,6 @@ function build({
logLevel: 'info',
};

if (platform === 'browser' && bundle) {
buildOptions.alias = {
'node:async_hooks': './src/utils/async_hooks_shim.ts',
};
}

// Prepend license header to the top of the file
if (format === 'cjs' || bundle) {
buildOptions.banner = {js: licenseHeaderText};
Expand Down
4 changes: 4 additions & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {installNodeClientLabelStore} from './utils/client_labels_node.js';

installNodeClientLabelStore();

export {AGENT_CARD_PATH, RemoteA2AAgent} from './a2a/a2a_remote_agent.js';
export type {
A2AStreamEventData,
Expand Down
10 changes: 10 additions & 0 deletions core/src/utils/async_hooks_shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/

/**
* A synchronous, single-slot stand-in for `AsyncLocalStorage`, for runtimes
* without `node:async_hooks` (browsers, most notably).
*
* It restores the previous value when `run` returns, so nesting works, but the
* value lives in one instance field rather than in an async context: it does
* not survive an `await`, and concurrent `run` calls overwrite each other. It
* is therefore not an `AsyncLocalStorage` equivalent and must never be used as
* the Node implementation.
*/
export class AsyncLocalStorage<T> {
private store: T | undefined;
run<R>(store: T, callback: () => R): R {
Expand Down
24 changes: 20 additions & 4 deletions core/src/utils/client_labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {AsyncLocalStorage} from 'node:async_hooks';
import {version} from '../version.js';
import {AsyncLocalStorage as SingleSlotClientLabelStore} from './async_hooks_shim.js';
import {isBrowser} from './env_aware_utils.js';

const ADK_LABEL = 'google-adk';
const LANGUAGE_LABEL = 'gl-typescript';
const AGENT_ENGINE_TELEMETRY_TAG = 'remote_reasoning_engine';
const AGENT_ENGINE_TELEMETRY_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_AGENT_ENGINE_ID';

const clientLabelLocalStorage = new AsyncLocalStorage<string>();
/** The slice of `AsyncLocalStorage` that client-label propagation needs. */
export interface ClientLabelStore {
run<R>(clientLabel: string, callback: () => R): R;
getStore(): string | undefined;
}

let clientLabelStore: ClientLabelStore =
new SingleSlotClientLabelStore<string>();

/**
* Installs the context store backing `runWithClientLabel`. Node calls this from
* `index.ts` to swap in a real `AsyncLocalStorage`; every other runtime keeps
* the synchronous fallback. Internal — not part of the public API.
*/
export function setClientLabelStore(store: ClientLabelStore): void {
clientLabelStore = store;
}

const USER_AGENT_PATTERNS = [
['Edge', /(?:Edg|Edge|EdgA)\/([0-9.]+)/i],
Expand Down Expand Up @@ -69,15 +85,15 @@ export function runWithClientLabel<R>(
throw new Error('Client label must be a non-empty string.');
}

return clientLabelLocalStorage.run(clientLabel, callback);
return clientLabelStore.run(clientLabel, callback);
}

/**
* Returns the current list of client labels that can be added to HTTP Headers.
*/
export function getClientLabels(): string[] {
const labels = _getDefaultLabels();
const contextLabel = clientLabelLocalStorage.getStore();
const contextLabel = clientLabelStore.getStore();
if (contextLabel) {
labels.push(contextLabel);
}
Expand Down
19 changes: 19 additions & 0 deletions core/src/utils/client_labels_node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {AsyncLocalStorage} from 'node:async_hooks';
import {setClientLabelStore} from './client_labels.js';

/**
* Installs Node's `AsyncLocalStorage` as the client-label context store.
*
* This module is the only place in `core/src` that names `node:async_hooks`, and
* nothing reachable from `index_web.ts` imports it, which is what keeps the
* browser entry point free of Node builtins.
*/
export function installNodeClientLabelStore(): void {
setClientLabelStore(new AsyncLocalStorage<string>());
}
63 changes: 63 additions & 0 deletions core/test/utils/async_hooks_shim_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {describe, expect, it} from 'vitest';
import {AsyncLocalStorage} from '../../src/utils/async_hooks_shim.js';

describe('async_hooks_shim', () => {
describe('AsyncLocalStorage', () => {
it('has no store before any run', () => {
const storage = new AsyncLocalStorage<string>();
expect(storage.getStore()).toBeUndefined();
});

it('exposes the store inside run and returns the callback result', () => {
const storage = new AsyncLocalStorage<string>();
const result = storage.run('a', () => storage.getStore());
expect(result).toBe('a');
});

it('restores the outer value when a nested run returns', () => {
const storage = new AsyncLocalStorage<string>();
const seen: Array<string | undefined> = [];

storage.run('outer', () => {
seen.push(storage.getStore());
storage.run('inner', () => {
seen.push(storage.getStore());
});
seen.push(storage.getStore());
});
seen.push(storage.getStore());

expect(seen).toEqual(['outer', 'inner', 'outer', undefined]);
});

it('restores the previous value when the callback throws', () => {
const storage = new AsyncLocalStorage<string>();

expect(() =>
storage.run('outer', () => {
storage.run('inner', () => {
throw new Error('boom');
});
}),
).toThrow('boom');
expect(storage.getStore()).toBeUndefined();
});

it('does not carry the value across an await, unlike AsyncLocalStorage', async () => {
const storage = new AsyncLocalStorage<string>();

const afterAwait = await storage.run('a', async () => {
await Promise.resolve();
return storage.getStore();
});

expect(afterAwait).toBeUndefined();
});
});
});
41 changes: 41 additions & 0 deletions core/test/utils/client_labels_node_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {getClientLabels, runWithClientLabel} from '@google/adk';
import {beforeEach, describe, expect, it} from 'vitest';
import {installNodeClientLabelStore} from '../../src/utils/client_labels_node.js';

/** Reads the label that `runWithClientLabel` put in the current context. */
function currentContextLabel(): string | undefined {
return getClientLabels().find((label) => label.startsWith('task-'));
}

describe('client_labels_node', () => {
beforeEach(() => {
installNodeClientLabelStore();
});

it('keeps the label across an await', async () => {
const seen = await runWithClientLabel('task-a', async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return currentContextLabel();
});

expect(seen).toBe('task-a');
});

it('isolates the labels of concurrent invocations', async () => {
const run = (label: string, delayMs: number) =>
runWithClientLabel(label, async () => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return currentContextLabel();
});

await expect(
Promise.all([run('task-a', 20), run('task-b', 5)]),
).resolves.toEqual(['task-a', 'task-b']);
});
});
88 changes: 87 additions & 1 deletion core/test/utils/client_labels_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,27 @@
*/

import {getClientLabels, runWithClientLabel} from '@google/adk';
import * as esbuild from 'esbuild';
import {fileURLToPath} from 'node:url';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {parseUserAgent} from '../../src/utils/client_labels.js';
import type {ClientLabelStore} from '../../src/utils/client_labels.js';
import {
parseUserAgent,
setClientLabelStore,
} from '../../src/utils/client_labels.js';
import {installNodeClientLabelStore} from '../../src/utils/client_labels_node.js';

const CLIENT_LABELS_SRC = fileURLToPath(
new URL('../../src/utils/client_labels.ts', import.meta.url),
);

/**
* The label `runWithClientLabel` contributed, if any. `getClientLabels` appends
* it after the two default labels.
*/
function contextLabel(): string | undefined {
return getClientLabels()[2];
}

describe('client_labels', () => {
describe('parseUserAgent', () => {
Expand Down Expand Up @@ -145,4 +164,71 @@ describe('client_labels', () => {
}).toThrow('Client label must be a non-empty string.');
});
});

describe('runWithClientLabel context isolation', () => {
it('should keep concurrent invocations from seeing each other label', async () => {
const run = (label: string, delayMs: number) =>
runWithClientLabel(label, async () => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return contextLabel();
});

await expect(
Promise.all([run('task-a', 20), run('task-b', 5)]),
).resolves.toEqual(['task-a', 'task-b']);
});

it('should restore the outer label when a nested run returns', () => {
const seen: Array<string | undefined> = [];

runWithClientLabel('outer', () => {
seen.push(contextLabel());
runWithClientLabel('inner', () => {
seen.push(contextLabel());
});
seen.push(contextLabel());
});
seen.push(contextLabel());

expect(seen).toEqual(['outer', 'inner', 'outer', undefined]);
});
});

describe('setClientLabelStore', () => {
afterEach(() => {
installNodeClientLabelStore();
});

it('should route both entry points through the installed store', () => {
const runCalls: string[] = [];
const fakeStore: ClientLabelStore = {
run<R>(clientLabel: string, callback: () => R): R {
runCalls.push(clientLabel);
return callback();
},
getStore: () => 'from-fake-store',
};
setClientLabelStore(fakeStore);

const seen = runWithClientLabel('routed-label', () => contextLabel());

expect(runCalls).toEqual(['routed-label']);
expect(seen).toBe('from-fake-store');
});
});

describe('browser safety', () => {
it('should bundle for the browser with no unresolved imports', async () => {
const result = await esbuild.build({
entryPoints: [CLIENT_LABELS_SRC],
bundle: true,
platform: 'browser',
format: 'esm',
write: false,
logLevel: 'silent',
});

expect(result.errors).toEqual([]);
});
});
});
Loading