Skip to content

Commit 2a76e4e

Browse files
temporal-spring-ai: accept ActivityOptions and classify non-retryable AI errors
- ActivityChatModel.forDefault(ActivityOptions) and forModel(String, ActivityOptions) overloads added. New public defaultActivityOptions() returns the plugin's default bundle so callers can tweak one field without losing the other sensible defaults. - ActivityMcpClient.create(ActivityOptions) + defaultActivityOptions() added, mirroring the chat side. - Default RetryOptions for chat calls now mark org.springframework.ai.retry.NonTransientAiException and java.lang.IllegalArgumentException non-retryable. Default options for MCP calls mark IllegalArgumentException non-retryable. User-supplied ActivityOptions pass through verbatim — the plugin does not augment them. - new ActivityChatModel(...) and new ActivityMcpClient(activity) constructors are @deprecated with javadoc pointing at the factories — they still work at runtime but skip the UI Summary labels the plugin-owned stub path attaches, which is now called out explicitly. - README: new "Activity options and retry behavior" section documents the defaults, how to customize, and the Summary/factory connection. - Tests: two new suites — ActivityOptionsAndRetryTest covers the non-retryable classification (1 attempt for NonTransientAiException, 3 attempts for transient RuntimeException, custom task queue landing on the scheduled activity); ActivitySummaryTest gains a regression test asserting forDefault(customOptions) still emits UI Summaries. - build.gradle: spring-ai-retry added as a testImplementation so tests can reference NonTransientAiException directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b3bb749 commit 2a76e4e

6 files changed

Lines changed: 445 additions & 23 deletions

File tree

temporal-spring-ai/README.md

Lines changed: 17 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+
> **UI labels:** the Temporal UI labels chat and MCP rows with a short Summary (`chat: <model> · <prompt>`, `mcp: <client>.<tool>`) — but only when the activity stub is built via one of the factories above. The legacy `new ActivityChatModel(stub)` / `new ActivityMcpClient(activity)` constructors are deprecated and do not produce UI labels.
70+
5471
## Tool Types
5572

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

temporal-spring-ai/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ 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 tests can reference Spring AI's NonTransientAiException to
50+
// verify the plugin's default retry classification.
51+
testImplementation 'org.springframework.ai:spring-ai-retry'
4952

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

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

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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;
910

1011
/**
@@ -47,6 +48,18 @@ public class ActivityMcpClient {
4748
/** Default maximum retry attempts for MCP activity calls. */
4849
public static final int DEFAULT_MAX_ATTEMPTS = 3;
4950

