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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ to docs, or any other relevant information.
customizable via the `cancelWorkflowUpdate` handler option; the default rejects with a
`NOT_IMPLEMENTED` handler error.
- **Experimental**: `@temporalio/google-adk-agents` package for running Google ADK agents as durable Temporal Workflows.
ADK's OpenTelemetry agent-loop spans can be exported replay-safely from the Workflow sandbox by composing with
`OpenTelemetryPlugin` from `@temporalio/interceptors-opentelemetry`; see the package README's telemetry section.

### Fixed

Expand Down
63 changes: 63 additions & 0 deletions contrib/google-adk-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,69 @@ const plugin = new GoogleAdkPlugin({
});
```

## Telemetry and observability

ADK instruments its agent loop with OpenTelemetry: the `gcp.vertex.agent`
tracer creates `invocation`, `invoke_agent <name>`, `call_llm` (with
`gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` attributes), and
`execute_tool` spans. Under this plugin the agent loop runs inside the Workflow
sandbox, so those spans are created there too — and **by default they are
silently dropped**: nothing registers a tracer provider inside the sandbox, so
ADK's tracer yields non-recording no-ops. A tracer provider configured in the
worker process (e.g. `NodeSDK`) does not see them either; the sandbox is
isolated from process globals.

To export them, compose with the SDK's OpenTelemetry integration
([`@temporalio/interceptors-opentelemetry`](https://github.com/temporalio/sdk-typescript/tree/main/contrib/interceptors-opentelemetry)),
placed before this plugin:

```typescript
import { OpenTelemetryPlugin } from '@temporalio/interceptors-opentelemetry';

const worker = await Worker.create({
// ...
plugins: [new OpenTelemetryPlugin({ resource, spanProcessor }), new GoogleAdkPlugin()],
});
```

Its Workflow interceptor registers a tracer provider inside the sandbox that
ADK's tracer binds to, and exports the spans through a Worker sink that is
**replay-gated**: spans are recorded when Workflow code first executes;
history replays re-run the agent-loop code but re-export nothing. You get
exactly one span per real model call or tool call, plus the interceptor's own
`RunWorkflow` / `StartActivity` spans, nested under the same trace. The plugin
pins `@opentelemetry/api` to a single copy in the Workflow bundle (the one
`@google/adk` resolves — ADK pins an exact api version, so a bundle could
otherwise contain two copies), so ADK's tracer binds to that provider no
matter which module evaluates ADK first.

Cautions:

- Do **not** register a custom telemetry sink with `callDuringReplay: true` —
every replayed workflow task would then re-emit the agent-loop spans,
over-counting each operation once per replay.
- The replay gate makes span export at-least-once, not exactly-once: a workflow
task **retry** (a task that failed or timed out and re-executes) is not a
replay, so its spans are re-emitted. Retries are rare in normal operation, but
don't build alerting that assumes exact span counts.
- `call_llm` spans carry the full request/response payloads as
`gcp.vertex.agent.llm_request` / `gcp.vertex.agent.llm_response` attributes.
Point the span processor somewhere approved for prompt content, or strip
those attributes in the processor.
- Custom payload/failure converter modules (`payloadConverterPath` /
`failureConverterPath`) evaluate **before** the plugin's polyfill loader. If
such a module imports `@google/adk` / `@google/genai`, import
`@temporalio/google-adk-agents/workflow` first: it installs the sandbox
polyfills (`Headers`, `structuredClone`, the WHATWG streams globals, and the
deterministic `performance` shim ADK's telemetry chain dereferences at module
load) before ADK evaluates.

ADK (as of 1.4.0) defines no OpenTelemetry metric instruments, so there is no
workflow-side metric telemetry to configure. Activity-side telemetry (the real
Gemini/MCP calls) runs in the worker process where normal Node OpenTelemetry
setup applies, and is naturally replay-immune — completed Activities are read
from history rather than re-executed.

## Operational notes

- Register `GoogleAdkPlugin` on the Worker. Passing it directly to `Client` does
Expand Down
4 changes: 4 additions & 0 deletions contrib/google-adk-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@
"devDependencies": {
"@google/adk": "^1.4.0",
"@google/genai": "^2.8.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@temporalio/client": "workspace:*",
"@temporalio/interceptors-opentelemetry": "workspace:*",
"@temporalio/testing": "workspace:*",
"@temporalio/worker": "workspace:*",
"@types/node": "^20.0.0",
Expand Down
25 changes: 25 additions & 0 deletions contrib/google-adk-agents/src/__tests__/adk-converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Temporal Technologies Inc.
* SPDX-License-Identifier: MIT
*
* A custom payload-converter module that imports `@google/adk`, following the
* documented workaround: converter modules evaluate before interceptor modules
* (so before the plugin's polyfill loader), and must therefore import
* `@temporalio/google-adk-agents/workflow` first to install the sandbox
* polyfills themselves. The E2E test bundles the tsc-compiled CommonJS form of
* this file (`lib/__tests__/adk-converter.js`), where every `require` is eager
* — the module layout that crashed on the `performance` global before the
* polyfill loader shimmed it.
*/

// The documented workaround import — must come first.
// eslint-disable-next-line import/no-unassigned-import
import '../workflow';
import { LlmAgent } from '@google/adk';
import { defaultPayloadConverter } from '@temporalio/common';

/** Referenced so the `@google/adk` barrel import is not elided. */
export const adkEvaluated = typeof LlmAgent === 'function';

export const payloadConverter = defaultPayloadConverter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Temporal Technologies Inc.
* SPDX-License-Identifier: MIT
*
* A user `interceptors.workflowModules` entry that imports `@google/adk`.
* Interceptor modules all evaluate before any interceptor factory runs, so
* this makes ADK's `telemetry/tracing.js` cache its tracer at module load —
* before `OpenTelemetryPlugin` registers the sandbox tracer provider. The
* telemetry test uses it to pin that ADK spans still export in this ordering,
* which requires the bundle to hold a single `@opentelemetry/api` copy.
*/

import { LlmAgent } from '@google/adk';

/** Referenced so the `@google/adk` barrel import is not elided. */
export const adkEvaluated = typeof LlmAgent === 'function';
75 changes: 75 additions & 0 deletions contrib/google-adk-agents/src/__tests__/compiled-cjs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Temporal Technologies Inc.
* SPDX-License-Identifier: MIT
*
* Regression coverage for workflow-bundle module layouts that evaluate ADK's
* telemetry chain (`@opentelemetry/sdk-trace-base` → `@opentelemetry/core`,
* whose browser build dereferences the `performance` global at module load)
* eagerly. The ESM test workflows dodge that chain through harmony-import
* pruning; the tsc-compiled CommonJS shape — what a compiled-TS production
* worker and the published `lib/` artifacts actually run — `require`s it
* unconditionally, so these tests pin that such bundles load and run with
* `GoogleAdkPlugin` alone (no `OpenTelemetryPlugin` composed).
*/

import path from 'node:path';

import test from 'ava';
import { Worker } from '@temporalio/worker';

import { GoogleAdkPlugin } from '../index';
import { defaultTestProvider, setupTestEnv, uid, workflowsPath } from './helpers';
import { singleModelCall } from './workflows';

const getEnv = setupTestEnv(test);

// `__dirname` is `lib/__tests__` at runtime, so these resolve to the
// tsc-compiled CommonJS artifacts, not the TypeScript sources.
const compiledWorkflowsPath = path.resolve(__dirname, './workflows.js');
const compiledConverterPath = path.resolve(__dirname, './adk-converter.js');

// Compiled-CJS workflow files load and run without OpenTelemetryPlugin (E2E)
test.serial('compiledCjsWorkflowBundleRuns', async (t) => {
const env = getEnv();
const taskQueue = uid('adk-cjs');
const worker = await Worker.create({
connection: env.nativeConnection,
taskQueue,
workflowsPath: compiledWorkflowsPath,
plugins: [new GoogleAdkPlugin({ modelProvider: defaultTestProvider() })],
});
const result = await worker.runUntil(
env.client.workflow.execute(singleModelCall, {
taskQueue,
workflowId: uid('wf-cjs'),
args: ['hello'],
})
);
t.is(result, 'fake-response:fake-model');
});

// A converter module importing @google/adk works via the documented workaround (E2E)
test.serial('converterModuleImportingAdkRunsWithWorkaround', async (t) => {
const env = getEnv();
const taskQueue = uid('adk-conv');
// Converter modules evaluate before interceptor modules — before the
// plugin's polyfill loader — so `adk-converter.ts` imports the workflow
// barrel first, per the documented workaround. Its compiled-CJS form
// evaluates the whole ADK barrel (telemetry chain included) eagerly.
const worker = await Worker.create({
connection: env.nativeConnection,
taskQueue,
workflowsPath,
dataConverter: { payloadConverterPath: compiledConverterPath },
plugins: [new GoogleAdkPlugin({ modelProvider: defaultTestProvider() })],
});
const result = await worker.runUntil(
env.client.workflow.execute(singleModelCall, {
taskQueue,
workflowId: uid('wf-conv'),
args: ['hello'],
})
);
t.is(result, 'fake-response:fake-model');
});
8 changes: 8 additions & 0 deletions contrib/google-adk-agents/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export interface WithWorkerOptions {
plugins: unknown[];
activities?: Record<string, (...args: never[]) => Promise<unknown>>;
maxCachedWorkflows?: number;
/**
* User `interceptors.workflowModules` entries; the plugin's polyfill loader
* still evaluates first, these follow.
*/
workflowInterceptorModules?: string[];
}

/**
Expand All @@ -144,6 +149,9 @@ export async function withWorker<T>(
plugins: options.plugins as any,
activities: options.activities as any,
maxCachedWorkflows: options.maxCachedWorkflows,
interceptors: options.workflowInterceptorModules
? { workflowModules: options.workflowInterceptorModules }
: undefined,
});
return worker.runUntil(fn());
}
Expand Down
69 changes: 69 additions & 0 deletions contrib/google-adk-agents/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* `configureWorker` output without bundling or executing a Workflow.
*/

import { createRequire } from 'node:module';

import test from 'ava';
import type { BundleOptions, WorkerOptions } from '@temporalio/worker';

Expand Down Expand Up @@ -41,6 +43,25 @@ test('configureBundler stubs ADK node-only packages and disallowed builtins', (t
for (const polyfilled of ['assert', 'url', 'util']) {
t.false(ignored.has(polyfilled), `expected polyfilled builtin ${polyfilled} to remain`);
}
// The pure-JS OpenTelemetry tracing packages must stay resolvable:
// `@temporalio/interceptors-opentelemetry` constructs a `BasicTracerProvider`
// from them inside the sandbox — the SDK's only replay-safe workflow span
// path. Re-stubbing any of these breaks composing with `OpenTelemetryPlugin`.
for (const otel of ['@opentelemetry/api', '@opentelemetry/sdk-trace-base', '@opentelemetry/resources']) {
t.false(ignored.has(otel), `expected OpenTelemetry package ${otel} to remain resolvable`);
}
});

test('configureBundler prepends the polyfill loader to workflowInterceptorModules', (t) => {
const plugin = new GoogleAdkPlugin();
const { workflowInterceptorModules } = plugin.configureBundler({
workflowsPath: 'wf',
workflowInterceptorModules: ['user-interceptors'],
} as BundleOptions);
// Must be first so the web-global polyfills install before any other
// per-workflow module (interceptors, then the user's workflows) evaluates.
t.is(workflowInterceptorModules?.[0], require.resolve('../load-polyfills'));
t.deepEqual(workflowInterceptorModules?.slice(1), ['user-interceptors']);
});

test('configureBundler appends the sandbox-compat plugin, preserving a user hook', (t) => {
Expand All @@ -60,6 +81,54 @@ test('configureBundler appends the sandbox-compat plugin, preserving a user hook
t.deepEqual(names, ['user-plugin', 'google-adk-sandbox-compat']);
});

test('sandbox-compat plugin rewrites @opentelemetry/api to the copy @google/adk resolves', (t) => {
const plugin = new GoogleAdkPlugin();
const { webpackConfigHook } = plugin.configureBundler({ workflowsPath: 'wf' } as BundleOptions);
const cfg = webpackConfigHook!({ plugins: [] } as never) as {
plugins: Array<{ name?: string; apply(compiler: unknown): void }>;
};
const compat = cfg.plugins.find((p) => p.name === 'google-adk-sandbox-compat')!;

// Drive the plugin against a minimal fake compiler to capture its
// `beforeResolve` tap.
let beforeResolve: ((data: { request?: string }) => void) | undefined;
compat.apply({
webpack: {
ProvidePlugin: class {
apply(): void {}
},
},
hooks: {
normalModuleFactory: {
tap: (_name: string, fn: (nmf: unknown) => void) =>
fn({
hooks: {
beforeResolve: {
tap: (_tapName: string, f: (data: { request?: string }) => void) => {
beforeResolve = f;
},
},
},
}),
},
},
});
t.truthy(beforeResolve);

// Every `@opentelemetry/api` request must land on ADK's own resolution, so
// ADK's module-load tracer and the OTel interceptor's provider registration
// share one api instance regardless of module evaluation order.
const expected = createRequire(require.resolve('@google/adk')).resolve('@opentelemetry/api');
const api = { request: '@opentelemetry/api' };
beforeResolve!(api);
t.is(api.request, expected);

// Packages that merely share the name prefix are untouched.
const apiLogs = { request: '@opentelemetry/api-logs' };
beforeResolve!(apiLogs);
t.is(apiLogs.request, '@opentelemetry/api-logs');
});

test('configureWorker registers model activities, plus an MCP pair per toolset', (t) => {
const modelOnly = new GoogleAdkPlugin().configureWorker({ taskQueue: 'tq' } as WorkerOptions).activities as Record<
string,
Expand Down
Loading
Loading