Skip to content

Commit 48d54c9

Browse files
Merge branch 'master' into spring-ai/provider-options-passthrough
2 parents 8d542e8 + c402f55 commit 48d54c9

19 files changed

Lines changed: 1875 additions & 111 deletions

temporal-spring-ai/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ public String run(String goal) {
5151
}
5252
```
5353

54+
## Activity options and retry behavior
55+
56+
`ActivityChatModel.forDefault()` / `forModel(name)` build the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` marked non-retryable so a bad API key or invalid prompt fails fast instead of churning through retries.
57+
58+
When you need finer control — a specific task queue, heartbeats, priority, or a custom `RetryOptions` — pass an `ActivityOptions` directly:
59+
60+
```java
61+
ActivityChatModel chatModel = ActivityChatModel.forDefault(
62+
ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
63+
.setTaskQueue("chat-heavy")
64+
.build());
65+
```
66+
67+
`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout.
68+
69+
The Temporal UI labels chat and MCP rows with a short Summary (`chat: <model>`, `mcp: <client>.<tool>`). `ActivityChatModel` and `ActivityMcpClient` are constructed only via these factories — there is no public constructor, so users can't accidentally end up in a code path that skips UI labels. Prompt text is deliberately not included in chat summaries to avoid leaking user input (which may contain PII, credentials, or other sensitive data) into workflow history and server logs.
70+
5471
## Tool Types
5572

5673
Tools passed to `defaultTools()` are handled based on their type:
@@ -97,6 +114,109 @@ public class MyTools {
97114

98115
Auto-detected and executed as Nexus operations, similar to activity stubs.
99116

117+
## Migrating from plain Spring AI
118+
119+
The plugin is designed so that bringing an existing Spring AI service onto Temporal is a localized change. Outside Temporal, you probably have something like:
120+
121+
```java
122+
@Service
123+
class AssistantService {
124+
private final ChatClient chatClient;
125+
126+
AssistantService(ChatModel chatModel) {
127+
this.chatClient = ChatClient.builder(chatModel)
128+
.defaultSystem("You are a helpful assistant.")
129+
.defaultTools(new WeatherTools(), new MyTools())
130+
.build();
131+
}
132+
133+
String respond(String goal) {
134+
return chatClient.prompt().user(goal).call().content();
135+
}
136+
}
137+
```
138+
139+
Inside a Temporal Workflow it becomes:
140+
141+
```java
142+
@WorkflowInterface
143+
interface AssistantWorkflow { @WorkflowMethod String respond(String goal); }
144+
145+
class AssistantWorkflowImpl implements AssistantWorkflow {
146+
private final ChatClient chatClient;
147+
148+
@WorkflowInit
149+
AssistantWorkflowImpl(String goal) {
150+
WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts);
151+
this.chatClient = TemporalChatClient.builder(ActivityChatModel.forDefault())
152+
.defaultSystem("You are a helpful assistant.")
153+
.defaultTools(weather, new MyTools())
154+
.build();
155+
}
156+
157+
@Override
158+
public String respond(String goal) {
159+
return chatClient.prompt().user(goal).call().content();
160+
}
161+
}
162+
```
163+
164+
Three substitutions:
165+
166+
| Outside Temporal | Inside a Temporal workflow |
167+
|---|---|
168+
| `ChatModel chatModel` (injected) | `ActivityChatModel.forDefault()` |
169+
| `ChatClient.builder(chatModel)` | `TemporalChatClient.builder(activityChatModel)` |
170+
| `new WeatherTools()` for a plain POJO tool | `Workflow.newActivityStub(WeatherActivity.class, ...)` for a durable tool |
171+
172+
Plain `@Tool` POJOs, `@SideEffectTool`-annotated classes, and Nexus service stubs all work the same way — see **Tool Types** above.
173+
174+
## Media in messages
175+
176+
If you attach media (images, audio, etc.) to a `UserMessage` or an `AssistantMessage`, prefer passing it by URI rather than raw bytes:
177+
178+
```java
179+
// Good — only the URL crosses the activity boundary.
180+
Media image = new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example.com/pic.png"));
181+
182+
// Works, but size-limited — see below.
183+
Media image = new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(bytes));
184+
```
185+
186+
Raw `byte[]` media gets serialized into every chat activity's input *and* result payload, which end up inside Temporal workflow history events. Server-side history events have a fixed 2 MiB size limit; to leave headroom for messages, tool definitions, and options, the plugin enforces a **1 MiB default cap** on inline media bytes and fails fast with an `IllegalArgumentException` pointing you at the URI alternative.
187+
188+
Override the cap by setting the system property `io.temporal.springai.maxMediaBytes` before your worker starts (pass a positive integer; `0` disables the check). For anything larger than a small thumbnail, the URI route is the right answer — have an activity write the bytes to blob storage, then pass only the URL into the conversation.
189+
190+
## Activity options and retry behavior
191+
192+
`ActivityChatModel.forDefault()` and `ActivityChatModel.forModel(name)` create the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` classified as non-retryable so a bad API key or invalid prompt fails fast.
193+
194+
Override with `ActivityChatModel.forModel(name, ActivityOptions)`:
195+
196+
```java
197+
ActivityOptions opts = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
198+
.setStartToCloseTimeout(Duration.ofMinutes(10))
199+
.setTaskQueue("reasoning-models")
200+
.build();
201+
ActivityChatModel chatModel = ActivityChatModel.forModel("reasoning", opts);
202+
```
203+
204+
For repeated per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. See that class's javadoc for the pattern.
205+
206+
`ActivityMcpClient.create()` / `create(ActivityOptions)` behave the same way with a 30-second default timeout.
207+
208+
## Known limitations
209+
210+
- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead.
211+
- **`defaultToolContext(Map<String, Object>)`** — not supported; tool context holds mutable state that can't safely cross the activity boundary. Pass required context as activity parameters or workflow state.
212+
- **Child workflow stubs as tools** — not supported. Wrap a plain `@Tool` method that starts the child workflow via `Workflow.newChildWorkflowStub(...)` and call through to it yourself.
213+
- **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media.
214+
- **Provider-specific `ChatOptions` via `ChatClient.defaultOptions(...)`** — works as long as your `ChatOptions` subclass overrides `copy()` to return its own type (every real provider class does this). A subclass inheriting the default `copy()` loses its identity before the plugin sees it — same behavior as outside Temporal.
215+
216+
## Observability
217+
218+
`TemporalChatClient.builder(chatModel, observationRegistry, customConvention)` accepts a Micrometer `ObservationRegistry` for Spring AI-side chat client metrics. Temporal-side metrics (activity durations, retries) are emitted by the SDK's `MetricsScope` — see the [Temporal Java SDK observability docs](https://docs.temporal.io/develop/java/observability) for how to wire an OpenTelemetry or Prometheus exporter onto your workers. The two layers compose: Spring AI observations cover what the caller does; Temporal metrics cover what the scheduled activity does.
219+
100220
## Optional Integrations
101221

