From 0cc5778a94af03fe5d36b2bb37faecc9394f9efc Mon Sep 17 00:00:00 2001 From: DABH Date: Mon, 26 Oct 2026 12:16:45 -0500 Subject: [PATCH 1/7] Add Google ADK Java integration samples --- README.md | 17 +++ googleadk/README.md | 134 ++++++++++++++++++ googleadk/build.gradle | 44 ++++++ .../samples/googleadk/AgentSupport.java | 25 ++++ .../samples/googleadk/SampleStarter.java | 125 ++++++++++++++++ .../samples/googleadk/SampleWorker.java | 72 ++++++++++ .../googleadk/basic/BasicWorkflow.java | 10 ++ .../googleadk/basic/BasicWorkflowImpl.java | 33 +++++ .../samples/googleadk/chat/ChatInput.java | 12 ++ .../samples/googleadk/chat/ChatWorkflow.java | 21 +++ .../googleadk/chat/ChatWorkflowImpl.java | 83 +++++++++++ .../humanintheloop/ApprovalWorkflow.java | 20 +++ .../humanintheloop/ApprovalWorkflowImpl.java | 75 ++++++++++ .../humanintheloop/OrderActivities.java | 13 ++ .../humanintheloop/OrderActivitiesImpl.java | 11 ++ .../samples/googleadk/mcp/EchoMcpServer.java | 58 ++++++++ .../samples/googleadk/mcp/McpWorkflow.java | 10 ++ .../googleadk/mcp/McpWorkflowImpl.java | 32 +++++ .../multiagent/MultiAgentWorkflow.java | 11 ++ .../multiagent/MultiAgentWorkflowImpl.java | 46 ++++++ .../googleadk/tools/ToolsWorkflow.java | 10 ++ .../googleadk/tools/ToolsWorkflowImpl.java | 43 ++++++ .../googleadk/tools/WeatherActivities.java | 15 ++ .../tools/WeatherActivitiesImpl.java | 11 ++ googleadk/src/main/resources/logback.xml | 8 ++ .../samples/googleadk/AdkTestEnvironment.java | 106 ++++++++++++++ .../googleadk/ApprovalWorkflowTest.java | 81 +++++++++++ .../samples/googleadk/BasicWorkflowTest.java | 30 ++++ .../samples/googleadk/ChatWorkflowTest.java | 80 +++++++++++ .../samples/googleadk/McpWorkflowTest.java | 48 +++++++ .../googleadk/MultiAgentWorkflowTest.java | 51 +++++++ .../samples/googleadk/ToolsWorkflowTest.java | 56 ++++++++ settings.gradle | 1 + 33 files changed, 1392 insertions(+) create mode 100644 googleadk/README.md create mode 100644 googleadk/build.gradle create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/AgentSupport.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivities.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivitiesImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/mcp/EchoMcpServer.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivities.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java create mode 100644 googleadk/src/main/resources/logback.xml create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/McpWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/MultiAgentWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java diff --git a/README.md b/README.md index 7a19472a..0307af40 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,23 @@ 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 a basic agent, Activity tools, parallel agents, MCP, human approval, and a +conversation that continues as new. 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 +export GOOGLE_API_KEY=your_key +./gradlew :googleadk:worker +./gradlew :googleadk:starter --args='basic' +``` + +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. diff --git a/googleadk/README.md b/googleadk/README.md new file mode 100644 index 00000000..1deaa278 --- /dev/null +++ b/googleadk/README.md @@ -0,0 +1,134 @@ +# Google ADK on Temporal + +Run native [Google ADK Java](https://github.com/google/adk-java) agents in Temporal Workflows using +the standalone [`io.temporal:google-adk:0.1.0`](https://central.sonatype.com/artifact/io.temporal/google-adk) +integration. This integration is **Public Preview**, not Generally Available. APIs and configuration +may change before General Availability. + +Model calls and MCP operations run as Temporal Activities. Agent orchestration, ordinary function +tools, and session state stay in the Workflow. Use `TemporalAdk.runnerBuilder()` to keep ADK's time, +identifiers, and parallel execution deterministic, and `TemporalAdk.run()` to await an agent turn. + +## Requirements + +- Java 17 or later. +- `io.temporal:google-adk:0.1.0`, which brings Google ADK Java 1.12.0. +- Temporal Java SDK and testing library 1.40.0 or later. The module declares a strict `[1.40.0,2.0)` + version range, preferring 1.40.0, independently of the other samples' SDK version. +- For a live run, a local Temporal Service (`temporal server start-dev`) and `GOOGLE_API_KEY` in the + Worker's environment. The Google GenAI client also supports Vertex AI's environment configuration. + +The plugin's version is independent of the SDK version. The plugin does not supply `temporal-sdk`; +the application adds it explicitly. Dependencies come from Maven Central. + +## Run a sample + +Start a local Temporal Service: + +```bash +temporal server start-dev +``` + +In another terminal, start the shared Worker from the repository root: + +```bash +export GOOGLE_API_KEY=your_key +./gradlew :googleadk:worker +``` + +Then run one of these commands in a third terminal: + +```bash +./gradlew :googleadk:starter --args='basic' +./gradlew :googleadk:starter --args='tools' +./gradlew :googleadk:starter --args='multiagent' +./gradlew :googleadk:starter --args='mcp' +``` + +The starter prints the agent's answer. All samples share the `google-adk-samples` Task Queue and +start a new Workflow ID for each invocation. Inspect their Activities at `http://localhost:8233`. + +| Scenario | Source | What it shows | +| --- | --- | --- | +| Basic | [`basic/`](src/main/java/io/temporal/samples/googleadk/basic) | A native `LlmAgent` with `TemporalModel`; one model call becomes `googleadk.InvokeModel`. | +| Activity tools | [`tools/`](src/main/java/io/temporal/samples/googleadk/tools) | `ActivityTools.create` exposes a weather Activity to the agent. The Activity returns sample weather data; replace its body with provider I/O. | +| Multiple agents | [`multiagent/`](src/main/java/io/temporal/samples/googleadk/multiagent) | A `ParallelAgent` schedules two model Activities concurrently on Workflow threads. | +| MCP | [`mcp/`](src/main/java/io/temporal/samples/googleadk/mcp) | A self-contained Java stdio echo server, started on first use by the Worker. Listing and calling tools become Activities. No Node or separately running MCP service is required. | +| Human approval | [`humanintheloop/`](src/main/java/io/temporal/samples/googleadk/humanintheloop) | An Activity tool waits for a human decision delivered by Signal. The shipping Activity returns sample data and makes no fulfillment request. | +| Conversation | [`chat/`](src/main/java/io/temporal/samples/googleadk/chat) | Signals queue messages, a Query returns the latest reply, and a session snapshot preserves the conversation through Continue-As-New. | + +[`SampleWorker`](src/main/java/io/temporal/samples/googleadk/SampleWorker.java) registers the +`GoogleAdkPlugin` on the Temporal Client, so its Workers inherit the model/MCP Activities and payload +converter. Credentials stay on the Worker. Native GenAI retries are disabled with one HTTP attempt; +Temporal Activity retries handle model failures. Register the plugin on external clients too, as +[`SampleStarter`](src/main/java/io/temporal/samples/googleadk/SampleStarter.java) does. + +## Approve or reject a tool call + +Start a fulfillment Workflow: + +```bash +./gradlew :googleadk:starter --args='approval' +``` + +Use the printed Workflow ID in place of `WORKFLOW_ID`. Query the pending tool name and arguments, +then approve or reject the calls from that turn: + +```bash +./gradlew :googleadk:starter --args='pending WORKFLOW_ID' +./gradlew :googleadk:starter --args='decide WORKFLOW_ID true' +# Or reject instead: +./gradlew :googleadk:starter --args='decide WORKFLOW_ID false' +./gradlew :googleadk:starter --args='result WORKFLOW_ID' +``` + +`pending` can initially be empty while the model runs; query again once the agent asks for approval. +The decision command sends a Signal; `result` waits for the final answer. The Workflow uses `Workflow.await` while paused, +then supplies `Hitl.confirmationResponse` to the next ADK turn. A rejected call never schedules the +shipping Activity. If the model asks for another confirmation, query and decide again. + +## Continue a conversation as new + +```bash +./gradlew :googleadk:starter --args='chat' +./gradlew :googleadk:starter --args='message WORKFLOW_ID My name is Ada.' +./gradlew :googleadk:starter --args='reply WORKFLOW_ID' +./gradlew :googleadk:starter --args='message WORKFLOW_ID Suggest a trip to Chicago.' +./gradlew :googleadk:starter --args='message WORKFLOW_ID What should I pack?' +./gradlew :googleadk:starter --args='message WORKFLOW_ID What is my name?' +./gradlew :googleadk:starter --args='reply WORKFLOW_ID' +./gradlew :googleadk:starter --args='finish WORKFLOW_ID' +``` + +`message` queues a turn without waiting for the model. Query again after the model Activity completes +to see its new reply. After three turns, the Workflow exports its ADK session, continues as new, and +imports the session in the next Run. Queued messages and the latest reply travel with the snapshot. +`finish` drains pending messages and returns the final answer. Continue-As-New limits each Run's +history; the snapshot still contains the conversation and must fit within Temporal's payload limits. + +## Test without provider credentials + +```bash +./gradlew :googleadk:test :googleadk:spotlessCheck +``` + +The tests use `ScriptedModel` from the published integration and `TestWorkflowEnvironment`; they +need no API keys or external Temporal Service. The MCP test starts the real local Java echo server. +Tests verify Activity retries and results, concurrent model scheduling, approval and rejection, +session snapshots and queued messages across Continue-As-New, and replay on a fresh Worker without +repeating model or tool calls. + +## Limitations + +- Plain ADK function tools run in the Workflow and must be deterministic. Put network, file, and + other I/O in Activities or MCP tools. +- Build Runners with `TemporalAdk.runnerBuilder()`. Do not override agent-level schedulers or + executors, or use RxJava blocking APIs inside a Workflow. +- Streaming chunks arrive after the model Activity completes. Token streaming out of a Workflow + and live bidirectional mode are unsupported. +- Session snapshots preserve session-scoped state and events. They do not carry app-, user-, or + temporary-scoped state. +- ADK OpenTelemetry spans are emitted again during replay when a tracing SDK is installed. + +See the [integration README](https://github.com/temporalio/ai-integrations/tree/main/java/google-adk) +for the full API and limitations. diff --git a/googleadk/build.gradle b/googleadk/build.gradle new file mode 100644 index 00000000..c2b82001 --- /dev/null +++ b/googleadk/build.gradle @@ -0,0 +1,44 @@ +// ADK samples consume the independently versioned integration from Maven Central. +dependencies { + implementation 'io.temporal:google-adk:0.1.0' + 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 + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/AgentSupport.java b/googleadk/src/main/java/io/temporal/samples/googleadk/AgentSupport.java new file mode 100644 index 00000000..5f8885f9 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/AgentSupport.java @@ -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 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 ""; + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java new file mode 100644 index 00000000..07b63fd5 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java @@ -0,0 +1,125 @@ +package io.temporal.samples.googleadk; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.googleadk.GoogleAdkPlugin; +import io.temporal.samples.googleadk.basic.BasicWorkflow; +import io.temporal.samples.googleadk.chat.ChatInput; +import io.temporal.samples.googleadk.chat.ChatWorkflow; +import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; +import io.temporal.samples.googleadk.mcp.McpWorkflow; +import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; +import io.temporal.samples.googleadk.tools.ToolsWorkflow; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.util.Arrays; +import java.util.UUID; + +/** Starts samples or sends messages to their existing Workflow Executions. */ +public final class SampleStarter { + private SampleStarter() {} + + public static void main(String[] args) { + if (args.length == 0) { + throw new IllegalArgumentException( + "Choose basic, tools, multiagent, mcp, approval, chat, pending, decide, result, message, reply, or finish."); + } + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + try { + // Register the plugin on external clients too, so Queries/Signals use the same converter. + WorkflowClient client = + WorkflowClient.newInstance( + service, + WorkflowClientOptions.newBuilder() + .setPlugins(GoogleAdkPlugin.newBuilder().build()) + .build()); + String id = "google-adk-" + UUID.randomUUID(); + WorkflowOptions options = + WorkflowOptions.newBuilder() + .setTaskQueue(AgentSupport.TASK_QUEUE) + .setWorkflowId(id) + .build(); + switch (args[0]) { + case "basic": + // @@@SNIPSTART java-google-adk-starter + BasicWorkflow workflow = client.newWorkflowStub(BasicWorkflow.class, options); + System.out.println(workflow.ask("What does Durable Execution mean?")); + // @@@SNIPEND + break; + case "tools": + System.out.println( + client + .newWorkflowStub(ToolsWorkflow.class, options) + .ask("What's the weather in Chicago?")); + break; + case "multiagent": + client + .newWorkflowStub(MultiAgentWorkflow.class, options) + .plan("Chicago") + .forEach(System.out::println); + break; + case "mcp": + System.out.println( + client.newWorkflowStub(McpWorkflow.class, options).echo("Hello from Temporal")); + break; + case "approval": + ApprovalWorkflow approval = client.newWorkflowStub(ApprovalWorkflow.class, options); + WorkflowClient.start(approval::fulfill, "Ship order demo-order, after confirmation."); + System.out.println("Workflow ID: " + id); + break; + case "chat": + ChatWorkflow chat = client.newWorkflowStub(ChatWorkflow.class, options); + WorkflowClient.start(chat::chat, ChatInput.empty()); + System.out.println("Workflow ID: " + id); + break; + case "pending": + requireArgs(args, 2); + client + .newWorkflowStub(ApprovalWorkflow.class, args[1]) + .pendingConfirmations() + .forEach(System.out::println); + break; + case "decide": + requireArgs(args, 3); + if (!args[2].equals("true") && !args[2].equals("false")) { + throw new IllegalArgumentException("Decision must be true or false."); + } + ApprovalWorkflow pending = client.newWorkflowStub(ApprovalWorkflow.class, args[1]); + pending.decide(Boolean.parseBoolean(args[2])); + System.out.println("Decision sent."); + break; + case "result": + requireArgs(args, 2); + System.out.println(client.newUntypedWorkflowStub(args[1]).getResult(String.class)); + break; + case "message": + requireArgs(args, 3); + client + .newWorkflowStub(ChatWorkflow.class, args[1]) + .message(String.join(" ", Arrays.copyOfRange(args, 2, args.length))); + break; + case "reply": + requireArgs(args, 2); + System.out.println(client.newWorkflowStub(ChatWorkflow.class, args[1]).lastReply()); + break; + case "finish": + requireArgs(args, 2); + ChatWorkflow conversation = client.newWorkflowStub(ChatWorkflow.class, args[1]); + conversation.finish(); + System.out.println( + io.temporal.client.WorkflowStub.fromTyped(conversation).getResult(String.class)); + break; + default: + throw new IllegalArgumentException("Unknown command: " + args[0]); + } + } finally { + service.shutdown(); + } + } + + private static void requireArgs(String[] args, int count) { + if (args.length < count) { + throw new IllegalArgumentException("Missing arguments; see googleadk/README.md."); + } + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java new file mode 100644 index 00000000..b826366d --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java @@ -0,0 +1,72 @@ +package io.temporal.samples.googleadk; + +import com.google.adk.models.Gemini; +import com.google.genai.Client; +import com.google.genai.types.HttpOptions; +import com.google.genai.types.HttpRetryOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.googleadk.GoogleAdkPlugin; +import io.temporal.samples.googleadk.basic.BasicWorkflowImpl; +import io.temporal.samples.googleadk.chat.ChatWorkflowImpl; +import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflowImpl; +import io.temporal.samples.googleadk.humanintheloop.OrderActivitiesImpl; +import io.temporal.samples.googleadk.mcp.EchoMcpServer; +import io.temporal.samples.googleadk.mcp.McpWorkflowImpl; +import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflowImpl; +import io.temporal.samples.googleadk.tools.ToolsWorkflowImpl; +import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +/** One worker hosts all six samples; real model credentials are used only here. */ +public final class SampleWorker { + private SampleWorker() {} + + public static void main(String[] args) { + // @@@SNIPSTART java-google-adk-worker + // Client reads GOOGLE_API_KEY or the Vertex AI environment variables on the worker. + // One HTTP attempt disables native retries so Temporal Activities own retries. + Client modelClient = + Client.builder() + .httpOptions( + HttpOptions.builder() + .retryOptions(HttpRetryOptions.builder().attempts(1).build()) + .build()) + .build(); + GoogleAdkPlugin plugin = + GoogleAdkPlugin.newBuilder() + .model( + AgentSupport.MODEL, + () -> Gemini.builder().modelName(AgentSupport.MODEL).apiClient(modelClient).build()) + .mcpToolset("echo", EchoMcpServer::toolset) + .build(); + WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); + WorkflowClient client = + WorkflowClient.newInstance( + service, WorkflowClientOptions.newBuilder().setPlugins(plugin).build()); + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(AgentSupport.TASK_QUEUE); + worker.registerWorkflowImplementationTypes( + BasicWorkflowImpl.class, + ToolsWorkflowImpl.class, + MultiAgentWorkflowImpl.class, + McpWorkflowImpl.class, + ApprovalWorkflowImpl.class, + ChatWorkflowImpl.class); + worker.registerActivitiesImplementations( + new WeatherActivitiesImpl(), new OrderActivitiesImpl()); + factory.start(); + // @@@SNIPEND + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + factory.shutdown(); + factory.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS); + modelClient.close(); + service.shutdown(); + })); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java new file mode 100644 index 00000000..d7929855 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.googleadk.basic; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface BasicWorkflow { + @WorkflowMethod + String ask(String question); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java new file mode 100644 index 00000000..7254f923 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java @@ -0,0 +1,33 @@ +package io.temporal.samples.googleadk.basic; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.events.Event; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.samples.googleadk.AgentSupport; +import java.util.List; + +public class BasicWorkflowImpl implements BasicWorkflow { + // @@@SNIPSTART java-google-adk-basic-workflow + @Override + public String ask(String question) { + LlmAgent agent = + LlmAgent.builder() + .name("assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Answer concisely.") + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("basic").agent(agent).build(); + List events = + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(question))); + return AgentSupport.lastText(events); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java new file mode 100644 index 00000000..8574f0ac --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java @@ -0,0 +1,12 @@ +package io.temporal.samples.googleadk.chat; + +import com.google.adk.sessions.Session; +import java.util.List; + +/** Session and queued messages carried into the next Run after Continue-As-New. */ +public record ChatInput( + Session session, List messages, boolean finished, String lastReply) { + public static ChatInput empty() { + return new ChatInput(null, List.of(), false, ""); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java new file mode 100644 index 00000000..6e3df819 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java @@ -0,0 +1,21 @@ +package io.temporal.samples.googleadk.chat; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface ChatWorkflow { + @WorkflowMethod + String chat(ChatInput input); + + @SignalMethod + void message(String text); + + @SignalMethod + void finish(); + + @QueryMethod + String lastReply(); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java new file mode 100644 index 00000000..67c7aa07 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java @@ -0,0 +1,83 @@ +package io.temporal.samples.googleadk.chat; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.Session; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.googleadk.session.Sessions; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.util.ArrayList; +import java.util.List; + +public class ChatWorkflowImpl implements ChatWorkflow { + private final List messages = new ArrayList<>(); + private boolean finished; + private String reply = ""; + + @WorkflowInit + public ChatWorkflowImpl(ChatInput input) { + messages.addAll(input.messages()); + finished = input.finished(); + reply = input.lastReply(); + } + + // @@@SNIPSTART java-google-adk-chat-workflow + @Override + public String chat(ChatInput input) { + LlmAgent agent = + LlmAgent.builder() + .name("chat_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Answer concisely, remembering the earlier turns in this conversation.") + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("chat").agent(agent).build(); + if (input.session() != null) { + Sessions.importSession(runner.sessionService(), input.session()); + } + int turns = 0; + while (!finished || !messages.isEmpty()) { + Workflow.await(() -> finished || !messages.isEmpty()); + if (messages.isEmpty()) { + break; + } + String text = messages.remove(0); + reply = + AgentSupport.lastText( + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(text)))); + // Use a small threshold to make Continue-As-New easy to observe in this sample. + if (++turns == 3 && !finished) { + Session snapshot = + Sessions.exportSession( + runner.sessionService(), "chat", AgentSupport.USER, AgentSupport.SESSION); + Workflow.continueAsNew(new ChatInput(snapshot, List.copyOf(messages), finished, reply)); + } + } + return reply; + } + + // @@@SNIPEND + + @Override + public void message(String text) { + messages.add(text); + } + + @Override + public void finish() { + finished = true; + } + + @Override + public String lastReply() { + return reply; + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflow.java new file mode 100644 index 00000000..d48f2ea0 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflow.java @@ -0,0 +1,20 @@ +package io.temporal.samples.googleadk.humanintheloop; + +import io.temporal.googleadk.tool.Hitl; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.List; + +@WorkflowInterface +public interface ApprovalWorkflow { + @WorkflowMethod + String fulfill(String question); + + @QueryMethod + List pendingConfirmations(); + + @SignalMethod + void decide(boolean confirmed); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java new file mode 100644 index 00000000..50b2f78d --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java @@ -0,0 +1,75 @@ +package io.temporal.samples.googleadk.humanintheloop; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.events.Event; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.googleadk.tool.ActivityTools; +import io.temporal.googleadk.tool.Hitl; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import java.util.List; + +public class ApprovalWorkflowImpl implements ApprovalWorkflow { + private List pending = List.of(); + private Boolean decision; + + // @@@SNIPSTART java-google-adk-approval-workflow + @Override + public String fulfill(String question) { + OrderActivities orders = + Workflow.newActivityStub( + OrderActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + LlmAgent agent = + LlmAgent.builder() + .name("fulfillment_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction( + "Use shipOrder when asked to ship an order. Respect rejected confirmations.") + .tools(ActivityTools.create(orders, "shipOrder", true)) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("approval").agent(agent).build(); + List events = + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(question))); + pending = Hitl.pendingConfirmations(events); + while (!pending.isEmpty()) { + Workflow.await(() -> decision != null); + Content response = + Hitl.confirmationResponse( + pending.stream() + .map(p -> new Hitl.ConfirmationDecision(p.functionCallId(), decision)) + .toArray(Hitl.ConfirmationDecision[]::new)); + decision = null; + pending = List.of(); + events = TemporalAdk.run(runner, AgentSupport.USER, AgentSupport.SESSION, response); + pending = Hitl.pendingConfirmations(events); + } + return AgentSupport.lastText(events); + } + + // @@@SNIPEND + + @Override + public List pendingConfirmations() { + return List.copyOf(pending); + } + + @Override + public void decide(boolean confirmed) { + decision = confirmed; + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivities.java b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivities.java new file mode 100644 index 00000000..90b328e2 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivities.java @@ -0,0 +1,13 @@ +package io.temporal.samples.googleadk.humanintheloop; + +import com.google.adk.tools.Annotations.Schema; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import java.util.Map; + +@ActivityInterface +public interface OrderActivities { + @ActivityMethod(name = "ShipOrder") + Map shipOrder( + @Schema(name = "orderId", description = "The order to ship") String orderId); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivitiesImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivitiesImpl.java new file mode 100644 index 00000000..a5a21152 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/OrderActivitiesImpl.java @@ -0,0 +1,11 @@ +package io.temporal.samples.googleadk.humanintheloop; + +import java.util.Map; + +public class OrderActivitiesImpl implements OrderActivities { + @Override + public Map shipOrder(String orderId) { + // No order is actually shipped. A real integration would call the fulfillment API here. + return Map.of("orderId", orderId, "status", "shipped"); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/EchoMcpServer.java b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/EchoMcpServer.java new file mode 100644 index 00000000..d044304e --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/EchoMcpServer.java @@ -0,0 +1,58 @@ +package io.temporal.samples.googleadk.mcp; + +import com.google.adk.tools.mcp.McpToolset; +import com.google.adk.tools.mcp.StdioServerParameters; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import tools.jackson.databind.json.JsonMapper; + +/** A local MCP echo server, launched by the worker without Node or another service. */ +public final class EchoMcpServer { + private EchoMcpServer() {} + + public static McpToolset toolset() { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = + System.getProperty("googleadk.echo.classpath", System.getProperty("java.class.path")); + return new McpToolset( + StdioServerParameters.builder() + .command(java) + .args(List.of("-cp", classpath, EchoMcpServer.class.getName())) + .build() + .toServerParameters()); + } + + public static void main(String[] args) throws InterruptedException { + JacksonMcpJsonMapper mapper = new JacksonMcpJsonMapper(JsonMapper.builder().build()); + McpSchema.Tool echo = + McpSchema.Tool.builder("echo") + .description("Echo the supplied text.") + .inputSchema( + Map.of( + "type", "object", + "properties", Map.of("text", Map.of("type", "string")), + "required", List.of("text"))) + .build(); + McpSyncServer server = + McpServer.sync(new StdioServerTransportProvider(mapper)) + .serverInfo("temporal-sample-echo", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) + .toolCall( + echo, + (exchange, request) -> + McpSchema.CallToolResult.builder() + .addTextContent((String) request.arguments().get("text")) + .isError(false) + .build()) + .build(); + Runtime.getRuntime().addShutdownHook(new Thread(server::closeGracefully)); + new CountDownLatch(1).await(); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflow.java new file mode 100644 index 00000000..fcc2a77d --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.googleadk.mcp; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface McpWorkflow { + @WorkflowMethod + String echo(String message); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflowImpl.java new file mode 100644 index 00000000..74daa71f --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/McpWorkflowImpl.java @@ -0,0 +1,32 @@ +package io.temporal.samples.googleadk.mcp; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.mcp.TemporalMcpToolset; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.samples.googleadk.AgentSupport; + +public class McpWorkflowImpl implements McpWorkflow { + // @@@SNIPSTART java-google-adk-mcp-workflow + @Override + public String echo(String message) { + LlmAgent agent = + LlmAgent.builder() + .name("echo_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Call echo with the user's message, then return the echoed text.") + .tools(TemporalMcpToolset.newBuilder("echo").cacheTools(true).build()) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("echo").agent(agent).build(); + return AgentSupport.lastText( + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(message)))); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflow.java new file mode 100644 index 00000000..ebacd2fa --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflow.java @@ -0,0 +1,11 @@ +package io.temporal.samples.googleadk.multiagent; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.List; + +@WorkflowInterface +public interface MultiAgentWorkflow { + @WorkflowMethod + List plan(String question); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflowImpl.java new file mode 100644 index 00000000..033b0251 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/MultiAgentWorkflowImpl.java @@ -0,0 +1,46 @@ +package io.temporal.samples.googleadk.multiagent; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.ParallelAgent; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.samples.googleadk.AgentSupport; +import java.util.List; + +public class MultiAgentWorkflowImpl implements MultiAgentWorkflow { + // @@@SNIPSTART java-google-adk-multiagent-workflow + @Override + public List plan(String question) { + LlmAgent planner = + LlmAgent.builder() + .name("planner") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Propose a short travel itinerary for the user's destination.") + .build(); + LlmAgent reviewer = + LlmAgent.builder() + .name("reviewer") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("List practical travel considerations for the user's destination.") + .build(); + ParallelAgent root = + ParallelAgent.builder().name("travel_team").subAgents(planner, reviewer).build(); + // The runner supplies workflow threads for ADK's parallel fan-out. + Runner runner = TemporalAdk.runnerBuilder().appName("travel").agent(root).build(); + return TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(question))) + .stream() + .flatMap(event -> event.content().stream()) + .map(Content::text) + .filter(text -> text != null && !text.isEmpty()) + .sorted() + .toList(); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflow.java new file mode 100644 index 00000000..e5a90236 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.googleadk.tools; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface ToolsWorkflow { + @WorkflowMethod + String ask(String question); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java new file mode 100644 index 00000000..ffbb8e5d --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java @@ -0,0 +1,43 @@ +package io.temporal.samples.googleadk.tools; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.googleadk.tool.ActivityTools; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class ToolsWorkflowImpl implements ToolsWorkflow { + // @@@SNIPSTART java-google-adk-tools-workflow + @Override + public String ask(String question) { + WeatherActivities weather = + Workflow.newActivityStub( + WeatherActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + LlmAgent agent = + LlmAgent.builder() + .name("weather_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Use getWeather to answer questions about the weather.") + .tools(ActivityTools.create(weather, "getWeather")) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("tools").agent(agent).build(); + return AgentSupport.lastText( + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(question)))); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivities.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivities.java new file mode 100644 index 00000000..8716a1df --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivities.java @@ -0,0 +1,15 @@ +package io.temporal.samples.googleadk.tools; + +import com.google.adk.tools.Annotations.Schema; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import java.util.Map; + +// @@@SNIPSTART java-google-adk-weather-activity +@ActivityInterface +public interface WeatherActivities { + @ActivityMethod(name = "GetWeather") + Map getWeather( + @Schema(name = "city", description = "The city to look up") String city); +} +// @@@SNIPEND diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java new file mode 100644 index 00000000..1bce34df --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java @@ -0,0 +1,11 @@ +package io.temporal.samples.googleadk.tools; + +import java.util.Map; + +public class WeatherActivitiesImpl implements WeatherActivities { + @Override + public Map getWeather(String city) { + // Replace this sample response with an HTTP call to your weather provider. + return Map.of("city", city, "conditions", "sunny", "temperatureF", 72); + } +} diff --git a/googleadk/src/main/resources/logback.xml b/googleadk/src/main/resources/logback.xml new file mode 100644 index 00000000..825d6ab1 --- /dev/null +++ b/googleadk/src/main/resources/logback.xml @@ -0,0 +1,8 @@ + + + + System.err + %d{HH:mm:ss} %-5level %logger{36} - %msg%n + + + diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java new file mode 100644 index 00000000..a7edeb79 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java @@ -0,0 +1,106 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.adk.models.LlmRequest; +import com.google.genai.types.FunctionResponse; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.googleadk.GoogleAdkPlugin; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.mcp.EchoMcpServer; +import io.temporal.testing.TestEnvironmentOptions; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BooleanSupplier; + +/** Tests use the published scripted model and an in-process Temporal Service. */ +final class AdkTestEnvironment implements AutoCloseable { + final TestWorkflowEnvironment environment; + final WorkflowClient client; + final Worker worker; + private final ScriptedModel model; + + AdkTestEnvironment(ScriptedModel model, Class... workflows) { + this.model = model; + GoogleAdkPlugin plugin = + GoogleAdkPlugin.newBuilder() + .model(AgentSupport.MODEL, model) + .mcpToolset("echo", EchoMcpServer::toolset) + .build(); + environment = newEnvironment(plugin); + client = environment.getWorkflowClient(); + worker = environment.newWorker(AgentSupport.TASK_QUEUE); + worker.registerWorkflowImplementationTypes(workflows); + } + + private static TestWorkflowEnvironment newEnvironment(GoogleAdkPlugin plugin) { + return TestWorkflowEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions(WorkflowClientOptions.newBuilder().setPlugins(plugin).build()) + .build()); + } + + T workflow(Class type) { + return client.newWorkflowStub( + type, + WorkflowOptions.newBuilder() + .setTaskQueue(AgentSupport.TASK_QUEUE) + .setWorkflowExecutionTimeout(Duration.ofMinutes(2)) + .build()); + } + + WorkflowExecutionHistory history(Object workflow) { + return client.fetchHistory(WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId()); + } + + void replay(WorkflowExecutionHistory history, Class implementation) throws Exception { + int requests = model.requests().size(); + // A new Worker proves replay works without cached Workflow or ADK session state. + GoogleAdkPlugin plugin = GoogleAdkPlugin.newBuilder().model(AgentSupport.MODEL, model).build(); + try (TestWorkflowEnvironment replay = newEnvironment(plugin)) { + Worker replayWorker = replay.newWorker("replay"); + replayWorker.registerWorkflowImplementationTypes(implementation); + replayWorker.replayWorkflowExecution(history); + } + assertEquals(requests, model.requests().size(), "replay must not call the model again"); + } + + static List activities(WorkflowExecutionHistory history) { + return history.getEvents().stream() + .filter(HistoryEvent::hasActivityTaskScheduledEventAttributes) + .map(event -> event.getActivityTaskScheduledEventAttributes().getActivityType().getName()) + .toList(); + } + + static Optional> 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); + } + + static void await(BooleanSupplier ready) throws InterruptedException { + for (int i = 0; i < 1000 && !ready.getAsBoolean(); i++) { + Thread.sleep(10); + } + assertTrue(ready.getAsBoolean(), "the Workflow did not reach the expected state"); + } + + @Override + public void close() { + environment.close(); + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java new file mode 100644 index 00000000..c634318b --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java @@ -0,0 +1,81 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; +import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflowImpl; +import io.temporal.samples.googleadk.humanintheloop.OrderActivities; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Timeout(30) +class ApprovalWorkflowTest { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void orderShipsOnlyAfterConfirmation(boolean confirmed) throws Exception { + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "shipOrder") + .map( + response -> { + if (response.containsKey("error")) { + return ScriptedModel.text( + String.valueOf(response.get("error")).contains("rejected") + ? "Order rejected." + : "Please approve the shipment."); + } + assertEquals("demo-order", response.get("orderId")); + return ScriptedModel.text("Order shipped."); + }) + .orElseGet( + () -> + ScriptedModel.functionCall( + "shipOrder", Map.of("orderId", "demo-order")))); + AtomicInteger shipments = new AtomicInteger(); + OrderActivities orders = + order -> { + shipments.incrementAndGet(); + return Map.of("orderId", order, "status", "shipped"); + }; + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ApprovalWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations(orders); + test.environment.start(); + ApprovalWorkflow workflow = test.workflow(ApprovalWorkflow.class); + WorkflowClient.start(workflow::fulfill, "Ship demo-order"); + AdkTestEnvironment.await(() -> !workflow.pendingConfirmations().isEmpty()); + + assertEquals("shipOrder", workflow.pendingConfirmations().get(0).toolName()); + assertEquals( + "demo-order", + workflow + .pendingConfirmations() + .get(0) + .originalFunctionCall() + .args() + .orElseThrow() + .get("orderId")); + assertEquals(0, shipments.get()); + assertFalse(AdkTestEnvironment.activities(test.history(workflow)).contains("ShipOrder")); + + workflow.decide(confirmed); + assertEquals( + confirmed ? "Order shipped." : "Order rejected.", + WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(confirmed ? 1 : 0, shipments.get()); + WorkflowExecutionHistory history = test.history(workflow); + assertEquals(confirmed, AdkTestEnvironment.activities(history).contains("ShipOrder")); + test.replay(history, ApprovalWorkflowImpl.class); + assertEquals(confirmed ? 1 : 0, shipments.get()); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java new file mode 100644 index 00000000..5f127856 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java @@ -0,0 +1,30 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.basic.BasicWorkflow; +import io.temporal.samples.googleadk.basic.BasicWorkflowImpl; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class BasicWorkflowTest { + @Test + void modelCallIsAnActivityAndReplaysWithoutCallingTheModel() throws Exception { + // @@@SNIPSTART java-google-adk-basic-test + ScriptedModel model = + ScriptedModel.of(AgentSupport.MODEL, ScriptedModel.text("Hello from ADK")); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, BasicWorkflowImpl.class)) { + test.environment.start(); + BasicWorkflow workflow = test.workflow(BasicWorkflow.class); + + assertEquals("Hello from ADK", workflow.ask("Hello")); + assertEquals( + List.of("googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), BasicWorkflowImpl.class); + } + // @@@SNIPEND + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java new file mode 100644 index 00000000..a2f628cf --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java @@ -0,0 +1,80 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.chat.ChatInput; +import io.temporal.samples.googleadk.chat.ChatWorkflow; +import io.temporal.samples.googleadk.chat.ChatWorkflowImpl; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class ChatWorkflowTest { + private static ScriptedModel countingModel() { + return ScriptedModel.responding( + AgentSupport.MODEL, + request -> + ScriptedModel.text( + "turns=" + + request.contents().stream() + .filter(content -> content.role().orElse("").equals("user")) + .count())); + } + + @Test + void conversationAndQueuedSignalsSurviveContinueAsNew() throws Exception { + ScriptedModel model = countingModel(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + WorkflowExecution firstRun = + WorkflowClient.start( + workflow::chat, + new ChatInput(null, List.of("one", "two", "three", "four"), false, "")); + AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=4")); + workflow.message("five"); + AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=5")); + workflow.finish(); + + assertEquals("turns=5", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(5, model.requests().size()); + WorkflowExecutionHistory firstHistory = + test.client.fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()); + assertTrue( + firstHistory.getEvents().stream() + .anyMatch( + event -> + event.getEventType() + == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW)); + WorkflowExecutionHistory lastHistory = test.history(workflow); + assertEquals( + firstRun.getRunId(), + lastHistory + .getEvents() + .get(0) + .getWorkflowExecutionStartedEventAttributes() + .getContinuedExecutionRunId()); + test.replay(firstHistory, ChatWorkflowImpl.class); + test.replay(lastHistory, ChatWorkflowImpl.class); + } + } + + @Test + void finishDrainsThePendingMessages() throws Exception { + ScriptedModel model = countingModel(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + assertEquals("turns=2", workflow.chat(new ChatInput(null, List.of("one", "two"), true, ""))); + test.replay(test.history(workflow), ChatWorkflowImpl.class); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/McpWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/McpWorkflowTest.java new file mode 100644 index 00000000..9c485e13 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/McpWorkflowTest.java @@ -0,0 +1,48 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.mcp.McpWorkflow; +import io.temporal.samples.googleadk.mcp.McpWorkflowImpl; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class McpWorkflowTest { + @Test + void realStdioEchoServerIsListedAndCalledThroughActivities() throws Exception { + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "echo") + .map( + response -> { + assertTrue(response.toString().contains("hello MCP")); + return ScriptedModel.text("hello MCP"); + }) + .orElseGet( + () -> { + assertTrue(request.tools().containsKey("echo")); + return ScriptedModel.functionCall("echo", Map.of("text", "hello MCP")); + })); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, McpWorkflowImpl.class)) { + test.environment.start(); + McpWorkflow workflow = test.workflow(McpWorkflow.class); + + assertEquals("hello MCP", workflow.echo("hello MCP")); + assertEquals( + List.of( + "googleadk.ListMcpTools", + "googleadk.InvokeModel", + "googleadk.CallMcpTool", + "googleadk.InvokeModel"), + AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), McpWorkflowImpl.class); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/MultiAgentWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/MultiAgentWorkflowTest.java new file mode 100644 index 00000000..ac2d6410 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/MultiAgentWorkflowTest.java @@ -0,0 +1,51 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; +import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflowImpl; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class MultiAgentWorkflowTest { + @Test + void bothAgentsScheduleTheirModelActivitiesTogetherAndReplay() throws Exception { + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> + ScriptedModel.text( + request.getSystemInstructions().stream() + .anyMatch(text -> text.contains("\"planner\"")) + ? "Visit the lakefront." + : "Bring a warm jacket.")); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, MultiAgentWorkflowImpl.class)) { + test.environment.start(); + MultiAgentWorkflow workflow = test.workflow(MultiAgentWorkflow.class); + + assertEquals( + List.of("Bring a warm jacket.", "Visit the lakefront."), workflow.plan("Chicago")); + WorkflowExecutionHistory history = test.history(workflow); + List activityOrder = + history.getEvents().stream() + .map(HistoryEvent::getEventType) + .filter( + type -> + type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED + || type == EventType.EVENT_TYPE_ACTIVITY_TASK_COMPLETED) + .toList(); + assertEquals( + List.of( + EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED, + EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED), + activityOrder.subList(0, 2)); + test.replay(history, MultiAgentWorkflowImpl.class); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java new file mode 100644 index 00000000..032b9d8a --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java @@ -0,0 +1,56 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.failure.ApplicationFailure; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.tools.ToolsWorkflow; +import io.temporal.samples.googleadk.tools.ToolsWorkflowImpl; +import io.temporal.samples.googleadk.tools.WeatherActivities; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class ToolsWorkflowTest { + @Test + void weatherToolRetriesAsAnActivityAndItsResultReachesTheModel() throws Exception { + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "getWeather") + .map( + response -> { + assertEquals("Chicago", response.get("city")); + assertEquals("sunny", response.get("conditions")); + return ScriptedModel.text("Chicago is sunny."); + }) + .orElseGet( + () -> ScriptedModel.functionCall("getWeather", Map.of("city", "Chicago")))); + AtomicInteger attempts = new AtomicInteger(); + WeatherActivities weather = + city -> { + if (attempts.incrementAndGet() == 1) { + throw ApplicationFailure.newFailure("Temporary provider failure", "WeatherUnavailable"); + } + return Map.of("city", city, "conditions", "sunny"); + }; + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ToolsWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations(weather); + test.environment.start(); + ToolsWorkflow workflow = test.workflow(ToolsWorkflow.class); + + assertEquals("Chicago is sunny.", workflow.ask("What's the weather in Chicago?")); + assertEquals(2, attempts.get()); + assertEquals(2, model.requests().size()); + assertEquals( + List.of("googleadk.InvokeModel", "GetWeather", "googleadk.InvokeModel"), + AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), ToolsWorkflowImpl.class); + assertEquals(2, attempts.get(), "replay must not repeat the weather call"); + } + } +} diff --git a/settings.gradle b/settings.gradle index e66f00a2..07fa40d9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ rootProject.name = 'temporal-java-samples' include 'core' +include 'googleadk' include 'springai:basic' include 'springai:mcp' include 'springai:multimodel' From 8ac6983d92edb001e4740a3bd8df7b344f39c4ba Mon Sep 17 00:00:00 2001 From: DABH Date: Mon, 26 Oct 2026 15:08:41 -0500 Subject: [PATCH 2/7] Synchronize chat replay test with Continue-As-New --- .../samples/googleadk/ChatWorkflowTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java index a2f628cf..c911099b 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java @@ -39,6 +39,19 @@ void conversationAndQueuedSignalsSurviveContinueAsNew() throws Exception { WorkflowClient.start( workflow::chat, new ChatInput(null, List.of("one", "two", "three", "four"), false, "")); + // Synchronize on the Run transition before querying the next Run, so Queries + // cannot race the test service's Continue-As-New command. + AdkTestEnvironment.await( + () -> + test + .client + .fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()) + .getEvents() + .stream() + .anyMatch( + event -> + event.getEventType() + == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW)); AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=4")); workflow.message("five"); AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=5")); From 8d6c96d73fc03fabee5c3dbd90e31d36d6820072 Mon Sep 17 00:00:00 2001 From: DABH Date: Mon, 26 Oct 2026 22:34:52 -0500 Subject: [PATCH 3/7] Expand Google ADK Java sample coverage and offline execution --- README.md | 11 +- googleadk/README.md | 180 +++++++++--------- googleadk/build.gradle | 1 + .../samples/googleadk/OfflineModel.java | 141 ++++++++++++++ .../samples/googleadk/SampleWorker.java | 76 +++++--- .../samples/googleadk/basic/README.md | 33 ++++ .../temporal/samples/googleadk/chat/README.md | 51 +++++ .../humanintheloop/ApprovalWorkflowImpl.java | 4 +- .../googleadk/humanintheloop/README.md | 42 ++++ .../longrunning/LongRunningWorkflow.java | 18 ++ .../longrunning/LongRunningWorkflowImpl.java | 77 ++++++++ .../samples/googleadk/longrunning/README.md | 46 +++++ .../longrunning/ReportActivities.java | 15 ++ .../longrunning/ReportActivitiesImpl.java | 10 + .../temporal/samples/googleadk/mcp/README.md | 36 ++++ .../samples/googleadk/multiagent/README.md | 35 ++++ .../googleadk/structuredoutput/README.md | 36 ++++ .../StructuredOutputWorkflow.java | 10 + .../StructuredOutputWorkflowImpl.java | 74 +++++++ .../structuredoutput/TravelPlan.java | 17 ++ .../samples/googleadk/tools/README.md | 36 ++++ .../googleadk/tools/TemperatureTools.java | 15 ++ .../googleadk/tools/ToolsWorkflowImpl.java | 9 +- .../tools/WeatherActivitiesImpl.java | 5 +- .../samples/googleadk/AdkTestEnvironment.java | 38 ++-- .../googleadk/ApprovalWorkflowTest.java | 96 ++++++++-- .../googleadk/LongRunningWorkflowTest.java | 93 +++++++++ .../samples/googleadk/OfflineSamplesTest.java | 60 ++++++ .../StructuredOutputWorkflowTest.java | 82 ++++++++ .../samples/googleadk/ToolsWorkflowTest.java | 69 ++++++- 30 files changed, 1236 insertions(+), 180 deletions(-) create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivities.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivitiesImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/mcp/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/TravelPlan.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/tools/TemperatureTools.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/LongRunningWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/StructuredOutputWorkflowTest.java diff --git a/README.md b/README.md index 0307af40..853d88fb 100644 --- a/README.md +++ b/README.md @@ -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; runnable with a local fake or Gemini. * [Lambda Worker](/lambda-worker): demonstrates running a Temporal Java Worker inside AWS Lambda. ## Learn more about Temporal and Java SDK @@ -232,15 +233,15 @@ To run any of the SpringBoot samples in your Temporal Cloud namespace: 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 a basic agent, Activity tools, parallel agents, MCP, human approval, and a -conversation that continues as new. The module requires Temporal Java SDK 1.40.0 or later. +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 -export GOOGLE_API_KEY=your_key -./gradlew :googleadk:worker -./gradlew :googleadk:starter --args='basic' +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +temporal workflow execute --type ToolsWorkflow --task-queue google-adk-samples --input '"What is the weather in Chicago?"' ``` See the [sample README](googleadk/README.md) for the other scenarios and credential-free tests. diff --git a/googleadk/README.md b/googleadk/README.md index 1deaa278..af7edecc 100644 --- a/googleadk/README.md +++ b/googleadk/README.md @@ -1,134 +1,124 @@ # Google ADK on Temporal -Run native [Google ADK Java](https://github.com/google/adk-java) agents in Temporal Workflows using -the standalone [`io.temporal:google-adk:0.1.0`](https://central.sonatype.com/artifact/io.temporal/google-adk) -integration. This integration is **Public Preview**, not Generally Available. APIs and configuration -may change before General Availability. +Run native [Google ADK Java](https://github.com/google/adk-java) agents durably with +[`io.temporal:google-adk:0.1.0`](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, ordinary function -tools, and session state stay in the Workflow. Use `TemporalAdk.runnerBuilder()` to keep ADK's time, -identifiers, and parallel execution deterministic, and `TemporalAdk.run()` to await an agent turn. +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. -- `io.temporal:google-adk:0.1.0`, which brings Google ADK Java 1.12.0. -- Temporal Java SDK and testing library 1.40.0 or later. The module declares a strict `[1.40.0,2.0)` - version range, preferring 1.40.0, independently of the other samples' SDK version. -- For a live run, a local Temporal Service (`temporal server start-dev`) and `GOOGLE_API_KEY` in the - Worker's environment. The Google GenAI client also supports Vertex AI's environment configuration. +- A local Temporal Service: `temporal server start-dev`. +- For live Gemini runs, `GOOGLE_API_KEY` in the Worker's environment, or Vertex AI configuration. -The plugin's version is independent of the SDK version. The plugin does not supply `temporal-sdk`; -the application adds it explicitly. Dependencies come from Maven Central. +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 a sample +## Run offline or with Gemini -Start a local Temporal Service: +Run every command from the repository root. Start the Service in one terminal: ```bash temporal server start-dev ``` -In another terminal, start the shared Worker from the repository root: +Start the Worker in a second terminal, choosing one model mode: ```bash -export GOOGLE_API_KEY=your_key -./gradlew :googleadk:worker +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker ``` -Then run one of these commands in a third terminal: - ```bash -./gradlew :googleadk:starter --args='basic' -./gradlew :googleadk:starter --args='tools' -./gradlew :googleadk:starter --args='multiagent' -./gradlew :googleadk:starter --args='mcp' +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker ``` -The starter prints the agent's answer. All samples share the `google-adk-samples` Task Queue and -start a new Workflow ID for each invocation. Inspect their Activities at `http://localhost:8233`. +`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 shows model and tool Activities. Use a new Workflow ID +when repeating a command that names an ID. + +## Coverage -| Scenario | Source | What it shows | +| Category | Scenario | Durable behavior | | --- | --- | --- | -| Basic | [`basic/`](src/main/java/io/temporal/samples/googleadk/basic) | A native `LlmAgent` with `TemporalModel`; one model call becomes `googleadk.InvokeModel`. | -| Activity tools | [`tools/`](src/main/java/io/temporal/samples/googleadk/tools) | `ActivityTools.create` exposes a weather Activity to the agent. The Activity returns sample weather data; replace its body with provider I/O. | -| Multiple agents | [`multiagent/`](src/main/java/io/temporal/samples/googleadk/multiagent) | A `ParallelAgent` schedules two model Activities concurrently on Workflow threads. | -| MCP | [`mcp/`](src/main/java/io/temporal/samples/googleadk/mcp) | A self-contained Java stdio echo server, started on first use by the Worker. Listing and calling tools become Activities. No Node or separately running MCP service is required. | -| Human approval | [`humanintheloop/`](src/main/java/io/temporal/samples/googleadk/humanintheloop) | An Activity tool waits for a human decision delivered by Signal. The shipping Activity returns sample data and makes no fulfillment request. | -| Conversation | [`chat/`](src/main/java/io/temporal/samples/googleadk/chat) | Signals queue messages, a Query returns the latest reply, and a session snapshot preserves the conversation through Continue-As-New. | - -[`SampleWorker`](src/main/java/io/temporal/samples/googleadk/SampleWorker.java) registers the -`GoogleAdkPlugin` on the Temporal Client, so its Workers inherit the model/MCP Activities and payload -converter. Credentials stay on the Worker. Native GenAI retries are disabled with one HTTP attempt; -Temporal Activity retries handle model failures. Register the plugin on external clients too, as -[`SampleStarter`](src/main/java/io/temporal/samples/googleadk/SampleStarter.java) does. +| Agent Chat | [chat](src/main/java/io/temporal/samples/googleadk/chat) | Signals queue turns; Queries expose replies; a session snapshot and pending messages survive 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 | Version 0.1.0 buffers ADK chunks until its model Activity completes; it has no Workflow Streams publisher or live bidirectional connection. | +| 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 | Replay-safe ADK tracing unsupported | ADK uses global OpenTelemetry providers; this plugin has no replay suppression, so installing a tracing exporter duplicates spans during replay. Temporal history and UI inspection remain available. | + +The unsupported entries describe the published integration's +[documented limits](https://github.com/temporalio/ai-integrations/blob/java/google-adk/v0.1.0/java/google-adk/README.md#limitations), +not an absence of the corresponding feature in ADK itself. + +Additional examples: + +- [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. +- [basic](src/main/java/io/temporal/samples/googleadk/basic) preserves the original single-turn example + and its documentation snippets. Start with Agent Chat for the conversation-oriented entry point. ## Approve or reject a tool call -Start a fulfillment Workflow: +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`. -```bash -./gradlew :googleadk:starter --args='approval' -``` +## Continue a conversation as new + +See [chat](src/main/java/io/temporal/samples/googleadk/chat) for CLI commands. After three turns the +Workflow exports its ADK session and carries queued messages and the latest reply into a new Run. +`finish` drains queued messages 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. -Use the printed Workflow ID in place of `WORKFLOW_ID`. Query the pending tool name and arguments, -then approve or reject the calls from that turn: +## Test without provider credentials ```bash -./gradlew :googleadk:starter --args='pending WORKFLOW_ID' -./gradlew :googleadk:starter --args='decide WORKFLOW_ID true' -# Or reject instead: -./gradlew :googleadk:starter --args='decide WORKFLOW_ID false' -./gradlew :googleadk:starter --args='result WORKFLOW_ID' +./gradlew :googleadk:test :googleadk:spotlessCheck :googleadk:build ``` -`pending` can initially be empty while the model runs; query again once the agent asks for approval. -The decision command sends a Signal; `result` waits for the final answer. The Workflow uses `Workflow.await` while paused, -then supplies `Hitl.confirmationResponse` to the next ADK turn. A rejected call never schedules the -shipping Activity. If the model asks for another confirmation, query and decide again. - -## Continue a conversation as new +Tests use the published `ScriptedModel` or the same `OfflineModel` as the runnable Worker, and +`TestWorkflowEnvironment`. They need no provider credentials, external Temporal Service, or telemetry +backend. The MCP tests launch the bundled local subprocess. -```bash -./gradlew :googleadk:starter --args='chat' -./gradlew :googleadk:starter --args='message WORKFLOW_ID My name is Ada.' -./gradlew :googleadk:starter --args='reply WORKFLOW_ID' -./gradlew :googleadk:starter --args='message WORKFLOW_ID Suggest a trip to Chicago.' -./gradlew :googleadk:starter --args='message WORKFLOW_ID What should I pack?' -./gradlew :googleadk:starter --args='message WORKFLOW_ID What is my name?' -./gradlew :googleadk:starter --args='reply WORKFLOW_ID' -./gradlew :googleadk:starter --args='finish WORKFLOW_ID' -``` +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, and session/queue 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. -`message` queues a turn without waiting for the model. Query again after the model Activity completes -to see its new reply. After three turns, the Workflow exports its ADK session, continues as new, and -imports the session in the next Run. Queued messages and the latest reply travel with the snapshot. -`finish` drains pending messages and returns the final answer. Continue-As-New limits each Run's -history; the snapshot still contains the conversation and must fit within Temporal's payload limits. +The original `:googleadk:starter` task remains available for `basic`, `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. -## Test without provider credentials +## Boundaries -```bash -./gradlew :googleadk:test :googleadk:spotlessCheck -``` +- 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. -The tests use `ScriptedModel` from the published integration and `TestWorkflowEnvironment`; they -need no API keys or external Temporal Service. The MCP test starts the real local Java echo server. -Tests verify Activity retries and results, concurrent model scheduling, approval and rejection, -session snapshots and queued messages across Continue-As-New, and replay on a fresh Worker without -repeating model or tool calls. - -## Limitations - -- Plain ADK function tools run in the Workflow and must be deterministic. Put network, file, and - other I/O in Activities or MCP tools. -- Build Runners with `TemporalAdk.runnerBuilder()`. Do not override agent-level schedulers or - executors, or use RxJava blocking APIs inside a Workflow. -- Streaming chunks arrive after the model Activity completes. Token streaming out of a Workflow - and live bidirectional mode are unsupported. -- Session snapshots preserve session-scoped state and events. They do not carry app-, user-, or - temporary-scoped state. -- ADK OpenTelemetry spans are emitted again during replay when a tracing SDK is installed. - -See the [integration README](https://github.com/temporalio/ai-integrations/tree/main/java/google-adk) +See the [integration reference](https://github.com/temporalio/ai-integrations/tree/main/java/google-adk) for the full API and limitations. diff --git a/googleadk/build.gradle b/googleadk/build.gradle index c2b82001..a5376c21 100644 --- a/googleadk/build.gradle +++ b/googleadk/build.gradle @@ -1,6 +1,7 @@ // ADK samples consume the independently versioned integration from Maven Central. dependencies { implementation 'io.temporal:google-adk:0.1.0' + implementation 'com.fasterxml.jackson.core:jackson-databind' implementation('io.temporal:temporal-sdk') { version { strictly '[1.40.0,2.0[' diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java new file mode 100644 index 00000000..35f574a6 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java @@ -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 generateContent(LlmRequest request, boolean stream) { + return Flowable.just(respond(request)); + } + + private static LlmResponse respond(LlmRequest request) { + if (request.tools().containsKey("getWeather")) { + Optional> weather = functionResponse(request, "getWeather"); + if (weather.isEmpty()) { + return call("getWeather", Map.of("city", "Chicago")); + } + Optional> 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> 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 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 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."); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java index b826366d..10ce20c8 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java @@ -1,5 +1,6 @@ package io.temporal.samples.googleadk; +import com.google.adk.models.BaseLlm; import com.google.adk.models.Gemini; import com.google.genai.Client; import com.google.genai.types.HttpOptions; @@ -11,52 +12,50 @@ import io.temporal.samples.googleadk.chat.ChatWorkflowImpl; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflowImpl; import io.temporal.samples.googleadk.humanintheloop.OrderActivitiesImpl; +import io.temporal.samples.googleadk.longrunning.LongRunningWorkflowImpl; +import io.temporal.samples.googleadk.longrunning.ReportActivitiesImpl; import io.temporal.samples.googleadk.mcp.EchoMcpServer; import io.temporal.samples.googleadk.mcp.McpWorkflowImpl; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflowImpl; +import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflowImpl; import io.temporal.samples.googleadk.tools.ToolsWorkflowImpl; import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import java.util.function.Supplier; -/** One worker hosts all six samples; real model credentials are used only here. */ public final class SampleWorker { private SampleWorker() {} public static void main(String[] args) { + String mode = System.getenv().getOrDefault("ADK_MODEL_MODE", "live"); + if (!mode.equals("offline") && !mode.equals("live")) { + throw new IllegalArgumentException("ADK_MODEL_MODE must be offline or live."); + } // @@@SNIPSTART java-google-adk-worker - // Client reads GOOGLE_API_KEY or the Vertex AI environment variables on the worker. - // One HTTP attempt disables native retries so Temporal Activities own retries. Client modelClient = - Client.builder() - .httpOptions( - HttpOptions.builder() - .retryOptions(HttpRetryOptions.builder().attempts(1).build()) - .build()) - .build(); + mode.equals("live") + ? Client.builder() + .httpOptions( + HttpOptions.builder() + .retryOptions(HttpRetryOptions.builder().attempts(1).build()) + .build()) + .build() + : null; GoogleAdkPlugin plugin = - GoogleAdkPlugin.newBuilder() - .model( - AgentSupport.MODEL, - () -> Gemini.builder().modelName(AgentSupport.MODEL).apiClient(modelClient).build()) - .mcpToolset("echo", EchoMcpServer::toolset) - .build(); + plugin( + modelClient == null + ? OfflineModel::new + : () -> + Gemini.builder().modelName(AgentSupport.MODEL).apiClient(modelClient).build()); WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); WorkflowClient client = WorkflowClient.newInstance( service, WorkflowClientOptions.newBuilder().setPlugins(plugin).build()); WorkerFactory factory = WorkerFactory.newInstance(client); Worker worker = factory.newWorker(AgentSupport.TASK_QUEUE); - worker.registerWorkflowImplementationTypes( - BasicWorkflowImpl.class, - ToolsWorkflowImpl.class, - MultiAgentWorkflowImpl.class, - McpWorkflowImpl.class, - ApprovalWorkflowImpl.class, - ChatWorkflowImpl.class); - worker.registerActivitiesImplementations( - new WeatherActivitiesImpl(), new OrderActivitiesImpl()); + register(worker); factory.start(); // @@@SNIPEND Runtime.getRuntime() @@ -65,8 +64,35 @@ public static void main(String[] args) { () -> { factory.shutdown(); factory.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS); - modelClient.close(); + if (modelClient != null) { + modelClient.close(); + } service.shutdown(); })); + System.out.println("Google ADK Worker: " + mode + " model, queue " + AgentSupport.TASK_QUEUE); + } + + // @@@SNIPSTART java-google-adk-worker-plugin + public static GoogleAdkPlugin plugin(Supplier models) { + return GoogleAdkPlugin.newBuilder() + .model(AgentSupport.MODEL, models) + .mcpToolset("echo", EchoMcpServer::toolset) + .build(); + } + + // @@@SNIPEND + + public static void register(Worker worker) { + worker.registerWorkflowImplementationTypes( + BasicWorkflowImpl.class, + ToolsWorkflowImpl.class, + MultiAgentWorkflowImpl.class, + McpWorkflowImpl.class, + ApprovalWorkflowImpl.class, + ChatWorkflowImpl.class, + StructuredOutputWorkflowImpl.class, + LongRunningWorkflowImpl.class); + worker.registerActivitiesImplementations( + new WeatherActivitiesImpl(), new OrderActivitiesImpl(), new ReportActivitiesImpl()); } } diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md new file mode 100644 index 00000000..fea9b919 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md @@ -0,0 +1,33 @@ +# Single-turn agent + +One ADK turn schedules a `googleadk.InvokeModel` Activity. The existing example and snippet IDs are +retained for documentation; [Agent Chat](../chat) is the entry point for conversations. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow execute --type BasicWorkflow --task-queue google-adk-samples --input '"Hello"' +``` + +Offline mode returns `turns=1; latest: Hello`; live mode returns Gemini's response. The UI shows one +model Activity. Replaying its history reads the recorded response without another provider call. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.BasicWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md new file mode 100644 index 00000000..f8098ad7 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md @@ -0,0 +1,51 @@ +# Agent Chat + +Hold a multi-turn ADK conversation using Signals and a Query. After three turns, export the session +and Continue-As-New with queued messages and the latest reply. `finish` drains the queue. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow start --workflow-id adk-chat --type ChatWorkflow --task-queue google-adk-samples --input '{"session":null,"messages":[],"finished":false,"lastReply":""}' +temporal workflow signal --workflow-id adk-chat --name message --input '"My name is Ada."' +temporal workflow query --workflow-id adk-chat --name lastReply +temporal workflow signal --workflow-id adk-chat --name message --input '"Plan a trip to Chicago."' +temporal workflow signal --workflow-id adk-chat --name message --input '"What should I pack?"' +temporal workflow query --workflow-id adk-chat --name lastReply +``` + +Repeat the Query until the third reply appears. In offline mode it starts with `turns=3;`. Then +continue the conversation in the next Run: + +```bash +temporal workflow signal --workflow-id adk-chat --name message --input '"What is my name?"' +temporal workflow signal --workflow-id adk-chat --name finish +temporal workflow result --workflow-id adk-chat +``` + +Queries may initially return an empty or previous reply while the model Activity runs. In offline +mode replies show the number of user turns and latest input. Wait for the third reply before sending +the fourth message to observe Continue-As-New; it returns `turns=4; latest: What is my name?`. +Live Gemini also sees the imported conversation and can recall Ada's name. The tests check queued +messages across a Run transition and replay both histories. A session snapshot still grows with the +conversation, so bound or compact real conversations before they reach payload limits. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.ChatWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java index 50b2f78d..3026749f 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/ApprovalWorkflowImpl.java @@ -70,6 +70,8 @@ public List pendingConfirmations() { @Override public void decide(boolean confirmed) { - decision = confirmed; + if (!pending.isEmpty() && decision == null) { + decision = confirmed; + } } } diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/README.md new file mode 100644 index 00000000..e25609da --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/humanintheloop/README.md @@ -0,0 +1,42 @@ +# Human approval + +ADK's `requireConfirmation` pauses a shipping tool before its Activity runs. The Workflow exposes +pending calls through a Query and waits durably for a decision Signal. Shipping returns sample data. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow start --workflow-id adk-approval --type ApprovalWorkflow --task-queue google-adk-samples --input '"Ship order demo-order, after confirmation."' +temporal workflow query --workflow-id adk-approval --name pendingConfirmations +temporal workflow signal --workflow-id adk-approval --name decide --input true +temporal workflow result --workflow-id adk-approval +``` + +Query again if the pending list is initially empty. Send `decide` only after a request appears; +use `--input false` on a fresh execution to reject instead. Offline results are `Order shipped.` or +`Order rejected.`. Live Gemini phrases the result. A rejection schedules no `ShipOrder` Activity. +A Signal decides the current pending batch; premature Signals are ignored and the first decision wins. If live +Gemini requests another confirmation, query and decide again. + +To cancel while waiting, run `temporal workflow cancel --workflow-id adk-approval` instead of deciding. +The Workflow cancels without shipping. Tests cover approval, rejection, cancellation, and replay. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.ApprovalWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflow.java new file mode 100644 index 00000000..2c374086 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflow.java @@ -0,0 +1,18 @@ +package io.temporal.samples.googleadk.longrunning; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface LongRunningWorkflow { + @WorkflowMethod + String prepare(String request); + + @QueryMethod + String pendingCall(); + + @SignalMethod + void complete(String functionCallId, String result); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflowImpl.java new file mode 100644 index 00000000..50a9dbb2 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/LongRunningWorkflowImpl.java @@ -0,0 +1,77 @@ +package io.temporal.samples.googleadk.longrunning; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.events.Event; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.Part; +import io.temporal.activity.ActivityOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.googleadk.tool.ActivityTools; +import io.temporal.googleadk.tool.Hitl; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +public class LongRunningWorkflowImpl implements LongRunningWorkflow { + private String callId = ""; + private String result; + + // @@@SNIPSTART java-google-adk-longrunning-workflow + @Override + public String prepare(String request) { + ReportActivities reports = + Workflow.newActivityStub( + ReportActivities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build()); + LlmAgent agent = + LlmAgent.builder() + .name("report_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Start exactly one report with startReport. Wait for its final result.") + .tools(ActivityTools.createLongRunning(reports, "startReport")) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("reports").agent(agent).build(); + List events = + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(request))); + List pending = Hitl.longRunningCalls(events); + if (pending.size() != 1) { + throw ApplicationFailure.newNonRetryableFailure( + "Expected exactly one pending report.", "InvalidReportRequest"); + } + FunctionCall call = pending.get(0); + callId = call.id().orElseThrow(); + Workflow.await(() -> result != null); + events = + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Hitl.functionResponse(call, Map.of("status", "done", "result", result))); + callId = ""; + return AgentSupport.lastText(events); + } + + // @@@SNIPEND + + @Override + public String pendingCall() { + return callId; + } + + @Override + public void complete(String functionCallId, String value) { + if (!callId.isEmpty() && callId.equals(functionCallId) && result == null && value != null) { + result = value; + } + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/README.md new file mode 100644 index 00000000..050fd550 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/README.md @@ -0,0 +1,46 @@ +# Long-running tool completion + +An Activity-backed long-running ADK tool submits a report and returns a pending status. The +Workflow waits for a correlated callback Signal, then uses `Hitl.functionResponse` to resume the +original call. No polling Activity or open model connection is needed while waiting. + +The Activity simulates submission; it does not run a report service. A real service would save the +Workflow/call correlation and send the same Signal when its work finishes. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow start --workflow-id adk-report --type LongRunningWorkflow --task-queue google-adk-samples --input '"Prepare a sales report."' +temporal workflow query --workflow-id adk-report --name pendingCall +temporal workflow signal --workflow-id adk-report --name complete --input '"CALL_ID"' --input '"Revenue grew 10%."' +temporal workflow result --workflow-id adk-report +``` + +Query until `pendingCall` is nonempty, then replace `CALL_ID` with that value. While waiting, wrong IDs and premature +callbacks are ignored, and the first matching result wins. Offline mode returns `Report complete: Revenue grew 10%.`; +live Gemini summarizes the result. History contains one `StartReport` Activity, with model calls +before submission, after the pending status, and after the final callback. + +To cancel the durable wait, use `temporal workflow cancel --workflow-id adk-report`. This does not +cancel a job already submitted to a real backend; that needs a separate cancellation Activity. +Tests cover wrong-call correlation, cancellation, and replay without another submission. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.LongRunningWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivities.java b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivities.java new file mode 100644 index 00000000..c95bb182 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivities.java @@ -0,0 +1,15 @@ +package io.temporal.samples.googleadk.longrunning; + +import com.google.adk.tools.Annotations.Schema; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import java.util.Map; + +// @@@SNIPSTART java-google-adk-longrunning-activity +@ActivityInterface +public interface ReportActivities { + @ActivityMethod(name = "StartReport") + Map startReport( + @Schema(name = "name", description = "The report to prepare") String name); +} +// @@@SNIPEND diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivitiesImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivitiesImpl.java new file mode 100644 index 00000000..76a64c4e --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/longrunning/ReportActivitiesImpl.java @@ -0,0 +1,10 @@ +package io.temporal.samples.googleadk.longrunning; + +import java.util.Map; + +public class ReportActivitiesImpl implements ReportActivities { + @Override + public Map startReport(String name) { + return Map.of("name", name, "status", "pending"); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/README.md new file mode 100644 index 00000000..3503a280 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/mcp/README.md @@ -0,0 +1,36 @@ +# Stateless MCP over stdio + +The agent lists and calls `echo` on a bundled Java MCP server. The Worker starts the subprocess on +first use; Workflows only reference its registered toolset name. Both protocol operations are +Activities. No Node installation, remote MCP service, or listening port is required. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow execute --type McpWorkflow --task-queue google-adk-samples --input '"Hello MCP"' +``` + +Both modes echo `Hello MCP` after calling the real local server. History contains `ListMcpTools`, +`InvokeModel`, `CallMcpTool`, and a final `InvokeModel`, all under the `googleadk.` prefix. +`cacheTools(true)` avoids listing again during the second model turn. The plugin closes the toolset +and subprocess when the Worker factory shuts down. Tests exercise stdio and replay the history. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.McpWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/README.md new file mode 100644 index 00000000..071c02de --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/multiagent/README.md @@ -0,0 +1,35 @@ +# Parallel agents + +ADK's `ParallelAgent` runs distinct planner and reviewer agents using the scheduler supplied by +`TemporalAdk.runnerBuilder()`. Their model Activities are scheduled concurrently, and the Workflow +returns their answers in a stable order. Do not set an agent-specific scheduler or executor. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow execute --type MultiAgentWorkflow --task-queue google-adk-samples --input '"Chicago"' +``` + +Offline mode returns `Bring a warm jacket.` and `Visit Chicago's lakefront.`. Live Gemini generates +both specialists' answers. The tests verify that both model Activities are scheduled before either +completes and that replay makes no further model calls. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.MultiAgentWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/README.md new file mode 100644 index 00000000..f99dfcd4 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/README.md @@ -0,0 +1,36 @@ +# Structured output + +ADK's `outputSchema` supplies a JSON schema to the model. The Workflow parses the reply into a +`TravelPlan` and validates required values before returning the typed result. Malformed JSON, +fractional days, trailing JSON, and invalid fields fail with the non-retryable `InvalidTravelPlan` +failure instead of repeatedly failing a Workflow Task. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow execute --type StructuredOutputWorkflow --task-queue google-adk-samples --input '"Plan two days in Chicago."' +``` + +Offline mode returns `{"city":"Chicago","days":2,"activities":["Walk the lakefront","Visit a museum"]}`. +Live Gemini supplies its own plan with the same shape. History contains one `googleadk.InvokeModel` +Activity. Tests verify the schema on the worker-side request, typed results, invalid replies, and replay. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.StructuredOutputWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflow.java new file mode 100644 index 00000000..0934bf41 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.googleadk.structuredoutput; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface StructuredOutputWorkflow { + @WorkflowMethod + TravelPlan plan(String request); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflowImpl.java new file mode 100644 index 00000000..7f4db0e0 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/StructuredOutputWorkflowImpl.java @@ -0,0 +1,74 @@ +package io.temporal.samples.googleadk.structuredoutput; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.SchemaUtils; +import com.google.adk.agents.LlmAgent; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import io.temporal.failure.ApplicationFailure; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.samples.googleadk.AgentSupport; +import java.util.List; +import java.util.Map; + +public class StructuredOutputWorkflowImpl implements StructuredOutputWorkflow { + // @@@SNIPSTART java-google-adk-structuredoutput-schema + private static final Schema PLAN_SCHEMA = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "city", Schema.builder().type("STRING").build(), + "days", Schema.builder().type("INTEGER").minimum(1.0).maximum(7.0).build(), + "activities", + Schema.builder() + .type("ARRAY") + .items(Schema.builder().type("STRING").build()) + .minItems(1L) + .build())) + .required(List.of("city", "days", "activities")) + .build(); + + // @@@SNIPEND + + // @@@SNIPSTART java-google-adk-structuredoutput-workflow + @Override + public TravelPlan plan(String request) { + LlmAgent agent = + LlmAgent.builder() + .name("structured_planner") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction( + "Return a travel plan as JSON with city, days (1 through 7), and activities.") + .outputSchema(PLAN_SCHEMA) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("structured_plan").agent(agent).build(); + String json = + AgentSupport.lastText( + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(request)))); + try { + ObjectMapper mapper = + new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + Map output = mapper.readValue(json, new TypeReference<>() {}); + if (output == null) { + throw new IllegalArgumentException("A plan must be a JSON object."); + } + SchemaUtils.validateMapOnSchema(output, PLAN_SCHEMA, false); + return mapper.convertValue(output, TravelPlan.class); + } catch (JsonProcessingException | IllegalArgumentException e) { + throw ApplicationFailure.newNonRetryableFailure( + "The model did not return a valid travel plan.", "InvalidTravelPlan"); + } + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/TravelPlan.java b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/TravelPlan.java new file mode 100644 index 00000000..7a351dd1 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/structuredoutput/TravelPlan.java @@ -0,0 +1,17 @@ +package io.temporal.samples.googleadk.structuredoutput; + +import java.util.List; + +public record TravelPlan(String city, int days, List activities) { + public TravelPlan { + if (city == null || city.isBlank() || days < 1 || days > 7) { + throw new IllegalArgumentException("A plan needs a city and between one and seven days."); + } + if (activities == null + || activities.isEmpty() + || activities.stream().anyMatch(activity -> activity == null || activity.isBlank())) { + throw new IllegalArgumentException("A plan needs at least one nonempty activity."); + } + activities = List.copyOf(activities); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/README.md new file mode 100644 index 00000000..452d29a2 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/README.md @@ -0,0 +1,36 @@ +# Workflow and Activity tools + +The agent calls `getWeather` as a Temporal Activity, then `toFahrenheit` as a deterministic function +on the Workflow thread. The Activity reads a host timestamp and returns illustrative fixed weather; +replace its body with provider I/O for real observations. The conversion needs no Activity. + +Run commands from the repository root. Start `temporal server start-dev` in one terminal. +In another, choose the credential-free offline Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) +for prerequisites and integration limits. + +In a third terminal: + +```bash +temporal workflow execute --type ToolsWorkflow --task-queue google-adk-samples --input '"What is the weather in Chicago? Give Celsius and Fahrenheit."' +``` + +Offline mode returns `Chicago is sunny: 20 C / 68.0 F.`. Live Gemini uses the same tools. History shows +three `googleadk.InvokeModel` Activities and one `GetWeather`, with no Activity for `toFahrenheit`. +The tests inject a transient weather failure, verify its retry and both tool results, then replay +without repeating the observation. + +Test without provider credentials or an external Service: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.ToolsWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/TemperatureTools.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/TemperatureTools.java new file mode 100644 index 00000000..9e972573 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/TemperatureTools.java @@ -0,0 +1,15 @@ +package io.temporal.samples.googleadk.tools; + +import com.google.adk.tools.Annotations.Schema; +import java.util.Map; + +public final class TemperatureTools { + private TemperatureTools() {} + + // @@@SNIPSTART java-google-adk-tools-function + public static Map toFahrenheit( + @Schema(name = "celsius", description = "Temperature in degrees Celsius") double celsius) { + return Map.of("fahrenheit", celsius * 9 / 5 + 32); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java index ffbb8e5d..9a7e9fae 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/ToolsWorkflowImpl.java @@ -2,6 +2,7 @@ import com.google.adk.agents.LlmAgent; import com.google.adk.runner.Runner; +import com.google.adk.tools.FunctionTool; import com.google.genai.types.Content; import com.google.genai.types.Part; import io.temporal.activity.ActivityOptions; @@ -28,8 +29,12 @@ public String ask(String question) { LlmAgent.builder() .name("weather_assistant") .model(TemporalModel.create(AgentSupport.MODEL)) - .instruction("Use getWeather to answer questions about the weather.") - .tools(ActivityTools.create(weather, "getWeather")) + .instruction( + "Use getWeather for conditions and Celsius temperature, then toFahrenheit to" + + " convert the temperature. Report the conditions and both temperatures.") + .tools( + ActivityTools.create(weather, "getWeather"), + FunctionTool.create(TemperatureTools.class, "toFahrenheit")) .build(); Runner runner = TemporalAdk.runnerBuilder().appName("tools").agent(agent).build(); return AgentSupport.lastText( diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java index 1bce34df..d556ba41 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/tools/WeatherActivitiesImpl.java @@ -1,11 +1,12 @@ package io.temporal.samples.googleadk.tools; +import java.time.Instant; import java.util.Map; public class WeatherActivitiesImpl implements WeatherActivities { @Override public Map getWeather(String city) { - // Replace this sample response with an HTTP call to your weather provider. - return Map.of("city", city, "conditions", "sunny", "temperatureF", 72); + return Map.of( + "city", city, "conditions", "sunny", "celsius", 20, "observedAt", Instant.now().toString()); } } diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java index a7edeb79..bcaa0c9b 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.adk.models.BaseLlm; import com.google.adk.models.LlmRequest; -import com.google.genai.types.FunctionResponse; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; @@ -13,7 +13,6 @@ import io.temporal.common.WorkflowExecutionHistory; import io.temporal.googleadk.GoogleAdkPlugin; import io.temporal.googleadk.testing.ScriptedModel; -import io.temporal.samples.googleadk.mcp.EchoMcpServer; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.worker.Worker; @@ -21,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.function.BooleanSupplier; /** Tests use the published scripted model and an in-process Temporal Service. */ @@ -28,19 +28,18 @@ final class AdkTestEnvironment implements AutoCloseable { final TestWorkflowEnvironment environment; final WorkflowClient client; final Worker worker; - private final ScriptedModel model; + final String taskQueue = "google-adk-test-" + UUID.randomUUID(); + private final BaseLlm model; - AdkTestEnvironment(ScriptedModel model, Class... workflows) { + AdkTestEnvironment(BaseLlm model, Class... workflows) { this.model = model; - GoogleAdkPlugin plugin = - GoogleAdkPlugin.newBuilder() - .model(AgentSupport.MODEL, model) - .mcpToolset("echo", EchoMcpServer::toolset) - .build(); + GoogleAdkPlugin plugin = SampleWorker.plugin(() -> model); environment = newEnvironment(plugin); client = environment.getWorkflowClient(); - worker = environment.newWorker(AgentSupport.TASK_QUEUE); - worker.registerWorkflowImplementationTypes(workflows); + worker = environment.newWorker(taskQueue); + if (workflows.length > 0) { + worker.registerWorkflowImplementationTypes(workflows); + } } private static TestWorkflowEnvironment newEnvironment(GoogleAdkPlugin plugin) { @@ -54,7 +53,8 @@ T workflow(Class type) { return client.newWorkflowStub( type, WorkflowOptions.newBuilder() - .setTaskQueue(AgentSupport.TASK_QUEUE) + .setTaskQueue(taskQueue) + .setWorkflowId("google-adk-test-" + UUID.randomUUID()) .setWorkflowExecutionTimeout(Duration.ofMinutes(2)) .build()); } @@ -64,7 +64,7 @@ WorkflowExecutionHistory history(Object workflow) { } void replay(WorkflowExecutionHistory history, Class implementation) throws Exception { - int requests = model.requests().size(); + int requests = model instanceof ScriptedModel scripted ? scripted.requests().size() : 0; // A new Worker proves replay works without cached Workflow or ADK session state. GoogleAdkPlugin plugin = GoogleAdkPlugin.newBuilder().model(AgentSupport.MODEL, model).build(); try (TestWorkflowEnvironment replay = newEnvironment(plugin)) { @@ -72,7 +72,9 @@ void replay(WorkflowExecutionHistory history, Class implementation) throws Ex replayWorker.registerWorkflowImplementationTypes(implementation); replayWorker.replayWorkflowExecution(history); } - assertEquals(requests, model.requests().size(), "replay must not call the model again"); + if (model instanceof ScriptedModel scripted) { + assertEquals(requests, scripted.requests().size(), "replay must not call the model again"); + } } static List activities(WorkflowExecutionHistory history) { @@ -83,13 +85,7 @@ static List activities(WorkflowExecutionHistory history) { } static Optional> 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); + return OfflineModel.functionResponse(request, name); } static void await(BooleanSupplier ready) throws InterruptedException { diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java index c634318b..dadaed6b 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ApprovalWorkflowTest.java @@ -2,45 +2,52 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowFailedException; import io.temporal.client.WorkflowStub; import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.failure.CanceledFailure; import io.temporal.googleadk.testing.ScriptedModel; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflowImpl; import io.temporal.samples.googleadk.humanintheloop.OrderActivities; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @Timeout(30) class ApprovalWorkflowTest { + private static ScriptedModel model() { + return ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "shipOrder") + .map( + response -> { + if (response.containsKey("error")) { + return ScriptedModel.text( + String.valueOf(response.get("error")).contains("rejected") + ? "Order rejected." + : "Please approve the shipment."); + } + assertEquals("demo-order", response.get("orderId")); + return ScriptedModel.text("Order shipped."); + }) + .orElseGet( + () -> + ScriptedModel.functionCall("shipOrder", Map.of("orderId", "demo-order")))); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void orderShipsOnlyAfterConfirmation(boolean confirmed) throws Exception { - ScriptedModel model = - ScriptedModel.responding( - AgentSupport.MODEL, - request -> - AdkTestEnvironment.functionResponse(request, "shipOrder") - .map( - response -> { - if (response.containsKey("error")) { - return ScriptedModel.text( - String.valueOf(response.get("error")).contains("rejected") - ? "Order rejected." - : "Please approve the shipment."); - } - assertEquals("demo-order", response.get("orderId")); - return ScriptedModel.text("Order shipped."); - }) - .orElseGet( - () -> - ScriptedModel.functionCall( - "shipOrder", Map.of("orderId", "demo-order")))); + ScriptedModel model = model(); AtomicInteger shipments = new AtomicInteger(); OrderActivities orders = order -> { @@ -78,4 +85,53 @@ void orderShipsOnlyAfterConfirmation(boolean confirmed) throws Exception { assertEquals(confirmed ? 1 : 0, shipments.get()); } } + + @Test + void aPrematureDecisionCannotApproveAFutureToolCall() throws Exception { + ScriptedModel model = model(); + AtomicInteger shipments = new AtomicInteger(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ApprovalWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations( + (OrderActivities) + order -> { + shipments.incrementAndGet(); + return Map.of("orderId", order, "status", "shipped"); + }); + test.environment.start(); + ApprovalWorkflow workflow = test.workflow(ApprovalWorkflow.class); + WorkflowStub stub = WorkflowStub.fromTyped(workflow); + stub.signalWithStart("decide", new Object[] {true}, new Object[] {"Ship demo-order"}); + AdkTestEnvironment.await(() -> !workflow.pendingConfirmations().isEmpty()); + workflow.decide(false); + assertEquals("Order rejected.", stub.getResult(String.class)); + assertEquals(0, shipments.get()); + test.replay(test.history(workflow), ApprovalWorkflowImpl.class); + } + } + + @Test + void cancellationWhileAwaitingApprovalNeverShipsTheOrder() throws Exception { + ScriptedModel model = model(); + AtomicInteger shipments = new AtomicInteger(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ApprovalWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations( + (OrderActivities) + order -> { + shipments.incrementAndGet(); + return Map.of("orderId", order, "status", "shipped"); + }); + test.environment.start(); + ApprovalWorkflow workflow = test.workflow(ApprovalWorkflow.class); + WorkflowClient.start(workflow::fulfill, "Ship demo-order"); + AdkTestEnvironment.await(() -> !workflow.pendingConfirmations().isEmpty()); + WorkflowStub stub = WorkflowStub.fromTyped(workflow); + stub.cancel(); + WorkflowFailedException failure = + assertThrows(WorkflowFailedException.class, () -> stub.getResult(String.class)); + assertInstanceOf(CanceledFailure.class, failure.getCause()); + assertEquals(0, shipments.get()); + assertFalse(AdkTestEnvironment.activities(test.history(workflow)).contains("ShipOrder")); + test.replay(test.history(workflow), ApprovalWorkflowImpl.class); + } + } } diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/LongRunningWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/LongRunningWorkflowTest.java new file mode 100644 index 00000000..628f4ab4 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/LongRunningWorkflowTest.java @@ -0,0 +1,93 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowStub; +import io.temporal.failure.CanceledFailure; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.longrunning.LongRunningWorkflow; +import io.temporal.samples.googleadk.longrunning.LongRunningWorkflowImpl; +import io.temporal.samples.googleadk.longrunning.ReportActivities; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class LongRunningWorkflowTest { + private static ScriptedModel model() { + return ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "startReport") + .map( + response -> + ScriptedModel.text( + "done".equals(response.get("status")) + ? "Report complete: " + response.get("result") + : "Report is pending.")) + .orElseGet( + () -> ScriptedModel.functionCall("startReport", Map.of("name", "sales")))); + } + + @Test + void callbackResumesTheOriginalToolCallWithoutSubmittingAnotherJob() throws Exception { + ScriptedModel model = model(); + AtomicInteger submissions = new AtomicInteger(); + ReportActivities reports = + name -> { + submissions.incrementAndGet(); + return Map.of("name", name, "status", "pending"); + }; + try (AdkTestEnvironment test = new AdkTestEnvironment(model, LongRunningWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations(reports); + test.environment.start(); + LongRunningWorkflow workflow = test.workflow(LongRunningWorkflow.class); + WorkflowClient.start(workflow::prepare, "Prepare a sales report."); + AdkTestEnvironment.await(() -> !workflow.pendingCall().isEmpty()); + String callId = workflow.pendingCall(); + test.replay(test.history(workflow), LongRunningWorkflowImpl.class); + workflow.complete("wrong-call", "ignore this"); + assertEquals(callId, workflow.pendingCall()); + workflow.complete(callId, "Revenue grew 10%."); + assertEquals( + "Report complete: Revenue grew 10%.", + WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(1, submissions.get()); + assertEquals( + List.of( + "googleadk.InvokeModel", + "StartReport", + "googleadk.InvokeModel", + "googleadk.InvokeModel"), + AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), LongRunningWorkflowImpl.class); + assertEquals(1, submissions.get()); + } + } + + @Test + void cancellationEndsTheDurableWaitWithoutAnotherModelCall() throws Exception { + ScriptedModel model = model(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, LongRunningWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations( + (ReportActivities) name -> Map.of("name", name, "status", "pending")); + test.environment.start(); + LongRunningWorkflow workflow = test.workflow(LongRunningWorkflow.class); + WorkflowClient.start(workflow::prepare, "Prepare a report."); + AdkTestEnvironment.await(() -> !workflow.pendingCall().isEmpty()); + WorkflowStub stub = WorkflowStub.fromTyped(workflow); + stub.cancel(); + WorkflowFailedException failure = + assertThrows(WorkflowFailedException.class, () -> stub.getResult(String.class)); + assertInstanceOf(CanceledFailure.class, failure.getCause()); + assertEquals(2, model.requests().size()); + test.replay(test.history(workflow), LongRunningWorkflowImpl.class); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java new file mode 100644 index 00000000..48668c9b --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java @@ -0,0 +1,60 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.samples.googleadk.basic.BasicWorkflow; +import io.temporal.samples.googleadk.chat.ChatInput; +import io.temporal.samples.googleadk.chat.ChatWorkflow; +import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; +import io.temporal.samples.googleadk.longrunning.LongRunningWorkflow; +import io.temporal.samples.googleadk.mcp.McpWorkflow; +import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; +import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflow; +import io.temporal.samples.googleadk.structuredoutput.TravelPlan; +import io.temporal.samples.googleadk.tools.ToolsWorkflow; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(60) +class OfflineSamplesTest { + @Test + void everyScenarioRunsWithTheProductionWorkerRegistrationAndOfflineProvider() throws Exception { + try (AdkTestEnvironment test = new AdkTestEnvironment(new OfflineModel())) { + SampleWorker.register(test.worker); + test.environment.start(); + assertTrue(test.workflow(BasicWorkflow.class).ask("Hello").contains("Hello")); + assertEquals( + "Chicago is sunny: 20 C / 68.0 F.", + test.workflow(ToolsWorkflow.class).ask("What's the weather in Chicago?")); + assertEquals( + List.of("Bring a warm jacket.", "Visit Chicago's lakefront."), + test.workflow(MultiAgentWorkflow.class).plan("Chicago")); + assertEquals("Hello MCP", test.workflow(McpWorkflow.class).echo("Hello MCP")); + assertEquals( + new TravelPlan("Chicago", 2, List.of("Walk the lakefront", "Visit a museum")), + test.workflow(StructuredOutputWorkflow.class).plan("Plan two days in Chicago.")); + assertEquals( + "turns=2; latest: two", + test.workflow(ChatWorkflow.class) + .chat(new ChatInput(null, List.of("one", "two"), true, ""))); + + ApprovalWorkflow approval = test.workflow(ApprovalWorkflow.class); + WorkflowClient.start(approval::fulfill, "Ship demo-order."); + AdkTestEnvironment.await(() -> !approval.pendingConfirmations().isEmpty()); + approval.decide(true); + assertEquals("Order shipped.", WorkflowStub.fromTyped(approval).getResult(String.class)); + + LongRunningWorkflow report = test.workflow(LongRunningWorkflow.class); + WorkflowClient.start(report::prepare, "Prepare a sales report."); + AdkTestEnvironment.await(() -> !report.pendingCall().isEmpty()); + report.complete(report.pendingCall(), "Revenue grew 10%."); + assertEquals( + "Report complete: Revenue grew 10%.", + WorkflowStub.fromTyped(report).getResult(String.class)); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/StructuredOutputWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/StructuredOutputWorkflowTest.java new file mode 100644 index 00000000..4a0d7c16 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/StructuredOutputWorkflowTest.java @@ -0,0 +1,82 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.temporal.client.WorkflowFailedException; +import io.temporal.failure.ApplicationFailure; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflow; +import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflowImpl; +import io.temporal.samples.googleadk.structuredoutput.TravelPlan; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Timeout(30) +class StructuredOutputWorkflowTest { + @Test + void schemaReachesTheModelAndTheWorkflowReturnsATypedPlan() throws Exception { + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> { + var config = request.config().orElseThrow(); + assertEquals("application/json", config.responseMimeType().orElseThrow()); + assertEquals( + List.of("city", "days", "activities"), + config.responseSchema().orElseThrow().required().orElseThrow()); + return ScriptedModel.text( + "{\"city\":\"Chicago\",\"days\":2,\"activities\":[\"Walk the lakefront\"]}"); + }); + try (AdkTestEnvironment test = + new AdkTestEnvironment(model, StructuredOutputWorkflowImpl.class)) { + test.environment.start(); + StructuredOutputWorkflow workflow = test.workflow(StructuredOutputWorkflow.class); + assertEquals( + new TravelPlan("Chicago", 2, List.of("Walk the lakefront")), + workflow.plan("Plan two days in Chicago.")); + assertEquals( + List.of("googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), StructuredOutputWorkflowImpl.class); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "not JSON", + "null", + "[]", + "{\"city\":123,\"days\":2,\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":\"2\",\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":8,\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":2,\"activities\":[42]}", + "{\"city\":\"Chicago\",\"days\":2}", + "{\"city\":\"Chicago\",\"days\":0,\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":2,\"activities\":[]}", + "{\"city\":\"Chicago\",\"days\":2.5,\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":null,\"activities\":[\"Walk\"]}", + "{\"city\":\"Chicago\",\"days\":2,\"activities\":[\"Walk\"]} {}" + }) + void invalidModelOutputFailsTheWorkflowInsteadOfRetryingItsTask(String response) + throws Exception { + ScriptedModel model = ScriptedModel.of(AgentSupport.MODEL, ScriptedModel.text(response)); + try (AdkTestEnvironment test = + new AdkTestEnvironment(model, StructuredOutputWorkflowImpl.class)) { + test.environment.start(); + StructuredOutputWorkflow workflow = test.workflow(StructuredOutputWorkflow.class); + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, () -> workflow.plan("Plan two days in Chicago.")); + assertEquals( + "InvalidTravelPlan", + assertInstanceOf(ApplicationFailure.class, failure.getCause()).getType()); + assertEquals(1, model.requests().size()); + test.replay(test.history(workflow), StructuredOutputWorkflowImpl.class); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java index 032b9d8a..6a74806b 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ToolsWorkflowTest.java @@ -1,7 +1,11 @@ package io.temporal.samples.googleadk; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import io.temporal.client.WorkflowFailedException; +import io.temporal.failure.ActivityFailure; import io.temporal.failure.ApplicationFailure; import io.temporal.googleadk.testing.ScriptedModel; import io.temporal.samples.googleadk.tools.ToolsWorkflow; @@ -21,36 +25,83 @@ void weatherToolRetriesAsAnActivityAndItsResultReachesTheModel() throws Exceptio ScriptedModel.responding( AgentSupport.MODEL, request -> - AdkTestEnvironment.functionResponse(request, "getWeather") + AdkTestEnvironment.functionResponse(request, "toFahrenheit") .map( response -> { - assertEquals("Chicago", response.get("city")); - assertEquals("sunny", response.get("conditions")); - return ScriptedModel.text("Chicago is sunny."); + assertEquals(68.0, ((Number) response.get("fahrenheit")).doubleValue()); + return ScriptedModel.text("Chicago is sunny: 20 C / 68 F."); }) .orElseGet( - () -> ScriptedModel.functionCall("getWeather", Map.of("city", "Chicago")))); + () -> + AdkTestEnvironment.functionResponse(request, "getWeather") + .map( + response -> { + assertEquals("Chicago", response.get("city")); + assertEquals("sunny", response.get("conditions")); + return ScriptedModel.functionCall( + "toFahrenheit", + Map.of("celsius", response.get("celsius"))); + }) + .orElseGet( + () -> + ScriptedModel.functionCall( + "getWeather", Map.of("city", "Chicago"))))); AtomicInteger attempts = new AtomicInteger(); WeatherActivities weather = city -> { if (attempts.incrementAndGet() == 1) { throw ApplicationFailure.newFailure("Temporary provider failure", "WeatherUnavailable"); } - return Map.of("city", city, "conditions", "sunny"); + return Map.of("city", city, "conditions", "sunny", "celsius", 20); }; try (AdkTestEnvironment test = new AdkTestEnvironment(model, ToolsWorkflowImpl.class)) { test.worker.registerActivitiesImplementations(weather); test.environment.start(); ToolsWorkflow workflow = test.workflow(ToolsWorkflow.class); - assertEquals("Chicago is sunny.", workflow.ask("What's the weather in Chicago?")); + assertEquals( + "Chicago is sunny: 20 C / 68 F.", workflow.ask("What's the weather in Chicago?")); assertEquals(2, attempts.get()); - assertEquals(2, model.requests().size()); + assertEquals(3, model.requests().size()); assertEquals( - List.of("googleadk.InvokeModel", "GetWeather", "googleadk.InvokeModel"), + List.of( + "googleadk.InvokeModel", + "GetWeather", + "googleadk.InvokeModel", + "googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); test.replay(test.history(workflow), ToolsWorkflowImpl.class); assertEquals(2, attempts.get(), "replay must not repeat the weather call"); } } + + @Test + void aNonRetryableToolFailureFailsTheWorkflowWithoutAnotherModelTurn() throws Exception { + ScriptedModel model = + ScriptedModel.of( + AgentSupport.MODEL, + ScriptedModel.functionCall("getWeather", Map.of("city", "Chicago"))); + AtomicInteger attempts = new AtomicInteger(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ToolsWorkflowImpl.class)) { + test.worker.registerActivitiesImplementations( + (WeatherActivities) + city -> { + attempts.incrementAndGet(); + throw ApplicationFailure.newNonRetryableFailure( + "Weather access denied", "WeatherDenied"); + }); + test.environment.start(); + ToolsWorkflow workflow = test.workflow(ToolsWorkflow.class); + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, () -> workflow.ask("What's the weather in Chicago?")); + ActivityFailure activityFailure = assertInstanceOf(ActivityFailure.class, failure.getCause()); + assertEquals( + "WeatherDenied", + assertInstanceOf(ApplicationFailure.class, activityFailure.getCause()).getType()); + assertEquals(1, attempts.get()); + assertEquals(1, model.requests().size()); + test.replay(test.history(workflow), ToolsWorkflowImpl.class); + } + } } From a4ef743ce4b928a402d52dd833ca8f8b547aa2bf Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 28 Oct 2026 00:14:56 -0500 Subject: [PATCH 4/7] Make ADK chat the entry point and add streaming and tracing samples --- README.md | 6 +- googleadk/README.md | 34 +++++--- googleadk/build.gradle | 7 ++ .../samples/googleadk/OfflineModel.java | 9 ++ .../samples/googleadk/SampleStarter.java | 45 ++++++++-- .../samples/googleadk/SampleWorker.java | 18 +++- .../googleadk/basic/BasicWorkflowImpl.java | 33 -------- .../samples/googleadk/basic/README.md | 33 -------- .../ObservabilityWorkflow.java} | 6 +- .../ObservabilityWorkflowImpl.java | 40 +++++++++ .../samples/googleadk/observability/README.md | 44 ++++++++++ .../ReplayAwareSpanExporter.java | 36 ++++++++ .../observability/SampleTelemetry.java | 54 ++++++++++++ .../googleadk/streaming/ModelChunk.java | 3 + .../samples/googleadk/streaming/README.md | 43 ++++++++++ .../streaming/StreamingWorkflow.java | 15 ++++ .../streaming/StreamingWorkflowImpl.java | 68 +++++++++++++++ .../samples/googleadk/AdkTestEnvironment.java | 15 +++- .../samples/googleadk/BasicWorkflowTest.java | 30 ------- .../samples/googleadk/ChatWorkflowTest.java | 12 ++- .../googleadk/ObservabilityWorkflowTest.java | 79 +++++++++++++++++ .../samples/googleadk/OfflineSamplesTest.java | 9 +- .../googleadk/StreamingWorkflowTest.java | 84 +++++++++++++++++++ 23 files changed, 592 insertions(+), 131 deletions(-) delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md rename googleadk/src/main/java/io/temporal/samples/googleadk/{basic/BasicWorkflow.java => observability/ObservabilityWorkflow.java} (50%) create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java create mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java delete mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java create mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java diff --git a/README.md b/README.md index 853d88fb..7a34db8c 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,13 @@ Run the Worker and a sample in separate terminals after starting `temporal serve ```bash ADK_MODEL_MODE=offline ./gradlew :googleadk:worker -temporal workflow execute --type ToolsWorkflow --task-queue google-adk-samples --input '"What is the weather in Chicago?"' +./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, +query replies, and finish the conversation. + See the [sample README](googleadk/README.md) for the other scenarios and credential-free tests. ### Running Spring AI Samples diff --git a/googleadk/README.md b/googleadk/README.md index af7edecc..9e47de88 100644 --- a/googleadk/README.md +++ b/googleadk/README.md @@ -38,7 +38,8 @@ 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. +Temporal Activities own retries. Stop the Worker before switching modes. Set `ADK_TRACING=console` to export Temporal operation spans; +see the [observability sample](src/main/java/io/temporal/samples/googleadk/observability) for details. 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 @@ -48,6 +49,15 @@ Use a third terminal for the scenario commands below. Every Worker polls `google The Temporal UI at 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 messages, query replies, and finish the conversation. + ## Coverage | Category | Scenario | Durable behavior | @@ -55,25 +65,24 @@ when repeating a command that names an ID. | Agent Chat | [chat](src/main/java/io/temporal/samples/googleadk/chat) | Signals queue turns; Queries expose replies; a session snapshot and pending messages survive 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 | Version 0.1.0 buffers ADK chunks until its model Activity completes; it has no Workflow Streams publisher or live bidirectional connection. | +| Streaming | [streaming](src/main/java/io/temporal/samples/googleadk/streaming) | Publish buffered ADK SSE chunks through Workflow Streams after the model Activity completes; finish with or without a subscriber. | | 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 | Replay-safe ADK tracing unsupported | ADK uses global OpenTelemetry providers; this plugin has no replay suppression, so installing a tracing exporter duplicates spans during replay. Temporal history and UI inspection remain available. | +| Observability | [observability](src/main/java/io/temporal/samples/googleadk/observability) | Export linked Temporal model/tool Activity spans with OpenTelemetry and suppress replayed Workflow spans. Native ADK global tracing remains unsupported for replay-safe use. | -The unsupported entries describe the published integration's -[documented limits](https://github.com/temporalio/ai-integrations/blob/java/google-adk/v0.1.0/java/google-adk/README.md#limitations), -not an absence of the corresponding feature in ADK itself. +The plugin's [documented limits](https://github.com/temporalio/ai-integrations/blob/java/google-adk/v0.1.0/java/google-adk/README.md#limitations) +still apply to live token forwarding, agent-facing code execution, and native ADK global tracing. +The streaming and observability READMEs explain the boundaries the samples exercise. -Additional examples: +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. -- [basic](src/main/java/io/temporal/samples/googleadk/basic) preserves the original single-turn example - and its documentation snippets. Start with Agent Chat for the conversation-oriented entry point. ## Approve or reject a tool call @@ -101,12 +110,13 @@ backend. 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, and session/queue recovery across Continue-As-New. Recorded histories +correlated callback completion, session/queue recovery across Continue-As-New, streamed chunk delivery, +and OpenTelemetry export without replay duplicates. 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 original `:googleadk:starter` task remains available for `basic`, `tools`, `multiagent`, `mcp`, -`approval`, and `chat`, and their message commands. The scenario READMEs use Temporal CLI directly. +The `:googleadk:starter` task starts Agent Chat by default. It also accepts `streaming`, `observability`, +`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. diff --git a/googleadk/build.gradle b/googleadk/build.gradle index a5376c21..40399424 100644 --- a/googleadk/build.gradle +++ b/googleadk/build.gradle @@ -2,6 +2,12 @@ dependencies { implementation 'io.temporal:google-adk:0.1.0' implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'io.temporal:temporal-workflowstreams:1.40.0' + implementation 'io.temporal:temporal-opentracing:1.40.0' + implementation platform('io.opentelemetry:opentelemetry-bom:1.51.0') + implementation 'io.opentelemetry:opentelemetry-sdk' + implementation 'io.opentelemetry:opentelemetry-exporter-logging' + implementation 'io.opentelemetry:opentelemetry-opentracing-shim' implementation('io.temporal:temporal-sdk') { version { strictly '[1.40.0,2.0[' @@ -20,6 +26,7 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'io.opentelemetry:opentelemetry-sdk-testing' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' errorproneJavac 'com.google.errorprone:javac:9+181-r4173-1' diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java index 35f574a6..135445e4 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java @@ -20,6 +20,12 @@ public OfflineModel() { @Override public Flowable generateContent(LlmRequest request, boolean stream) { + if (stream) { + return Flowable.just( + text("Hello ").toBuilder().partial(true).turnComplete(false).build(), + text("from ADK").toBuilder().partial(true).turnComplete(false).build(), + text("Hello from ADK")); + } return Flowable.just(respond(request)); } @@ -29,6 +35,9 @@ private static LlmResponse respond(LlmRequest request) { if (weather.isEmpty()) { return call("getWeather", Map.of("city", "Chicago")); } + if (!request.tools().containsKey("toFahrenheit")) { + return text("Chicago is " + weather.get().get("conditions") + "."); + } Optional> converted = functionResponse(request, "toFahrenheit"); if (converted.isEmpty()) { return call("toFahrenheit", Map.of("celsius", weather.get().get("celsius"))); diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java index 07b63fd5..1ad4dbec 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java @@ -4,14 +4,19 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; import io.temporal.googleadk.GoogleAdkPlugin; -import io.temporal.samples.googleadk.basic.BasicWorkflow; import io.temporal.samples.googleadk.chat.ChatInput; import io.temporal.samples.googleadk.chat.ChatWorkflow; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; import io.temporal.samples.googleadk.mcp.McpWorkflow; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; +import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; +import io.temporal.samples.googleadk.streaming.ModelChunk; +import io.temporal.samples.googleadk.streaming.StreamingWorkflow; import io.temporal.samples.googleadk.tools.ToolsWorkflow; import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.workflowstreams.WorkflowStreamClient; +import io.temporal.workflowstreams.WorkflowStreamItem; +import io.temporal.workflowstreams.WorkflowStreamSubscription; import java.util.Arrays; import java.util.UUID; @@ -21,8 +26,7 @@ private SampleStarter() {} public static void main(String[] args) { if (args.length == 0) { - throw new IllegalArgumentException( - "Choose basic, tools, multiagent, mcp, approval, chat, pending, decide, result, message, reply, or finish."); + args = new String[] {"chat"}; } WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); try { @@ -40,11 +44,34 @@ public static void main(String[] args) { .setWorkflowId(id) .build(); switch (args[0]) { - case "basic": - // @@@SNIPSTART java-google-adk-starter - BasicWorkflow workflow = client.newWorkflowStub(BasicWorkflow.class, options); - System.out.println(workflow.ask("What does Durable Execution mean?")); - // @@@SNIPEND + case "streaming": + StreamingWorkflow streaming = client.newWorkflowStub(StreamingWorkflow.class, options); + WorkflowClient.start(streaming::stream, "Say hello."); + try (WorkflowStreamClient output = WorkflowStreamClient.newInstance(client, id); + WorkflowStreamSubscription subscription = output.topic("model").subscribe(0)) { + for (WorkflowStreamItem item : subscription) { + ModelChunk chunk = + client + .getOptions() + .getDataConverter() + .fromPayload(item.getPayload(), ModelChunk.class, ModelChunk.class); + System.out.println(chunk.kind() + ": " + chunk.text()); + if (chunk.kind().equals("complete")) { + streaming.acknowledge(); + break; + } + } + } + System.out.println( + "Workflow result: " + + io.temporal.client.WorkflowStub.fromTyped(streaming) + .getResult(java.util.List.class)); + break; + case "observability": + System.out.println( + client + .newWorkflowStub(ObservabilityWorkflow.class, options) + .observe("What's the weather in Chicago?")); break; case "tools": System.out.println( @@ -68,9 +95,11 @@ public static void main(String[] args) { System.out.println("Workflow ID: " + id); break; case "chat": + // @@@SNIPSTART java-google-adk-starter ChatWorkflow chat = client.newWorkflowStub(ChatWorkflow.class, options); WorkflowClient.start(chat::chat, ChatInput.empty()); System.out.println("Workflow ID: " + id); + // @@@SNIPEND break; case "pending": requireArgs(args, 2); diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java index 10ce20c8..f2c2864e 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java @@ -8,7 +8,6 @@ import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.googleadk.GoogleAdkPlugin; -import io.temporal.samples.googleadk.basic.BasicWorkflowImpl; import io.temporal.samples.googleadk.chat.ChatWorkflowImpl; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflowImpl; import io.temporal.samples.googleadk.humanintheloop.OrderActivitiesImpl; @@ -17,12 +16,16 @@ import io.temporal.samples.googleadk.mcp.EchoMcpServer; import io.temporal.samples.googleadk.mcp.McpWorkflowImpl; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflowImpl; +import io.temporal.samples.googleadk.observability.ObservabilityWorkflowImpl; +import io.temporal.samples.googleadk.observability.SampleTelemetry; +import io.temporal.samples.googleadk.streaming.StreamingWorkflowImpl; import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflowImpl; import io.temporal.samples.googleadk.tools.ToolsWorkflowImpl; import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; import java.util.function.Supplier; public final class SampleWorker { @@ -53,7 +56,14 @@ public static void main(String[] args) { WorkflowClient client = WorkflowClient.newInstance( service, WorkflowClientOptions.newBuilder().setPlugins(plugin).build()); - WorkerFactory factory = WorkerFactory.newInstance(client); + SampleTelemetry telemetry = + "console".equals(System.getenv("ADK_TRACING")) ? SampleTelemetry.console() : null; + WorkerFactory factory = + WorkerFactory.newInstance( + client, + telemetry == null + ? WorkerFactoryOptions.newBuilder().build() + : telemetry.workerOptions()); Worker worker = factory.newWorker(AgentSupport.TASK_QUEUE); register(worker); factory.start(); @@ -67,6 +77,7 @@ public static void main(String[] args) { if (modelClient != null) { modelClient.close(); } + if (telemetry != null) telemetry.close(); service.shutdown(); })); System.out.println("Google ADK Worker: " + mode + " model, queue " + AgentSupport.TASK_QUEUE); @@ -84,12 +95,13 @@ public static GoogleAdkPlugin plugin(Supplier models) { public static void register(Worker worker) { worker.registerWorkflowImplementationTypes( - BasicWorkflowImpl.class, ToolsWorkflowImpl.class, MultiAgentWorkflowImpl.class, McpWorkflowImpl.class, ApprovalWorkflowImpl.class, ChatWorkflowImpl.class, + StreamingWorkflowImpl.class, + ObservabilityWorkflowImpl.class, StructuredOutputWorkflowImpl.class, LongRunningWorkflowImpl.class); worker.registerActivitiesImplementations( diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java deleted file mode 100644 index 7254f923..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflowImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.temporal.samples.googleadk.basic; - -import com.google.adk.agents.LlmAgent; -import com.google.adk.events.Event; -import com.google.adk.runner.Runner; -import com.google.genai.types.Content; -import com.google.genai.types.Part; -import io.temporal.googleadk.TemporalAdk; -import io.temporal.googleadk.model.TemporalModel; -import io.temporal.samples.googleadk.AgentSupport; -import java.util.List; - -public class BasicWorkflowImpl implements BasicWorkflow { - // @@@SNIPSTART java-google-adk-basic-workflow - @Override - public String ask(String question) { - LlmAgent agent = - LlmAgent.builder() - .name("assistant") - .model(TemporalModel.create(AgentSupport.MODEL)) - .instruction("Answer concisely.") - .build(); - Runner runner = TemporalAdk.runnerBuilder().appName("basic").agent(agent).build(); - List events = - TemporalAdk.run( - runner, - AgentSupport.USER, - AgentSupport.SESSION, - Content.fromParts(Part.fromText(question))); - return AgentSupport.lastText(events); - } - // @@@SNIPEND -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md deleted file mode 100644 index fea9b919..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Single-turn agent - -One ADK turn schedules a `googleadk.InvokeModel` Activity. The existing example and snippet IDs are -retained for documentation; [Agent Chat](../chat) is the entry point for conversations. - -Run commands from the repository root. Start `temporal server start-dev` in one terminal. -In another, choose the credential-free offline Worker or a live Gemini Worker: - -```bash -ADK_MODEL_MODE=offline ./gradlew :googleadk:worker -``` - -```bash -GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker -``` - -Stop the Worker before switching modes. See the [suite README](../../../../../../../../README.md) -for prerequisites and integration limits. - -In a third terminal: - -```bash -temporal workflow execute --type BasicWorkflow --task-queue google-adk-samples --input '"Hello"' -``` - -Offline mode returns `turns=1; latest: Hello`; live mode returns Gemini's response. The UI shows one -model Activity. Replaying its history reads the recorded response without another provider call. - -Test without provider credentials or an external Service: - -```bash -./gradlew :googleadk:test --tests io.temporal.samples.googleadk.BasicWorkflowTest -``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java similarity index 50% rename from googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java rename to googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java index d7929855..9f75ff9a 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/basic/BasicWorkflow.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java @@ -1,10 +1,10 @@ -package io.temporal.samples.googleadk.basic; +package io.temporal.samples.googleadk.observability; import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; @WorkflowInterface -public interface BasicWorkflow { +public interface ObservabilityWorkflow { @WorkflowMethod - String ask(String question); + String observe(String prompt); } diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java new file mode 100644 index 00000000..8bcbc73f --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java @@ -0,0 +1,40 @@ +package io.temporal.samples.googleadk.observability; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.activity.ActivityOptions; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.googleadk.tool.ActivityTools; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.samples.googleadk.tools.WeatherActivities; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class ObservabilityWorkflowImpl implements ObservabilityWorkflow { + // @@@SNIPSTART java-google-adk-observability-workflow + @Override + public String observe(String prompt) { + WeatherActivities weather = + Workflow.newActivityStub( + WeatherActivities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build()); + LlmAgent agent = + LlmAgent.builder() + .name("observed_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Use getWeather and summarize its result.") + .tools(ActivityTools.create(weather, "getWeather")) + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("observability").agent(agent).build(); + return AgentSupport.lastText( + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(prompt)))); + } + // @@@SNIPEND +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md new file mode 100644 index 00000000..f2dd3078 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md @@ -0,0 +1,44 @@ +# OpenTelemetry for ADK Workflows and Activities + +Trace the Temporal Workflow and its model/tool Activities with OpenTelemetry. The example uses the +SDK's tracing interceptor through its OpenTracing bridge, an instance-scoped OpenTelemetry SDK, +and a replay-aware exporter. It exports Temporal operation spans without installing a global ADK +telemetry provider. Native ADK agent-internal spans still lack replay suppression in plugin 0.1.0. + +From the repository root, start `temporal server start-dev` in one terminal. Start a Worker with +console span export in another: + +```bash +ADK_MODEL_MODE=offline ADK_TRACING=console ./gradlew :googleadk:worker +``` + +For live Gemini, stop the offline Worker and restart it with: + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ADK_TRACING=console ./gradlew :googleadk:worker +``` + +In a third terminal: + +```bash +temporal workflow execute --type ObservabilityWorkflow --task-queue google-adk-samples --input '"What is the weather in Chicago?"' +``` + +Offline mode returns `Chicago is sunny.`. The Worker logs a `RunWorkflow:ObservabilityWorkflow` +span, two `RunActivity:googleadk.InvokeModel` spans, and one `RunActivity:GetWeather` span, together +with their scheduling spans. W3C trace context links these operations into one trace. The weather +Activity returns illustrative data and a host timestamp. + +`SampleTelemetry` accepts a standard `SpanExporter`; replace the console exporter to send spans to +an OpenTelemetry backend. Keep its `ReplayAwareSpanExporter` in the `SimpleSpanProcessor` so replay +is checked on the Workflow thread before any asynchronous export. Query spans remain observable. +The exporter suppresses spans ended during replay. As with Temporal Activity execution, a real +Activity retry exports another attempt; traces do not promise exactly-once execution. + +The tests use an in-memory exporter and a scripted model, requiring no keys, telemetry service, or +external Temporal Service. They verify model/tool spans and trace linkage, failed/retried tool +attempts, and replay without additional exported spans: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.ObservabilityWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java new file mode 100644 index 00000000..5e05df3c --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java @@ -0,0 +1,36 @@ +package io.temporal.samples.googleadk.observability; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.temporal.workflow.unsafe.WorkflowUnsafe; +import java.util.Collection; + +public final class ReplayAwareSpanExporter implements SpanExporter { + private final SpanExporter delegate; + + public ReplayAwareSpanExporter(SpanExporter delegate) { + this.delegate = delegate; + } + + // @@@SNIPSTART java-google-adk-observability-exporter + @Override + public CompletableResultCode export(Collection spans) { + if (WorkflowUnsafe.isWorkflowThread() && WorkflowUnsafe.isReplaying()) { + spans = spans.stream().filter(span -> span.getName().startsWith("HandleQuery:")).toList(); + } + return spans.isEmpty() ? CompletableResultCode.ofSuccess() : delegate.export(spans); + } + + // @@@SNIPEND + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java new file mode 100644 index 00000000..bd9e4f7e --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java @@ -0,0 +1,54 @@ +package io.temporal.samples.googleadk.observability; + +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.exporter.logging.LoggingSpanExporter; +import io.opentelemetry.opentracingshim.OpenTracingShim; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.temporal.opentracing.OpenTracingOptions; +import io.temporal.opentracing.OpenTracingSpanContextCodec; +import io.temporal.opentracing.OpenTracingWorkerInterceptor; +import io.temporal.worker.WorkerFactoryOptions; + +public final class SampleTelemetry implements AutoCloseable { + private final OpenTelemetrySdk telemetry; + private final OpenTracingOptions options; + + // @@@SNIPSTART java-google-adk-observability-setup + public SampleTelemetry(SpanExporter exporter) { + telemetry = + OpenTelemetrySdk.builder() + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .setTracerProvider( + SdkTracerProvider.builder() + .addSpanProcessor( + SimpleSpanProcessor.create(new ReplayAwareSpanExporter(exporter))) + .build()) + .build(); + options = + OpenTracingOptions.newBuilder() + .setTracer(OpenTracingShim.createTracerShim(telemetry)) + .setSpanContextCodec(OpenTracingSpanContextCodec.TEXT_MAP_CODEC) + .build(); + } + + public WorkerFactoryOptions workerOptions() { + return WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new OpenTracingWorkerInterceptor(options)) + .build(); + } + + // @@@SNIPEND + + public static SampleTelemetry console() { + return new SampleTelemetry(LoggingSpanExporter.create()); + } + + @Override + public void close() { + telemetry.close(); + } +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java new file mode 100644 index 00000000..3e480d8d --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java @@ -0,0 +1,3 @@ +package io.temporal.samples.googleadk.streaming; + +public record ModelChunk(String kind, String text) {} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md new file mode 100644 index 00000000..496e41e1 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md @@ -0,0 +1,43 @@ +# Buffered ADK streaming through Workflow Streams + +ADK Java supports SSE model responses. Version 0.1.0 of the Temporal plugin collects their chunks +inside one model Activity. This scenario publishes those partial and final events through the +experimental `temporal-workflowstreams` module after the Activity completes. Subscribers receive +ordered chunks at that point; live token forwarding while the provider is running requires a plugin +change. Bidirectional live connections are unsupported through `TemporalModel`. + +From the repository root, start `temporal server start-dev` in one terminal. In another, choose a +credential-free Worker or a live Gemini Worker: + +```bash +ADK_MODEL_MODE=offline ./gradlew :googleadk:worker +``` + +```bash +GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker +``` + +Stop the Worker before switching modes. Start a Workflow and stream subscriber in a third terminal: + +```bash +./gradlew :googleadk:starter --args='streaming' +``` + +Offline mode prints two partial chunks (`Hello ` and `from ADK`), a final `Hello from ADK`, and a +`complete` event. The subscriber acknowledges completion so the Workflow can return. With no +subscriber, the Workflow completes after a ten-second acknowledgement deadline: + +```bash +temporal workflow execute --type StreamingWorkflow --task-queue google-adk-samples --input '"Say hello."' +``` + +Subscribe when starting the Workflow; the stream serves buffered items while the Workflow is open. +The acknowledgement deadline bounds the wait and does not provide a grace period for arbitrary late +subscribers after completion. Model output is recorded by one `googleadk.InvokeModel` Activity and +is not requested again during replay. + +Test ordered partial/final delivery, subscriber completion, absence of a subscriber, and replay: + +```bash +./gradlew :googleadk:test --tests io.temporal.samples.googleadk.StreamingWorkflowTest +``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java new file mode 100644 index 00000000..0eb90b77 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java @@ -0,0 +1,15 @@ +package io.temporal.samples.googleadk.streaming; + +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.List; + +@WorkflowInterface +public interface StreamingWorkflow { + @WorkflowMethod + List stream(String prompt); + + @SignalMethod + void acknowledge(); +} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java new file mode 100644 index 00000000..4af783d0 --- /dev/null +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java @@ -0,0 +1,68 @@ +package io.temporal.samples.googleadk.streaming; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.events.Event; +import com.google.adk.runner.Runner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.temporal.googleadk.TemporalAdk; +import io.temporal.googleadk.model.TemporalModel; +import io.temporal.samples.googleadk.AgentSupport; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import io.temporal.workflowstreams.WorkflowStream; +import java.time.Duration; +import java.util.List; + +public class StreamingWorkflowImpl implements StreamingWorkflow { + private final WorkflowStream output; + private boolean acknowledged; + + @WorkflowInit + public StreamingWorkflowImpl(String prompt) { + output = WorkflowStream.newInstance(null); + } + + // @@@SNIPSTART java-google-adk-streaming-workflow + @Override + public List stream(String prompt) { + LlmAgent agent = + LlmAgent.builder() + .name("streaming_assistant") + .model(TemporalModel.create(AgentSupport.MODEL)) + .instruction("Answer concisely.") + .build(); + Runner runner = TemporalAdk.runnerBuilder().appName("streaming").agent(agent).build(); + List events = + TemporalAdk.run( + runner, + AgentSupport.USER, + AgentSupport.SESSION, + Content.fromParts(Part.fromText(prompt)), + RunConfig.builder() + .streamingMode(RunConfig.StreamingMode.SSE) + .autoCreateSession(true) + .build()); + List chunks = + events.stream() + .filter(event -> event.content().isPresent()) + .map( + event -> + new ModelChunk( + event.partial().orElse(false) ? "partial" : "final", + event.content().map(Content::text).orElse(""))) + .toList(); + for (ModelChunk chunk : chunks) output.topic("model").publish(chunk); + output.topic("model").publish(new ModelChunk("complete", AgentSupport.lastText(events))); + Workflow.await(Duration.ofSeconds(10), () -> acknowledged); + return chunks; + } + + // @@@SNIPEND + + @Override + public void acknowledge() { + acknowledged = true; + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java index bcaa0c9b..01cfce54 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java @@ -16,6 +16,7 @@ import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactoryOptions; import java.time.Duration; import java.util.List; import java.util.Map; @@ -30,11 +31,17 @@ final class AdkTestEnvironment implements AutoCloseable { final Worker worker; final String taskQueue = "google-adk-test-" + UUID.randomUUID(); private final BaseLlm model; + private final WorkerFactoryOptions workerOptions; AdkTestEnvironment(BaseLlm model, Class... workflows) { + this(model, WorkerFactoryOptions.newBuilder().build(), workflows); + } + + AdkTestEnvironment(BaseLlm model, WorkerFactoryOptions workerOptions, Class... workflows) { this.model = model; + this.workerOptions = workerOptions; GoogleAdkPlugin plugin = SampleWorker.plugin(() -> model); - environment = newEnvironment(plugin); + environment = newEnvironment(plugin, workerOptions); client = environment.getWorkflowClient(); worker = environment.newWorker(taskQueue); if (workflows.length > 0) { @@ -42,9 +49,11 @@ final class AdkTestEnvironment implements AutoCloseable { } } - private static TestWorkflowEnvironment newEnvironment(GoogleAdkPlugin plugin) { + private static TestWorkflowEnvironment newEnvironment( + GoogleAdkPlugin plugin, WorkerFactoryOptions workerOptions) { return TestWorkflowEnvironment.newInstance( TestEnvironmentOptions.newBuilder() + .setWorkerFactoryOptions(workerOptions) .setWorkflowClientOptions(WorkflowClientOptions.newBuilder().setPlugins(plugin).build()) .build()); } @@ -67,7 +76,7 @@ void replay(WorkflowExecutionHistory history, Class implementation) throws Ex int requests = model instanceof ScriptedModel scripted ? scripted.requests().size() : 0; // A new Worker proves replay works without cached Workflow or ADK session state. GoogleAdkPlugin plugin = GoogleAdkPlugin.newBuilder().model(AgentSupport.MODEL, model).build(); - try (TestWorkflowEnvironment replay = newEnvironment(plugin)) { + try (TestWorkflowEnvironment replay = newEnvironment(plugin, workerOptions)) { Worker replayWorker = replay.newWorker("replay"); replayWorker.registerWorkflowImplementationTypes(implementation); replayWorker.replayWorkflowExecution(history); diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java deleted file mode 100644 index 5f127856..00000000 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/BasicWorkflowTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.temporal.samples.googleadk; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.temporal.googleadk.testing.ScriptedModel; -import io.temporal.samples.googleadk.basic.BasicWorkflow; -import io.temporal.samples.googleadk.basic.BasicWorkflowImpl; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -@Timeout(30) -class BasicWorkflowTest { - @Test - void modelCallIsAnActivityAndReplaysWithoutCallingTheModel() throws Exception { - // @@@SNIPSTART java-google-adk-basic-test - ScriptedModel model = - ScriptedModel.of(AgentSupport.MODEL, ScriptedModel.text("Hello from ADK")); - try (AdkTestEnvironment test = new AdkTestEnvironment(model, BasicWorkflowImpl.class)) { - test.environment.start(); - BasicWorkflow workflow = test.workflow(BasicWorkflow.class); - - assertEquals("Hello from ADK", workflow.ask("Hello")); - assertEquals( - List.of("googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); - test.replay(test.history(workflow), BasicWorkflowImpl.class); - } - // @@@SNIPEND - } -} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java index c911099b..3af98548 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java @@ -82,12 +82,20 @@ void conversationAndQueuedSignalsSurviveContinueAsNew() throws Exception { @Test void finishDrainsThePendingMessages() throws Exception { - ScriptedModel model = countingModel(); + // @@@SNIPSTART java-google-adk-chat-test + ScriptedModel model = + ScriptedModel.of( + AgentSupport.MODEL, + ScriptedModel.text("First reply"), + ScriptedModel.text("Last reply")); try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { test.environment.start(); ChatWorkflow workflow = test.workflow(ChatWorkflow.class); - assertEquals("turns=2", workflow.chat(new ChatInput(null, List.of("one", "two"), true, ""))); + assertEquals( + "Last reply", workflow.chat(new ChatInput(null, List.of("one", "two"), true, ""))); + assertEquals(2, model.requests().size()); test.replay(test.history(workflow), ChatWorkflowImpl.class); } + // @@@SNIPEND } } diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java new file mode 100644 index 00000000..7eb3add4 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java @@ -0,0 +1,79 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.temporal.failure.ApplicationFailure; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; +import io.temporal.samples.googleadk.observability.ObservabilityWorkflowImpl; +import io.temporal.samples.googleadk.observability.SampleTelemetry; +import io.temporal.samples.googleadk.tools.WeatherActivities; +import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Timeout(30) +class ObservabilityWorkflowTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void modelAndToolSpansShareATraceAndReplayExportsNothing(boolean retryWeather) throws Exception { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> + AdkTestEnvironment.functionResponse(request, "getWeather") + .map(response -> ScriptedModel.text("Chicago is sunny.")) + .orElseGet( + () -> ScriptedModel.functionCall("getWeather", Map.of("city", "Chicago")))); + try (SampleTelemetry telemetry = new SampleTelemetry(exporter); + AdkTestEnvironment test = + new AdkTestEnvironment( + model, telemetry.workerOptions(), ObservabilityWorkflowImpl.class)) { + AtomicInteger attempts = new AtomicInteger(); + test.worker.registerActivitiesImplementations( + (WeatherActivities) + city -> { + if (attempts.incrementAndGet() == 1 && retryWeather) { + throw ApplicationFailure.newFailure( + "Transient weather error", "WeatherUnavailable"); + } + return new WeatherActivitiesImpl().getWeather(city); + }); + test.environment.start(); + ObservabilityWorkflow workflow = test.workflow(ObservabilityWorkflow.class); + assertEquals("Chicago is sunny.", workflow.observe("What's the weather in Chicago?")); + AdkTestEnvironment.await( + () -> + exporter.getFinishedSpanItems().stream() + .anyMatch(span -> span.getName().startsWith("RunWorkflow:"))); + var spans = exporter.getFinishedSpanItems(); + assertEquals( + 2, + spans.stream() + .filter(span -> span.getName().equals("RunActivity:googleadk.InvokeModel")) + .count()); + assertEquals( + retryWeather ? 2 : 1, + spans.stream().filter(span -> span.getName().equals("RunActivity:GetWeather")).count()); + assertEquals( + retryWeather, + spans.stream().anyMatch(span -> span.getStatus().getStatusCode() == StatusCode.ERROR)); + assertTrue( + spans.stream().allMatch(span -> span.getTraceId().equals(spans.get(0).getTraceId()))); + assertEquals(spans.size(), spans.stream().map(SpanData::getSpanId).distinct().count()); + test.replay(test.history(workflow), ObservabilityWorkflowImpl.class); + assertEquals( + spans.size(), + exporter.getFinishedSpanItems().size(), + "replay exports no duplicate spans"); + } + } +} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java index 48668c9b..ba0dcef1 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java @@ -1,17 +1,17 @@ package io.temporal.samples.googleadk; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowStub; -import io.temporal.samples.googleadk.basic.BasicWorkflow; import io.temporal.samples.googleadk.chat.ChatInput; import io.temporal.samples.googleadk.chat.ChatWorkflow; import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; import io.temporal.samples.googleadk.longrunning.LongRunningWorkflow; import io.temporal.samples.googleadk.mcp.McpWorkflow; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; +import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; +import io.temporal.samples.googleadk.streaming.StreamingWorkflow; import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflow; import io.temporal.samples.googleadk.structuredoutput.TravelPlan; import io.temporal.samples.googleadk.tools.ToolsWorkflow; @@ -26,7 +26,10 @@ void everyScenarioRunsWithTheProductionWorkerRegistrationAndOfflineProvider() th try (AdkTestEnvironment test = new AdkTestEnvironment(new OfflineModel())) { SampleWorker.register(test.worker); test.environment.start(); - assertTrue(test.workflow(BasicWorkflow.class).ask("Hello").contains("Hello")); + assertEquals( + "Chicago is sunny.", + test.workflow(ObservabilityWorkflow.class).observe("What's the weather in Chicago?")); + assertEquals(3, test.workflow(StreamingWorkflow.class).stream("Say hello.").size()); assertEquals( "Chicago is sunny: 20 C / 68.0 F.", test.workflow(ToolsWorkflow.class).ask("What's the weather in Chicago?")); diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java new file mode 100644 index 00000000..ac07d8e9 --- /dev/null +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java @@ -0,0 +1,84 @@ +package io.temporal.samples.googleadk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.googleadk.testing.ScriptedModel; +import io.temporal.samples.googleadk.streaming.ModelChunk; +import io.temporal.samples.googleadk.streaming.StreamingWorkflow; +import io.temporal.samples.googleadk.streaming.StreamingWorkflowImpl; +import io.temporal.workflowstreams.WorkflowStreamClient; +import io.temporal.workflowstreams.WorkflowStreamItem; +import io.temporal.workflowstreams.WorkflowStreamSubscription; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(30) +class StreamingWorkflowTest { + private static ScriptedModel model() { + return ScriptedModel.streaming( + AgentSupport.MODEL, + request -> + List.of( + ScriptedModel.partialText("Hel"), + ScriptedModel.partialText("lo"), + ScriptedModel.text("Hello"))); + } + + @Test + void noSubscriberIsRequiredAndTheBufferedChunksReplayInOrder() throws Exception { + ScriptedModel model = model(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, StreamingWorkflowImpl.class)) { + test.environment.start(); + StreamingWorkflow workflow = test.workflow(StreamingWorkflow.class); + assertEquals( + List.of( + new ModelChunk("partial", "Hel"), + new ModelChunk("partial", "lo"), + new ModelChunk("final", "Hello")), + workflow.stream("Hello")); + assertEquals( + List.of("googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); + test.replay(test.history(workflow), StreamingWorkflowImpl.class); + } + } + + @Test + void subscriberReceivesEveryChunkAndAcknowledgesCompletion() throws Exception { + ScriptedModel model = model(); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, StreamingWorkflowImpl.class)) { + test.environment.start(); + StreamingWorkflow workflow = test.workflow(StreamingWorkflow.class); + var execution = WorkflowClient.start(workflow::stream, "Hello"); + List received = new ArrayList<>(); + try (WorkflowStreamClient stream = + WorkflowStreamClient.newInstance(test.client, execution.getWorkflowId()); + WorkflowStreamSubscription subscription = stream.topic("model").subscribe(0)) { + for (WorkflowStreamItem item : subscription) { + ModelChunk chunk = + test.client + .getOptions() + .getDataConverter() + .fromPayload(item.getPayload(), ModelChunk.class, ModelChunk.class); + received.add(chunk); + if (chunk.kind().equals("complete")) { + workflow.acknowledge(); + break; + } + } + } + assertEquals( + List.of( + new ModelChunk("partial", "Hel"), + new ModelChunk("partial", "lo"), + new ModelChunk("final", "Hello"), + new ModelChunk("complete", "Hello")), + received); + assertEquals(3, WorkflowStub.fromTyped(workflow).getResult(List.class).size()); + test.replay(test.history(workflow), StreamingWorkflowImpl.class); + } + } +} From 4fcf4c568bdba580f7a4aeb87f2a4f74166617d5 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 28 Oct 2026 11:43:30 -0500 Subject: [PATCH 5/7] Remove buffered streaming and Temporal tracing samples --- googleadk/README.md | 19 ++--- googleadk/build.gradle | 7 -- .../samples/googleadk/OfflineModel.java | 9 -- .../samples/googleadk/SampleStarter.java | 35 -------- .../samples/googleadk/SampleWorker.java | 16 +--- .../observability/ObservabilityWorkflow.java | 10 --- .../ObservabilityWorkflowImpl.java | 40 --------- .../samples/googleadk/observability/README.md | 44 ---------- .../ReplayAwareSpanExporter.java | 36 -------- .../observability/SampleTelemetry.java | 54 ------------ .../googleadk/streaming/ModelChunk.java | 3 - .../samples/googleadk/streaming/README.md | 43 ---------- .../streaming/StreamingWorkflow.java | 15 ---- .../streaming/StreamingWorkflowImpl.java | 68 --------------- .../samples/googleadk/AdkTestEnvironment.java | 15 +--- .../googleadk/ObservabilityWorkflowTest.java | 79 ----------------- .../samples/googleadk/OfflineSamplesTest.java | 6 -- .../googleadk/StreamingWorkflowTest.java | 84 ------------------- 18 files changed, 12 insertions(+), 571 deletions(-) delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java delete mode 100644 googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java delete mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java delete mode 100644 googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java diff --git a/googleadk/README.md b/googleadk/README.md index 9e47de88..91198f1f 100644 --- a/googleadk/README.md +++ b/googleadk/README.md @@ -38,8 +38,7 @@ 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. Set `ADK_TRACING=console` to export Temporal operation spans; -see the [observability sample](src/main/java/io/temporal/samples/googleadk/observability) for details. +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 @@ -65,16 +64,15 @@ to send messages, query replies, and finish the conversation. | Agent Chat | [chat](src/main/java/io/temporal/samples/googleadk/chat) | Signals queue turns; Queries expose replies; a session snapshot and pending messages survive 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 | [streaming](src/main/java/io/temporal/samples/googleadk/streaming) | Publish buffered ADK SSE chunks through Workflow Streams after the model Activity completes; finish with or without a subscriber. | +| Streaming | Unsupported | Plugin 0.1.0 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 | [observability](src/main/java/io/temporal/samples/googleadk/observability) | Export linked Temporal model/tool Activity spans with OpenTelemetry and suppress replayed Workflow spans. Native ADK global tracing remains unsupported for replay-safe use. | +| 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.0/java/google-adk/README.md#limitations) still apply to live token forwarding, agent-facing code execution, and native ADK global tracing. -The streaming and observability READMEs explain the boundaries the samples exercise. Start with [Agent Chat](src/main/java/io/temporal/samples/googleadk/chat); it is the introductory sample. The suite also includes: @@ -105,18 +103,17 @@ the conversation snapshot itself still grows and must fit Temporal's payload lim ``` Tests use the published `ScriptedModel` or the same `OfflineModel` as the runnable Worker, and -`TestWorkflowEnvironment`. They need no provider credentials, external Temporal Service, or telemetry -backend. The MCP tests launch the bundled local subprocess. +`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, session/queue recovery across Continue-As-New, streamed chunk delivery, -and OpenTelemetry export without replay duplicates. Recorded histories +correlated callback completion, and session/queue 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 `streaming`, `observability`, -`tools`, `multiagent`, `mcp`, `approval`, and `chat`, and their message commands. The scenario READMEs use Temporal CLI directly. +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. diff --git a/googleadk/build.gradle b/googleadk/build.gradle index 40399424..a5376c21 100644 --- a/googleadk/build.gradle +++ b/googleadk/build.gradle @@ -2,12 +2,6 @@ dependencies { implementation 'io.temporal:google-adk:0.1.0' implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'io.temporal:temporal-workflowstreams:1.40.0' - implementation 'io.temporal:temporal-opentracing:1.40.0' - implementation platform('io.opentelemetry:opentelemetry-bom:1.51.0') - implementation 'io.opentelemetry:opentelemetry-sdk' - implementation 'io.opentelemetry:opentelemetry-exporter-logging' - implementation 'io.opentelemetry:opentelemetry-opentracing-shim' implementation('io.temporal:temporal-sdk') { version { strictly '[1.40.0,2.0[' @@ -26,7 +20,6 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'io.opentelemetry:opentelemetry-sdk-testing' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' errorproneJavac 'com.google.errorprone:javac:9+181-r4173-1' diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java index 135445e4..35f574a6 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/OfflineModel.java @@ -20,12 +20,6 @@ public OfflineModel() { @Override public Flowable generateContent(LlmRequest request, boolean stream) { - if (stream) { - return Flowable.just( - text("Hello ").toBuilder().partial(true).turnComplete(false).build(), - text("from ADK").toBuilder().partial(true).turnComplete(false).build(), - text("Hello from ADK")); - } return Flowable.just(respond(request)); } @@ -35,9 +29,6 @@ private static LlmResponse respond(LlmRequest request) { if (weather.isEmpty()) { return call("getWeather", Map.of("city", "Chicago")); } - if (!request.tools().containsKey("toFahrenheit")) { - return text("Chicago is " + weather.get().get("conditions") + "."); - } Optional> converted = functionResponse(request, "toFahrenheit"); if (converted.isEmpty()) { return call("toFahrenheit", Map.of("celsius", weather.get().get("celsius"))); diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java index 1ad4dbec..d5350416 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java @@ -9,14 +9,8 @@ import io.temporal.samples.googleadk.humanintheloop.ApprovalWorkflow; import io.temporal.samples.googleadk.mcp.McpWorkflow; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; -import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; -import io.temporal.samples.googleadk.streaming.ModelChunk; -import io.temporal.samples.googleadk.streaming.StreamingWorkflow; import io.temporal.samples.googleadk.tools.ToolsWorkflow; import io.temporal.serviceclient.WorkflowServiceStubs; -import io.temporal.workflowstreams.WorkflowStreamClient; -import io.temporal.workflowstreams.WorkflowStreamItem; -import io.temporal.workflowstreams.WorkflowStreamSubscription; import java.util.Arrays; import java.util.UUID; @@ -44,35 +38,6 @@ public static void main(String[] args) { .setWorkflowId(id) .build(); switch (args[0]) { - case "streaming": - StreamingWorkflow streaming = client.newWorkflowStub(StreamingWorkflow.class, options); - WorkflowClient.start(streaming::stream, "Say hello."); - try (WorkflowStreamClient output = WorkflowStreamClient.newInstance(client, id); - WorkflowStreamSubscription subscription = output.topic("model").subscribe(0)) { - for (WorkflowStreamItem item : subscription) { - ModelChunk chunk = - client - .getOptions() - .getDataConverter() - .fromPayload(item.getPayload(), ModelChunk.class, ModelChunk.class); - System.out.println(chunk.kind() + ": " + chunk.text()); - if (chunk.kind().equals("complete")) { - streaming.acknowledge(); - break; - } - } - } - System.out.println( - "Workflow result: " - + io.temporal.client.WorkflowStub.fromTyped(streaming) - .getResult(java.util.List.class)); - break; - case "observability": - System.out.println( - client - .newWorkflowStub(ObservabilityWorkflow.class, options) - .observe("What's the weather in Chicago?")); - break; case "tools": System.out.println( client diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java index f2c2864e..7ebfe560 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleWorker.java @@ -16,16 +16,12 @@ import io.temporal.samples.googleadk.mcp.EchoMcpServer; import io.temporal.samples.googleadk.mcp.McpWorkflowImpl; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflowImpl; -import io.temporal.samples.googleadk.observability.ObservabilityWorkflowImpl; -import io.temporal.samples.googleadk.observability.SampleTelemetry; -import io.temporal.samples.googleadk.streaming.StreamingWorkflowImpl; import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflowImpl; import io.temporal.samples.googleadk.tools.ToolsWorkflowImpl; import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; -import io.temporal.worker.WorkerFactoryOptions; import java.util.function.Supplier; public final class SampleWorker { @@ -56,14 +52,7 @@ public static void main(String[] args) { WorkflowClient client = WorkflowClient.newInstance( service, WorkflowClientOptions.newBuilder().setPlugins(plugin).build()); - SampleTelemetry telemetry = - "console".equals(System.getenv("ADK_TRACING")) ? SampleTelemetry.console() : null; - WorkerFactory factory = - WorkerFactory.newInstance( - client, - telemetry == null - ? WorkerFactoryOptions.newBuilder().build() - : telemetry.workerOptions()); + WorkerFactory factory = WorkerFactory.newInstance(client); Worker worker = factory.newWorker(AgentSupport.TASK_QUEUE); register(worker); factory.start(); @@ -77,7 +66,6 @@ public static void main(String[] args) { if (modelClient != null) { modelClient.close(); } - if (telemetry != null) telemetry.close(); service.shutdown(); })); System.out.println("Google ADK Worker: " + mode + " model, queue " + AgentSupport.TASK_QUEUE); @@ -100,8 +88,6 @@ public static void register(Worker worker) { McpWorkflowImpl.class, ApprovalWorkflowImpl.class, ChatWorkflowImpl.class, - StreamingWorkflowImpl.class, - ObservabilityWorkflowImpl.class, StructuredOutputWorkflowImpl.class, LongRunningWorkflowImpl.class); worker.registerActivitiesImplementations( diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java deleted file mode 100644 index 9f75ff9a..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflow.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.temporal.samples.googleadk.observability; - -import io.temporal.workflow.WorkflowInterface; -import io.temporal.workflow.WorkflowMethod; - -@WorkflowInterface -public interface ObservabilityWorkflow { - @WorkflowMethod - String observe(String prompt); -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java deleted file mode 100644 index 8bcbc73f..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ObservabilityWorkflowImpl.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.temporal.samples.googleadk.observability; - -import com.google.adk.agents.LlmAgent; -import com.google.adk.runner.Runner; -import com.google.genai.types.Content; -import com.google.genai.types.Part; -import io.temporal.activity.ActivityOptions; -import io.temporal.googleadk.TemporalAdk; -import io.temporal.googleadk.model.TemporalModel; -import io.temporal.googleadk.tool.ActivityTools; -import io.temporal.samples.googleadk.AgentSupport; -import io.temporal.samples.googleadk.tools.WeatherActivities; -import io.temporal.workflow.Workflow; -import java.time.Duration; - -public class ObservabilityWorkflowImpl implements ObservabilityWorkflow { - // @@@SNIPSTART java-google-adk-observability-workflow - @Override - public String observe(String prompt) { - WeatherActivities weather = - Workflow.newActivityStub( - WeatherActivities.class, - ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build()); - LlmAgent agent = - LlmAgent.builder() - .name("observed_assistant") - .model(TemporalModel.create(AgentSupport.MODEL)) - .instruction("Use getWeather and summarize its result.") - .tools(ActivityTools.create(weather, "getWeather")) - .build(); - Runner runner = TemporalAdk.runnerBuilder().appName("observability").agent(agent).build(); - return AgentSupport.lastText( - TemporalAdk.run( - runner, - AgentSupport.USER, - AgentSupport.SESSION, - Content.fromParts(Part.fromText(prompt)))); - } - // @@@SNIPEND -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md deleted file mode 100644 index f2dd3078..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# OpenTelemetry for ADK Workflows and Activities - -Trace the Temporal Workflow and its model/tool Activities with OpenTelemetry. The example uses the -SDK's tracing interceptor through its OpenTracing bridge, an instance-scoped OpenTelemetry SDK, -and a replay-aware exporter. It exports Temporal operation spans without installing a global ADK -telemetry provider. Native ADK agent-internal spans still lack replay suppression in plugin 0.1.0. - -From the repository root, start `temporal server start-dev` in one terminal. Start a Worker with -console span export in another: - -```bash -ADK_MODEL_MODE=offline ADK_TRACING=console ./gradlew :googleadk:worker -``` - -For live Gemini, stop the offline Worker and restart it with: - -```bash -GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ADK_TRACING=console ./gradlew :googleadk:worker -``` - -In a third terminal: - -```bash -temporal workflow execute --type ObservabilityWorkflow --task-queue google-adk-samples --input '"What is the weather in Chicago?"' -``` - -Offline mode returns `Chicago is sunny.`. The Worker logs a `RunWorkflow:ObservabilityWorkflow` -span, two `RunActivity:googleadk.InvokeModel` spans, and one `RunActivity:GetWeather` span, together -with their scheduling spans. W3C trace context links these operations into one trace. The weather -Activity returns illustrative data and a host timestamp. - -`SampleTelemetry` accepts a standard `SpanExporter`; replace the console exporter to send spans to -an OpenTelemetry backend. Keep its `ReplayAwareSpanExporter` in the `SimpleSpanProcessor` so replay -is checked on the Workflow thread before any asynchronous export. Query spans remain observable. -The exporter suppresses spans ended during replay. As with Temporal Activity execution, a real -Activity retry exports another attempt; traces do not promise exactly-once execution. - -The tests use an in-memory exporter and a scripted model, requiring no keys, telemetry service, or -external Temporal Service. They verify model/tool spans and trace linkage, failed/retried tool -attempts, and replay without additional exported spans: - -```bash -./gradlew :googleadk:test --tests io.temporal.samples.googleadk.ObservabilityWorkflowTest -``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java deleted file mode 100644 index 5e05df3c..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/ReplayAwareSpanExporter.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.temporal.samples.googleadk.observability; - -import io.opentelemetry.sdk.common.CompletableResultCode; -import io.opentelemetry.sdk.trace.data.SpanData; -import io.opentelemetry.sdk.trace.export.SpanExporter; -import io.temporal.workflow.unsafe.WorkflowUnsafe; -import java.util.Collection; - -public final class ReplayAwareSpanExporter implements SpanExporter { - private final SpanExporter delegate; - - public ReplayAwareSpanExporter(SpanExporter delegate) { - this.delegate = delegate; - } - - // @@@SNIPSTART java-google-adk-observability-exporter - @Override - public CompletableResultCode export(Collection spans) { - if (WorkflowUnsafe.isWorkflowThread() && WorkflowUnsafe.isReplaying()) { - spans = spans.stream().filter(span -> span.getName().startsWith("HandleQuery:")).toList(); - } - return spans.isEmpty() ? CompletableResultCode.ofSuccess() : delegate.export(spans); - } - - // @@@SNIPEND - - @Override - public CompletableResultCode flush() { - return delegate.flush(); - } - - @Override - public CompletableResultCode shutdown() { - return delegate.shutdown(); - } -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java b/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java deleted file mode 100644 index bd9e4f7e..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/observability/SampleTelemetry.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.temporal.samples.googleadk.observability; - -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.exporter.logging.LoggingSpanExporter; -import io.opentelemetry.opentracingshim.OpenTracingShim; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; -import io.opentelemetry.sdk.trace.export.SpanExporter; -import io.temporal.opentracing.OpenTracingOptions; -import io.temporal.opentracing.OpenTracingSpanContextCodec; -import io.temporal.opentracing.OpenTracingWorkerInterceptor; -import io.temporal.worker.WorkerFactoryOptions; - -public final class SampleTelemetry implements AutoCloseable { - private final OpenTelemetrySdk telemetry; - private final OpenTracingOptions options; - - // @@@SNIPSTART java-google-adk-observability-setup - public SampleTelemetry(SpanExporter exporter) { - telemetry = - OpenTelemetrySdk.builder() - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .setTracerProvider( - SdkTracerProvider.builder() - .addSpanProcessor( - SimpleSpanProcessor.create(new ReplayAwareSpanExporter(exporter))) - .build()) - .build(); - options = - OpenTracingOptions.newBuilder() - .setTracer(OpenTracingShim.createTracerShim(telemetry)) - .setSpanContextCodec(OpenTracingSpanContextCodec.TEXT_MAP_CODEC) - .build(); - } - - public WorkerFactoryOptions workerOptions() { - return WorkerFactoryOptions.newBuilder() - .setWorkerInterceptors(new OpenTracingWorkerInterceptor(options)) - .build(); - } - - // @@@SNIPEND - - public static SampleTelemetry console() { - return new SampleTelemetry(LoggingSpanExporter.create()); - } - - @Override - public void close() { - telemetry.close(); - } -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java deleted file mode 100644 index 3e480d8d..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/ModelChunk.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.temporal.samples.googleadk.streaming; - -public record ModelChunk(String kind, String text) {} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md deleted file mode 100644 index 496e41e1..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Buffered ADK streaming through Workflow Streams - -ADK Java supports SSE model responses. Version 0.1.0 of the Temporal plugin collects their chunks -inside one model Activity. This scenario publishes those partial and final events through the -experimental `temporal-workflowstreams` module after the Activity completes. Subscribers receive -ordered chunks at that point; live token forwarding while the provider is running requires a plugin -change. Bidirectional live connections are unsupported through `TemporalModel`. - -From the repository root, start `temporal server start-dev` in one terminal. In another, choose a -credential-free Worker or a live Gemini Worker: - -```bash -ADK_MODEL_MODE=offline ./gradlew :googleadk:worker -``` - -```bash -GOOGLE_API_KEY=your_key ADK_MODEL_MODE=live ./gradlew :googleadk:worker -``` - -Stop the Worker before switching modes. Start a Workflow and stream subscriber in a third terminal: - -```bash -./gradlew :googleadk:starter --args='streaming' -``` - -Offline mode prints two partial chunks (`Hello ` and `from ADK`), a final `Hello from ADK`, and a -`complete` event. The subscriber acknowledges completion so the Workflow can return. With no -subscriber, the Workflow completes after a ten-second acknowledgement deadline: - -```bash -temporal workflow execute --type StreamingWorkflow --task-queue google-adk-samples --input '"Say hello."' -``` - -Subscribe when starting the Workflow; the stream serves buffered items while the Workflow is open. -The acknowledgement deadline bounds the wait and does not provide a grace period for arbitrary late -subscribers after completion. Model output is recorded by one `googleadk.InvokeModel` Activity and -is not requested again during replay. - -Test ordered partial/final delivery, subscriber completion, absence of a subscriber, and replay: - -```bash -./gradlew :googleadk:test --tests io.temporal.samples.googleadk.StreamingWorkflowTest -``` diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java deleted file mode 100644 index 0eb90b77..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflow.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.temporal.samples.googleadk.streaming; - -import io.temporal.workflow.SignalMethod; -import io.temporal.workflow.WorkflowInterface; -import io.temporal.workflow.WorkflowMethod; -import java.util.List; - -@WorkflowInterface -public interface StreamingWorkflow { - @WorkflowMethod - List stream(String prompt); - - @SignalMethod - void acknowledge(); -} diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java deleted file mode 100644 index 4af783d0..00000000 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/streaming/StreamingWorkflowImpl.java +++ /dev/null @@ -1,68 +0,0 @@ -package io.temporal.samples.googleadk.streaming; - -import com.google.adk.agents.LlmAgent; -import com.google.adk.agents.RunConfig; -import com.google.adk.events.Event; -import com.google.adk.runner.Runner; -import com.google.genai.types.Content; -import com.google.genai.types.Part; -import io.temporal.googleadk.TemporalAdk; -import io.temporal.googleadk.model.TemporalModel; -import io.temporal.samples.googleadk.AgentSupport; -import io.temporal.workflow.Workflow; -import io.temporal.workflow.WorkflowInit; -import io.temporal.workflowstreams.WorkflowStream; -import java.time.Duration; -import java.util.List; - -public class StreamingWorkflowImpl implements StreamingWorkflow { - private final WorkflowStream output; - private boolean acknowledged; - - @WorkflowInit - public StreamingWorkflowImpl(String prompt) { - output = WorkflowStream.newInstance(null); - } - - // @@@SNIPSTART java-google-adk-streaming-workflow - @Override - public List stream(String prompt) { - LlmAgent agent = - LlmAgent.builder() - .name("streaming_assistant") - .model(TemporalModel.create(AgentSupport.MODEL)) - .instruction("Answer concisely.") - .build(); - Runner runner = TemporalAdk.runnerBuilder().appName("streaming").agent(agent).build(); - List events = - TemporalAdk.run( - runner, - AgentSupport.USER, - AgentSupport.SESSION, - Content.fromParts(Part.fromText(prompt)), - RunConfig.builder() - .streamingMode(RunConfig.StreamingMode.SSE) - .autoCreateSession(true) - .build()); - List chunks = - events.stream() - .filter(event -> event.content().isPresent()) - .map( - event -> - new ModelChunk( - event.partial().orElse(false) ? "partial" : "final", - event.content().map(Content::text).orElse(""))) - .toList(); - for (ModelChunk chunk : chunks) output.topic("model").publish(chunk); - output.topic("model").publish(new ModelChunk("complete", AgentSupport.lastText(events))); - Workflow.await(Duration.ofSeconds(10), () -> acknowledged); - return chunks; - } - - // @@@SNIPEND - - @Override - public void acknowledge() { - acknowledged = true; - } -} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java index 01cfce54..bcaa0c9b 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/AdkTestEnvironment.java @@ -16,7 +16,6 @@ import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.worker.Worker; -import io.temporal.worker.WorkerFactoryOptions; import java.time.Duration; import java.util.List; import java.util.Map; @@ -31,17 +30,11 @@ final class AdkTestEnvironment implements AutoCloseable { final Worker worker; final String taskQueue = "google-adk-test-" + UUID.randomUUID(); private final BaseLlm model; - private final WorkerFactoryOptions workerOptions; AdkTestEnvironment(BaseLlm model, Class... workflows) { - this(model, WorkerFactoryOptions.newBuilder().build(), workflows); - } - - AdkTestEnvironment(BaseLlm model, WorkerFactoryOptions workerOptions, Class... workflows) { this.model = model; - this.workerOptions = workerOptions; GoogleAdkPlugin plugin = SampleWorker.plugin(() -> model); - environment = newEnvironment(plugin, workerOptions); + environment = newEnvironment(plugin); client = environment.getWorkflowClient(); worker = environment.newWorker(taskQueue); if (workflows.length > 0) { @@ -49,11 +42,9 @@ final class AdkTestEnvironment implements AutoCloseable { } } - private static TestWorkflowEnvironment newEnvironment( - GoogleAdkPlugin plugin, WorkerFactoryOptions workerOptions) { + private static TestWorkflowEnvironment newEnvironment(GoogleAdkPlugin plugin) { return TestWorkflowEnvironment.newInstance( TestEnvironmentOptions.newBuilder() - .setWorkerFactoryOptions(workerOptions) .setWorkflowClientOptions(WorkflowClientOptions.newBuilder().setPlugins(plugin).build()) .build()); } @@ -76,7 +67,7 @@ void replay(WorkflowExecutionHistory history, Class implementation) throws Ex int requests = model instanceof ScriptedModel scripted ? scripted.requests().size() : 0; // A new Worker proves replay works without cached Workflow or ADK session state. GoogleAdkPlugin plugin = GoogleAdkPlugin.newBuilder().model(AgentSupport.MODEL, model).build(); - try (TestWorkflowEnvironment replay = newEnvironment(plugin, workerOptions)) { + try (TestWorkflowEnvironment replay = newEnvironment(plugin)) { Worker replayWorker = replay.newWorker("replay"); replayWorker.registerWorkflowImplementationTypes(implementation); replayWorker.replayWorkflowExecution(history); diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java deleted file mode 100644 index 7eb3add4..00000000 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ObservabilityWorkflowTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.temporal.samples.googleadk; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; -import io.opentelemetry.sdk.trace.data.SpanData; -import io.temporal.failure.ApplicationFailure; -import io.temporal.googleadk.testing.ScriptedModel; -import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; -import io.temporal.samples.googleadk.observability.ObservabilityWorkflowImpl; -import io.temporal.samples.googleadk.observability.SampleTelemetry; -import io.temporal.samples.googleadk.tools.WeatherActivities; -import io.temporal.samples.googleadk.tools.WeatherActivitiesImpl; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -@Timeout(30) -class ObservabilityWorkflowTest { - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void modelAndToolSpansShareATraceAndReplayExportsNothing(boolean retryWeather) throws Exception { - InMemorySpanExporter exporter = InMemorySpanExporter.create(); - ScriptedModel model = - ScriptedModel.responding( - AgentSupport.MODEL, - request -> - AdkTestEnvironment.functionResponse(request, "getWeather") - .map(response -> ScriptedModel.text("Chicago is sunny.")) - .orElseGet( - () -> ScriptedModel.functionCall("getWeather", Map.of("city", "Chicago")))); - try (SampleTelemetry telemetry = new SampleTelemetry(exporter); - AdkTestEnvironment test = - new AdkTestEnvironment( - model, telemetry.workerOptions(), ObservabilityWorkflowImpl.class)) { - AtomicInteger attempts = new AtomicInteger(); - test.worker.registerActivitiesImplementations( - (WeatherActivities) - city -> { - if (attempts.incrementAndGet() == 1 && retryWeather) { - throw ApplicationFailure.newFailure( - "Transient weather error", "WeatherUnavailable"); - } - return new WeatherActivitiesImpl().getWeather(city); - }); - test.environment.start(); - ObservabilityWorkflow workflow = test.workflow(ObservabilityWorkflow.class); - assertEquals("Chicago is sunny.", workflow.observe("What's the weather in Chicago?")); - AdkTestEnvironment.await( - () -> - exporter.getFinishedSpanItems().stream() - .anyMatch(span -> span.getName().startsWith("RunWorkflow:"))); - var spans = exporter.getFinishedSpanItems(); - assertEquals( - 2, - spans.stream() - .filter(span -> span.getName().equals("RunActivity:googleadk.InvokeModel")) - .count()); - assertEquals( - retryWeather ? 2 : 1, - spans.stream().filter(span -> span.getName().equals("RunActivity:GetWeather")).count()); - assertEquals( - retryWeather, - spans.stream().anyMatch(span -> span.getStatus().getStatusCode() == StatusCode.ERROR)); - assertTrue( - spans.stream().allMatch(span -> span.getTraceId().equals(spans.get(0).getTraceId()))); - assertEquals(spans.size(), spans.stream().map(SpanData::getSpanId).distinct().count()); - test.replay(test.history(workflow), ObservabilityWorkflowImpl.class); - assertEquals( - spans.size(), - exporter.getFinishedSpanItems().size(), - "replay exports no duplicate spans"); - } - } -} diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java index ba0dcef1..7270e522 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java @@ -10,8 +10,6 @@ import io.temporal.samples.googleadk.longrunning.LongRunningWorkflow; import io.temporal.samples.googleadk.mcp.McpWorkflow; import io.temporal.samples.googleadk.multiagent.MultiAgentWorkflow; -import io.temporal.samples.googleadk.observability.ObservabilityWorkflow; -import io.temporal.samples.googleadk.streaming.StreamingWorkflow; import io.temporal.samples.googleadk.structuredoutput.StructuredOutputWorkflow; import io.temporal.samples.googleadk.structuredoutput.TravelPlan; import io.temporal.samples.googleadk.tools.ToolsWorkflow; @@ -26,10 +24,6 @@ void everyScenarioRunsWithTheProductionWorkerRegistrationAndOfflineProvider() th try (AdkTestEnvironment test = new AdkTestEnvironment(new OfflineModel())) { SampleWorker.register(test.worker); test.environment.start(); - assertEquals( - "Chicago is sunny.", - test.workflow(ObservabilityWorkflow.class).observe("What's the weather in Chicago?")); - assertEquals(3, test.workflow(StreamingWorkflow.class).stream("Say hello.").size()); assertEquals( "Chicago is sunny: 20 C / 68.0 F.", test.workflow(ToolsWorkflow.class).ask("What's the weather in Chicago?")); diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java deleted file mode 100644 index ac07d8e9..00000000 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/StreamingWorkflowTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package io.temporal.samples.googleadk; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.temporal.client.WorkflowClient; -import io.temporal.client.WorkflowStub; -import io.temporal.googleadk.testing.ScriptedModel; -import io.temporal.samples.googleadk.streaming.ModelChunk; -import io.temporal.samples.googleadk.streaming.StreamingWorkflow; -import io.temporal.samples.googleadk.streaming.StreamingWorkflowImpl; -import io.temporal.workflowstreams.WorkflowStreamClient; -import io.temporal.workflowstreams.WorkflowStreamItem; -import io.temporal.workflowstreams.WorkflowStreamSubscription; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -@Timeout(30) -class StreamingWorkflowTest { - private static ScriptedModel model() { - return ScriptedModel.streaming( - AgentSupport.MODEL, - request -> - List.of( - ScriptedModel.partialText("Hel"), - ScriptedModel.partialText("lo"), - ScriptedModel.text("Hello"))); - } - - @Test - void noSubscriberIsRequiredAndTheBufferedChunksReplayInOrder() throws Exception { - ScriptedModel model = model(); - try (AdkTestEnvironment test = new AdkTestEnvironment(model, StreamingWorkflowImpl.class)) { - test.environment.start(); - StreamingWorkflow workflow = test.workflow(StreamingWorkflow.class); - assertEquals( - List.of( - new ModelChunk("partial", "Hel"), - new ModelChunk("partial", "lo"), - new ModelChunk("final", "Hello")), - workflow.stream("Hello")); - assertEquals( - List.of("googleadk.InvokeModel"), AdkTestEnvironment.activities(test.history(workflow))); - test.replay(test.history(workflow), StreamingWorkflowImpl.class); - } - } - - @Test - void subscriberReceivesEveryChunkAndAcknowledgesCompletion() throws Exception { - ScriptedModel model = model(); - try (AdkTestEnvironment test = new AdkTestEnvironment(model, StreamingWorkflowImpl.class)) { - test.environment.start(); - StreamingWorkflow workflow = test.workflow(StreamingWorkflow.class); - var execution = WorkflowClient.start(workflow::stream, "Hello"); - List received = new ArrayList<>(); - try (WorkflowStreamClient stream = - WorkflowStreamClient.newInstance(test.client, execution.getWorkflowId()); - WorkflowStreamSubscription subscription = stream.topic("model").subscribe(0)) { - for (WorkflowStreamItem item : subscription) { - ModelChunk chunk = - test.client - .getOptions() - .getDataConverter() - .fromPayload(item.getPayload(), ModelChunk.class, ModelChunk.class); - received.add(chunk); - if (chunk.kind().equals("complete")) { - workflow.acknowledge(); - break; - } - } - } - assertEquals( - List.of( - new ModelChunk("partial", "Hel"), - new ModelChunk("partial", "lo"), - new ModelChunk("final", "Hello"), - new ModelChunk("complete", "Hello")), - received); - assertEquals(3, WorkflowStub.fromTyped(workflow).getResult(List.class).size()); - test.replay(test.history(workflow), StreamingWorkflowImpl.class); - } - } -} From 5e56497be69d8a703e4436b7a7858e58903da9d6 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 29 Oct 2026 21:45:05 -0500 Subject: [PATCH 6/7] Use Workflow Updates for Java ADK chat turns --- README.md | 2 +- googleadk/README.md | 22 +- googleadk/build.gradle | 2 +- .../samples/googleadk/SampleStarter.java | 8 +- .../samples/googleadk/chat/ChatInput.java | 7 +- .../samples/googleadk/chat/ChatWorkflow.java | 11 +- .../googleadk/chat/ChatWorkflowImpl.java | 71 ++--- .../temporal/samples/googleadk/chat/README.md | 35 +-- .../samples/googleadk/ChatWorkflowTest.java | 251 +++++++++++++++--- .../samples/googleadk/OfflineSamplesTest.java | 10 +- 10 files changed, 300 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 7a34db8c..1c9b8c40 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ ADK_MODEL_MODE=offline ./gradlew :googleadk:worker 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, -query replies, and finish the conversation. +receive each Update's reply, and finish the conversation. See the [sample README](googleadk/README.md) for the other scenarios and credential-free tests. diff --git a/googleadk/README.md b/googleadk/README.md index 91198f1f..d053850e 100644 --- a/googleadk/README.md +++ b/googleadk/README.md @@ -1,7 +1,7 @@ # Google ADK on Temporal Run native [Google ADK Java](https://github.com/google/adk-java) agents durably with -[`io.temporal:google-adk:0.1.0`](https://central.sonatype.com/artifact/io.temporal/google-adk). +[`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 @@ -55,23 +55,23 @@ Start with Agent Chat in that third terminal: ``` The starter prints the Workflow ID. Follow the [chat commands](src/main/java/io/temporal/samples/googleadk/chat) -to send messages, query replies, and finish the conversation. +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) | Signals queue turns; Queries expose replies; a session snapshot and pending messages survive Continue-As-New. | +| 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.0 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). | +| 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.0/java/google-adk/README.md#limitations) +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. @@ -92,9 +92,10 @@ 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 exports its ADK session and carries queued messages and the latest reply into a new Run. -`finish` drains queued messages 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. +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 @@ -108,8 +109,9 @@ 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, and session/queue recovery across Continue-As-New. Recorded histories -replay on a fresh Worker without repeating model or tool calls. `OfflineSamplesTest` runs every +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`, diff --git a/googleadk/build.gradle b/googleadk/build.gradle index a5376c21..99651613 100644 --- a/googleadk/build.gradle +++ b/googleadk/build.gradle @@ -1,6 +1,6 @@ // ADK samples consume the independently versioned integration from Maven Central. dependencies { - implementation 'io.temporal:google-adk:0.1.0' + implementation 'io.temporal:google-adk:0.1.1' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation('io.temporal:temporal-sdk') { version { diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java index d5350416..f2134339 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/SampleStarter.java @@ -24,7 +24,6 @@ public static void main(String[] args) { } WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs(); try { - // Register the plugin on external clients too, so Queries/Signals use the same converter. WorkflowClient client = WorkflowClient.newInstance( service, @@ -88,9 +87,10 @@ public static void main(String[] args) { break; case "message": requireArgs(args, 3); - client - .newWorkflowStub(ChatWorkflow.class, args[1]) - .message(String.join(" ", Arrays.copyOfRange(args, 2, args.length))); + System.out.println( + client + .newWorkflowStub(ChatWorkflow.class, args[1]) + .message(String.join(" ", Arrays.copyOfRange(args, 2, args.length)))); break; case "reply": requireArgs(args, 2); diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java index 8574f0ac..f719f750 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatInput.java @@ -1,12 +1,9 @@ package io.temporal.samples.googleadk.chat; import com.google.adk.sessions.Session; -import java.util.List; -/** Session and queued messages carried into the next Run after Continue-As-New. */ -public record ChatInput( - Session session, List messages, boolean finished, String lastReply) { +public record ChatInput(Session session, String lastReply) { public static ChatInput empty() { - return new ChatInput(null, List.of(), false, ""); + return new ChatInput(null, ""); } } diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java index 6e3df819..4288c192 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflow.java @@ -2,16 +2,22 @@ import io.temporal.workflow.QueryMethod; import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.UpdateValidatorMethod; import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; +// @@@SNIPSTART java-google-adk-chat-interface @WorkflowInterface public interface ChatWorkflow { @WorkflowMethod String chat(ChatInput input); - @SignalMethod - void message(String text); + @UpdateMethod + String message(String text); + + @UpdateValidatorMethod(updateName = "message") + void validateMessage(String text); @SignalMethod void finish(); @@ -19,3 +25,4 @@ public interface ChatWorkflow { @QueryMethod String lastReply(); } +// @@@SNIPEND diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java index 67c7aa07..0b1c2cc7 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/ChatWorkflowImpl.java @@ -11,41 +11,50 @@ import io.temporal.samples.googleadk.AgentSupport; import io.temporal.workflow.Workflow; import io.temporal.workflow.WorkflowInit; -import java.util.ArrayList; -import java.util.List; +import io.temporal.workflow.WorkflowLock; +// @@@SNIPSTART java-google-adk-chat-workflow public class ChatWorkflowImpl implements ChatWorkflow { - private final List messages = new ArrayList<>(); + private final WorkflowLock turnLock = Workflow.newWorkflowLock(); + private final Runner runner; private boolean finished; - private String reply = ""; + private String reply; + private int turns; @WorkflowInit public ChatWorkflowImpl(ChatInput input) { - messages.addAll(input.messages()); - finished = input.finished(); reply = input.lastReply(); - } - - // @@@SNIPSTART java-google-adk-chat-workflow - @Override - public String chat(ChatInput input) { LlmAgent agent = LlmAgent.builder() .name("chat_assistant") .model(TemporalModel.create(AgentSupport.MODEL)) .instruction("Answer concisely, remembering the earlier turns in this conversation.") .build(); - Runner runner = TemporalAdk.runnerBuilder().appName("chat").agent(agent).build(); + runner = TemporalAdk.runnerBuilder().appName("chat").agent(agent).build(); if (input.session() != null) { Sessions.importSession(runner.sessionService(), input.session()); } - int turns = 0; - while (!finished || !messages.isEmpty()) { - Workflow.await(() -> finished || !messages.isEmpty()); - if (messages.isEmpty()) { - break; - } - String text = messages.remove(0); + } + + @Override + public String chat(ChatInput input) { + Workflow.await( + () -> + finished || turns >= 3 || (turns > 0 && Workflow.getInfo().isContinueAsNewSuggested())); + Workflow.await(Workflow::isEveryHandlerFinished); + if (!finished) { + Session snapshot = + Sessions.exportSession( + runner.sessionService(), "chat", AgentSupport.USER, AgentSupport.SESSION); + Workflow.continueAsNew(new ChatInput(snapshot, reply)); + } + return reply; + } + + @Override + public String message(String text) { + turnLock.lock(); + try { reply = AgentSupport.lastText( TemporalAdk.run( @@ -53,22 +62,21 @@ public String chat(ChatInput input) { AgentSupport.USER, AgentSupport.SESSION, Content.fromParts(Part.fromText(text)))); - // Use a small threshold to make Continue-As-New easy to observe in this sample. - if (++turns == 3 && !finished) { - Session snapshot = - Sessions.exportSession( - runner.sessionService(), "chat", AgentSupport.USER, AgentSupport.SESSION); - Workflow.continueAsNew(new ChatInput(snapshot, List.copyOf(messages), finished, reply)); - } + turns++; + return reply; + } finally { + turnLock.unlock(); } - return reply; } - // @@@SNIPEND - @Override - public void message(String text) { - messages.add(text); + public void validateMessage(String text) { + if (finished) { + throw new IllegalStateException("The conversation is finishing."); + } + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("The message must not be blank."); + } } @Override @@ -81,3 +89,4 @@ public String lastReply() { return reply; } } +// @@@SNIPEND diff --git a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md index f8098ad7..d82289d1 100644 --- a/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md +++ b/googleadk/src/main/java/io/temporal/samples/googleadk/chat/README.md @@ -1,7 +1,8 @@ # Agent Chat -Hold a multi-turn ADK conversation using Signals and a Query. After three turns, export the session -and Continue-As-New with queued messages and the latest reply. `finish` drains the queue. +Send each user message as a Workflow Update and receive that turn's reply directly. Concurrent turns +run one at a time on the same ADK session. After three completed turns, the Workflow waits for accepted +Updates to finish, exports the session, and Continues-As-New with the latest reply. Run commands from the repository root. Start `temporal server start-dev` in one terminal. In another, choose the credential-free offline Worker or a live Gemini Worker: @@ -20,29 +21,29 @@ for prerequisites and integration limits. In a third terminal: ```bash -temporal workflow start --workflow-id adk-chat --type ChatWorkflow --task-queue google-adk-samples --input '{"session":null,"messages":[],"finished":false,"lastReply":""}' -temporal workflow signal --workflow-id adk-chat --name message --input '"My name is Ada."' -temporal workflow query --workflow-id adk-chat --name lastReply -temporal workflow signal --workflow-id adk-chat --name message --input '"Plan a trip to Chicago."' -temporal workflow signal --workflow-id adk-chat --name message --input '"What should I pack?"' -temporal workflow query --workflow-id adk-chat --name lastReply +temporal workflow start --workflow-id adk-chat --type ChatWorkflow --task-queue google-adk-samples --input '{"session":null,"lastReply":""}' +temporal workflow update execute --workflow-id adk-chat --name message --input '"My name is Ada."' +temporal workflow update execute --workflow-id adk-chat --name message --input '"Plan a trip to Chicago."' +temporal workflow update execute --workflow-id adk-chat --name message --input '"What should I pack?"' ``` -Repeat the Query until the third reply appears. In offline mode it starts with `turns=3;`. Then -continue the conversation in the next Run: +Each Update waits for its own reply. In offline mode, the third response starts with `turns=3;`. +Continue the conversation across the Run transition, then finish: ```bash -temporal workflow signal --workflow-id adk-chat --name message --input '"What is my name?"' +temporal workflow update execute --workflow-id adk-chat --name message --input '"What is my name?"' temporal workflow signal --workflow-id adk-chat --name finish temporal workflow result --workflow-id adk-chat ``` -Queries may initially return an empty or previous reply while the model Activity runs. In offline -mode replies show the number of user turns and latest input. Wait for the third reply before sending -the fourth message to observe Continue-As-New; it returns `turns=4; latest: What is my name?`. -Live Gemini also sees the imported conversation and can recall Ada's name. The tests check queued -messages across a Run transition and replay both histories. A session snapshot still grows with the -conversation, so bound or compact real conversations before they reach payload limits. +The fourth offline reply is `turns=4; latest: What is my name?`. Live Gemini also sees the imported +conversation and can recall Ada's name. `finish` rejects new messages and lets accepted Updates finish +before returning the last reply. Blank messages are rejected before invoking the model. + +The optional `lastReply` Query inspects the latest completed turn; Updates return their replies +without polling. The tests check concurrent Updates at a Run transition, draining accepted Updates +on finish, and replay of both histories. A session snapshot still grows with the conversation, so +bound or compact real conversations before they reach payload limits. Test without provider credentials or an external Service: diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java index 3af98548..5a5f0214 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/ChatWorkflowTest.java @@ -1,72 +1,143 @@ package io.temporal.samples.googleadk; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.adk.models.LlmRequest; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowUpdateException; +import io.temporal.client.WorkflowUpdateHandle; +import io.temporal.client.WorkflowUpdateStage; import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.failure.ApplicationFailure; import io.temporal.googleadk.testing.ScriptedModel; import io.temporal.samples.googleadk.chat.ChatInput; import io.temporal.samples.googleadk.chat.ChatWorkflow; import io.temporal.samples.googleadk.chat.ChatWorkflowImpl; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; @Timeout(30) class ChatWorkflowTest { - private static ScriptedModel countingModel() { - return ScriptedModel.responding( - AgentSupport.MODEL, - request -> - ScriptedModel.text( - "turns=" - + request.contents().stream() - .filter(content -> content.role().orElse("").equals("user")) - .count())); + private static List messages(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()) + .toList(); + } + + private static WorkflowUpdateHandle message(ChatWorkflow workflow, String text) { + return WorkflowStub.fromTyped(workflow) + .startUpdate("message", WorkflowUpdateStage.ACCEPTED, String.class, text); + } + + private static void blockModel(CountDownLatch entered, CountDownLatch release) { + entered.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("The test did not release the model call."); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static boolean continuedAsNew(WorkflowExecutionHistory history) { + return history.getEvents().stream() + .anyMatch( + event -> + event.getEventType() == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW); } @Test - void conversationAndQueuedSignalsSurviveContinueAsNew() throws Exception { - ScriptedModel model = countingModel(); + void updatesReturnTheirOwnRepliesAndReplay() throws Exception { + // @@@SNIPSTART java-google-adk-chat-test + ScriptedModel model = + ScriptedModel.of( + AgentSupport.MODEL, + ScriptedModel.text("First reply"), + ScriptedModel.text("Last reply")); try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { test.environment.start(); ChatWorkflow workflow = test.workflow(ChatWorkflow.class); - WorkflowExecution firstRun = - WorkflowClient.start( - workflow::chat, - new ChatInput(null, List.of("one", "two", "three", "four"), false, "")); - // Synchronize on the Run transition before querying the next Run, so Queries - // cannot race the test service's Continue-As-New command. + WorkflowClient.start(workflow::chat, ChatInput.empty()); + + assertEquals("First reply", workflow.message("one")); + assertEquals("Last reply", workflow.message("two")); + workflow.finish(); + assertEquals("Last reply", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(2, model.requests().size()); + test.replay(test.history(workflow), ChatWorkflowImpl.class); + } + // @@@SNIPEND + } + + @Test + void concurrentUpdatesFinishBeforeContinueAsNewAndKeepTheConversation() throws Exception { + CountDownLatch thirdEntered = new CountDownLatch(1); + CountDownLatch releaseThird = new CountDownLatch(1); + CountDownLatch fourthEntered = new CountDownLatch(1); + CountDownLatch releaseFourth = new CountDownLatch(1); + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> { + List turns = messages(request); + if (turns.size() == 3) { + blockModel(thirdEntered, releaseThird); + } else if (turns.size() == 4) { + blockModel(fourthEntered, releaseFourth); + } + return ScriptedModel.text(String.join(", ", turns)); + }); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow starter = test.workflow(ChatWorkflow.class); + WorkflowExecution firstRun = WorkflowClient.start(starter::chat, ChatInput.empty()); + ChatWorkflow workflow = + test.client.newWorkflowStub(ChatWorkflow.class, firstRun.getWorkflowId()); + assertEquals("one", workflow.message("one")); + assertEquals("one, two", workflow.message("two")); + + WorkflowUpdateHandle third = message(workflow, "three"); + assertTrue(thirdEntered.await(10, TimeUnit.SECONDS)); + WorkflowUpdateHandle fourth = message(workflow, "four"); + assertEquals(3, AdkTestEnvironment.activities(test.history(workflow)).size()); + releaseThird.countDown(); + assertEquals("one, two, three", third.getResult()); + assertTrue(fourthEntered.await(10, TimeUnit.SECONDS)); + assertFalse( + continuedAsNew(test.client.fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()))); + releaseFourth.countDown(); + assertEquals("one, two, three, four", fourth.getResult()); AdkTestEnvironment.await( () -> - test - .client - .fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()) - .getEvents() - .stream() - .anyMatch( - event -> - event.getEventType() - == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW)); - AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=4")); - workflow.message("five"); - AdkTestEnvironment.await(() -> workflow.lastReply().equals("turns=5")); - workflow.finish(); + continuedAsNew( + test.client.fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()))); - assertEquals("turns=5", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals("one, two, three, four", workflow.lastReply()); + assertEquals("one, two, three, four, five", workflow.message("five")); + workflow.finish(); + assertEquals( + "one, two, three, four, five", WorkflowStub.fromTyped(workflow).getResult(String.class)); assertEquals(5, model.requests().size()); WorkflowExecutionHistory firstHistory = test.client.fetchHistory(firstRun.getWorkflowId(), firstRun.getRunId()); - assertTrue( - firstHistory.getEvents().stream() - .anyMatch( - event -> - event.getEventType() - == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW)); WorkflowExecutionHistory lastHistory = test.history(workflow); assertEquals( firstRun.getRunId(), @@ -75,27 +146,119 @@ void conversationAndQueuedSignalsSurviveContinueAsNew() throws Exception { .get(0) .getWorkflowExecutionStartedEventAttributes() .getContinuedExecutionRunId()); + assertEquals(4, AdkTestEnvironment.activities(firstHistory).size()); + assertEquals(1, AdkTestEnvironment.activities(lastHistory).size()); test.replay(firstHistory, ChatWorkflowImpl.class); test.replay(lastHistory, ChatWorkflowImpl.class); + } finally { + releaseThird.countDown(); + releaseFourth.countDown(); } } @Test - void finishDrainsThePendingMessages() throws Exception { - // @@@SNIPSTART java-google-adk-chat-test + void finishDrainsAcceptedUpdatesAndRejectsNewMessages() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); ScriptedModel model = - ScriptedModel.of( + ScriptedModel.responding( AgentSupport.MODEL, - ScriptedModel.text("First reply"), - ScriptedModel.text("Last reply")); + request -> { + List turns = messages(request); + if (turns.size() == 1) { + blockModel(entered, release); + } + return ScriptedModel.text(String.join(", ", turns)); + }); try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { test.environment.start(); ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + WorkflowClient.start(workflow::chat, ChatInput.empty()); + WorkflowUpdateHandle first = message(workflow, "one"); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + WorkflowUpdateHandle second = message(workflow, "two"); + assertEquals(1, AdkTestEnvironment.activities(test.history(workflow)).size()); + + workflow.finish(); + assertEquals("", workflow.lastReply()); + WorkflowUpdateException failure = + assertThrows(WorkflowUpdateException.class, () -> workflow.message("late")); assertEquals( - "Last reply", workflow.chat(new ChatInput(null, List.of("one", "two"), true, ""))); + IllegalStateException.class.getName(), + assertInstanceOf(ApplicationFailure.class, failure.getCause()).getType()); + release.countDown(); + assertEquals("one", first.getResult()); + assertEquals("one, two", second.getResult()); + assertEquals("one, two", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(2, model.requests().size()); + test.replay(test.history(workflow), ChatWorkflowImpl.class); + } finally { + release.countDown(); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void invalidMessagesAreRejectedWithoutCallingTheModel(String text) throws Exception { + ScriptedModel model = ScriptedModel.of(AgentSupport.MODEL, ScriptedModel.text("ok")); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + WorkflowClient.start(workflow::chat, ChatInput.empty()); + WorkflowUpdateException failure = + assertThrows(WorkflowUpdateException.class, () -> workflow.message(text)); + assertEquals( + IllegalArgumentException.class.getName(), + assertInstanceOf(ApplicationFailure.class, failure.getCause()).getType()); + assertTrue(model.requests().isEmpty()); + assertEquals("ok", workflow.message("valid")); + workflow.finish(); + assertEquals("ok", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(1, model.requests().size()); + test.replay(test.history(workflow), ChatWorkflowImpl.class); + } + } + + @Test + void finishBeforeTheFirstTurnCompletesWithoutCallingTheModel() throws Exception { + ScriptedModel model = ScriptedModel.of(AgentSupport.MODEL, ScriptedModel.text("unused")); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + WorkflowClient.start(workflow::chat, ChatInput.empty()); + workflow.finish(); + assertEquals("", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertTrue(model.requests().isEmpty()); + assertTrue(AdkTestEnvironment.activities(test.history(workflow)).isEmpty()); + test.replay(test.history(workflow), ChatWorkflowImpl.class); + } + } + + @Test + void failedUpdatesReleaseTheTurnLockAndTheConversationCanContinue() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + ScriptedModel model = + ScriptedModel.responding( + AgentSupport.MODEL, + request -> { + if (attempts.incrementAndGet() == 1) { + throw ApplicationFailure.newNonRetryableFailure("Model unavailable", "ModelDenied"); + } + return ScriptedModel.text("Recovered"); + }); + try (AdkTestEnvironment test = new AdkTestEnvironment(model, ChatWorkflowImpl.class)) { + test.environment.start(); + ChatWorkflow workflow = test.workflow(ChatWorkflow.class); + WorkflowClient.start(workflow::chat, ChatInput.empty()); + assertThrows(WorkflowUpdateException.class, () -> workflow.message("one")); + assertEquals("", workflow.lastReply()); + assertEquals("Recovered", workflow.message("two")); + workflow.finish(); + assertEquals("Recovered", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(2, attempts.get()); assertEquals(2, model.requests().size()); test.replay(test.history(workflow), ChatWorkflowImpl.class); } - // @@@SNIPEND } } diff --git a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java index 7270e522..dbf1bb01 100644 --- a/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java +++ b/googleadk/src/test/java/io/temporal/samples/googleadk/OfflineSamplesTest.java @@ -34,10 +34,12 @@ void everyScenarioRunsWithTheProductionWorkerRegistrationAndOfflineProvider() th assertEquals( new TravelPlan("Chicago", 2, List.of("Walk the lakefront", "Visit a museum")), test.workflow(StructuredOutputWorkflow.class).plan("Plan two days in Chicago.")); - assertEquals( - "turns=2; latest: two", - test.workflow(ChatWorkflow.class) - .chat(new ChatInput(null, List.of("one", "two"), true, ""))); + ChatWorkflow chat = test.workflow(ChatWorkflow.class); + WorkflowClient.start(chat::chat, ChatInput.empty()); + assertEquals("turns=1; latest: one", chat.message("one")); + assertEquals("turns=2; latest: two", chat.message("two")); + chat.finish(); + assertEquals("turns=2; latest: two", WorkflowStub.fromTyped(chat).getResult(String.class)); ApprovalWorkflow approval = test.workflow(ApprovalWorkflow.class); WorkflowClient.start(approval::fulfill, "Ship demo-order."); From a97a3736dc688b64dcbf379bc953dc9a684fdd99 Mon Sep 17 00:00:00 2001 From: David Hyde Date: Thu, 29 Oct 2026 21:54:35 -0500 Subject: [PATCH 7/7] clean up readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c9b8c40..fd8010f3 100644 --- a/README.md +++ b/README.md @@ -8,7 +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; runnable with a local fake or Gemini. +* [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