Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions temporal-spring-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ public String run(String goal) {
}
```

## Activity options and retry behavior

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

When you need finer control — a specific task queue, heartbeats, priority, or a custom `RetryOptions` — pass an `ActivityOptions` directly:

```java
ActivityChatModel chatModel = ActivityChatModel.forDefault(
ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
.setTaskQueue("chat-heavy")
.build());
```

`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout.

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.

## Tool Types

Tools passed to `defaultTools()` are handled based on their type:
Expand Down
3 changes: 3 additions & 0 deletions temporal-spring-ai/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ dependencies {
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.ai:spring-ai-rag'
// Needed only so tests can reference Spring AI's NonTransientAiException to
// verify the plugin's default retry classification.
testImplementation 'org.springframework.ai:spring-ai-retry'

testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@
* @WorkflowInit
* public MyWorkflowImpl() {
* // Create the activity-backed chat model
* ChatModelActivity chatModelActivity = Workflow.newActivityStub(
* ChatModelActivity.class, activityOptions);
* ActivityChatModel activityChatModel = new ActivityChatModel(chatModelActivity);
* ActivityChatModel activityChatModel = ActivityChatModel.forDefault();
*
* // Create tools
* WeatherActivity weatherTool = Workflow.newActivityStub(WeatherActivity.class, opts);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Workflow;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;

Expand Down Expand Up @@ -48,61 +49,82 @@ public class ActivityMcpClient {
/** Default maximum retry attempts for MCP activity calls. */
public static final int DEFAULT_MAX_ATTEMPTS = 3;

private final McpClientActivity activity;
@Nullable private final ActivityOptions baseOptions;
private Map<String, McpSchema.ServerCapabilities> serverCapabilities;
private Map<String, McpSchema.Implementation> clientInfo;

/**
* Creates a new ActivityMcpClient with the given activity stub.
* Error types that the default retry policy treats as non-retryable. {@link
* IllegalArgumentException} covers unknown-client-name lookups. Client-not-found is already
* thrown as an {@code ApplicationFailure} with {@code nonRetryable=true} and wins on its own.
*
* @param activity the activity stub for MCP operations
* <p>Applied only to the factories that build {@link ActivityOptions} internally. When callers
* pass their own {@link ActivityOptions} via {@link #create(ActivityOptions)}, their {@link
* RetryOptions} are used verbatim.
*/
public ActivityMcpClient(McpClientActivity activity) {
this(activity, null);
}
public static final List<String> DEFAULT_NON_RETRYABLE_ERROR_TYPES =
List.of("java.lang.IllegalArgumentException");

/**
* Creates a new ActivityMcpClient. When {@code baseOptions} is non-null, {@link #callTool(String,
* McpSchema.CallToolRequest, String)} rebuilds the activity stub with a per-call Summary on top
* of those options. When null, the caller supplied a pre-built stub whose options we don't know,
* so we call through it as-is and drop any requested summary.
*/
private ActivityMcpClient(McpClientActivity activity, @Nullable ActivityOptions baseOptions) {
private final McpClientActivity activity;
private final ActivityOptions baseOptions;
private Map<String, McpSchema.ServerCapabilities> serverCapabilities;
private Map<String, McpSchema.Implementation> clientInfo;

/** Use one of the {@link #create()} / {@link #create(ActivityOptions)} factories. */
private ActivityMcpClient(McpClientActivity activity, ActivityOptions baseOptions) {
this.activity = activity;
this.baseOptions = baseOptions;
}

/**
* Creates an ActivityMcpClient with default options.
* Creates an ActivityMcpClient with the plugin's default {@link ActivityOptions} (30-second
* start-to-close timeout, 3 attempts, {@link IllegalArgumentException} marked non-retryable).
*
* <p><strong>Must be called from workflow code.</strong>
*
* @return a new ActivityMcpClient
*/
public static ActivityMcpClient create() {
return create(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
return create(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
}

/**
* Creates an ActivityMcpClient with custom options.
* Creates an ActivityMcpClient using the supplied {@link ActivityOptions}. Pass this when you
* need a specific task queue, heartbeat, priority, or custom {@link RetryOptions}. The provided
* options are used verbatim — the plugin does not augment the caller's {@link RetryOptions}.
*
* <p><strong>Must be called from workflow code.</strong>
*
* @param timeout the activity start-to-close timeout
* @param maxAttempts the maximum number of retry attempts
* @param options the activity options to use for each MCP call
* @return a new ActivityMcpClient
*/
public static ActivityMcpClient create(Duration timeout, int maxAttempts) {
ActivityOptions options =
ActivityOptions.newBuilder()
.setStartToCloseTimeout(timeout)
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
.build();
public static ActivityMcpClient create(ActivityOptions options) {
McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options);
return new ActivityMcpClient(activity, options);
}

/**
* Returns the plugin's default {@link ActivityOptions} for MCP calls. Useful as a starting point
* when you want to tweak a field without losing the sensible defaults:
*
* <pre>{@code
* ActivityMcpClient.create(
* ActivityOptions.newBuilder(ActivityMcpClient.defaultActivityOptions())
* .setTaskQueue("mcp-heavy")
* .build());
* }</pre>
*/
public static ActivityOptions defaultActivityOptions() {
return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS);
}

private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) {
return ActivityOptions.newBuilder()
.setStartToCloseTimeout(timeout)
.setRetryOptions(
RetryOptions.newBuilder()
.setMaximumAttempts(maxAttempts)
.setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0]))
.build())
.build();
}

/**
* Gets the server capabilities for all connected MCP clients.
*
Expand Down Expand Up @@ -144,9 +166,7 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe

/**
* Calls a tool on a specific MCP client, attaching the given activity Summary to the scheduled
* activity so it renders meaningfully in the Temporal UI. Falls back to the base stub when no
* {@link ActivityOptions} are known (e.g. when this client was constructed from a user-supplied
* stub rather than one of the {@link #create} factories).
* activity so it renders meaningfully in the Temporal UI.
*
* @param clientName the name of the MCP client
* @param request the tool call request
Expand All @@ -155,17 +175,14 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe
*/
public McpSchema.CallToolResult callTool(
String clientName, McpSchema.CallToolRequest request, @Nullable String summary) {
// Overlay the summary onto a fresh stub only when both a summary is requested AND we have
// a recipe to rebuild the stub from (baseOptions). If either is missing, fall through to
// the cached activity — it already has baseOptions baked in if we knew them at construction.
if (summary != null && baseOptions != null) {
McpClientActivity stub =
Workflow.newActivityStub(
McpClientActivity.class,
ActivityOptions.newBuilder(baseOptions).setSummary(summary).build());
return stub.callTool(clientName, request);
if (summary == null) {
return activity.callTool(clientName, request);
}
return activity.callTool(clientName, request);
McpClientActivity stub =
Workflow.newActivityStub(
McpClientActivity.class,
ActivityOptions.newBuilder(baseOptions).setSummary(summary).build());
return stub.callTool(clientName, request);
}

/**
Expand Down
Loading
Loading