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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ It contains the following modules:
* [SpringBoot](/springboot): showcases SpringBoot autoconfig integration.
* [SpringBoot Basic](/springboot-basic): Minimal sample showing SpringBoot autoconfig integration without any extra external dependencies.
* [Spring AI](/springai): demonstrates the Temporal Spring AI integration — durable AI agents with chat models, tools, MCP servers, vector stores, and embeddings.
* [Google ADK](/googleadk): native ADK agents with durable conversations, tools, MCP, human approval, structured output, and asynchronous tool completion.
* [Lambda Worker](/lambda-worker): demonstrates running a Temporal Java Worker inside AWS Lambda.

## Learn more about Temporal and Java SDK
Expand Down Expand Up @@ -228,6 +229,27 @@ To run any of the SpringBoot samples in your Temporal Cloud namespace:

3. Follow the previous section from step 2

### Running Google ADK samples

The [Google ADK samples](googleadk) use the Public Preview
[`io.temporal:google-adk`](https://github.com/temporalio/ai-integrations/tree/main/java/google-adk)
integration. They cover durable conversations, Workflow and Activity tools, parallel agents, MCP,
human approval, structured output, and asynchronous tool completion. The module requires Temporal
Java SDK 1.40.0 or later.

Run the Worker and a sample in separate terminals after starting `temporal server start-dev`:

```bash
ADK_MODEL_MODE=offline ./gradlew :googleadk:worker
./gradlew :googleadk:starter
```

The default starter launches Agent Chat and prints its Workflow ID. Use the
[chat commands](googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md) to send messages,
receive each Update's reply, and finish the conversation.

See the [sample README](googleadk/README.md) for the other scenarios and credential-free tests.

### Running Spring AI Samples

The Spring AI samples demonstrate the [Temporal Spring AI integration](https://github.com/temporalio/sdk-java/tree/master/temporal-spring-ai), which makes Spring AI agents durable on Temporal — model calls run as Temporal Activities recorded in Workflow history, and tools are dispatched per their type so they fit Workflow execution.
Expand Down
133 changes: 133 additions & 0 deletions googleadk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Google ADK on Temporal

Run native [Google ADK Java](https://github.com/google/adk-java) agents durably with
[`io.temporal:google-adk:0.1.1`](https://central.sonatype.com/artifact/io.temporal/google-adk).
The integration is **Public Preview**; APIs and configuration may change before General Availability.

Model calls and MCP operations run as Temporal Activities. Agent orchestration, deterministic function
tools, and session state stay in the Workflow. Build Runners with `TemporalAdk.runnerBuilder()` and
await agent turns with `TemporalAdk.run()` so ADK uses Workflow time, identifiers, and threads.

## Requirements

- Java 17 or later.
- A local Temporal Service: `temporal server start-dev`.
- For live Gemini runs, `GOOGLE_API_KEY` in the Worker's environment, or Vertex AI configuration.

Dependencies come from Maven Central. This module supplies Temporal SDK/testing in `[1.40.0,2.0)`,
preferring 1.40.0, independently of other samples. The integration brings ADK Java 1.12.0.

## Run offline or with Gemini

Run every command from the repository root. Start the Service in one terminal:

```bash
temporal server start-dev
```

Start the Worker in a second terminal, choosing one model mode:

```bash
ADK_MODEL_MODE=offline ./gradlew :googleadk:worker
```

```bash
GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker
```

`live` is the default. Offline mode uses a deliberately small, stateless scripted `BaseLlm` for these
scenarios, including fixed Chicago weather and travel examples. It makes no provider requests and
needs no key. Live mode constructs Gemini only on the Worker and disables GenAI's HTTP retries so
Temporal Activities own retries. Stop the Worker before switching modes.

Both modes run the same Worker registration, plugin, Workflows, Activities, messages, and result
path. The MCP scenario launches the real Java stdio server in both modes. Offline mode still needs
Temporal running, and a first Gradle build may download dependencies.

Use a third terminal for the scenario commands below. Every Worker polls `google-adk-samples`.
The Temporal UI at <http://localhost:8233> shows model and tool Activities. Use a new Workflow ID
when repeating a command that names an ID.

Start with Agent Chat in that third terminal:

```bash
./gradlew :googleadk:starter
```

The starter prints the Workflow ID. Follow the [chat commands](src/main/java/io/temporal/samples/googleadk/chat)
to send Updates, receive their replies, and finish the conversation.

## Coverage

| Category | Scenario | Durable behavior |
| --- | --- | --- |
| Agent Chat | [chat](src/main/java/io/temporal/samples/googleadk/chat) | Updates return each turn's reply; accepted turns finish before a session snapshot carries the conversation through Continue-As-New. |
| Tools | [tools](src/main/java/io/temporal/samples/googleadk/tools) | A nondeterministic weather observation runs in an Activity; a pure temperature conversion runs in the Workflow. |
| MCP | [mcp](src/main/java/io/temporal/samples/googleadk/mcp) | List and call a bundled stateless Java stdio server through Activities. |
| Streaming | Unsupported | Plugin 0.1.1 buffers SSE responses until the model Activity completes; it cannot deliver output through Workflow Streams while the Activity is running. See [#257](https://github.com/temporalio/ai-integrations/issues/257). |
| Human in the Loop | [humanintheloop](src/main/java/io/temporal/samples/googleadk/humanintheloop) | A Signal approves or rejects ADK's pending tool confirmation; cancellation ends the durable wait. |
| Structured Output | [structuredoutput](src/main/java/io/temporal/samples/googleadk/structuredoutput) | ADK supplies the model's output schema; the Workflow validates JSON and returns a typed `TravelPlan` or a non-retryable failure. |
| Multi-Agent | [multiagent](src/main/java/io/temporal/samples/googleadk/multiagent) | ADK's `ParallelAgent` starts planner and reviewer model Activities concurrently on Workflow threads. |
| Sandboxes | Unsupported | The plugin does not durably manage an agent-facing code execution environment. ADK code executors that perform host I/O cannot run on the Workflow thread. |
| Observability | Unsupported | Native ADK tracing has no replay suppression; installing an OpenTelemetry SDK can export duplicate agent/model/tool spans during replay. See [#258](https://github.com/temporalio/ai-integrations/issues/258). |

The plugin's [documented limits](https://github.com/temporalio/ai-integrations/blob/java/google-adk/v0.1.1/java/google-adk/README.md#limitations)
still apply to live token forwarding, agent-facing code execution, and native ADK global tracing.

Start with [Agent Chat](src/main/java/io/temporal/samples/googleadk/chat); it is the introductory sample.
The suite also includes:

- [longrunning](src/main/java/io/temporal/samples/googleadk/longrunning) uses the plugin's
`ActivityTools.createLongRunning`, `Hitl.longRunningCalls`, and `Hitl.functionResponse` APIs.
A submitted report remains pending until a correlated callback Signal delivers its result.
This differs from approving an operation before it executes: it resumes an operation already started.

## Approve or reject a tool call

See [humanintheloop](src/main/java/io/temporal/samples/googleadk/humanintheloop) for CLI commands to
start, query, decide, and cancel. Query until `pendingConfirmations` contains a request before
sending `decide`. A decision applies to the currently pending batch; premature Signals are ignored, and the first
decision for a pending batch wins. Rejection never schedules `ShipOrder`.

## Continue a conversation as new

See [chat](src/main/java/io/temporal/samples/googleadk/chat) for CLI commands. After three turns the
Workflow waits for accepted Updates to finish, exports its ADK session, and carries the session and
latest reply into a new Run. `finish` rejects new messages, drains accepted Updates, and returns the
last reply. Continue-As-New bounds each Run's history; the conversation snapshot itself still grows
and must fit Temporal's payload limits.

## Test without provider credentials

```bash
./gradlew :googleadk:test :googleadk:spotlessCheck :googleadk:build
```

Tests use the published `ScriptedModel` or the same `OfflineModel` as the runnable Worker, and
`TestWorkflowEnvironment`. They need no provider credentials or external Temporal Service.
The MCP tests launch the bundled local subprocess.

Each supported scenario has a targeted test command in its README. Tests assert Activity scheduling,
retry behavior, function results, confirmation decisions and cancellation, schema rejection,
correlated callback completion, Update replies and concurrency, and session recovery across
Continue-As-New. Recorded histories replay on a fresh Worker without repeating model or tool calls.
`OfflineSamplesTest` runs every
scenario with `SampleWorker.register` and the actual offline provider.

The `:googleadk:starter` task starts Agent Chat by default. It also accepts `tools`, `multiagent`,
`mcp`, `approval`, and `chat`, and their message commands. The scenario READMEs use Temporal CLI directly.
External Java clients that exchange ADK `Session` or `Content` payloads must also register
`GoogleAdkPlugin` to use its payload converter; ordinary CLI JSON inputs need no Java client.

## Boundaries

- Put host clocks, files, network services, and nondeterministic tools in Activities or MCP. The
weather Activity deliberately reads `Instant.now()` alongside illustrative, fixed conditions;
it does not call a real weather provider. Shipping and report submission also return sample data.
- Do not override agent-level schedulers/executors or use RxJava blocking APIs inside Workflows.
- Session snapshots preserve session-scoped state and events, not app-, user-, or temporary state.
- A cancellation stops the Workflow's wait. It does not undo a shipment or cancel an external report
already submitted; a real backend needs an explicit cancellation Activity.

See the [integration reference](https://github.com/temporalio/ai-integrations/tree/main/java/google-adk)
for the full API and limitations.
45 changes: 45 additions & 0 deletions googleadk/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ADK samples consume the independently versioned integration from Maven Central.
dependencies {
implementation 'io.temporal:google-adk:0.1.1'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation('io.temporal:temporal-sdk') {
version {
strictly '[1.40.0,2.0['
prefer '1.40.0'
}
}
testImplementation('io.temporal:temporal-testing') {
version {
strictly '[1.40.0,2.0['
prefer '1.40.0'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why prefer ? I don't think any of our other samples declare this and not really sure why we would prefer 1.40.0? Generally we always want people running the latests version of the SDK

}
}
implementation 'io.modelcontextprotocol.sdk:mcp:2.0.0'
implementation 'io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0'
runtimeOnly 'ch.qos.logback:logback-classic:1.5.18'

testImplementation platform('org.junit:junit-bom:5.11.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

errorproneJavac 'com.google.errorprone:javac:9+181-r4173-1'
errorprone 'com.google.errorprone:error_prone_core:2.28.0'
}

tasks.register('worker', JavaExec) {
classpath = sourceSets.main.runtimeClasspath
mainClass = 'io.temporal.samples.googleadk.SampleWorker'
}

tasks.register('starter', JavaExec) {
classpath = sourceSets.main.runtimeClasspath
mainClass = 'io.temporal.samples.googleadk.SampleStarter'
}

test {
// Gradle's test worker uses its own bootstrap classpath. The MCP subprocess
// needs the sample application's classpath, just as it has in the worker task.
doFirst {
systemProperty 'googleadk.echo.classpath', sourceSets.main.runtimeClasspath.asPath
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.temporal.samples.googleadk;

import com.google.adk.events.Event;
import com.google.genai.types.Content;
import java.util.List;

/** Shared names and a deterministic helper for reading an agent's final answer. */
public final class AgentSupport {
public static final String MODEL = "gemini-2.5-flash";
public static final String TASK_QUEUE = "google-adk-samples";
public static final String USER = "user-1";
public static final String SESSION = "session-1";

private AgentSupport() {}

public static String lastText(List<Event> events) {
for (int i = events.size() - 1; i >= 0; i--) {
String text = events.get(i).content().map(Content::text).orElse("");
if (text != null && !text.isEmpty()) {
return text;
}
}
return "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package io.temporal.samples.googleadk;

import com.google.adk.models.BaseLlm;
import com.google.adk.models.BaseLlmConnection;
import com.google.adk.models.LlmRequest;
import com.google.adk.models.LlmResponse;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Flowable;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class OfflineModel extends BaseLlm {
public OfflineModel() {
super(AgentSupport.MODEL);
}

@Override
public Flowable<LlmResponse> generateContent(LlmRequest request, boolean stream) {
return Flowable.just(respond(request));
}

private static LlmResponse respond(LlmRequest request) {
if (request.tools().containsKey("getWeather")) {
Optional<Map<String, Object>> weather = functionResponse(request, "getWeather");
if (weather.isEmpty()) {
return call("getWeather", Map.of("city", "Chicago"));
}
Optional<Map<String, Object>> converted = functionResponse(request, "toFahrenheit");
if (converted.isEmpty()) {
return call("toFahrenheit", Map.of("celsius", weather.get().get("celsius")));
}
return text(
"Chicago is sunny: "
+ weather.get().get("celsius")
+ " C / "
+ converted.get().get("fahrenheit")
+ " F.");
}
if (request.tools().containsKey("shipOrder")) {
return functionResponse(request, "shipOrder")
.map(
response ->
response.containsKey("error")
? text(
response.get("error").toString().contains("rejected")
? "Order rejected."
: "Please approve the shipment.")
: text("Order shipped."))
.orElseGet(() -> call("shipOrder", Map.of("orderId", "demo-order")));
}
if (request.tools().containsKey("startReport")) {
return functionResponse(request, "startReport")
.map(
response ->
text(
"done".equals(response.get("status"))
? "Report complete: " + response.get("result")
: "Report is pending."))
.orElseGet(() -> call("startReport", Map.of("name", "sales")));
}
if (request.tools().containsKey("echo")) {
return functionResponse(request, "echo")
.map(OfflineModel::echo)
.orElseGet(() -> call("echo", Map.of("text", lastUserText(request))));
}
if (request.config().flatMap(config -> config.responseSchema()).isPresent()) {
return text(
"{\"city\":\"Chicago\",\"days\":2,\"activities\":[\"Walk the lakefront\",\"Visit a museum\"]}");
}
if (request.getSystemInstructions().stream().anyMatch(text -> text.contains("\"planner\""))) {
return text("Visit Chicago's lakefront.");
}
if (request.getSystemInstructions().stream().anyMatch(text -> text.contains("\"reviewer\""))) {
return text("Bring a warm jacket.");
}
long turns =
request.contents().stream()
.filter(content -> content.role().orElse("").equals("user"))
.count();
return text("turns=" + turns + "; latest: " + lastUserText(request));
}

static Optional<Map<String, Object>> functionResponse(LlmRequest request, String name) {
return request.contents().stream()
.flatMap(content -> content.parts().orElse(List.of()).stream())
.flatMap(part -> part.functionResponse().stream())
.filter(response -> response.name().orElse("").equals(name))
.map(FunctionResponse::response)
.flatMap(Optional::stream)
.reduce((first, last) -> last);
}

private static LlmResponse echo(Map<String, Object> response) {
if (response.get("text_output") instanceof List<?> outputs
&& !outputs.isEmpty()
&& outputs.get(0) instanceof Map<?, ?> first
&& first.get("text") instanceof String value) {
return text(value);
}
throw new IllegalArgumentException("The echo tool returned no text output.");
}

private static String lastUserText(LlmRequest request) {
return request.contents().stream()
.filter(content -> content.role().orElse("").equals("user"))
.flatMap(content -> content.parts().orElse(List.of()).stream())
.flatMap(part -> part.text().stream())
.reduce((first, last) -> last)
.orElse("Hello");
}

private static LlmResponse text(String value) {
return LlmResponse.builder()
.content(Content.builder().role("model").parts(List.of(Part.fromText(value))).build())
.turnComplete(true)
.build();
}

private static LlmResponse call(String name, Map<String, Object> arguments) {
return LlmResponse.builder()
.content(
Content.builder()
.role("model")
.parts(
List.of(
Part.builder()
.functionCall(FunctionCall.builder().name(name).args(arguments).build())
.build()))
.build())
.build();
}

@Override
public BaseLlmConnection connect(LlmRequest request) {
throw new UnsupportedOperationException("The offline model has no live connection.");
}
}
Loading
Loading