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: + * + *

    + *
  1. Build an {@link ActivityChatModel} via its factory to get a standard Spring AI ChatModel + * backed by a durable Temporal activity + *
  2. Create activity stubs for tools (e.g., {@link WeatherActivity}) + *
  3. Create deterministic tools (e.g., {@link StringTools}) + *
  4. Create side-effect tools (e.g., {@link TimestampTools}) + *
  5. Use {@link TemporalChatClient} to build a tool-aware chat client + *
+ * + *

The AI model can call: + * + *

+ */ +public class ChatWorkflowImpl implements ChatWorkflow { + + private final ChatClient chatClient; + private boolean ended = false; + private int messageCount = 0; + + // @@@SNIPSTART samples-java-spring-ai-chat-workflow-init + @WorkflowInit + public ChatWorkflowImpl(String systemPrompt) { + // Build an activity-backed chat model. The factory creates the activity stub + // internally and registers per-call Summaries on the Temporal UI. + ActivityChatModel activityChatModel = ActivityChatModel.forDefault(); + + // Create an activity stub for weather tools - these execute as durable activities + WeatherActivity weatherTool = + Workflow.newActivityStub( + WeatherActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + + // Create deterministic tools - these execute directly in the workflow + StringTools stringTools = new StringTools(); + + // Create side-effect tools - these are wrapped in Workflow.sideEffect() + // The result is recorded in history, making replay deterministic + TimestampTools timestampTools = new TimestampTools(); + + // Create chat memory - uses in-memory storage that gets rebuilt on replay + ChatMemory chatMemory = + MessageWindowChatMemory.builder() + .chatMemoryRepository(new InMemoryChatMemoryRepository()) + .maxMessages(20) + .build(); + + // Build a TemporalChatClient with tools and memory + // - Activity stubs (weatherTool) become durable AI tools + // - plain workflow tool classes (stringTools) execute directly in workflow + // - @SideEffectTool classes (timestampTools) are wrapped in sideEffect() + // - PromptChatMemoryAdvisor maintains conversation history + this.chatClient = + TemporalChatClient.builder(activityChatModel) + .defaultSystem(systemPrompt) + .defaultTools(weatherTool, stringTools, timestampTools) + .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build()) + .build(); + } + + // @@@SNIPEND + + @Override + public String run(String systemPrompt) { + // systemPrompt is unused here on purpose — @WorkflowInit requires the constructor + // and the @WorkflowMethod to share a parameter list, and the constructor above + // already consumed it to build the chat client. + Workflow.await(() -> ended); + return "Chat ended after " + messageCount + " messages."; + } + + @Override + public String chat(String message) { + messageCount++; + return chatClient.prompt().user(message).call().content(); + } + + @Override + public void end() { + ended = true; + } +} diff --git a/springai/basic/src/main/java/io/temporal/samples/springai/chat/StringTools.java b/springai/basic/src/main/java/io/temporal/samples/springai/chat/StringTools.java new file mode 100644 index 00000000..2f58321b --- /dev/null +++ b/springai/basic/src/main/java/io/temporal/samples/springai/chat/StringTools.java @@ -0,0 +1,57 @@ +package io.temporal.samples.springai.chat; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Deterministic string manipulation tools. + * + *

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 WEATHER_DATA = + Map.of( + "seattle", new String[] {"Rainy", "55"}, + "new york", new String[] {"Partly Cloudy", "62"}, + "los angeles", new String[] {"Sunny", "78"}, + "chicago", new String[] {"Windy", "48"}, + "miami", new String[] {"Hot and Humid", "88"}, + "denver", new String[] {"Clear", "45"}, + "boston", new String[] {"Overcast", "52"}); + + @Override + public String getWeather(String city) { + String normalizedCity = city.toLowerCase(java.util.Locale.ROOT).trim(); + String[] weather = WEATHER_DATA.getOrDefault(normalizedCity, new String[] {"Unknown", "60"}); + + int humidity = 40 + new Random().nextInt(40); // 40-80% + + return String.format( + "Weather in %s: %s, Temperature: %s°F, Humidity: %d%%", + city, weather[0], weather[1], humidity); + } + + @Override + public String getForecast(String city, int days) { + if (days < 1 || days > 7) { + return "Error: Days must be between 1 and 7"; + } + + StringBuilder forecast = new StringBuilder(); + forecast.append(String.format("%d-day forecast for %s:\n", days, city)); + + String[] conditions = {"Sunny", "Partly Cloudy", "Cloudy", "Rainy", "Clear"}; + Random random = new Random(); + + for (int i = 1; i <= days; i++) { + String condition = conditions[random.nextInt(conditions.length)]; + int high = 50 + random.nextInt(30); + int low = high - 10 - random.nextInt(10); + forecast.append( + String.format(" Day %d: %s, High: %d°F, Low: %d°F\n", i, condition, high, low)); + } + + return forecast.toString(); + } +} diff --git a/springai/basic/src/main/resources/application.yaml b/springai/basic/src/main/resources/application.yaml new file mode 100644 index 00000000..93e1597e --- /dev/null +++ b/springai/basic/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + application: + name: spring-ai-temporal-example + main: + web-application-type: none + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + temperature: 0.7 + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: spring-ai-example + workflow-classes: + - io.temporal.samples.springai.chat.ChatWorkflowImpl + activity-beans: + - weatherActivityImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/mcp/build.gradle b/springai/mcp/build.gradle new file mode 100644 index 00000000..33446191 --- /dev/null +++ b/springai/mcp/build.gradle @@ -0,0 +1,8 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-starter-mcp-client' + implementation 'org.springframework.ai:spring-ai-rag' + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java new file mode 100644 index 00000000..0e831f22 --- /dev/null +++ b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java @@ -0,0 +1,144 @@ +package io.temporal.samples.springai.mcp; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Scanner; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; + +/** + * Example application demonstrating MCP (Model Context Protocol) integration. + * + *

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. + * + *

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

+ * + *
    + *
  1. Start a Temporal dev server: {@code temporal server start-dev} + *
  2. Set OPENAI_API_KEY environment variable + *
  3. Ensure Node.js/npx is available (for MCP server) + *
  4. Optionally set MCP_ALLOWED_PATH (defaults to /tmp/mcp-example) + *
  5. Run: {@code ./gradlew :springai:mcp:bootRun} + *
+ */ +@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(" - Chat with the AI"); + System.out.println(" quit - End the chat"); + System.out.println(); + + // Get a workflow stub for sending signals/queries + McpWorkflow workflowStub = workflowClient.newWorkflowStub(McpWorkflow.class, workflowId); + + // Note: tools command may take a moment to work while workflow initializes + System.out.println("Type 'tools' to list available MCP tools.\n"); + + Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8); + while (true) { + System.out.print("> "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit")) { + workflowStub.end(); + System.out.println("Chat ended. Goodbye!"); + break; + } + + if (input.equalsIgnoreCase("tools")) { + System.out.println(workflowStub.listTools()); + continue; + } + + if (input.isEmpty()) { + continue; + } + + System.out.println("[Processing...]"); + + // Capture current response BEFORE sending, so we can detect when it changes + String previousResponse = workflowStub.getLastResponse(); + + // Send the message via signal + workflowStub.chat(input); + + // Poll until the response changes (workflow has processed our message) + for (int i = 0; i < 600; i++) { // Wait up to 60 seconds (MCP tools can be slow) + String response = workflowStub.getLastResponse(); + if (!response.equals(previousResponse)) { + System.out.println("\n[AI]: " + response + "\n"); + break; + } + Thread.sleep(100); + } + } + } +} diff --git a/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflow.java b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflow.java new file mode 100644 index 00000000..4eeb1ec3 --- /dev/null +++ b/springai/mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflow.java @@ -0,0 +1,52 @@ +package io.temporal.samples.springai.mcp; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * Workflow interface demonstrating MCP (Model Context Protocol) integration. + * + *

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: + * + *

    + *
  1. Creates an MCP client that wraps the McpClientActivity + *
  2. Discovers tools from connected MCP servers + *
  3. Registers those tools with the chat client + *
  4. When the AI calls an MCP tool, it executes as a durable activity + *
+ * + *

This example uses the filesystem MCP server which provides tools like: + * + *

+ */ +public class McpWorkflowImpl implements McpWorkflow { + + private ChatClient chatClient; + private List mcpTools; + private String lastResponse = ""; + private boolean ended = false; + private int messageCount = 0; + private boolean initialized = false; + + @Override + public String run() { + // Discover MCP tools at the start of workflow execution (not in constructor) + // This avoids the "root workflow thread yielding" warning + ActivityMcpClient mcpClient = ActivityMcpClient.create(); + mcpTools = McpToolCallback.fromMcpClient(mcpClient); + + // Create the chat model (uses the default ChatModel bean) + ActivityChatModel chatModel = ActivityChatModel.forDefault(); + + // Create chat memory - uses in-memory storage that gets rebuilt on replay + // since the same messages will be added in the same order + ChatMemory chatMemory = + MessageWindowChatMemory.builder() + .chatMemoryRepository(new InMemoryChatMemoryRepository()) + .maxMessages(20) + .build(); + + // Build the chat client with MCP tools and memory advisor + this.chatClient = + TemporalChatClient.builder(chatModel) + .defaultSystem( + """ + You are a helpful assistant with access to file system tools. + You can read files, write files, list directories, and more. + + When asked to perform file operations, use your tools. + Always confirm what you did after completing an operation. + """) + .defaultToolCallbacks(mcpTools) + .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build()) + .build(); + + initialized = true; + + // Wait until the chat is ended + Workflow.await(() -> ended); + return "Chat ended after " + messageCount + " messages."; + } + + @Override + public void chat(String message) { + // The signal can land before run() has finished setting up the chat client (MCP tool + // discovery is an activity, so initialization yields). Block in workflow time until + // init completes, then process the message — the client's signal RPC has already + // returned, so this only delays the workflow-side handling. + Workflow.await(() -> initialized); + + messageCount++; + + // PromptChatMemoryAdvisor automatically handles conversation history + lastResponse = chatClient.prompt().user(message).call().content(); + } + + @Override + public String getLastResponse() { + return lastResponse; + } + + @Override + public String listTools() { + if (!initialized || mcpTools == null) { + return "Workflow is still initializing. Please wait a moment and try again."; + } + + if (mcpTools.isEmpty()) { + return "No MCP tools available. Check MCP server configuration."; + } + + return mcpTools.stream() + .map( + tool -> + " - " + + tool.getToolDefinition().name() + + ": " + + tool.getToolDefinition().description()) + .collect(Collectors.joining("\n", "Available MCP tools:\n", "")); + } + + @Override + public void end() { + ended = true; + } +} diff --git a/springai/mcp/src/main/resources/application.yaml b/springai/mcp/src/main/resources/application.yaml new file mode 100644 index 00000000..53551db6 --- /dev/null +++ b/springai/mcp/src/main/resources/application.yaml @@ -0,0 +1,33 @@ +spring: + main: + banner-mode: off + web-application-type: none + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + mcp: + client: + stdio: + connections: + # Filesystem MCP server - provides read_file, write_file, list_directory tools + filesystem: + command: npx + args: + - "-y" + - "@modelcontextprotocol/server-filesystem" + - "${MCP_ALLOWED_PATH:/tmp/mcp-example}" + + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: mcp-example-queue + workflow-classes: + - io.temporal.samples.springai.mcp.McpWorkflowImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/multimodel/build.gradle b/springai/multimodel/build.gradle new file mode 100644 index 00000000..c5f71f8e --- /dev/null +++ b/springai/multimodel/build.gradle @@ -0,0 +1,6 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-starter-model-anthropic' +} diff --git a/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/ChatModelConfig.java b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/ChatModelConfig.java new file mode 100644 index 00000000..6bc4c1eb --- /dev/null +++ b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/ChatModelConfig.java @@ -0,0 +1,95 @@ +package io.temporal.samples.springai.multimodel; + +import io.temporal.activity.ActivityOptions; +import io.temporal.springai.autoconfigure.ChatModelActivityOptions; +import io.temporal.springai.model.ActivityChatModel; +import java.time.Duration; +import java.util.Map; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * Configuration for multiple chat models from different providers. + * + *

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: + * + *

+ */ +@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)}. + * + *

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. + * + *

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

+ * + *
    + *
  1. Start a Temporal dev server: {@code temporal server start-dev} + *
  2. Set OPENAI_API_KEY environment variable + *
  3. Set ANTHROPIC_API_KEY environment variable + *
  4. Run: {@code ./gradlew :springai:multimodel:bootRun} + *
+ */ +@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: - Send to OpenAI"); + System.out.println(" anthropic: - Send to Anthropic"); + System.out.println(" think: - Send to Anthropic with extended thinking enabled"); + System.out.println(" - No prefix routes to the default model"); + System.out.println(" quit - End the chat"); + System.out.println(); + + // Get a workflow stub for sending signals + MultiModelWorkflow workflowStub = + workflowClient.newWorkflowStub(MultiModelWorkflow.class, workflowId); + + Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8); + while (true) { + System.out.print("> "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit")) { + workflowStub.end(); + System.out.println("Chat ended. Goodbye!"); + break; + } + + // Parse the model and message + String modelName; + String message; + + if (input.startsWith("openai:")) { + modelName = "openai"; + message = input.substring(7).trim(); + } else if (input.startsWith("anthropic:")) { + modelName = "anthropic"; + message = input.substring(10).trim(); + } else if (input.startsWith("think:")) { + modelName = "think"; + message = input.substring(6).trim(); + } else { + // No prefix — route to the workflow's "default" model (the @Primary bean, + // resolved by ActivityChatModel.forDefault() inside the workflow). + modelName = "default"; + message = input; + } + + if (message.isEmpty()) { + System.out.println("Please enter a message."); + continue; + } + + // Capture current response BEFORE sending so we can detect the change against + // the pre-signal baseline (not against an empty string — the workflow may already + // hold the previous prompt's reply). + String previousResponse = workflowStub.getLastResponse(); + System.out.println("[Sending to " + modelName + " model...]"); + workflowStub.chat(modelName, message); + + // Poll until the response changes from the pre-signal baseline. + for (int i = 0; i < 300; i++) { // Wait up to 30 seconds + String response = workflowStub.getLastResponse(); + if (!response.equals(previousResponse)) { + System.out.println( + "\n[" + modelName.toUpperCase(java.util.Locale.ROOT) + "]: " + response + "\n"); + break; + } + Thread.sleep(100); + } + } + } +} diff --git a/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflow.java b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflow.java new file mode 100644 index 00000000..7102040e --- /dev/null +++ b/springai/multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflow.java @@ -0,0 +1,45 @@ +package io.temporal.samples.springai.multimodel; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * Workflow interface demonstrating multiple chat 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: + * + *

    + *
  • openai - Uses OpenAI gpt-4o-mini for quick, cost-effective responses + *
  • anthropic - Uses Anthropic Claude for complex reasoning tasks + *
  • think - Uses Anthropic Claude with extended thinking enabled for hard + * problems. This demonstrates {@code temporal-spring-ai}'s provider-specific ChatOptions + * pass-through: an {@link AnthropicChatOptions} instance with a {@code thinking} + * configuration is attached per call, and every field survives the round-trip across the + * activity boundary. + *
  • default - Uses the primary/default model (OpenAI) + *