51+
/**
52+
* Error types that the default retry policy treats as non-retryable. {@link
53+
* IllegalArgumentException} covers unknown-client-name lookups. Client-not-found is already
54+
* thrown as an {@code ApplicationFailure} with {@code nonRetryable=true} and wins on its own.
55+
*
56+
* <p>Applied only to the factories that build {@link ActivityOptions} internally. When callers
57+
* pass their own {@link ActivityOptions} via {@link #create(ActivityOptions)}, their {@link
58+
* RetryOptions} are used verbatim.
59+
*/
60+
public static final List<String> DEFAULT_NON_RETRYABLE_ERROR_TYPES =
61+
List.of("java.lang.IllegalArgumentException");
62+
5063
private final McpClientActivity activity;
5164
private final ActivityOptions baseOptions;
5265
private Map<String, McpSchema.ServerCapabilities> serverCapabilities;
@@ -55,8 +68,16 @@ public class ActivityMcpClient {
5568
/**
5669
* Creates a new ActivityMcpClient with the given activity stub.
5770
*
71+
* <p><strong>Note:</strong> this constructor does not label scheduled MCP activities with a
72+
* Summary in the Temporal UI, because the plugin doesn't know the {@link ActivityOptions} the
73+
* caller used to build {@code activity}. Prefer {@link #create()} or {@link
74+
* #create(ActivityOptions)} when you want UI labels.
75+
*
5876
* @param activity the activity stub for MCP operations
77+
* @deprecated Prefer {@link #create()} / {@link #create(ActivityOptions)} — those keep the UI
78+
* Summary labels the plugin attaches to each MCP tool call.
5979
*/
80+
@Deprecated
6081
public ActivityMcpClient(McpClientActivity activity) {
6182
this(activity, null);
6283
}
@@ -72,18 +93,20 @@ private ActivityMcpClient(McpClientActivity activity, ActivityOptions baseOption
7293
}
7394

7495
/**
75-
* Creates an ActivityMcpClient with default options.
96+
* Creates an ActivityMcpClient with the plugin's default {@link ActivityOptions} (30-second
97+
* start-to-close timeout, 3 attempts, {@link IllegalArgumentException} marked non-retryable).
7698
*
7799
* <p><strong>Must be called from workflow code.</strong>
78100
*
79101
* @return a new ActivityMcpClient
80102
*/
81103
public static ActivityMcpClient create() {
82-
return create(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
104+
return create(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
83105
}
84106

85107
/**
86-
* Creates an ActivityMcpClient with custom options.
108+
* Creates an ActivityMcpClient with a custom timeout and attempt count. Other defaults
109+
* (non-retryable error classification) are preserved.
87110
*
88111
* <p><strong>Must be called from workflow code.</strong>
89112
*
@@ -92,15 +115,50 @@ public static ActivityMcpClient create() {
92115
* @return a new ActivityMcpClient
93116
*/
94117
public static ActivityMcpClient create(Duration timeout, int maxAttempts) {
95-
ActivityOptions options =
96-
ActivityOptions.newBuilder()
97-
.setStartToCloseTimeout(timeout)
98-
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
99-
.build();
118+
return create(defaultActivityOptions(timeout, maxAttempts));
119+
}
120+
121+
/**
122+
* Creates an ActivityMcpClient using the supplied {@link ActivityOptions}. Pass this when you
123+
* need a specific task queue, heartbeat, priority, or custom {@link RetryOptions}. The provided
124+
* options are used verbatim — the plugin does not augment the caller's {@link RetryOptions}.
125+
*
126+
* <p><strong>Must be called from workflow code.</strong>
127+
*
128+
* @param options the activity options to use for each MCP call
129+
* @return a new ActivityMcpClient
130+
*/
131+
public static ActivityMcpClient create(ActivityOptions options) {
100132
McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options);
101133
return new ActivityMcpClient(activity, options);
102134
}
103135

136+
/**
137+
* Returns the plugin's default {@link ActivityOptions} for MCP calls. Useful as a starting point
138+
* when you want to tweak a field without losing the sensible defaults:
139+
*
140+
* <pre>{@code
141+
* ActivityMcpClient.create(
142+
* ActivityOptions.newBuilder(ActivityMcpClient.defaultActivityOptions())
143+
* .setTaskQueue("mcp-heavy")
144+
* .build());
145+
* }</pre>
146+
*/
147+
public static ActivityOptions defaultActivityOptions() {
148+
return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
149+
}
150+
151+
private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) {
152+
return ActivityOptions.newBuilder()
153+
.setStartToCloseTimeout(timeout)
154+
.setRetryOptions(
155+
RetryOptions.newBuilder()
156+
.setMaximumAttempts(maxAttempts)
157+
.setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0]))
158+
.build())
159+
.build();
160+
}
161+
104162
/**
105163
* Gets the server capabilities for all connected MCP clients.
106164
*

temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ public class ActivityChatModel implements ChatModel {
8383
/** Default maximum retry attempts for chat model activity calls. */
8484
public static final int DEFAULT_MAX_ATTEMPTS = 3;
8585

