diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 12855485..55d30357 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,10 @@ # Primary owners -* @tsurdilo @temporalio/sdk @antmendoza \ No newline at end of file +* @tsurdilo @temporalio/sdk @antmendoza + +# Below are owners for samples for modules +# that are owned by teams other than the SDK team. +# For each one, we add the owning team, as well as +# @temporalio/sdk, so the SDK team can continue to +# manage repo-wide concerns +/springai/ @temporalio/ai-sdk @temporalio/sdk \ No newline at end of file diff --git a/.gitignore b/.gitignore index 038d5ef4..a1abef91 100644 --- a/.gitignore +++ b/.gitignore @@ -5,14 +5,8 @@ target .DS_Store .idea .gradle -/build -/core/build -/springboot/build -/springboot-basic/build -/out -/core/out -/springboot/out -/springboot-basic/out +**/build/ +**/out/ .classpath .project .settings/ diff --git a/README.md b/README.md index 2b97cbfa..24581870 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ This repository contains samples that demonstrate various capabilities of Temporal using the [Java SDK](https://github.com/temporalio/sdk-java). -It contains two modules: +It contains the following modules: * [Core](/core): showcases many different SDK features. * [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. ## Learn more about Temporal and Java SDK @@ -16,9 +17,7 @@ It contains two modules: ## Requirements -- Java 1.8+ for build and runtime of core samples -- Java 1.8+ for build and runtime of SpringBoot samples when using SpringBoot 2 -- Java 1.17+ for build and runtime of Spring Boot samples when using SpringBoot 3 +- Java 17+ - Local Temporal Server, easiest to get started would be using [Temporal CLI](https://github.com/temporalio/cli). For more options see docs [here](https://docs.temporal.io/kb/all-the-ways-to-run-a-cluster). @@ -213,3 +212,22 @@ To run any of the SpringBoot samples in your Temporal Cloud namespace: ./gradlew bootRun --args='--spring.profiles.active=tc' 3. Follow the previous section from step 2 + +### 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. + +Each sample is its own Spring Boot application with an interactive CLI. Run from the main repo dir: + + ./gradlew :springai:basic:bootRun + ./gradlew :springai:mcp:bootRun + ./gradlew :springai:multimodel:bootRun + ./gradlew :springai:rag:bootRun + +All samples need an `OPENAI_API_KEY` environment variable; some need additional setup (see each sample's source for details). + +More info on each sample: +- [**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. +- [**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. +- [**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`. +- [**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. diff --git a/gradle/springai.gradle b/gradle/springai.gradle new file mode 100644 index 00000000..394c8807 --- /dev/null +++ b/gradle/springai.gradle @@ -0,0 +1,61 @@ +// Shared configuration for all Spring AI sample modules. +// Applied via: apply from: "$rootDir/gradle/springai.gradle" +// +// Note on Spring Boot version skew: the root build.gradle pins the +// org.springframework.boot Gradle plugin at $springBootPluginVersion (currently +// 2.7.13) for the legacy springboot/ samples. Spring AI 1.1.0 requires Spring +// Boot 3.5.x, which we get by importing the spring-boot-dependencies BOM at +// $springBootVersionForSpringAi below. The plugin and the BOM are independent — +// the plugin contributes bootJar/bootRun task wiring, the BOM dictates +// dependency versions — so this works in practice even though the two version +// numbers don't match. Long-term fix is to either move the plugin declaration +// out of the root plugins block (so each module applies its own version) or +// migrate the legacy springboot/ samples to Spring Boot 3.x; until one of those +// happens, this skew is intentional. + +apply plugin: 'org.springframework.boot' +apply plugin: 'io.spring.dependency-management' + +ext { + springBootVersionForSpringAi = '3.5.3' + springAiVersion = '1.1.0' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencyManagement { + imports { + mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersionForSpringAi" + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + } +} + +dependencies { + implementation "io.temporal:temporal-spring-boot-starter:$javaSDKVersion" + implementation "io.temporal:temporal-spring-ai:$javaSDKVersion" + // temporal-spring-ai declares temporal-sdk as compileOnly, so bring it in explicitly. + implementation "io.temporal:temporal-sdk:$javaSDKVersion" + + // Spring Boot + implementation 'org.springframework.boot:spring-boot-starter' + + dependencies { + errorproneJavac('com.google.errorprone:javac:9+181-r4173-1') + errorprone('com.google.errorprone:error_prone_core:2.28.0') + } +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} + +bootRun { + standardInput = System.in +} diff --git a/settings.gradle b/settings.gradle index 65c976aa..b99ad281 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,9 @@ rootProject.name = 'temporal-java-samples' include 'core' +include 'springai:basic' +include 'springai:mcp' +include 'springai:multimodel' +include 'springai:rag' include 'springboot' include 'springboot-basic' + diff --git a/springai/basic/build.gradle b/springai/basic/build.gradle new file mode 100644 index 00000000..49fd72b0 --- /dev/null +++ b/springai/basic/build.gradle @@ -0,0 +1,5 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' +} diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatExampleApplication.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatExampleApplication.java new file mode 100644 index 00000000..e96c79c6 --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatExampleApplication.java @@ -0,0 +1,87 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import java.util.Scanner; +import java.util.UUID; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * Example application demonstrating the Spring AI Temporal plugin. + * + *
Starts an interactive chat workflow where each AI call is a durable Temporal activity with + * automatic retries and timeout handling. + */ +@SpringBootApplication +public class ChatExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatExampleApplication.class, args); + } +} + +@Component +class ChatRunner { + + private final WorkflowClient workflowClient; + + ChatRunner(WorkflowClient workflowClient) { + this.workflowClient = workflowClient; + } + + @EventListener(ApplicationReadyEvent.class) + public void run() { + String workflowId = "chat-" + UUID.randomUUID().toString().substring(0, 8); + + System.out.println("\n==========================================="); + System.out.println(" Spring AI + Temporal Chat Demo"); + System.out.println("==========================================="); + System.out.println("Workflow ID: " + workflowId); + System.out.println("Type messages, or 'quit' to exit.\n"); + + // Start the chat workflow + ChatWorkflow workflow = + workflowClient.newWorkflowStub( + ChatWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + .setTaskQueue("spring-ai-example") + .build()); + + WorkflowClient.start(workflow::run, "You are a helpful assistant. Be concise."); + + // Get stub for the running workflow + ChatWorkflow chat = workflowClient.newWorkflowStub(ChatWorkflow.class, workflowId); + + // Interactive loop + try (Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8)) { + while (true) { + System.out.print("You: "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + chat.end(); + break; + } + + if (input.isEmpty()) { + continue; + } + + try { + String response = chat.chat(input); + System.out.println("Assistant: " + response + "\n"); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage() + "\n"); + } + } + } + + System.out.println("Goodbye!"); + System.exit(0); + } +} diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java new file mode 100644 index 00000000..32d70db3 --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java @@ -0,0 +1,38 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * A chat workflow that maintains a conversation with an AI model. + * + *
The workflow runs until explicitly ended via the {@link #end()} signal. Messages can be sent + * via the {@link #chat(String)} update method, which returns the AI's response synchronously. + */ +@WorkflowInterface +public interface ChatWorkflow { + + /** + * Starts the chat workflow and waits until ended. + * + * @param systemPrompt the system prompt that defines the AI's behavior + * @return a summary when the chat ends + */ + @WorkflowMethod + String run(String systemPrompt); + + /** + * Sends a message to the AI and returns its response. + * + * @param message the user's message + * @return the AI's response + */ + @UpdateMethod + String chat(String message); + + /** Ends the chat session. */ + @SignalMethod + void end(); +} diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java new file mode 100644 index 00000000..887576c6 --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java @@ -0,0 +1,111 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.time.Duration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; + +/** + * Implementation of the chat workflow using Spring AI's ChatClient with Temporal tools. + * + *
This demonstrates how to use the Spring AI plugin within a Temporal workflow: + * + *
The AI model can call: + * + *
These tools execute directly in workflow context. Since they are pure functions (same output + * for same input, no side effects), they are safe for Temporal replay without any wrapping. + */ +// @@@SNIPSTART samples-java-spring-ai-plain-tool +public class StringTools { + + @Tool(description = "Reverse a string, returning the characters in opposite order") + public String reverse(@ToolParam(description = "The string to reverse") String input) { + if (input == null) { + return null; + } + return new StringBuilder(input).reverse().toString(); + } + + @Tool(description = "Count the number of words in a text") + public int countWords(@ToolParam(description = "The text to count words in") String text) { + if (text == null || text.isBlank()) { + return 0; + } + return text.trim().split("\\s+").length; + } + + @Tool(description = "Convert text to all uppercase letters") + public String toUpperCase(@ToolParam(description = "The text to convert") String text) { + if (text == null) { + return null; + } + return text.toUpperCase(java.util.Locale.ROOT); + } + + @Tool(description = "Convert text to all lowercase letters") + public String toLowerCase(@ToolParam(description = "The text to convert") String text) { + if (text == null) { + return null; + } + return text.toLowerCase(java.util.Locale.ROOT); + } + + @Tool(description = "Check if a string is a palindrome (reads the same forwards and backwards)") + public boolean isPalindrome(@ToolParam(description = "The text to check") String text) { + if (text == null) { + return false; + } + String normalized = text.toLowerCase(java.util.Locale.ROOT).replaceAll("\\s+", ""); + String reversed = new StringBuilder(normalized).reverse().toString(); + return normalized.equals(reversed); + } +} +// @@@SNIPEND diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java new file mode 100644 index 00000000..d6a0945a --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java @@ -0,0 +1,102 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.springai.tool.SideEffectTool; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Side-effect tools that return non-deterministic values. + * + *
This class demonstrates the use of {@link SideEffectTool} annotation for tools that are + * non-deterministic but don't need the full durability of an activity. + * + *
Side-effect tools are wrapped in {@code Workflow.sideEffect()}, which: + * + *
Use {@code @SideEffectTool} for: + * + *
Example usage: + * + *
{@code
+ * TimestampTools timestampTools = new TimestampTools();
+ * this.chatClient = TemporalChatClient.builder(activityChatModel)
+ * .defaultTools(timestampTools) // Wrapped in sideEffect()
+ * .build();
+ * }
+ */
+// @@@SNIPSTART samples-java-spring-ai-side-effect-tool
+@SideEffectTool
+public class TimestampTools {
+
+ private static final DateTimeFormatter FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault());
+
+ /**
+ * Gets the current date and time.
+ *
+ * This is non-deterministic (returns different values each time), but wrapped in sideEffect() + * it becomes safe for workflow replay. + * + * @return the current date and time as a formatted string + */ + @Tool(description = "Get the current date and time") + public String getCurrentDateTime() { + return FORMATTER.format(Instant.now()); + } + + /** + * Gets the current Unix timestamp in milliseconds. + * + * @return the current time in milliseconds since epoch + */ + @Tool(description = "Get the current Unix timestamp in milliseconds") + public long getCurrentTimestamp() { + return System.currentTimeMillis(); + } + + /** + * Generates a random UUID. + * + * @return a new random UUID string + */ + @Tool(description = "Generate a random UUID") + public String generateUuid() { + return UUID.randomUUID().toString(); + } + + /** + * Gets the current date and time in a specific timezone. + * + * @param timezone the timezone ID (e.g., "America/New_York", "UTC", "Europe/London") + * @return the current date and time in the specified timezone + */ + @Tool(description = "Get the current date and time in a specific timezone") + public String getDateTimeInTimezone( + @ToolParam(description = "Timezone ID (e.g., 'America/New_York', 'UTC', 'Europe/London')") + String timezone) { + try { + ZoneId zoneId = ZoneId.of(timezone); + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(zoneId); + return formatter.format(Instant.now()); + } catch (Exception e) { + return "Invalid timezone: " + timezone + ". Use formats like 'America/New_York' or 'UTC'."; + } + } +} +// @@@SNIPEND diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java new file mode 100644 index 00000000..2dcb8374 --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java @@ -0,0 +1,51 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Activity interface for weather-related operations. + * + *
This demonstrates how to combine Temporal's {@link ActivityInterface} with Spring AI's {@link + * Tool} annotation to create activity-based AI tools. + * + *
When passed to {@code TemporalChatClient.builder().defaultTools(weatherActivity)}, the AI + * model can call these methods, and they will execute as durable Temporal activities with automatic + * retries and timeout handling. + */ +// @@@SNIPSTART samples-java-spring-ai-activity-tool +@ActivityInterface +public interface WeatherActivity { + + /** + * Gets the current weather for a city. + * + *
The {@code @Tool} annotation makes this method available to the AI model, while the + * {@code @ActivityInterface} ensures it executes as a Temporal activity. + * + * @param city the name of the city + * @return a description of the current weather + */ + @Tool( + description = + "Get the current weather for a city. Returns temperature, conditions, and humidity.") + @ActivityMethod + String getWeather( + @ToolParam(description = "The name of the city (e.g., 'Seattle', 'New York')") String city); + + /** + * Gets the weather forecast for a city. + * + * @param city the name of the city + * @param days the number of days to forecast (1-7) + * @return the weather forecast + */ + @Tool(description = "Get the weather forecast for a city for the specified number of days.") + @ActivityMethod + String getForecast( + @ToolParam(description = "The name of the city") String city, + @ToolParam(description = "Number of days to forecast (1-7)") int days); +} +// @@@SNIPEND diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java new file mode 100644 index 00000000..03b01ee7 --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java @@ -0,0 +1,64 @@ +package io.temporal.samples.springai.chat; + +import java.util.Map; +import java.util.Random; +import org.springframework.stereotype.Component; + +/** + * Implementation of {@link WeatherActivity}. + * + *
This is a mock implementation that returns simulated weather data. In a real application, this + * would call an external weather API. + * + *
Note: This class is registered as a Spring {@code @Component} so it can be auto-discovered.
+ * The {@code SpringAiPlugin} will register it with Temporal workers.
+ */
+@Component
+public class WeatherActivityImpl implements WeatherActivity {
+
+ // Mock weather data for demo purposes
+ private static final Map This application shows how to use tools from MCP servers within Temporal workflows. It
+ * connects to a filesystem MCP server and provides an AI assistant that can read and write files.
+ *
+ * This workflow shows how to use tools from MCP servers within Temporal workflows. The AI model
+ * can call MCP tools (like file system operations) as durable activities.
+ */
+@WorkflowInterface
+public interface McpWorkflow {
+
+ /**
+ * Runs the workflow until ended.
+ *
+ * @return summary of the chat session
+ */
+ @WorkflowMethod
+ String run();
+
+ /**
+ * Sends a message to the AI assistant with MCP tools available.
+ *
+ * @param message the user message
+ */
+ @SignalMethod
+ void chat(String message);
+
+ /**
+ * Gets the last response from the AI.
+ *
+ * @return the last response
+ */
+ @QueryMethod
+ String getLastResponse();
+
+ /**
+ * Lists the available MCP tools.
+ *
+ * @return list of available tools
+ */
+ @QueryMethod
+ String listTools();
+
+ /** Ends the chat session. */
+ @SignalMethod
+ void end();
+}
diff --git a/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java
new file mode 100644
index 00000000..c9bcaa99
--- /dev/null
+++ b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java
@@ -0,0 +1,131 @@
+package io.temporal.samples.springai.mcp;
+
+import io.temporal.springai.chat.TemporalChatClient;
+import io.temporal.springai.mcp.ActivityMcpClient;
+import io.temporal.springai.mcp.McpToolCallback;
+import io.temporal.springai.model.ActivityChatModel;
+import io.temporal.workflow.Workflow;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
+import org.springframework.ai.chat.memory.MessageWindowChatMemory;
+import org.springframework.ai.tool.ToolCallback;
+
+/**
+ * Implementation of the MCP workflow.
+ *
+ * This demonstrates how to use MCP tools from external servers within a Temporal workflow. The
+ * workflow:
+ *
+ * This example uses the filesystem MCP server which provides tools like:
+ *
+ * This demonstrates how to configure multiple AI providers in a Spring Boot application. Each
+ * model is registered as a separate bean with a unique name.
+ *
+ * In workflows, these can be accessed via:
+ *
+ * The Anthropic entry bumps the start-to-close timeout (reasoning models can take minutes) and
+ * caps the schedule-to-close so a stuck request can't keep re-attempting forever. Building on
+ * {@link ActivityChatModel#defaultActivityOptions()} preserves the plugin's
+ * non-retryable-AI-error classification without having to restate it.
+ *
+ * The workflow still uses the per-call {@code ChatClient.defaultOptions(...)} path for things
+ * that change per prompt (see the {@code think:} route in {@code MultiModelWorkflowImpl} —
+ * extended thinking is enabled per call, not globally).
+ */
+ // @@@SNIPSTART samples-java-spring-ai-per-model-options
+ @Bean
+ public ChatModelActivityOptions chatModelActivityOptions() {
+ return new ChatModelActivityOptions(
+ Map.of(
+ "anthropicChatModel",
+ ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
+ .setStartToCloseTimeout(Duration.ofMinutes(5))
+ .setScheduleToCloseTimeout(Duration.ofMinutes(15))
+ .build()));
+ }
+ // @@@SNIPEND
+}
diff --git a/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelApplication.java b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelApplication.java
new file mode 100644
index 00000000..0eadc4b8
--- /dev/null
+++ b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelApplication.java
@@ -0,0 +1,139 @@
+package io.temporal.samples.springai.multimodel;
+
+import io.temporal.client.WorkflowClient;
+import io.temporal.client.WorkflowOptions;
+import java.util.Scanner;
+import java.util.UUID;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Example application demonstrating multi-model support with different AI providers.
+ *
+ * This application shows how to use different AI providers (OpenAI and Anthropic) within the
+ * same Temporal workflow. It provides an interactive CLI where you can send messages to different
+ * models.
+ *
+ * This workflow shows how to use different AI models for different purposes within the same
+ * workflow.
+ */
+@WorkflowInterface
+public interface MultiModelWorkflow {
+
+ /**
+ * Runs the workflow until ended.
+ *
+ * @return summary of the chat session
+ */
+ @WorkflowMethod
+ String run();
+
+ /**
+ * Sends a message to a specific model.
+ *
+ * @param modelName the name of the model to use ("openai", "anthropic", "think", or "default")
+ * @param message the user message
+ */
+ @SignalMethod
+ void chat(String modelName, String message);
+
+ /**
+ * Gets the last response.
+ *
+ * @return the last response from any model
+ */
+ @QueryMethod
+ String getLastResponse();
+
+ /** Ends the chat session. */
+ @SignalMethod
+ void end();
+}
diff --git a/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java
new file mode 100644
index 00000000..3a1e55c6
--- /dev/null
+++ b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java
@@ -0,0 +1,147 @@
+package io.temporal.samples.springai.multimodel;
+
+import io.temporal.springai.chat.TemporalChatClient;
+import io.temporal.springai.model.ActivityChatModel;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.WorkflowInit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.springframework.ai.anthropic.AnthropicChatOptions;
+import org.springframework.ai.anthropic.api.AnthropicApi;
+import org.springframework.ai.chat.client.ChatClient;
+
+/**
+ * Implementation of the multi-model workflow.
+ *
+ * This demonstrates how to use multiple AI providers in a single workflow:
+ *
+ * The workflow uses two patterns for wiring activity options:
+ *
+ * This application shows how to use the plugin's VectorStoreActivity to build a durable
+ * knowledge base within Temporal workflows. Embeddings are produced by whichever {@code
+ * EmbeddingModel} the configured Spring AI {@code VectorStore} uses internally — this sample does
+ * not invoke {@code EmbeddingModelActivity} directly.
+ *
+ * This workflow shows how to use VectorStoreActivity and EmbeddingModelActivity to build a
+ * durable knowledge base that can be queried with natural language.
+ */
+@WorkflowInterface
+public interface RagWorkflow {
+
+ /**
+ * Runs the workflow until ended.
+ *
+ * @return summary of the session
+ */
+ @WorkflowMethod
+ String run();
+
+ /**
+ * Adds a document to the knowledge base.
+ *
+ * @param id unique identifier for the document
+ * @param content the document content
+ */
+ @SignalMethod
+ void addDocument(String id, String content);
+
+ /**
+ * Asks a question using RAG - retrieves relevant documents and generates an answer.
+ *
+ * @param question the question to answer
+ */
+ @SignalMethod
+ void ask(String question);
+
+ /**
+ * Searches for similar documents without generating an answer.
+ *
+ * @param query the search query
+ * @param topK number of results to return
+ */
+ @SignalMethod
+ void search(String query, int topK);
+
+ /**
+ * Gets the last response from the AI or search.
+ *
+ * @return the last response
+ */
+ @QueryMethod
+ String getLastResponse();
+
+ /**
+ * Gets the current document count.
+ *
+ * @return number of documents in the knowledge base
+ */
+ @QueryMethod
+ int getDocumentCount();
+
+ /** Ends the session. */
+ @SignalMethod
+ void end();
+}
diff --git a/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java
new file mode 100644
index 00000000..6ea1f1b1
--- /dev/null
+++ b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java
@@ -0,0 +1,167 @@
+package io.temporal.samples.springai.rag;
+
+import io.temporal.activity.ActivityOptions;
+import io.temporal.common.RetryOptions;
+import io.temporal.springai.activity.VectorStoreActivity;
+import io.temporal.springai.chat.TemporalChatClient;
+import io.temporal.springai.model.ActivityChatModel;
+import io.temporal.springai.model.VectorStoreTypes;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.WorkflowInit;
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.springframework.ai.chat.client.ChatClient;
+
+/**
+ * Implementation of the RAG workflow.
+ *
+ * This demonstrates:
+ *
+ * All operations are durable Temporal activities - if the worker restarts, the workflow will
+ * continue from where it left off.
+ */
+public class RagWorkflowImpl implements RagWorkflow {
+
+ private final VectorStoreActivity vectorStore;
+ private final ChatClient chatClient;
+
+ private String lastResponse = "";
+ private int documentCount = 0;
+ private boolean ended = false;
+
+ @WorkflowInit
+ public RagWorkflowImpl() {
+ // Create activity stubs with appropriate timeouts
+ ActivityOptions activityOptions =
+ ActivityOptions.newBuilder()
+ .setStartToCloseTimeout(Duration.ofMinutes(2))
+ .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build())
+ .build();
+
+ this.vectorStore = Workflow.newActivityStub(VectorStoreActivity.class, activityOptions);
+
+ // Create the chat client
+ ActivityChatModel chatModel = ActivityChatModel.forDefault();
+ this.chatClient =
+ TemporalChatClient.builder(chatModel)
+ .defaultSystem(
+ """
+ You are a helpful assistant that answers questions based on the provided context.
+
+ When answering:
+ - Use only the information from the context provided
+ - If the context doesn't contain relevant information, say so
+ - Be concise and direct
+ """)
+ .build();
+ }
+
+ @Override
+ public String run() {
+ Workflow.await(() -> ended);
+ return "Session ended. Processed " + documentCount + " documents.";
+ }
+
+ @Override
+ public void addDocument(String id, String content) {
+ // Create a document and add it to the vector store
+ // The vector store will use the embedding model to generate embeddings
+ VectorStoreTypes.Document doc = new VectorStoreTypes.Document(id, content);
+ vectorStore.addDocuments(new VectorStoreTypes.AddDocumentsInput(List.of(doc)));
+
+ documentCount++;
+ lastResponse =
+ "Added document '" + id + "' to knowledge base. Total documents: " + documentCount;
+ }
+
+ @Override
+ public void ask(String question) {
+ // Step 1: Search for relevant documents
+ VectorStoreTypes.SearchOutput searchResults =
+ vectorStore.similaritySearch(new VectorStoreTypes.SearchInput(question, 3));
+
+ if (searchResults.documents().isEmpty()) {
+ lastResponse = "No relevant documents found in the knowledge base.";
+ return;
+ }
+
+ // Step 2: Build context from search results
+ String context =
+ searchResults.documents().stream()
+ .map(result -> result.document().text())
+ .collect(Collectors.joining("\n\n---\n\n"));
+
+ // Step 3: Generate answer using the context
+ lastResponse =
+ chatClient
+ .prompt()
+ .user(
+ u ->
+ u.text(
+ """
+ Context:
+ {context}
+
+ Question: {question}
+
+ Answer based on the context above:
+ """)
+ .param("context", context)
+ .param("question", question))
+ .call()
+ .content();
+ }
+
+ @Override
+ public void search(String query, int topK) {
+ VectorStoreTypes.SearchOutput searchResults =
+ vectorStore.similaritySearch(new VectorStoreTypes.SearchInput(query, topK));
+
+ if (searchResults.documents().isEmpty()) {
+ lastResponse = "No matching documents found.";
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder("Search results:\n\n");
+ for (int i = 0; i < searchResults.documents().size(); i++) {
+ VectorStoreTypes.SearchResult result = searchResults.documents().get(i);
+ sb.append(
+ String.format(
+ "%d. [Score: %.3f] %s\n %s\n\n",
+ i + 1,
+ result.score(),
+ result.document().id(),
+ truncate(result.document().text(), 100)));
+ }
+ lastResponse = sb.toString();
+ }
+
+ @Override
+ public String getLastResponse() {
+ return lastResponse;
+ }
+
+ @Override
+ public int getDocumentCount() {
+ return documentCount;
+ }
+
+ @Override
+ public void end() {
+ ended = true;
+ }
+
+ private String truncate(String text, int maxLength) {
+ if (text.length() <= maxLength) {
+ return text;
+ }
+ return text.substring(0, maxLength) + "...";
+ }
+}
diff --git a/springai/rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java b/springai/rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java
new file mode 100644
index 00000000..7909e92d
--- /dev/null
+++ b/springai/rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java
@@ -0,0 +1,32 @@
+package io.temporal.samples.springai.rag;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.vectorstore.SimpleVectorStore;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration for the vector store.
+ *
+ * This example uses Spring AI's SimpleVectorStore, an in-memory vector store that's perfect for
+ * demos and testing. In production, you'd use a real vector database like Pinecone, Weaviate,
+ * Milvus, or pgvector.
+ */
+@Configuration
+public class VectorStoreConfig {
+
+ /**
+ * Creates an in-memory vector store using the provided embedding model.
+ *
+ * The SimpleVectorStore stores vectors in memory and uses the embedding model to convert text
+ * to vectors when documents are added.
+ *
+ * @param embeddingModel the embedding model to use for vectorization
+ * @return the configured vector store
+ */
+ @Bean
+ public VectorStore vectorStore(EmbeddingModel embeddingModel) {
+ return SimpleVectorStore.builder(embeddingModel).build();
+ }
+}
diff --git a/springai/rag/src/main/resources/application.yaml b/springai/rag/src/main/resources/application.yaml
new file mode 100644
index 00000000..b8889cb7
--- /dev/null
+++ b/springai/rag/src/main/resources/application.yaml
@@ -0,0 +1,25 @@
+spring:
+ main:
+ banner-mode: off
+ web-application-type: none
+ ai:
+ openai:
+ api-key: ${OPENAI_API_KEY}
+ chat:
+ options:
+ model: gpt-4o-mini
+ embedding:
+ options:
+ model: text-embedding-3-small
+
+ temporal:
+ connection:
+ target: localhost:7233
+ workers:
+ - task-queue: rag-example-queue
+ workflow-classes:
+ - io.temporal.samples.springai.rag.RagWorkflowImpl
+
+logging:
+ level:
+ io.temporal.springai: DEBUG
Usage
+ *
+ *
+ * Commands:
+ * tools - List available MCP tools
+ * <any message> - Chat with the AI (it can use file tools)
+ * quit - End the chat
+ *
+ *
+ * Example Interactions
+ *
+ *
+ * > List files in the current directory
+ * [AI uses list_directory tool and returns results]
+ *
+ * > Create a file called hello.txt with "Hello from MCP!"
+ * [AI uses write_file tool]
+ *
+ * > Read the contents of hello.txt
+ * [AI uses read_file tool]
+ *
+ *
+ * Prerequisites
+ *
+ *
+ *
+ */
+@SpringBootApplication
+public class McpApplication {
+
+ private static final String TASK_QUEUE = "mcp-example-queue";
+
+ @Autowired private WorkflowClient workflowClient;
+
+ public static void main(String[] args) throws Exception {
+ // The filesystem MCP server refuses to start if the allowed path doesn't
+ // exist, so create it up front. Must happen before SpringApplication.run —
+ // the MCP client connects during context startup.
+ String allowedPath = System.getenv().getOrDefault("MCP_ALLOWED_PATH", "/tmp/mcp-example");
+ Files.createDirectories(Path.of(allowedPath));
+
+ SpringApplication.run(McpApplication.class, args);
+ }
+
+ /** Runs after workers are started (ApplicationReadyEvent fires after CommandLineRunner). */
+ @EventListener(ApplicationReadyEvent.class)
+ public void onReady() throws Exception {
+ // Start a new workflow
+ String workflowId = "mcp-example-" + UUID.randomUUID().toString().substring(0, 8);
+ McpWorkflow workflow =
+ workflowClient.newWorkflowStub(
+ McpWorkflow.class,
+ WorkflowOptions.newBuilder()
+ .setTaskQueue(TASK_QUEUE)
+ .setWorkflowId(workflowId)
+ .build());
+
+ // Start the workflow asynchronously
+ WorkflowClient.start(workflow::run);
+
+ // Give the workflow time to initialize (first workflow task must complete)
+ Thread.sleep(1000);
+
+ System.out.println("\n=== MCP Tools Demo ===");
+ System.out.println("Workflow ID: " + workflowId);
+ System.out.println("\nThis demo uses the filesystem MCP server.");
+ System.out.println("The AI can read, write, and list files in the allowed directory.");
+ System.out.println("\nCommands:");
+ System.out.println(" tools - List available MCP tools");
+ System.out.println("
+ *
+ *
+ *
+ *
+ */
+public class McpWorkflowImpl implements McpWorkflow {
+
+ private ChatClient chatClient;
+ private List
+ *
+ */
+@Configuration
+public class ChatModelConfig {
+
+ @Value("${spring.ai.openai.api-key}")
+ private String openAiApiKey;
+
+ @Value("${spring.ai.anthropic.api-key}")
+ private String anthropicApiKey;
+
+ /**
+ * OpenAI model using gpt-4o-mini for quick, cost-effective responses. Marked as @Primary so it's
+ * used when no specific model is requested.
+ */
+ @Bean
+ @Primary
+ public ChatModel openAiChatModel() {
+ OpenAiApi api = OpenAiApi.builder().apiKey(openAiApiKey).build();
+ OpenAiChatOptions options =
+ OpenAiChatOptions.builder().model("gpt-4o-mini").temperature(0.7).build();
+ return OpenAiChatModel.builder().openAiApi(api).defaultOptions(options).build();
+ }
+
+ /** Anthropic model using Claude for complex reasoning tasks. */
+ @Bean
+ public ChatModel anthropicChatModel() {
+ AnthropicApi api = AnthropicApi.builder().apiKey(anthropicApiKey).build();
+ AnthropicChatOptions options =
+ AnthropicChatOptions.builder()
+ .model("claude-sonnet-4-20250514")
+ .temperature(0.3) // Lower temperature for more focused reasoning
+ .build();
+ return AnthropicChatModel.builder().anthropicApi(api).defaultOptions(options).build();
+ }
+
+ /**
+ * Per-model {@link ActivityOptions} overrides, declared as a single Spring bean. When present,
+ * {@link ActivityChatModel#forModel(String)} and {@link ActivityChatModel#forDefault()} consult
+ * this map before falling back to the plugin's defaults — so workflows can build a
+ * fully-configured chat model with nothing more than {@code ActivityChatModel.forModel(name)}.
+ *
+ * Usage
+ *
+ *
+ * Commands:
+ * openai: <message> - Send to OpenAI (gpt-4o-mini)
+ * anthropic: <message> - Send to Anthropic (Claude)
+ * think: <message> - Send to Anthropic with extended thinking enabled
+ * <message> - No prefix routes to the default model (the @Primary bean)
+ * quit - End the chat
+ *
+ *
+ * Prerequisites
+ *
+ *
+ *
+ */
+@SpringBootApplication
+public class MultiModelApplication implements CommandLineRunner {
+
+ private static final String TASK_QUEUE = "multi-model-queue";
+
+ @Autowired private WorkflowClient workflowClient;
+
+ public static void main(String[] args) {
+ SpringApplication.run(MultiModelApplication.class, args);
+ }
+
+ @Override
+ public void run(String... args) throws Exception {
+ // Start a new workflow
+ String workflowId = "multi-model-" + UUID.randomUUID().toString().substring(0, 8);
+ MultiModelWorkflow workflow =
+ workflowClient.newWorkflowStub(
+ MultiModelWorkflow.class,
+ WorkflowOptions.newBuilder()
+ .setTaskQueue(TASK_QUEUE)
+ .setWorkflowId(workflowId)
+ .build());
+
+ // Start the workflow asynchronously
+ WorkflowClient.start(workflow::run);
+
+ System.out.println("\n=== Multi-Provider Chat Demo ===");
+ System.out.println("Workflow ID: " + workflowId);
+ System.out.println("\nAvailable models:");
+ System.out.println(" openai: OpenAI gpt-4o-mini");
+ System.out.println(" anthropic: Anthropic Claude");
+ System.out.println(" think: Anthropic Claude with extended thinking (per-call options)");
+ System.out.println(" default: no prefix — routes to the @Primary model (OpenAI)");
+ System.out.println("\nCommands:");
+ System.out.println(" openai:
+ *
+ *
+ *
+ *
+ */
+public class MultiModelWorkflowImpl implements MultiModelWorkflow {
+
+ private final MapUsage
+ *
+ *
+ * Commands:
+ * add <id> <content> - Add a document to the knowledge base
+ * ask <question> - Ask a question (uses RAG)
+ * search <query> - Search for similar documents
+ * count - Show document count
+ * quit - End the session
+ *
+ *
+ * Prerequisites
+ *
+ *
+ *
+ */
+@SpringBootApplication
+public class RagApplication implements CommandLineRunner {
+
+ private static final String TASK_QUEUE = "rag-example-queue";
+
+ @Autowired private WorkflowClient workflowClient;
+
+ public static void main(String[] args) {
+ SpringApplication.run(RagApplication.class, args);
+ }
+
+ @Override
+ public void run(String... args) throws Exception {
+ // Start a new workflow
+ String workflowId = "rag-example-" + UUID.randomUUID().toString().substring(0, 8);
+ RagWorkflow workflow =
+ workflowClient.newWorkflowStub(
+ RagWorkflow.class,
+ WorkflowOptions.newBuilder()
+ .setTaskQueue(TASK_QUEUE)
+ .setWorkflowId(workflowId)
+ .build());
+
+ // Start the workflow asynchronously
+ WorkflowClient.start(workflow::run);
+
+ System.out.println("\n=== RAG (Retrieval-Augmented Generation) Demo ===");
+ System.out.println("Workflow ID: " + workflowId);
+ System.out.println("\nThis demo uses VectorStoreActivity to build a durable knowledge");
+ System.out.println("base with semantic search (embeddings are handled by the configured");
+ System.out.println("Spring AI VectorStore).");
+ System.out.println("\nCommands:");
+ System.out.println(" add
+ *
+ *
+ *