Skip to content

Commit ba3fd94

Browse files
Merge remote-tracking branch 'origin/master' into d/move-extensions
2 parents ae07187 + 911dd02 commit ba3fd94

11 files changed

Lines changed: 986 additions & 48 deletions

File tree

contrib/temporal-spring-ai/README.md

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ ActivityChatModel chatModel = ActivityChatModel.forDefault(
6464
.build());
6565
```
6666

67+
For configuration-driven per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. A key equal to `ChatModelTypes.DEFAULT_MODEL_NAME` (the literal `"default"`) acts as a global catch-all: any chat model that lacks a bean-name-specific entry — including models contributed by third-party starters that your application did not declare directly — picks up that entry.
68+
69+
```java
70+
@Bean
71+
ChatModelActivityOptions chatModelActivityOptions() {
72+
ActivityOptions fiveMinute = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
73+
.setStartToCloseTimeout(Duration.ofMinutes(5))
74+
.build();
75+
return new ChatModelActivityOptions(Map.of(
76+
ChatModelTypes.DEFAULT_MODEL_NAME, fiveMinute, // global baseline
77+
"claude", ActivityOptions.newBuilder(fiveMinute).setTaskQueue("claude-heavy").build())); // override
78+
}
79+
```
80+
81+
Keys that neither match a registered ChatModel bean name nor equal `"default"` cause plugin construction to fail, so a typo surfaces at startup.
82+
6783
`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout.
6884

6985
The Temporal UI labels chat and MCP rows with a short Summary (`chat: <model>`, `mcp: <client>.<tool>`). `ActivityChatModel` and `ActivityMcpClient` are constructed only via these factories — there is no public constructor, so users can't accidentally end up in a code path that skips UI labels. Prompt text is deliberately not included in chat summaries to avoid leaking user input (which may contain PII, credentials, or other sensitive data) into workflow history and server logs.
@@ -187,24 +203,6 @@ Raw `byte[]` media gets serialized into every chat activity's input *and* result
187203

188204
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.
189205

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-
208206
## Known limitations
209207

