Skip to content

Add temporal-spring-ai module#2829

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

Add temporal-spring-ai module#2829
donald-pinckney wants to merge 15 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 module 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:

  • LLM calls must run as activities (network I/O, retryable, durable)
  • Tool execution must run in the workflow (so tools can themselves be activities, sideEffects, or other Temporal primitives)

How ActivityChatModel solves this

ActivityChatModel implements Spring AI's ChatModel interface with the same recursive call()internalCall() pattern used by OpenAiChatModel and other Spring AI implementations. The difference is what happens inside:

  1. Model call: converts the prompt to serializable ChatModelTypes, sends it to ChatModelActivity (a Temporal activity), gets back the response
  2. Tool check: if the model requested tool calls, ToolCallingManager.executeToolCalls() runs the tools — these are our ActivityToolCallback, SideEffectToolCallback, etc. which execute as Temporal activities or Workflow.sideEffect()
  3. Loop: sends tool results back to the model (recursive internalCall()) until the model returns a final response

The activity-side ChatModelActivityImpl sets internalToolExecutionEnabled(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:

Type Detection Execution Use case
Activity stub instanceof ActivityInvocationHandler Temporal activity Network I/O, external APIs
Local activity stub instanceof LocalActivityInvocationHandler Temporal local activity Fast local operations
@DeterministicTool Annotation Direct call in workflow Pure functions (string ops, math)
@SideEffectTool Annotation Workflow.sideEffect() Timestamps, UUIDs, random values
Nexus service stub instanceof NexusServiceInvocationHandler Nexus operation Cross-namespace calls

Conditional 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):

Auto-config class Condition Plugin Registers
SpringAiTemporalAutoConfiguration ChatModel + Worker SpringAiPlugin ChatModelActivity, ExecuteToolLocalActivity
SpringAiVectorStoreAutoConfiguration VectorStore bean VectorStorePlugin VectorStoreActivity
SpringAiEmbeddingAutoConfiguration EmbeddingModel bean EmbeddingModelPlugin EmbeddingModelActivity
SpringAiMcpAutoConfiguration McpSyncClient class McpPlugin McpClientActivity

Related

Test plan

  • Unit tests: type conversion — ChatModelActivityImplTest (10 tests)
  • Unit tests: tool detection — TemporalToolUtilTest (22 tests)
  • Unit tests: plugin registration — SpringAiPluginTest (9 tests)
  • Replay tests: workflow determinism via WorkflowReplayer — WorkflowDeterminismTest (2 tests)
  • Verify all 5 samples boot against Temporal dev server

🤖 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

.build();
} else {
// Send tool results back to the model (recursive call)
return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()));
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 — 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.

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.

Test coverage added (ChatModelActivityImplTest, 10 tests). Ready to add loop limit — next up.

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 — converted recursive call to iterative loop with MAX_TOOL_CALL_ITERATIONS = 10 limit.

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.

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.

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.

private final VectorStore vectorStore;

public VectorStorePlugin(VectorStore vectorStore) {
super("io.temporal.spring-ai-vectorstore");
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.

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 {
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?

* <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
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.

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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 + ChatModelActivity to 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.

Comment on lines +292 to +300
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());
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +136
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.");
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +37
@Bean
public SpringAiPlugin springAiPlugin(
@Autowired Map<String, ChatModel> chatModels,
@Autowired(required = false) @Nullable ChatModel primaryChatModel) {
return new SpringAiPlugin(chatModels, primaryChatModel);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +90
@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);

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@DABH DABH left a comment

Choose a reason for hiding this comment

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

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')
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

* @see io.temporal.springai.tool.SideEffectTool
* @see LocalActivityToolCallbackWrapper
*/
public class SandboxingAdvisor implements CallAdvisor {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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?

Comment on lines +102 to +105
if (serverCapabilities == null) {
serverCapabilities = activity.getServerCapabilities();
}
return serverCapabilities;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +100 to +101
// Note: ToolContext cannot be passed through the activity, so we ignore it here.
// If context is needed, consider using activity parameters or workflow state.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should we warn users about this?

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Message(
@JsonProperty("content") Object rawContent,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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?

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.

4 participants