Skip to content

Commit 64d5b2e

Browse files
temporal-spring-ai: guard large media byte[] from entering workflow history (#2860)
* temporal-spring-ai: plan — media byte[] size guard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: guard large media byte[] from entering workflow history ChatModelTypes.checkMediaSize() rejects Media byte[] payloads larger than 1 MiB (default, overridable via the 'io.temporal.springai.maxMediaBytes' system property) with a non-retryable ApplicationFailure pointing users at the URI-based Media constructor. ActivityChatModel.toMediaContent and ChatModelActivityImpl.fromMedia call the guard on both directions of the activity boundary. The guard throws a non-retryable ApplicationFailure — not a plain IllegalArgumentException — because this is a permanent, programmer-level error. Throwing a RuntimeException would cause the workflow task to be retried forever (or the activity to churn through its maxAttempts) rather than surfacing the real problem. README gains a "Media in messages" section documenting the cap, the override property, and the URI alternative. Tests: MediaSizeGuardTest covers the helper directly (unit), plus inbound/outbound activity-boundary paths via TestWorkflowEnvironment: - oversized user-message media fails the workflow with the guard error - small media passes through - URI-based media passes regardless of would-be size - assistant-echoed oversized media fails the activity 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccaf4a6 commit 64d5b2e

5 files changed

Lines changed: 312 additions & 0 deletions

File tree

temporal-spring-ai/README.md

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

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

117+
## Media in messages
118+
119+
If you attach media (images, audio, etc.) to a `UserMessage` or an `AssistantMessage`, prefer passing it by URI rather than raw bytes:
120+
121+
```java
122+
// Good — only the URL crosses the activity boundary.
123+
Media image = new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example.com/pic.png"));
124+
125+
// Works, but size-limited — see below.
126+
Media image = new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(bytes));
127+
```
128+
129+
Raw `byte[]` media gets serialized into every chat activity's input *and* result payload, which end up inside Temporal workflow history events. Server-side history events have a fixed 2 MiB size limit; to leave headroom for messages, tool definitions, and options, the plugin enforces a **1 MiB default cap** on inline media bytes and fails fast with an `IllegalArgumentException` pointing you at the URI alternative.
130+
131+
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.
132+
117133
## Optional Integrations
118134

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ private ChatModelTypes.MediaContent fromMedia(Media media) {
239239
if (media.getData() instanceof String uri) {
240240
return new ChatModelTypes.MediaContent(mimeType, uri);
241241
} else if (media.getData() instanceof byte[] data) {
242+
ChatModelTypes.checkMediaSize(data);
242243
return new ChatModelTypes.MediaContent(mimeType, data);
243244
}
244245
throw new IllegalArgumentException(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ private ChatModelTypes.MediaContent toMediaContent(Media media) {
372372
if (media.getData() instanceof String uri) {
373373
return new ChatModelTypes.MediaContent(mimeType, uri);
374374
} else if (media.getData() instanceof byte[] data) {
375+
ChatModelTypes.checkMediaSize(data);
375376
return new ChatModelTypes.MediaContent(mimeType, data);
376377
}
377378
throw new IllegalArgumentException(

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
55
import com.fasterxml.jackson.annotation.JsonInclude;
66
import com.fasterxml.jackson.annotation.JsonProperty;
7+
import io.temporal.failure.ApplicationFailure;
78
import java.time.Duration;
89
import java.util.List;
910
import javax.annotation.Nullable;
@@ -16,6 +17,50 @@
1617
*/
1718
public final class ChatModelTypes {
1819

20+
/**
21+
* Maximum size, in bytes, of a single {@link MediaContent#data()} byte array carried across the
22+
* chat activity boundary. Bytes above this threshold land inside workflow history events, which
23+
* have a fixed 2 MiB per-event limit on the Temporal server. 1 MiB leaves headroom for the rest
24+
* of a chat payload (messages, tool definitions, options).
25+
*
26+
* <p>Users who want to raise or lower the cap can set the system property {@code
27+
* io.temporal.springai.maxMediaBytes} to a positive integer before the chat activity runs; values
28+
* &lt;= 0 disable the guard entirely. For most workloads, pass media by URI instead — write the
29+
* bytes to a binary store from an activity, and pass only the URL across the conversation.
30+
*/
31+
public static final long MAX_MEDIA_BYTES_IN_HISTORY =
32+
Long.getLong("io.temporal.springai.maxMediaBytes", 1L * 1024 * 1024);
33+
34+
/** Failure type on the {@link ApplicationFailure} thrown by {@link #checkMediaSize(byte[])}. */
35+
public static final String MEDIA_SIZE_EXCEEDED_FAILURE_TYPE = "MediaSizeExceeded";
36+
37+
/**
38+
* Throws a non-retryable {@link ApplicationFailure} if {@code data} exceeds {@link
39+
* #MAX_MEDIA_BYTES_IN_HISTORY}. Non-retryable because this is a permanent, programmer-level error
40+
* — retrying the same oversized payload will never succeed, and using a plain {@link
41+
* RuntimeException} here would cause the workflow task to be retried forever (or the activity to
42+
* churn through its {@code maxAttempts}) rather than surfacing the real problem. The failure
43+
* message points the caller at the URI-based {@code Media} constructor. Pass-through otherwise.
44+
*/
45+
public static void checkMediaSize(byte[] data) {
46+
if (data == null) {
47+
return;
48+
}
49+
long limit = MAX_MEDIA_BYTES_IN_HISTORY;
50+
if (limit > 0 && data.length > limit) {
51+
throw ApplicationFailure.newNonRetryableFailure(
52+
"Media byte[] is "
53+
+ data.length
54+
+ " bytes, which exceeds the "
55+
+ limit
56+
+ "-byte limit for inline media in Temporal workflow history. Pass the media by "
57+
+ "URI instead: store the bytes outside the workflow (e.g. S3) and construct "
58+
+ "Media(mimeType, URI). Set the system property "
59+
+ "'io.temporal.springai.maxMediaBytes' to override this limit (or 0 to disable).",
60+
MEDIA_SIZE_EXCEEDED_FAILURE_TYPE);
61+
}
62+
}
63+
1964
private ChatModelTypes() {}
2065

2166
/**
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package io.temporal.springai;
2+
3+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
import static org.junit.jupiter.api.Assertions.assertTrue;
7+
8+
import io.temporal.client.WorkflowClient;
9+
import io.temporal.client.WorkflowException;
10+
import io.temporal.client.WorkflowOptions;
11+
import io.temporal.failure.ApplicationFailure;
12+
import io.temporal.springai.activity.ChatModelActivityImpl;
13+
import io.temporal.springai.model.ActivityChatModel;
14+
import io.temporal.springai.model.ChatModelTypes;
15+
import io.temporal.testing.TestWorkflowEnvironment;
16+
import io.temporal.worker.Worker;
17+
import io.temporal.workflow.WorkflowInterface;
18+
import io.temporal.workflow.WorkflowMethod;
19+
import java.net.URI;
20+
import java.util.List;
21+
import org.junit.jupiter.api.AfterEach;
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
import org.springframework.ai.chat.messages.AssistantMessage;
25+
import org.springframework.ai.chat.messages.UserMessage;
26+
import org.springframework.ai.chat.model.ChatModel;
27+
import org.springframework.ai.chat.model.ChatResponse;
28+
import org.springframework.ai.chat.model.Generation;
29+
import org.springframework.ai.chat.prompt.Prompt;
30+
import org.springframework.ai.content.Media;
31+
import org.springframework.core.io.ByteArrayResource;
32+
import org.springframework.util.MimeType;
33+
import org.springframework.util.MimeTypeUtils;
34+
35+
/**
36+
* Unit tests around {@link ChatModelTypes#checkMediaSize(byte[])} plus integration-style tests
37+
* against a live TestWorkflowEnvironment to make sure the guard fires on both the inbound (workflow
38+
* → activity) and outbound (activity → workflow) conversion paths.
39+
*/
40+
class MediaSizeGuardTest {
41+
42+
private static final String TASK_QUEUE = "test-spring-ai-media-size-guard";
43+
44+
private TestWorkflowEnvironment testEnv;
45+
private WorkflowClient client;
46+
47+
@BeforeEach
48+
void setUp() {
49+
testEnv = TestWorkflowEnvironment.newInstance();
50+
client = testEnv.getWorkflowClient();
51+
}
52+
53+
@AfterEach
54+
void tearDown() {
55+
testEnv.close();
56+
}
57+
58+
@Test
59+
void checkMediaSize_smallPayload_passes() {
60+
byte[] small = new byte[500 * 1024]; // 500 KiB, well under 1 MiB
61+
assertDoesNotThrow(() -> ChatModelTypes.checkMediaSize(small));
62+
}
63+
64+
@Test
65+
void checkMediaSize_oversizedPayload_throwsNonRetryableApplicationFailure() {
66+
byte[] big = new byte[(int) ChatModelTypes.MAX_MEDIA_BYTES_IN_HISTORY + 1];
67+
ApplicationFailure ex =
68+
assertThrows(ApplicationFailure.class, () -> ChatModelTypes.checkMediaSize(big));
69+
assertTrue(ex.isNonRetryable(), "guard must throw a non-retryable ApplicationFailure");
70+
assertEquals(ChatModelTypes.MEDIA_SIZE_EXCEEDED_FAILURE_TYPE, ex.getType());
71+
String msg = ex.getOriginalMessage();
72+
assertTrue(msg.contains("URI"), "message should point at the URI alternative: " + msg);
73+
assertTrue(
74+
msg.contains("io.temporal.springai.maxMediaBytes"),
75+
"message should mention the override system property: " + msg);
76+
}
77+
78+
@Test
79+
void checkMediaSize_null_passes() {
80+
assertDoesNotThrow(() -> ChatModelTypes.checkMediaSize(null));
81+
}
82+
83+
@Test
84+
void inboundPath_oversizedUserMessageMedia_failsTheWorkflow() {
85+
// Workflow → activity direction: the workflow builds a Prompt with a huge byte[] media,
86+
// ActivityChatModel.createActivityInput calls toMediaContent → checkMediaSize throws.
87+
Worker worker = testEnv.newWorker(TASK_QUEUE);
88+
worker.registerWorkflowImplementationTypes(BigInboundMediaWorkflowImpl.class);
89+
worker.registerActivitiesImplementations(new ChatModelActivityImpl(new StubChatModel()));
90+
testEnv.start();
91+
92+
ChatWorkflow workflow =
93+
client.newWorkflowStub(
94+
ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build());
95+
WorkflowException ex = assertThrows(WorkflowException.class, () -> workflow.chat("hi"));
96+
String message = rootMessage(ex);
97+
assertTrue(
98+
message.contains(ChatModelTypes.MEDIA_SIZE_EXCEEDED_FAILURE_TYPE)
99+
|| message.contains("-byte limit"),
100+
"expected size-guard failure, got: " + message);
101+
}
102+
103+
@Test
104+
void inboundPath_smallMedia_passes() {
105+
Worker worker = testEnv.newWorker(TASK_QUEUE);
106+
worker.registerWorkflowImplementationTypes(SmallInboundMediaWorkflowImpl.class);
107+
worker.registerActivitiesImplementations(new ChatModelActivityImpl(new StubChatModel()));
108+
testEnv.start();
109+
110+
ChatWorkflow workflow =
111+
client.newWorkflowStub(
112+
ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build());
113+
assertEquals("pong", workflow.chat("hi"));
114+
}
115+
116+
@Test
117+
void inboundPath_uriMedia_passes_regardlessOfSize() {
118+
// URI-based media is not subject to the byte[] guard — bytes stay out of workflow history.
119+
Worker worker = testEnv.newWorker(TASK_QUEUE);
120+
worker.registerWorkflowImplementationTypes(UriMediaWorkflowImpl.class);
121+
worker.registerActivitiesImplementations(new ChatModelActivityImpl(new StubChatModel()));
122+
testEnv.start();
123+
124+
ChatWorkflow workflow =
125+
client.newWorkflowStub(
126+
ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build());
127+
assertEquals("pong", workflow.chat("hi"));
128+
}
129+
130+
@Test
131+
void outboundPath_assistantEchoesOversizedMedia_failsTheActivity() {
132+
// Activity → workflow direction: the stub ChatModel returns an assistant message with a
133+
// huge byte[] media, ChatModelActivityImpl.fromMedia → checkMediaSize throws.
134+
Worker worker = testEnv.newWorker(TASK_QUEUE);
135+
worker.registerWorkflowImplementationTypes(EchoMediaWorkflowImpl.class);
136+
worker.registerActivitiesImplementations(
137+
new ChatModelActivityImpl(new BigOutboundMediaChatModel()));
138+
testEnv.start();
139+
140+
ChatWorkflow workflow =
141+
client.newWorkflowStub(
142+
ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build());
143+
WorkflowException ex = assertThrows(WorkflowException.class, () -> workflow.chat("hi"));
144+
String message = rootMessage(ex);
145+
assertTrue(
146+
message.contains("exceeds the") && message.contains("-byte limit"),
147+
"expected size-guard failure on return path, got: " + message);
148+
}
149+
150+
private static String rootMessage(Throwable t) {
151+
Throwable cur = t;
152+
while (cur.getCause() != null) {
153+
cur = cur.getCause();
154+
}
155+
return cur.getMessage() == null ? "" : cur.getMessage();
156+
}
157+
158+
@WorkflowInterface
159+
public interface ChatWorkflow {
160+
@WorkflowMethod
161+
String chat(String message);
162+
}
163+
164+
public static class BigInboundMediaWorkflowImpl implements ChatWorkflow {
165+
@Override
166+
public String chat(String message) {
167+
byte[] big = new byte[(int) ChatModelTypes.MAX_MEDIA_BYTES_IN_HISTORY + 1];
168+
UserMessage userMessage =
169+
UserMessage.builder()
170+
.text(message)
171+
.media(List.of(new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(big))))
172+
.build();
173+
ActivityChatModel chatModel = ActivityChatModel.forDefault();
174+
return chatModel.call(new Prompt(List.of(userMessage))).getResult().getOutput().getText();
175+
}
176+
}
177+
178+
public static class SmallInboundMediaWorkflowImpl implements ChatWorkflow {
179+
@Override
180+
public String chat(String message) {
181+
byte[] small = new byte[16 * 1024]; // 16 KiB
182+
UserMessage userMessage =
183+
UserMessage.builder()
184+
.text(message)
185+
.media(List.of(new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(small))))
186+
.build();
187+
ActivityChatModel chatModel = ActivityChatModel.forDefault();
188+
return chatModel.call(new Prompt(List.of(userMessage))).getResult().getOutput().getText();
189+
}
190+
}
191+
192+
public static class UriMediaWorkflowImpl implements ChatWorkflow {
193+
@Override
194+
public String chat(String message) {
195+
UserMessage userMessage =
196+
UserMessage.builder()
197+
.text(message)
198+
.media(
199+
List.of(
200+
new Media(
201+
MimeTypeUtils.IMAGE_PNG, URI.create("https://cdn.example.com/huge.png"))))
202+
.build();
203+
ActivityChatModel chatModel = ActivityChatModel.forDefault();
204+
return chatModel.call(new Prompt(List.of(userMessage))).getResult().getOutput().getText();
205+
}
206+
}
207+
208+
public static class EchoMediaWorkflowImpl implements ChatWorkflow {
209+
@Override
210+
public String chat(String message) {
211+
ActivityChatModel chatModel = ActivityChatModel.forDefault();
212+
return chatModel.call(new Prompt(message)).getResult().getOutput().getText();
213+
}
214+
}
215+
216+
/** Returns "pong" — used to verify non-failing paths. */
217+
private static class StubChatModel implements ChatModel {
218+
@Override
219+
public ChatResponse call(Prompt prompt) {
220+
return ChatResponse.builder()
221+
.generations(List.of(new Generation(new AssistantMessage("pong"))))
222+
.build();
223+
}
224+
225+
@Override
226+
public reactor.core.publisher.Flux<ChatResponse> stream(Prompt prompt) {
227+
throw new UnsupportedOperationException();
228+
}
229+
}
230+
231+
/** Returns an assistant message carrying a huge byte[] media, to trip the outbound guard. */
232+
private static class BigOutboundMediaChatModel implements ChatModel {
233+
@Override
234+
public ChatResponse call(Prompt prompt) {
235+
byte[] big = new byte[(int) ChatModelTypes.MAX_MEDIA_BYTES_IN_HISTORY + 1];
236+
AssistantMessage assistant =
237+
AssistantMessage.builder()
238+
.content("")
239+
.media(List.of(new Media(MimeType.valueOf("image/png"), new ByteArrayResource(big))))
240+
.build();
241+
return ChatResponse.builder().generations(List.of(new Generation(assistant))).build();
242+
}
243+
244+
@Override
245+
public reactor.core.publisher.Flux<ChatResponse> stream(Prompt prompt) {
246+
throw new UnsupportedOperationException();
247+
}
248+
}
249+
}

0 commit comments

Comments
 (0)