102222
Auto-configured when their dependencies are on the classpath:

temporal-spring-ai/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ dependencies {
4646
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
4747
testImplementation 'org.springframework.boot:spring-boot-starter-test'
4848
testImplementation 'org.springframework.ai:spring-ai-rag'
49+
// Needed only so McpPluginTest can mock/reference McpSyncClient directly.
50+
testImplementation 'org.springframework.ai:spring-ai-mcp'
51+
// Needed only so tests can reference Spring AI's NonTransientAiException to
52+
// verify the plugin's default retry classification.
53+
testImplementation 'org.springframework.ai:spring-ai-retry'
4954

5055
testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}"
5156
testRuntimeOnly "org.junit.platform:junit-platform-launcher"

temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ private abstract static class ToolCallingChatOptionsMixin {}
6262
* @param chatModel the chat model to use
6363
*/
6464
public ChatModelActivityImpl(ChatModel chatModel) {
65-
this.chatModels = Map.of("default", chatModel);
66-
this.defaultModelName = "default";
65+
this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel);
66+
this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME;
6767
}
6868

6969
/**
@@ -327,6 +327,7 @@ private ChatModelTypes.MediaContent fromMedia(Media media) {
327327
if (media.getData() instanceof String uri) {
328328
return new ChatModelTypes.MediaContent(mimeType, uri);
329329
} else if (media.getData() instanceof byte[] data) {
330+
ChatModelTypes.checkMediaSize(data);
330331
return new ChatModelTypes.MediaContent(mimeType, data);
331332
}
332333
throw new IllegalArgumentException(

temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import io.micrometer.observation.ObservationRegistry;
44
import io.temporal.springai.util.TemporalToolUtil;
55
import java.util.Map;
6+
import javax.annotation.Nullable;
67
import org.springframework.ai.chat.client.ChatClient;
78
import org.springframework.ai.chat.client.DefaultChatClient;
89
import org.springframework.ai.chat.client.DefaultChatClientBuilder;
910
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
1011
import org.springframework.ai.chat.model.ChatModel;
11-
import org.springframework.lang.Nullable;
1212
import org.springframework.util.Assert;
1313

1414
/**
@@ -29,9 +29,7 @@
2929
* @WorkflowInit
3030
* public MyWorkflowImpl() {
3131
* // Create the activity-backed chat model
32-
* ChatModelActivity chatModelActivity = Workflow.newActivityStub(
33-
* ChatModelActivity.class, activityOptions);
34-
* ActivityChatModel activityChatModel = new ActivityChatModel(chatModelActivity);
32+
* ActivityChatModel activityChatModel = ActivityChatModel.forDefault();
3533
*
3634
* // Create tools
3735
* WeatherActivity weatherTool = Workflow.newActivityStub(WeatherActivity.class, opts);

temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import io.temporal.common.RetryOptions;
66
import io.temporal.workflow.Workflow;
77
import java.time.Duration;
8+
import java.util.List;
89
import java.util.Map;
10+
import javax.annotation.Nullable;
911

1012
/**
1113
* A workflow-safe wrapper for MCP (Model Context Protocol) client operations.
@@ -47,48 +49,80 @@ public class ActivityMcpClient {
4749
/** Default maximum retry attempts for MCP activity calls. */
4850
public static final int DEFAULT_MAX_ATTEMPTS = 3;
4951

