From 5c378df4389a2dcd387c56092df1a9cd2417cfe5 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 15:52:42 -0400 Subject: [PATCH 01/14] =?UTF-8?q?temporal-spring-ai:=20plan=20=E2=80=94=20?= =?UTF-8?q?activity=20summaries=20for=20debuggability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 temporal-spring-ai/PLAN.md diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md new file mode 100644 index 000000000..c9fa74361 --- /dev/null +++ b/temporal-spring-ai/PLAN.md @@ -0,0 +1,69 @@ +# Plan: Activity summaries for debuggability + +## Scope + +Temporal's AI integration guide requires activity calls to set a Summary so +the Temporal UI renders something more useful than a bare +`ChatModelActivity.callChatModel`. Today none of the activities the plugin +issues on behalf of a workflow set one. This branch adds summaries on every +activity stub the plugin produces. + +## Files to change + +- `src/main/java/io/temporal/springai/model/ActivityChatModel.java` + - Before calling the activity, build a summary like + `"chat: " + modelName + " · " + truncate(lastUserMessage, 60)` and attach + it via per-call options. Since `ChatModelActivity` is a typed stub, either + rebuild the stub with `ActivityOptions.newBuilder(existing).setSummary(...)` + per call, or invoke via `ActivityStub.fromTyped(stub)` with per-call + `ActivityOptions` so the summary reflects the current prompt. + - Fall back to `"chat: " + modelName` if the prompt has no user text. + +- `src/main/java/io/temporal/springai/tool/ActivityToolCallback.java` + - Today `call(String toolInput)` delegates directly to the underlying + `MethodToolCallback`, which invokes the activity. Attach a summary of the + form `"tool: " + toolDefinition.name()` before delegation. Simplest path: + bypass `MethodToolCallback` and invoke via `ActivityStub.fromTyped(...)` + with per-call options including summary. + +- `src/main/java/io/temporal/springai/mcp/McpToolCallback.java` + - In `call(String toolInput)`, before calling `client.callTool(...)`, + attach a summary `"mcp: " + clientName + "." + tool.name()`. Plumb this + through `ActivityMcpClient` so it either exposes + `callTool(clientName, request, summary)` or accepts per-call options. + +- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` + - Add the summary overload; keep the current one for backward compat. + +## Test plan + +- Add `ActivitySummaryTest` (or extend `WorkflowDeterminismTest`) that runs a + workflow, fetches history via `client.fetchHistory(...)`, and asserts + `ActivityTaskScheduledEventAttributes.userMetadata.summary` for chat and + tool activities contains the expected strings. +- Replay via `WorkflowReplayer` to confirm no determinism regression. + +## PR + +**Title:** `temporal-spring-ai: attach activity summaries for chat, tools, and MCP calls` + +**Body:** + +``` +## What was changed +- Chat model activity calls now carry a Summary of the form + `chat: · `. +- Activity-backed tool calls carry a Summary of the form + `tool: `. +- MCP tool calls carry a Summary of the form + `mcp: .`. +- `ActivityMcpClient` gains a `callTool(clientName, request, summary)` + overload; prior signature is preserved. + +## Why? +The Temporal UI renders activity summaries prominently in workflow timelines. +Without them, every chat/tool/MCP call in a Spring AI workflow shows up as an +opaque `ChatModelActivity.callChatModel` row, making it painful to debug +multi-step agents. This matches the debuggability guidance in Temporal's AI +partner integration guide and the behavior of the Python/TypeScript plugins. +``` From 39ef1bfc5a8118f1b52b50db182e9894437ae45d Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 16:22:28 -0400 Subject: [PATCH 02/14] =?UTF-8?q?temporal-spring-ai:=20amend=20plan=20?= =?UTF-8?q?=E2=80=94=20limit=20summaries=20to=20chat=20+=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrows the activity-summaries scope to cases where the plugin itself owns activity-stub creation. Activity-backed tool calls, Nexus tool calls, and @SideEffectTool calls are now explicitly out of scope; the first two would require per-call option overrides on user-owned stubs (no clean API), and the third writes MarkerRecorded events which have no Summary field. Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 124 ++++++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 43 deletions(-) diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md index c9fa74361..f19382067 100644 --- a/temporal-spring-ai/PLAN.md +++ b/temporal-spring-ai/PLAN.md @@ -2,68 +2,106 @@ ## Scope -Temporal's AI integration guide requires activity calls to set a Summary so -the Temporal UI renders something more useful than a bare -`ChatModelActivity.callChatModel`. Today none of the activities the plugin -issues on behalf of a workflow set one. This branch adds summaries on every -activity stub the plugin produces. +Temporal's AI integration guide recommends setting a Summary on scheduled +activities so the Temporal UI can render something more useful than a bare +`ChatModelActivity.callChatModel`. The Java SDK exposes this via +`ActivityOptions.Builder.setSummary(String)`; the summary is baked into the +stub at creation time. + +This branch adds summaries **only on the activity calls the plugin itself +creates stubs for** — chat model and MCP. Cases where the *user* creates the +stub and hands it to the plugin are deliberately out of scope. + +### Why chat and MCP only + +- **Chat model**: `ActivityChatModel.forModel(...)` builds the stub, so the + plugin knows (and owns) the `ActivityOptions`. Adding a summary is a + one-line overlay before calling the activity. +- **MCP**: `ActivityMcpClient.create(...)` builds the stub, same story. + +### Explicitly out of scope + +- **Activity-backed tools** (`ActivityToolCallback`): the user creates the + activity stub in their workflow (`Workflow.newActivityStub(...)`) and hands + it to `.defaultTools(...)`. The stub's `ActivityOptions` are sealed inside + the proxy, and there is no public API to override options per call. + Reaching in reflectively is brittle and was rejected during review. + Scheduled activities for tool calls keep their default + (activity-type-name) row in the UI. +- **Nexus tools** (`NexusToolCallback`): same shape as activity tools — user + owns the stub. Skip for the same reason. +- **`@SideEffectTool`** (`SideEffectToolCallback`): runs via + `Workflow.sideEffect(...)`, which records a `MarkerRecorded` event, not an + `ActivityTaskScheduled` event. `MarkerRecorded` has no user-facing Summary + field, so there is nothing to attach. Skip. ## Files to change - `src/main/java/io/temporal/springai/model/ActivityChatModel.java` - - Before calling the activity, build a summary like - `"chat: " + modelName + " · " + truncate(lastUserMessage, 60)` and attach - it via per-call options. Since `ChatModelActivity` is a typed stub, either - rebuild the stub with `ActivityOptions.newBuilder(existing).setSummary(...)` - per call, or invoke via `ActivityStub.fromTyped(stub)` with per-call - `ActivityOptions` so the summary reflects the current prompt. - - Fall back to `"chat: " + modelName` if the prompt has no user text. - -- `src/main/java/io/temporal/springai/tool/ActivityToolCallback.java` - - Today `call(String toolInput)` delegates directly to the underlying - `MethodToolCallback`, which invokes the activity. Attach a summary of the - form `"tool: " + toolDefinition.name()` before delegation. Simplest path: - bypass `MethodToolCallback` and invoke via `ActivityStub.fromTyped(...)` - with per-call options including summary. + - Keep the existing stub but also retain the `ActivityOptions` used to + build it (new private field). When `ActivityOptions` are known, each + call rebuilds the stub via + `ActivityOptions.newBuilder(base).setSummary(s).build()`. When the bare + public constructor is used (user-supplied stub, options unknown), fall + back to the cached stub — no summary, no reflection. + - Summary format: `"chat: " + modelName + " · " + truncate(lastUserText, 60)` + with newlines replaced by spaces; `"chat: " + modelName` when there is no + user text. + +- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` + - Same pattern: store the `ActivityOptions` used by `create(...)`. Add a + `callTool(clientName, request, summary)` overload; the existing + `callTool(clientName, request)` delegates to it with a null summary. - `src/main/java/io/temporal/springai/mcp/McpToolCallback.java` - - In `call(String toolInput)`, before calling `client.callTool(...)`, - attach a summary `"mcp: " + clientName + "." + tool.name()`. Plumb this - through `ActivityMcpClient` so it either exposes - `callTool(clientName, request, summary)` or accepts per-call options. + - `call(toolInput)` builds `"mcp: " + clientName + "." + tool.name()` and + passes it to the new overload. -- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` - - Add the summary overload; keep the current one for backward compat. +No changes to `TemporalStubUtil`, `ActivityToolCallback`, `ActivityToolUtil`, +`NexusToolCallback`, or `SideEffectToolCallback`. ## Test plan -- Add `ActivitySummaryTest` (or extend `WorkflowDeterminismTest`) that runs a - workflow, fetches history via `client.fetchHistory(...)`, and asserts - `ActivityTaskScheduledEventAttributes.userMetadata.summary` for chat and - tool activities contains the expected strings. -- Replay via `WorkflowReplayer` to confirm no determinism regression. +- Add `ActivitySummaryTest` under `src/test/...`: + - Run a workflow that uses `ActivityChatModel.forDefault()` and triggers a + single chat call. Fetch history via `client.fetchHistory(...)` and + assert the `ActivityTaskScheduled` event's `userMetadata.summary` + decodes to a string starting with `"chat: default"`. + - Same pattern for MCP: use a fake `McpSyncClient` that returns a known + tool; the workflow drives a tool call; assert the scheduled + `McpClientActivity.callTool` activity has a summary matching + `"mcp: ."`. +- Replay the captured history with `WorkflowReplayer.replayWorkflowExecution` + in each test to confirm no determinism regression. ## PR -**Title:** `temporal-spring-ai: attach activity summaries for chat, tools, and MCP calls` +**Title:** `temporal-spring-ai: attach activity summaries for chat and MCP calls` **Body:** ``` ## What was changed -- Chat model activity calls now carry a Summary of the form - `chat: · `. -- Activity-backed tool calls carry a Summary of the form - `tool: `. -- MCP tool calls carry a Summary of the form - `mcp: .`. +- `ActivityChatModel` attaches an activity Summary of the form + `chat: · ` to each chat-model activity call. - `ActivityMcpClient` gains a `callTool(clientName, request, summary)` - overload; prior signature is preserved. + overload; `McpToolCallback` uses it to attach `mcp: .`. +- Summaries are applied only when the plugin itself created the activity + stub (via the existing `forModel`/`create` factories). When the user + supplies their own pre-built stub, behavior is unchanged. + +Activity-backed tool calls, Nexus tool calls, and `@SideEffectTool` calls +are intentionally out of scope — either the stub is user-owned (activity +and Nexus tools) or there is no Summary slot on the resulting history event +(`@SideEffectTool` writes a `MarkerRecorded`, not an +`ActivityTaskScheduled`). ## Why? -The Temporal UI renders activity summaries prominently in workflow timelines. -Without them, every chat/tool/MCP call in a Spring AI workflow shows up as an -opaque `ChatModelActivity.callChatModel` row, making it painful to debug -multi-step agents. This matches the debuggability guidance in Temporal's AI -partner integration guide and the behavior of the Python/TypeScript plugins. +Without Summaries, every chat and MCP step in a Spring AI workflow shows up +as an opaque `callChatModel` or `callTool` row in the Temporal UI, making +multi-step agents hard to follow. Chat and MCP are the cases where the +plugin owns stub creation end-to-end, so adding the Summary is a cheap, +low-risk overlay; broader coverage would require reaching into user-owned +stubs and was deliberately left out to keep this change small and +review-friendly. ``` From 62c3e0eb066eb00ac5c74c7eb68ae3d23051d1d3 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 16:23:26 -0400 Subject: [PATCH 03/14] temporal-spring-ai: add ActivitySummaryTest (chat path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs a workflow that drives a single chat call through ActivityChatModel, fetches the resulting history, and asserts the ActivityTaskScheduled event for callChatModel carries a userMetadata Summary that starts with "chat: default" and includes the user prompt. Intentionally fails against unmodified chat code — the implementation follows in a subsequent commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/ActivitySummaryTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java new file mode 100644 index 000000000..36c9cd26e --- /dev/null +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java @@ -0,0 +1,132 @@ +package io.temporal.springai; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.springai.activity.ChatModelActivityImpl; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * Verifies that activity calls scheduled by the Spring AI plugin carry a human-readable Summary in + * their {@code userMetadata} so the Temporal UI can label them meaningfully. + */ +class ActivitySummaryTest { + + private static final String TASK_QUEUE = "test-spring-ai-summary"; + + private TestWorkflowEnvironment testEnv; + private WorkflowClient client; + + @BeforeEach + void setUp() { + testEnv = TestWorkflowEnvironment.newInstance(); + client = testEnv.getWorkflowClient(); + } + + @AfterEach + void tearDown() { + testEnv.close(); + } + + @Test + void chatActivity_carriesSummaryWithModelAndUserPrompt() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); + worker.registerActivitiesImplementations( + new ChatModelActivityImpl(new StubChatModel("Hello!"))); + testEnv.start(); + + SummaryTestWorkflow workflow = + client.newWorkflowStub( + SummaryTestWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + workflow.chat("What is the capital of France?"); + + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + List events = + client.fetchHistory(workflowId).getHistory().getEventsList(); + + String summary = findChatActivitySummary(events); + assertNotNull(summary, "ActivityTaskScheduled event for callChatModel should have a Summary"); + assertTrue( + summary.startsWith("chat: default"), + "Summary should start with 'chat: default' but was: " + summary); + assertTrue( + summary.contains("What is the capital of France?"), + "Summary should contain the user prompt but was: " + summary); + } + + private static String findChatActivitySummary(List events) { + for (HistoryEvent event : events) { + if (!event.hasActivityTaskScheduledEventAttributes()) { + continue; + } + String activityType = + event.getActivityTaskScheduledEventAttributes().getActivityType().getName(); + if (!"callChatModel".equals(activityType)) { + continue; + } + if (!event.hasUserMetadata() || !event.getUserMetadata().hasSummary()) { + return null; + } + return DefaultDataConverter.STANDARD_INSTANCE.fromPayload( + event.getUserMetadata().getSummary(), String.class, String.class); + } + return null; + } + + @WorkflowInterface + public interface SummaryTestWorkflow { + @WorkflowMethod + String chat(String message); + } + + public static class ChatWorkflowImpl implements SummaryTestWorkflow { + @Override + public String chat(String message) { + ActivityChatModel chatModel = ActivityChatModel.forDefault(); + ChatClient chatClient = TemporalChatClient.builder(chatModel).build(); + return chatClient.prompt().user(message).call().content(); + } + } + + private static class StubChatModel implements ChatModel { + private final String response; + + StubChatModel(String response) { + this.response = response; + } + + @Override + public ChatResponse call(Prompt prompt) { + return ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage(response)))) + .build(); + } + + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + throw new UnsupportedOperationException(); + } + } +} From c8d97179e65deb5b496b0062aa2942354fc396c6 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 16:30:53 -0400 Subject: [PATCH 04/14] temporal-spring-ai: attach activity summaries for chat and MCP calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityChatModel.forModel() now stores the ActivityOptions it built and, on each chat call, rebuilds the stub with a per-call Summary of the form "chat: · ". When a caller passes a pre-built stub directly via the public constructors, behavior is unchanged (no options known → no summary overlay). ActivityMcpClient.create() does the same and adds a callTool(clientName, request, summary) overload. McpToolCallback passes "mcp: .". Also fixes the activity-type-name casing in ActivitySummaryTest — Temporal capitalizes the first character of method-name-derived activity types, so the event carries "CallChatModel", not "callChatModel". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/mcp/ActivityMcpClient.java | 51 ++++++++++++--- .../springai/mcp/McpToolCallback.java | 3 +- .../springai/model/ActivityChatModel.java | 62 ++++++++++++++++--- .../springai/ActivitySummaryTest.java | 5 +- 4 files changed, 98 insertions(+), 23 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 360412a83..6e9895a0a 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -48,6 +48,7 @@ public class ActivityMcpClient { public static final int DEFAULT_MAX_ATTEMPTS = 3; private final McpClientActivity activity; + private final ActivityOptions baseOptions; private Map serverCapabilities; private Map clientInfo; @@ -57,7 +58,17 @@ public class ActivityMcpClient { * @param activity the activity stub for MCP operations */ public ActivityMcpClient(McpClientActivity activity) { + this(activity, null); + } + + /** + * 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. + */ + private ActivityMcpClient(McpClientActivity activity, ActivityOptions baseOptions) { this.activity = activity; + this.baseOptions = baseOptions; } /** @@ -81,14 +92,13 @@ public static ActivityMcpClient create() { * @return a new ActivityMcpClient */ public static ActivityMcpClient create(Duration timeout, int maxAttempts) { - McpClientActivity activity = - Workflow.newActivityStub( - McpClientActivity.class, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(timeout) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) - .build()); - return new ActivityMcpClient(activity); + ActivityOptions options = + ActivityOptions.newBuilder() + .setStartToCloseTimeout(timeout) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) + .build(); + McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options); + return new ActivityMcpClient(activity, options); } /** @@ -127,7 +137,30 @@ public Map getClientInfo() { * @return the tool call result */ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRequest request) { - return activity.callTool(clientName, request); + return callTool(clientName, request, null); + } + + /** + * 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). + * + * @param clientName the name of the MCP client + * @param request the tool call request + * @param summary the activity Summary, or null to omit + * @return the tool call result + */ + public McpSchema.CallToolResult callTool( + String clientName, McpSchema.CallToolRequest request, String summary) { + if (summary == null || baseOptions == null) { + return activity.callTool(clientName, request); + } + McpClientActivity stub = + Workflow.newActivityStub( + McpClientActivity.class, + ActivityOptions.newBuilder(baseOptions).setSummary(summary).build()); + return stub.callTool(clientName, request); } /** diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java index 9cf821aae..48736af6a 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java @@ -106,7 +106,8 @@ public String call(String toolInput) { // Use the original tool name (not prefixed) when calling the MCP server McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(tool.name(), arguments); - McpSchema.CallToolResult result = client.callTool(clientName, request); + String summary = "mcp: " + clientName + "." + tool.name(); + McpSchema.CallToolResult result = client.callTool(clientName, request, summary); // Return the result as-is (including errors) so the AI can handle them. // For example, an "access denied" error lets the AI suggest a valid path. diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index c446db1d8..fe4ee5857 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -85,6 +85,7 @@ public class ActivityChatModel implements ChatModel { private final ChatModelActivity chatModelActivity; private final String modelName; + private final ActivityOptions baseOptions; private final ToolCallingManager toolCallingManager; private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; @@ -94,7 +95,7 @@ public class ActivityChatModel implements ChatModel { * @param chatModelActivity the activity stub for calling the chat model */ public ActivityChatModel(ChatModelActivity chatModelActivity) { - this(chatModelActivity, null); + this(chatModelActivity, null, null); } /** @@ -104,8 +105,19 @@ public ActivityChatModel(ChatModelActivity chatModelActivity) { * @param modelName the name of the chat model to use, or null for default */ public ActivityChatModel(ChatModelActivity chatModelActivity, String modelName) { + this(chatModelActivity, modelName, null); + } + + /** + * Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code + * baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top + * of those options so the Temporal UI can label the chat activity meaningfully. + */ + private ActivityChatModel( + ChatModelActivity chatModelActivity, String modelName, ActivityOptions baseOptions) { this.chatModelActivity = chatModelActivity; this.modelName = modelName; + this.baseOptions = baseOptions; this.toolCallingManager = ToolCallingManager.builder().build(); this.toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate(); } @@ -151,14 +163,13 @@ public static ActivityChatModel forModel(String modelName) { * @return an ActivityChatModel for the specified chat model */ public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) { - ChatModelActivity activity = - Workflow.newActivityStub( - ChatModelActivity.class, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(timeout) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) - .build()); - return new ActivityChatModel(activity, modelName); + ActivityOptions options = + ActivityOptions.newBuilder() + .setStartToCloseTimeout(timeout) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) + .build(); + ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options); + return new ActivityChatModel(activity, modelName, options); } /** @@ -193,7 +204,8 @@ public ChatResponse call(Prompt prompt) { private ChatResponse internalCall(Prompt prompt) { // Convert prompt to activity input and call the activity ChatModelTypes.ChatModelActivityInput input = createActivityInput(prompt); - ChatModelTypes.ChatModelActivityOutput output = chatModelActivity.callChatModel(input); + ChatModelActivity stub = stubForCall(prompt); + ChatModelTypes.ChatModelActivityOutput output = stub.callChatModel(input); // Convert activity output to ChatResponse ChatResponse response = toResponse(output); @@ -219,6 +231,36 @@ private ChatResponse internalCall(Prompt prompt) { return response; } + private ChatModelActivity stubForCall(Prompt prompt) { + if (baseOptions == null) { + return chatModelActivity; + } + ActivityOptions withSummary = + ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary(prompt)).build(); + return Workflow.newActivityStub(ChatModelActivity.class, withSummary); + } + + private String buildSummary(Prompt prompt) { + String label = modelName != null ? modelName : "default"; + String userText = lastUserText(prompt); + if (userText == null || userText.isEmpty()) { + return "chat: " + label; + } + String truncated = userText.length() > 60 ? userText.substring(0, 60) + "…" : userText; + return "chat: " + label + " · " + truncated.replace('\n', ' '); + } + + private String lastUserText(Prompt prompt) { + List instructions = prompt.getInstructions(); + for (int i = instructions.size() - 1; i >= 0; i--) { + Message m = instructions.get(i); + if (m.getMessageType() == MessageType.USER) { + return m.getText(); + } + } + return null; + } + private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) { // Convert messages List messages = diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java index 36c9cd26e..9f34773d8 100644 --- a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java @@ -63,8 +63,7 @@ void chatActivity_carriesSummaryWithModelAndUserPrompt() { workflow.chat("What is the capital of France?"); String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); - List events = - client.fetchHistory(workflowId).getHistory().getEventsList(); + List events = client.fetchHistory(workflowId).getHistory().getEventsList(); String summary = findChatActivitySummary(events); assertNotNull(summary, "ActivityTaskScheduled event for callChatModel should have a Summary"); @@ -83,7 +82,7 @@ private static String findChatActivitySummary(List events) { } String activityType = event.getActivityTaskScheduledEventAttributes().getActivityType().getName(); - if (!"callChatModel".equals(activityType)) { + if (!"CallChatModel".equals(activityType)) { continue; } if (!event.hasUserMetadata() || !event.getUserMetadata().hasSummary()) { From a33c9e02f5f6cd8bcae4ffea74b8016a77876b33 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 12:06:36 -0400 Subject: [PATCH 05/14] temporal-spring-ai: drop PLAN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 107 ------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 temporal-spring-ai/PLAN.md diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md deleted file mode 100644 index f19382067..000000000 --- a/temporal-spring-ai/PLAN.md +++ /dev/null @@ -1,107 +0,0 @@ -# Plan: Activity summaries for debuggability - -## Scope - -Temporal's AI integration guide recommends setting a Summary on scheduled -activities so the Temporal UI can render something more useful than a bare -`ChatModelActivity.callChatModel`. The Java SDK exposes this via -`ActivityOptions.Builder.setSummary(String)`; the summary is baked into the -stub at creation time. - -This branch adds summaries **only on the activity calls the plugin itself -creates stubs for** — chat model and MCP. Cases where the *user* creates the -stub and hands it to the plugin are deliberately out of scope. - -### Why chat and MCP only - -- **Chat model**: `ActivityChatModel.forModel(...)` builds the stub, so the - plugin knows (and owns) the `ActivityOptions`. Adding a summary is a - one-line overlay before calling the activity. -- **MCP**: `ActivityMcpClient.create(...)` builds the stub, same story. - -### Explicitly out of scope - -- **Activity-backed tools** (`ActivityToolCallback`): the user creates the - activity stub in their workflow (`Workflow.newActivityStub(...)`) and hands - it to `.defaultTools(...)`. The stub's `ActivityOptions` are sealed inside - the proxy, and there is no public API to override options per call. - Reaching in reflectively is brittle and was rejected during review. - Scheduled activities for tool calls keep their default - (activity-type-name) row in the UI. -- **Nexus tools** (`NexusToolCallback`): same shape as activity tools — user - owns the stub. Skip for the same reason. -- **`@SideEffectTool`** (`SideEffectToolCallback`): runs via - `Workflow.sideEffect(...)`, which records a `MarkerRecorded` event, not an - `ActivityTaskScheduled` event. `MarkerRecorded` has no user-facing Summary - field, so there is nothing to attach. Skip. - -## Files to change - -- `src/main/java/io/temporal/springai/model/ActivityChatModel.java` - - Keep the existing stub but also retain the `ActivityOptions` used to - build it (new private field). When `ActivityOptions` are known, each - call rebuilds the stub via - `ActivityOptions.newBuilder(base).setSummary(s).build()`. When the bare - public constructor is used (user-supplied stub, options unknown), fall - back to the cached stub — no summary, no reflection. - - Summary format: `"chat: " + modelName + " · " + truncate(lastUserText, 60)` - with newlines replaced by spaces; `"chat: " + modelName` when there is no - user text. - -- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` - - Same pattern: store the `ActivityOptions` used by `create(...)`. Add a - `callTool(clientName, request, summary)` overload; the existing - `callTool(clientName, request)` delegates to it with a null summary. - -- `src/main/java/io/temporal/springai/mcp/McpToolCallback.java` - - `call(toolInput)` builds `"mcp: " + clientName + "." + tool.name()` and - passes it to the new overload. - -No changes to `TemporalStubUtil`, `ActivityToolCallback`, `ActivityToolUtil`, -`NexusToolCallback`, or `SideEffectToolCallback`. - -## Test plan - -- Add `ActivitySummaryTest` under `src/test/...`: - - Run a workflow that uses `ActivityChatModel.forDefault()` and triggers a - single chat call. Fetch history via `client.fetchHistory(...)` and - assert the `ActivityTaskScheduled` event's `userMetadata.summary` - decodes to a string starting with `"chat: default"`. - - Same pattern for MCP: use a fake `McpSyncClient` that returns a known - tool; the workflow drives a tool call; assert the scheduled - `McpClientActivity.callTool` activity has a summary matching - `"mcp: ."`. -- Replay the captured history with `WorkflowReplayer.replayWorkflowExecution` - in each test to confirm no determinism regression. - -## PR - -**Title:** `temporal-spring-ai: attach activity summaries for chat and MCP calls` - -**Body:** - -``` -## What was changed -- `ActivityChatModel` attaches an activity Summary of the form - `chat: · ` to each chat-model activity call. -- `ActivityMcpClient` gains a `callTool(clientName, request, summary)` - overload; `McpToolCallback` uses it to attach `mcp: .`. -- Summaries are applied only when the plugin itself created the activity - stub (via the existing `forModel`/`create` factories). When the user - supplies their own pre-built stub, behavior is unchanged. - -Activity-backed tool calls, Nexus tool calls, and `@SideEffectTool` calls -are intentionally out of scope — either the stub is user-owned (activity -and Nexus tools) or there is no Summary slot on the resulting history event -(`@SideEffectTool` writes a `MarkerRecorded`, not an -`ActivityTaskScheduled`). - -## Why? -Without Summaries, every chat and MCP step in a Spring AI workflow shows up -as an opaque `callChatModel` or `callTool` row in the Temporal UI, making -multi-step agents hard to follow. Chat and MCP are the cases where the -plugin owns stub creation end-to-end, so adding the Summary is a cheap, -low-risk overlay; broader coverage would require reaching into user-owned -stubs and was deliberately left out to keep this change small and -review-friendly. -``` From d3351a385954417d1077be744cf26d7cfc6805f5 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 12:55:58 -0400 Subject: [PATCH 06/14] temporal-spring-ai: drop user prompt from chat activity Summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Summary now carries only the model label ("chat: ") instead of "chat: · ". Including even a truncated prompt leaks whatever the prompt contains — PII, secrets, internal identifiers — into workflow history, server logs, and the Temporal UI, which is a surprising default for an observability label. An opt-in API for callers who explicitly want the prompt in the Summary can be added later if there's demand. ActivitySummaryTest.chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt asserts the Summary equals "chat: default" exactly and defensively checks that no part of the prompt leaked in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/model/ActivityChatModel.java | 28 ++++++------------- .../springai/ActivitySummaryTest.java | 19 ++++++++----- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index fe4ee5857..3bf75a931 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -236,29 +236,19 @@ private ChatModelActivity stubForCall(Prompt prompt) { return chatModelActivity; } ActivityOptions withSummary = - ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary(prompt)).build(); + ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build(); return Workflow.newActivityStub(ChatModelActivity.class, withSummary); } - private String buildSummary(Prompt prompt) { + /** + * Builds the activity Summary. Intentionally omits the user prompt — including even a truncated + * slice would leak whatever the prompt contains (PII, secrets, internal identifiers) into + * workflow history, server logs, and the Temporal UI, which is a surprising default for a plain + * observability label. + */ + private String buildSummary() { String label = modelName != null ? modelName : "default"; - String userText = lastUserText(prompt); - if (userText == null || userText.isEmpty()) { - return "chat: " + label; - } - String truncated = userText.length() > 60 ? userText.substring(0, 60) + "…" : userText; - return "chat: " + label + " · " + truncated.replace('\n', ' '); - } - - private String lastUserText(Prompt prompt) { - List instructions = prompt.getInstructions(); - for (int i = instructions.size() - 1; i >= 0; i--) { - Message m = instructions.get(i); - if (m.getMessageType() == MessageType.USER) { - return m.getText(); - } - } - return null; + return "chat: " + label; } private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) { diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java index 9f34773d8..bf0efebba 100644 --- a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java @@ -1,5 +1,6 @@ package io.temporal.springai; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -49,7 +50,7 @@ void tearDown() { } @Test - void chatActivity_carriesSummaryWithModelAndUserPrompt() { + void chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt() { Worker worker = testEnv.newWorker(TASK_QUEUE); worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); worker.registerActivitiesImplementations( @@ -60,19 +61,23 @@ void chatActivity_carriesSummaryWithModelAndUserPrompt() { client.newWorkflowStub( SummaryTestWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); - workflow.chat("What is the capital of France?"); + String prompt = "What is the capital of France?"; + workflow.chat(prompt); String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); List events = client.fetchHistory(workflowId).getHistory().getEventsList(); String summary = findChatActivitySummary(events); assertNotNull(summary, "ActivityTaskScheduled event for callChatModel should have a Summary"); + assertEquals( + "chat: default", + summary, + "Summary must be just the model label — user prompts can contain PII and must not" + + " appear in history/logs/UI by default."); + // Defense in depth: explicitly confirm no part of the prompt leaked into the summary. assertTrue( - summary.startsWith("chat: default"), - "Summary should start with 'chat: default' but was: " + summary); - assertTrue( - summary.contains("What is the capital of France?"), - "Summary should contain the user prompt but was: " + summary); + !summary.contains(prompt) && !summary.contains("France"), + "Summary must not include user prompt content, got: " + summary); } private static String findChatActivitySummary(List events) { From 66f4dd994fda71f17b59738f191bbb2bfc9b42c5 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 19:32:09 -0400 Subject: [PATCH 07/14] temporal-spring-ai: wrap nullable fields/params in Optional<> Addresses review feedback (thread on #2852) preferring typesafe Optional over nullable fields and raw null delegation. Three sites changed: - ActivityChatModel: modelName and baseOptions fields + private ctor params are now Optional / Optional. getModelName() returns Optional. Public ctors and factories still accept nullable String modelName at the API boundary (matches prior javadoc: "null for default"); they normalize via Optional.ofNullable before storing. Internal readers use .map / .orElse instead of null checks. - ActivityMcpClient: the 3-arg callTool's summary parameter is now Optional. The 2-arg convenience overload passes Optional.empty(). The rebuild-with-summary branch uses .isPresent() + .get() instead of null checks. - McpToolCallback.call(...) wraps its generated summary string in Optional.of(...) before passing to callTool. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/mcp/ActivityMcpClient.java | 37 ++++++++------ .../springai/mcp/McpToolCallback.java | 3 +- .../springai/model/ActivityChatModel.java | 50 +++++++++++-------- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 6e9895a0a..c4aff717b 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -6,6 +6,7 @@ import io.temporal.workflow.Workflow; import java.time.Duration; import java.util.Map; +import java.util.Optional; /** * A workflow-safe wrapper for MCP (Model Context Protocol) client operations. @@ -48,7 +49,7 @@ public class ActivityMcpClient { public static final int DEFAULT_MAX_ATTEMPTS = 3; private final McpClientActivity activity; - private final ActivityOptions baseOptions; + private final Optional baseOptions; private Map serverCapabilities; private Map clientInfo; @@ -58,15 +59,16 @@ public class ActivityMcpClient { * @param activity the activity stub for MCP operations */ public ActivityMcpClient(McpClientActivity activity) { - this(activity, null); + this(activity, Optional.empty()); } /** - * Creates a new ActivityMcpClient. When {@code baseOptions} is non-null, {@link #callTool(String, + * Creates a new ActivityMcpClient. When {@code baseOptions} is present, {@link #callTool(String, * McpSchema.CallToolRequest, String)} rebuilds the activity stub with a per-call Summary on top - * of those options. + * of those options. When empty, 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, ActivityOptions baseOptions) { + private ActivityMcpClient(McpClientActivity activity, Optional baseOptions) { this.activity = activity; this.baseOptions = baseOptions; } @@ -98,7 +100,7 @@ public static ActivityMcpClient create(Duration timeout, int maxAttempts) { .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) .build(); McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options); - return new ActivityMcpClient(activity, options); + return new ActivityMcpClient(activity, Optional.of(options)); } /** @@ -137,7 +139,7 @@ public Map getClientInfo() { * @return the tool call result */ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRequest request) { - return callTool(clientName, request, null); + return callTool(clientName, request, Optional.empty()); } /** @@ -148,19 +150,22 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe * * @param clientName the name of the MCP client * @param request the tool call request - * @param summary the activity Summary, or null to omit + * @param summary the activity Summary, or empty to omit * @return the tool call result */ public McpSchema.CallToolResult callTool( - String clientName, McpSchema.CallToolRequest request, String summary) { - if (summary == null || baseOptions == null) { - return activity.callTool(clientName, request); + String clientName, McpSchema.CallToolRequest request, Optional 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.isPresent() && baseOptions.isPresent()) { + McpClientActivity stub = + Workflow.newActivityStub( + McpClientActivity.class, + ActivityOptions.newBuilder(baseOptions.get()).setSummary(summary.get()).build()); + return stub.callTool(clientName, request); } - McpClientActivity stub = - Workflow.newActivityStub( - McpClientActivity.class, - ActivityOptions.newBuilder(baseOptions).setSummary(summary).build()); - return stub.callTool(clientName, request); + return activity.callTool(clientName, request); } /** diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java index 48736af6a..8410087f8 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java @@ -3,6 +3,7 @@ import io.modelcontextprotocol.spec.McpSchema; import java.util.List; import java.util.Map; +import java.util.Optional; import org.springframework.ai.mcp.McpToolUtils; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.tool.ToolCallback; @@ -106,7 +107,7 @@ public String call(String toolInput) { // Use the original tool name (not prefixed) when calling the MCP server McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(tool.name(), arguments); - String summary = "mcp: " + clientName + "." + tool.name(); + Optional summary = Optional.of("mcp: " + clientName + "." + tool.name()); McpSchema.CallToolResult result = client.callTool(clientName, request, summary); // Return the result as-is (including errors) so the AI can handle them. diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 3bf75a931..36b2ef306 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -9,6 +9,7 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.metadata.ChatResponseMetadata; @@ -84,8 +85,8 @@ public class ActivityChatModel implements ChatModel { public static final int DEFAULT_MAX_ATTEMPTS = 3; private final ChatModelActivity chatModelActivity; - private final String modelName; - private final ActivityOptions baseOptions; + private final Optional modelName; + private final Optional baseOptions; private final ToolCallingManager toolCallingManager; private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; @@ -95,7 +96,7 @@ public class ActivityChatModel implements ChatModel { * @param chatModelActivity the activity stub for calling the chat model */ public ActivityChatModel(ChatModelActivity chatModelActivity) { - this(chatModelActivity, null, null); + this(chatModelActivity, Optional.empty(), Optional.empty()); } /** @@ -105,16 +106,20 @@ public ActivityChatModel(ChatModelActivity chatModelActivity) { * @param modelName the name of the chat model to use, or null for default */ public ActivityChatModel(ChatModelActivity chatModelActivity, String modelName) { - this(chatModelActivity, modelName, null); + this(chatModelActivity, Optional.ofNullable(modelName), Optional.empty()); } /** * Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code - * baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top - * of those options so the Temporal UI can label the chat activity meaningfully. + * baseOptions} is present, each call rebuilds the activity stub with a per-call Summary on top of + * those options so the Temporal UI can label the chat activity meaningfully. When empty, the + * caller supplied a pre-built stub whose options we don't know, so we call through it as-is + * without a summary. */ private ActivityChatModel( - ChatModelActivity chatModelActivity, String modelName, ActivityOptions baseOptions) { + ChatModelActivity chatModelActivity, + Optional modelName, + Optional baseOptions) { this.chatModelActivity = chatModelActivity; this.modelName = modelName; this.baseOptions = baseOptions; @@ -169,15 +174,14 @@ public static ActivityChatModel forModel(String modelName, Duration timeout, int .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) .build(); ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options); - return new ActivityChatModel(activity, modelName, options); + return new ActivityChatModel(activity, Optional.ofNullable(modelName), Optional.of(options)); } /** - * Returns the name of the chat model this instance uses. - * - * @return the model name, or null if using the default model + * Returns the name of the chat model this instance uses, or empty if it uses the plugin default + * (the {@code @Primary} {@code ChatModel} bean or the first one registered). */ - public String getModelName() { + public Optional getModelName() { return modelName; } @@ -232,12 +236,14 @@ private ChatResponse internalCall(Prompt prompt) { } private ChatModelActivity stubForCall(Prompt prompt) { - if (baseOptions == null) { - return chatModelActivity; - } - ActivityOptions withSummary = - ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build(); - return Workflow.newActivityStub(ChatModelActivity.class, withSummary); + return baseOptions + .map( + base -> { + ActivityOptions withSummary = + ActivityOptions.newBuilder(base).setSummary(buildSummary()).build(); + return Workflow.newActivityStub(ChatModelActivity.class, withSummary); + }) + .orElse(chatModelActivity); } /** @@ -247,8 +253,7 @@ private ChatModelActivity stubForCall(Prompt prompt) { * observability label. */ private String buildSummary() { - String label = modelName != null ? modelName : "default"; - return "chat: " + label; + return "chat: " + modelName.orElse("default"); } private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) { @@ -290,7 +295,10 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) } } - return new ChatModelTypes.ChatModelActivityInput(modelName, messages, modelOptions, tools); + // The serialized record field is String (null = use activity-side default model), so unwrap + // at the serialization boundary. Within this class we keep modelName as Optional. + return new ChatModelTypes.ChatModelActivityInput( + modelName.orElse(null), messages, modelOptions, tools); } private List toActivityMessages(Message message) { From 0b3d10d4fc92d079d4057fa49426d244b4ea1a59 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 20:39:07 -0400 Subject: [PATCH 08/14] temporal-spring-ai: use @Nullable annotations instead of Optional<> Reverses the previous Optional<> commit in favor of @Nullable on the nullable fields/params. Matches the dominant convention in temporal-sdk (431+ @Nullable usages on public API surface) and avoids a thicker runtime story for what is fundamentally a documentation / IDE-hint concern (no NullAway in the build either way). - ActivityChatModel: modelName and baseOptions fields + private ctor params are now @Nullable String / @Nullable ActivityOptions. Public ctors/factories accept nullable String modelName at the API boundary directly. getModelName() returns @Nullable String. - ActivityMcpClient: baseOptions field and callTool(..., summary) param use @Nullable instead of Optional<>. 2-arg callTool passes null; McpToolCallback passes a plain String. - ChatModelTypes.ChatModelActivityInput record: @Nullable on modelName and modelOptions fields so deserialized readers see the nullability in the signature (per the reviewer's concern about the deserialization-side typesafety). Consistent with org.springframework.lang.Nullable already used elsewhere in this module (TemporalChatClient, SpringAiPlugin). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/mcp/ActivityMcpClient.java | 24 ++++----- .../springai/mcp/McpToolCallback.java | 3 +- .../springai/model/ActivityChatModel.java | 51 +++++++++---------- .../springai/model/ChatModelTypes.java | 13 +++-- 4 files changed, 45 insertions(+), 46 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index c4aff717b..5389c2863 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -6,7 +6,7 @@ import io.temporal.workflow.Workflow; import java.time.Duration; import java.util.Map; -import java.util.Optional; +import org.springframework.lang.Nullable; /** * A workflow-safe wrapper for MCP (Model Context Protocol) client operations. @@ -49,7 +49,7 @@ public class ActivityMcpClient { public static final int DEFAULT_MAX_ATTEMPTS = 3; private final McpClientActivity activity; - private final Optional baseOptions; + @Nullable private final ActivityOptions baseOptions; private Map serverCapabilities; private Map clientInfo; @@ -59,16 +59,16 @@ public class ActivityMcpClient { * @param activity the activity stub for MCP operations */ public ActivityMcpClient(McpClientActivity activity) { - this(activity, Optional.empty()); + this(activity, null); } /** - * Creates a new ActivityMcpClient. When {@code baseOptions} is present, {@link #callTool(String, + * 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 empty, the caller supplied a pre-built stub whose options we don't know, + * 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, Optional baseOptions) { + private ActivityMcpClient(McpClientActivity activity, @Nullable ActivityOptions baseOptions) { this.activity = activity; this.baseOptions = baseOptions; } @@ -100,7 +100,7 @@ public static ActivityMcpClient create(Duration timeout, int maxAttempts) { .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) .build(); McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options); - return new ActivityMcpClient(activity, Optional.of(options)); + return new ActivityMcpClient(activity, options); } /** @@ -139,7 +139,7 @@ public Map getClientInfo() { * @return the tool call result */ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRequest request) { - return callTool(clientName, request, Optional.empty()); + return callTool(clientName, request, null); } /** @@ -150,19 +150,19 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe * * @param clientName the name of the MCP client * @param request the tool call request - * @param summary the activity Summary, or empty to omit + * @param summary the activity Summary, or null to omit * @return the tool call result */ public McpSchema.CallToolResult callTool( - String clientName, McpSchema.CallToolRequest request, Optional summary) { + 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.isPresent() && baseOptions.isPresent()) { + if (summary != null && baseOptions != null) { McpClientActivity stub = Workflow.newActivityStub( McpClientActivity.class, - ActivityOptions.newBuilder(baseOptions.get()).setSummary(summary.get()).build()); + ActivityOptions.newBuilder(baseOptions).setSummary(summary).build()); return stub.callTool(clientName, request); } return activity.callTool(clientName, request); diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java index 8410087f8..48736af6a 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java @@ -3,7 +3,6 @@ import io.modelcontextprotocol.spec.McpSchema; import java.util.List; import java.util.Map; -import java.util.Optional; import org.springframework.ai.mcp.McpToolUtils; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.tool.ToolCallback; @@ -107,7 +106,7 @@ public String call(String toolInput) { // Use the original tool name (not prefixed) when calling the MCP server McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(tool.name(), arguments); - Optional summary = Optional.of("mcp: " + clientName + "." + tool.name()); + String summary = "mcp: " + clientName + "." + tool.name(); McpSchema.CallToolResult result = client.callTool(clientName, request, summary); // Return the result as-is (including errors) so the AI can handle them. diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 36b2ef306..8412d63cf 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -9,7 +9,6 @@ import java.time.Duration; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.metadata.ChatResponseMetadata; @@ -22,6 +21,7 @@ import org.springframework.ai.model.tool.*; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.core.io.ByteArrayResource; +import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import reactor.core.publisher.Flux; @@ -85,8 +85,8 @@ public class ActivityChatModel implements ChatModel { public static final int DEFAULT_MAX_ATTEMPTS = 3; private final ChatModelActivity chatModelActivity; - private final Optional modelName; - private final Optional baseOptions; + @Nullable private final String modelName; + @Nullable private final ActivityOptions baseOptions; private final ToolCallingManager toolCallingManager; private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; @@ -96,7 +96,7 @@ public class ActivityChatModel implements ChatModel { * @param chatModelActivity the activity stub for calling the chat model */ public ActivityChatModel(ChatModelActivity chatModelActivity) { - this(chatModelActivity, Optional.empty(), Optional.empty()); + this(chatModelActivity, null, null); } /** @@ -105,21 +105,21 @@ public ActivityChatModel(ChatModelActivity chatModelActivity) { * @param chatModelActivity the activity stub for calling the chat model * @param modelName the name of the chat model to use, or null for default */ - public ActivityChatModel(ChatModelActivity chatModelActivity, String modelName) { - this(chatModelActivity, Optional.ofNullable(modelName), Optional.empty()); + public ActivityChatModel(ChatModelActivity chatModelActivity, @Nullable String modelName) { + this(chatModelActivity, modelName, null); } /** * Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code - * baseOptions} is present, each call rebuilds the activity stub with a per-call Summary on top of - * those options so the Temporal UI can label the chat activity meaningfully. When empty, the + * baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top + * of those options so the Temporal UI can label the chat activity meaningfully. When null, the * caller supplied a pre-built stub whose options we don't know, so we call through it as-is * without a summary. */ private ActivityChatModel( ChatModelActivity chatModelActivity, - Optional modelName, - Optional baseOptions) { + @Nullable String modelName, + @Nullable ActivityOptions baseOptions) { this.chatModelActivity = chatModelActivity; this.modelName = modelName; this.baseOptions = baseOptions; @@ -167,21 +167,23 @@ public static ActivityChatModel forModel(String modelName) { * @param maxAttempts the maximum number of retry attempts * @return an ActivityChatModel for the specified chat model */ - public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) { + public static ActivityChatModel forModel( + @Nullable String modelName, Duration timeout, int maxAttempts) { ActivityOptions options = ActivityOptions.newBuilder() .setStartToCloseTimeout(timeout) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) .build(); ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options); - return new ActivityChatModel(activity, Optional.ofNullable(modelName), Optional.of(options)); + return new ActivityChatModel(activity, modelName, options); } /** - * Returns the name of the chat model this instance uses, or empty if it uses the plugin default + * Returns the name of the chat model this instance uses, or null if it uses the plugin default * (the {@code @Primary} {@code ChatModel} bean or the first one registered). */ - public Optional getModelName() { + @Nullable + public String getModelName() { return modelName; } @@ -236,14 +238,12 @@ private ChatResponse internalCall(Prompt prompt) { } private ChatModelActivity stubForCall(Prompt prompt) { - return baseOptions - .map( - base -> { - ActivityOptions withSummary = - ActivityOptions.newBuilder(base).setSummary(buildSummary()).build(); - return Workflow.newActivityStub(ChatModelActivity.class, withSummary); - }) - .orElse(chatModelActivity); + if (baseOptions == null) { + return chatModelActivity; + } + ActivityOptions withSummary = + ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build(); + return Workflow.newActivityStub(ChatModelActivity.class, withSummary); } /** @@ -253,7 +253,7 @@ private ChatModelActivity stubForCall(Prompt prompt) { * observability label. */ private String buildSummary() { - return "chat: " + modelName.orElse("default"); + return "chat: " + (modelName != null ? modelName : "default"); } private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) { @@ -295,10 +295,7 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) } } - // The serialized record field is String (null = use activity-side default model), so unwrap - // at the serialization boundary. Within this class we keep modelName as Optional. - return new ChatModelTypes.ChatModelActivityInput( - modelName.orElse(null), messages, modelOptions, tools); + return new ChatModelTypes.ChatModelActivityInput(modelName, messages, modelOptions, tools); } private List toActivityMessages(Message message) { diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java index 7dca5e316..0188b78f5 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Duration; import java.util.List; +import org.springframework.lang.Nullable; /** * Serializable types for chat model activity requests and responses. @@ -20,21 +21,23 @@ private ChatModelTypes() {} /** * Input to the chat model activity. * - * @param modelName the name of the chat model bean to use (null for default) + * @param modelName the name of the chat model bean to use, or null for the activity-side default + * model * @param messages the conversation messages - * @param modelOptions options for the chat model (temperature, max tokens, etc.) + * @param modelOptions options for the chat model (temperature, max tokens, etc.), or null to use + * the chat model's own defaults * @param tools tool definitions the model may call */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record ChatModelActivityInput( - @JsonProperty("model_name") String modelName, + @JsonProperty("model_name") @Nullable String modelName, @JsonProperty("messages") List messages, - @JsonProperty("model_options") ModelOptions modelOptions, + @JsonProperty("model_options") @Nullable ModelOptions modelOptions, @JsonProperty("tools") List tools) { /** Creates input for the default chat model. */ public ChatModelActivityInput( - List messages, ModelOptions modelOptions, List tools) { + List messages, @Nullable ModelOptions modelOptions, List tools) { this(null, messages, modelOptions, tools); } } From b8219c9822d64a3eff45a0e578d26d5889799deb Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 20:44:28 -0400 Subject: [PATCH 09/14] temporal-spring-ai: use javax.annotation.Nullable for consistency Switch the five files in this module that imported org.springframework.lang.Nullable over to javax.annotation.Nullable to match the dominant convention in sdk-java (197 usages vs. 7). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/io/temporal/springai/chat/TemporalChatClient.java | 2 +- .../main/java/io/temporal/springai/mcp/ActivityMcpClient.java | 2 +- .../main/java/io/temporal/springai/model/ActivityChatModel.java | 2 +- .../main/java/io/temporal/springai/model/ChatModelTypes.java | 2 +- .../main/java/io/temporal/springai/plugin/SpringAiPlugin.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java index 94517502e..6a3051db7 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java @@ -3,12 +3,12 @@ import io.micrometer.observation.ObservationRegistry; import io.temporal.springai.util.TemporalToolUtil; import java.util.Map; +import javax.annotation.Nullable; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.DefaultChatClient; import org.springframework.ai.chat.client.DefaultChatClientBuilder; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 5389c2863..0df9f99b4 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -6,7 +6,7 @@ import io.temporal.workflow.Workflow; import java.time.Duration; import java.util.Map; -import org.springframework.lang.Nullable; +import javax.annotation.Nullable; /** * A workflow-safe wrapper for MCP (Model Context Protocol) client operations. diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 8412d63cf..284d471c1 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import javax.annotation.Nullable; import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatModel; @@ -21,7 +22,6 @@ import org.springframework.ai.model.tool.*; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.core.io.ByteArrayResource; -import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import reactor.core.publisher.Flux; diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java index 0188b78f5..c1f57317d 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ChatModelTypes.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Duration; import java.util.List; -import org.springframework.lang.Nullable; +import javax.annotation.Nullable; /** * Serializable types for chat model activity requests and responses. diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java b/temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java index 0438ff558..ad13d9abb 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java @@ -7,10 +7,10 @@ import java.util.LinkedHashMap; import java.util.Map; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.lang.Nullable; /** * Core Temporal plugin that registers {@link io.temporal.springai.activity.ChatModelActivity} with From 2a824bb56523a7a45184514b1f2a78e1361dc3fe Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 15:53:51 -0400 Subject: [PATCH 10/14] =?UTF-8?q?temporal-spring-ai:=20plan=20=E2=80=94=20?= =?UTF-8?q?ActivityOptions=20overloads=20and=20non-retryable=20error=20cla?= =?UTF-8?q?ssification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 temporal-spring-ai/PLAN.md diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md new file mode 100644 index 000000000..675a3fb04 --- /dev/null +++ b/temporal-spring-ai/PLAN.md @@ -0,0 +1,84 @@ +# Plan: Retry classification + ActivityOptions acceptance + +## Scope + +Two related production-readiness gaps: + +1. `ActivityChatModel.forDefault()` / `forModel(name)` hard-code + `maxAttempts=3` with no `nonRetryableErrorTypes`. A bad API key, 400 Bad + Request, or invalid-prompt error churns through retries + exponential + backoff before failing. `ActivityMcpClient.create()` has the same issue. +2. The factory API only lets users override timeout + maxAttempts. There's + no way to pass heartbeats, a specific task queue, priorities, + `doNotRetry`, or a custom Summary. Users who need any of these today + have to bypass the factories entirely and construct their own + `Workflow.newActivityStub(ChatModelActivity.class, ...)`. + +## Files to change + +- `src/main/java/io/temporal/springai/model/ActivityChatModel.java` + - Add `forDefault(ActivityOptions options)` and + `forModel(String modelName, ActivityOptions options)` overloads. + - Change the existing `forModel(modelName, timeout, maxAttempts)` to build + an `ActivityOptions` internally that includes a default + `RetryOptions.setDoNotRetry(...)` list (see §Retry classification below) + and delegate to the new `ActivityOptions` overload. + - Keep all existing overloads working — purely additive API. + +- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` + - Same treatment: add `create(ActivityOptions options)` overload; existing + `create(timeout, maxAttempts)` delegates. + +### Retry classification + +Default `doNotRetry` list for chat model activities should cover clearly +permanent failures from Spring AI. Candidates (need to verify exact FQCNs +against spring-ai 1.1): + +- `org.springframework.ai.retry.NonTransientAiException` +- `java.lang.IllegalArgumentException` (e.g. unknown model name from + `ChatModelActivityImpl.resolveChatModel`) + +Do **not** add `RuntimeException` or broad superclasses; the default Temporal +behavior should still retry on network errors, 5xx, rate-limits, timeouts. + +MCP activities: add `IllegalArgumentException` (client-not-found is already +thrown as `ApplicationFailure` with `nonRetryable=true`, which wins on its +own, so no extra entries strictly required). + +## Test plan + +- Unit test: pass an `ActivityOptions` with a custom `taskQueue` and a custom + `doNotRetry` entry; verify (via test env history inspection) the activity + was scheduled on that task queue and that a thrown + `NonTransientAiException` does not trigger retry. +- Unit test: default factory still retries on a transient `RuntimeException` + (ensure the doNotRetry list doesn't over-reach). +- `WorkflowReplayer` replay check. + +## PR + +**Title:** `temporal-spring-ai: accept ActivityOptions and classify non-retryable AI errors` + +**Body:** + +``` +## What was changed +- `ActivityChatModel.forDefault(ActivityOptions)` and + `ActivityChatModel.forModel(String, ActivityOptions)` overloads added. +- `ActivityMcpClient.create(ActivityOptions)` overload added. +- Default RetryOptions for chat and MCP activities now include + `doNotRetry` entries for clearly non-transient Spring AI failures + (`NonTransientAiException`, `IllegalArgumentException`). +- Existing factory signatures are preserved; they now delegate to the new + `ActivityOptions` overloads. + +## Why? +Previously the only way to customize the chat or MCP activity stub was via +`(timeout, maxAttempts)` — no heartbeats, task queue override, Summary, +priority, or `doNotRetry`. Users hitting those needs had to bypass the +factories entirely. Also: transient-vs-permanent classification matters a +lot for LLM calls (a 401 shouldn't retry for minutes), and the integration +guide specifically calls out "tell Temporal which errors are retryable and +which are not." This brings the plugin in line. +``` From 46dcee4ca3a1d060ef10e87364f86b8f12a496a8 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 16:56:00 -0400 Subject: [PATCH 11/14] =?UTF-8?q?temporal-spring-ai:=20amend=20plan=20?= =?UTF-8?q?=E2=80=94=20ActivityOptions=20overloads=20fix=20summaries=20UX?= =?UTF-8?q?=20wart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity-summaries branch only overlays Summaries when the factories (forModel/create) built the stub. Users who need custom timeouts today fall back to the public constructor, which silently drops UI Summaries. The ActivityOptions overloads planned here are the proper fix: they let users customize the stub and keep Summary labels. Plan now also covers deprecating the public constructors with javadoc pointing at the factories. Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 73 ++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md index 675a3fb04..807212cf1 100644 --- a/temporal-spring-ai/PLAN.md +++ b/temporal-spring-ai/PLAN.md @@ -14,6 +14,32 @@ Two related production-readiness gaps: have to bypass the factories entirely and construct their own `Workflow.newActivityStub(ChatModelActivity.class, ...)`. +### Interaction with the `spring-ai/activity-summaries` branch + +The summaries branch landed a plugin-owned `ActivityOptions` store on +`ActivityChatModel` and `ActivityMcpClient`: the factories (`forModel`, +`create`) now retain the options they built the stub with, and the per-call +path overlays a Summary on top. The bare public constructors +(`new ActivityChatModel(stub)`, `new ActivityMcpClient(activity)`) receive +no options and therefore emit no summary — silently. That's a real UX wart: +anyone who rolls their own stub today loses Summary labels on the chat / +MCP rows in the Temporal UI. + +The `ActivityOptions` factory overloads this branch adds **are the proper +fix** for that wart. Users who need non-default timeouts / retries / task +queue / heartbeats today fall back to the public constructor to get those; +giving them a factory that takes `ActivityOptions` means they keep +summaries while still customizing the stub. This branch should therefore: + +- Explicitly document (in `ActivityChatModel` and `ActivityMcpClient` + javadocs, and the README) that Summary labels appear only when the stub + is built via one of the factories, and point at the new overloads as the + recommended path for customization. +- Leave the public constructors in place for backward compatibility, but + mark them `@Deprecated` or, at minimum, add a javadoc note that they + skip UI summaries. Decision: ship the deprecation alongside the new + factory so the doc and the warning point to the same replacement. + ## Files to change - `src/main/java/io/temporal/springai/model/ActivityChatModel.java` @@ -23,11 +49,30 @@ Two related production-readiness gaps: an `ActivityOptions` internally that includes a default `RetryOptions.setDoNotRetry(...)` list (see §Retry classification below) and delegate to the new `ActivityOptions` overload. - - Keep all existing overloads working — purely additive API. + - The new overloads pass their `ActivityOptions` into the existing + private `(stub, modelName, baseOptions)` constructor added by the + summaries branch, so Summary labels work out of the box — no extra + wiring. + - Javadoc on the public `ActivityChatModel(ChatModelActivity[, String])` + constructors: add a note that callers who want UI Summaries should + prefer the new `forDefault(ActivityOptions)` / `forModel(String, + ActivityOptions)` factories. Mark the constructors `@Deprecated` with + a message pointing at the factory replacement. + - Keep all existing overloads working at runtime — purely additive API. - `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` - Same treatment: add `create(ActivityOptions options)` overload; existing `create(timeout, maxAttempts)` delegates. + - Thread the passed `ActivityOptions` through the private + `(activity, baseOptions)` constructor added by the summaries branch so + `callTool(..., summary)` overlays work. + - `@Deprecated` the public `new ActivityMcpClient(activity)` constructor + with a javadoc note recommending the factory for Summary support. + +- `README.md` + - Brief note in the quick-start and the "Tool Types" section that chat + and MCP rows in the Temporal UI are labeled with Summaries when (and + only when) built via the factories. ### Retry classification @@ -54,6 +99,11 @@ own, so no extra entries strictly required). `NonTransientAiException` does not trigger retry. - Unit test: default factory still retries on a transient `RuntimeException` (ensure the doNotRetry list doesn't over-reach). +- Extend `ActivitySummaryTest` (added on the summaries branch) to assert a + workflow built via `ActivityChatModel.forDefault(customOptions)` still + carries the expected `chat: …` summary on its scheduled chat activity. + This guards against a regression where the new overloads silently drop + the `baseOptions` wiring. - `WorkflowReplayer` replay check. ## PR @@ -70,15 +120,24 @@ own, so no extra entries strictly required). - Default RetryOptions for chat and MCP activities now include `doNotRetry` entries for clearly non-transient Spring AI failures (`NonTransientAiException`, `IllegalArgumentException`). -- Existing factory signatures are preserved; they now delegate to the new +- Public `new ActivityChatModel(...)` and `new ActivityMcpClient(...)` + constructors are `@Deprecated` with javadoc pointing at the factory + replacement. They still work at runtime but skip UI Summaries, which is + now called out explicitly. +- Existing factory signatures are preserved; they delegate to the new `ActivityOptions` overloads. ## Why? -Previously the only way to customize the chat or MCP activity stub was via -`(timeout, maxAttempts)` — no heartbeats, task queue override, Summary, +Previously the only way to customize the chat or MCP activity stub was +via `(timeout, maxAttempts)` — no heartbeats, task queue override, priority, or `doNotRetry`. Users hitting those needs had to bypass the -factories entirely. Also: transient-vs-permanent classification matters a -lot for LLM calls (a 401 shouldn't retry for minutes), and the integration -guide specifically calls out "tell Temporal which errors are retryable and +factories and call the public constructor with a hand-built stub, which +as of the summaries branch silently drops the Temporal UI labels for +chat and MCP rows. The new `ActivityOptions` overloads close that gap: +users get full customization *and* keep the Summary labels. + +Also: transient-vs-permanent classification matters a lot for LLM calls +(a 401 shouldn't retry for minutes), and the integration guide +specifically calls out "tell Temporal which errors are retryable and which are not." This brings the plugin in line. ``` From 4a95f23b9bd84c68dd04dbb701b4cb8e9afdc49b Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 17:08:41 -0400 Subject: [PATCH 12/14] temporal-spring-ai: accept ActivityOptions and classify non-retryable AI errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- temporal-spring-ai/PLAN.md | 23 +- temporal-spring-ai/README.md | 17 ++ temporal-spring-ai/build.gradle | 3 + .../springai/chat/TemporalChatClient.java | 4 +- .../springai/mcp/ActivityMcpClient.java | 107 ++++++--- .../springai/model/ActivityChatModel.java | 152 ++++++------ .../springai/ActivityOptionsAndRetryTest.java | 221 ++++++++++++++++++ .../springai/ActivitySummaryTest.java | 43 ++++ 8 files changed, 450 insertions(+), 120 deletions(-) create mode 100644 temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md index 807212cf1..e1f1768d9 100644 --- a/temporal-spring-ai/PLAN.md +++ b/temporal-spring-ai/PLAN.md @@ -35,10 +35,10 @@ summaries while still customizing the stub. This branch should therefore: javadocs, and the README) that Summary labels appear only when the stub is built via one of the factories, and point at the new overloads as the recommended path for customization. -- Leave the public constructors in place for backward compatibility, but - mark them `@Deprecated` or, at minimum, add a javadoc note that they - skip UI summaries. Decision: ship the deprecation alongside the new - factory so the doc and the warning point to the same replacement. +- Remove the public constructors outright rather than marking them + `@Deprecated`. This module is still pre-release (public preview, no + released artifact), so there are no pinned callers to migrate — the + deprecate-then-remove dance is pure noise. ## Files to change @@ -56,8 +56,8 @@ summaries while still customizing the stub. This branch should therefore: - Javadoc on the public `ActivityChatModel(ChatModelActivity[, String])` constructors: add a note that callers who want UI Summaries should prefer the new `forDefault(ActivityOptions)` / `forModel(String, - ActivityOptions)` factories. Mark the constructors `@Deprecated` with - a message pointing at the factory replacement. + ActivityOptions)` factories. Remove them outright (this module has not cut a public release yet, so + there are no users pinned to those signatures to accommodate). - Keep all existing overloads working at runtime — purely additive API. - `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` @@ -66,8 +66,8 @@ summaries while still customizing the stub. This branch should therefore: - Thread the passed `ActivityOptions` through the private `(activity, baseOptions)` constructor added by the summaries branch so `callTool(..., summary)` overlays work. - - `@Deprecated` the public `new ActivityMcpClient(activity)` constructor - with a javadoc note recommending the factory for Summary support. + - Remove the public `new ActivityMcpClient(activity)` constructor + outright for the same reason. - `README.md` - Brief note in the quick-start and the "Tool Types" section that chat @@ -120,10 +120,9 @@ own, so no extra entries strictly required). - Default RetryOptions for chat and MCP activities now include `doNotRetry` entries for clearly non-transient Spring AI failures (`NonTransientAiException`, `IllegalArgumentException`). -- Public `new ActivityChatModel(...)` and `new ActivityMcpClient(...)` - constructors are `@Deprecated` with javadoc pointing at the factory - replacement. They still work at runtime but skip UI Summaries, which is - now called out explicitly. +- The public `new ActivityChatModel(...)` and `new ActivityMcpClient(...)` + constructors are removed. The module is pre-release so there are no + pinned callers to migrate; the factories are the single entry point. - Existing factory signatures are preserved; they delegate to the new `ActivityOptions` overloads. diff --git a/temporal-spring-ai/README.md b/temporal-spring-ai/README.md index 3fbc587a7..a19e96a4f 100644 --- a/temporal-spring-ai/README.md +++ b/temporal-spring-ai/README.md @@ -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: `, `mcp: .`). `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: diff --git a/temporal-spring-ai/build.gradle b/temporal-spring-ai/build.gradle index c176ed3d8..12b66cc19 100644 --- a/temporal-spring-ai/build.gradle +++ b/temporal-spring-ai/build.gradle @@ -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" diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java index 6a3051db7..be42468eb 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java @@ -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); diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 0df9f99b4..24416b0a8 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -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; @@ -48,44 +49,44 @@ 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 serverCapabilities; - private Map 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 + *

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 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 serverCapabilities; + private Map 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). * *

