Skip to content

Commit 1cd021a

Browse files
Merge remote-tracking branch 'origin/master' into spring-ai/per-model-timeouts
# Conflicts: # temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java
2 parents 1b7f04b + c402f55 commit 1cd021a

6 files changed

Lines changed: 106 additions & 11 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/ActivityChatModel.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ private ActivityChatModel(
110110
*
111111
* <ol>
112112
* <li>An entry registered on {@link SpringAiPlugin} under {@link
113-
* SpringAiPlugin#DEFAULT_MODEL_NAME} in the per-model {@code ActivityOptions} map, if any.
113+
* ChatModelTypes#DEFAULT_MODEL_NAME} in the per-model {@code ActivityOptions} map, if any.
114114
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
115115
* clearly permanent AI errors marked non-retryable).
116116
* </ol>
@@ -124,7 +124,7 @@ private ActivityChatModel(
124124
*/
125125
public static ActivityChatModel forDefault() {
126126
ActivityOptions options =
127-
SpringAiPluginOptions.optionsFor(SpringAiPlugin.DEFAULT_MODEL_NAME)
127+
SpringAiPluginOptions.optionsFor(ChatModelTypes.DEFAULT_MODEL_NAME)
128128
.orElseGet(ActivityChatModel::defaultActivityOptions);
129129
return forDefault(options);
130130
}

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: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.temporal.activity.ActivityOptions;
44
import io.temporal.common.SimplePlugin;
55
import io.temporal.springai.activity.ChatModelActivityImpl;
6+
import io.temporal.springai.model.ChatModelTypes;
67
import io.temporal.worker.Worker;
78
import java.util.Collections;
89
import java.util.LinkedHashMap;
@@ -44,9 +45,6 @@ public class SpringAiPlugin extends SimplePlugin {
4445

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

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

@@ -56,7 +54,7 @@ public class SpringAiPlugin extends SimplePlugin {
5654
* @param chatModel the Spring AI chat model to wrap as an activity
5755
*/
5856
public SpringAiPlugin(ChatModel chatModel) {
59-
this(Map.of(DEFAULT_MODEL_NAME, chatModel), null, Map.of());
57+
this(Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel), null, Map.of());
6058
}
6159

6260
/**
@@ -74,8 +72,9 @@ public SpringAiPlugin(Map<String, ChatModel> chatModels, @Nullable ChatModel pri
7472
*
7573
* <p>Entries in {@code perModelOptions} are keyed by chat-model bean name and consulted by {@link
7674
* io.temporal.springai.model.ActivityChatModel#forModel(String)} (and by {@link
77-
* io.temporal.springai.model.ActivityChatModel#forDefault()} via {@link #DEFAULT_MODEL_NAME}).
78-
* Callers who pass explicit {@code ActivityOptions} to a factory bypass this map entirely.
75+
* io.temporal.springai.model.ActivityChatModel#forDefault()} via {@link
76+
* ChatModelTypes#DEFAULT_MODEL_NAME}). Callers who pass explicit {@code ActivityOptions} to a
77+
* factory bypass this map entirely.
7978
*
8079
* @param chatModels map of bean names to ChatModel instances
8180
* @param primaryChatModel the primary chat model (used to determine default), or null

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)