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. + * + *
+ * Commands: + * tools - List available MCP tools + * <any message> - Chat with the AI (it can use file tools) + * quit - End the chat + *+ * + *
+ * > 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] + *+ * + *
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: + * + *
This example uses the filesystem MCP server which provides tools like: + * + *
This demonstrates how to configure multiple AI providers in a Spring Boot application. Each + * model is registered as a separate bean with a unique name. + * + *
In workflows, these can be accessed via: + * + *
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. + * + *
+ * 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 + *+ * + *
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: + * + *
The workflow shows three ways to create ActivityChatModel: + * + *
This application shows how to use the plugin's VectorStoreActivity and EmbeddingModelActivity + * to build a durable knowledge base within Temporal workflows. + * + *
+ * 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 + *+ * + *
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: + * + *
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}. + * + *
+ * # 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 + *+ * + *
When the application starts, you'll see warning messages about unsafe tools being wrapped in + * local activities. Try these prompts: + * + *
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}. + * + *
There are two ways to enable sandboxing: + * + *
{@code
+ * .defaultAdvisors(new SandboxingAdvisor())
+ * .defaultToolCallbacks(ToolCallbacks.from(unsafeTools))
+ *
+ * }
+ * {@code
+ * .defaultAdvisors(new SandboxingAdvisor())
+ * .defaultTools(unsafeTools)
+ *
+ * }
+ * This example uses SandboxingAdvisor, which matches the original design. + * + *
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. + * + *
When you call a tool like "What time is it?", you'll see: + * + *
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. + * + *
These tools have several problems: + * + *
If you see warnings about your tools being wrapped in local activities, you have three + * options: + * + *
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: + * + *
The AI model can call: + * + *
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: + * + *
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: + * + *
Use {@code @SideEffectTool} for: + * + *
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