Skip to content

Commit f279e5f

Browse files
donald-pinckneyclaudemaciejdudko
authored
Add Spring AI samples (#775)
* Spring AI samples * Fix sample configs and remove runtimeOnly workarounds - Remove runtimeOnly spring-ai-rag and spring-ai-mcp from shared config (no longer needed after T6 plugin split in sdk-java) - Fix workflow class package references (old prototype packages) - Add web-application-type: none to RAG and multimodel configs - Exclude conflicting chat auto-configs in multimodel sample All 5 samples now boot successfully against a Temporal dev server. MCP sample requires Node.js/npx for the MCP server. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update samples for T15: remove @DeterministicTool and sandboxing sample - StringTools: remove @DeterministicTool (plain tools run in workflow context by default now) - Delete springai-sandboxing sample (SandboxingAdvisor removed from SDK) - Update comments referencing @DeterministicTool Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clean up Spring AI samples PR for merge - Remove committed build/ directories and add them to .gitignore. - Gate includeBuild('../sdk-java') on sibling checkout existence so the build works out-of-the-box when the springai samples are not being touched (and will resolve cleanly once temporal-spring-ai publishes). - core: pin io.grpc:grpc-util version explicitly and switch the SSL sample to AdvancedTlsX509KeyManager.updateIdentityCredentials (non-deprecated API) to fix compilation under -Werror with the composite-build grpc. - springai-multimodel: fix doc mismatch on model names, route unprefixed input to the "default" model, drop hard-coded model-version hints from system prompts. - springai-rag: correct Javadoc to match actual code (no EmbeddingModelActivity), accept bare "ask" as a usage-prompt trigger, normalize text block indentation. - springai-mcp: fix advisor name comment, normalize text block indentation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use mavenLocal for temporal-spring-ai instead of composite build Replace the includeBuild('../sdk-java') dependency-substitution block with a plain mavenLocal() repository and a pinned 1.35.0-SNAPSHOT coordinate for the springai* samples. Build temporal-spring-ai locally with `./gradlew publishToMavenLocal` in an sdk-java checkout and the samples pick it up via mavenLocal — no composite build, no SDK-wide substitution. A nice side-effect: the core module no longer has a newer grpc forced onto its classpath, so the SSL sample's gRPC workarounds (explicit grpc-util dep + updateIdentityCredentials switch) are no longer needed. Revert them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add TASK_QUEUE.json tracking remaining PR work Tracks the mavenLocal workaround unwind (blocked on temporal-spring-ai publishing) and unaddressed reviewer threads. Delete before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Updated after changes to Spring AI * Add snipsync markers to Spring AI samples Wraps natural snippet boundaries in the spring-ai samples so the docs/spring-ai-integration page can pull canonical code via snipsync instead of carrying inline copies. Six markers added: - samples-java-spring-ai-chat-workflow-init: ChatWorkflowImpl @WorkflowInit - samples-java-spring-ai-activity-tool: WeatherActivity interface - samples-java-spring-ai-side-effect-tool: TimestampTools class - samples-java-spring-ai-plain-tool: StringTools class - samples-java-spring-ai-per-model-options: ChatModelConfig per-model bean - samples-java-spring-ai-provider-options: MultiModelWorkflowImpl think route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spring-ai samples: pin to released temporal-spring-ai 1.35.0 temporal-spring-ai is now on Maven Central. Drop the mavenLocal() repository and the SNAPSHOT pin that were bridging the gap; the springai* samples now resolve from mavenCentral like everything else. springAiSdkVersion stays separate from javaSDKVersion because the spring-ai module requires a newer SDK than the rest of the samples currently pin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Group spring-ai samples under a single springai/ directory Moves the four spring-ai sample modules from top-level peers into nested subprojects of springai/, so the repo root has one springai/ instead of four (springai, springai-mcp, springai-multimodel, springai-rag). Each module keeps its own build.gradle and Spring Boot entry point — only the directory layout and Gradle project paths change. Invocations become :springai:basic, :springai:mcp, etc. Also folds in the earlier simplification: now that javaSDKVersion is 1.35.0 globally, gradle/springai.gradle drops the separate springAiSdkVersion variable and references the global one directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/mcp: await initialization in chat signal handler Replaces the early-return-with-placeholder pattern in McpWorkflowImpl.chat() with Workflow.await(() -> initialized). Signals can yield, so awaiting is the idiomatic Temporal way to handle "operation arrived before init finished" — the client's signal RPC has already returned, and the workflow-side handling just resumes once run() completes MCP tool discovery and finishes building the chat client. listTools() stays placeholder-based because it's a @QueryMethod and queries cannot yield. Addresses brianstrauch review comment on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/multimodel: drop redundant default: CLI prefix The CLI exposed both `default:` and `openai:` prefixes, which both ended up calling OpenAI — ChatModelConfig declares @primary on openAiChatModel, so ActivityChatModel.forDefault() resolves to it. Reviewers reasonably found this confusing. Drop the explicit `default:` prefix; route no-prefix input to the "default" workflow client instead. The chatClients entry stays so the sample still demonstrates both forDefault() (@primary resolution) and forModel(name) (explicit lookup) — added a comment block at the registration site explaining that the dual entry is intentional. Addresses brianstrauch review comment on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/rag: show usage when add/search are typed without args Reviewer noted that bare `ask` falls through to printing usage but bare `add` or `search` did not — the startsWith check required a trailing space. Apply the same equals|startsWith pattern that the ask branch already uses, with a length guard around the substring. Addresses brianstrauch review comment on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add ai-sdk to CODEOWNERS for springai * springai/multimodel: include "think" in chat() javadoc model names The javadoc listed "openai", "anthropic", "default" but the workflow also accepts "think" (Anthropic with extended thinking enabled). Add it to the @param doc so users don't send a name and wonder why it falls through to the unknown-model branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/basic: explain why run()'s systemPrompt parameter is unused @WorkflowInit requires the constructor and the @WorkflowMethod to share a parameter list, so run(String) must take systemPrompt even though only the constructor uses it. Add a comment so readers don't trip over the apparent unused parameter (and so static analyzers that flagged it have an explanation in-source). Addresses Copilot review on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * gradle/springai: document the Spring Boot plugin/BOM version skew The root build.gradle pins the Spring Boot Gradle plugin at springBootPluginVersion (2.7.13) for the legacy springboot/ samples, while Spring AI 1.1.0 needs Spring Boot 3.5.x via BOM import. The plugin and BOM are independent enough that this works in practice, but a future reader (or static analyzer) reasonably gets confused. Add a comment block explaining the trade-off and naming the two follow-up paths that would actually fix it (move plugin out of root plugins block, or migrate legacy springboot/ to 3.x). Addresses Copilot review on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * .gitignore: collapse per-module build/out entries into globs Replaces twelve explicit per-module entries (/build, /core/build, /springai/basic/build, ...) with two globs (**/build/, **/out/) so new sample modules don't need a .gitignore edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * README: document Spring AI samples and Java 17 requirement Adds Spring AI to the module list at the top, describes the four nested samples (basic, mcp, multimodel, rag) under a new "Running Spring AI Samples" section, and consolidates the per-sample Java version notes into a single "Java 17+ for all samples" line now that #779 bumped the project-wide target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai samples: address Copilot review nits Five small fixes flagged in PR #775 review: - Rename springai/basic/src/main/resources/application.yml to .yaml to match the convention used by every other sample in the repo. - Fix the @code ./gradlew :example-* paths in McpApplication, RagApplication, and MultiModelApplication javadocs to the actual module paths (:springai:mcp, :springai:rag, :springai:multimodel). - Add spring.main.web-application-type: none to springai/mcp's application.yaml. The module pulls in spring-boot-starter-webflux which would otherwise spin up a reactive web server we don't want. Matches the other three springai samples. - Drop stale references to EmbeddingModelActivity from the RagApplication javadoc and startup banner. The sample uses VectorStoreActivity exclusively; embeddings are produced inside the configured Spring AI VectorStore, not via a separate activity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Untrack .vscode/ IDE settings Slipped into the previous commit via git add -A. .vscode/ is per-developer IDE config — add to .gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * undo gitignore change * springai/multimodel: use LinkedHashMap for chatClients The unknown-model error message renders chatClients.keySet() into lastResponse, which is workflow state. HashMap iteration order isn't guaranteed across JVMs, so a replay on a different worker could produce a different rendered string and surface as a non-determinism error. LinkedHashMap iterates in insertion order, which is deterministic regardless of JVM. Addresses Copilot review on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/rag: capture lastResponse before signaling, not after waitForResponse() previously read getLastResponse() *after* the signal had been sent. If the workflow processed the signal between the signal call and the first poll, the captured baseline was already the new response and the loop waited for a second change that never came, then timed out. Fix matches the pattern McpApplication uses: capture previousResponse before the signal, pass it into waitForResponse, and poll for a value different from that pre-signal baseline. Addresses Copilot review on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * springai/multimodel: capture previous response before signaling Same bug pattern that RagApplication had: the polling loop compared against an empty-string baseline, so the very first poll could return the stale prior response and print it as if it were the new one. Capture previousResponse before sending the chat signal and wait until getLastResponse() differs from that pre-signal baseline. Also drop the redundant initial Thread.sleep(100) — the loop's own sleep handles backoff, and reading immediately is fine when we're comparing against the pre-signal baseline. Addresses Copilot review on PR #775. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove TASK_QUEUE.json Internal tracking file scoped to PR #775. All listed tasks and review threads have been resolved or replied to; the file's job is done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update README.md Co-authored-by: Maciej Dudkowski <maciej.dudkowski@temporal.io> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Maciej Dudkowski <maciej.dudkowski@temporal.io>
1 parent c4f8e78 commit f279e5f

31 files changed

Lines changed: 1926 additions & 13 deletions

.github/CODEOWNERS

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# Primary owners
22

3-
* @tsurdilo @temporalio/sdk @antmendoza
3+
* @tsurdilo @temporalio/sdk @antmendoza
4+
5+
# Below are owners for samples for modules
6+
# that are owned by teams other than the SDK team.
7+
# For each one, we add the owning team, as well as
8+
# @temporalio/sdk, so the SDK team can continue to
9+
# manage repo-wide concerns
10+
/springai/ @temporalio/ai-sdk @temporalio/sdk

.gitignore

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,8 @@ target
55
.DS_Store
66
.idea
77
.gradle
8-
/build
9-
/core/build
10-
/springboot/build
11-
/springboot-basic/build
12-
/out
13-
/core/out
14-
/springboot/out
15-
/springboot-basic/out
8+
**/build/
9+
**/out/
1610
.classpath
1711
.project
1812
.settings/

README.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
This repository contains samples that demonstrate various capabilities of
44
Temporal using the [Java SDK](https://github.com/temporalio/sdk-java).
55

6-
It contains two modules:
6+
It contains the following modules:
77
* [Core](/core): showcases many different SDK features.
88
* [SpringBoot](/springboot): showcases SpringBoot autoconfig integration.
99
* [SpringBoot Basic](/springboot-basic): Minimal sample showing SpringBoot autoconfig integration without any extra external dependencies.
10+
* [Spring AI](/springai): demonstrates the Temporal Spring AI integration — durable AI agents with chat models, tools, MCP servers, vector stores, and embeddings.
1011

1112
## Learn more about Temporal and Java SDK
1213

@@ -16,9 +17,7 @@ It contains two modules:
1617

1718
## Requirements
1819

19-
- Java 1.8+ for build and runtime of core samples
20-
- Java 1.8+ for build and runtime of SpringBoot samples when using SpringBoot 2
21-
- Java 1.17+ for build and runtime of Spring Boot samples when using SpringBoot 3
20+
- Java 17+
2221
- Local Temporal Server, easiest to get started would be using [Temporal CLI](https://github.com/temporalio/cli).
2322
For more options see docs [here](https://docs.temporal.io/kb/all-the-ways-to-run-a-cluster).
2423

@@ -213,3 +212,22 @@ To run any of the SpringBoot samples in your Temporal Cloud namespace:
213212
./gradlew bootRun --args='--spring.profiles.active=tc'
214213

215214
3. Follow the previous section from step 2
215+
216+
### Running Spring AI Samples
217+
218+
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.
219+
220+
Each sample is its own Spring Boot application with an interactive CLI. Run from the main repo dir:
221+
222+
./gradlew :springai:basic:bootRun
223+
./gradlew :springai:mcp:bootRun
224+
./gradlew :springai:multimodel:bootRun
225+
./gradlew :springai:rag:bootRun
226+
227+
All samples need an `OPENAI_API_KEY` environment variable; some need additional setup (see each sample's source for details).
228+
229+
More info on each sample:
230+
- [**Basic**](/springai/basic): Chat workflow with three tool flavors — activity-backed (`WeatherActivity`), plain workflow tools (`StringTools`), and `@SideEffectTool` (`TimestampTools`) — plus a `PromptChatMemoryAdvisor` for conversation history.
231+
- [**MCP**](/springai/mcp): Connects to a Model Context Protocol server and exposes its tools to the AI through Temporal activities. Defaults to the filesystem MCP server.
232+
- [**Multi-Model**](/springai/multimodel): Two providers in one workflow (OpenAI and Anthropic), per-model `ActivityOptions` overrides via a Spring bean, plus a route that exercises Anthropic's extended-thinking mode through provider-specific `ChatOptions` pass-through. Requires `ANTHROPIC_API_KEY` in addition to `OPENAI_API_KEY`.
233+
- [**RAG**](/springai/rag): Vector store + embeddings for retrieval-augmented generation. Add documents, then ask questions; the workflow searches the vector store and grounds the answer in the retrieved context.

gradle/springai.gradle

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Shared configuration for all Spring AI sample modules.
2+
// Applied via: apply from: "$rootDir/gradle/springai.gradle"
3+
//
4+
// Note on Spring Boot version skew: the root build.gradle pins the
5+
// org.springframework.boot Gradle plugin at $springBootPluginVersion (currently
6+
// 2.7.13) for the legacy springboot/ samples. Spring AI 1.1.0 requires Spring
7+
// Boot 3.5.x, which we get by importing the spring-boot-dependencies BOM at
8+
// $springBootVersionForSpringAi below. The plugin and the BOM are independent —
9+
// the plugin contributes bootJar/bootRun task wiring, the BOM dictates
10+
// dependency versions — so this works in practice even though the two version
11+
// numbers don't match. Long-term fix is to either move the plugin declaration
12+
// out of the root plugins block (so each module applies its own version) or
13+
// migrate the legacy springboot/ samples to Spring Boot 3.x; until one of those
14+
// happens, this skew is intentional.
15+
16+
apply plugin: 'org.springframework.boot'
17+
apply plugin: 'io.spring.dependency-management'
18+
19+
ext {
20+
springBootVersionForSpringAi = '3.5.3'
21+
springAiVersion = '1.1.0'
22+
}
23+
24+
java {
25+
sourceCompatibility = JavaVersion.VERSION_17
26+
targetCompatibility = JavaVersion.VERSION_17
27+
}
28+
29+
dependencyManagement {
30+
imports {
31+
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersionForSpringAi"
32+
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
33+
}
34+
}
35+
36+
dependencies {
37+
implementation "io.temporal:temporal-spring-boot-starter:$javaSDKVersion"
38+
implementation "io.temporal:temporal-spring-ai:$javaSDKVersion"
39+
// temporal-spring-ai declares temporal-sdk as compileOnly, so bring it in explicitly.
40+
implementation "io.temporal:temporal-sdk:$javaSDKVersion"
41+
42+
// Spring Boot
43+
implementation 'org.springframework.boot:spring-boot-starter'
44+
45+
dependencies {
46+
errorproneJavac('com.google.errorprone:javac:9+181-r4173-1')
47+
errorprone('com.google.errorprone:error_prone_core:2.28.0')
48+
}
49+
}
50+
51+
bootJar {
52+
enabled = false
53+
}
54+
55+
jar {
56+
enabled = true
57+
}
58+
59+
bootRun {
60+
standardInput = System.in
61+
}

settings.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
rootProject.name = 'temporal-java-samples'
22
include 'core'
3+
include 'springai:basic'
4+
include 'springai:mcp'
5+
include 'springai:multimodel'
6+
include 'springai:rag'
37
include 'springboot'
48
include 'springboot-basic'
9+

springai/basic/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apply from: "$rootDir/gradle/springai.gradle"
2+
3+
dependencies {
4+
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
5+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.temporal.samples.springai.chat;
2+
3+
import io.temporal.client.WorkflowClient;
4+
import io.temporal.client.WorkflowOptions;
5+
import java.util.Scanner;
6+
import java.util.UUID;
7+
import org.springframework.boot.SpringApplication;
8+
import org.springframework.boot.autoconfigure.SpringBootApplication;
9+
import org.springframework.boot.context.event.ApplicationReadyEvent;
10+
import org.springframework.context.event.EventListener;
11+
import org.springframework.stereotype.Component;
12+
13+
/**
14+
* Example application demonstrating the Spring AI Temporal plugin.
15+
*
16+
* <p>Starts an interactive chat workflow where each AI call is a durable Temporal activity with
17+
* automatic retries and timeout handling.
18+
*/
19+
@SpringBootApplication
20+
public class ChatExampleApplication {
21+
22+
public static void main(String[] args) {
23+
SpringApplication.run(ChatExampleApplication.class, args);
24+
}
25+
}
26+
27+
@Component
28+
class ChatRunner {
29+
30+
private final WorkflowClient workflowClient;
31+
32+
ChatRunner(WorkflowClient workflowClient) {
33+
this.workflowClient = workflowClient;
34+
}
35+
36+
@EventListener(ApplicationReadyEvent.class)
37+
public void run() {
38+
String workflowId = "chat-" + UUID.randomUUID().toString().substring(0, 8);
39+
40+
System.out.println("\n===========================================");
41+
System.out.println(" Spring AI + Temporal Chat Demo");
42+
System.out.println("===========================================");
43+
System.out.println("Workflow ID: " + workflowId);
44+
System.out.println("Type messages, or 'quit' to exit.\n");
45+
46+
// Start the chat workflow
47+
ChatWorkflow workflow =
48+
workflowClient.newWorkflowStub(
49+
ChatWorkflow.class,
50+
WorkflowOptions.newBuilder()
51+
.setWorkflowId(workflowId)
52+
.setTaskQueue("spring-ai-example")
53+
.build());
54+
55+
WorkflowClient.start(workflow::run, "You are a helpful assistant. Be concise.");
56+
57+
// Get stub for the running workflow
58+
ChatWorkflow chat = workflowClient.newWorkflowStub(ChatWorkflow.class, workflowId);
59+
60+
// Interactive loop
61+
try (Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8)) {
62+
while (true) {
63+
System.out.print("You: ");
64+
String input = scanner.nextLine().trim();
65+
66+
if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) {
67+
chat.end();
68+
break;
69+
}
70+
71+
if (input.isEmpty()) {
72+
continue;
73+
}
74+
75+
try {
76+
String response = chat.chat(input);
77+
System.out.println("Assistant: " + response + "\n");
78+
} catch (Exception e) {
79+
System.err.println("Error: " + e.getMessage() + "\n");
80+
}
81+
}
82+
}
83+
84+
System.out.println("Goodbye!");
85+
System.exit(0);
86+
}
87+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.temporal.samples.springai.chat;
2+
3+
import io.temporal.workflow.SignalMethod;
4+
import io.temporal.workflow.UpdateMethod;
5+
import io.temporal.workflow.WorkflowInterface;
6+
import io.temporal.workflow.WorkflowMethod;
7+
8+
/**
9+
* A chat workflow that maintains a conversation with an AI model.
10+
*
11+
* <p>The workflow runs until explicitly ended via the {@link #end()} signal. Messages can be sent
12+
* via the {@link #chat(String)} update method, which returns the AI's response synchronously.
13+
*/
14+
@WorkflowInterface
15+
public interface ChatWorkflow {
16+
17+
/**
18+
* Starts the chat workflow and waits until ended.
19+
*
20+
* @param systemPrompt the system prompt that defines the AI's behavior
21+
* @return a summary when the chat ends
22+
*/
23+
@WorkflowMethod
24+
String run(String systemPrompt);
25+
26+
/**
27+
* Sends a message to the AI and returns its response.
28+
*
29+
* @param message the user's message
30+
* @return the AI's response
31+
*/
32+
@UpdateMethod
33+
String chat(String message);
34+
35+
/** Ends the chat session. */
36+
@SignalMethod
37+
void end();
38+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package io.temporal.samples.springai.chat;
2+
3+
import io.temporal.activity.ActivityOptions;
4+
import io.temporal.common.RetryOptions;
5+
import io.temporal.springai.chat.TemporalChatClient;
6+
import io.temporal.springai.model.ActivityChatModel;
7+
import io.temporal.workflow.Workflow;
8+
import io.temporal.workflow.WorkflowInit;
9+
import java.time.Duration;
10+
import org.springframework.ai.chat.client.ChatClient;
11+
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
12+
import org.springframework.ai.chat.memory.ChatMemory;
13+
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
14+
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
15+
16+
/**
17+
* Implementation of the chat workflow using Spring AI's ChatClient with Temporal tools.
18+
*
19+
* <p>This demonstrates how to use the Spring AI plugin within a Temporal workflow:
20+
*
21+
* <ol>
22+
* <li>Build an {@link ActivityChatModel} via its factory to get a standard Spring AI ChatModel
23+
* backed by a durable Temporal activity
24+
* <li>Create activity stubs for tools (e.g., {@link WeatherActivity})
25+
* <li>Create deterministic tools (e.g., {@link StringTools})
26+
* <li>Create side-effect tools (e.g., {@link TimestampTools})
27+
* <li>Use {@link TemporalChatClient} to build a tool-aware chat client
28+
* </ol>
29+
*
30+
* <p>The AI model can call:
31+
*
32+
* <ul>
33+
* <li>{@code getWeather(city)} - Executes as a durable Temporal activity
34+
* <li>{@code getForecast(city, days)} - Executes as a durable Temporal activity
35+
* <li>{@code reverse(text)}, {@code countWords(text)}, etc. - Execute directly in workflow (plain
36+
* workflow tool)
37+
* <li>{@code getCurrentDateTime()}, {@code generateUuid()}, etc. - Wrapped in sideEffect
38+
* (@SideEffectTool)
39+
* </ul>
40+
*/
41+
public class ChatWorkflowImpl implements ChatWorkflow {
42+
43+
private final ChatClient chatClient;
44+
private boolean ended = false;
45+
private int messageCount = 0;
46+
47+
// @@@SNIPSTART samples-java-spring-ai-chat-workflow-init
48+
@WorkflowInit
49+
public ChatWorkflowImpl(String systemPrompt) {
50+
// Build an activity-backed chat model. The factory creates the activity stub
51+
// internally and registers per-call Summaries on the Temporal UI.
52+
ActivityChatModel activityChatModel = ActivityChatModel.forDefault();
53+
54+
// Create an activity stub for weather tools - these execute as durable activities
55+
WeatherActivity weatherTool =
56+
Workflow.newActivityStub(
57+
WeatherActivity.class,
58+
ActivityOptions.newBuilder()
59+
.setStartToCloseTimeout(Duration.ofSeconds(30))
60+
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build())
61+
.build());
62+
63+
// Create deterministic tools - these execute directly in the workflow
64+
StringTools stringTools = new StringTools();
65+
66+
// Create side-effect tools - these are wrapped in Workflow.sideEffect()
67+
// The result is recorded in history, making replay deterministic
68+
TimestampTools timestampTools = new TimestampTools();
69+
70+
// Create chat memory - uses in-memory storage that gets rebuilt on replay
71+
ChatMemory chatMemory =
72+
MessageWindowChatMemory.builder()
73+
.chatMemoryRepository(new InMemoryChatMemoryRepository())
74+
.maxMessages(20)
75+
.build();
76+
77+
// Build a TemporalChatClient with tools and memory
78+
// - Activity stubs (weatherTool) become durable AI tools
79+
// - plain workflow tool classes (stringTools) execute directly in workflow
80+
// - @SideEffectTool classes (timestampTools) are wrapped in sideEffect()
81+
// - PromptChatMemoryAdvisor maintains conversation history
82+
this.chatClient =
83+
TemporalChatClient.builder(activityChatModel)
84+
.defaultSystem(systemPrompt)
85+
.defaultTools(weatherTool, stringTools, timestampTools)
86+
.defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
87+
.build();
88+
}
89+
90+
// @@@SNIPEND
91+
92+
@Override
93+
public String run(String systemPrompt) {
94+
// systemPrompt is unused here on purpose — @WorkflowInit requires the constructor
95+
// and the @WorkflowMethod to share a parameter list, and the constructor above
96+
// already consumed it to build the chat client.
97+
Workflow.await(() -> ended);
98+
return "Chat ended after " + messageCount + " messages.";
99+
}
100+
101+
@Override
102+
public String chat(String message) {
103+
messageCount++;
104+
return chatClient.prompt().user(message).call().content();
105+
}
106+
107+
@Override
108+
public void end() {
109+
ended = true;
110+
}
111+
}

0 commit comments

Comments
 (0)