Skip to content

Commit b248c88

Browse files
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>
1 parent 633a1fd commit b248c88

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
@@ -97,6 +97,93 @@ public class MyTools {
9797

9898
Auto-detected and executed as Nexus operations, similar to activity stubs.
9999

100+
## Migrating from plain Spring AI
101+
102+
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:
103+
104+
```java
105+
@Service
106+
class AssistantService {
107+
private final ChatClient chatClient;
108+
109+
AssistantService(ChatModel chatModel) {
110+
this.chatClient = ChatClient.builder(chatModel)
111+
.defaultSystem("You are a helpful assistant.")
112+
.defaultTools(new WeatherTools(), new MyTools())
113+
.build();
114+
}
115+
116+
String respond(String goal) {
117+
return chatClient.prompt().user(goal).call().content();
118+
}
119+
}
120+
```
121+
122+
Inside a workflow it becomes:
123+
124+
```java
125+
@WorkflowInterface
126+
interface AssistantWorkflow { @WorkflowMethod String respond(String goal); }
127+
128+
class AssistantWorkflowImpl implements AssistantWorkflow {
129+
private final ChatClient chatClient;
130+
131+
@WorkflowInit
132+
AssistantWorkflowImpl(String goal) {
133+
WeatherActivity weather = Workflow.newActivityStub(WeatherActivity.class, opts);
134+
this.chatClient = TemporalChatClient.builder(ActivityChatModel.forDefault())
135+
.defaultSystem("You are a helpful assistant.")
136+
.defaultTools(weather, new MyTools())
137+
.build();
138+
}
139+
140+
@Override
141+
public String respond(String goal) {
142+
return chatClient.prompt().user(goal).call().content();
143+
}
144+
}
145+
```
146+
147+
Three substitutions:
148+
149+
| Outside Temporal | Inside a Temporal workflow |
150+
|---|---|
151+
| `ChatModel chatModel` (injected) | `ActivityChatModel.forDefault()` |
152+
| `ChatClient.builder(chatModel)` | `TemporalChatClient.builder(activityChatModel)` |
153+
| `new WeatherTools()` for a plain POJO tool | `Workflow.newActivityStub(WeatherActivity.class, ...)` for a durable tool |
154+
155+
Plain `@Tool` POJOs, `@SideEffectTool`-annotated classes, and Nexus service stubs all work the same way — see **Tool Types** above.
156+
157+
## Activity options and retry behavior
158+
159+
`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.
160+
161+
Override with `ActivityChatModel.forModel(name, ActivityOptions)`:
162+
163+
```java
164+
ActivityOptions opts = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
165+
.setStartToCloseTimeout(Duration.ofMinutes(10))
166+
.setTaskQueue("reasoning-models")
167+
.build();
168+
ActivityChatModel chatModel = ActivityChatModel.forModel("reasoning", opts);
169+
```
170+
171+
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.
172+
173+
`ActivityMcpClient.create()` / `create(ActivityOptions)` behave the same way with a 30-second default timeout.
174+
175+
## Known limitations
176+
177+
- **Streaming (`chatClient.stream(...)`)** — not supported. Activity results are unary; a streaming API doesn't fit Temporal's durability model without buffering. Use `.call()` instead.
178+
- **`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.
179+
- **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.
180+
- **Media `byte[]` size** — inline bytes are capped at 1 MiB per payload (see "Media in messages" above). Prefer URI-based media.
181+
- **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.
182+
183+
## Observability
184+
185+
`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.
186+
100187
## Optional Integrations
101188

102189
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
@@ -15,6 +15,14 @@
1515
*/
1616
public final class ChatModelTypes {
1717

18+
/**
19+
* The name used for the default chat model when no {@code modelName} is specified on an activity
20+
* input or when {@link io.temporal.springai.model.ActivityChatModel#forDefault()} is called.
21+
* Lives here rather than on {@code SpringAiPlugin} so both the activity impl and the plugin can
22+
* reference it without the activity package importing the plugin package.
23+
*/
24+
public static final String DEFAULT_MODEL_NAME = "default";
25+
1826
private ChatModelTypes() {}
1927

2028
/**

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)