52+
/**
53+
* Error types that the default retry policy treats as non-retryable. {@link
54+
* IllegalArgumentException} covers unknown-client-name lookups. Client-not-found is already
55+
* thrown as an {@code ApplicationFailure} with {@code nonRetryable=true} and wins on its own.
56+
*
57+
* <p>Applied only to the factories that build {@link ActivityOptions} internally. When callers
58+
* pass their own {@link ActivityOptions} via {@link #create(ActivityOptions)}, their {@link
59+
* RetryOptions} are used verbatim.
60+
*/
61+
public static final List<String> DEFAULT_NON_RETRYABLE_ERROR_TYPES =
62+
List.of("java.lang.IllegalArgumentException");
63+
5064
private final McpClientActivity activity;
65+
private final ActivityOptions baseOptions;
5166
private Map<String, McpSchema.ServerCapabilities> serverCapabilities;
5267
private Map<String, McpSchema.Implementation> clientInfo;
5368

54-
/**
55-
* Creates a new ActivityMcpClient with the given activity stub.
56-
*
57-
* @param activity the activity stub for MCP operations
58-
*/
59-
public ActivityMcpClient(McpClientActivity activity) {
69+
/** Use one of the {@link #create()} / {@link #create(ActivityOptions)} factories. */
70+
private ActivityMcpClient(McpClientActivity activity, ActivityOptions baseOptions) {
6071
this.activity = activity;
72+
this.baseOptions = baseOptions;
6173
}
6274

6375
/**
64-
* Creates an ActivityMcpClient with default options.
76+
* Creates an ActivityMcpClient with the plugin's default {@link ActivityOptions} (30-second
77+
* start-to-close timeout, 3 attempts, {@link IllegalArgumentException} marked non-retryable).
6578
*
6679
* <p><strong>Must be called from workflow code.</strong>
6780
*
6881
* @return a new ActivityMcpClient
6982
*/
7083
public static ActivityMcpClient create() {
71-
return create(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
84+
return create(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
7285
}
7386

7487
/**
75-
* Creates an ActivityMcpClient with custom options.
88+
* Creates an ActivityMcpClient using the supplied {@link ActivityOptions}. Pass this when you
89+
* need a specific task queue, heartbeat, priority, or custom {@link RetryOptions}. The provided
90+
* options are used verbatim — the plugin does not augment the caller's {@link RetryOptions}.
7691
*
7792
* <p><strong>Must be called from workflow code.</strong>
7893
*
79-
* @param timeout the activity start-to-close timeout
80-
* @param maxAttempts the maximum number of retry attempts
94+
* @param options the activity options to use for each MCP call
8195
* @return a new ActivityMcpClient
8296
*/
83-
public static ActivityMcpClient create(Duration timeout, int maxAttempts) {
84-
McpClientActivity activity =
85-
Workflow.newActivityStub(
86-
McpClientActivity.class,
87-
ActivityOptions.newBuilder()
88-
.setStartToCloseTimeout(timeout)
89-
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
90-
.build());
91-
return new ActivityMcpClient(activity);
97+
public static ActivityMcpClient create(ActivityOptions options) {
98+
McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options);
99+
return new ActivityMcpClient(activity, options);
100+
}
101+
102+
/**
103+
* Returns the plugin's default {@link ActivityOptions} for MCP calls. Useful as a starting point
104+
* when you want to tweak a field without losing the sensible defaults:
105+
*
106+
* <pre>{@code
107+
* ActivityMcpClient.create(
108+
* ActivityOptions.newBuilder(ActivityMcpClient.defaultActivityOptions())
109+
* .setTaskQueue("mcp-heavy")
110+
* .build());
111+
* }</pre>
112+
*/
113+
public static ActivityOptions defaultActivityOptions() {
114+
return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
115+
}
116+
117+
private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) {
118+
return ActivityOptions.newBuilder()
119+
.setStartToCloseTimeout(timeout)
120+
.setRetryOptions(
121+
RetryOptions.newBuilder()
122+
.setMaximumAttempts(maxAttempts)
123+
.setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0]))
124+
.build())
125+
.build();
92126
}
93127