Must be called from workflow code. * * @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 with a custom timeout and attempt count. Other defaults + * (non-retryable error classification) are preserved. * *

Must be called from workflow code. * @@ -94,15 +95,50 @@ public static ActivityMcpClient create() { * @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(); + return create(defaultActivityOptions(timeout, maxAttempts)); + } + + /** + * 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}. + * + *

Must be called from workflow code. + * + * @param options the activity options to use for each MCP call + * @return a new ActivityMcpClient + */ + 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: + * + *

{@code
+   * ActivityMcpClient.create(
+   *     ActivityOptions.newBuilder(ActivityMcpClient.defaultActivityOptions())
+   *         .setTaskQueue("mcp-heavy")
+   *         .build());
+   * }
+ */ + 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. * @@ -144,9 +180,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 @@ -155,17 +189,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); } /** diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 284d471c1..2f486ee74 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -37,25 +37,7 @@ * *

Usage

* - *

For a single chat model, use the constructor directly: - * - *

{@code
- * @WorkflowInit
- * public MyWorkflowImpl() {
- *     ChatModelActivity chatModelActivity = Workflow.newActivityStub(
- *         ChatModelActivity.class,
- *         ActivityOptions.newBuilder()
- *             .setStartToCloseTimeout(Duration.ofMinutes(2))
- *             .build());
- *
- *     ActivityChatModel chatModel = new ActivityChatModel(chatModelActivity);
- *     this.chatClient = ChatClient.builder(chatModel).build();
- * }
- * }
- * - *

Multiple Chat Models

- * - *

For applications with multiple chat models, use the static factory methods: + *

Build instances via the static factory methods: * *

{@code
  * @WorkflowInit
@@ -84,42 +66,31 @@ public class ActivityChatModel implements ChatModel {
   /** Default maximum retry attempts for chat model activity calls. */
   public static final int DEFAULT_MAX_ATTEMPTS = 3;
 
