Repository navigation
Add Google ADK Java integration samples #811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DABH
wants to merge
7
commits into
main
Choose a base branch
from
brian/google-adk-java-samples
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0cc5778
Add Google ADK Java integration samples
DABH 8ac6983
Synchronize chat replay test with Continue-As-New
DABH 8d6c96d
Expand Google ADK Java sample coverage and offline execution
DABH a4ef743
Make ADK chat the entry point and add streaming and tracing samples
DABH 4fcf4c5
Remove buffered streaming and Temporal tracing samples
DABH 5e56497
Use Workflow Updates for Java ADK chat turns
DABH a97a373
clean up readme
DABH File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| } | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
25 changes: 25 additions & 0 deletions
25
googleadk/src/main/java/io/temporal/samples/googleadk/AgentSupport.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ""; | ||
| } | ||
| } |
141 changes: 141 additions & 0 deletions
141
googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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."); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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