Skip to content

Commit 7b99807

Browse files
Merge branch 'master' into spring-ai/response-metadata
2 parents 75b3dce + ccaf4a6 commit 7b99807

10 files changed

Lines changed: 609 additions & 92 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+
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:

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