|
1 | | -# `temporal-spring-ai`: Temporal + Spring AI Integration |
2 | | - |
3 | | -Integrates [Spring AI](https://docs.spring.io/spring-ai/reference/) with [Temporal](https://temporal.io/) workflows, making AI model calls, tool execution, vector store operations, embeddings, and MCP tool calls durable Temporal primitives. |
4 | | - |
5 | | -> [!WARNING] |
6 | | -> `temporal-spring-ai` is currently in Public Preview, and will continue to evolve and improve. |
7 | | -> We would love to hear your feedback - positive or negative - over in the [Community Slack](https://t.mp/slack), in the [#topic-ai channel](https://temporalio.slack.com/archives/C0818FQPYKY) |
8 | | -
|
9 | | - |
10 | | -## Compatibility |
11 | | - |
12 | | -| Dependency | Minimum Version | |
13 | | -|-------------------|-----------------| |
14 | | -| Java | 17 | |
15 | | -| Spring Boot | 3.x | |
16 | | -| Spring AI | 1.1.0 | |
17 | | -| Temporal Java SDK | 1.35.0 | |
18 | | - |
19 | | -## Quick Start |
20 | | - |
21 | | -Add the dependency (Maven): |
22 | | - |
23 | | -```xml |
24 | | -<dependency> |
25 | | - <groupId>io.temporal</groupId> |
26 | | - <artifactId>temporal-spring-ai</artifactId> |
27 | | - <version>${temporal-sdk.version}</version> |
28 | | -</dependency> |
29 | | -``` |
30 | | - |
31 | | -You also need `temporal-spring-boot-starter` and a Spring AI model starter (e.g. `spring-ai-starter-model-openai`). |
32 | | - |
33 | | -The plugin auto-registers `ChatModelActivity` with all Temporal workers. In your workflow: |
34 | | - |
35 | | -```java |
36 | | -@WorkflowInit |
37 | | -public MyWorkflowImpl(String goal) { |
38 | | - ActivityChatModel chatModel = ActivityChatModel.forDefault(); |
39 | | - |
40 | | - WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts); |
41 | | - |
42 | | - this.chatClient = TemporalChatClient.builder(chatModel) |
43 | | - .defaultSystem("You are a helpful assistant.") |
44 | | - .defaultTools(weather, new MyTools()) |
45 | | - .build(); |
46 | | -} |
47 | | - |
48 | | -@Override |
49 | | -public String run(String goal) { |
50 | | - return chatClient.prompt().user(goal).call().content(); |
51 | | -} |
52 | | -``` |
53 | | - |
54 | | -## Activity options and retry behavior |
55 | | - |
56 | | -`ActivityChatModel.forDefault()` / `forModel(name)` build the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` marked non-retryable so a bad API key or invalid prompt fails fast instead of churning through retries. |
57 | | - |
58 | | -When you need finer control — a specific task queue, heartbeats, priority, or a custom `RetryOptions` — pass an `ActivityOptions` directly: |
59 | | - |
60 | | -```java |
61 | | -ActivityChatModel chatModel = ActivityChatModel.forDefault( |
62 | | - ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) |
63 | | - .setTaskQueue("chat-heavy") |
64 | | - .build()); |
65 | | -``` |
66 | | - |
67 | | -For configuration-driven per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. A key equal to `ChatModelTypes.DEFAULT_MODEL_NAME` (the literal `"default"`) acts as a global catch-all: any chat model that lacks a bean-name-specific entry — including models contributed by third-party starters that your application did not declare directly — picks up that entry. |
68 | | - |
69 | | -```java |
70 | | -@Bean |
71 | | -ChatModelActivityOptions chatModelActivityOptions() { |
72 | | - ActivityOptions fiveMinute = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) |
73 | | - .setStartToCloseTimeout(Duration.ofMinutes(5)) |
74 | | - .build(); |
75 | | - return new ChatModelActivityOptions(Map.of( |
76 | | - ChatModelTypes.DEFAULT_MODEL_NAME, fiveMinute, // global baseline |
77 | | - "claude", ActivityOptions.newBuilder(fiveMinute).setTaskQueue("claude-heavy").build())); // override |
78 | | -} |
79 | | -``` |
80 | | - |
81 | | -Keys that neither match a registered ChatModel bean name nor equal `"default"` cause plugin construction to fail, so a typo surfaces at startup. |
82 | | - |
83 | | -`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout. |
84 | | - |
85 | | -The Temporal UI labels chat and MCP rows with a short Summary (`chat: <model>`, `mcp: <client>.<tool>`). `ActivityChatModel` and `ActivityMcpClient` are constructed only via these factories — there is no public constructor, so users can't accidentally end up in a code path that skips UI labels. Prompt text is deliberately not included in chat summaries to avoid leaking user input (which may contain PII, credentials, or other sensitive data) into workflow history and server logs. |
86 | | - |
87 | | -## Tool Types |
88 | | - |
89 | | -Tools passed to `defaultTools()` are handled based on their type: |
90 | | - |
91 | | -### Activity stubs |
92 | | - |
93 | | -Interfaces annotated with both `@ActivityInterface` and `@Tool` methods. Auto-detected and executed as durable Temporal activities with retries and timeouts. |
94 | | - |
95 | | -```java |
96 | | -@ActivityInterface |
97 | | -public interface WeatherActivity { |
98 | | - @Tool(description = "Get weather for a city") @ActivityMethod |
99 | | - String getWeather(String city); |
100 | | -} |
101 | | -``` |
102 | | - |
103 | | -### `@SideEffectTool` |
104 | | - |
105 | | -Classes annotated with `@SideEffectTool`. Each `@Tool` method is wrapped in `Workflow.sideEffect()` — the result is recorded in history on first execution and replayed from history on subsequent replays. Use for cheap non-deterministic operations (timestamps, UUIDs). |
106 | | - |
107 | | -```java |
108 | | -@SideEffectTool |
109 | | -public class TimestampTools { |
110 | | - @Tool(description = "Get current time") |
111 | | - public String now() { return Instant.now().toString(); } |
112 | | -} |
113 | | -``` |
114 | | - |
115 | | -### Plain tools |
116 | | - |
117 | | -Any class with `@Tool` methods that isn't a stub or `@SideEffectTool`. Executes directly in the workflow thread. The user is responsible for determinism — call activities, `Workflow.sideEffect()`, child workflows, etc. as needed. |
118 | | - |
119 | | -```java |
120 | | -public class MyTools { |
121 | | - @Tool(description = "Process data") |
122 | | - public String process(String input) { |
123 | | - SomeActivity act = Workflow.newActivityStub(SomeActivity.class, opts); |
124 | | - return act.doWork(input); |
125 | | - } |
126 | | -} |
127 | | -``` |
128 | | - |
129 | | -### Nexus service stubs |
130 | | - |
131 | | -Auto-detected and executed as Nexus operations, similar to activity stubs. |
132 | | - |
133 | | -## Migrating from plain Spring AI |
134 | | - |
135 | | -The plugin is designed so that bringing an existing Spring AI service onto Temporal is a localized change. Outside Temporal, you probably have something like: |
136 | | - |
137 | | -```java |
138 | | -@Service |
139 | | -class AssistantService { |
140 | | - private final ChatClient chatClient; |
141 | | - |
142 | | - AssistantService(ChatModel chatModel) { |
143 | | - this.chatClient = ChatClient.builder(chatModel) |
144 | | - .defaultSystem("You are a helpful assistant.") |
145 | | - .defaultTools(new WeatherTools(), new MyTools()) |
146 | | - .build(); |
147 | | - } |
148 | | - |
149 | | - String respond(String goal) { |
150 | | - return chatClient.prompt().user(goal).call().content(); |
151 | | - } |
152 | | -} |
153 | | -``` |
154 | | - |
155 | | -Inside a Temporal Workflow it becomes: |
156 | | - |
157 | | -```java |
158 | | -@WorkflowInterface |
159 | | -interface AssistantWorkflow { @WorkflowMethod String respond(String goal); } |
160 | | - |
161 | | -class AssistantWorkflowImpl implements AssistantWorkflow { |
162 | | - private final ChatClient chatClient; |
163 | | - |
164 | | - @WorkflowInit |
165 | | - AssistantWorkflowImpl(String goal) { |
166 | | - WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts); |
167 | | - this.chatClient = TemporalChatClient.builder(ActivityChatModel.forDefault()) |
168 | | - .defaultSystem("You are a helpful assistant.") |
169 | | - .defaultTools(weather, new MyTools()) |
170 | | - .build(); |
171 | | - } |
172 | | - |
173 | | - @Override |
174 | | - public String respond(String goal) { |
175 | | - return chatClient.prompt().user(goal).call().content(); |
176 | | - } |
177 | | -} |
178 | | -``` |
179 | | - |
180 | | -Three substitutions: |
181 | | - |
182 | | -| Outside Temporal | Inside a Temporal workflow | |
183 | | -|---|---| |
184 | | -| `ChatModel chatModel` (injected) | `ActivityChatModel.forDefault()` | |
185 | | -| `ChatClient.builder(chatModel)` | `TemporalChatClient.builder(activityChatModel)` | |
186 | | -| `new WeatherTools()` for a plain POJO tool | `Workflow.newActivityStub(WeatherActivity.class, ...)` for a durable tool | |
187 | | - |
188 | | -Plain `@Tool` POJOs, `@SideEffectTool`-annotated classes, and Nexus service stubs all work the same way — see **Tool Types** above. |
189 | | - |
190 | | -## Media in messages |
191 | | - |
192 | | -If you attach media (images, audio, etc.) to a `UserMessage` or an `AssistantMessage`, prefer passing it by URI rather than raw bytes: |
193 | | - |
194 | | -```java |
195 | | -// Good — only the URL crosses the activity boundary. |
196 | | -Media image = new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example.com/pic.png")); |
197 | | - |
198 | | -// Works, but size-limited — see below. |
199 | | -Media image = new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(bytes)); |
200 | | -``` |
201 | | - |
202 | | -Raw `byte[]` media gets serialized into every chat activity's input *and* result payload, which end up inside Temporal workflow history events. Server-side history events have a fixed 2 MiB size limit; to leave headroom for messages, tool definitions, and options, the plugin enforces a **1 MiB default cap** on inline media bytes and fails fast with an `IllegalArgumentException` pointing you at the URI alternative. |
203 | | - |
204 | | -Override the cap by setting the system property `io.temporal.springai.maxMediaBytes` before your worker starts (pass a positive integer; `0` disables the check). For anything larger than a small thumbnail, the URI route is the right answer — have an activity write the bytes to blob storage, then pass only the URL into the conversation. |
205 | | - |
206 | | -## Known limitations |
207 | | - |
208 | | -- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead. |
209 | | -- **`defaultToolContext(Map<String, Object>)`** — not supported; tool context holds mutable state that can't safely cross the activity boundary. Pass required context as activity parameters or workflow state. |
210 | | -- **Child workflow stubs as tools** — not supported. Wrap a plain `@Tool` method that starts the child workflow via `Workflow.newChildWorkflowStub(...)` and call through to it yourself. |
211 | | -- **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media. |
212 | | -- **Provider-specific `ChatOptions` via `ChatClient.defaultOptions(...)`** — works as long as your `ChatOptions` subclass overrides `copy()` to return its own type (every real provider class does this). A subclass inheriting the default `copy()` loses its identity before the plugin sees it — same behavior as outside Temporal. |
213 | | - |
214 | | -## Observability |
215 | | - |
216 | | -`TemporalChatClient.builder(chatModel, observationRegistry, customConvention)` accepts a Micrometer `ObservationRegistry` for Spring AI-side chat client metrics. Temporal-side metrics (activity durations, retries) are emitted by the SDK's `MetricsScope` — see the [Temporal Java SDK observability docs](https://docs.temporal.io/develop/java/observability) for how to wire an OpenTelemetry or Prometheus exporter onto your workers. The two layers compose: Spring AI observations cover what the caller does; Temporal metrics cover what the scheduled activity does. |
217 | | - |
218 | | -## Optional Integrations |
219 | | - |
220 | | -Auto-configured when their dependencies are on the classpath: |
221 | | - |
222 | | -| Feature | Dependency | What it registers | |
223 | | -|--------------|-----------------|--------------------------| |
224 | | -| Vector Store | `spring-ai-rag` | `VectorStoreActivity` | |
225 | | -| Embeddings | `spring-ai-rag` | `EmbeddingModelActivity` | |
226 | | -| MCP | `spring-ai-mcp` | `McpClientActivity` | |
227 | | - |
228 | | -These can also be set up programmatically without auto-configuration: |
229 | | - |
230 | | -```java |
231 | | -new VectorStorePlugin(vectorStore) |
232 | | -new EmbeddingModelPlugin(embeddingModel) |
233 | | -new McpPlugin() |
234 | | -``` |
| 1 | +# Temporal Spring AI has moved |
| 2 | + |
| 3 | +The Spring AI integration is developed and released in |
| 4 | +[temporalio/ai-integrations](https://github.com/temporalio/ai-integrations/tree/main/java/spring-ai) |
| 5 | +under the independently versioned Maven coordinate `io.temporal:spring-ai`. |
| 6 | +Version [0.1.0 is available on Maven Central](https://central.sonatype.com/artifact/io.temporal/spring-ai/0.1.0). |
| 7 | +The Java package remains `io.temporal.springai`. |
| 8 | + |
| 9 | +See the [standalone usage guide](https://github.com/temporalio/ai-integrations/tree/main/java/spring-ai) |
| 10 | +for installation and migration instructions. The integration's releases are |
| 11 | +versioned independently of the Java SDK. |
| 12 | + |
| 13 | +The standalone 0.1.0 release requires Java 17 or later, Spring AI 2, Spring Boot 4, |
| 14 | +and Temporal Java SDK 1.40.0 or later. Spring AI 1 / Spring Boot 3 applications |
| 15 | +should remain on `io.temporal:temporal-spring-ai:1.40.x` until they are ready to |
| 16 | +migrate. Spring AI 1 workflow histories are not replay-compatible with the new |
| 17 | +integration; complete existing executions or use separate worker deployments. |
| 18 | + |
| 19 | +This directory contains only a relocation POM from the next SDK version of |
| 20 | +`io.temporal:temporal-spring-ai` to `io.temporal:spring-ai:0.1.0`. It has no source, |
| 21 | +tests, JARs, or Spring AI dependencies. The SDK BOM retains the old coordinate for |
| 22 | +this final relocation release. |
| 23 | + |
| 24 | +After publishing the one-time relocation POM, remove this Gradle project, its BOM |
| 25 | +constraint, and its CODEOWNERS entry from sdk-java. Existing releases remain |
| 26 | +available at their original coordinates. |
0 commit comments