+ * + *

The workflow uses two patterns for wiring activity options: + * + *

    + *
  • Bean-based overrides (declarative, static) — {@code ChatModelConfig} declares a + * {@code ChatModelActivityOptions} bean with per-model {@code ActivityOptions}. The workflow + * then just calls {@link ActivityChatModel#forDefault()} / {@link + * ActivityChatModel#forModel(String)}, which consult that bean automatically. That covers + * static per-model config (Anthropic needs a longer timeout because reasoning models are + * slow; OpenAI uses plugin defaults). + *
  • Per-call {@code ChatClient.defaultOptions(...)} (imperative, dynamic) — the {@code + * think:} route builds an {@link AnthropicChatOptions} instance with {@code thinking=ENABLED} + * and attaches it via {@code defaultOptions(...)}. That exercises the plugin's + * provider-specific ChatOptions pass-through: every field survives the round-trip across the + * activity boundary. + *
+ */ +public class MultiModelWorkflowImpl implements MultiModelWorkflow { + + private final Map chatClients; + private String lastResponse = ""; + private boolean ended = false; + private int messageCount = 0; + + @WorkflowInit + public MultiModelWorkflowImpl() { + // LinkedHashMap (not HashMap) so the keySet() rendering in the unknown-model error + // path is deterministic across replays. HashMap iteration order is unspecified and + // can differ between JVM versions or workers. + chatClients = new LinkedHashMap<>(); + + // The "default" and "openai" entries below both end up calling OpenAI in this sample — + // ChatModelConfig declares @Primary on openAiChatModel, so ActivityChatModel.forDefault() + // resolves to it. They're registered as two entries on purpose: "default" demonstrates + // the forDefault() / @Primary-resolution path (no bean name needed in workflow code), + // and "openai" demonstrates the forModel(name) explicit-lookup path. In a workflow that + // only uses one model, you'd typically pick one of these two patterns and stop there. + ActivityChatModel defaultModel = ActivityChatModel.forDefault(); + chatClients.put( + "default", + TemporalChatClient.builder(defaultModel) + .defaultSystem("You are a helpful assistant. You are the DEFAULT model.") + .build()); + + ActivityChatModel openAiModel = ActivityChatModel.forModel("openAiChatModel"); + chatClients.put( + "openai", + TemporalChatClient.builder(openAiModel) + .defaultSystem("You are a helpful assistant powered by OpenAI. Keep answers concise.") + .build()); + + // Create a chat client using Anthropic Claude. The per-model ActivityOptions (longer + // start-to-close + schedule-to-close caps) live on the ChatModelActivityOptions bean in + // ChatModelConfig; forModel(name) consults that bean automatically. The workflow stays + // free of infrastructure wiring — ideal for static per-model config. + ActivityChatModel anthropicModel = ActivityChatModel.forModel("anthropicChatModel"); + chatClients.put( + "anthropic", + TemporalChatClient.builder(anthropicModel) + .defaultSystem( + "You are a helpful assistant powered by Anthropic. " + + "You excel at careful reasoning and nuanced responses.") + .build()); + + // Create a chat client that turns on Anthropic's extended-thinking mode. This exercises + // the plugin's provider-specific ChatOptions pass-through end to end: the + // AnthropicChatOptions (with thinking=ENABLED + budget_tokens) is passed via + // .defaultOptions(...) on the ChatClient, crosses the activity boundary serialized as + // (class name, JSON), and is rehydrated by ChatModelActivityImpl before the prompt is + // sent to Claude. Required side effects for extended thinking: temperature must be 1.0 + // and max_tokens must exceed budget_tokens. + // @@@SNIPSTART samples-java-spring-ai-provider-options + AnthropicChatOptions thinkingOptions = + AnthropicChatOptions.builder() + .thinking(AnthropicApi.ThinkingType.ENABLED, 1024) + .temperature(1.0) + .maxTokens(4096) + .build(); + chatClients.put( + "think", + TemporalChatClient.builder(anthropicModel) + .defaultSystem( + "You are a helpful assistant powered by Anthropic with extended thinking. " + + "Use the thinking budget to reason carefully, then give a crisp answer " + + "that reflects the reasoning you did.") + .defaultOptions(thinkingOptions) + .build()); + // @@@SNIPEND + } + + @Override + public String run() { + // Wait until the chat is ended + Workflow.await(() -> ended); + return "Chat ended after " + messageCount + " messages."; + } + + @Override + public void chat(String modelName, String message) { + messageCount++; + + ChatClient client = chatClients.get(modelName); + if (client == null) { + lastResponse = "Unknown model: " + modelName + ". Available: " + chatClients.keySet(); + return; + } + + lastResponse = client.prompt().user(message).call().content(); + } + + @Override + public String getLastResponse() { + return lastResponse; + } + + @Override + public void end() { + ended = true; + } +} diff --git a/springai/multimodel/src/main/resources/application.yaml b/springai/multimodel/src/main/resources/application.yaml new file mode 100644 index 00000000..0029472d --- /dev/null +++ b/springai/multimodel/src/main/resources/application.yaml @@ -0,0 +1,26 @@ +spring: + main: + banner-mode: off + web-application-type: none + autoconfigure: + exclude: + - org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration + - org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration + ai: + openai: + api-key: ${OPENAI_API_KEY} + anthropic: + api-key: ${ANTHROPIC_API_KEY} + # Note: The actual models are configured in ChatModelConfig.java + + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: multi-model-queue + workflow-classes: + - io.temporal.samples.springai.multimodel.MultiModelWorkflowImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/rag/build.gradle b/springai/rag/build.gradle new file mode 100644 index 00000000..0b1ddbf8 --- /dev/null +++ b/springai/rag/build.gradle @@ -0,0 +1,6 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-rag' +} diff --git a/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagApplication.java b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagApplication.java new file mode 100644 index 00000000..0e4bce1e --- /dev/null +++ b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagApplication.java @@ -0,0 +1,162 @@ +package io.temporal.samples.springai.rag; + +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 RAG with a Spring AI VectorStore. + * + *

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. + * + *

Usage

+ * + *
+ * 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

+ * + *
    + *
  1. Start a Temporal dev server: {@code temporal server start-dev} + *
  2. Set OPENAI_API_KEY environment variable + *
  3. Run: {@code ./gradlew :springai:rag:bootRun} + *
+ */ +@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 - Add a document"); + System.out.println(" ask - Ask a question (RAG)"); + System.out.println(" search - Search documents"); + System.out.println(" count - Show document count"); + System.out.println(" quit - End session"); + System.out.println("\nTry adding some documents first, then ask questions about them!"); + System.out.println(); + + // Get a workflow stub for sending signals/queries + RagWorkflow workflowStub = workflowClient.newWorkflowStub(RagWorkflow.class, workflowId); + + Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8); + while (true) { + System.out.print("> "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit")) { + workflowStub.end(); + System.out.println("Session ended. Goodbye!"); + break; + } + + if (input.equalsIgnoreCase("count")) { + System.out.println("Documents in knowledge base: " + workflowStub.getDocumentCount()); + continue; + } + + if (input.equals("add") || input.startsWith("add ")) { + String rest = input.length() > 3 ? input.substring(4).trim() : ""; + int spaceIndex = rest.indexOf(' '); + if (spaceIndex == -1) { + System.out.println("Usage: add "); + continue; + } + String id = rest.substring(0, spaceIndex); + String content = rest.substring(spaceIndex + 1).trim(); + + // Capture current response BEFORE sending so waitForResponse can detect when it changes. + String previousResponse = workflowStub.getLastResponse(); + System.out.println("[Adding document...]"); + workflowStub.addDocument(id, content); + waitForResponse(workflowStub, previousResponse); + continue; + } + + if (input.equals("ask") || input.startsWith("ask ")) { + String question = input.length() > 3 ? input.substring(4).trim() : ""; + if (question.isEmpty()) { + System.out.println("Usage: ask "); + continue; + } + + String previousResponse = workflowStub.getLastResponse(); + System.out.println("[Searching and generating answer...]"); + workflowStub.ask(question); + waitForResponse(workflowStub, previousResponse); + continue; + } + + if (input.equals("search") || input.startsWith("search ")) { + String query = input.length() > 6 ? input.substring(7).trim() : ""; + if (query.isEmpty()) { + System.out.println("Usage: search "); + continue; + } + + String previousResponse = workflowStub.getLastResponse(); + System.out.println("[Searching...]"); + workflowStub.search(query, 5); + waitForResponse(workflowStub, previousResponse); + continue; + } + + if (!input.isEmpty()) { + System.out.println("Unknown command. Use: add, ask, search, count, or quit"); + } + } + } + + private void waitForResponse(RagWorkflow workflowStub, String previousResponse) + throws InterruptedException { + for (int i = 0; i < 600; i++) { // Wait up to 60 seconds + Thread.sleep(100); + String response = workflowStub.getLastResponse(); + if (!response.equals(previousResponse)) { + System.out.println("\n" + response + "\n"); + return; + } + } + System.out.println("[Timeout waiting for response]"); + } +} diff --git a/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflow.java b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflow.java new file mode 100644 index 00000000..89ef0e00 --- /dev/null +++ b/springai/rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflow.java @@ -0,0 +1,70 @@ +package io.temporal.samples.springai.rag; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * Workflow interface demonstrating RAG (Retrieval-Augmented Generation). + * + *

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: + * + *

    + *
  • Using {@link VectorStoreActivity} to store and search documents (the configured {@code + * VectorStore} handles embedding internally) + *
  • Combining vector search with chat for RAG + *
+ * + *

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