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
...oral-spring-ai/src/main/java/io/temporal/springai/tool/LocalActivityToolCallbackWrapper.java
Show resolved
Hide resolved
temporal-spring-ai/src/main/java/io/temporal/springai/util/TemporalStubUtil.java
Outdated
Show resolved
Hide resolved
| .build(); | ||
| } else { | ||
| // Send tool results back to the model (recursive call) | ||
| return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); |
There was a problem hiding this comment.
Medium severity — unbounded recursive tool loop.
This recursively calls call() when the model keeps requesting tools. A misbehaving model could cause infinite recursion / stack overflow. Consider adding a max iteration count. Spring AI's default ToolCallingManager has one internally, but since the loop is driven manually here, that limit is bypassed.
There was a problem hiding this comment.
Test coverage added (ChatModelActivityImplTest, 10 tests). Ready to add loop limit — next up.
There was a problem hiding this comment.
Done — converted recursive call to iterative loop with MAX_TOOL_CALL_ITERATIONS = 10 limit.
There was a problem hiding this comment.
Reverted — removed the iteration limit and switched back to recursive internalCall() matching Spring AI's OpenAiChatModel pattern. Temporal's activity timeouts and workflow execution timeout already bound runaway tool loops.
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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.
| private final VectorStore vectorStore; | ||
|
|
||
| public VectorStorePlugin(VectorStore vectorStore) { | ||
| super("io.temporal.spring-ai-vectorstore"); |
There was a problem hiding this comment.
Would it make more sense to use a SimplePlugin builder to create this? In other languages we would not need to manually override initializeWorker, I would expect there to be some way to use the simple plugin's registerActivitiesImplementations rather than the underlying worker.
| * | ||
| * @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?
| * <li>{@link ActivityToolCallback} - Already safe (executes as activity) | ||
| * <li>{@link NexusToolCallback} - Already safe (executes as Nexus operation) | ||
| * <li>{@link SideEffectToolCallback} - Already safe (wrapped in sideEffect()) | ||
| * <li>Other callbacks - Wrapped in {@link LocalActivityToolCallbackWrapper} with warning |
There was a problem hiding this comment.
Mmmm... this is not the approach I have taken elsewhere. I'm of the opinion that tools should execute in workflow context by default, where the user could define a tool which does various workflow operations without us needing to define special tool types for them. Child workflow, multiple operations, etc.
There was a problem hiding this comment.
Pull request overview
Adds a new temporal-spring-ai module that integrates Spring AI with Temporal by making chat model calls, tool execution, embeddings, vector store operations, and MCP tool calls durable Temporal primitives.
Changes:
- Introduces
ActivityChatModel+ChatModelActivityto run LLM calls as activities while executing tools deterministically in workflows. - Adds tool conversion utilities (activity/local activity/Nexus stubs,
@DeterministicTool,@SideEffectTool) plus a sandboxing advisor for unsafe tools. - Adds Spring Boot auto-configuration + worker plugins for ChatModel, VectorStore, EmbeddingModel, and MCP, along with unit + replay determinism tests.
Reviewed changes
Copilot reviewed 45 out of 45 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java | Workflow-safe ChatModel that delegates model calls to a Temporal activity and loops for tool execution. |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivity.java | Activity interface for invoking Spring AI chat models durably. |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java | Activity implementation converting between serializable types and Spring AI prompt/response types. |
| temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java | Serializable request/response/message/tool types for activity communication. |
| temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java | ChatClient builder that auto-converts Temporal primitives into Spring AI tool callbacks. |
| temporal-spring-ai/src/main/java/io/temporal/springai/util/TemporalToolUtil.java | Converts provided tool objects into appropriate ToolCallback implementations. |
| temporal-spring-ai/src/main/java/io/temporal/springai/util/TemporalStubUtil.java | Detects Temporal stub types via proxy invocation handlers. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/DeterministicTool.java | Annotation marking tools safe to execute directly in workflows. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/SideEffectTool.java | Annotation marking tools to be executed via Workflow.sideEffect(). |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/SideEffectToolCallback.java | Wrapper executing tool callbacks inside Workflow.sideEffect() for determinism. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/ActivityToolUtil.java | Extracts Spring AI tool definitions from activity interfaces/stubs. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/ActivityToolCallback.java | Marker wrapper for callbacks backed by Temporal activity stubs. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/NexusToolUtil.java | Extracts Spring AI tool definitions from Nexus service stubs. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/NexusToolCallback.java | Marker wrapper for callbacks backed by Nexus service stubs. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/ExecuteToolLocalActivity.java | Local-activity interface used to execute otherwise-unsafe tool callbacks deterministically. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/ExecuteToolLocalActivityImpl.java | Local-activity implementation that looks up and executes registered callbacks. |
| temporal-spring-ai/src/main/java/io/temporal/springai/tool/LocalActivityToolCallbackWrapper.java | Wraps arbitrary callbacks into a local activity using a static callback registry. |
| temporal-spring-ai/src/main/java/io/temporal/springai/advisor/SandboxingAdvisor.java | Advisor that wraps non-Temporal-safe tool callbacks in local activities with warnings. |
| temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java | Core worker plugin registering ChatModel + tool-execution local activity. |
| temporal-spring-ai/src/main/java/io/temporal/springai/plugin/VectorStorePlugin.java | Optional worker plugin registering VectorStore activity. |
| temporal-spring-ai/src/main/java/io/temporal/springai/plugin/EmbeddingModelPlugin.java | Optional worker plugin registering EmbeddingModel activity. |
| temporal-spring-ai/src/main/java/io/temporal/springai/plugin/McpPlugin.java | Optional worker plugin registering MCP client activity (supports deferred registration). |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/VectorStoreActivity.java | Activity interface for vector store operations. |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/VectorStoreActivityImpl.java | Vector store activity implementation + type conversions. |
| temporal-spring-ai/src/main/java/io/temporal/springai/model/VectorStoreTypes.java | Serializable types for VectorStore activity calls. |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/EmbeddingModelActivity.java | Activity interface for embedding operations. |
| temporal-spring-ai/src/main/java/io/temporal/springai/activity/EmbeddingModelActivityImpl.java | Embedding activity implementation + conversions. |
| temporal-spring-ai/src/main/java/io/temporal/springai/model/EmbeddingModelTypes.java | Serializable types for EmbeddingModel activity calls. |
| temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpClientActivity.java | Activity interface for MCP client operations. |
| temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpClientActivityImpl.java | MCP activity implementation delegating to Spring AI MCP sync clients. |
| temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java | Workflow-side wrapper around MCP activities (cached lookups). |
| temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java | ToolCallback implementation that calls MCP tools through activities. |
| temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiTemporalAutoConfiguration.java | Core Spring Boot auto-config producing the main Spring AI plugin bean. |
| temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiVectorStoreAutoConfiguration.java | Conditional auto-config for vector store plugin. |
| temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiEmbeddingAutoConfiguration.java | Conditional auto-config for embedding plugin. |
| temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiMcpAutoConfiguration.java | Conditional auto-config for MCP plugin. |
| temporal-spring-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | Registers the module’s Spring Boot auto-configurations. |
| temporal-spring-ai/src/test/java/io/temporal/springai/activity/ChatModelActivityImplTest.java | Unit tests for serializable type conversions and tool-call preservation. |
| temporal-spring-ai/src/test/java/io/temporal/springai/util/TemporalToolUtilTest.java | Unit tests for tool detection/conversion and stub detection utilities. |
| temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java | Unit tests for plugin registration behavior. |
| temporal-spring-ai/src/test/java/io/temporal/springai/WorkflowDeterminismTest.java | Replay test intended to validate determinism via captured history replay. |
| temporal-spring-ai/build.gradle | New module build with Java 17 target and Spring AI dependencies. |
| temporal-bom/build.gradle | Adds temporal-spring-ai to the BOM. |
| settings.gradle | Includes the new temporal-spring-ai module in the build. |
| TASK_QUEUE.json | Task tracking artifact for the module work items. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| tc.type(), | ||
| new ChatModelTypes.Message.ChatCompletionFunction( | ||
| tc.name(), tc.arguments()))) | ||
| .collect(Collectors.toList()); | ||
| } | ||
| List<ChatModelTypes.MediaContent> mediaContents = | ||
| assistantMessage.getMedia().stream() | ||
| .map(this::toMediaContent) | ||
| .collect(Collectors.toList()); |
There was a problem hiding this comment.
In the ASSISTANT branch, assistantMessage.getMedia().stream() is called without a null/empty guard. Spring AI messages elsewhere in this module use CollectionUtils.isEmpty(...) when reading AssistantMessage#getMedia() (e.g., ChatModelActivityImpl), which suggests getMedia() may be null. If a caller passes an AssistantMessage without media set, this will throw an NPE while converting the Prompt. Consider guarding with CollectionUtils.isEmpty(assistantMessage.getMedia()) (and treating null as empty) to keep ActivityChatModel.call() robust for user-supplied prompts.
| throw new IllegalArgumentException( | ||
| "Tool object of type '" | ||
| + toolObject.getClass().getName() | ||
| + "' is not a " | ||
| + "recognized Temporal primitive (activity stub, local activity stub) or " | ||
| + "a class annotated with @DeterministicTool or @SideEffectTool. " | ||
| + "To use a plain object as a tool, either: " | ||
| + "(1) annotate its class with @DeterministicTool if it's truly deterministic, " | ||
| + "(2) annotate with @SideEffectTool if it's non-deterministic but cheap, " | ||
| + "(3) wrap it in an activity for durable execution, or " | ||
| + "(4) use defaultToolCallbacks() with SandboxingAdvisor to wrap unsafe tools."); |
There was a problem hiding this comment.
The IllegalArgumentException message for unknown tool types doesn’t mention Nexus service stubs as a supported/recognized Temporal primitive, even though convertTools explicitly supports TemporalStubUtil.isNexusServiceStub(...) and converts via NexusToolUtil.fromNexusServiceStub(...). This makes the error misleading for users. Update the message to include Nexus service stubs (and ideally child workflow stubs as explicitly unsupported) so it matches actual behavior.
| @Bean | ||
| public SpringAiPlugin springAiPlugin( | ||
| @Autowired Map<String, ChatModel> chatModels, | ||
| @Autowired(required = false) @Nullable ChatModel primaryChatModel) { | ||
| return new SpringAiPlugin(chatModels, primaryChatModel); | ||
| } |
There was a problem hiding this comment.
Injecting @Autowired(required = false) ChatModel primaryChatModel will still fail application startup when multiple ChatModel beans exist but none is marked @Primary (Spring will throw NoUniqueBeanDefinitionException before this bean method is invoked). Since the plugin already has a deterministic fallback (first entry in the injected map), consider changing this parameter to something that won’t fail under ambiguity (e.g., use an ObjectProvider<ChatModel> and catch NoUniqueBeanDefinitionException, or drop this injection entirely and always let SpringAiPlugin choose from the map).
| @Test | ||
| void workflowWithTools_replaysDeterministically() throws Exception { | ||
| Worker worker = testEnv.newWorker(TASK_QUEUE); | ||
| worker.registerWorkflowImplementationTypes(ChatWithToolsWorkflowImpl.class); | ||
| worker.registerActivitiesImplementations( | ||
| new ChatModelActivityImpl(new StubChatModel("I used the tools!"))); | ||
|
|
||
| testEnv.start(); | ||
|
|
||
| TestChatWorkflow workflow = | ||
| client.newWorkflowStub( | ||
| TestChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); | ||
|
|
||
| String result = workflow.chat("Use tools"); | ||
| assertEquals("I used the tools!", result); | ||
|
|
There was a problem hiding this comment.
workflowWithTools_replaysDeterministically doesn’t actually exercise the tool-calling path: StubChatModel always returns a final AssistantMessage with no tool calls, so none of the default tools (DeterministicTool/SideEffectTool wrappers) are invoked during the workflow run. That means the replay assertion isn’t validating determinism of tool execution. Consider adjusting the stub model to first return an assistant message containing tool call requests (and then a final message after tool responses) so this test covers the intended behavior.
DABH
left a comment
There was a problem hiding this comment.
Looks like a ton of progress in the right direction! 🚀 Just a few questions/comments (on top of what others have already pointed out). Take them or leave them :)
| api(platform("org.springframework.ai:spring-ai-bom:$springAiVersion")) | ||
|
|
||
| // this module shouldn't carry temporal-sdk with it, especially for situations when users may be using a shaded artifact | ||
| compileOnly project(':temporal-sdk') |
There was a problem hiding this comment.
Could this increase friction though if a Spring user is just trying to get started and doesn't realize they need to import the sdk as well? Is there a temporal-spring-ai-starter planned that would pull in the right transitive dependencies to help with easy onboarding? Maybe not necessary but just a thought
| description = '''Temporal Java SDK Spring AI Plugin''' | ||
|
|
||
| ext { | ||
| springAiVersion = '1.1.0' |
There was a problem hiding this comment.
Probably clarify in the docs/readme the compatibility matrix for the module? (So - maybe not in this PR - but at least in the forthcoming docs PR)
...oral-spring-ai/src/main/java/io/temporal/springai/tool/LocalActivityToolCallbackWrapper.java
Show resolved
Hide resolved
| * @see io.temporal.springai.tool.SideEffectTool | ||
| * @see LocalActivityToolCallbackWrapper | ||
| */ | ||
| public class SandboxingAdvisor implements CallAdvisor { |
There was a problem hiding this comment.
If you do go ahead with this route, it looks like tests are lacking at the moment, is there a meaningful way to explicitly test adviseCall?
| if (serverCapabilities == null) { | ||
| serverCapabilities = activity.getServerCapabilities(); | ||
| } | ||
| return serverCapabilities; |
There was a problem hiding this comment.
Does it make sense to cache this in terms of replayability? If the workflow is replayed in some way where the activity's server capabilities have changed, we'd get a stale value? Is that a realistic thing we need to worry about?
| */ | ||
| public McpClientActivityImpl(List<McpSyncClient> mcpClients) { | ||
| this.mcpClients = | ||
| mcpClients.stream().collect(Collectors.toMap(c -> c.getClientInfo().name(), c -> c)); |
There was a problem hiding this comment.
Can two MCP clients have the same name() in their ClientInfo? That would throw an IllegalStateException so we should handle that or validate or something
| // Note: ToolContext cannot be passed through the activity, so we ignore it here. | ||
| // If context is needed, consider using activity parameters or workflow state. |
| @JsonInclude(JsonInclude.Include.NON_NULL) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| public record Message( | ||
| @JsonProperty("content") Object rawContent, |
There was a problem hiding this comment.
You always cast this to String (ChatModelActivityImpl.toSpringMessage() and ActivityChatModel.toAssistantMessage()). Should we just make this a String? If we tried to deserialize something into a non-String, we'd get a ClassCastException I think?
| return new EmbeddingModelTypes.DimensionsOutput(embeddingModel.dimensions()); | ||
| } | ||
|
|
||
| private List<Double> toDoubleList(float[] floats) { |
There was a problem hiding this comment.
Nitpick here but technically this creates a ton of Double objects (1536+ dimensions for OpenAI)... consider using float[] or double[] in EmbeddingModelTypes to avoid boxing and reduce serialization payload size?
Summary
Adds
temporal-spring-aimodule that integrates Spring AI with Temporal workflows, making AI model calls, tool execution, vector store operations, embeddings, and MCP tool calls durable Temporal primitives.Design
The core problem
Spring AI's
ChatModel.call()does two things in one method: it calls the LLM and executes any tools the LLM requests, looping until the model returns a final text response. In a Temporal workflow, these need to happen in different execution contexts:How ActivityChatModel solves this
ActivityChatModelimplements Spring AI'sChatModelinterface with the same recursivecall()→internalCall()pattern used byOpenAiChatModeland other Spring AI implementations. The difference is what happens inside:ChatModelTypes, sends it toChatModelActivity(a Temporal activity), gets back the responseToolCallingManager.executeToolCalls()runs the tools — these are ourActivityToolCallback,SideEffectToolCallback, etc. which execute as Temporal activities orWorkflow.sideEffect()internalCall()) until the model returns a final responseThe activity-side
ChatModelActivityImplsetsinternalToolExecutionEnabled(false)so the actual LLM provider (OpenAI, Anthropic, etc.) only returns tool call requests without executing them — execution happens back in the workflow.Tool classification
Tools passed to
TemporalChatClient.builder().defaultTools()are automatically classified:instanceof ActivityInvocationHandlerinstanceof LocalActivityInvocationHandler@DeterministicTool@SideEffectToolWorkflow.sideEffect()instanceof NexusServiceInvocationHandlerConditional auto-configuration
Optional integrations only load when their dependencies are on the classpath, matching Spring AI's own approach (where RAG, MCP, etc. are separate opt-in starters):
SpringAiTemporalAutoConfigurationChatModel+WorkerSpringAiPluginChatModelActivity,ExecuteToolLocalActivitySpringAiVectorStoreAutoConfigurationVectorStorebeanVectorStorePluginVectorStoreActivitySpringAiEmbeddingAutoConfigurationEmbeddingModelbeanEmbeddingModelPluginEmbeddingModelActivitySpringAiMcpAutoConfigurationMcpSyncClientclassMcpPluginMcpClientActivityRelated
Test plan
WorkflowReplayer— WorkflowDeterminismTest (2 tests)🤖 Generated with Claude Code