Skip to content

Commit c402f55

Browse files
donald-pinckneyclaudebrianstrauch
authored
temporal-spring-ai: README expansion and default-model-name consolidation (#2861)
* temporal-spring-ai: plan — README expansion and default-model-name cleanup Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: README expansion and default-model-name consolidation 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) <noreply@anthropic.com> * temporal-spring-ai: drop PLAN.md Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update temporal-spring-ai/README.md Co-authored-by: Brian Strauch <brian@brianstrauch.com> * Update README to clarify streaming support limitation Clarified the limitation regarding streaming support in chatClient. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Brian Strauch <brian@brianstrauch.com>
1 parent 9eae4a8 commit c402f55

5 files changed

Lines changed: 102 additions & 8 deletions

File tree

temporal-spring-ai/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,63 @@ public class MyTools {
114114

115115
Auto-detected and executed as Nexus operations, similar to activity stubs.
116116

117+
## Migrating from plain Spring AI
118+
119+
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:
120+
121+
```java
122+
@Service
123+
class AssistantService {
124+
private final ChatClient chatClient;
125+
126+
AssistantService(ChatModel chatModel) {
127+
this.chatClient = ChatClient.builder(chatModel)
128+
.defaultSystem("You are a helpful assistant.")
129+
.defaultTools(new WeatherTools(), new MyTools())
130+
.build();
131+
}
132+
133+
String respond(String goal) {
134+
return chatClient.prompt().user(goal).call().content();
135+
}
136+
}
137+
```
138+
139+
Inside a Temporal Workflow it becomes:
140+
141+
```java
142+
@WorkflowInterface
143+
interface AssistantWorkflow { @WorkflowMethod String respond(String goal); }
144+
145+
class AssistantWorkflowImpl implements AssistantWorkflow {
146+
private final ChatClient chatClient;
147+
148+
@WorkflowInit
149+
AssistantWorkflowImpl(String goal) {
150+
WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts);
151+
this.chatClient = TemporalChatClient.builder(ActivityChatModel.forDefault())
152+
.defaultSystem("You are a helpful assistant.")
153+
.defaultTools(weather, new MyTools())
154+
.build();
155+
}
156+
157+
@Override
158+
public String respond(String goal) {
159+
return chatClient.prompt().user(goal).call().content();
160+
}
161+
}
162+
```
163+
164+
Three substitutions:
165+
166+
| Outside Temporal | Inside a Temporal workflow |
167+
|---|---|
168+
| `ChatModel chatModel` (injected) | `ActivityChatModel.forDefault()` |
169+
| `ChatClient.builder(chatModel)` | `TemporalChatClient.builder(activityChatModel)` |
170+
| `new WeatherTools()` for a plain POJO tool | `Workflow.newActivityStub(WeatherActivity.class, ...)` for a durable tool |
171+
172+
Plain `@Tool` POJOs, `@SideEffectTool`-annotated classes, and Nexus service stubs all work the same way — see **Tool Types** above.
173+
117174
## Media in messages
118175

119176
If you attach media (images, audio, etc.) to a `UserMessage` or an `AssistantMessage`, prefer passing it by URI rather than raw bytes:
@@ -130,6 +187,36 @@ Raw `byte[]` media gets serialized into every chat activity's input *and* result
130187

131188
Override the cap by setting the system property `io.temporal.springai.maxMediaBytes` before your worker starts (pass a positive integer; `0` disables the check). For anything larger than a small thumbnail, the URI route is the right answer — have an activity write the bytes to blob storage, then pass only the URL into the conversation.
132189

190+
## Activity options and retry behavior
191+
192+
`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.
193+
194+
Override with `ActivityChatModel.forModel(name, ActivityOptions)`:
195+
196+
```java
197+
ActivityOptions opts = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
198+
.setStartToCloseTimeout(Duration.ofMinutes(10))
199+
.setTaskQueue("reasoning-models")
200+
.build();
201+
ActivityChatModel chatModel = ActivityChatModel.forModel("reasoning", opts);
202+
```
203+
204+
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.
205+
206+
`ActivityMcpClient.create()` / `create(ActivityOptions)` behave the same way with a 30-second default timeout.
207+
208+
## Known limitations
209+
210+
- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead.
211+
- **`defaultToolContext(Map<String, Object>)`** — not supported; tool context holds mutable state that can't safely cross the activity boundary. Pass required context as activity parameters or workflow state.
212+
- **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.
213+
- **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media.
214+
- **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.
215+
216+
## Observability
217+
218+
`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.
219+
133220
## Optional Integrations
134221

135222
Auto-configured when their dependencies are on the classpath:

temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public class ChatModelActivityImpl implements ChatModelActivity {
3939
* @param chatModel the chat model to use
4040
*/
4141
public ChatModelActivityImpl(ChatModel chatModel) {
42-
this.chatModels = Map.of("default", chatModel);
43-
this.defaultModelName = "default";
42+
this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel);
43+
this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME;
4444
}
4545

4646
/**

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
*/
1818
public final class ChatModelTypes {
1919

20+
/**
21+
* The name used for the default chat model when no {@code modelName} is specified on an activity
22+
* input or when {@link io.temporal.springai.model.ActivityChatModel#forDefault()} is called.
23+
* Lives here rather than on {@code SpringAiPlugin} so both the activity impl and the plugin can
24+
* reference it without the activity package importing the plugin package.
25+
*/
26+
public static final String DEFAULT_MODEL_NAME = "default";
27+
2028
/**
2129
* Maximum size, in bytes, of a single {@link MediaContent#data()} byte array carried across the
2230
* chat activity boundary. Bytes above this threshold land inside workflow history events, which

temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.temporal.common.SimplePlugin;
44
import io.temporal.springai.activity.ChatModelActivityImpl;
5+
import io.temporal.springai.model.ChatModelTypes;
56
import io.temporal.worker.Worker;
67
import java.util.Collections;
78
import java.util.LinkedHashMap;
@@ -43,9 +44,6 @@ public class SpringAiPlugin extends SimplePlugin {
4344

4445
private static final Logger log = LoggerFactory.getLogger(SpringAiPlugin.class);
4546

46-
/** The name used for the default chat model when none is specified. */
47-
public static final String DEFAULT_MODEL_NAME = "default";
48-
4947
private final Map<String, ChatModel> chatModels;
5048
private final String defaultModelName;
5149

@@ -56,8 +54,8 @@ public class SpringAiPlugin extends SimplePlugin {
5654
*/
5755
public SpringAiPlugin(ChatModel chatModel) {
5856
super("io.temporal.spring-ai");
59-
this.chatModels = Map.of(DEFAULT_MODEL_NAME, chatModel);
60-
this.defaultModelName = DEFAULT_MODEL_NAME;
57+
this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel);
58+
this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME;
6159
}
6260

6361
/**

temporal-spring-ai/src/test/java/io/temporal/springai/plugin/SpringAiPluginTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.temporal.springai.activity.ChatModelActivityImpl;
77
import io.temporal.springai.activity.EmbeddingModelActivityImpl;
88
import io.temporal.springai.activity.VectorStoreActivityImpl;
9+
import io.temporal.springai.model.ChatModelTypes;
910
import io.temporal.worker.Worker;
1011
import java.util.*;
1112
import java.util.stream.Collectors;
@@ -92,7 +93,7 @@ void singleModelConstructor_usesDefaultModelName() {
9293
ChatModel chatModel = mock(ChatModel.class);
9394
SpringAiPlugin plugin = new SpringAiPlugin(chatModel);
9495

95-
assertEquals(SpringAiPlugin.DEFAULT_MODEL_NAME, plugin.getDefaultModelName());
96+
assertEquals(ChatModelTypes.DEFAULT_MODEL_NAME, plugin.getDefaultModelName());
9697
assertSame(chatModel, plugin.getChatModel());
9798
}
9899

0 commit comments

Comments
 (0)