From 633a1fdd64aa756ed6a42f4a3651925d21ffb188 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Tue, 21 Apr 2026 15:57:29 -0400 Subject: [PATCH 1/5] =?UTF-8?q?temporal-spring-ai:=20plan=20=E2=80=94=20RE?= =?UTF-8?q?ADME=20expansion=20and=20default-model-name=20cleanup?= 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..cb7a10781 --- /dev/null +++ b/temporal-spring-ai/PLAN.md @@ -0,0 +1,84 @@ +# Plan: README expansion + naming tidy + +## Scope + +Two small, user-facing cleanups that don't fit any of the other branches. + +### 1. Consolidate the "default" model-name constant + +`SpringAiPlugin.DEFAULT_MODEL_NAME = "default"` exists, but +`ChatModelActivityImpl` hard-codes the literal `"default"` in its +single-arg constructor: + +```java +public ChatModelActivityImpl(ChatModel chatModel) { + this.chatModels = Map.of("default", chatModel); + this.defaultModelName = "default"; +} +``` + +Replace the two string literals with `SpringAiPlugin.DEFAULT_MODEL_NAME`. +If introducing a cyclic dependency is a concern (activity → plugin), move +the constant to `ChatModelTypes` instead and have both reference it there. + +### 2. README expansion + +Current README covers quick-start and tool types. Add sections for: + +- **Migrating from plain Spring AI** — a side-by-side showing a + controller/service that calls `ChatClient.builder(chatModel)` and the + same code inside a `@WorkflowInit` using + `TemporalChatClient.builder(ActivityChatModel.forDefault())`. Emphasize + the integration guide's "easy migration" goal. +- **Error handling** — brief note that chat and MCP activities have + default retry policies; how to override via the `ActivityOptions` + overload (added in `spring-ai/retry-and-options`); which Spring AI + exceptions are classified non-retryable by default. +- **Known limitations** + - `ChatClient.stream(...)` is not supported. + - `defaultToolContext` is not supported (already documented inline). + - Media `byte[]` is size-limited — prefer URI-based media (see + `spring-ai/media-size-guard`). + - Child workflow stubs aren't supported as tools (already noted in + `TemporalToolUtil` but not in README). +- **Observability** — pointer to Temporal's OpenTelemetry interceptor + docs and note that `TemporalChatClient.builder(model, registry, conv)` + accepts an `ObservationRegistry` for Spring AI-side metrics. + +## Files to change + +- `src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java` + - Replace `"default"` literals with a shared constant. +- (Optional) `src/main/java/io/temporal/springai/model/ChatModelTypes.java` + - Host the constant if moving it out of `SpringAiPlugin` to break a + cycle. +- `README.md` + - Add the four new sections above. + +## Test plan + +- No behavior change — existing tests should continue to pass. +- Spot-check the README changes render correctly in the GitHub preview. + +## PR + +**Title:** `temporal-spring-ai: README expansion and default-model-name cleanup` + +**Body:** + +``` +## What was changed +- Replaced hard-coded `"default"` model-name literal in + `ChatModelActivityImpl` with a shared constant. +- Expanded README with four new sections: migrating from plain + Spring AI, error handling / retry overrides, known limitations, + and observability pointers. + +## Why? +The magic string had already drifted once (new constant lived in +`SpringAiPlugin` but the activity kept its own copy) — consolidating +prevents the next drift. The README covered quick-start but left +adopters guessing about migration steps, failure modes, and what +isn't supported; each section here answers a question we've seen or +anticipate from early users. +``` From b248c8821b222e8ef7da0385a2f6e1a9de0f3d90 Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 11:57:00 -0400 Subject: [PATCH 2/5] temporal-spring-ai: README expansion and default-model-name consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the "default" model-name constant from SpringAiPlugin to ChatModelTypes and uses it from both SpringAiPlugin and ChatModelActivityImpl. Removes the duplicate literal in the activity's single-arg constructor (which had drifted — the constant existed but the activity kept its own copy). ChatModelTypes is the logical home: the activity package already imports it, so hosting the constant there avoids the activity → plugin import direction that would otherwise be a package cycle. README gains four new sections: - Migrating from plain Spring AI: a three-row table showing which three substitutions get an existing ChatClient-based service onto Temporal. Matches the integration guide's "easy migration" goal. - Activity options and retry behavior: documents the default timeouts and non-retryable classification, and shows the forModel(name, ActivityOptions) escape hatch for custom knobs. - Known limitations: streaming, defaultToolContext, child-workflow stubs as tools, the media byte[] cap, and the ChatOptions.copy() constraint that affects ChatClient.defaultOptions users. - Observability: pointer to the Temporal Java SDK docs plus a note that TemporalChatClient accepts a Micrometer ObservationRegistry. Co-Authored-By: Claude Opus 4.7 (1M context) --- temporal-spring-ai/README.md | 87 +++++++++++++++++++ .../activity/ChatModelActivityImpl.java | 4 +- .../springai/model/ChatModelTypes.java | 8 ++ .../springai/plugin/SpringAiPlugin.java | 8 +- .../springai/plugin/SpringAiPluginTest.java | 3 +- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/temporal-spring-ai/README.md b/temporal-spring-ai/README.md index 3fbc587a7..3b5f99177 100644 --- a/temporal-spring-ai/README.md +++ b/temporal-spring-ai/README.md @@ -97,6 +97,93 @@ public class MyTools { Auto-detected and executed as Nexus operations, similar to activity stubs. +## Migrating from plain Spring AI + +The plugin is designed so that bringing an existing Spring AI service onto Temporal is a localized change. Outside Temporal, you probably have something like: + +```java +@Service +class AssistantService { + private final ChatClient chatClient; + + AssistantService(ChatModel chatModel) { + this.chatClient = ChatClient.builder(chatModel) + .defaultSystem("You are a helpful assistant.") + .defaultTools(new WeatherTools(), new MyTools()) + .build(); + } + + String respond(String goal) { + return chatClient.prompt().user(goal).call().content(); + } +} +``` + +Inside a workflow it becomes: + +```java +@WorkflowInterface +interface AssistantWorkflow { @WorkflowMethod String respond(String goal); } + +class AssistantWorkflowImpl implements AssistantWorkflow { + private final ChatClient chatClient; + + @WorkflowInit + AssistantWorkflowImpl(String goal) { + WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts); + this.chatClient = TemporalChatClient.builder(ActivityChatModel.forDefault()) + .defaultSystem("You are a helpful assistant.") + .defaultTools(weather, new MyTools()) + .build(); + } + + @Override + public String respond(String goal) { + return chatClient.prompt().user(goal).call().content(); + } +} +``` + +Three substitutions: + +| Outside Temporal | Inside a Temporal workflow | +|---|---| +| `ChatModel chatModel` (injected) | `ActivityChatModel.forDefault()` | +| `ChatClient.builder(chatModel)` | `TemporalChatClient.builder(activityChatModel)` | +| `new WeatherTools()` for a plain POJO tool | `Workflow.newActivityStub(WeatherActivity.class, ...)` for a durable tool | + +Plain `@Tool` POJOs, `@SideEffectTool`-annotated classes, and Nexus service stubs all work the same way — see **Tool Types** above. + +## Activity options and retry behavior + +`ActivityChatModel.forDefault()` and `ActivityChatModel.forModel(name)` create the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` classified as non-retryable so a bad API key or invalid prompt fails fast. + +Override with `ActivityChatModel.forModel(name, ActivityOptions)`: + +```java +ActivityOptions opts = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setStartToCloseTimeout(Duration.ofMinutes(10)) + .setTaskQueue("reasoning-models") + .build(); +ActivityChatModel chatModel = ActivityChatModel.forModel("reasoning", opts); +``` + +For repeated per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. See that class's javadoc for the pattern. + +`ActivityMcpClient.create()` / `create(ActivityOptions)` behave the same way with a 30-second default timeout. + +## Known limitations + +- **Streaming (`chatClient.stream(...)`)** — not supported. Activity results are unary; a streaming API doesn't fit Temporal's durability model without buffering. Use `.call()` instead. +- **`defaultToolContext(Map)`** — not supported; tool context holds mutable state that can't safely cross the activity boundary. Pass required context as activity parameters or workflow state. +- **Child workflow stubs as tools** — not supported. Wrap a plain `@Tool` method that starts the child workflow via `Workflow.newChildWorkflowStub(...)` and call through to it yourself. +- **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media. +- **Provider-specific `ChatOptions` via `ChatClient.defaultOptions(...)`** — works as long as your `ChatOptions` subclass overrides `copy()` to return its own type (every real provider class does this). A subclass inheriting the default `copy()` loses its identity before the plugin sees it — same behavior as outside Temporal. + +## Observability + +`TemporalChatClient.builder(chatModel, observationRegistry, customConvention)` accepts a Micrometer `ObservationRegistry` for Spring AI-side chat client metrics. Temporal-side metrics (activity durations, retries) are emitted by the SDK's `MetricsScope` — see the [Temporal Java SDK observability docs](https://docs.temporal.io/develop/java/observability) for how to wire an OpenTelemetry or Prometheus exporter onto your workers. The two layers compose: Spring AI observations cover what the caller does; Temporal metrics cover what the scheduled activity does. + ## Optional Integrations Auto-configured when their dependencies are on the classpath: diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java b/temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java index 4eca09e67..95e9254a6 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java @@ -39,8 +39,8 @@ public class ChatModelActivityImpl implements ChatModelActivity { * @param chatModel the chat model to use */ public ChatModelActivityImpl(ChatModel chatModel) { - this.chatModels = Map.of("default", chatModel); - this.defaultModelName = "default"; + this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel); + this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME; } /** 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..f292fa63b 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 @@ -15,6 +15,14 @@ */ public final class ChatModelTypes { + /** + * The name used for the default chat model when no {@code modelName} is specified on an activity + * input or when {@link io.temporal.springai.model.ActivityChatModel#forDefault()} is called. + * Lives here rather than on {@code SpringAiPlugin} so both the activity impl and the plugin can + * reference it without the activity package importing the plugin package. + */ + public static final String DEFAULT_MODEL_NAME = "default"; + private ChatModelTypes() {} /** 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..912caba2d 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 @@ -2,6 +2,7 @@ import io.temporal.common.SimplePlugin; import io.temporal.springai.activity.ChatModelActivityImpl; +import io.temporal.springai.model.ChatModelTypes; import io.temporal.worker.Worker; import java.util.Collections; import java.util.LinkedHashMap; @@ -43,9 +44,6 @@ public class SpringAiPlugin extends SimplePlugin { private static final Logger log = LoggerFactory.getLogger(SpringAiPlugin.class); - /** The name used for the default chat model when none is specified. */ - public static final String DEFAULT_MODEL_NAME = "default"; - private final Map chatModels; private final String defaultModelName; @@ -56,8 +54,8 @@ public class SpringAiPlugin extends SimplePlugin { */ public SpringAiPlugin(ChatModel chatModel) { super("io.temporal.spring-ai"); - this.chatModels = Map.of(DEFAULT_MODEL_NAME, chatModel); - this.defaultModelName = DEFAULT_MODEL_NAME; + this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel); + this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME; } /** diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java index 869ae7e5c..92c15c4de 100644 --- a/temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java @@ -6,6 +6,7 @@ import io.temporal.springai.activity.ChatModelActivityImpl; import io.temporal.springai.activity.EmbeddingModelActivityImpl; import io.temporal.springai.activity.VectorStoreActivityImpl; +import io.temporal.springai.model.ChatModelTypes; import io.temporal.worker.Worker; import java.util.*; import java.util.stream.Collectors; @@ -92,7 +93,7 @@ void singleModelConstructor_usesDefaultModelName() { ChatModel chatModel = mock(ChatModel.class); SpringAiPlugin plugin = new SpringAiPlugin(chatModel); - assertEquals(SpringAiPlugin.DEFAULT_MODEL_NAME, plugin.getDefaultModelName()); + assertEquals(ChatModelTypes.DEFAULT_MODEL_NAME, plugin.getDefaultModelName()); assertSame(chatModel, plugin.getChatModel()); } From a293aa7d3ec40f8f1707a382a86e09130b357aec Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Wed, 22 Apr 2026 12:05:53 -0400 Subject: [PATCH 3/5] 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 | 84 -------------------------------------- 1 file changed, 84 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 cb7a10781..000000000 --- a/temporal-spring-ai/PLAN.md +++ /dev/null @@ -1,84 +0,0 @@ -# Plan: README expansion + naming tidy - -## Scope - -Two small, user-facing cleanups that don't fit any of the other branches. - -### 1. Consolidate the "default" model-name constant - -`SpringAiPlugin.DEFAULT_MODEL_NAME = "default"` exists, but -`ChatModelActivityImpl` hard-codes the literal `"default"` in its -single-arg constructor: - -```java -public ChatModelActivityImpl(ChatModel chatModel) { - this.chatModels = Map.of("default", chatModel); - this.defaultModelName = "default"; -} -``` - -Replace the two string literals with `SpringAiPlugin.DEFAULT_MODEL_NAME`. -If introducing a cyclic dependency is a concern (activity → plugin), move -the constant to `ChatModelTypes` instead and have both reference it there. - -### 2. README expansion - -Current README covers quick-start and tool types. Add sections for: - -- **Migrating from plain Spring AI** — a side-by-side showing a - controller/service that calls `ChatClient.builder(chatModel)` and the - same code inside a `@WorkflowInit` using - `TemporalChatClient.builder(ActivityChatModel.forDefault())`. Emphasize - the integration guide's "easy migration" goal. -- **Error handling** — brief note that chat and MCP activities have - default retry policies; how to override via the `ActivityOptions` - overload (added in `spring-ai/retry-and-options`); which Spring AI - exceptions are classified non-retryable by default. -- **Known limitations** - - `ChatClient.stream(...)` is not supported. - - `defaultToolContext` is not supported (already documented inline). - - Media `byte[]` is size-limited — prefer URI-based media (see - `spring-ai/media-size-guard`). - - Child workflow stubs aren't supported as tools (already noted in - `TemporalToolUtil` but not in README). -- **Observability** — pointer to Temporal's OpenTelemetry interceptor - docs and note that `TemporalChatClient.builder(model, registry, conv)` - accepts an `ObservationRegistry` for Spring AI-side metrics. - -## Files to change - -- `src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java` - - Replace `"default"` literals with a shared constant. -- (Optional) `src/main/java/io/temporal/springai/model/ChatModelTypes.java` - - Host the constant if moving it out of `SpringAiPlugin` to break a - cycle. -- `README.md` - - Add the four new sections above. - -## Test plan - -- No behavior change — existing tests should continue to pass. -- Spot-check the README changes render correctly in the GitHub preview. - -## PR - -**Title:** `temporal-spring-ai: README expansion and default-model-name cleanup` - -**Body:** - -``` -## What was changed -- Replaced hard-coded `"default"` model-name literal in - `ChatModelActivityImpl` with a shared constant. -- Expanded README with four new sections: migrating from plain - Spring AI, error handling / retry overrides, known limitations, - and observability pointers. - -## Why? -The magic string had already drifted once (new constant lived in -`SpringAiPlugin` but the activity kept its own copy) — consolidating -prevents the next drift. The README covered quick-start but left -adopters guessing about migration steps, failure modes, and what -isn't supported; each section here answers a question we've seen or -anticipate from early users. -``` From ad19d14a1a7340b6c1c18bec9db9fc353c43cd6d Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 23 Apr 2026 14:52:34 -0400 Subject: [PATCH 4/5] Update temporal-spring-ai/README.md Co-authored-by: Brian Strauch --- temporal-spring-ai/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-spring-ai/README.md b/temporal-spring-ai/README.md index 3b5f99177..2a0a4cd95 100644 --- a/temporal-spring-ai/README.md +++ b/temporal-spring-ai/README.md @@ -119,7 +119,7 @@ class AssistantService { } ``` -Inside a workflow it becomes: +Inside a Temporal Workflow it becomes: ```java @WorkflowInterface From e7e84a94f0fe00aaceac924c4b9997b319d068eb Mon Sep 17 00:00:00 2001 From: Donald Pinckney Date: Thu, 23 Apr 2026 14:53:26 -0400 Subject: [PATCH 5/5] Update README to clarify streaming support limitation Clarified the limitation regarding streaming support in chatClient. --- temporal-spring-ai/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-spring-ai/README.md b/temporal-spring-ai/README.md index 2a0a4cd95..1a29edd17 100644 --- a/temporal-spring-ai/README.md +++ b/temporal-spring-ai/README.md @@ -174,7 +174,7 @@ For repeated per-model overrides, declare a `ChatModelActivityOptions` bean and ## Known limitations -- **Streaming (`chatClient.stream(...)`)** — not supported. Activity results are unary; a streaming API doesn't fit Temporal's durability model without buffering. Use `.call()` instead. +- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead. - **`defaultToolContext(Map)`** — not supported; tool context holds mutable state that can't safely cross the activity boundary. Pass required context as activity parameters or workflow state. - **Child workflow stubs as tools** — not supported. Wrap a plain `@Tool` method that starts the child workflow via `Workflow.newChildWorkflowStub(...)` and call through to it yourself. - **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media.