Skip to content

Commit cad6172

Browse files
temporal-spring-ai: attach activity summaries for chat and MCP calls (#2852)
* temporal-spring-ai: plan — activity summaries for debuggability Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: amend plan — limit summaries to chat + MCP Narrows the activity-summaries scope to cases where the plugin itself owns activity-stub creation. Activity-backed tool calls, Nexus tool calls, and @SideEffectTool calls are now explicitly out of scope; the first two would require per-call option overrides on user-owned stubs (no clean API), and the third writes MarkerRecorded events which have no Summary field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: add ActivitySummaryTest (chat path) Runs a workflow that drives a single chat call through ActivityChatModel, fetches the resulting history, and asserts the ActivityTaskScheduled event for callChatModel carries a userMetadata Summary that starts with "chat: default" and includes the user prompt. Intentionally fails against unmodified chat code — the implementation follows in a subsequent commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: attach activity summaries for chat and MCP calls ActivityChatModel.forModel() now stores the ActivityOptions it built and, on each chat call, rebuilds the stub with a per-call Summary of the form "chat: <model> · <first 60 chars of user prompt>". When a caller passes a pre-built stub directly via the public constructors, behavior is unchanged (no options known → no summary overlay). ActivityMcpClient.create() does the same and adds a callTool(clientName, request, summary) overload. McpToolCallback passes "mcp: <client>.<tool>". Also fixes the activity-type-name casing in ActivitySummaryTest — Temporal capitalizes the first character of method-name-derived activity types, so the event carries "CallChatModel", not "callChatModel". 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> * temporal-spring-ai: drop user prompt from chat activity Summary The Summary now carries only the model label ("chat: <model>") instead of "chat: <model> · <first 60 chars of user prompt>". Including even a truncated prompt leaks whatever the prompt contains — PII, secrets, internal identifiers — into workflow history, server logs, and the Temporal UI, which is a surprising default for an observability label. An opt-in API for callers who explicitly want the prompt in the Summary can be added later if there's demand. ActivitySummaryTest.chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt asserts the Summary equals "chat: default" exactly and defensively checks that no part of the prompt leaked in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: wrap nullable fields/params in Optional<> Addresses review feedback (thread on #2852) preferring typesafe Optional<T> over nullable fields and raw null delegation. Three sites changed: - ActivityChatModel: modelName and baseOptions fields + private ctor params are now Optional<String> / Optional<ActivityOptions>. getModelName() returns Optional<String>. Public ctors and factories still accept nullable String modelName at the API boundary (matches prior javadoc: "null for default"); they normalize via Optional.ofNullable before storing. Internal readers use .map / .orElse instead of null checks. - ActivityMcpClient: the 3-arg callTool's summary parameter is now Optional<String>. The 2-arg convenience overload passes Optional.empty(). The rebuild-with-summary branch uses .isPresent() + .get() instead of null checks. - McpToolCallback.call(...) wraps its generated summary string in Optional.of(...) before passing to callTool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: use @nullable annotations instead of Optional<> Reverses the previous Optional<> commit in favor of @nullable on the nullable fields/params. Matches the dominant convention in temporal-sdk (431+ @nullable usages on public API surface) and avoids a thicker runtime story for what is fundamentally a documentation / IDE-hint concern (no NullAway in the build either way). - ActivityChatModel: modelName and baseOptions fields + private ctor params are now @nullable String / @nullable ActivityOptions. Public ctors/factories accept nullable String modelName at the API boundary directly. getModelName() returns @nullable String. - ActivityMcpClient: baseOptions field and callTool(..., summary) param use @nullable instead of Optional<>. 2-arg callTool passes null; McpToolCallback passes a plain String. - ChatModelTypes.ChatModelActivityInput record: @nullable on modelName and modelOptions fields so deserialized readers see the nullability in the signature (per the reviewer's concern about the deserialization-side typesafety). Consistent with org.springframework.lang.Nullable already used elsewhere in this module (TemporalChatClient, SpringAiPlugin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: use javax.annotation.Nullable for consistency Switch the five files in this module that imported org.springframework.lang.Nullable over to javax.annotation.Nullable to match the dominant convention in sdk-java (197 usages vs. 7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d3c157c commit cad6172

7 files changed

Lines changed: 247 additions & 32 deletions

File tree

temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import io.micrometer.observation.ObservationRegistry;
44
import io.temporal.springai.util.TemporalToolUtil;
55
import java.util.Map;
6+
import javax.annotation.Nullable;
67
import org.springframework.ai.chat.client.ChatClient;
78
import org.springframework.ai.chat.client.DefaultChatClient;
89
import org.springframework.ai.chat.client.DefaultChatClientBuilder;
910
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
1011
import org.springframework.ai.chat.model.ChatModel;
11-
import org.springframework.lang.Nullable;
1212
import org.springframework.util.Assert;
1313

1414
/**

temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.temporal.workflow.Workflow;
77
import java.time.Duration;
88
import java.util.Map;
9+
import javax.annotation.Nullable;
910

1011
/**
1112
* A workflow-safe wrapper for MCP (Model Context Protocol) client operations.
@@ -48,6 +49,7 @@ public class ActivityMcpClient {
4849
public static final int DEFAULT_MAX_ATTEMPTS = 3;
4950

5051
private final McpClientActivity activity;
52+
@Nullable private final ActivityOptions baseOptions;
5153
private Map<String, McpSchema.ServerCapabilities> serverCapabilities;
5254
private Map<String, McpSchema.Implementation> clientInfo;
5355

@@ -57,7 +59,18 @@ public class ActivityMcpClient {
5759
* @param activity the activity stub for MCP operations
5860
*/
5961
public ActivityMcpClient(McpClientActivity activity) {
62+
this(activity, null);
63+
}
64+
65+
/**
66+
* Creates a new ActivityMcpClient. When {@code baseOptions} is non-null, {@link #callTool(String,
67+
* McpSchema.CallToolRequest, String)} rebuilds the activity stub with a per-call Summary on top
68+
* of those options. When null, the caller supplied a pre-built stub whose options we don't know,
69+
* so we call through it as-is and drop any requested summary.
70+
*/
71+
private ActivityMcpClient(McpClientActivity activity, @Nullable ActivityOptions baseOptions) {
6072
this.activity = activity;
73+
this.baseOptions = baseOptions;
6174
}
6275

6376
/**
@@ -81,14 +94,13 @@ public static ActivityMcpClient create() {
8194
* @return a new ActivityMcpClient
8295
*/
8396
public static ActivityMcpClient create(Duration timeout, int maxAttempts) {
84-
McpClientActivity activity =
85-
Workflow.newActivityStub(
86-
McpClientActivity.class,
87-
ActivityOptions.newBuilder()
88-
.setStartToCloseTimeout(timeout)
89-
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
90-
.build());
91-
return new ActivityMcpClient(activity);
97+
ActivityOptions options =
98+
ActivityOptions.newBuilder()
99+
.setStartToCloseTimeout(timeout)
100+
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
101+
.build();
102+
McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options);
103+
return new ActivityMcpClient(activity, options);
92104
}
93105

94106
/**
@@ -127,6 +139,32 @@ public Map<String, McpSchema.Implementation> getClientInfo() {
127139
* @return the tool call result
128140
*/
129141
public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRequest request) {
142+
return callTool(clientName, request, null);
143+
}
144+
145+
/**
146+
* Calls a tool on a specific MCP client, attaching the given activity Summary to the scheduled
147+
* activity so it renders meaningfully in the Temporal UI. Falls back to the base stub when no
148+
* {@link ActivityOptions} are known (e.g. when this client was constructed from a user-supplied
149+
* stub rather than one of the {@link #create} factories).
150+
*
151+
* @param clientName the name of the MCP client
152+
* @param request the tool call request
153+
* @param summary the activity Summary, or null to omit
154+
* @return the tool call result
155+
*/
156+
public McpSchema.CallToolResult callTool(
157+
String clientName, McpSchema.CallToolRequest request, @Nullable String summary) {
158+
// Overlay the summary onto a fresh stub only when both a summary is requested AND we have
159+
// a recipe to rebuild the stub from (baseOptions). If either is missing, fall through to
160+
// the cached activity — it already has baseOptions baked in if we knew them at construction.
161+
if (summary != null && baseOptions != null) {
162+
McpClientActivity stub =
163+
Workflow.newActivityStub(
164+
McpClientActivity.class,
165+
ActivityOptions.newBuilder(baseOptions).setSummary(summary).build());
166+
return stub.callTool(clientName, request);
167+
}
130168
return activity.callTool(clientName, request);
131169
}
132170

