diff --git a/core/build.gradle b/core/build.gradle index 62fbfa8e9..1cdabfc2f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -7,6 +7,9 @@ dependencies { // Environment configuration implementation "io.temporal:temporal-envconfig:$javaSDKVersion" + // Needed for SSL sample (AdvancedTlsX509KeyManager) + implementation "io.grpc:grpc-util" + // Needed for SDK related functionality implementation(platform("com.fasterxml.jackson:jackson-bom:2.17.2")) implementation "com.fasterxml.jackson.core:jackson-databind" diff --git a/core/src/main/java/io/temporal/samples/ssl/Starter.java b/core/src/main/java/io/temporal/samples/ssl/Starter.java index c69101473..7e90c6fc9 100644 --- a/core/src/main/java/io/temporal/samples/ssl/Starter.java +++ b/core/src/main/java/io/temporal/samples/ssl/Starter.java @@ -50,12 +50,14 @@ public static void main(String[] args) throws Exception { if (refreshPeriod > 0) { AdvancedTlsX509KeyManager clientKeyManager = new AdvancedTlsX509KeyManager(); // Reload credentials every minute - clientKeyManager.updateIdentityCredentialsFromFile( - clientKeyFile, - clientCertFile, - refreshPeriod, - TimeUnit.MINUTES, - Executors.newScheduledThreadPool(1)); + @SuppressWarnings("InlineMeInliner") + var unused = + clientKeyManager.updateIdentityCredentialsFromFile( + clientKeyFile, + clientCertFile, + refreshPeriod, + TimeUnit.MINUTES, + Executors.newScheduledThreadPool(1)); sslContext = GrpcSslContexts.configure(SslContextBuilder.forClient().keyManager(clientKeyManager)) .build(); diff --git a/gradle/springai.gradle b/gradle/springai.gradle new file mode 100644 index 000000000..98ab26173 --- /dev/null +++ b/gradle/springai.gradle @@ -0,0 +1,48 @@ +// Shared configuration for all Spring AI sample modules. +// Applied via: apply from: "$rootDir/gradle/springai.gradle" + +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 { + // Temporal + implementation "io.temporal:temporal-spring-boot-starter:$javaSDKVersion" + implementation "io.temporal:temporal-spring-ai:$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 65c976aaa..d4a31a663 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,24 @@ rootProject.name = 'temporal-java-samples' include 'core' +include 'springai' +include 'springai-mcp' +include 'springai-multimodel' +include 'springai-rag' +include 'springai-sandboxing' include 'springboot' include 'springboot-basic' + +// Include local sdk-java build for temporal-spring-ai (until published to Maven Central). +// temporal-spring-ai requires the plugin API (SimplePlugin) which is not yet in a released SDK, +// so we substitute all SDK modules from the local build. +includeBuild('../sdk-java') { + dependencySubstitution { + substitute module('io.temporal:temporal-spring-ai') using project(':temporal-spring-ai') + substitute module('io.temporal:temporal-sdk') using project(':temporal-sdk') + substitute module('io.temporal:temporal-serviceclient') using project(':temporal-serviceclient') + substitute module('io.temporal:temporal-spring-boot-autoconfigure') using project(':temporal-spring-boot-autoconfigure') + substitute module('io.temporal:temporal-spring-boot-starter') using project(':temporal-spring-boot-starter') + substitute module('io.temporal:temporal-testing') using project(':temporal-testing') + substitute module('io.temporal:temporal-opentracing') using project(':temporal-opentracing') + } +} diff --git a/springai-mcp/build.gradle b/springai-mcp/build.gradle new file mode 100644 index 000000000..334461917 --- /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/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class new file mode 100644 index 000000000..fbf3cad95 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class differ diff --git a/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class new file mode 100644 index 000000000..9d53d6b89 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class differ diff --git a/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class new file mode 100644 index 000000000..b9d3ee226 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class differ diff --git a/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin b/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..e0c1ae49f Binary files /dev/null and b/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin differ 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 000000000..5dc44c013 --- /dev/null +++ b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java @@ -0,0 +1,136 @@ +package io.temporal.samples.springai.mcp; + +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.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 :example-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) { + 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 000000000..4eeb1ec30 --- /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 000000000..a3c5ae144 --- /dev/null +++ b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java @@ -0,0 +1,130 @@ +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) { + if (!initialized) { + lastResponse = "Workflow is still initializing. Please wait a moment."; + return; + } + + messageCount++; + + // MessageChatMemoryAdvisor 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 000000000..60a48f031 --- /dev/null +++ b/springai-mcp/src/main/resources/application.yaml @@ -0,0 +1,32 @@ +spring: + main: + banner-mode: off + 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 000000000..c5f71f8e8 --- /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/build/classes/java/main/io/temporal/samples/springai/multimodel/ChatModelConfig.class b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/ChatModelConfig.class new file mode 100644 index 000000000..09a829e9a Binary files /dev/null and b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/ChatModelConfig.class differ diff --git a/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelApplication.class b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelApplication.class new file mode 100644 index 000000000..5c7d13e78 Binary files /dev/null and b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelApplication.class differ diff --git a/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflow.class b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflow.class new file mode 100644 index 000000000..fc0b2d7e8 Binary files /dev/null and b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflow.class differ diff --git a/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.class b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.class new file mode 100644 index 000000000..3b0f05b45 Binary files /dev/null and b/springai-multimodel/build/classes/java/main/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.class differ diff --git a/springai-multimodel/build/tmp/compileJava/previous-compilation-data.bin b/springai-multimodel/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..4b066751b Binary files /dev/null and b/springai-multimodel/build/tmp/compileJava/previous-compilation-data.bin differ 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 000000000..6e1bc0c1e --- /dev/null +++ b/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/ChatModelConfig.java @@ -0,0 +1,62 @@ +package io.temporal.samples.springai.multimodel; + +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(); + } +} 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 000000000..3c14f167c --- /dev/null +++ b/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelApplication.java @@ -0,0 +1,137 @@ +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)
+ *   default: <message>   - Send to default model (OpenAI)
+ *   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 :example-multi-model: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(" default: 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(" default: - Send to 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("default:")) { + modelName = "default"; + message = input.substring(8).trim(); + } else { + // Default to openai if no prefix + modelName = "openai"; + message = input; + } + + if (message.isEmpty()) { + System.out.println("Please enter a message."); + continue; + } + + System.out.println("[Sending to " + modelName + " model...]"); + + // Send the message via signal + workflowStub.chat(modelName, message); + + // Wait a moment for processing, then query for response + Thread.sleep(100); + + // Poll for response (in production, you'd use a more sophisticated approach) + String lastResponse = ""; + for (int i = 0; i < 300; i++) { // Wait up to 30 seconds + String response = workflowStub.getLastResponse(); + if (!response.isEmpty() && !response.equals(lastResponse)) { + 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 000000000..c9f2d4c66 --- /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 ("fast", "smart", 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 000000000..55f722437 --- /dev/null +++ b/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java @@ -0,0 +1,107 @@ +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.time.Duration; +import java.util.HashMap; +import java.util.Map; +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 + *
  • default - Uses the primary/default model (OpenAI) + *
+ * + *

The workflow shows three ways to create ActivityChatModel: + * + *

    + *
  1. {@code ActivityChatModel.forDefault()} - Uses the default model + *
  2. {@code ActivityChatModel.forModel("beanName")} - Uses a specific model by bean name + *
  3. {@code ActivityChatModel.forModel("name", timeout, retries)} - Custom options + *
+ */ +public class MultiModelWorkflowImpl implements MultiModelWorkflow { + + private final Map chatClients; + private String lastResponse = ""; + private boolean ended = false; + private int messageCount = 0; + + @WorkflowInit + public MultiModelWorkflowImpl() { + chatClients = new HashMap<>(); + + // Create a chat client using the default model + // This uses the @Primary bean or the first ChatModel bean + ActivityChatModel defaultModel = ActivityChatModel.forDefault(); + chatClients.put( + "default", + TemporalChatClient.builder(defaultModel) + .defaultSystem("You are a helpful assistant. You are the DEFAULT model.") + .build()); + + // Create a chat client using OpenAI (gpt-4o-mini) + // This references the bean name defined in ChatModelConfig + ActivityChatModel openAiModel = ActivityChatModel.forModel("openAiChatModel"); + chatClients.put( + "openai", + TemporalChatClient.builder(openAiModel) + .defaultSystem( + "You are a helpful assistant powered by OpenAI. " + + "Keep answers concise. You are GPT-4o-mini.") + .build()); + + // Create a chat client using Anthropic Claude with custom timeout + // Complex reasoning might take longer, so we give it more time + ActivityChatModel anthropicModel = + ActivityChatModel.forModel( + "anthropicChatModel", + Duration.ofMinutes(5), // Longer timeout for complex reasoning + 3); // 3 retry attempts + chatClients.put( + "anthropic", + TemporalChatClient.builder(anthropicModel) + .defaultSystem( + "You are a helpful assistant powered by Anthropic. " + + "You excel at careful reasoning and nuanced responses. You are Claude.") + .build()); + } + + @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 000000000..0029472df --- /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 000000000..0b1ddbf89 --- /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/build/classes/java/main/io/temporal/samples/springai/rag/RagApplication.class b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagApplication.class new file mode 100644 index 000000000..6ea208cf7 Binary files /dev/null and b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagApplication.class differ diff --git a/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflow.class b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflow.class new file mode 100644 index 000000000..5ec0ebbb8 Binary files /dev/null and b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflow.class differ diff --git a/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflowImpl.class b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflowImpl.class new file mode 100644 index 000000000..4fee70e0c Binary files /dev/null and b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/RagWorkflowImpl.class differ diff --git a/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/VectorStoreConfig.class b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/VectorStoreConfig.class new file mode 100644 index 000000000..406828bfc Binary files /dev/null and b/springai-rag/build/classes/java/main/io/temporal/samples/springai/rag/VectorStoreConfig.class differ diff --git a/springai-rag/build/tmp/compileJava/previous-compilation-data.bin b/springai-rag/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..0feeaa0f0 Binary files /dev/null and b/springai-rag/build/tmp/compileJava/previous-compilation-data.bin differ 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 000000000..75ad76de5 --- /dev/null +++ b/springai-rag/src/main/java/io/temporal/samples/springai/rag/RagApplication.java @@ -0,0 +1,155 @@ +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 VectorStore and Embeddings. + * + *

This application shows how to use the plugin's VectorStoreActivity and EmbeddingModelActivity + * to build a durable knowledge base within Temporal workflows. + * + *

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 :example-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 and EmbeddingModelActivity"); + System.out.println("to build a durable knowledge base with semantic search."); + 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.startsWith("add ")) { + String rest = 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(); + + System.out.println("[Adding document...]"); + workflowStub.addDocument(id, content); + waitForResponse(workflowStub); + continue; + } + + if (input.startsWith("ask ")) { + String question = input.substring(4).trim(); + if (question.isEmpty()) { + System.out.println("Usage: ask "); + continue; + } + + System.out.println("[Searching and generating answer...]"); + workflowStub.ask(question); + waitForResponse(workflowStub); + continue; + } + + if (input.startsWith("search ")) { + String query = input.substring(7).trim(); + if (query.isEmpty()) { + System.out.println("Usage: search "); + continue; + } + + System.out.println("[Searching...]"); + workflowStub.search(query, 5); + waitForResponse(workflowStub); + continue; + } + + if (!input.isEmpty()) { + System.out.println("Unknown command. Use: add, ask, search, count, or quit"); + } + } + } + + private void waitForResponse(RagWorkflow workflowStub) throws InterruptedException { + String lastResponse = workflowStub.getLastResponse(); + for (int i = 0; i < 600; i++) { // Wait up to 60 seconds + Thread.sleep(100); + String response = workflowStub.getLastResponse(); + if (!response.equals(lastResponse)) { + 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 000000000..89ef0e00c --- /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 000000000..b626775d9 --- /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 EmbeddingModelActivity} to generate embeddings + *
  • Using {@link VectorStoreActivity} to store and search documents + *
  • 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 000000000..7909e92d4 --- /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 000000000..b8889cb70 --- /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 diff --git a/springai-sandboxing/build.gradle b/springai-sandboxing/build.gradle new file mode 100644 index 000000000..49fd72b0f --- /dev/null +++ b/springai-sandboxing/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-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingApplication.class b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingApplication.class new file mode 100644 index 000000000..b4f506f1e Binary files /dev/null and b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingApplication.class differ diff --git a/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingRunner.class b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingRunner.class new file mode 100644 index 000000000..82f4e0278 Binary files /dev/null and b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingRunner.class differ diff --git a/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.class b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.class new file mode 100644 index 000000000..ebb91c20e Binary files /dev/null and b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.class differ diff --git a/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.class b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.class new file mode 100644 index 000000000..0d86231aa Binary files /dev/null and b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.class differ diff --git a/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/UnsafeTools.class b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/UnsafeTools.class new file mode 100644 index 000000000..994d21cfd Binary files /dev/null and b/springai-sandboxing/build/classes/java/main/io/temporal/samples/springai/sandboxing/UnsafeTools.class differ diff --git a/springai-sandboxing/build/tmp/compileJava/previous-compilation-data.bin b/springai-sandboxing/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..175fe64ef Binary files /dev/null and b/springai-sandboxing/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingApplication.java b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingApplication.java new file mode 100644 index 000000000..edf8b5a14 --- /dev/null +++ b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingApplication.java @@ -0,0 +1,126 @@ +package io.temporal.samples.springai.sandboxing; + +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 sandboxing of unsafe tools. + * + *

This example shows how to use {@link io.temporal.springai.advisor.SandboxingAdvisor} to safely + * use tools that are not properly annotated with {@code @DeterministicTool} or + * {@code @SideEffectTool}. + * + *

Running the Example

+ * + *
+ * # Start Temporal server
+ * temporal server start-dev
+ *
+ * # Set OpenAI API key
+ * export OPENAI_API_KEY=your-key
+ *
+ * # Run the example
+ * cd example-sandboxing
+ * ../gradlew bootRun --console=plain
+ * 
+ * + *

What to Observe

+ * + *

When the application starts, you'll see warning messages about unsafe tools being wrapped in + * local activities. Try these prompts: + * + *

    + *
  • "What time is it?" - Calls currentTime() wrapped in local activity + *
  • "Generate a random number between 1 and 100" - Calls randomNumber() wrapped + *
  • "What is my java.version system property?" - Calls getSystemProperty() wrapped + *
+ * + *

In the Temporal UI, you'll see local activity markers for each tool call, demonstrating that + * the tools are being sandboxed for workflow safety. + */ +@SpringBootApplication +public class SandboxingApplication { + + public static void main(String[] args) { + SpringApplication.run(SandboxingApplication.class, args); + } +} + +@Component +class SandboxingRunner { + + private final WorkflowClient workflowClient; + + SandboxingRunner(WorkflowClient workflowClient) { + this.workflowClient = workflowClient; + } + + @EventListener(ApplicationReadyEvent.class) + public void run() { + String workflowId = "sandbox-" + UUID.randomUUID().toString().substring(0, 8); + + System.out.println("\n==========================================="); + System.out.println(" Sandboxing Demo - Unsafe Tools Example"); + System.out.println("==========================================="); + System.out.println("Workflow ID: " + workflowId); + System.out.println("\nThis demo shows how sandboxing mode handles"); + System.out.println("tools that aren't properly annotated."); + System.out.println("\nWatch for WARN messages about tools being"); + System.out.println("wrapped in local activities.\n"); + System.out.println("Try: 'What time is it?'"); + System.out.println("Try: 'Generate a random number 1-100'"); + System.out.println("Type 'quit' to exit.\n"); + + // Start the chat workflow + SandboxingWorkflow workflow = + workflowClient.newWorkflowStub( + SandboxingWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + .setTaskQueue("spring-ai-sandboxing-example") + .build()); + + WorkflowClient.start( + workflow::run, + "You are a helpful assistant with access to system tools. " + + "Use the available tools when asked about time, random numbers, " + + "or system properties. Be concise."); + + // Get stub for the running workflow + SandboxingWorkflow chat = workflowClient.newWorkflowStub(SandboxingWorkflow.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-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.java b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.java new file mode 100644 index 000000000..0c1aeccf8 --- /dev/null +++ b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflow.java @@ -0,0 +1,38 @@ +package io.temporal.samples.springai.sandboxing; + +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * Workflow interface for the sandboxing demonstration. + * + *

This is a simple chat workflow that demonstrates how unsafe tools can be sandboxed to prevent + * non-deterministic behavior. + */ +@WorkflowInterface +public interface SandboxingWorkflow { + + /** + * Main workflow method. Waits for the chat session to end. + * + * @param systemPrompt the system prompt to configure the AI assistant + * @return a summary of the chat session + */ + @WorkflowMethod + String run(String systemPrompt); + + /** + * Sends a message to the AI and receives a response. + * + * @param message the user's message + * @return the AI's response + */ + @UpdateMethod + String chat(String message); + + /** Signals the workflow to end the chat session. */ + @SignalMethod + void end(); +} diff --git a/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.java b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.java new file mode 100644 index 000000000..f2e30c197 --- /dev/null +++ b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/SandboxingWorkflowImpl.java @@ -0,0 +1,128 @@ +package io.temporal.samples.springai.sandboxing; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivity; +import io.temporal.springai.advisor.SandboxingAdvisor; +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.support.ToolCallbacks; + +/** + * Demonstrates sandboxing mode for unsafe tools. + * + *

This workflow shows how to use {@link SandboxingAdvisor} to safely use tools that are not + * properly annotated with {@code @DeterministicTool} or {@code @SideEffectTool}. + * + *

Two Sandboxing Approaches

+ * + *

There are two ways to enable sandboxing: + * + *

    + *
  1. SandboxingAdvisor (recommended) - Wraps tools at call time: + *
    {@code
    + * .defaultAdvisors(new SandboxingAdvisor())
    + * .defaultToolCallbacks(ToolCallbacks.from(unsafeTools))
    + *
    + * }
    + *
  2. Builder option - Wraps tools at registration time: + *
    {@code
    + * .defaultAdvisors(new SandboxingAdvisor())
    + * .defaultTools(unsafeTools)
    + *
    + * }
    + *
+ * + *

This example uses SandboxingAdvisor, which matches the original design. + * + *

What This Example Demonstrates

+ * + *

When running this example, you will see warning messages like: + * + *

+ * WARN io.temporal.springai.advisor.SandboxingAdvisor - Tool 'currentTime'
+ *   (org.springframework.ai.tool.method.MethodToolCallback) is not guaranteed
+ *   to be deterministic. Wrapping in local activity for workflow safety.
+ * 
+ * + *

Despite the warnings, the tools will work correctly because they are wrapped in local + * activities. However, this adds overhead. The warnings help developers identify tools that should + * be properly annotated. + * + *

Viewing in Temporal UI

+ * + *

When you call a tool like "What time is it?", you'll see: + * + *

    + *
  1. A {@code ChatModelActivity} task for the AI model call + *
  2. A local activity marker for the sandboxed tool call + *
+ * + *

Compare this to the main example where {@code @SideEffectTool} is used - there you'll see a + * sideEffect marker instead of a local activity, which is more lightweight. + * + * @see UnsafeTools + * @see SandboxingAdvisor + */ +public class SandboxingWorkflowImpl implements SandboxingWorkflow { + + private final ChatClient chatClient; + private boolean ended = false; + private int messageCount = 0; + + @WorkflowInit + public SandboxingWorkflowImpl(String systemPrompt) { + // Create an activity stub for calling the AI model + ChatModelActivity chatModelActivity = + Workflow.newActivityStub( + ChatModelActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + + // Wrap the activity in ActivityChatModel to use with Spring AI + ActivityChatModel activityChatModel = new ActivityChatModel(chatModelActivity); + + // Create unsafe tools - NOT annotated with @DeterministicTool or @SideEffectTool + UnsafeTools unsafeTools = new UnsafeTools(); + + // Build a TemporalChatClient with SandboxingAdvisor + // + // The SandboxingAdvisor intercepts chat requests and wraps any tool callbacks + // that aren't already safe (ActivityToolCallback, SideEffectToolCallback) in + // LocalActivityToolCallbackWrapper. + // + // Note: We use defaultToolCallbacks() with ToolCallbacks.from() instead of + // defaultTools() because defaultTools() would reject the unannotated tools. + // The SandboxingAdvisor handles the wrapping at call time instead. + this.chatClient = + TemporalChatClient.builder(activityChatModel) + .defaultAdvisors(new SandboxingAdvisor()) // <-- Wraps unsafe tools! + .defaultSystem(systemPrompt) + .defaultToolCallbacks(ToolCallbacks.from(unsafeTools)) // Pass as raw callbacks + .build(); + } + + @Override + public String run(String systemPrompt) { + // Wait until the chat is ended + 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-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/UnsafeTools.java b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/UnsafeTools.java new file mode 100644 index 000000000..27d1e2924 --- /dev/null +++ b/springai-sandboxing/src/main/java/io/temporal/samples/springai/sandboxing/UnsafeTools.java @@ -0,0 +1,93 @@ +package io.temporal.samples.springai.sandboxing; + +import org.springframework.ai.tool.annotation.Tool; + +/** + * Example of tools that are NOT properly annotated for workflow safety. + * + *

This class demonstrates what happens when you pass tools to {@code TemporalChatClient} without + * the proper {@code @DeterministicTool} or {@code @SideEffectTool} annotations. + * + *

Without sandboxing: Passing this class to {@code .defaultTools()} will throw an {@link + * IllegalArgumentException} because the framework cannot verify the tools are workflow-safe. + * + *

With sandboxing: When {@link io.temporal.springai.advisor.SandboxingAdvisor} is used, + * these tools are automatically wrapped in local activities with a warning. This ensures + * deterministic replay but adds overhead. + * + *

Why This Is "Unsafe"

+ * + *

These tools have several problems: + * + *

    + *
  • {@code currentTime()} - Returns different values on replay (non-deterministic) + *
  • {@code getSystemProperty()} - Depends on system environment (non-deterministic) + *
  • {@code randomNumber()} - Returns random values (non-deterministic) + *
+ * + *

How To Fix

+ * + *

If you see warnings about your tools being wrapped in local activities, you have three + * options: + * + *

    + *
  1. Add {@code @DeterministicTool} if the tool is truly deterministic (same output for same + * input, no side effects) + *
  2. Add {@code @SideEffectTool} if the tool is non-deterministic but doesn't need activity + * durability (timestamps, UUIDs, random values) + *
  3. Convert the tool to a Temporal activity for full durability and retry support + *
+ * + * @see io.temporal.springai.tool.DeterministicTool + * @see io.temporal.springai.tool.SideEffectTool + */ +public class UnsafeTools { + + /** + * Gets the current time in milliseconds. + * + *

Problem: Returns different values on workflow replay, breaking determinism. + * + *

Fix: Annotate the class with {@code @SideEffectTool} to wrap in {@code + * Workflow.sideEffect()}. + * + * @return current time in milliseconds since epoch + */ + @Tool(description = "Get the current time in milliseconds since epoch") + public long currentTime() { + return System.currentTimeMillis(); + } + + /** + * Gets a system property value. + * + *

Problem: System properties can differ between workers or change over time, leading to + * non-deterministic behavior on replay. + * + *

Fix: Either read system properties at workflow start and pass as state, or use an + * activity to read system properties. + * + * @param name the property name + * @return the property value, or "not set" if not found + */ + @Tool(description = "Get a system property value") + public String getSystemProperty(String name) { + return System.getProperty(name, "not set"); + } + + /** + * Generates a random number. + * + *

Problem: Returns different values on replay, breaking determinism. + * + *

Fix: Annotate the class with {@code @SideEffectTool} to wrap in {@code + * Workflow.sideEffect()}. + * + * @param max the maximum value (exclusive) + * @return a random number between 0 and max + */ + @Tool(description = "Generate a random number between 0 and the given maximum (exclusive)") + public int randomNumber(int max) { + return (int) (Math.random() * max); + } +} diff --git a/springai-sandboxing/src/main/resources/application.yml b/springai-sandboxing/src/main/resources/application.yml new file mode 100644 index 000000000..b16272842 --- /dev/null +++ b/springai-sandboxing/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + application: + name: spring-ai-temporal-sandboxing-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-sandboxing-example + workflow-classes: + - io.temporal.samples.springai.sandboxing.SandboxingWorkflowImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/build.gradle b/springai/build.gradle new file mode 100644 index 000000000..49fd72b0f --- /dev/null +++ b/springai/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/build/bootRunMainClassName b/springai/build/bootRunMainClassName new file mode 100644 index 000000000..86e957690 --- /dev/null +++ b/springai/build/bootRunMainClassName @@ -0,0 +1 @@ +io.temporal.samples.springai.chat.ChatExampleApplication \ No newline at end of file diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class new file mode 100644 index 000000000..070057fbd Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class new file mode 100644 index 000000000..a71b5ee1e Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class new file mode 100644 index 000000000..1475c8e78 Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class new file mode 100644 index 000000000..dc15b627a Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class new file mode 100644 index 000000000..e51060bcc Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class new file mode 100644 index 000000000..c28fb2eae Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class new file mode 100644 index 000000000..b8fe3d9b9 Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class new file mode 100644 index 000000000..389f7e5fc Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class differ diff --git a/springai/build/resources/main/application.yml b/springai/build/resources/main/application.yml new file mode 100644 index 000000000..93e1597e0 --- /dev/null +++ b/springai/build/resources/main/application.yml @@ -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/build/tmp/compileJava/previous-compilation-data.bin b/springai/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..0809d4d12 Binary files /dev/null and b/springai/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/ChatExampleApplication.java b/springai/src/main/java/io/temporal/samples/springai/chat/ChatExampleApplication.java new file mode 100644 index 000000000..e96c79c6f --- /dev/null +++ b/springai/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/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java new file mode 100644 index 000000000..32d70db34 --- /dev/null +++ b/springai/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/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java new file mode 100644 index 000000000..038754c8d --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java @@ -0,0 +1,115 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivity; +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. Create an activity stub for {@link ChatModelActivity} + *
  2. Wrap it in {@link ActivityChatModel} to get a standard Spring AI ChatModel + *
  3. Create activity stubs for tools (e.g., {@link WeatherActivity}) + *
  4. Create deterministic tools (e.g., {@link StringTools}) + *
  5. Create side-effect tools (e.g., {@link TimestampTools}) + *
  6. Use {@link TemporalChatClient} to build a tool-aware chat client + *
+ * + *

The AI model can call: + * + *

    + *
  • {@code getWeather(city)} - Executes as a durable Temporal activity + *
  • {@code getForecast(city, days)} - Executes as a durable Temporal activity + *
  • {@code reverse(text)}, {@code countWords(text)}, etc. - Execute directly in workflow + * (@DeterministicTool) + *
  • {@code getCurrentDateTime()}, {@code generateUuid()}, etc. - Wrapped in sideEffect + * (@SideEffectTool) + *
+ */ +public class ChatWorkflowImpl implements ChatWorkflow { + + private final ChatClient chatClient; + private boolean ended = false; + private int messageCount = 0; + + @WorkflowInit + public ChatWorkflowImpl(String systemPrompt) { + // Create an activity stub for calling the AI model + ChatModelActivity chatModelActivity = + Workflow.newActivityStub( + ChatModelActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build()); + + // Wrap the activity in ActivityChatModel to use with Spring AI + ActivityChatModel activityChatModel = new ActivityChatModel(chatModelActivity); + + // 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 + // - @DeterministicTool 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(); + } + + @Override + public String run(String systemPrompt) { + // Wait until the chat is ended + 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/src/main/java/io/temporal/samples/springai/chat/StringTools.java b/springai/src/main/java/io/temporal/samples/springai/chat/StringTools.java new file mode 100644 index 000000000..db9a2d9c5 --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/StringTools.java @@ -0,0 +1,104 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.springai.tool.DeterministicTool; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Deterministic string manipulation tools. + * + *

This class demonstrates the use of {@link DeterministicTool} annotation for tools that are + * safe to execute directly in a Temporal workflow without wrapping in an activity. + * + *

Deterministic tools must: + * + *

    + *
  • Always produce the same output for the same input + *
  • Have no side effects (no I/O, no random numbers, no system time) + *
  • Not call any non-deterministic APIs + *
+ * + *

Example usage: + * + *

{@code
+ * StringTools stringTools = new StringTools();
+ * this.chatClient = TemporalChatClient.builder(activityChatModel)
+ *         .defaultTools(stringTools)  // Executes directly in workflow
+ *         .build();
+ * }
+ */ +@DeterministicTool +public class StringTools { + + /** + * Reverses a string. + * + * @param input the string to reverse + * @return the reversed string + */ + @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(); + } + + /** + * Counts the number of words in a text. + * + * @param text the text to count words in + * @return the word count + */ + @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; + } + + /** + * Converts text to uppercase. + * + * @param text the text to convert + * @return the uppercase text + */ + @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); + } + + /** + * Converts text to lowercase. + * + * @param text the text to convert + * @return the lowercase text + */ + @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); + } + + /** + * Checks if a string is a palindrome. + * + * @param text the text to check + * @return true if the text is a palindrome (ignoring case and spaces) + */ + @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); + } +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java b/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java new file mode 100644 index 000000000..db9415888 --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java @@ -0,0 +1,100 @@ +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: + * + *

    + *
  • Records the result in workflow history on first execution + *
  • Returns the recorded result on replay (deterministic) + *
  • Does not create activity tasks (lightweight) + *
+ * + *

Use {@code @SideEffectTool} for: + * + *

    + *
  • Getting current time/date + *
  • Generating random values (UUIDs, random numbers) + *
  • Any non-deterministic operation that doesn't need retry/durability + *
+ * + *

Example usage: + * + *

{@code
+ * TimestampTools timestampTools = new TimestampTools();
+ * this.chatClient = TemporalChatClient.builder(activityChatModel)
+ *         .defaultTools(timestampTools)  // Wrapped in sideEffect()
+ *         .build();
+ * }
+ */ +@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'."; + } + } +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java new file mode 100644 index 000000000..3d098564b --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java @@ -0,0 +1,49 @@ +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. + */ +@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); +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java new file mode 100644 index 000000000..03b01ee7a --- /dev/null +++ b/springai/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/src/main/resources/application.yml b/springai/src/main/resources/application.yml new file mode 100644 index 000000000..93e1597e0 --- /dev/null +++ b/springai/src/main/resources/application.yml @@ -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