Skip to content

Commit 686a62d

Browse files
Add temporal-spring-ai module (#2829)
* Add temporal-spring-ai module for Spring AI integration Adds a new module that integrates Spring AI with Temporal workflows, enabling durable AI model calls, vector store operations, embeddings, and MCP tool execution as Temporal activities. Key components: - ActivityChatModel: ChatModel implementation backed by activities - TemporalChatClient: Temporal-aware ChatClient with tool detection - SpringAiPlugin: Auto-registers Spring AI activities with workers - Tool system: @DeterministicTool, @SideEffectTool, activity-backed tools - MCP integration: ActivityMcpClient for durable MCP tool calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Document callback registry lifecycle risk and add stream() override T9: Add javadoc to LocalActivityToolCallbackWrapper explaining the leak risk when workflows are evicted from worker cache mid-execution. T11: Override stream() in ActivityChatModel to throw UnsupportedOperationException with a clear message, since streaming through Temporal activities is not supported. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add tests for temporal-spring-ai (T1-T4) T1: ChatModelActivityImplTest (10 tests) - type conversion between ChatModelTypes and Spring AI types, multi-model resolution, tool definition passthrough, model options mapping. T2: TemporalToolUtilTest (22 tests) - tool detection and conversion for @DeterministicTool, @SideEffectTool, stub type detection, error cases for unknown/null types. T3: WorkflowDeterminismTest (2 tests) - verifies workflows using ActivityChatModel with tools complete without non-determinism errors in the Temporal test environment. T4: SpringAiPluginTest (10 tests) - plugin registration with various bean combinations, multi-model support, default model resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update TASK_QUEUE.json: T1-T4, T9, T11 completed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add T14 (NPE bug) to TASK_QUEUE.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix UUID non-determinism, null metadata NPE, and unbounded tool loop T5: Replace UUID.randomUUID() with Workflow.randomUUID() in LocalActivityToolCallbackWrapper to ensure deterministic replay. T7: Convert recursive tool call loop in ActivityChatModel.call() to iterative loop with MAX_TOOL_CALL_ITERATIONS (10) limit to prevent infinite recursion from misbehaving models. T14: Fix NPE when ChatResponse metadata is null by only calling .metadata() on the builder when metadata is non-null. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Split SpringAiPlugin into conditional auto-configuration (T6) Split the monolithic SpringAiPlugin into one core plugin + three optional plugins, each with its own @ConditionalOnClass-guarded auto-configuration: - SpringAiPlugin: core chat + ExecuteToolLocalActivity (always) - VectorStorePlugin: VectorStore activity (when spring-ai-rag present) - EmbeddingModelPlugin: EmbeddingModel activity (when spring-ai-rag present) - McpPlugin: MCP activity (when spring-ai-mcp present) This fixes ClassNotFoundException when optional deps aren't on the runtime classpath. compileOnly scopes now work correctly because Spring skips loading the conditional classes entirely when the @ConditionalOnClass check fails. Also resolves T10 (unnecessary MCP reflection) — McpPlugin directly references McpClientActivityImpl instead of using Class.forName(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update TASK_QUEUE.json: T5, T6, T7, T10, T14 completed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update TASK_QUEUE.json: T12 completed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Replace fragile string matching with instanceof in TemporalStubUtil (T8) Use direct instanceof checks against the SDK's internal invocation handler classes instead of string-matching on class names. Since the plugin lives in the SDK repo, any handler rename would break compilation rather than silently failing at runtime. ChildWorkflowInvocationHandler is package-private so it still uses a class name check (endsWith instead of contains for precision). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update TASK_QUEUE.json: T8 completed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use WorkflowReplayer for proper replay determinism tests Previously the tests just ran workflows forward. Now they capture the event history after execution and replay it with WorkflowReplayer.replayWorkflowExecution(), which will throw NonDeterministicException if the workflow code generates different commands on replay. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Simplify stream() exception message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Revert tool call iteration limit, match Spring AI's recursive pattern Remove MAX_TOOL_CALL_ITERATIONS and the iterative loop. Use recursive internalCall() matching Spring AI's OpenAiChatModel pattern. Temporal's activity timeouts and workflow execution timeout already bound runaway tool loops. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix javadoc reference for publishToMavenLocal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use SimplePlugin builder for VectorStore and EmbeddingModel plugins Replace VectorStorePlugin and EmbeddingModelPlugin subclasses with SimplePlugin.newBuilder().registerActivitiesImplementations() in the auto-config classes. These plugins are trivial activity registrations that don't need custom subclasses when the builder already supports this. SpringAiPlugin stays as a subclass (has getter API for chat models). McpPlugin stays as a subclass (needs SmartInitializingSingleton for deferred registration). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clean up TASK_QUEUE.json: remove completed tasks, add T15 Remove all completed/reverted tasks. Add T15 for the tool execution model change discussed in PR review (run plain tools in workflow context by default, remove @DeterministicTool and SandboxingAdvisor). Blocked on finalizing review discussion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add link to proposed design for T15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Triage Copilot and DABH review comments into TASK_QUEUE T16-T21: fixes (NPE guard, error message, multi-ChatModel bug, replay test, duplicate MCP names, embedding boxing) T22-T24: design discussions to have with Don (starter artifact, MCP caching, Object vs String) T25-T27: replies (docs, SandboxingAdvisor tests, ToolContext drop — last two likely moot if T15 lands) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T16: Guard assistantMessage.getMedia() against null Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T17: Include Nexus stubs in unrecognized tool type error message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T18: Use ObjectProvider to fix NoUniqueBeanDefinitionException Multiple ChatModel beans without @primary caused startup failure. ObjectProvider.getIfAvailable() returns null gracefully instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T19: Make replay test exercise tool calls ToolCallingStubChatModel returns a tool call request on first call, then final text after receiving the tool response. This verifies the full model -> tool -> model loop replays deterministically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T20: Handle duplicate MCP client names with clear error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T21: Use float[] instead of List<Double> in EmbeddingModelTypes Avoids boxing 1536+ Double objects per embedding. float[] matches Spring AI's native embedding representation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T18 fix: Use getIfUnique() instead of getIfAvailable() getIfAvailable() still throws NoUniqueBeanDefinitionException when multiple beans exist without @primary. getIfUnique() returns null in that case, which is what we want — SpringAiPlugin falls back to the first entry in the map. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add T28: Restore plugin classes as public API per tconley review Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clean up TASK_QUEUE: remove completed, mark superseded Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T28: Restore VectorStorePlugin and EmbeddingModelPlugin as public classes Use SimplePlugin's builder constructor (super(Builder)) so the classes are named and user-creatable (new VectorStorePlugin(vectorStore)) while still using the builder internally rather than overriding initializeWorker. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T23: Resolved — MCP capability caching is correct Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T24: Change from discussion to fix — rawContent should be String Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T22: Defer starter artifact to after PR merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T25: Replied to DABH about docs. Added T29 for README follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T29: Bump to high priority, do in this PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T15: Also remove LocalActivityToolCallbackWrapper and ExecuteToolLocalActivity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T24: Change ChatModelTypes.Message rawContent from Object to String Spring AI's Content.getText() returns String. We always cast to String on both sides. Object type gave false flexibility that would ClassCastException at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T29: Add README with compatibility matrix and quick start Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T30: Fix edge CI Java version mismatch Edge CI sets edgeDepsTest which compiles temporal-sdk targeting Java 21. Our module hardcoded Java 17, causing Gradle to reject the dependency at resolution time. Now uses 21 when edgeDepsTest is set, 17 otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T31: Skip temporal-spring-ai on JDK < 17 Docker CI runs Java 11 which can't compile --release 17. Conditionally exclude the module from settings.gradle and BOM when the build JDK is below 17. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * T15: Plain tools execute in workflow context by default Tools passed to defaultTools() that aren't activity stubs, nexus stubs, or @SideEffectTool now execute directly in workflow context. The user is responsible for determinism — they can call activities, sideEffect, child workflows, etc. from within their tool methods. Removed: - @DeterministicTool annotation (plain tools are the default now) - SandboxingAdvisor (no more automatic wrapping) - LocalActivityToolCallbackWrapper, ExecuteToolLocalActivity, ExecuteToolLocalActivityImpl (only used by SandboxingAdvisor) - ExecuteToolLocalActivity registration from SpringAiPlugin This matches how other Temporal AI integrations handle tools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clean up TASK_QUEUE: remove completed and superseded Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update README with tool execution model Document the three tool types: activity stubs (auto-detected), @SideEffectTool (wrapped in sideEffect), and plain tools (execute in workflow context, user responsible for determinism). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * README: mention programmatic plugin setup for optional integrations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove TASK_QUEUE.json Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Publish temporal-spring-ai from the JDK 11 release job via toolchains The module was previously excluded from the build whenever Gradle ran on JDK <17, which silently dropped it from the release and snapshot publish jobs (both use JDK 11). Switch to the same pattern temporal-sdk already uses: resolve a JDK 17+ compiler/javadoc/launcher via Gradle toolchains when the running JVM is older, and drop the conditional include guards in settings.gradle and the BOM so the artifact always publishes. Shared javadoc config in gradle/java.gradle now keys flag choices off the javadoc tool's language version instead of JavaVersion.current(), so modules that override javadocTool (temporal-spring-ai on JDK 11) don't get JDK 9-12-only flags passed to a JDK 17 tool. Add JDK 17 alongside JDK 11 in the publish workflows so toolchain discovery has a matching installation to resolve. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Align markdown tables * Skip temporal-spring-ai tests when testJavaVersion < 17 The unit_test_jdk8 CI job runs tests with -PtestJavaVersion=11 to verify Java 8 bytecode works on an older runtime. temporal-spring-ai emits Java 17 bytecode, so its classes can't load on JDK 11 — the test JVM fails with UnsupportedClassVersionError before any test runs. Gate the test task on testJavaVersion >= springAiReleaseInt so the module is silently skipped in that job, which is what we want: there is no meaningful "Spring AI on JDK 11" configuration to test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b6f4283 commit 686a62d

40 files changed

Lines changed: 3971 additions & 0 deletions

settings.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ include 'temporal-testing'
66
include 'temporal-test-server'
77
include 'temporal-opentracing'
88
include 'temporal-kotlin'
9+
include 'temporal-spring-ai'
910
include 'temporal-spring-boot-autoconfigure'
1011
include 'temporal-spring-boot-starter'
1112
include 'temporal-remote-data-encoder'

temporal-bom/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
api project(':temporal-sdk')
1313
api project(':temporal-serviceclient')
1414
api project(':temporal-shaded')
15+
api project(':temporal-spring-ai')
1516
api project(':temporal-spring-boot-autoconfigure')
1617
api project(':temporal-spring-boot-starter')
1718
api project(':temporal-test-server')

temporal-spring-ai/README.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# temporal-spring-ai
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+
## Compatibility
6+
7+
| Dependency | Minimum Version |
8+
|-------------------|-----------------|
9+
| Java | 17 |
10+
| Spring Boot | 3.x |
11+
| Spring AI | 1.1.0 |
12+
| Temporal Java SDK | 1.33.0 |
13+
14+
## Quick Start
15+
16+
Add the dependency (Maven):
17+
18+
```xml
19+
<dependency>
20+
<groupId>io.temporal</groupId>
21+
<artifactId>temporal-spring-ai</artifactId>
22+
<version>${temporal-sdk.version}</version>
23+
</dependency>
24+
```
25+
26+
You also need `temporal-spring-boot-starter` and a Spring AI model starter (e.g. `spring-ai-starter-model-openai`).
27+
28+
The plugin auto-registers `ChatModelActivity` with all Temporal workers. In your workflow:
29+
30+
```java
31+
@WorkflowInit
32+
public MyWorkflowImpl(String goal) {
33+
ActivityChatModel chatModel = ActivityChatModel.forDefault();
34+
35+
WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts);
36+
37+
this.chatClient = TemporalChatClient.builder(chatModel)
38+
.defaultSystem("You are a helpful assistant.")
39+
.defaultTools(weather, new MyTools())
40+
.build();
41+
}
42+
43+
@Override
44+
public String run(String goal) {
45+
return chatClient.prompt().user(goal).call().content();
46+
}
47+
```
48+
49+
## Tool Types
50+
51+
Tools passed to `defaultTools()` are handled based on their type:
52+
53+
### Activity stubs
54+
55+
Interfaces annotated with both `@ActivityInterface` and `@Tool` methods. Auto-detected and executed as durable Temporal activities with retries and timeouts.
56+
57+
```java
58+
@ActivityInterface
59+
public interface WeatherActivity {
60+
@Tool(description = "Get weather for a city") @ActivityMethod
61+
String getWeather(String city);
62+
}
63+
```
64+
65+
### `@SideEffectTool`
66+
67+
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).
68+
69+
```java
70+
@SideEffectTool
71+
public class TimestampTools {
72+
@Tool(description = "Get current time")
73+
public String now() { return Instant.now().toString(); }
74+
}
75+
```
76+
77+
### Plain tools
78+
79+
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.
80+
81+
```java
82+
public class MyTools {
83+
@Tool(description = "Process data")
84+
public String process(String input) {
85+
SomeActivity act = Workflow.newActivityStub(SomeActivity.class, opts);
86+
return act.doWork(input);
87+
}
88+
}
89+
```
90+
91+
### Nexus service stubs
92+
93+
Auto-detected and executed as Nexus operations, similar to activity stubs.
94+
95+
## Optional Integrations
96+
97+
Auto-configured when their dependencies are on the classpath:
98+
99+
| Feature | Dependency | What it registers |
100+
|--------------|-----------------|--------------------------|
101+
| Vector Store | `spring-ai-rag` | `VectorStoreActivity` |
102+
| Embeddings | `spring-ai-rag` | `EmbeddingModelActivity` |
103+
| MCP | `spring-ai-mcp` | `McpClientActivity` |
104+
105+
These can also be set up programmatically without auto-configuration:
106+
107+
```java
108+
new VectorStorePlugin(vectorStore)
109+
new EmbeddingModelPlugin(embeddingModel)
110+
new McpPlugin()
111+
```