210208
- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead.

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

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package io.temporal.springai.activity;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
34
import io.temporal.springai.model.ChatModelTypes;
45
import io.temporal.springai.model.ChatModelTypes.Message;
56
import java.net.URI;
67
import java.net.URISyntaxException;
78
import java.util.List;
89
import java.util.Map;
910
import java.util.stream.Collectors;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
1013
import org.springframework.ai.chat.messages.*;
1114
import org.springframework.ai.chat.model.ChatModel;
1215
import org.springframework.ai.chat.model.ChatResponse;
16+
import org.springframework.ai.chat.prompt.ChatOptions;
1317
import org.springframework.ai.chat.prompt.Prompt;
1418
import org.springframework.ai.content.Media;
1519
import org.springframework.ai.model.tool.ToolCallingChatOptions;
@@ -30,6 +34,25 @@
3034
*/
3135
public class ChatModelActivityImpl implements ChatModelActivity {
3236

37+
private static final Logger log = LoggerFactory.getLogger(ChatModelActivityImpl.class);
38+
39+
/**
40+
* Reads the caller's {@link ChatOptions} back out of the serialized JSON carried on {@link
41+
* ChatModelTypes.ModelOptions}. Plain Jackson — the workflow side wrote the blob with a matching
42+
* plain {@link ObjectMapper}.
43+
*/
44+
private static final ObjectMapper OPTIONS_MAPPER =
45+
new ObjectMapper().addMixIn(ToolCallingChatOptions.class, ToolCallingChatOptionsMixin.class);
46+
47+
/**
48+
* Mirror of the mixin in {@code ActivityChatModel} so deserialization ignores the same tool-bag
49+
* properties the workflow side skipped.
50+
*/
51+
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(
52+
value = {"toolCallbacks", "toolNames", "toolContext"},
53+
ignoreUnknown = true)
54+
private abstract static class ToolCallingChatOptionsMixin {}
55+
3356
private final Map<String, ChatModel> chatModels;
3457
private final String defaultModelName;
3558

@@ -77,6 +100,32 @@ private Prompt createPrompt(ChatModelTypes.ChatModelActivityInput input) {
77100
List<org.springframework.ai.chat.messages.Message> messages =
78101
input.messages().stream().map(this::toSpringMessage).collect(Collectors.toList());
79102

103+
List<ToolCallback> toolCallbacks = stubToolCallbacks(input);
104+
105+
// Primary path: rehydrate the caller's exact ChatOptions subclass from the serialized blob.
106+
// Preserves provider-specific fields (OpenAI reasoning_effort, Anthropic thinking budget,
107+
// etc.) that aren't representable in the common ModelOptions record.
108+
ChatOptions rehydrated = tryRehydrateChatOptions(input.modelOptions());
109+
if (rehydrated instanceof ToolCallingChatOptions tcOpts) {
110+
tcOpts.setInternalToolExecutionEnabled(false);
111+
if (!toolCallbacks.isEmpty()) {
112+
tcOpts.setToolCallbacks(toolCallbacks);
113+
}
114+
return Prompt.builder().messages(messages).chatOptions(tcOpts).build();
115+
}
116+
if (rehydrated != null) {
117+
// Caller's ChatOptions isn't a ToolCallingChatOptions. Accept it as-is; tool callbacks
118+
// can't be attached via this path, but most provider options in practice are
119+
// ToolCallingChatOptions subclasses so this branch is a rare fallback.
120+
log.debug(
121+
"Rehydrated ChatOptions {} is not a ToolCallingChatOptions; tool callbacks will be"
122+
+ " omitted for this call.",
123+
rehydrated.getClass().getName());
124+
return Prompt.builder().messages(messages).chatOptions(rehydrated).build();
125+
}
126+
127+
// Fallback path: no serialized blob, or rehydration failed. Build a ToolCallingChatOptions
128+
// from the common scalar fields.
80129
ToolCallingChatOptions.Builder optionsBuilder =
81130
ToolCallingChatOptions.builder()
82131
.internalToolExecutionEnabled(false); // Let workflow handle tool execution
@@ -93,24 +142,63 @@ private Prompt createPrompt(ChatModelTypes.ChatModelActivityInput input) {
93142
if (opts.stopSequences() != null) optionsBuilder.stopSequences(opts.stopSequences());
94143
}
95144

96-
// Add tool callbacks (stubs that provide definitions but won't be executed
97-
// since internalToolExecutionEnabled is false)
98-
if (!CollectionUtils.isEmpty(input.tools())) {
99-
List<ToolCallback> toolCallbacks =
100-
input.tools().stream()
101-
.map(
102-
tool ->
103-
createStubToolCallback(
104-
tool.function().name(),
105-
tool.function().description(),
106-
tool.function().jsonSchema()))
107-
.collect(Collectors.toList());
145+
if (!toolCallbacks.isEmpty()) {
108146
optionsBuilder.toolCallbacks(toolCallbacks);
109147
}
110148

111-
ToolCallingChatOptions chatOptions = optionsBuilder.build();
149+
return Prompt.builder().messages(messages).chatOptions(optionsBuilder.build()).build();
150+
}
151+
152+
private List<ToolCallback> stubToolCallbacks(ChatModelTypes.ChatModelActivityInput input) {
153+
if (CollectionUtils.isEmpty(input.tools())) {
154+
return List.of();
155+
}
156+
return input.tools().stream()
157+
.map(
158+
tool ->
159+
createStubToolCallback(
160+
tool.function().name(),
161+
tool.function().description(),
162+
tool.function().jsonSchema()))
163+
.collect(Collectors.toList());
164+
}
112165

113-
return Prompt.builder().messages(messages).chatOptions(chatOptions).build();
166+
/**
167+
* Attempts to rehydrate the caller's exact {@link ChatOptions} subclass from the serialized blob
168+
* in {@code modelOptions}. Returns {@code null} if the blob is absent or rehydration fails, in
169+
* which case the caller should use the common-field fallback.
170+
*/
171+
private ChatOptions tryRehydrateChatOptions(ChatModelTypes.ModelOptions modelOptions) {
172+
if (modelOptions == null
173+
|| modelOptions.chatOptionsClass() == null
174+
|| modelOptions.chatOptionsJson() == null) {
175+
return null;
176+
}
177+
String className = modelOptions.chatOptionsClass();
178+
try {
179+
Class<?> cls = Class.forName(className, true, Thread.currentThread().getContextClassLoader());
180+
if (!ChatOptions.class.isAssignableFrom(cls)) {
181+
log.warn(
182+
"Serialized ChatOptions class {} is not a ChatOptions; falling back to common fields.",
183+
className);
184+
return null;
185+
}
186+
return (ChatOptions) OPTIONS_MAPPER.readValue(modelOptions.chatOptionsJson(), cls);
187+
} catch (ClassNotFoundException e) {
188+
log.warn(
189+
"Could not load ChatOptions class {} on the activity side; falling back to common"
190+
+ " fields. This typically means spring-ai-<provider> is not on this worker's"
191+
+ " classpath.",
192+
className);
193+
return null;
194+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
195+
log.warn(
196+
"Could not deserialize ChatOptions of type {} on the activity side; falling back to"
197+
+ " common fields. Cause: {}",
198+
className,
199+
e.getMessage());
200+
return null;
201+
}
114202
}
115203

116204
private org.springframework.ai.chat.messages.Message toSpringMessage(Message message) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.temporal.springai.autoconfigure;
2+
3+
import io.temporal.activity.ActivityOptions;
4+
import io.temporal.springai.model.ActivityChatModel;
5+
import io.temporal.springai.plugin.SpringAiPlugin;
6+
import java.util.Map;
7+
8+
/**
9+
* Spring-bean wrapper for the per-model {@link ActivityOptions} map consumed by {@link
10+
* SpringAiPlugin}. Inject this type into your config by declaring a bean that returns an instance
11+
* of it:
12+
*
13+
* <pre>{@code
14+
* @Bean
15+
* ChatModelActivityOptions chatModelActivityOptions() {
16+
* return new ChatModelActivityOptions(Map.of(
17+
* "reasoning", ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
18+
* .setStartToCloseTimeout(Duration.ofMinutes(15))
19+
* .build()));
20+
* }
21+
* }</pre>
22+
*
23+
* <p>The wrapper exists so auto-configuration can inject your options by <em>type</em>, not by bean
24+
* name. Spring's default behavior for {@code Map<String, T>} injection is to collect every bean of
25+
* type {@code T} into a map keyed by bean name — which for a generic type like {@code Map<String,
26+
* ActivityOptions>} would sweep in any unrelated {@link ActivityOptions} bean you have in the
27+
* context. Having a dedicated wrapper type avoids that collision entirely.
28+
*
29+
* <p>Keys are chat-model bean names; values are the full {@link ActivityOptions} to use when {@link
30+
* ActivityChatModel#forModel(String)} / {@link ActivityChatModel#forDefault()} build the stub for
31+
* that model. Use {@link ActivityChatModel#defaultActivityOptions()} as the baseline so the
32+
* plugin's non-retryable-error classification is preserved.
33+
*
34+
* <p>A key equal to {@link io.temporal.springai.model.ChatModelTypes#DEFAULT_MODEL_NAME} (the
35+
* literal {@code "default"}) acts as a global catch-all: any chat model that lacks a
36+
* bean-name-specific entry — including models contributed by third-party starters that your
37+
* application did not declare directly — picks up the {@code "default"} entry. Keys that neither
38+
* match a registered ChatModel bean nor equal {@code "default"} cause plugin construction to fail.
39+
*
40+
* @param byModelName per-model-bean-name {@link ActivityOptions} overrides; may be empty
41+
*/
42+
public record ChatModelActivityOptions(Map<String, ActivityOptions> byModelName) {
43+
44+
public ChatModelActivityOptions {
45+
byModelName = byModelName == null ? Map.of() : Map.copyOf(byModelName);
46+
}
47+
48+
/** Returns an empty registry. */
49+
public static ChatModelActivityOptions empty() {
50+
return new ChatModelActivityOptions(Map.of());
51+
}
52+
}

contrib/temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiTemporalAutoConfiguration.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.temporal.springai.autoconfigure;
22

3+
import io.temporal.activity.ActivityOptions;
34
import io.temporal.springai.plugin.SpringAiPlugin;
45
import java.util.Map;
56
import org.springframework.ai.chat.model.ChatModel;
@@ -28,9 +29,32 @@
2829
name = {"org.springframework.ai.chat.model.ChatModel", "io.temporal.worker.Worker"})
2930
public class SpringAiTemporalAutoConfiguration {
3031

32+
/**
33+
* Builds the {@link SpringAiPlugin}. Picks up an optional {@link ChatModelActivityOptions} bean
34+
* and forwards its map to the plugin as per-model {@link ActivityOptions} overrides. The wrapper
35+
* type exists so this auto-config can inject user options <em>by type</em> — trying to inject
36+
* {@code Map<String, ActivityOptions>} directly would trigger Spring's collection-of-beans
37+
* autowiring and sweep in any unrelated {@link ActivityOptions} bean in the context.
38+
*
39+
* <p>Example user config:
40+
*
41+
* <pre>{@code
42+
* @Bean
43+
* ChatModelActivityOptions chatModelActivityOptions() {
44+
* return new ChatModelActivityOptions(Map.of(
45+
* "reasoning", ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
46+
* .setStartToCloseTimeout(Duration.ofMinutes(15))
47+
* .build()));
48+
* }
49+
* }</pre>
50+
*/
3151
@Bean
3252
public SpringAiPlugin springAiPlugin(
33-
@Autowired Map<String, ChatModel> chatModels, ObjectProvider<ChatModel> primaryChatModel) {
34-
return new SpringAiPlugin(chatModels, primaryChatModel.getIfUnique());
53+
@Autowired Map<String, ChatModel> chatModels,
54+
ObjectProvider<ChatModel> primaryChatModel,
55+
ObjectProvider<ChatModelActivityOptions> perModelOptions) {
56+
ChatModelActivityOptions options =
57+
perModelOptions.getIfAvailable(ChatModelActivityOptions::empty);
58+
return new SpringAiPlugin(chatModels, primaryChatModel.getIfUnique(), options.byModelName());
3559
}
3660
}

0 commit comments

Comments
 (0)