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;
     }

Reply via email to