94128
/**
@@ -127,7 +161,28 @@ public Map<String, McpSchema.Implementation> getClientInfo() {
127161
* @return the tool call result
128162
*/
129163
public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRequest request) {
130-
return activity.callTool(clientName, request);
164+
return callTool(clientName, request, null);
165+
}
166+
167+
/**
168+
* Calls a tool on a specific MCP client, attaching the given activity Summary to the scheduled
169+
* activity so it renders meaningfully in the Temporal UI.
170+
*
171+
* @param clientName the name of the MCP client
172+
* @param request the tool call request
173+
* @param summary the activity Summary, or null to omit
174+
* @return the tool call result
175+
*/
176+
public McpSchema.CallToolResult callTool(
177+
String clientName, McpSchema.CallToolRequest request, @Nullable String summary) {
178+
if (summary == null) {
179+
return activity.callTool(clientName, request);
180+
}
181+
McpClientActivity stub =
182+
Workflow.newActivityStub(
183+
McpClientActivity.class,
184+
ActivityOptions.newBuilder(baseOptions).setSummary(summary).build());
185+
return stub.callTool(clientName, request);
131186
}
132187

133188
/**

temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ public String call(String toolInput) {
106106

107107
// Use the original tool name (not prefixed) when calling the MCP server
108108
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(tool.name(), arguments);
109-
McpSchema.CallToolResult result = client.callTool(clientName, request);
109+
String summary = "mcp: " + clientName + "." + tool.name();
110+
McpSchema.CallToolResult result = client.callTool(clientName, request, summary);
110111

111112
// Return the result as-is (including errors) so the AI can handle them.
112113
// For example, an "access denied" error lets the AI suggest a valid path.

0 commit comments

Comments
 (0)