Conversation
donald-pinckney
left a comment
There was a problem hiding this comment.
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
...oral-spring-ai/src/main/java/io/temporal/springai/tool/LocalActivityToolCallbackWrapper.java
Outdated
Show resolved
Hide resolved
| */ | ||
| public class LocalActivityToolCallbackWrapper implements ToolCallback { | ||
|
|
||
| private static final Map<String, ToolCallback> CALLBACK_REGISTRY = new ConcurrentHashMap<>(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done — added javadoc documenting the eviction leak risk and pointing to getRegisteredCallbackCount() for monitoring.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
most likely not going ahead with SandboxingAdvisor, which would also kill this.
temporal-spring-ai/src/main/java/io/temporal/springai/util/TemporalStubUtil.java
Outdated
Show resolved
Hide resolved
temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java
Outdated
Show resolved
Hide resolved
temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java
Outdated
Show resolved
Hide resolved
temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java
Show resolved
Hide resolved
f210b63 to
35e9b29
Compare
donald-pinckney
left a comment
There was a problem hiding this comment.
Found an additional bug during testing.
temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java
Outdated
Show resolved
Hide resolved
6b01988 to
4fd80ec
Compare
There was a problem hiding this comment.
Obviously will delete this prior to merging ;)
ee8e3c0 to
2a40064
Compare
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>
2a40064 to
969aabd
Compare
| * <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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
temporal-spring-ai/src/main/java/io/temporal/springai/plugin/VectorStorePlugin.java
Outdated
Show resolved
Hide resolved
| * | ||
| * @see ExecuteToolLocalActivity | ||
| */ | ||
| public class LocalActivityToolCallbackWrapper implements ToolCallback { |
There was a problem hiding this comment.
Is there a good reason we are making local activities so primary?
There was a problem hiding this comment.
would be resolved following other discussion below
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java
Outdated
Show resolved
Hide resolved
...i/src/main/java/io/temporal/springai/autoconfigure/SpringAiVectorStoreAutoConfiguration.java
Outdated
Show resolved
Hide resolved
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>
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
Tool Types
Tools passed to
defaultTools()are handled based on their type:@ActivityInterface+@Tool. Auto-detected, executed as durable activities.@SideEffectTool— wrapped inWorkflow.sideEffect(). Use for timestamps, UUIDs, etc.@Toolmethods. Executes directly in workflow context; user is responsible for determinism.Optional Integrations
Auto-configured when their dependencies are on the classpath. Can also be created programmatically.
spring-ai-ragVectorStoreActivityspring-ai-ragEmbeddingModelActivityspring-ai-mcpMcpClientActivityCompatibility
Related
🤖 Generated with Claude Code