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
11 changes: 6 additions & 5 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# @temporalio/sdk will be requested for review when
# someone opens a pull request.
* @temporalio/sdk
/ai-sdk/ @temporalio/sdk @temporalio/ai-sdk
/langsmith/ @temporalio/sdk @temporalio/ai-sdk
/openai-agents/ @temporalio/sdk @temporalio/ai-sdk
/strands-agents/ @temporalio/sdk @temporalio/ai-sdk
/workflow-streams/ @temporalio/sdk @temporalio/ai-sdk
/ai-sdk/ @temporalio/sdk @temporalio/ai-sdk
/google-adk-agents/ @temporalio/sdk @temporalio/ai-sdk
/langsmith/ @temporalio/sdk @temporalio/ai-sdk
/openai-agents/ @temporalio/sdk @temporalio/ai-sdk
/strands-agents/ @temporalio/sdk @temporalio/ai-sdk
/workflow-streams/ @temporalio/sdk @temporalio/ai-sdk
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
eager-workflow-start
early-return
empty
google-adk-agents
hello-world
langsmith
mutex
Expand Down
1 change: 1 addition & 0 deletions .scripts/copy-shared-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const ESLINTIGNORE_EXCLUDE = [

const POST_CREATE_EXCLUDE = [
'openai-agents',
'google-adk-agents',
'env-config',
'dsl-interpreter',
'eager-workflow-start',
Expand Down
1 change: 1 addition & 0 deletions .scripts/list-of-samples.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"expense",
"fetch-esm",
"food-delivery",
"google-adk-agents",
"grpc-calls",
"hello-world",
"hello-world-js",
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ and you'll be given the list of sample options.
- [**Customer Service**](./openai-agents/customer-service): A long-running, multi-turn Workflow driven by Updates and Queries, with triage handoffs and `continueAsNew` to bound history.
- [**Nexus Tools**](./openai-agents/nexus-tools): Expose a Nexus Operation as an agent tool with `nexusOperationAsTool`.
- [**Streaming**](./openai-agents/src/streaming): Run an agent in streaming mode over a Workflow Stream, with an external client subscribing to the model's deltas live.
- [**Google ADK Agents**](./google-adk-agents): Run [Google Agent Development Kit](https://github.com/google/adk-js) (`@google/adk`) agents as Temporal Workflows with the `@temporalio/google-adk-agents` integration. The [`google-adk-agents/`](./google-adk-agents) directory contains eight samples:
- [**Agent Chat**](./google-adk-agents/src/agent-chat): Multi-turn agent chat with Updates, queryable history, and Continue-As-New.
- [**Tools**](./google-adk-agents/src/tools): An existing Temporal Activity exposed to the agent as an ADK tool with `activityAsTool`.
- [**Multi-Agent**](./google-adk-agents/src/multi-agent): A coordinator `LlmAgent` starts an ADK `transfer_to_agent` relay through a researcher and a writer, each with its own `TemporalModel`.
- [**MCP**](./google-adk-agents/src/mcp): A `TemporalMCPToolset` backed by a filesystem MCP server the Worker opens over stdio.
- [**Streaming**](./google-adk-agents/src/streaming): Token streaming from a direct `TemporalModel` call — no agent loop — over a Workflow Stream, with an external client printing the deltas as they arrive.
- [**Human in the Loop**](./google-adk-agents/src/human-in-the-loop): A `LongRunningFunctionTool` whose completion is gated by a Temporal Signal or Update.
- [**Structured Output**](./google-adk-agents/src/structured-output): Schema-constrained agent output validated at the Workflow boundary.
- [**Observability**](./google-adk-agents/src/observability): Token usage, latency, and call counts from the agent loop's OpenTelemetry spans, by composing `OpenTelemetryPlugin` onto the Worker alongside `GoogleAdkPlugin`.

### Full-stack apps

Expand Down
3 changes: 3 additions & 0 deletions google-adk-agents/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
lib
.eslintrc.js
48 changes: 48 additions & 0 deletions google-adk-agents/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const { builtinModules } = require('module');

const ALLOWED_NODE_BUILTINS = new Set(['assert']);

module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'deprecation'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
// recommended for safety
'@typescript-eslint/no-floating-promises': 'error', // forgetting to await Activities and Workflow APIs is bad
'deprecation/deprecation': 'warn',

// code style preference
'object-shorthand': ['error', 'always'],

// relaxed rules, for convenience
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'off',
},
overrides: [
{
files: ['src/**/workflows.ts', 'src/**/workflows-*.ts', 'src/**/workflows/*.ts'],
rules: {
'no-restricted-imports': [
'error',
...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]),
],
},
},
],
};
2 changes: 2 additions & 0 deletions google-adk-agents/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
lib
node_modules
1 change: 1 addition & 0 deletions google-adk-agents/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
1 change: 1 addition & 0 deletions google-adk-agents/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
20 changes: 20 additions & 0 deletions google-adk-agents/.post-create
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
To begin development, install the Temporal CLI:

Mac: {cyan brew install temporal}
Other: Download and extract the latest release from https://github.com/temporalio/cli/releases/latest

Start Temporal Server:

{cyan temporal server start-dev}

Use Node version 22 or later:

Mac: {cyan brew install node@22}
Other: https://nodejs.org/en/download/

This sample has several scenarios under {cyan src/}. Using two other shells, start a Worker for one scenario and run its client (example: {cyan agent-chat}):

{cyan GEMINI_API_KEY=<your-key> npx ts-node src/agent-chat/worker.ts}
{cyan npx ts-node src/agent-chat/client.ts}

See README.md for the full list of scenarios.
1 change: 1 addition & 0 deletions google-adk-agents/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib
2 changes: 2 additions & 0 deletions google-adk-agents/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
printWidth: 120
singleQuote: true
31 changes: 31 additions & 0 deletions google-adk-agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Google ADK Agents

These samples use the `@temporalio/google-adk-agents` integration to run [Google Agent Development Kit](https://github.com/google/adk-js) (`@google/adk`) agents as durable Temporal Workflows. The ADK agent graph — the `Runner` loop, `LlmAgent`s, tools, and MCP toolsets — runs inside the Workflow and replays deterministically, while its non-deterministic I/O — model calls, MCP tool calls, and Activities exposed as tools — runs as durable Activities, so they retry on failure and are not repeated during Workflow replay.

This is a single project: one `package.json` and one set of configs at the `google-adk-agents/` root, with each scenario in its own subdirectory under `src/`. Run `npm install` once here, then run any scenario by path (see each scenario's README). The integration package itself is documented in the [`@temporalio/google-adk-agents` README](https://github.com/temporalio/sdk-typescript/tree/main/contrib/google-adk-agents).

## Prerequisites

These apply to every sample in this directory:

- A running Temporal dev server: `temporal server start-dev`.
- Node 22 or later.
- A Gemini API key for live runs: `export GEMINI_API_KEY=...`. Each scenario also documents a credential-free local mode.
- Dependencies installed once at the `google-adk-agents/` root: `npm install`.

Each scenario's README describes how to start its Worker and run its scenarios by path.

## Samples

| Sample | Demonstrates |
| :--------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------ |
| [`agent-chat`](./src/agent-chat) | Multi-turn chat through Updates, inspectable state through a Query, and Continue-As-New with conversation history. |
| [`tools`](./src/tools) | An existing Temporal Activity exposed to the agent as an ADK tool via `activityAsTool`. |
| [`multi-agent`](./src/multi-agent) | A `transfer_to_agent` relay from a coordinator `LlmAgent` through a researcher and a writer, each with its own `TemporalModel`. |
| [`mcp`](./src/mcp) | A `TemporalMCPToolset` backed by an `mcpToolsets` factory on the plugin (a filesystem MCP server over stdio). |
| [`streaming`](./src/streaming) | Token streaming from a direct `TemporalModel` call — no agent loop — over the Workflow streams API. |
| [`human-in-the-loop`](./src/human-in-the-loop) | A `LongRunningFunctionTool` whose completion is gated by a Temporal Signal or Update. |
| [`structured-output`](./src/structured-output) | An agent constrained by an output schema, with validation at the Workflow boundary. |
| [`observability`](./src/observability) | Token usage, latency, and call counts, by composing `OpenTelemetryPlugin` onto the Worker alongside `GoogleAdkPlugin`. |

ADK code executors are not included because the integration does not provide a durable Activity boundary for sandbox execution.
44 changes: 44 additions & 0 deletions google-adk-agents/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "temporal-google-adk-agents",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc --build",
"build.watch": "tsc --build --watch",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"test": "mocha --exit --require ts-node/register --require source-map-support/register \"src/*/mocha/*.test.ts\""
},
"dependencies": {
"@temporalio/client": "^1.23.0",
"@temporalio/common": "^1.23.0",
"@temporalio/google-adk-agents": "^1.23.0",
"@temporalio/interceptors-opentelemetry": "^1.23.0",
"@temporalio/worker": "^1.23.0",
"@temporalio/workflow": "^1.23.0",
"@temporalio/workflow-streams": "^1.23.0",
"@google/adk": ">=1.5.0 <1.6.0",
"@google/genai": "^2.9.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"nanoid": "3.x"
},
"devDependencies": {
"@temporalio/testing": "^1.23.0",
"@tsconfig/node22": "^22.0.0",
"@types/mocha": "8.x",
"@types/node": "^22.9.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"mocha": "8.x",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"source-map-support": "^0.5.21"
}
}
13 changes: 13 additions & 0 deletions google-adk-agents/src/agent-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Agent chat