86+
/**
87+
* Error types that the default retry policy treats as non-retryable. These represent clearly
88+
* permanent failures — a bad API key, an invalid prompt, an unknown model name — where retrying
89+
* wastes time and money.
90+
*
91+
* <p>Applied only to the factories that build {@link ActivityOptions} internally. When callers
92+
* pass their own {@link ActivityOptions} (via {@link #forDefault(ActivityOptions)} or {@link
93+
* #forModel(String, ActivityOptions)}), their {@link RetryOptions} are used verbatim.
94+
*/
95+
public static final List<String> DEFAULT_NON_RETRYABLE_ERROR_TYPES =
96+
List.of(
97+
"org.springframework.ai.retry.NonTransientAiException",
98+
"java.lang.IllegalArgumentException");
99+
86100
private final ChatModelActivity chatModelActivity;
87101
private final String modelName;
88102
private final ActivityOptions baseOptions;
@@ -92,18 +106,33 @@ public class ActivityChatModel implements ChatModel {
92106
/**
93107
* Creates a new ActivityChatModel that uses the default chat model.
94108
*
109+
* <p><strong>Note:</strong> this constructor does not label the scheduled chat activity with a
110+
* Summary in the Temporal UI, because the plugin doesn't know the {@link ActivityOptions} the
111+
* caller used to build {@code chatModelActivity}. Prefer {@link #forDefault()} or {@link
112+
* #forDefault(ActivityOptions)} when you want the UI labels.
113+
*
95114
* @param chatModelActivity the activity stub for calling the chat model
115+
* @deprecated Prefer {@link #forDefault()} / {@link #forDefault(ActivityOptions)} — those keep
116+
* the UI Summary labels the plugin attaches to each chat call.
96117
*/
118+
@Deprecated
97119
public ActivityChatModel(ChatModelActivity chatModelActivity) {
98120
this(chatModelActivity, null, null);
99121
}
100122

101123
/**
102124
* Creates a new ActivityChatModel that uses a specific chat model.
103125
*
126+
* <p><strong>Note:</strong> this constructor does not label the scheduled chat activity with a
127+
* Summary in the Temporal UI. Prefer {@link #forModel(String)} or {@link #forModel(String,
128+
* ActivityOptions)} when you want the UI labels.
129+
*
104130
* @param chatModelActivity the activity stub for calling the chat model
105131
* @param modelName the name of the chat model to use, or null for default
132+
* @deprecated Prefer {@link #forModel(String)} / {@link #forModel(String, ActivityOptions)} —
133+
* those keep the UI Summary labels the plugin attaches to each chat call.
106134
*/
135+
@Deprecated
107136
public ActivityChatModel(ChatModelActivity chatModelActivity, String modelName) {
108137
this(chatModelActivity, modelName, null);
109138
}
@@ -123,24 +152,36 @@ private ActivityChatModel(
123152
}
124153

125154
/**
126-
* Creates an ActivityChatModel for the default chat model.
127-
*
128-
* <p>This factory method creates the activity stub internally with default timeout and retry
129-
* options.
155+
* Creates an ActivityChatModel for the default chat model with the plugin's default {@link
156+
* ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors
157+
* marked non-retryable).
130158
*
131159
* <p><strong>Must be called from workflow code.</strong>
132160
*
133161
* @return an ActivityChatModel for the default chat model
134162
*/
135163
public static ActivityChatModel forDefault() {
136-
return forModel(null, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
164+
return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
137165
}
138166

139167
/**
140-
* Creates an ActivityChatModel for a specific chat model by bean name.
168+
* Creates an ActivityChatModel for the default chat model using the supplied {@link
169+
* ActivityOptions}. Pass this when you need a specific task queue, heartbeat, priority, or a
170+
* custom {@link RetryOptions} — anything the {@code (timeout, maxAttempts)} convenience factory
171+
* can't express.
172+
*
173+
* <p><strong>Must be called from workflow code.</strong>
141174
*
142-
* <p>This factory method creates the activity stub internally with default timeout and retry
143-
* options.
175+
* @param options the activity options to use for each chat call
176+
* @return an ActivityChatModel for the default chat model
177+
*/
178+
public static ActivityChatModel forDefault(ActivityOptions options) {
179+
return forModel(null, options);
180+
}
181+
182+
/**
183+
* Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default
184+
* {@link ActivityOptions}.
144185
*
145186
* <p><strong>Must be called from workflow code.</strong>
146187
*
@@ -149,11 +190,12 @@ public static ActivityChatModel forDefault() {
149190
* @throws IllegalArgumentException if no model with that name exists (at activity runtime)
150191
*/
151192
public static ActivityChatModel forModel(String modelName) {
152-
return forModel(modelName, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
193+
return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
153194
}
154195

155196
/**
156-
* Creates an ActivityChatModel for a specific chat model with custom options.
197+
* Creates an ActivityChatModel for a specific chat model with a custom timeout and attempt count.
198+
* The remaining defaults (non-retryable error classification) are preserved.
157199
*
158200
* <p><strong>Must be called from workflow code.</strong>
159201
*
@@ -163,15 +205,53 @@ public static ActivityChatModel forModel(String modelName) {
163205
* @return an ActivityChatModel for the specified chat model
164206
*/
165207
public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) {
166-
ActivityOptions options =
167-
ActivityOptions.newBuilder()
168-
.setStartToCloseTimeout(timeout)
169-
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
170-
.build();
208+
return forModel(modelName, defaultActivityOptions(timeout, maxAttempts));
209+
}
210+
211+
/**
212+
* Creates an ActivityChatModel for a specific chat model using the supplied {@link
213+
* ActivityOptions}. The provided options are used verbatim — the plugin does not augment the
214+
* caller's {@link RetryOptions} or merge in its defaults. If you want the plugin-default
215+
* non-retryable error classification, copy {@link #DEFAULT_NON_RETRYABLE_ERROR_TYPES} into your
216+
* own {@link RetryOptions}.
217+
*
218+
* <p><strong>Must be called from workflow code.</strong>
219+
*
220+
* @param modelName the bean name of the chat model, or null for default
221+
* @param options the activity options to use for each chat call
222+
* @return an ActivityChatModel for the specified chat model
223+
*/
224+
public static ActivityChatModel forModel(String modelName, ActivityOptions options) {
171225
ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options);
172226
return new ActivityChatModel(activity, modelName, options);
173227
}
174228

229+
/**
230+
* Returns the plugin's default {@link ActivityOptions} for chat model calls. Useful as a starting
231+
* point when you want to customize one or two fields without losing the sensible defaults:
232+
*
233+
* <pre>{@code
234+
* ActivityChatModel.forDefault(
235+
* ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
236+
* .setTaskQueue("chat-heavy")
237+
* .build());
238+
* }</pre>
239+
*/
240+
public static ActivityOptions defaultActivityOptions() {
241+
return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
242+
}
243+
244+
private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) {
245+
return ActivityOptions.newBuilder()
246+
.setStartToCloseTimeout(timeout)
247+
.setRetryOptions(
248+
RetryOptions.newBuilder()
249+
.setMaximumAttempts(maxAttempts)
250+
.setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0]))
251+
.build())
252+
.build();
253+
}
254+
175255
/**
176256
* Returns the name of the chat model this instance uses.
177257
*

0 commit comments

Comments
 (0)