temporal-spring-ai/build.gradle

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
description = '''Temporal Java SDK Spring AI Plugin'''
2+
3+
ext {
4+
springAiVersion = '1.1.0'
5+
// Spring AI requires Spring Boot 3.x / Java 17+
6+
springBootVersionForSpringAi = "$springBoot3Version"
7+
}
8+
9+
// Spring AI requires Java 17+, override the default Java 8 target from java.gradle.
10+
// When edgeDepsTest is set, use 21 to match other modules.
11+
ext {
12+
springAiReleaseInt = project.hasProperty("edgeDepsTest") ? 21 : 17
13+
}
14+
15+
compileJava {
16+
options.compilerArgs.removeAll(['--release', '8'])
17+
options.release = springAiReleaseInt
18+
}
19+
20+
compileTestJava {
21+
options.compilerArgs.removeAll(['--release', '8'])
22+
options.release = springAiReleaseInt
23+
}
24+
25+
dependencies {
26+
api(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersionForSpringAi"))
27+
api(platform("org.springframework.ai:spring-ai-bom:$springAiVersion"))
28+
29+
// this module shouldn't carry temporal-sdk with it, especially for situations when users may be using a shaded artifact
30+
compileOnly project(':temporal-sdk')
31+
compileOnly project(':temporal-spring-boot-autoconfigure')
32+
33+
api 'org.springframework.boot:spring-boot-autoconfigure'
34+
api 'org.springframework.ai:spring-ai-client-chat'
35+
36+
implementation 'org.springframework.boot:spring-boot-starter'
37+
38+
// Optional: Vector store support
39+
compileOnly 'org.springframework.ai:spring-ai-rag'
40+
41+
// Optional: MCP (Model Context Protocol) support
42+
compileOnly 'org.springframework.ai:spring-ai-mcp'
43+
44+
testImplementation project(':temporal-sdk')
45+
testImplementation project(':temporal-testing')
46+
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
47+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
48+
testImplementation 'org.springframework.ai:spring-ai-rag'
49+
50+
testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}"
51+
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
52+
}
53+
54+
tasks.test {
55+
useJUnitPlatform()
56+
// This module's bytecode targets Java 17+, so it can't run on older JVMs.
57+
// The unit_test_jdk8 CI job sets -PtestJavaVersion=11 to verify Java 8 bytecode
58+
// works on JDK 11 — skip our tests there since the classes won't even load.
59+
if (project.hasProperty("testJavaVersion")
60+
&& (project.property("testJavaVersion") as int) < springAiReleaseInt) {
61+
enabled = false
62+
}
63+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.temporal.springai.activity;
2+
3+
import io.temporal.activity.ActivityInterface;
4+
import io.temporal.activity.ActivityMethod;
5+
import io.temporal.springai.model.ChatModelTypes;
6+
7+
/**
8+
* Temporal activity interface for calling Spring AI chat models.
9+
*
10+
* <p>This activity wraps a Spring AI {@link org.springframework.ai.chat.model.ChatModel} and makes
11+
* it callable from within Temporal workflows. The activity handles serialization of prompts and
12+
* responses, enabling durable AI conversations with automatic retries and timeout handling.
13+
*/
14+
@ActivityInterface
15+
public interface ChatModelActivity {
16+
17+
/**
18+
* Calls the chat model with the given input.
19+
*
20+
* @param input the chat model input containing messages, options, and tool definitions
21+
* @return the chat model output containing generated responses and metadata
22+
*/
23+
@ActivityMethod
24+
ChatModelTypes.ChatModelActivityOutput callChatModel(ChatModelTypes.ChatModelActivityInput input);
25+
}

0 commit comments

Comments
 (0)