-  private final ChatModelActivity chatModelActivity;
-  @Nullable private final String modelName;
-  @Nullable private final ActivityOptions baseOptions;
-  private final ToolCallingManager toolCallingManager;
-  private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate;
-
   /**
-   * Creates a new ActivityChatModel that uses the default chat model.
+   * Error types that the default retry policy treats as non-retryable. These represent clearly
+   * permanent failures — a bad API key, an invalid prompt, an unknown model name — where retrying
+   * wastes time and money.
    *
-   * @param chatModelActivity the activity stub for calling the chat model
+   * 

Applied only to the factories that build {@link ActivityOptions} internally. When callers + * pass their own {@link ActivityOptions} (via {@link #forDefault(ActivityOptions)} or {@link + * #forModel(String, ActivityOptions)}), their {@link RetryOptions} are used verbatim. */ - public ActivityChatModel(ChatModelActivity chatModelActivity) { - this(chatModelActivity, null, null); - } + public static final List DEFAULT_NON_RETRYABLE_ERROR_TYPES = + List.of( + "org.springframework.ai.retry.NonTransientAiException", + "java.lang.IllegalArgumentException"); - /** - * Creates a new ActivityChatModel that uses a specific chat model. - * - * @param chatModelActivity the activity stub for calling the chat model - * @param modelName the name of the chat model to use, or null for default - */ - public ActivityChatModel(ChatModelActivity chatModelActivity, @Nullable String modelName) { - this(chatModelActivity, modelName, null); - } + private final ChatModelActivity chatModelActivity; + @Nullable private final String modelName; + private final ActivityOptions baseOptions; + private final ToolCallingManager toolCallingManager; + private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; - /** - * Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code - * baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top - * of those options so the Temporal UI can label the chat activity meaningfully. When null, the - * caller supplied a pre-built stub whose options we don't know, so we call through it as-is - * without a summary. - */ + /** Use one of the {@link #forDefault()} / {@link #forModel(String)} factories. */ private ActivityChatModel( ChatModelActivity chatModelActivity, @Nullable String modelName, - @Nullable ActivityOptions baseOptions) { + ActivityOptions baseOptions) { this.chatModelActivity = chatModelActivity; this.modelName = modelName; this.baseOptions = baseOptions; @@ -128,24 +99,36 @@ private ActivityChatModel( } /** - * Creates an ActivityChatModel for the default chat model. - * - *

This factory method creates the activity stub internally with default timeout and retry - * options. + * Creates an ActivityChatModel for the default chat model with the plugin's default {@link + * ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors + * marked non-retryable). * *

Must be called from workflow code. * * @return an ActivityChatModel for the default chat model */ public static ActivityChatModel forDefault() { - return forModel(null, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } /** - * Creates an ActivityChatModel for a specific chat model by bean name. + * Creates an ActivityChatModel for the default chat model using the supplied {@link + * ActivityOptions}. Pass this when you need a specific task queue, heartbeat, priority, or a + * custom {@link RetryOptions} — anything the {@code (timeout, maxAttempts)} convenience factory + * can't express. * - *

This factory method creates the activity stub internally with default timeout and retry - * options. + *

Must be called from workflow code. + * + * @param options the activity options to use for each chat call + * @return an ActivityChatModel for the default chat model + */ + public static ActivityChatModel forDefault(ActivityOptions options) { + return forModel(null, options); + } + + /** + * Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default + * {@link ActivityOptions}. * *

Must be called from workflow code. * @@ -154,11 +137,12 @@ public static ActivityChatModel forDefault() { * @throws IllegalArgumentException if no model with that name exists (at activity runtime) */ public static ActivityChatModel forModel(String modelName) { - return forModel(modelName, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } /** - * Creates an ActivityChatModel for a specific chat model with custom options. + * Creates an ActivityChatModel for a specific chat model with a custom timeout and attempt count. + * The remaining defaults (non-retryable error classification) are preserved. * *

Must be called from workflow code. * @@ -167,17 +151,54 @@ public static ActivityChatModel forModel(String modelName) { * @param maxAttempts the maximum number of retry attempts * @return an ActivityChatModel for the specified chat model */ - public static ActivityChatModel forModel( - @Nullable String modelName, Duration timeout, int maxAttempts) { - ActivityOptions options = - ActivityOptions.newBuilder() - .setStartToCloseTimeout(timeout) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) - .build(); + public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) { + return forModel(modelName, defaultActivityOptions(timeout, maxAttempts)); + } + + /** + * Creates an ActivityChatModel for a specific chat model using the supplied {@link + * ActivityOptions}. The provided options are used verbatim — the plugin does not augment the + * caller's {@link RetryOptions} or merge in its defaults. If you want the plugin-default + * non-retryable error classification, copy {@link #DEFAULT_NON_RETRYABLE_ERROR_TYPES} into your + * own {@link RetryOptions}. + * + *

Must be called from workflow code. + * + * @param modelName the bean name of the chat model, or null for default + * @param options the activity options to use for each chat call + * @return an ActivityChatModel for the specified chat model + */ + public static ActivityChatModel forModel(@Nullable String modelName, ActivityOptions options) { ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options); return new ActivityChatModel(activity, modelName, options); } + /** + * Returns the plugin's default {@link ActivityOptions} for chat model calls. Useful as a starting + * point when you want to customize one or two fields without losing the sensible defaults: + * + *

{@code
+   * ActivityChatModel.forDefault(
+   *     ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
+   *         .setTaskQueue("chat-heavy")
+   *         .build());
+   * }
+ */ + 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(); + } + /** * Returns the name of the chat model this instance uses, or null if it uses the plugin default * (the {@code @Primary} {@code ChatModel} bean or the first one registered). @@ -238,9 +259,6 @@ private ChatResponse internalCall(Prompt prompt) { } private ChatModelActivity stubForCall(Prompt prompt) { - if (baseOptions == null) { - return chatModelActivity; - } ActivityOptions withSummary = ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build(); return Workflow.newActivityStub(ChatModelActivity.class, withSummary); diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java new file mode 100644 index 000000000..ce602de16 --- /dev/null +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java @@ -0,0 +1,221 @@ +package io.temporal.springai; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.activity.ActivityOptions; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivityImpl; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.retry.NonTransientAiException; + +/** + * Verifies the retry-classification and custom-{@link ActivityOptions} surface added by the + * retry-and-options branch. + */ +class ActivityOptionsAndRetryTest { + + private static final String TASK_QUEUE = "test-spring-ai-retry"; + private static final String CUSTOM_TASK_QUEUE = "test-spring-ai-custom-queue"; + + private TestWorkflowEnvironment testEnv; + private WorkflowClient client; + + @BeforeEach + void setUp() { + testEnv = TestWorkflowEnvironment.newInstance(); + client = testEnv.getWorkflowClient(); + } + + @AfterEach + void tearDown() { + testEnv.close(); + } + + @Test + void defaultFactory_marksNonTransientAiExceptionNonRetryable() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); + CountingChatModel model = new CountingChatModel(new NonTransientAiException("bad key")); + worker.registerActivitiesImplementations(new ChatModelActivityImpl(model)); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + try { + workflow.chat("hello"); + } catch (WorkflowException expected) { + // Expected — the workflow propagates the non-retryable failure. + } + + assertEquals( + 1, + model.callCount.get(), + "NonTransientAiException must not be retried by the default policy"); + } + + @Test + void defaultFactory_retriesTransientExceptions() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); + // First 2 calls throw a plain RuntimeException (transient to Temporal); 3rd succeeds. + CountingChatModel model = + new CountingChatModel(new RuntimeException("flaky"), new RuntimeException("flaky"), null); + worker.registerActivitiesImplementations(new ChatModelActivityImpl(model)); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + String result = workflow.chat("hello"); + assertEquals("ok", result); + assertEquals( + 3, model.callCount.get(), "Transient RuntimeException should be retried up to 3 attempts"); + } + + @Test + void customOptions_landOnScheduledActivity() { + // Worker on the default task queue runs the workflow. A second worker on the *custom* task + // queue runs the chat activity — the chat stub's task queue override is what routes there. + Worker workflowWorker = testEnv.newWorker(TASK_QUEUE); + workflowWorker.registerWorkflowImplementationTypes(CustomQueueChatWorkflowImpl.class); + + Worker activityWorker = testEnv.newWorker(CUSTOM_TASK_QUEUE); + activityWorker.registerActivitiesImplementations( + new ChatModelActivityImpl(new FixedChatModel("routed"))); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + assertEquals("routed", workflow.chat("hi")); + + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + List events = client.fetchHistory(workflowId).getHistory().getEventsList(); + String scheduledOn = findScheduledActivityTaskQueue(events); + assertTrue( + CUSTOM_TASK_QUEUE.equals(scheduledOn), + "callChatModel activity should have been scheduled on the custom task queue, but was: " + + scheduledOn); + } + + private static String findScheduledActivityTaskQueue(List events) { + for (HistoryEvent e : events) { + if (!e.hasActivityTaskScheduledEventAttributes()) continue; + var attrs = e.getActivityTaskScheduledEventAttributes(); + if ("CallChatModel".equals(attrs.getActivityType().getName())) { + return attrs.getTaskQueue().getName(); + } + } + return null; + } + + @WorkflowInterface + public interface ChatWorkflow { + @WorkflowMethod + String chat(String message); + } + + public static class ChatWorkflowImpl implements ChatWorkflow { + @Override + public String chat(String message) { + ActivityChatModel model = ActivityChatModel.forDefault(); + ChatClient chat = TemporalChatClient.builder(model).build(); + return chat.prompt().user(message).call().content(); + } + } + + public static class CustomQueueChatWorkflowImpl implements ChatWorkflow { + @Override + public String chat(String message) { + ActivityOptions opts = + ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setTaskQueue(CUSTOM_TASK_QUEUE) + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(1) + .build()) // keep this test fast — don't retry on surprise failures + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build(); + ActivityChatModel model = ActivityChatModel.forDefault(opts); + ChatClient chat = TemporalChatClient.builder(model).build(); + return chat.prompt().user(message).call().content(); + } + } + + /** + * ChatModel whose behavior is scripted by a queue of outcomes. Each outcome is either a Throwable + * (thrown on that call) or null (return "ok"). Extra calls after the queue empties replay the + * last outcome. + */ + private static class CountingChatModel implements ChatModel { + final AtomicInteger callCount = new AtomicInteger(0); + private final Object[] outcomes; + + CountingChatModel(Object... outcomes) { + this.outcomes = outcomes; + } + + @Override + public ChatResponse call(Prompt prompt) { + int i = callCount.getAndIncrement(); + Object outcome = outcomes[Math.min(i, outcomes.length - 1)]; + if (outcome instanceof RuntimeException re) { + throw re; + } + return ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage("ok")))) + .build(); + } + + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + throw new UnsupportedOperationException(); + } + } + + private static class FixedChatModel implements ChatModel { + private final String content; + + FixedChatModel(String content) { + this.content = content; + } + + @Override + public ChatResponse call(Prompt prompt) { + return ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage(content)))) + .build(); + } + + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java index bf0efebba..a7d11c070 100644 --- a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java @@ -80,6 +80,35 @@ void chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt() { "Summary must not include user prompt content, got: " + summary); } + /** + * Regression guard: `forDefault(ActivityOptions)` must thread its options through the + * summary-bearing constructor so custom-options users still get UI labels. + */ + @Test + void chatActivity_customOptions_stillCarriesSummary() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(CustomOptionsChatWorkflowImpl.class); + worker.registerActivitiesImplementations( + new ChatModelActivityImpl(new StubChatModel("Bonjour!"))); + testEnv.start(); + + SummaryTestWorkflow workflow = + client.newWorkflowStub( + SummaryTestWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + workflow.chat("Parlez-vous francais?"); + + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + List events = client.fetchHistory(workflowId).getHistory().getEventsList(); + + String summary = findChatActivitySummary(events); + assertNotNull( + summary, "forDefault(ActivityOptions) should still attach a Summary to the chat activity"); + assertTrue( + summary.startsWith("chat: default"), + "Summary should start with 'chat: default' but was: " + summary); + } + private static String findChatActivitySummary(List events) { for (HistoryEvent event : events) { if (!event.hasActivityTaskScheduledEventAttributes()) { @@ -114,6 +143,20 @@ public String chat(String message) { } } + public static class CustomOptionsChatWorkflowImpl implements SummaryTestWorkflow { + @Override + public String chat(String message) { + io.temporal.activity.ActivityOptions options = + io.temporal.activity.ActivityOptions.newBuilder( + ActivityChatModel.defaultActivityOptions()) + .setStartToCloseTimeout(java.time.Duration.ofMinutes(5)) + .build(); + ActivityChatModel chatModel = ActivityChatModel.forDefault(options); + ChatClient chatClient = TemporalChatClient.builder(chatModel).build(); + return chatClient.prompt().user(message).call().content(); + } + } + private static class StubChatModel implements ChatModel { private final String response; From 785a185bd3156115bac043f3f4109a55d480754c Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 12:07:14 -0400 Subject: [PATCH 13/14] temporal-spring-ai: drop PLAN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/PLAN.md | 142 ------------------------------------- 1 file changed, 142 deletions(-) delete mode 100644 temporal-spring-ai/PLAN.md diff --git a/temporal-spring-ai/PLAN.md b/temporal-spring-ai/PLAN.md deleted file mode 100644 index e1f1768d9..000000000 --- a/temporal-spring-ai/PLAN.md +++ /dev/null @@ -1,142 +0,0 @@ -# Plan: Retry classification + ActivityOptions acceptance - -## Scope - -Two related production-readiness gaps: - -1. `ActivityChatModel.forDefault()` / `forModel(name)` hard-code - `maxAttempts=3` with no `nonRetryableErrorTypes`. A bad API key, 400 Bad - Request, or invalid-prompt error churns through retries + exponential - backoff before failing. `ActivityMcpClient.create()` has the same issue. -2. The factory API only lets users override timeout + maxAttempts. There's - no way to pass heartbeats, a specific task queue, priorities, - `doNotRetry`, or a custom Summary. Users who need any of these today - have to bypass the factories entirely and construct their own - `Workflow.newActivityStub(ChatModelActivity.class, ...)`. - -### Interaction with the `spring-ai/activity-summaries` branch - -The summaries branch landed a plugin-owned `ActivityOptions` store on -`ActivityChatModel` and `ActivityMcpClient`: the factories (`forModel`, -`create`) now retain the options they built the stub with, and the per-call -path overlays a Summary on top. The bare public constructors -(`new ActivityChatModel(stub)`, `new ActivityMcpClient(activity)`) receive -no options and therefore emit no summary — silently. That's a real UX wart: -anyone who rolls their own stub today loses Summary labels on the chat / -MCP rows in the Temporal UI. - -The `ActivityOptions` factory overloads this branch adds **are the proper -fix** for that wart. Users who need non-default timeouts / retries / task -queue / heartbeats today fall back to the public constructor to get those; -giving them a factory that takes `ActivityOptions` means they keep -summaries while still customizing the stub. This branch should therefore: - -- Explicitly document (in `ActivityChatModel` and `ActivityMcpClient` - javadocs, and the README) that Summary labels appear only when the stub - is built via one of the factories, and point at the new overloads as the - recommended path for customization. -- Remove the public constructors outright rather than marking them - `@Deprecated`. This module is still pre-release (public preview, no - released artifact), so there are no pinned callers to migrate — the - deprecate-then-remove dance is pure noise. - -## Files to change - -- `src/main/java/io/temporal/springai/model/ActivityChatModel.java` - - Add `forDefault(ActivityOptions options)` and - `forModel(String modelName, ActivityOptions options)` overloads. - - Change the existing `forModel(modelName, timeout, maxAttempts)` to build - an `ActivityOptions` internally that includes a default - `RetryOptions.setDoNotRetry(...)` list (see §Retry classification below) - and delegate to the new `ActivityOptions` overload. - - The new overloads pass their `ActivityOptions` into the existing - private `(stub, modelName, baseOptions)` constructor added by the - summaries branch, so Summary labels work out of the box — no extra - wiring. - - Javadoc on the public `ActivityChatModel(ChatModelActivity[, String])` - constructors: add a note that callers who want UI Summaries should - prefer the new `forDefault(ActivityOptions)` / `forModel(String, - ActivityOptions)` factories. Remove them outright (this module has not cut a public release yet, so - there are no users pinned to those signatures to accommodate). - - Keep all existing overloads working at runtime — purely additive API. - -- `src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java` - - Same treatment: add `create(ActivityOptions options)` overload; existing - `create(timeout, maxAttempts)` delegates. - - Thread the passed `ActivityOptions` through the private - `(activity, baseOptions)` constructor added by the summaries branch so - `callTool(..., summary)` overlays work. - - Remove the public `new ActivityMcpClient(activity)` constructor - outright for the same reason. - -- `README.md` - - Brief note in the quick-start and the "Tool Types" section that chat - and MCP rows in the Temporal UI are labeled with Summaries when (and - only when) built via the factories. - -### Retry classification - -Default `doNotRetry` list for chat model activities should cover clearly -permanent failures from Spring AI. Candidates (need to verify exact FQCNs -against spring-ai 1.1): - -- `org.springframework.ai.retry.NonTransientAiException` -- `java.lang.IllegalArgumentException` (e.g. unknown model name from - `ChatModelActivityImpl.resolveChatModel`) - -Do **not** add `RuntimeException` or broad superclasses; the default Temporal -behavior should still retry on network errors, 5xx, rate-limits, timeouts. - -MCP activities: add `IllegalArgumentException` (client-not-found is already -thrown as `ApplicationFailure` with `nonRetryable=true`, which wins on its -own, so no extra entries strictly required). - -## Test plan - -- Unit test: pass an `ActivityOptions` with a custom `taskQueue` and a custom - `doNotRetry` entry; verify (via test env history inspection) the activity - was scheduled on that task queue and that a thrown - `NonTransientAiException` does not trigger retry. -- Unit test: default factory still retries on a transient `RuntimeException` - (ensure the doNotRetry list doesn't over-reach). -- Extend `ActivitySummaryTest` (added on the summaries branch) to assert a - workflow built via `ActivityChatModel.forDefault(customOptions)` still - carries the expected `chat: …` summary on its scheduled chat activity. - This guards against a regression where the new overloads silently drop - the `baseOptions` wiring. -- `WorkflowReplayer` replay check. - -## PR - -**Title:** `temporal-spring-ai: accept ActivityOptions and classify non-retryable AI errors` - -**Body:** - -``` -## What was changed -- `ActivityChatModel.forDefault(ActivityOptions)` and - `ActivityChatModel.forModel(String, ActivityOptions)` overloads added. -- `ActivityMcpClient.create(ActivityOptions)` overload added. -- Default RetryOptions for chat and MCP activities now include - `doNotRetry` entries for clearly non-transient Spring AI failures - (`NonTransientAiException`, `IllegalArgumentException`). -- The public `new ActivityChatModel(...)` and `new ActivityMcpClient(...)` - constructors are removed. The module is pre-release so there are no - pinned callers to migrate; the factories are the single entry point. -- Existing factory signatures are preserved; they delegate to the new - `ActivityOptions` overloads. - -## Why? -Previously the only way to customize the chat or MCP activity stub was -via `(timeout, maxAttempts)` — no heartbeats, task queue override, -priority, or `doNotRetry`. Users hitting those needs had to bypass the -factories and call the public constructor with a hand-built stub, which -as of the summaries branch silently drops the Temporal UI labels for -chat and MCP rows. The new `ActivityOptions` overloads close that gap: -users get full customization *and* keep the Summary labels. - -Also: transient-vs-permanent classification matters a lot for LLM calls -(a 401 shouldn't retry for minutes), and the integration guide -specifically calls out "tell Temporal which errors are retryable and -which are not." This brings the plugin in line. -``` From 006d8ad7ac715045614901de81849b40fe631e83 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 13:52:09 -0400 Subject: [PATCH 14/14] temporal-spring-ai: remove (Duration, int) factory overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that forDefault(ActivityOptions) / forModel(String, ActivityOptions) / create(ActivityOptions) exist, the (Duration, int) convenience overloads are asymmetric dead weight — they expose two of N ActivityOptions fields as positional parameters, and callers wanting anything else (heartbeats, task queue, custom retry backoff, ...) have to drop to the ActivityOptions path anyway. Removed pre-release so the API surface is consistent: no-arg → plugin defaults; ActivityOptions arg → caller options. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../springai/mcp/ActivityMcpClient.java | 14 ------------- .../springai/model/ActivityChatModel.java | 21 +++---------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 24416b0a8..d4a6af967 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -84,20 +84,6 @@ public static ActivityMcpClient create() { return create(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } - /** - * Creates an ActivityMcpClient with a custom timeout and attempt count. Other defaults - * (non-retryable error classification) are preserved. - * - *

Must be called from workflow code. - * - * @param timeout the activity start-to-close timeout - * @param maxAttempts the maximum number of retry attempts - * @return a new ActivityMcpClient - */ - public static ActivityMcpClient create(Duration timeout, int maxAttempts) { - return create(defaultActivityOptions(timeout, maxAttempts)); - } - /** * 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 diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 2f486ee74..5a86f03ab 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -113,9 +113,9 @@ public static ActivityChatModel forDefault() { /** * Creates an ActivityChatModel for the default chat model using the supplied {@link - * ActivityOptions}. Pass this when you need a specific task queue, heartbeat, priority, or a - * custom {@link RetryOptions} — anything the {@code (timeout, maxAttempts)} convenience factory - * can't express. + * ActivityOptions}. Pass this when you need to customize any field on the chat activity stub — + * timeouts, retry policy, task queue, heartbeat, priority, etc. Build on top of {@link + * #defaultActivityOptions()} to inherit the plugin's non-retryable-AI-error classification. * *

Must be called from workflow code. * @@ -140,21 +140,6 @@ public static ActivityChatModel forModel(String modelName) { return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } - /** - * Creates an ActivityChatModel for a specific chat model with a custom timeout and attempt count. - * The remaining defaults (non-retryable error classification) are preserved. - * - *

Must be called from workflow code. - * - * @param modelName the bean name of the chat model, or null for default - * @param timeout the activity start-to-close timeout - * @param maxAttempts the maximum number of retry attempts - * @return an ActivityChatModel for the specified chat model - */ - public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) { - return forModel(modelName, defaultActivityOptions(timeout, maxAttempts)); - } - /** * Creates an ActivityChatModel for a specific chat model using the supplied {@link * ActivityOptions}. The provided options are used verbatim — the plugin does not augment the