This sample accepts messages through a Workflow Update, exposes conversation history through a Query, and carries that history across Continue-As-New runs.

Start a live Worker with `GEMINI_API_KEY=... npx ts-node src/agent-chat/worker.ts` or a credential-free Worker with `MODEL_PROVIDER=fake npx ts-node src/agent-chat/worker.ts`.

Run `npx ts-node src/agent-chat/client.ts`. Enter `/history` to query state and `/quit` to stop.

Run its API-key-free test with:

```sh
npx mocha --exit --require ts-node/register --require source-map-support/register "src/agent-chat/mocha/*.test.ts"
```
31 changes: 31 additions & 0 deletions google-adk-agents/src/agent-chat/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createInterface } from 'readline/promises';
import { Connection, Client } from '@temporalio/client';
import { nanoid } from 'nanoid';
import { agentChat, getChatState, sendMessage } from './workflows';

async function run() {
const connection = await Connection.connect();
const client = new Client({ connection });
const handle = await client.workflow.start(agentChat, {
taskQueue: 'google-adk-agent-chat',
workflowId: 'google-adk-agent-chat-' + nanoid(),
});
const input = createInterface({ input: process.stdin, output: process.stdout });
for (;;) {
const prompt = await input.question('you> ');
if (prompt === '/quit') break;
if (prompt === '/history') {
console.log((await handle.query(getChatState)).messages);
continue;
}
console.log(`assistant> ${await handle.executeUpdate(sendMessage, { args: [prompt] })}`);
}
input.close();
await handle.terminate();
await connection.close();
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
95 changes: 95 additions & 0 deletions google-adk-agents/src/agent-chat/mocha/workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { LlmRequest, LlmResponse } from '@google/adk';
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
import { GoogleAdkPlugin } from '@temporalio/google-adk-agents';
import { FakeLlm } from '@temporalio/google-adk-agents/testing';
import { after, before, describe, it } from 'mocha';
import assert from 'assert';
import { offlineModelProvider } from '../offline-model';
import { agentChat, getChatState, sendMessage } from '../workflows';

describe('google-adk-agents/agent-chat', function () {
this.timeout(30_000);
let testEnv: TestWorkflowEnvironment;
before(async () => (testEnv = await TestWorkflowEnvironment.createLocal()));
after(async () => testEnv?.teardown());

it('accepts multiple turns and continues as new with conversation state', async () => {
const requests: LlmRequest[] = [];
const taskQueue = `test-google-adk-agent-chat-${Date.now()}`;
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue,
workflowsPath: require.resolve('../workflows'),
plugins: [new GoogleAdkPlugin({ modelProvider: offlineModelProvider((request) => requests.push(request)) })],
});
await worker.runUntil(async () => {
const handle = await testEnv.client.workflow.start(agentChat, {
args: [[], 0, 1, 2],
workflowId: taskQueue,
taskQueue,
});
assert.strictEqual(await handle.executeUpdate(sendMessage, { args: ['My name is Ada.'] }), 'Hello, Ada.');
assert.strictEqual(await handle.executeUpdate(sendMessage, { args: ['What is my name?'] }), 'Your name is Ada.');
await testEnv.sleep(100);
const state = await handle.query(getChatState);
assert.deepStrictEqual(state.messages, [
{ role: 'user', text: 'My name is Ada.' },
{ role: 'assistant', text: 'Hello, Ada.' },
{ role: 'user', text: 'What is my name?' },
{ role: 'assistant', text: 'Your name is Ada.' },
]);
assert.strictEqual(requests.length, 2);
const requestText = (requests[1].contents ?? [])
.flatMap((content) => content.parts ?? [])
.map((part) => part.text ?? '')
.join('\n');
assert.ok(requestText.includes('user: My name is Ada.\nassistant: Hello, Ada.\nuser: What is my name?'));
assert.ok(state.runs >= 2);
await handle.terminate();
});
});

it('serializes concurrent updates with shared conversation context', async () => {
const requests: LlmRequest[] = [];
class InspectingLlm extends FakeLlm {
override async *generateContentAsync(request: LlmRequest): AsyncGenerator<LlmResponse, void> {
requests.push(request);
yield {
content: { role: 'model', parts: [{ text: requests.length === 1 ? 'First answer.' : 'Second answer.' }] },
turnComplete: true,
};
}
}
const taskQueue = `test-google-adk-agent-chat-concurrent-${Date.now()}`;
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue,
workflowsPath: require.resolve('../workflows'),
plugins: [new GoogleAdkPlugin({ modelProvider: () => new InspectingLlm() })],
});
await worker.runUntil(async () => {
const handle = await testEnv.client.workflow.start(agentChat, {
args: [[], 0, 1, 3],
workflowId: taskQueue,
taskQueue,
});
const first = handle.executeUpdate(sendMessage, { args: ['First prompt.'] });
const second = handle.executeUpdate(sendMessage, { args: ['Second prompt.'] });
assert.deepStrictEqual((await Promise.all([first, second])).sort(), ['First answer.', 'Second answer.']);
assert.strictEqual(requests.length, 2);
const firstPrompt = JSON.stringify(requests[0]).includes('First prompt.') ? 'First prompt.' : 'Second prompt.';
const secondPrompt = firstPrompt === 'First prompt.' ? 'Second prompt.' : 'First prompt.';
assert.ok(JSON.stringify(requests[1]).includes(firstPrompt));
assert.match(JSON.stringify(requests[1]), /First answer\./);
const state = await handle.query(getChatState);
assert.deepStrictEqual(state.messages, [
{ role: 'user', text: firstPrompt },
{ role: 'assistant', text: 'First answer.' },
{ role: 'user', text: secondPrompt },
{ role: 'assistant', text: 'Second answer.' },
]);
await handle.terminate();
});
});
});
23 changes: 23 additions & 0 deletions google-adk-agents/src/agent-chat/offline-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BaseLlm } from '@google/adk';
import type { BaseLlmConnection, LlmRequest, LlmResponse } from '@google/adk';

export function offlineModelProvider(onRequest?: (request: LlmRequest) => void): (model: string) => BaseLlm {
class OfflineLlm extends BaseLlm {
override async *generateContentAsync(request: LlmRequest): AsyncGenerator<LlmResponse, void> {
onRequest?.(request);
const text = (request.contents ?? [])
.flatMap((content) => content.parts ?? [])
.map((part) => part.text ?? '')
.join('\n');
const answer = text.includes('user: My name is Ada.\nassistant: Hello, Ada.\nuser: What is my name?')
? 'Your name is Ada.'
: 'Hello, Ada.';
yield { content: { role: 'model', parts: [{ text: answer }] }, turnComplete: true };
}

override async connect(): Promise<BaseLlmConnection> {
throw new Error('OfflineLlm does not support connect().');
}
}
return (model) => new OfflineLlm({ model });
}
Loading
Loading