temporal-spring-ai/src/main/java/io/temporal/springai/mcp/McpToolCallback.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ public String call(String toolInput) {
106106

107107
// Use the original tool name (not prefixed) when calling the MCP server
108108
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(tool.name(), arguments);
109-
McpSchema.CallToolResult result = client.callTool(clientName, request);
109+
String summary = "mcp: " + clientName + "." + tool.name();
110+
McpSchema.CallToolResult result = client.callTool(clientName, request, summary);
110111

111112
// Return the result as-is (including errors) so the AI can handle them.
112113
// For example, an "access denied" error lets the AI suggest a valid path.

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

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.List;
1111
import java.util.Map;
1212
import java.util.stream.Collectors;
13+
import javax.annotation.Nullable;
1314
import org.springframework.ai.chat.messages.*;
1415
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
1516
import org.springframework.ai.chat.model.ChatModel;
@@ -84,7 +85,8 @@ public class ActivityChatModel implements ChatModel {
8485
public static final int DEFAULT_MAX_ATTEMPTS = 3;
8586

8687
private final ChatModelActivity chatModelActivity;
87-
private final String modelName;
88+
@Nullable private final String modelName;
89+
@Nullable private final ActivityOptions baseOptions;
8890
private final ToolCallingManager toolCallingManager;
8991
private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate;
9092

@@ -94,7 +96,7 @@ public class ActivityChatModel implements ChatModel {
9496
* @param chatModelActivity the activity stub for calling the chat model
9597
*/
9698
public ActivityChatModel(ChatModelActivity chatModelActivity) {
97-
this(chatModelActivity, null);
99+
this(chatModelActivity, null, null);
98100
}
99101

100102
/**
@@ -103,9 +105,24 @@ public ActivityChatModel(ChatModelActivity chatModelActivity) {
103105
* @param chatModelActivity the activity stub for calling the chat model
104106
* @param modelName the name of the chat model to use, or null for default
105107
*/
106-
public ActivityChatModel(ChatModelActivity chatModelActivity, String modelName) {
108+
public ActivityChatModel(ChatModelActivity chatModelActivity, @Nullable String modelName) {
109+
this(chatModelActivity, modelName, null);
110+
}
111+
112+
/**
113+
* Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code
114+
* baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top
115+
* of those options so the Temporal UI can label the chat activity meaningfully. When null, the
116+
* caller supplied a pre-built stub whose options we don't know, so we call through it as-is
117+
* without a summary.
118+
*/
119+
private ActivityChatModel(
120+
ChatModelActivity chatModelActivity,
121+
@Nullable String modelName,
122+
@Nullable ActivityOptions baseOptions) {
107123
this.chatModelActivity = chatModelActivity;
108124
this.modelName = modelName;
125+
this.baseOptions = baseOptions;
109126
this.toolCallingManager = ToolCallingManager.builder().build();
110127
this.toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate();
111128
}
@@ -150,22 +167,22 @@ public static ActivityChatModel forModel(String modelName) {
150167
* @param maxAttempts the maximum number of retry attempts
151168
* @return an ActivityChatModel for the specified chat model
152169
*/
153-
public static ActivityChatModel forModel(String modelName, Duration timeout, int maxAttempts) {
154-
ChatModelActivity activity =
155-
Workflow.newActivityStub(
156-
ChatModelActivity.class,
157-
ActivityOptions.newBuilder()
158-
.setStartToCloseTimeout(timeout)
159-
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
160-
.build());
161-
return new ActivityChatModel(activity, modelName);
170+
public static ActivityChatModel forModel(
171+
@Nullable String modelName, Duration timeout, int maxAttempts) {
172+
ActivityOptions options =
173+
ActivityOptions.newBuilder()
174+
.setStartToCloseTimeout(timeout)
175+
.setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build())
176+
.build();
177+
ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options);
178+
return new ActivityChatModel(activity, modelName, options);
162179
}
163180

164181
/**
165-
* Returns the name of the chat model this instance uses.
166-
*
167-
* @return the model name, or null if using the default model
182+
* Returns the name of the chat model this instance uses, or null if it uses the plugin default
183+
* (the {@code @Primary} {@code ChatModel} bean or the first one registered).
168184
*/
185+
@Nullable
169186
public String getModelName() {
170187
return modelName;
171188
}
@@ -193,7 +210,8 @@ public ChatResponse call(Prompt prompt) {
193210
private ChatResponse internalCall(Prompt prompt) {
194211
// Convert prompt to activity input and call the activity
195212
ChatModelTypes.ChatModelActivityInput input = createActivityInput(prompt);
196-
ChatModelTypes.ChatModelActivityOutput output = chatModelActivity.callChatModel(input);
213+
ChatModelActivity stub = stubForCall(prompt);
214+
ChatModelTypes.ChatModelActivityOutput output = stub.callChatModel(input);
197215

198216
// Convert activity output to ChatResponse
199217
ChatResponse response = toResponse(output);
@@ -219,6 +237,25 @@ private ChatResponse internalCall(Prompt prompt) {
219237
return response;
220238
}
221239

240+
private ChatModelActivity stubForCall(Prompt prompt) {
241+
if (baseOptions == null) {
242+
return chatModelActivity;
243+
}
244+
ActivityOptions withSummary =
245+
ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build();
246+
return Workflow.newActivityStub(ChatModelActivity.class, withSummary);
247+
}
248+
249+
/**
250+
* Builds the activity Summary. Intentionally omits the user prompt — including even a truncated
251+
* slice would leak whatever the prompt contains (PII, secrets, internal identifiers) into
252+
* workflow history, server logs, and the Temporal UI, which is a surprising default for a plain
253+
* observability label.
254+
*/
255+
private String buildSummary() {
256+
return "chat: " + (modelName != null ? modelName : "default");
257+
}
258+
222259
private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt) {
223260
// Convert messages
224261
List<ChatModelTypes.Message> messages =

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.fasterxml.jackson.annotation.JsonProperty;
77
import java.time.Duration;
88
import java.util.List;
9+
import javax.annotation.Nullable;
910

1011
/**
1112
* Serializable types for chat model activity requests and responses.
@@ -20,21 +21,23 @@ private ChatModelTypes() {}
2021
/**
2122
* Input to the chat model activity.
2223
*
23-
* @param modelName the name of the chat model bean to use (null for default)
24+
* @param modelName the name of the chat model bean to use, or null for the activity-side default
25+
* model
2426
* @param messages the conversation messages
25-
* @param modelOptions options for the chat model (temperature, max tokens, etc.)
27+
* @param modelOptions options for the chat model (temperature, max tokens, etc.), or null to use
28+
* the chat model's own defaults
2629
* @param tools tool definitions the model may call
2730
*/
2831
@JsonInclude(JsonInclude.Include.NON_NULL)
2932
@JsonIgnoreProperties(ignoreUnknown = true)
3033
public record ChatModelActivityInput(
31-
@JsonProperty("model_name") String modelName,
34+
@JsonProperty("model_name") @Nullable String modelName,
3235
@JsonProperty("messages") List<Message> messages,
33-
@JsonProperty("model_options") ModelOptions modelOptions,
36+
@JsonProperty("model_options") @Nullable ModelOptions modelOptions,
3437
@JsonProperty("tools") List<FunctionTool> tools) {
3538
/** Creates input for the default chat model. */
3639
public ChatModelActivityInput(
37-
List<Message> messages, ModelOptions modelOptions, List<FunctionTool> tools) {
40+
List<Message> messages, @Nullable ModelOptions modelOptions, List<FunctionTool> tools) {
3841
this(null, messages, modelOptions, tools);
3942
}
4043
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
import java.util.LinkedHashMap;
88
import java.util.Map;
99
import javax.annotation.Nonnull;
10+
import javax.annotation.Nullable;
1011
import org.slf4j.Logger;
1112
import org.slf4j.LoggerFactory;
1213
import org.springframework.ai.chat.model.ChatModel;
13-
import org.springframework.lang.Nullable;
1414

1515
/**
1616
* Core Temporal plugin that registers {@link io.temporal.springai.activity.ChatModelActivity} with

0 commit comments

Comments
 (0)