This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 87dd7873 [api][java][python] Align multimodal message contracts and
safe logging (#1163)
87dd7873 is described below
commit 87dd7873928802201e166d1d13de478f38e4cdc4
Author: Wenjin Xie <[email protected]>
AuthorDate: Mon Sep 28 13:55:23 2026 +0800
[api][java][python] Align multimodal message contracts and safe logging
(#1163)
Generated-by: Codex (model/version not exposed)
Co-authored-by: Codex <[email protected]>
---
.../flink/agents/api/chat/messages/AudioBlock.java | 20 ++-
.../agents/api/chat/messages/Base64Source.java | 10 +-
.../agents/api/chat/messages/ChatMessage.java | 17 +--
.../agents/api/chat/messages/DocumentBlock.java | 20 ++-
.../flink/agents/api/chat/messages/ImageBlock.java | 20 ++-
.../flink/agents/api/chat/messages/MediaBlock.java | 3 +
.../api/chat/messages/MediaFieldDeserializers.java | 60 +++++++++
.../flink/agents/api/chat/messages/UrlSource.java | 7 +-
.../flink/agents/api/chat/messages/VideoBlock.java | 20 ++-
.../messages/ChatMessageSerializationTest.java | 146 +++++++++++++++++++++
docs/content/docs/development/chat_models.md | 47 +++++++
docs/content/docs/operations/monitoring.md | 13 +-
.../flink/agents/plan/actions/ChatModelAction.java | 3 +-
.../agents/plan/resource/python/PythonPrompt.java | 7 +-
.../agents/plan/actions/ChatModelActionTest.java | 21 +++
.../plan/resource/python/PythonPromptTest.java | 17 +++
python/flink_agents/api/chat_message.py | 27 ++--
python/flink_agents/api/tests/test_chat_message.py | 94 +++++++++++++
.../flink_agents/plan/actions/chat_model_action.py | 4 +-
.../plan/tests/actions/test_chat_model_action.py | 34 +++--
.../tests/actions/test_chat_model_action_retry.py | 30 ++++-
.../runtime/python/utils/JavaResourceAdapter.java | 7 +-
22 files changed, 557 insertions(+), 70 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
index 4ebf3a4b..503a9b61 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import javax.annotation.Nullable;
@@ -28,11 +29,22 @@ public final class AudioBlock extends MediaBlock {
@JsonCreator
public AudioBlock(
- @JsonProperty("media_type") String mediaType,
+ @JsonProperty("media_type")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String mediaType,
@JsonProperty("source") MediaSource source,
- @JsonProperty("name") @Nullable String name,
- @JsonProperty("size_bytes") @Nullable Long sizeBytes,
- @JsonProperty("sha256") @Nullable String sha256) {
+ @JsonProperty("name")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String name,
+ @JsonProperty("size_bytes")
+ @JsonDeserialize(using =
MediaFieldDeserializers.SizeBytes.class)
+ @Nullable
+ Long sizeBytes,
+ @JsonProperty("sha256")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String sha256) {
super(mediaType, source, name, sizeBytes, sha256);
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
index 22d3488a..12403613 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -31,7 +32,10 @@ public final class Base64Source extends MediaSource {
private final String data;
@JsonCreator
- public Base64Source(@JsonProperty("data") String data) {
+ public Base64Source(
+ @JsonProperty("data")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String data) {
if (data == null || data.isEmpty()) {
throw new IllegalArgumentException("A base64 source requires a
non-empty payload.");
}
@@ -47,11 +51,11 @@ public final class Base64Source extends MediaSource {
return "base64";
}
- /** The decoded byte count implied by the base64 length. */
+ /** Infers the byte count assuming valid standard Base64, without
decoding. */
@Override
public Long getSizeBytes() {
long padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
- return data.length() * 3L / 4 - padding;
+ return Math.max(0L, data.length() * 3L / 4 - padding);
}
/**
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ChatMessage.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ChatMessage.java
index 24e00932..f08a6a4d 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ChatMessage.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ChatMessage.java
@@ -59,7 +59,7 @@ public class ChatMessage {
/** Default constructor with SYSTEM role */
public ChatMessage() {
- this(MessageRole.SYSTEM, (List<ContentBlock>) null, null, null);
+ this(MessageRole.SYSTEM, Collections.emptyList(), null, null);
}
/** Constructor with role and text content */
@@ -109,9 +109,7 @@ public class ChatMessage {
/** An unmodifiable copy — since blocks are immutable, this freezes the
content. */
private static List<ContentBlock> snapshotOf(List<ContentBlock> blocks) {
- return blocks == null || blocks.isEmpty()
- ? Collections.emptyList()
- : Collections.unmodifiableList(new ArrayList<>(blocks));
+ return List.copyOf(Objects.requireNonNull(blocks, "blocks must not be
null"));
}
public MessageRole getRole() {
@@ -175,13 +173,10 @@ public class ChatMessage {
/** Replaces the content with blocks given as plain maps — see {@link
#getBlocksAsMaps()}. */
@JsonIgnore
public void setBlocksFromMaps(List<Map<String, Object>> blockMaps) {
- this.blocks =
- blockMaps == null
- ? Collections.emptyList()
- : Collections.unmodifiableList(
- blockMaps.stream()
- .map(map -> MAPPER.convertValue(map,
ContentBlock.class))
- .collect(Collectors.toList()));
+ setBlocks(
+ Objects.requireNonNull(blockMaps, "blocks must not be
null").stream()
+ .map(map -> MAPPER.convertValue(map,
ContentBlock.class))
+ .collect(Collectors.toList()));
}
/** The text projection: the ordered concatenation of this message's
{@link TextBlock}s. */
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
index 2911f007..6991355d 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import javax.annotation.Nullable;
@@ -28,11 +29,22 @@ public final class DocumentBlock extends MediaBlock {
@JsonCreator
public DocumentBlock(
- @JsonProperty("media_type") String mediaType,
+ @JsonProperty("media_type")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String mediaType,
@JsonProperty("source") MediaSource source,
- @JsonProperty("name") @Nullable String name,
- @JsonProperty("size_bytes") @Nullable Long sizeBytes,
- @JsonProperty("sha256") @Nullable String sha256) {
+ @JsonProperty("name")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String name,
+ @JsonProperty("size_bytes")
+ @JsonDeserialize(using =
MediaFieldDeserializers.SizeBytes.class)
+ @Nullable
+ Long sizeBytes,
+ @JsonProperty("sha256")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String sha256) {
super(mediaType, source, name, sizeBytes, sha256);
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
index 9b184302..9816f6dd 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import javax.annotation.Nullable;
@@ -28,11 +29,22 @@ public final class ImageBlock extends MediaBlock {
@JsonCreator
public ImageBlock(
- @JsonProperty("media_type") String mediaType,
+ @JsonProperty("media_type")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String mediaType,
@JsonProperty("source") MediaSource source,
- @JsonProperty("name") @Nullable String name,
- @JsonProperty("size_bytes") @Nullable Long sizeBytes,
- @JsonProperty("sha256") @Nullable String sha256) {
+ @JsonProperty("name")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String name,
+ @JsonProperty("size_bytes")
+ @JsonDeserialize(using =
MediaFieldDeserializers.SizeBytes.class)
+ @Nullable
+ Long sizeBytes,
+ @JsonProperty("sha256")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String sha256) {
super(mediaType, source, name, sizeBytes, sha256);
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
index dbcd49a3..28d351aa 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
@@ -69,6 +69,9 @@ public abstract class MediaBlock extends ContentBlock {
if (source == null) {
throw new IllegalArgumentException("A media block requires a
source.");
}
+ if (sizeBytes != null && sizeBytes < 0) {
+ throw new IllegalArgumentException("size_bytes must not be
negative.");
+ }
this.mediaType = mediaType;
this.source = source;
this.name = name;
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaFieldDeserializers.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaFieldDeserializers.java
new file mode 100644
index 00000000..7fc95a7f
--- /dev/null
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaFieldDeserializers.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.chat.messages;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import java.io.IOException;
+
+/** Field-local wire validation; does not change unrelated ObjectMapper
coercion rules. */
+final class MediaFieldDeserializers {
+ private MediaFieldDeserializers() {}
+
+ /** Rejects scalar coercion while allowing null for optional metadata. */
+ public static final class StringValue extends JsonDeserializer<String> {
+ @Override
+ public String deserialize(JsonParser parser, DeserializationContext
context)
+ throws IOException {
+ if (!parser.hasToken(JsonToken.VALUE_STRING)) {
+ return context.reportInputMismatch(String.class, "Expected a
JSON string.");
+ }
+ return parser.getText();
+ }
+ }
+
+ /** A non-negative signed 64-bit JSON integer; booleans, strings and
floats are rejected. */
+ public static final class SizeBytes extends JsonDeserializer<Long> {
+ @Override
+ public Long deserialize(JsonParser parser, DeserializationContext
context)
+ throws IOException {
+ if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
+ return context.reportInputMismatch(
+ Long.class, "size_bytes must be a JSON integer.");
+ }
+ long value = parser.getLongValue();
+ if (value < 0) {
+ return context.reportInputMismatch(Long.class, "size_bytes
must not be negative.");
+ }
+ return value;
+ }
+ }
+}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/UrlSource.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/UrlSource.java
index c7c010a0..7736e9e7 100644
--- a/api/src/main/java/org/apache/flink/agents/api/chat/messages/UrlSource.java
+++ b/api/src/main/java/org/apache/flink/agents/api/chat/messages/UrlSource.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.net.URI;
import java.net.URISyntaxException;
@@ -38,7 +39,9 @@ public final class UrlSource extends MediaSource {
private final String url;
@JsonCreator
- public UrlSource(@JsonProperty("url") String url) {
+ public UrlSource(
+ @JsonProperty("url") @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("A URL source requires a
non-empty URL.");
}
@@ -107,6 +110,6 @@ public final class UrlSource extends MediaSource {
@Override
public String toString() {
- return "UrlSource(" + url + ")";
+ return "UrlSource(<redacted>)";
}
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
index f9fb1aee..56e9f9e7 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
@@ -20,6 +20,7 @@ package org.apache.flink.agents.api.chat.messages;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import javax.annotation.Nullable;
@@ -28,11 +29,22 @@ public final class VideoBlock extends MediaBlock {
@JsonCreator
public VideoBlock(
- @JsonProperty("media_type") String mediaType,
+ @JsonProperty("media_type")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ String mediaType,
@JsonProperty("source") MediaSource source,
- @JsonProperty("name") @Nullable String name,
- @JsonProperty("size_bytes") @Nullable Long sizeBytes,
- @JsonProperty("sha256") @Nullable String sha256) {
+ @JsonProperty("name")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String name,
+ @JsonProperty("size_bytes")
+ @JsonDeserialize(using =
MediaFieldDeserializers.SizeBytes.class)
+ @Nullable
+ Long sizeBytes,
+ @JsonProperty("sha256")
+ @JsonDeserialize(using =
MediaFieldDeserializers.StringValue.class)
+ @Nullable
+ String sha256) {
super(mediaType, source, name, sizeBytes, sha256);
}
diff --git
a/api/src/test/java/org/apache/flink/agents/api/chat/messages/ChatMessageSerializationTest.java
b/api/src/test/java/org/apache/flink/agents/api/chat/messages/ChatMessageSerializationTest.java
index 21c535c6..07b46a57 100644
---
a/api/src/test/java/org/apache/flink/agents/api/chat/messages/ChatMessageSerializationTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/chat/messages/ChatMessageSerializationTest.java
@@ -23,6 +23,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import java.math.BigInteger;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -38,6 +42,148 @@ class ChatMessageSerializationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
+ @Test
+ void nullBlocksFailAtEveryEntryPoint() throws Exception {
+ assertThat(MAPPER.readValue("{}",
ChatMessage.class).getBlocks()).isEmpty();
+ for (String blocks : List.of("null", "[null]")) {
+ assertThatThrownBy(
+ () ->
+ MAPPER.readValue(
+ "{\"blocks\":" + blocks + "}",
ChatMessage.class))
+ .isInstanceOf(JsonMappingException.class);
+ }
+ ChatMessage message = ChatMessage.user("original");
+ assertThatThrownBy(() ->
message.setBlocks(null)).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() ->
message.setBlocks(Arrays.asList((ContentBlock) null)))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(
+ () -> new ChatMessage(MessageRole.USER,
Arrays.asList((ContentBlock) null)))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> message.setBlocksFromMaps(null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(
+ () ->
message.setBlocksFromMaps(Arrays.asList((Map<String, Object>) null)))
+ .isInstanceOf(NullPointerException.class);
+ assertThat(message.getText()).isEqualTo("original");
+ }
+
+ @Test
+ void mediaFieldsRejectScalarCoercionOnJsonAndMapPaths() throws Exception {
+ for (String kind : List.of("image", "audio", "video", "document")) {
+ for (String field : List.of("media_type", "name", "sha256",
"size_bytes")) {
+ for (Object value : List.of(true, 1.5, "1.0")) {
+ if (!field.equals("size_bytes") && value instanceof
String) {
+ continue;
+ }
+ Map<String, Object> block =
+ new HashMap<>(
+ Map.of(
+ "type",
+ kind,
+ "media_type",
+ "image/png",
+ "source",
+ Map.of("type", "base64", "data",
"aGk=")));
+ block.put(field, value);
+ assertRejectedBlock(block);
+ }
+ }
+ }
+ for (String field : List.of("media_type", "name", "sha256")) {
+ Map<String, Object> block =
+ new HashMap<>(
+ Map.of(
+ "type",
+ "image",
+ "media_type",
+ "image/png",
+ "source",
+ Map.of("type", "base64", "data", "aGk=")));
+ block.put(field, 5);
+ assertRejectedBlock(block);
+ }
+ for (Object value : List.of(-1, 1.0, "1", new
BigInteger("9223372036854775808"))) {
+ assertRejectedBlock(
+ Map.of(
+ "type",
+ "image",
+ "media_type",
+ "image/png",
+ "source",
+ Map.of("type", "base64", "data", "aGk="),
+ "size_bytes",
+ value));
+ }
+ for (String source : List.of("base64", "url")) {
+ for (Object value : List.of(5, true, 1.5)) {
+ assertRejectedBlock(
+ Map.of(
+ "type",
+ "image",
+ "media_type",
+ "image/png",
+ "source",
+ Map.of(
+ "type",
+ source,
+ source.equals("base64") ? "data" :
"url",
+ value)));
+ }
+ }
+ for (long size : List.of(0L, Long.MAX_VALUE)) {
+ ImageBlock block =
+ new ImageBlock("image/png", new Base64Source("aGk="),
null, size, null);
+ assertThat(MAPPER.readValue(MAPPER.writeValueAsString(block),
ContentBlock.class))
+ .isEqualTo(block);
+ }
+ assertThatThrownBy(
+ () ->
+ new ImageBlock(
+ "image/png", new Base64Source("aGk="),
null, -1L, null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ private static void assertRejectedBlock(Map<String, Object> block) throws
Exception {
+ String json = MAPPER.writeValueAsString(Map.of("blocks",
List.of(block)));
+ assertThatThrownBy(() -> MAPPER.readValue(json, ChatMessage.class))
+ .isInstanceOf(JsonMappingException.class);
+ assertThatThrownBy(() -> new
ChatMessage().setBlocksFromMaps(List.of(block)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void base64PayloadPreservationAndSafeRepresentations() throws Exception {
+ // Sources carry data without decoding; even malformed payloads must
not report negatives.
+ for (String data :
+ List.of("=", "===", "a", "a===", "aGk==", "aG=k", "aGk\n",
"aG-_", "图像")) {
+ Base64Source source = new Base64Source(data);
+ assertThat(source.getSizeBytes()).isNotNegative();
+ assertThat(MAPPER.readValue(MAPPER.writeValueAsString(source),
MediaSource.class))
+ .isEqualTo(source);
+ ChatMessage message = new ChatMessage();
+ message.setBlocksFromMaps(
+ List.of(
+ Map.of(
+ "type",
+ "image",
+ "media_type",
+ "image/png",
+ "source",
+ Map.of("type", "base64", "data", data))));
+ assertThat(((ImageBlock)
message.getBlocks().get(0)).getSource()).isEqualTo(source);
+ }
+ for (String data : List.of("YQ==", "YQ", "aGk=", "aGk", "YWJj")) {
+ Base64Source source = new Base64Source(data);
+ assertThat(source.getSizeBytes())
+ .isEqualTo((long) Base64.getDecoder().decode(data).length);
+ assertThat(MAPPER.readValue(MAPPER.writeValueAsString(source),
MediaSource.class))
+ .isEqualTo(source);
+ }
+ UrlSource url = new
UrlSource("https://user:[email protected]/x?token=secret");
+ assertThat(url.toString()).doesNotContain("password", "secret");
+ assertThat(MAPPER.writeValueAsString(url)).contains("password",
"secret");
+ }
+
@Test
@DisplayName("A text-only message serializes to a single typed text block")
void testTextOnlyWireShape() throws Exception {
diff --git a/docs/content/docs/development/chat_models.md
b/docs/content/docs/development/chat_models.md
index bb52bc0e..d862ac42 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -53,6 +53,53 @@ Chat models communicate through built-in events:
A `ChatRequestEvent` can name a model router instead of a chat model to pick
the model per request; see [Model Routing]({{< ref
"docs/development/model_routing" >}}).
+### Message content blocks
+
+Use `ChatMessage.blocks` to combine text and media in a single message, in the
+order you want them presented. Supported block types are `TextBlock`,
`ImageBlock`,
+`AudioBlock`, `VideoBlock`, and `DocumentBlock`. For example, a message can
contain
+a question followed by an image:
+
+**Provider support:** Built-in provider integrations currently send only the
text
+portion of a message. These examples construct multimodal messages; sending
their
+media to a model requires a provider integration that supports those block
types.
+
+{{< tabs "Message content blocks" >}}
+
+{{< tab "Python" >}}
+```python
+from flink_agents.api.chat_message import ChatMessage, ImageBlock, TextBlock
+
+message = ChatMessage.user([
+ TextBlock(text="Describe this image"),
+ ImageBlock.from_url("image/png", "https://example.org/image.png"),
+])
+```
+{{< /tab >}}
+
+{{< tab "Java" >}}
+```java
+ChatMessage message = ChatMessage.user(List.of(
+ TextBlock.of("Describe this image"),
+ ImageBlock.fromUrl("image/png", "https://example.org/image.png")));
+```
+{{< /tab >}}
+
+{{< /tabs >}}
+
+Each media block requires a MIME type such as `image/png` and a media source.
+Use `from_url` (Python) or `fromUrl` (Java) for an external URL, as above. For
+inline data, use `from_base64(media_type, data)` or `fromBase64(mediaType,
data)`
+with an already Base64-encoded string. The framework does not encode the data
or
+verify that its contents match the declared media type. Choose a concrete block
+such as `ImageBlock`, rather than constructing the shared `MediaBlock` base
class.
+
+Media blocks also accept optional metadata: `name` for a file name,
`size_bytes`
+for the media size in bytes, and `sha256` for a content checksum.
+
+To read only the text, use `.text` (Python) or `getText()` (Java). These
concatenate
+the text blocks and exclude media.
+
### Usage Example
Here's how to define and use chat models in a workflow agent:
diff --git a/docs/content/docs/operations/monitoring.md
b/docs/content/docs/operations/monitoring.md
index 29b182b7..9c539294 100644
--- a/docs/content/docs/operations/monitoring.md
+++ b/docs/content/docs/operations/monitoring.md
@@ -414,9 +414,14 @@ Each event type is logged at a configurable verbosity.
Three levels are supporte
|------------|----------------------------------------------------------------------------------------------------------------|
| `OFF` | Events of this type are not logged.
|
| `STANDARD` | Events are logged, but the payload may be truncated or
summarized to keep logs concise. **This is the default.** |
-| `VERBOSE` | Events are logged with the full, untruncated payload.
|
+| `VERBOSE` | Events are logged without truncation, after applicable media
sanitization. |
-The global default is set by [`event-log.level`]({{< ref
"docs/operations/configuration#core-options" >}}). At `STANDARD` level, the
payload is shrunk along three independent axes — long strings, large arrays,
and deep nesting — controlled by `event-log.standard.max-string-length`,
`event-log.standard.max-array-elements`, and `event-log.standard.max-depth`
respectively. Setting any threshold to `0` disables that specific truncation;
setting all three to `0` makes `STANDARD` behave identi [...]
+The global default is set by [`event-log.level`]({{< ref
"docs/operations/configuration#core-options" >}}). At `STANDARD` level, the
payload is shrunk along three independent axes — long strings, large arrays,
and deep nesting — controlled by `event-log.standard.max-string-length`,
`event-log.standard.max-array-elements`, and `event-log.standard.max-depth`
respectively. Setting any threshold to `0` disables that specific truncation;
setting all three to `0` makes `STANDARD` behave identi [...]
+
+**Media sanitization.** When logging `ChatMessage` objects, all enabled log
levels
+omit inline Base64 media data and remove userinfo, query strings, and fragments
+from media URLs. Media metadata is retained. Message text is not automatically
+redacted.
**Fields that are never truncated.** Structural and identifying fields are
always preserved in full so log consumers can still group, route, and correlate
records: `timestamp`, `logLevel`, trace fields such as `inputRunId` and
`executionId`, `eventId`, `eventType`, and lifecycle fields such as `status`
and `problemCategory`. Truncation only applies to large nested content under
`eventAttributes` (long strings, big arrays, deeply nested objects).
@@ -442,8 +447,8 @@ Example record at `STANDARD` with a long string and a large
array truncated:
"model": "gpt-4",
"messages": {
"truncatedList": [
- {"role": "system", "content": "You are a helpful assistant..."},
- {"role": "user", "content": {"truncatedString": "Analyze this doc...",
"omittedChars": 1000}}
+ {"role": "system", "blocks": [{"type": "text", "text": "You are a
helpful assistant..."}]},
+ {"role": "user", "blocks": [{"type": "text", "text":
{"truncatedString": "Analyze this doc...", "omittedChars": 1000}}]}
],
"omittedElements": 30
}
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
index d92948a9..6b404901 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
@@ -345,7 +345,8 @@ public class ChatModelAction {
}
Map<String, Object> extraArgs = new HashMap<>(response.getExtraArgs());
extraArgs.put(STRUCTURED_OUTPUT, structuredOutput);
- return new ChatMessage(response.getRole(), output, extraArgs);
+ return new ChatMessage(
+ response.getRole(), response.getBlocks(),
response.getToolCalls(), extraArgs);
}
/**
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonPrompt.java
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonPrompt.java
index ed05870f..e05600dd 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonPrompt.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonPrompt.java
@@ -18,7 +18,6 @@
package org.apache.flink.agents.plan.resource.python;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
-import org.apache.flink.agents.api.chat.messages.ContentBlock;
import org.apache.flink.agents.api.chat.messages.MessageRole;
import org.apache.flink.agents.api.prompt.Prompt;
@@ -79,15 +78,15 @@ public class PythonPrompt extends Prompt.LocalPrompt {
String roleValue = messageMap.get("role").toString();
MessageRole role = MessageRole.fromValue(roleValue);
- List<Map<String, Object>> blocks = (List<Map<String, Object>>)
messageMap.get("blocks");
+ List<Map<String, Object>> blocks =
+ (List<Map<String, Object>>) messageMap.getOrDefault("blocks",
List.of());
List<Map<String, Object>> toolCalls =
(List<Map<String, Object>>) messageMap.get("tool_calls");
Map<String, Object> extraArgs = (Map<String, Object>)
messageMap.get("extra_args");
- ChatMessage message =
- new ChatMessage(role, (List<ContentBlock>) null, toolCalls,
extraArgs);
+ ChatMessage message = new ChatMessage(role, List.of(), toolCalls,
extraArgs);
message.setBlocksFromMaps(blocks);
return message;
}
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
index cfdffd03..7c09b382 100644
---
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
+++
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
@@ -19,7 +19,9 @@ package org.apache.flink.agents.plan.actions;
import org.apache.flink.agents.api.agents.Agent;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.ImageBlock;
import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
import org.apache.flink.agents.api.context.RunnerContext;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
@@ -27,9 +29,11 @@ import org.apache.flink.agents.api.trace.ExecutionReporter;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -43,6 +47,23 @@ import static org.mockito.Mockito.withSettings;
/** Tests for {@link ChatModelAction}. */
class ChatModelActionTest {
+ @Test
+ void structuredOutputPreservesOriginalBlocks() throws Exception {
+ ChatMessage original =
+ new ChatMessage(
+ MessageRole.ASSISTANT,
+ List.of(
+ new TextBlock("```json\n{\"answer\":42}\n```"),
+ ImageBlock.fromBase64("image/png", "aGk=")),
+ List.of(),
+ Map.of("provider", "test"));
+ ChatMessage parsed =
ChatModelAction.generateStructuredOutput(original, Map.class);
+ assertEquals(original.getBlocks(), parsed.getBlocks());
+ assertEquals(Map.of("answer", 42),
parsed.getExtraArgs().get(Agent.STRUCTURED_OUTPUT));
+ assertEquals("test", parsed.getExtraArgs().get("provider"));
+
assertFalse(original.getExtraArgs().containsKey(Agent.STRUCTURED_OUTPUT));
+ }
+
private static final String PARSEABLE_CONTENT = "{\"answer\":\"42\"}";
private static ChatMessage responseWith(Map<String, Object> extraArgs) {
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonPromptTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonPromptTest.java
index 839e11ad..620a873d 100644
---
a/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonPromptTest.java
+++
b/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonPromptTest.java
@@ -96,6 +96,23 @@ public class PythonPromptTest {
});
}
+ @Test
+ public void testOmittedBlocksDefaultToEmptyButExplicitNullIsRejected() {
+ Map<String, Object> message = messageDump("user", null);
+ Map<String, Object> serialized = Map.of("template", List.of(message));
+ assertThatThrownBy(() -> PythonPrompt.fromSerializedMap(serialized))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("blocks must not be null");
+
+ message.remove("blocks");
+ assertThat(
+ PythonPrompt.fromSerializedMap(serialized)
+ .formatMessages(MessageRole.USER, Map.of())
+ .get(0)
+ .getBlocks())
+ .isEmpty();
+ }
+
private static Map<String, Object> messageDump(String role,
List<Map<String, Object>> blocks) {
Map<String, Object> message = new HashMap<>();
message.put("role", role);
diff --git a/python/flink_agents/api/chat_message.py
b/python/flink_agents/api/chat_message.py
index c3a49112..e6f7c7fc 100644
--- a/python/flink_agents/api/chat_message.py
+++ b/python/flink_agents/api/chat_message.py
@@ -18,7 +18,12 @@
from enum import Enum
from typing import Any, Dict, List, Literal, Sequence
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ StrictStr,
+)
from typing_extensions import Annotated, Self
@@ -62,13 +67,13 @@ class Base64Source(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
type: Literal["base64"] = "base64"
- data: str = Field(min_length=1)
+ data: StrictStr = Field(min_length=1, repr=False)
@property
def size_bytes(self) -> int:
- """The decoded byte count implied by the base64 length."""
+ """Infer the byte count assuming valid standard Base64, without
decoding."""
padding = 2 if self.data.endswith("==") else 1 if
self.data.endswith("=") else 0
- return len(self.data) * 3 // 4 - padding
+ return max(0, len(self.data) * 3 // 4 - padding)
def __str__(self) -> str:
return f"Base64Source({self.size_bytes} bytes)"
@@ -84,10 +89,10 @@ class UrlSource(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
type: Literal["url"] = "url"
- url: str = Field(min_length=1)
+ url: StrictStr = Field(min_length=1, repr=False)
def __str__(self) -> str:
- return f"UrlSource({self.url})"
+ return "UrlSource(<redacted>)"
MediaSource = Annotated[
@@ -122,11 +127,11 @@ class MediaBlock(BaseModel):
# min_length mirrors the Java constructor: both languages reject an empty
# media type, so a block valid here is valid after crossing the bridge.
- media_type: str = Field(min_length=1)
+ media_type: StrictStr = Field(min_length=1)
source: MediaSource
- name: str | None = None
- size_bytes: int | None = None
- sha256: str | None = None
+ name: StrictStr | None = None
+ size_bytes: Annotated[int, Field(strict=True, ge=0, le=2**63 - 1)] | None
= None
+ sha256: StrictStr | None = None
@classmethod
def from_base64(cls, media_type: str, data: str, **kwargs: Any) -> Self:
@@ -197,7 +202,7 @@ class ChatMessage(BaseModel):
# Unknown keys fail loudly: the replaced `content` field would otherwise be
# silently ignored, producing an empty message instead of an error.
- model_config = ConfigDict(extra="forbid")
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
role: MessageRole = MessageRole.USER
blocks: List[ContentBlock] = Field(default_factory=list)
diff --git a/python/flink_agents/api/tests/test_chat_message.py
b/python/flink_agents/api/tests/test_chat_message.py
index 241d8409..ca824705 100644
--- a/python/flink_agents/api/tests/test_chat_message.py
+++ b/python/flink_agents/api/tests/test_chat_message.py
@@ -43,6 +43,100 @@ def test_text_only_wire_shape() -> None:
}
[email protected]("blocks", [None, [None]])
+def test_null_blocks_rejected_at_construction_and_assignment(blocks) -> None:
+ with pytest.raises(ValidationError):
+ ChatMessage(blocks=blocks)
+ message = ChatMessage.user("original")
+ with pytest.raises(ValidationError):
+ message.blocks = blocks
+ assert message.text == "original"
+
+
+def test_block_list_copies_input_supports_append_and_dumps_as_list() -> None:
+ blocks = [TextBlock(text="original")]
+ message = ChatMessage.user(blocks)
+ blocks.clear()
+ assert message.text == "original"
+ message.blocks.append(TextBlock(text=" appended"))
+ assert message.text == "original appended"
+ message.blocks = [TextBlock(text="replacement")]
+ assert message.text == "replacement"
+ assert isinstance(message.model_dump()["blocks"], list)
+ assert isinstance(message.model_dump(mode="json")["blocks"], list)
+
+
[email protected]("kind", ["image", "audio", "video", "document"])
[email protected]("field", ["media_type", "name", "sha256"])
[email protected]("value", [5, True, 1.5])
+def test_media_strings_reject_coercion(kind, field, value) -> None:
+ payload = {
+ "type": kind,
+ "media_type": "image/png",
+ "source": {"type": "base64", "data": "aGk="},
+ field: value,
+ }
+ with pytest.raises(ValidationError):
+ ChatMessage.model_validate({"blocks": [payload]})
+
+
[email protected]("value", [-1, True, False, 1.5, 1.0, "1", "1.0",
2**63])
+def test_size_bytes_rejects_invalid_values(value) -> None:
+ with pytest.raises(ValidationError):
+ ImageBlock.from_base64("image/png", "aGk=", size_bytes=value)
+
+
[email protected]("value", [None, 0, 2**63 - 1])
+def test_size_bytes_accepts_boundaries(value) -> None:
+ block = ImageBlock.from_base64("image/png", "aGk=", size_bytes=value)
+ assert block.size_bytes == value
+
+
[email protected]("value", [5, True, 1.5, b"aGk="])
+def test_sources_reject_non_strings(value) -> None:
+ with pytest.raises(ValidationError):
+ Base64Source(data=value)
+ with pytest.raises(ValidationError):
+ UrlSource(url=value)
+
+
[email protected](
+ "data", ["=", "===", "a", "a===", "aGk==", "aG=k", "aGk\n", "aG-_", "图像"]
+)
+def test_base64_preserves_unvalidated_payload(data) -> None:
+ """The source carries data; malformed input must not produce negative
sizes."""
+ source = Base64Source(data=data)
+ assert source.size_bytes >= 0
+ assert Base64Source.model_validate_json(source.model_dump_json()) == source
+ assert source.model_dump()["data"] == data
+
+
[email protected](
+ ("data", "size"), [("YQ==", 1), ("YQ", 1), ("aGk=", 2), ("aGk", 2),
("YWJj", 3)]
+)
+def test_base64_size_and_wire_preservation(data, size) -> None:
+ source = Base64Source(data=data)
+ assert source.size_bytes == size
+ assert source.model_dump()["data"] == data
+
+
+def test_media_representations_hide_payloads_but_wire_preserves_them() -> None:
+ data = "c2VjcmV0"
+ url = "https://user:[email protected]/x?token=secret"
+ message = ChatMessage.user(
+ [
+ ImageBlock.from_base64("image/png", data),
+ ImageBlock.from_url("image/png", url),
+ ]
+ )
+ assert data not in repr([message])
+ assert url not in repr([message])
+ assert url not in str(message.blocks[1])
+ dumped = message.model_dump(mode="json")
+ assert dumped["blocks"][0]["source"]["data"] == data
+ assert dumped["blocks"][1]["source"]["url"] == url
+
+
def test_media_block_wire_shape_omits_absent_fields() -> None:
message = ChatMessage.user(
[
diff --git a/python/flink_agents/plan/actions/chat_model_action.py
b/python/flink_agents/plan/actions/chat_model_action.py
index 41678514..d2880d0f 100644
--- a/python/flink_agents/plan/actions/chat_model_action.py
+++ b/python/flink_agents/plan/actions/chat_model_action.py
@@ -466,7 +466,9 @@ async def chat(
total_wait_time_sec += current_wait_sec
else:
_logger.debug(
- f"Chat request {initial_request_id} failed, the input
chat messages are {messages}."
+ "Chat request %s failed (%d input messages).",
+ initial_request_id,
+ len(messages),
)
raise
finally:
diff --git a/python/flink_agents/plan/tests/actions/test_chat_model_action.py
b/python/flink_agents/plan/tests/actions/test_chat_model_action.py
index 71e9904e..30e2ad98 100644
--- a/python/flink_agents/plan/tests/actions/test_chat_model_action.py
+++ b/python/flink_agents/plan/tests/actions/test_chat_model_action.py
@@ -22,7 +22,12 @@ from pydantic import BaseModel
from pyflink.common.typeinfo import BasicTypeInfo, RowTypeInfo
from flink_agents.api.agents.react_agent import OutputSchema
-from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_message import (
+ ChatMessage,
+ ImageBlock,
+ MessageRole,
+ TextBlock,
+)
from flink_agents.api.memory_object import MemoryType
from flink_agents.api.trace import ExecutionReporter
from flink_agents.plan.actions.chat_model_action import (
@@ -50,9 +55,7 @@ def _assert_primitive(obj) -> None:
return
if isinstance(obj, dict):
for k, v in obj.items():
- assert isinstance(k, str | int | float | bool), (
- f"non-primitive key: {k!r}"
- )
+ assert isinstance(k, str | int | float | bool), f"non-primitive
key: {k!r}"
_assert_primitive(v)
return
msg = f"non-primitive value of type {type(obj).__name__}: {obj!r}"
@@ -63,6 +66,21 @@ class _Result(BaseModel):
result: int
+def test_structured_output_preserves_original_blocks():
+ message = ChatMessage.assistant(
+ [
+ TextBlock(text='```json\n{"result": 42}\n```'),
+ ImageBlock.from_base64("image/png", "aGk="),
+ ]
+ )
+ original_blocks = message.blocks
+ parsed = _generate_structured_output_with_report(
+ MagicMock(spec=ExecutionReporter), message,
OutputSchema(output_schema=_Result)
+ )
+ assert parsed.blocks == original_blocks
+ assert parsed.extra_args["structured_output"] == _Result(result=42)
+
+
def test_clean_llm_response_with_json_block():
input_str = '```json\n{"key": "value"}\n```'
expected = '{"key": "value"}'
@@ -164,9 +182,7 @@ def test_get_context_none_output_schema():
def test_request_event_key_match_after_normalization():
mem = _memory()
event_id = uuid4()
- _save_tool_request_event_context(
- mem, event_id, uuid4(), "ollama", None, None
- )
+ _save_tool_request_event_context(mem, event_id, uuid4(), "ollama", None,
None)
context = _get_tool_request_event_context(mem, event_id)
assert context != {}
assert context["model"] == "ollama"
@@ -192,9 +208,7 @@ def test_output_schema_rowtypeinfo_round_trip():
["result"],
)
)
- _save_tool_request_event_context(
- mem, event_id, uuid4(), "ollama", None, schema
- )
+ _save_tool_request_event_context(mem, event_id, uuid4(), "ollama", None,
schema)
context = _get_tool_request_event_context(mem, event_id)
assert isinstance(context["output_schema"], OutputSchema)
assert context["output_schema"].output_schema.get_field_names() ==
["result"]
diff --git
a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
index 2c31bd57..b0fd153d 100644
--- a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
+++ b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
@@ -28,7 +28,7 @@ from pydantic import BaseModel
from flink_agents.api.agents.agent import STRUCTURED_OUTPUT
from flink_agents.api.agents.react_agent import OutputSchema
-from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_message import ChatMessage, ImageBlock, MessageRole
from flink_agents.api.core_options import (
AgentExecutionOptions,
ErrorHandlingStrategy,
@@ -164,6 +164,34 @@ def _create_mock_runner_context(
class TestChatModelActionRetry:
"""Tests for retry behavior in chat()."""
+ def test_failure_debug_log_contains_only_message_count(self, caplog) ->
None:
+ model = MagicMock()
+ model.chat.side_effect = RuntimeError("provider unavailable")
+ ctx, _, _, _ = _create_mock_runner_context(
+ model, error_handling_strategy=ErrorHandlingStrategy.FAIL
+ )
+ messages = [
+ ChatMessage.user(
+ [
+ ImageBlock.from_base64("image/png", "c2VjcmV0"),
+ ImageBlock.from_url(
+ "image/png",
"https://u:[email protected]/x?token=secret"
+ ),
+ ]
+ )
+ ]
+ with (
+ caplog.at_level(
+ "DEBUG", logger="flink_agents.plan.actions.chat_model_action"
+ ),
+ pytest.raises(RuntimeError, match="provider unavailable"),
+ ):
+ asyncio.run(chat(uuid4(), "test-model", messages, {}, None, ctx))
+ assert "1 input messages" in caplog.text
+ assert "c2VjcmV0" not in caplog.text
+ assert "password" not in caplog.text
+ assert "token=secret" not in caplog.text
+
def test_chat_succeeds_without_retry(self) -> None:
"""No retry needed: retry_count=0, total_retry_wait_sec=0, no
metrics."""
chat_model = MagicMock()
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
index b00cd7b5..587e5483 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
@@ -19,7 +19,6 @@ package org.apache.flink.agents.runtime.python.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
-import org.apache.flink.agents.api.chat.messages.ContentBlock;
import org.apache.flink.agents.api.chat.messages.MessageRole;
import org.apache.flink.agents.api.resource.Resource;
import org.apache.flink.agents.api.resource.ResourceContext;
@@ -106,11 +105,7 @@ public class JavaResourceAdapter {
Map<String, Object> extraArgs) {
// TODO: Delete this method after the pemja findClass method is fixed.
ChatMessage message =
- new ChatMessage(
- MessageRole.fromValue(roleValue),
- (List<ContentBlock>) null,
- toolCalls,
- extraArgs);
+ new ChatMessage(MessageRole.fromValue(roleValue), List.of(),
toolCalls, extraArgs);
message.setBlocksFromMaps(blocks);
return message;
}