Skip to content

Add temporal-spring-ai module#2829

Open
donald-pinckney wants to merge 44 commits intomasterfrom
d/20260406-164203
Open

Add temporal-spring-ai module#2829
donald-pinckney wants to merge 44 commits intomasterfrom
d/20260406-164203

Conversation

@donald-pinckney
Copy link
Copy Markdown
Contributor

@donald-pinckney donald-pinckney commented Apr 6, 2026

Summary

Adds temporal-spring-ai, a new module that integrates Spring AI with Temporal workflows. AI model calls become durable activities; tools can be activity stubs, @SideEffectTool-wrapped, or plain workflow code.

Quick Start

@WorkflowInit
public MyWorkflowImpl(String goal) {
    ActivityChatModel chatModel = ActivityChatModel.forDefault();
    WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts);

    this.chatClient = TemporalChatClient.builder(chatModel)
            .defaultSystem("You are a helpful assistant.")
            .defaultTools(weather, new MyTools())
            .build();
}

@Override
public String run(String goal) {
    return chatClient.prompt().user(goal).call().content();
}

Tool Types

Tools passed to defaultTools() are handled based on their type:

  • Activity stubs — interfaces with @ActivityInterface + @Tool. Auto-detected, executed as durable activities.
  • @SideEffectTool — wrapped in Workflow.sideEffect(). Use for timestamps, UUIDs, etc.
  • Plain tools — any class with @Tool methods. Executes directly in workflow context; user is responsible for determinism.
  • Nexus service stubs — auto-detected, executed as Nexus operations.

Optional Integrations

Auto-configured when their dependencies are on the classpath. Can also be created programmatically.

Feature Dependency What it registers
Vector Store spring-ai-rag VectorStoreActivity
Embeddings spring-ai-rag EmbeddingModelActivity
MCP spring-ai-mcp McpClientActivity

Compatibility

Dependency Minimum Version
Java 17
Spring Boot 3.x
Spring AI 1.1.0
Temporal Java SDK 1.33.0

Related

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor Author

@donald-pinckney donald-pinckney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review: temporal-spring-ai plugin

What's done well

Determinism architecture is sound. The core design — routing all non-deterministic operations (LLM calls, vector store ops, embeddings, MCP tools) through Temporal activities — is exactly right. The three-tier tool system (@DeterministicTool for pure functions, @SideEffectTool for cheap non-determinism, activity stubs for durable I/O) maps cleanly onto Temporal's primitives.

Tool execution stays in the workflow. ChatModelActivityImpl sets internalToolExecutionEnabled(false) and only passes tool definitions to the model. The actual tool dispatch happens back in the workflow via ToolCallingManager, which means tool calls respect their Temporal wrapping (activity, sideEffect, etc.).

SideEffectToolCallback correctly wraps each call in Workflow.sideEffect(), recording the result in history on first execution and replaying the stored value.

ActivityChatModel.call() handles the tool loop correctly — it recursively calls itself when the model requests tools that don't returnDirect, maintaining proper conversation history.

Issues flagged inline below

*/
public class LocalActivityToolCallbackWrapper implements ToolCallback {

private static final Map<String, ToolCallback> CALLBACK_REGISTRY = new ConcurrentHashMap<>();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity — static registry lifecycle risk.

This static ConcurrentHashMap holds live ToolCallback references. Callbacks are removed in a finally block after the local activity completes, but if the workflow is evicted from the worker cache mid-execution (before finally runs), callbacks leak. Worth documenting or adding periodic cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added javadoc documenting the eviction leak risk and pointing to getRegisteredCallbackCount() for monitoring.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a bounded size or TTL-based eviction strategy for the hash map make more sense, as opposed to just giving users a method to monitor the count? Even some (imo very high) limit like 10,000? Or maybe log a warning in case a user hits some huge number like that? Wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most likely not going ahead with SandboxingAdvisor, which would also kill this.

Copy link
Copy Markdown
Contributor Author

@donald-pinckney donald-pinckney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found an additional bug during testing.

@donald-pinckney donald-pinckney force-pushed the d/20260406-164203 branch 2 times, most recently from 6b01988 to 4fd80ec Compare April 7, 2026 20:12
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obviously will delete this prior to merging ;)

donald-pinckney and others added 15 commits April 8, 2026 11:49
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>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* <p>This plugin is conditionally created by auto-configuration when Spring AI's {@link
* VectorStore} is on the classpath and a VectorStore bean is available.
*/
public class VectorStorePlugin extends SimplePlugin {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the primary reason for these being separate plugins because of spring configuration interaction? Otherwise I would think it makes more sense to have one with configurations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not just Spring config interaction, it's about not forcing transitive dependencies on users. A single plugin would either need all deps as implementation or use reflection/Object types to avoid class references (fragile/yucky). Given that normal (non-Temporal) Spring AI already ships things as separate artifacts, this seemed like the most natural spring-ai-native design.

Other option is to have implementation dependencies on Spring AI's various artifacts (for MCP, RAG, etc.), which is simpler but aligns less with Spring from what I've seen. Others could weigh in here though.

To be clear, you can't just have compileOnly deps on those optional dependencies, because Spring needs to load and introspect the plugin class at startup, so any field types that don't exist at runtime cause a ClassNotFoundException.

*
* @see ExecuteToolLocalActivity
*/
public class LocalActivityToolCallbackWrapper implements ToolCallback {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a good reason we are making local activities so primary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be resolved following other discussion below

donald-pinckney and others added 2 commits April 9, 2026 18:44
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
donald-pinckney and others added 22 commits April 10, 2026 08:45
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sses

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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lActivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants