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 25282b57 [Feature][api][runtime] Support auto resolve memory reference 
for passing data across actions. (#950)
25282b57 is described below

commit 25282b57bdf71b6c1c085059088367b0c131ce1d
Author: Jinkun Liu <[email protected]>
AuthorDate: Tue Sep 15 20:50:20 2026 -0700

    [Feature][api][runtime] Support auto resolve memory reference for passing 
data across actions. (#950)
---
 .../java/org/apache/flink/agents/api/Event.java    |  84 +++++-
 .../org/apache/flink/agents/api/OutputEvent.java   |   7 +-
 .../flink/agents/api/context/MemoryObject.java     |   8 +-
 .../apache/flink/agents/api/context/MemoryRef.java |  80 +++++-
 .../agents/api/CrossLanguageEventSnapshotTest.java |  26 ++
 .../org/apache/flink/agents/api/EventTest.java     |  49 ++++
 .../agents/api/context/MemoryRefJsonTest.java      |  57 ++++
 .../java/agent_run_begin_event.json                |   1 +
 .../java/chat_request_event.json                   |   1 +
 .../java/chat_response_event.json                  |   1 +
 .../java/context_retrieval_request_event.json      |   1 +
 .../java/context_retrieval_response_event.json     |   1 +
 .../java/generic_event_with_attrs.json             |   3 +-
 .../java/input_event.json                          |   1 +
 .../java/memory_ref_attachment_event.json          |  14 +
 .../java/output_event.json                         |   1 +
 .../java/short_term_write_event.json               |   1 +
 .../java/tool_request_event.json                   |   1 +
 .../java/tool_response_event.json                  |   1 +
 .../python/agent_run_begin_event.json              |   3 +-
 .../python/chat_request_event.json                 |   3 +-
 .../python/chat_response_event.json                |   3 +-
 .../python/context_retrieval_request_event.json    |   3 +-
 .../python/context_retrieval_response_event.json   |   3 +-
 .../python/generic_event_with_attrs.json           |   3 +-
 .../python/input_event.json                        |   3 +-
 .../python/memory_ref_attachment_event.json        |  14 +
 .../python/output_event.json                       |   3 +-
 .../python/python_only_subclass_event.json         |   3 +-
 .../python/short_term_write_event.json             |   3 +-
 .../python/tool_request_event.json                 |   3 +-
 .../python/tool_response_event.json                |   3 +-
 python/flink_agents/api/events/event.py            |  47 +++-
 python/flink_agents/api/memory_object.py           |   2 +-
 python/flink_agents/api/memory_reference.py        |  12 +-
 .../tests/test_cross_language_event_snapshots.py   |  27 ++
 python/flink_agents/api/tests/test_event.py        |  42 ++-
 .../event_attachments_test.py                      | 103 +++++++
 .../flink_agents/runtime/flink_runner_context.py   |   2 +
 .../runtime/memory/event_attachment_utils.py       | 108 +++++++
 python/flink_agents/runtime/python_java_utils.py   |   7 +
 .../runtime/tests/test_event_attachment_utils.py   | 178 ++++++++++++
 .../runtime/tests/test_flink_runner_context.py     |  68 +++++
 .../agents/runtime/context/RunnerContextImpl.java  |   6 +
 .../runtime/memory/EventAttachmentUtils.java       | 146 ++++++++++
 .../runtime/operator/ActionExecutionOperator.java  |  10 +
 .../agents/runtime/operator/JavaActionTask.java    |  20 +-
 .../runtime/python/utils/PythonActionExecutor.java |   6 +
 .../runtime/actionstate/ActionStateSerdeTest.java  |   8 +
 .../runtime/memory/EventAttachmentUtilsTest.java   | 310 +++++++++++++++++++++
 .../operator/ActionExecutionOperatorTest.java      |  80 ++++++
 .../runtime/operator/JavaActionTaskTest.java       | 188 +++++++++++++
 .../python/utils/PythonActionExecutorTest.java     |  36 +++
 53 files changed, 1751 insertions(+), 43 deletions(-)

diff --git a/api/src/main/java/org/apache/flink/agents/api/Event.java 
b/api/src/main/java/org/apache/flink/agents/api/Event.java
index 43286ac8..83538fe8 100644
--- a/api/src/main/java/org/apache/flink/agents/api/Event.java
+++ b/api/src/main/java/org/apache/flink/agents/api/Event.java
@@ -22,7 +22,13 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import org.apache.flink.agents.api.context.MemoryRef;
 
 import javax.annotation.Nullable;
 
@@ -42,6 +48,11 @@ public class Event {
     private final String type;
     private final Map<String, Object> attributes;
 
+    // Keep the annotation on the field as well as the creator parameter so it 
also applies when
+    // Jackson constructs Event subclasses whose creators do not declare 
attachments.
+    @JsonDeserialize(contentUsing = AttachmentValueDeserializer.class)
+    private final Map<String, Object> attachments;
+
     @Nullable private UUID upstreamEventId;
     @Nullable private String upstreamActionName;
 
@@ -53,7 +64,7 @@ public class Event {
 
     /** Unified event with user-defined type and attributes. */
     public Event(String type, Map<String, Object> attributes) {
-        this(UUID.randomUUID(), type, attributes);
+        this(UUID.randomUUID(), type, attributes, new HashMap<>());
     }
 
     /** Unified event with user-defined type and empty attributes. */
@@ -70,6 +81,7 @@ public class Event {
      * @param id the existing Event ID
      * @param type the Event type used for routing
      * @param attributes the Event payload
+     * @param attachments key-value data passed between Actions through 
sensory memory
      * @param upstreamEventId the ID of the direct upstream Event, or {@code 
null}
      * @param upstreamActionName the name of the emitting Action, or {@code 
null}
      */
@@ -78,6 +90,9 @@ public class Event {
             @JsonProperty("id") UUID id,
             @JsonProperty("type") String type,
             @JsonProperty("attributes") Map<String, Object> attributes,
+            @JsonProperty("attachments")
+                    @JsonDeserialize(contentUsing = 
AttachmentValueDeserializer.class)
+                    Map<String, Object> attachments,
             @JsonProperty("upstreamEventId") @Nullable UUID upstreamEventId,
             @JsonProperty("upstreamActionName") @Nullable String 
upstreamActionName) {
         if (type == null || type.isEmpty()) {
@@ -86,14 +101,31 @@ public class Event {
         // Explicit null matches an omitted id: both mint a per-occurrence 
UUID.
         this.id = id != null ? id : UUID.randomUUID();
         this.type = type;
-        this.attributes = attributes != null ? attributes : new HashMap<>();
+        this.attributes = attributes != null ? new HashMap<>(attributes) : new 
HashMap<>();
+        this.attachments = attachments != null ? new HashMap<>(attachments) : 
new HashMap<>();
         this.upstreamEventId = upstreamEventId;
         this.upstreamActionName = upstreamActionName;
     }
 
+    /** Reconstructs an Event with an existing identity, attachments, and no 
upstream lineage. */
+    public Event(
+            UUID id, String type, Map<String, Object> attributes, Map<String, 
Object> attachments) {
+        this(id, type, attributes, attachments, null, null);
+    }
+
+    /** Reconstructs an Event with an existing identity and optional 
framework-managed lineage. */
+    public Event(
+            UUID id,
+            String type,
+            Map<String, Object> attributes,
+            @Nullable UUID upstreamEventId,
+            @Nullable String upstreamActionName) {
+        this(id, type, attributes, new HashMap<>(), upstreamEventId, 
upstreamActionName);
+    }
+
     /** Reconstructs an Event with an existing identity and no upstream 
lineage. */
     public Event(UUID id, String type, Map<String, Object> attributes) {
-        this(id, type, attributes, null, null);
+        this(id, type, attributes, new HashMap<>(), null, null);
     }
 
     public UUID getId() {
@@ -110,6 +142,10 @@ public class Event {
         return attributes;
     }
 
+    public Map<String, Object> getAttachments() {
+        return attachments;
+    }
+
     /** Returns the ID of the Event consumed by the Action that emitted this 
Event. */
     @Nullable
     @JsonInclude(JsonInclude.Include.NON_NULL)
@@ -150,6 +186,20 @@ public class Event {
         attributes.put(name, value);
     }
 
+    public Object getAttachment(String name) {
+        return attachments.get(name);
+    }
+
+    /**
+     * Sets an attachment on this Event.
+     *
+     * <p>If {@code value} is a {@link MemoryRef}, it must reference sensory 
memory and will not be
+     * wrapped again.
+     */
+    public void setAttachment(String name, Object value) {
+        attachments.put(name, value);
+    }
+
     @JsonIgnore
     public boolean hasSourceTimestamp() {
         return sourceTimestamp != null;
@@ -166,9 +216,9 @@ public class Event {
     }
 
     /**
-     * Creates a base Event from another Event, copying its identity, data, 
and framework metadata.
-     * Subclasses override this to reconstruct typed event objects with proper 
field
-     * deserialization.
+     * Creates a base Event from another Event, copying its identity, data, 
attachments, and
+     * framework metadata. Subclasses override this to reconstruct typed event 
objects with proper
+     * field deserialization.
      */
     public static Event fromEvent(Event event) {
         return reconstructFrom(
@@ -194,6 +244,8 @@ public class Event {
                             + source.getId());
         }
         Event reconstructedEvent = reconstructed;
+        reconstructedEvent.attachments.clear();
+        reconstructedEvent.attachments.putAll(source.attachments);
         reconstructedEvent.sourceTimestamp = source.sourceTimestamp;
         reconstructedEvent.upstreamEventId = source.upstreamEventId;
         reconstructedEvent.upstreamActionName = source.upstreamActionName;
@@ -211,6 +263,21 @@ public class Event {
         return MAPPER.readValue(json, Event.class);
     }
 
+    /** Deserializes one attachment value, preserving explicitly tagged memory 
references. */
+    static final class AttachmentValueDeserializer extends 
JsonDeserializer<Object> {
+
+        @Override
+        public Object deserialize(JsonParser parser, DeserializationContext 
context)
+                throws IOException {
+            JsonNode node = parser.getCodec().readTree(parser);
+            if (node.isObject()
+                    && 
MemoryRef.TYPE_VALUE.equals(node.path(MemoryRef.TYPE_FIELD).asText())) {
+                return parser.getCodec().treeToValue(node, MemoryRef.class);
+            }
+            return parser.getCodec().treeToValue(node, Object.class);
+        }
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
@@ -218,11 +285,12 @@ public class Event {
         Event other = (Event) o;
         return Objects.equals(this.id, other.id)
                 && Objects.equals(this.getType(), other.getType())
-                && Objects.equals(this.attributes, other.attributes);
+                && Objects.equals(this.attributes, other.attributes)
+                && Objects.equals(this.attachments, other.attachments);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(id, getType(), attributes);
+        return Objects.hash(id, getType(), attributes, attachments);
     }
 }
diff --git a/api/src/main/java/org/apache/flink/agents/api/OutputEvent.java 
b/api/src/main/java/org/apache/flink/agents/api/OutputEvent.java
index 7c57d634..c139deee 100644
--- a/api/src/main/java/org/apache/flink/agents/api/OutputEvent.java
+++ b/api/src/main/java/org/apache/flink/agents/api/OutputEvent.java
@@ -27,7 +27,8 @@ import java.util.UUID;
 
 /**
  * Event representing a result from agent. By generating an OutputEvent, 
actions can emit output
- * data.
+ * data. Attachments are only supported on events passed between actions and 
cannot be carried by an
+ * OutputEvent.
  */
 public class OutputEvent extends Event {
 
@@ -50,8 +51,12 @@ public class OutputEvent extends Event {
      *
      * @param event the base event containing the output data in attributes
      * @return a typed OutputEvent
+     * @throws IllegalArgumentException if the source Event carries attachments
      */
     public static OutputEvent fromEvent(Event event) {
+        if (!event.getAttachments().isEmpty()) {
+            throw new IllegalArgumentException("OutputEvent cannot carry 
attachments.");
+        }
         return reconstructFrom(event, OutputEvent::new);
     }
 
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java 
b/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
index 0f18471c..2b19062e 100644
--- a/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
+++ b/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
@@ -21,9 +21,9 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * A representation of an object in the short-term memory. It is responsible 
for accessing and
- * manipulating (direct or indirect) fields within the memory structure. A 
direct field is a field
- * which stores primitive data directly, while an indirect filed is a field 
which represents a
+ * A representation of an object in sensory or short-term memory. It is 
responsible for accessing
+ * and manipulating (direct or indirect) fields within the memory structure. A 
direct field is a
+ * field which stores primitive data directly, while an indirect filed is a 
field which represents a
  * nested object.Fields can be accessed using an absolute or relative path.
  */
 public interface MemoryObject {
@@ -46,7 +46,7 @@ public interface MemoryObject {
     /**
      * Returns a MemoryObject that represents the path of the given reference.
      *
-     * @param ref a reference to a data item in the short-term memory
+     * @param ref a reference to a data item in the memory
      * @return a MemoryObject instance pointing to the referenced field. 
Returns null if the path of
      *     the reference does not exist.
      * @throws Exception if an error occurs during state access
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/context/MemoryRef.java 
b/api/src/main/java/org/apache/flink/agents/api/context/MemoryRef.java
index df6f6c18..062865f3 100644
--- a/api/src/main/java/org/apache/flink/agents/api/context/MemoryRef.java
+++ b/api/src/main/java/org/apache/flink/agents/api/context/MemoryRef.java
@@ -17,17 +17,38 @@
  */
 package org.apache.flink.agents.api.context;
 
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import java.io.IOException;
 import java.io.Serializable;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 
 /**
- * A serializable, persistent reference to a specific data item in Short-Term 
Memory. It acts as a
- * lightweight pointer, containing the path of the data, allowing for 
efficient passing of large
- * objects between Actions.
+ * A serializable, persistent reference to a specific data item in a {@link 
MemoryObject}. It acts
+ * as a lightweight pointer, containing the path of the data, allowing for 
efficient passing of
+ * large objects between Actions.
  */
+@JsonSerialize(using = MemoryRef.Serializer.class)
+@JsonDeserialize(using = MemoryRef.Deserializer.class)
 public final class MemoryRef implements Serializable {
     private static final long serialVersionUID = 1L;
 
+    public static final String TYPE_FIELD = "@type";
+    public static final String TYPE_VALUE = "memory_ref";
+    public static final String MEMORY_TYPE_FIELD = "memory_type";
+    public static final String PATH_FIELD = "path";
+
     private final MemoryObject.MemoryType type;
     private final String path;
 
@@ -39,7 +60,7 @@ public final class MemoryRef implements Serializable {
     /**
      * Creates a new MemoryRef instance with the given path.
      *
-     * @param path The absolute path of the data in Short-Term Memory.
+     * @param path The absolute path of the data in memory.
      * @return A new MemoryRef instance.
      */
     public static MemoryRef create(MemoryObject.MemoryType type, String path) {
@@ -49,7 +70,7 @@ public final class MemoryRef implements Serializable {
     /**
      * Resolves the reference using the provided RunnerContext to get the 
actual data.
      *
-     * @param ctx The current execution context, used to access Short-Term 
Memory.
+     * @param ctx The current execution context, used to access memory.
      * @return The deserialized, original data object.
      * @throws Exception if the memory cannot be accessed or the data cannot 
be resolved.
      */
@@ -67,6 +88,55 @@ public final class MemoryRef implements Serializable {
         return path;
     }
 
+    public MemoryObject.MemoryType getType() {
+        return type;
+    }
+
+    /** Serializes a {@link MemoryRef} to JSON. */
+    public static final class Serializer extends StdSerializer<MemoryRef> {
+
+        public Serializer() {
+            super(MemoryRef.class);
+        }
+
+        @Override
+        public void serialize(MemoryRef value, JsonGenerator generator, 
SerializerProvider provider)
+                throws IOException {
+            Map<String, String> serialized = new LinkedHashMap<>();
+            serialized.put(TYPE_FIELD, TYPE_VALUE);
+            serialized.put(MEMORY_TYPE_FIELD, 
value.getType().name().toLowerCase(Locale.ROOT));
+            serialized.put(PATH_FIELD, value.getPath());
+            generator.writeObject(serialized);
+        }
+    }
+
+    /** Deserializes a {@link MemoryRef} from JSON. */
+    public static final class Deserializer extends StdDeserializer<MemoryRef> {
+
+        public Deserializer() {
+            super(MemoryRef.class);
+        }
+
+        @Override
+        public MemoryRef deserialize(JsonParser parser, DeserializationContext 
context)
+                throws IOException {
+            JsonNode node = parser.getCodec().readTree(parser);
+            JsonNode typeNode = node.get(MEMORY_TYPE_FIELD);
+            JsonNode pathNode = node.get(PATH_FIELD);
+            if (typeNode == null || typeNode.isNull() || pathNode == null || 
pathNode.isNull()) {
+                throw new IllegalArgumentException(
+                        "MemoryRef JSON must contain non-null '"
+                                + MEMORY_TYPE_FIELD
+                                + "' and '"
+                                + PATH_FIELD
+                                + "' fields.");
+            }
+            MemoryObject.MemoryType memoryType =
+                    
MemoryObject.MemoryType.valueOf(typeNode.asText().toUpperCase(Locale.ROOT));
+            return create(memoryType, pathNode.asText());
+        }
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
index b09e6a13..1a6e42da 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
@@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.agents.OutputSchema;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
 import org.apache.flink.agents.api.event.AgentRunBeginEvent;
 import org.apache.flink.agents.api.event.ChatRequestEvent;
 import org.apache.flink.agents.api.event.ChatResponseEvent;
@@ -52,6 +54,7 @@ import java.util.UUID;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -68,6 +71,7 @@ class CrossLanguageEventSnapshotTest {
     private static final String FIXED_TOOL_CALL_ID = "call_aaaa";
     private static final String FIXED_TOOL_CALL_ID_NUMERIC = "call_bbbb";
     private static final String FIXED_TOOL_CALL_ID_BOOL = "call_cccc";
+    private static final String MEMORY_REF_ATTACHMENT_EVENT_TYPE = 
"_memory_ref_attachment_event";
     private static final long FIXED_TIMESTAMP = 1_700_000_000_000L;
 
     private static Path snapshotDir;
@@ -579,6 +583,28 @@ class CrossLanguageEventSnapshotTest {
         assertEquals(7, attrs.get("count"));
     }
 
+    @Test
+    void memoryRefAttachmentCrossLanguageSnapshotIsStable() throws Exception {
+        Event javaEvent =
+                new Event(
+                        FIXED_EVENT_ID, MEMORY_REF_ATTACHMENT_EVENT_TYPE, 
Map.of("value", "ping"));
+        javaEvent.setAttachment(
+                "payload", MemoryRef.create(MemoryObject.MemoryType.SENSORY, 
"memory.path"));
+        if (regenerateRequested()) {
+            writeJavaSnapshot("memory_ref_attachment_event.json", javaEvent);
+        }
+        assertJavaSnapshotStable("memory_ref_attachment_event.json", 
javaEvent);
+
+        Event pythonEvent = 
readPythonSnapshot("memory_ref_attachment_event.json");
+        assertEquals(FIXED_EVENT_ID, pythonEvent.getId());
+        assertEquals(MEMORY_REF_ATTACHMENT_EVENT_TYPE, pythonEvent.getType());
+        assertEquals("ping", pythonEvent.getAttr("value"));
+        MemoryRef reference =
+                assertInstanceOf(MemoryRef.class, 
pythonEvent.getAttachment("payload"));
+        assertEquals(MemoryObject.MemoryType.SENSORY, reference.getType());
+        assertEquals("memory.path", reference.getPath());
+    }
+
     // ── Smoke ──────────────────────────────────────────────────────────────
 
     @Test
diff --git a/api/src/test/java/org/apache/flink/agents/api/EventTest.java 
b/api/src/test/java/org/apache/flink/agents/api/EventTest.java
index a87357f2..56439698 100644
--- a/api/src/test/java/org/apache/flink/agents/api/EventTest.java
+++ b/api/src/test/java/org/apache/flink/agents/api/EventTest.java
@@ -20,6 +20,8 @@ package org.apache.flink.agents.api;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -145,6 +147,38 @@ class EventTest {
         assertNull(event.getUpstreamActionName());
     }
 
+    @Test
+    void testUnifiedEventJsonRoundTripPreservesAttachments() throws Exception {
+        Map<String, Object> attachments = new HashMap<>();
+        attachments.put("payload", Map.of("memory_type", "sensory", "path", 
"memory.path"));
+        Event original = new Event(UUID.randomUUID(), "MyEvent", new 
HashMap<>(), attachments);
+
+        Event restored =
+                
objectMapper.readValue(objectMapper.writeValueAsString(original), Event.class);
+
+        assertEquals(attachments, restored.getAttachments());
+    }
+
+    @Test
+    void testUnifiedEventJsonRoundTripPreservesMemoryRefAttachments() throws 
Exception {
+        MemoryRef reference = 
MemoryRef.create(MemoryObject.MemoryType.SENSORY, "memory.path");
+        Event original =
+                new Event(
+                        UUID.randomUUID(),
+                        "MyEvent",
+                        new HashMap<>(),
+                        Map.of("payload", reference));
+
+        String json = objectMapper.writeValueAsString(original);
+        JsonNode attachment = 
objectMapper.readTree(json).get("attachments").get("payload");
+        Event restored = objectMapper.readValue(json, Event.class);
+
+        assertEquals("memory_ref", attachment.get("@type").asText());
+        assertEquals("sensory", attachment.get("memory_type").asText());
+        assertEquals("memory.path", attachment.get("path").asText());
+        assertEquals(reference, restored.getAttachment("payload"));
+    }
+
     @Test
     void testSubclassedEventJsonRoundTrip() throws Exception {
         InputEvent original = new InputEvent("round trip");
@@ -317,6 +351,21 @@ class EventTest {
         assertEquals("output_action", copy.getUpstreamActionName());
     }
 
+    @Test
+    void testOutputEventFromEventRejectsAttachments() {
+        Event original =
+                new Event(
+                        UUID.randomUUID(),
+                        OutputEvent.EVENT_TYPE,
+                        Map.of("output", "result"),
+                        Map.of("payload", "attachment"));
+
+        IllegalArgumentException error =
+                assertThrows(IllegalArgumentException.class, () -> 
OutputEvent.fromEvent(original));
+
+        assertEquals("OutputEvent cannot carry attachments.", 
error.getMessage());
+    }
+
     @Test
     void 
testCustomFactoryReconstructsSameOccurrenceWithoutManualMetadataCopy() {
         UUID upstreamEventId = UUID.randomUUID();
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/context/MemoryRefJsonTest.java 
b/api/src/test/java/org/apache/flink/agents/api/context/MemoryRefJsonTest.java
new file mode 100644
index 00000000..ee8359b1
--- /dev/null
+++ 
b/api/src/test/java/org/apache/flink/agents/api/context/MemoryRefJsonTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.context;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class MemoryRefJsonTest {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Test
+    void serializesAndDeserializesMemoryRef() throws Exception {
+        MemoryRef original = MemoryRef.create(MemoryObject.MemoryType.SENSORY, 
"memory.path");
+
+        String json = objectMapper.writeValueAsString(original);
+        JsonNode node = objectMapper.readTree(json);
+        MemoryRef restored = objectMapper.readValue(json, MemoryRef.class);
+
+        assertEquals("memory_ref", node.get("@type").asText());
+        assertEquals("sensory", node.get("memory_type").asText());
+        assertEquals("memory.path", node.get("path").asText());
+        assertEquals(original, restored);
+        assertEquals(MemoryObject.MemoryType.SENSORY, restored.getType());
+    }
+
+    @Test
+    void rejectsInvalidMemoryRef() throws JsonProcessingException {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        objectMapper.readValue(
+                                
"{\"memory_type\":\"unknown\",\"path\":\"memory.path\"}",
+                                MemoryRef.class),
+                "No enum constant 
org.apache.flink.agents.api.context.MemoryObject.MemoryType.UNKNOWN");
+    }
+}
diff --git 
a/e2e-test/cross-language-event-snapshots/java/agent_run_begin_event.json 
b/e2e-test/cross-language-event-snapshots/java/agent_run_begin_event.json
index 5c050f0a..096ab944 100644
--- a/e2e-test/cross-language-event-snapshots/java/agent_run_begin_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/agent_run_begin_event.json
@@ -7,5 +7,6 @@
       "user.address.city" : "SF"
     }
   },
+  "attachments" : { },
   "type" : "_agent_run_begin_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/chat_request_event.json 
b/e2e-test/cross-language-event-snapshots/java/chat_request_event.json
index 347c47e7..1a233418 100644
--- a/e2e-test/cross-language-event-snapshots/java/chat_request_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/chat_request_event.json
@@ -9,5 +9,6 @@
       "extra_args" : { }
     } ]
   },
+  "attachments" : { },
   "type" : "_chat_request_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/chat_response_event.json 
b/e2e-test/cross-language-event-snapshots/java/chat_response_event.json
index 3d5b4793..562b034b 100644
--- a/e2e-test/cross-language-event-snapshots/java/chat_response_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/chat_response_event.json
@@ -11,5 +11,6 @@
     "retry_count" : 0,
     "total_retry_wait_sec" : 0
   },
+  "attachments" : { },
   "type" : "_chat_response_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/context_retrieval_request_event.json
 
b/e2e-test/cross-language-event-snapshots/java/context_retrieval_request_event.json
index ead03f8d..e9f868cd 100644
--- 
a/e2e-test/cross-language-event-snapshots/java/context_retrieval_request_event.json
+++ 
b/e2e-test/cross-language-event-snapshots/java/context_retrieval_request_event.json
@@ -5,5 +5,6 @@
     "vector_store" : "test-store",
     "max_results" : 5
   },
+  "attachments" : { },
   "type" : "_context_retrieval_request_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/context_retrieval_response_event.json
 
b/e2e-test/cross-language-event-snapshots/java/context_retrieval_response_event.json
index 90592d56..b7cd0caa 100644
--- 
a/e2e-test/cross-language-event-snapshots/java/context_retrieval_response_event.json
+++ 
b/e2e-test/cross-language-event-snapshots/java/context_retrieval_response_event.json
@@ -13,5 +13,6 @@
       "score" : null
     } ]
   },
+  "attachments" : { },
   "type" : "_context_retrieval_response_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/generic_event_with_attrs.json 
b/e2e-test/cross-language-event-snapshots/java/generic_event_with_attrs.json
index 96b9fc0f..91f7216b 100644
--- a/e2e-test/cross-language-event-snapshots/java/generic_event_with_attrs.json
+++ b/e2e-test/cross-language-event-snapshots/java/generic_event_with_attrs.json
@@ -11,5 +11,6 @@
     "k_dict" : {
       "nested" : "value"
     }
-  }
+  },
+  "attachments" : { }
 }
diff --git a/e2e-test/cross-language-event-snapshots/java/input_event.json 
b/e2e-test/cross-language-event-snapshots/java/input_event.json
index 8150a1ce..72801563 100644
--- a/e2e-test/cross-language-event-snapshots/java/input_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/input_event.json
@@ -3,5 +3,6 @@
   "attributes" : {
     "input" : "hello"
   },
+  "attachments" : { },
   "type" : "_input_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/memory_ref_attachment_event.json 
b/e2e-test/cross-language-event-snapshots/java/memory_ref_attachment_event.json
new file mode 100644
index 00000000..43956e65
--- /dev/null
+++ 
b/e2e-test/cross-language-event-snapshots/java/memory_ref_attachment_event.json
@@ -0,0 +1,14 @@
+{
+  "id" : "00000000-0000-0000-0000-000000000001",
+  "type" : "_memory_ref_attachment_event",
+  "attributes" : {
+    "value" : "ping"
+  },
+  "attachments" : {
+    "payload" : {
+      "@type" : "memory_ref",
+      "memory_type" : "sensory",
+      "path" : "memory.path"
+    }
+  }
+}
diff --git a/e2e-test/cross-language-event-snapshots/java/output_event.json 
b/e2e-test/cross-language-event-snapshots/java/output_event.json
index 3fb4269e..3d7941ed 100644
--- a/e2e-test/cross-language-event-snapshots/java/output_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/output_event.json
@@ -3,5 +3,6 @@
   "attributes" : {
     "output" : "world"
   },
+  "attachments" : { },
   "type" : "_output_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/short_term_write_event.json 
b/e2e-test/cross-language-event-snapshots/java/short_term_write_event.json
index a6f906c2..f62d61b1 100644
--- a/e2e-test/cross-language-event-snapshots/java/short_term_write_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/short_term_write_event.json
@@ -7,5 +7,6 @@
       "profile.m_a01" : null
     }
   },
+  "attachments" : { },
   "type" : "_short_term_write_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/tool_request_event.json 
b/e2e-test/cross-language-event-snapshots/java/tool_request_event.json
index 0f8ab2f3..0b88c932 100644
--- a/e2e-test/cross-language-event-snapshots/java/tool_request_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/tool_request_event.json
@@ -10,5 +10,6 @@
       }
     } ]
   },
+  "attachments" : { },
   "type" : "_tool_request_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/java/tool_response_event.json 
b/e2e-test/cross-language-event-snapshots/java/tool_response_event.json
index 04698abe..850dea38 100644
--- a/e2e-test/cross-language-event-snapshots/java/tool_response_event.json
+++ b/e2e-test/cross-language-event-snapshots/java/tool_response_event.json
@@ -18,5 +18,6 @@
     "external_ids" : { },
     "timestamp" : 1700000000000
   },
+  "attachments" : { },
   "type" : "_tool_response_event"
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/agent_run_begin_event.json 
b/e2e-test/cross-language-event-snapshots/python/agent_run_begin_event.json
index 79626d1d..6768af7e 100644
--- a/e2e-test/cross-language-event-snapshots/python/agent_run_begin_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/agent_run_begin_event.json
@@ -7,5 +7,6 @@
       "user.tier": "gold",
       "user.address.city": "SF"
     }
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/chat_request_event.json 
b/e2e-test/cross-language-event-snapshots/python/chat_request_event.json
index ac880823..6d5ca620 100644
--- a/e2e-test/cross-language-event-snapshots/python/chat_request_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/chat_request_event.json
@@ -13,5 +13,6 @@
     ],
     "prompt_args": {},
     "output_schema": null
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/chat_response_event.json 
b/e2e-test/cross-language-event-snapshots/python/chat_response_event.json
index bafb2811..fae3956f 100644
--- a/e2e-test/cross-language-event-snapshots/python/chat_response_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/chat_response_event.json
@@ -11,5 +11,6 @@
     },
     "retry_count": 0,
     "total_retry_wait_sec": 0
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/context_retrieval_request_event.json
 
b/e2e-test/cross-language-event-snapshots/python/context_retrieval_request_event.json
index 357ce8bc..6ad4ef14 100644
--- 
a/e2e-test/cross-language-event-snapshots/python/context_retrieval_request_event.json
+++ 
b/e2e-test/cross-language-event-snapshots/python/context_retrieval_request_event.json
@@ -5,5 +5,6 @@
     "query": "what is flink",
     "vector_store": "test-store",
     "max_results": 5
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/context_retrieval_response_event.json
 
b/e2e-test/cross-language-event-snapshots/python/context_retrieval_response_event.json
index 95e14f0a..3c0dc4e4 100644
--- 
a/e2e-test/cross-language-event-snapshots/python/context_retrieval_response_event.json
+++ 
b/e2e-test/cross-language-event-snapshots/python/context_retrieval_response_event.json
@@ -15,5 +15,6 @@
         "score": null
       }
     ]
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/generic_event_with_attrs.json 
b/e2e-test/cross-language-event-snapshots/python/generic_event_with_attrs.json
index cfd461b3..1896e8ca 100644
--- 
a/e2e-test/cross-language-event-snapshots/python/generic_event_with_attrs.json
+++ 
b/e2e-test/cross-language-event-snapshots/python/generic_event_with_attrs.json
@@ -15,5 +15,6 @@
     "k_dict": {
       "nested": "value"
     }
-  }
+  },
+  "attachments": {}
 }
diff --git a/e2e-test/cross-language-event-snapshots/python/input_event.json 
b/e2e-test/cross-language-event-snapshots/python/input_event.json
index db24e4c5..35eab9af 100644
--- a/e2e-test/cross-language-event-snapshots/python/input_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/input_event.json
@@ -3,5 +3,6 @@
   "type": "_input_event",
   "attributes": {
     "input": "hello"
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/memory_ref_attachment_event.json
 
b/e2e-test/cross-language-event-snapshots/python/memory_ref_attachment_event.json
new file mode 100644
index 00000000..203d613c
--- /dev/null
+++ 
b/e2e-test/cross-language-event-snapshots/python/memory_ref_attachment_event.json
@@ -0,0 +1,14 @@
+{
+  "id": "00000000-0000-0000-0000-000000000001",
+  "type": "_memory_ref_attachment_event",
+  "attributes": {
+    "value": "ping"
+  },
+  "attachments": {
+    "payload": {
+      "@type": "memory_ref",
+      "memory_type": "sensory",
+      "path": "memory.path"
+    }
+  }
+}
diff --git a/e2e-test/cross-language-event-snapshots/python/output_event.json 
b/e2e-test/cross-language-event-snapshots/python/output_event.json
index f4b48a74..7d0d2c4a 100644
--- a/e2e-test/cross-language-event-snapshots/python/output_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/output_event.json
@@ -3,5 +3,6 @@
   "type": "_output_event",
   "attributes": {
     "output": "world"
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/python_only_subclass_event.json
 
b/e2e-test/cross-language-event-snapshots/python/python_only_subclass_event.json
index a48448c1..087c32a4 100644
--- 
a/e2e-test/cross-language-event-snapshots/python/python_only_subclass_event.json
+++ 
b/e2e-test/cross-language-event-snapshots/python/python_only_subclass_event.json
@@ -4,5 +4,6 @@
   "attributes": {
     "value": "ping",
     "count": 7
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/short_term_write_event.json 
b/e2e-test/cross-language-event-snapshots/python/short_term_write_event.json
index 1a4ac90a..5588c317 100644
--- a/e2e-test/cross-language-event-snapshots/python/short_term_write_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/short_term_write_event.json
@@ -7,5 +7,6 @@
       "user.tier": "gold",
       "profile.m_a01": null
     }
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/tool_request_event.json 
b/e2e-test/cross-language-event-snapshots/python/tool_request_event.json
index 2ac1fc51..d311b941 100644
--- a/e2e-test/cross-language-event-snapshots/python/tool_request_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/tool_request_event.json
@@ -12,5 +12,6 @@
         }
       }
     ]
-  }
+  },
+  "attachments": {}
 }
diff --git 
a/e2e-test/cross-language-event-snapshots/python/tool_response_event.json 
b/e2e-test/cross-language-event-snapshots/python/tool_response_event.json
index 4ec6d0b0..65b2cbd6 100644
--- a/e2e-test/cross-language-event-snapshots/python/tool_response_event.json
+++ b/e2e-test/cross-language-event-snapshots/python/tool_response_event.json
@@ -19,5 +19,6 @@
       "call_bbbb": null,
       "call_cccc": null
     }
-  }
+  },
+  "attachments": {}
 }
diff --git a/python/flink_agents/api/events/event.py 
b/python/flink_agents/api/events/event.py
index 57fa10d1..ea269ca4 100644
--- a/python/flink_agents/api/events/event.py
+++ b/python/flink_agents/api/events/event.py
@@ -36,6 +36,8 @@ from pydantic import (
 from pydantic_core import PydanticSerializationError
 from pyflink.common import Row
 
+from flink_agents.api.memory_reference import MemoryRef
+
 
 def _reconstruct_row_if_needed(data: Any) -> Any:
     """Recursively reconstruct pyflink Row objects from their JSON-serialized 
dicts.
@@ -79,6 +81,8 @@ class Event(BaseModel, extra="allow"):
         Event type string used for routing. Required for all events.
     attributes : Dict[str, Any]
         Key-value properties for the event data.
+    attachments : Dict[str, Any]
+        Key-value data passed between actions through sensory memory.
     upstream_event_id : UUID | None
         The ID of the direct upstream Event, or None.
     upstream_action_name : str | None
@@ -88,6 +92,7 @@ class Event(BaseModel, extra="allow"):
     id: UUID = Field(default_factory=uuid4, frozen=True)
     type: str
     attributes: Dict[str, Any] = Field(default_factory=dict)
+    attachments: Dict[str, Any] = Field(default_factory=dict)
     upstream_event_id: UUID | None = Field(
         default=None,
         validation_alias=AliasChoices("upstream_event_id", "upstreamEventId"),
@@ -105,6 +110,20 @@ class Event(BaseModel, extra="allow"):
         """Treat explicit None like an omitted id and mint a per-occurrence 
UUID."""
         return uuid4() if value is None else value
 
+    @field_validator("attachments", mode="before")
+    @classmethod
+    def _deserialize_memory_ref_attachments(cls, attachments: Any) -> Any:
+        """Restore explicitly tagged memory-reference attachment values."""
+        if not isinstance(attachments, dict):
+            return attachments
+        return {
+            key: MemoryRef.model_validate(value)
+            if isinstance(value, dict)
+            and value.get(MemoryRef.TYPE_FIELD) == MemoryRef.TYPE_VALUE
+            else value
+            for key, value in attachments.items()
+        }
+
     @staticmethod
     def __serialize_unknown(field: Any) -> Dict[str, Any]:
         """Handle serialization of unknown types, specifically Row objects."""
@@ -143,20 +162,22 @@ class Event(BaseModel, extra="allow"):
 
     @model_validator(mode="after")
     def validate_serializable_fields(self) -> "Event":
-        """Validate that all Event fields can be serialized."""
-        self.model_dump_json()
+        """Validate JSON event fields without serializing raw attachments."""
+        self.model_dump_json(exclude={"attachments"})
         return self
 
     def __setattr__(self, name: str, value: Any) -> None:
         super().__setattr__(name, value)
-        # Ensure added property can be serialized.
-        self.model_dump_json()
+        # Raw attachments are offloaded to sensory memory before sending. 
Validate every
+        # other field here without serializing those payloads.
+        self.model_dump_json(exclude={"attachments"})
 
     def reconstruct_from(self, source: "Event") -> Self:
         """Return a typed copy representing the same Event occurrence as 
source."""
         return self.model_copy(
             update={
                 "id": source.id,
+                "attachments": dict(source.attachments),
                 "upstream_event_id": source.upstream_event_id,
                 "upstream_action_name": source.upstream_action_name,
             }
@@ -174,6 +195,18 @@ class Event(BaseModel, extra="allow"):
         """Set an attribute value in the attributes map."""
         self.attributes[name] = value
 
+    def get_attachment(self, name: str) -> Any:
+        """Get an attachment value from the attachments map."""
+        return self.attachments.get(name)
+
+    def set_attachment(self, name: str, value: Any) -> None:
+        """Set an attachment value in the attachments map.
+
+        If ``value`` is a :class:`MemoryRef`, it must reference sensory memory 
and
+        will not be wrapped again.
+        """
+        self.attachments = {**self.attachments, name: value}
+
     @classmethod
     def from_event(cls, event: "Event") -> "Event":
         """Reconstruct a typed event from a base Event.
@@ -248,6 +281,9 @@ class OutputEvent(Event):
     """Event representing a result from agent. By generating an OutputEvent,
     actions can emit output data.
 
+    Attachments are only supported on events passed between actions and cannot
+    be carried by an OutputEvent.
+
     Attributes:
     ----------
     output : Any
@@ -266,6 +302,9 @@ class OutputEvent(Event):
     @classmethod
     @override
     def from_event(cls, event: Event) -> "OutputEvent":
+        if event.attachments:
+            msg = "OutputEvent cannot carry attachments."
+            raise ValueError(msg)
         assert "output" in event.attributes
         result = OutputEvent(output=event.attributes["output"])
         return result.reconstruct_from(event)
diff --git a/python/flink_agents/api/memory_object.py 
b/python/flink_agents/api/memory_object.py
index 169d2e16..96452c7b 100644
--- a/python/flink_agents/api/memory_object.py
+++ b/python/flink_agents/api/memory_object.py
@@ -91,7 +91,7 @@ def _validate(value: Any, where: str) -> None:
 class MemoryType(Enum):
     """Memory types based on MemoryObject."""
 
-    SENSORY = ("sensory",)
+    SENSORY = "sensory"
     SHORT_TERM = "short_term"
 
 
diff --git a/python/flink_agents/api/memory_reference.py 
b/python/flink_agents/api/memory_reference.py
index 5c37793d..d48e614b 100644
--- a/python/flink_agents/api/memory_reference.py
+++ b/python/flink_agents/api/memory_reference.py
@@ -17,9 +17,9 @@
 
#################################################################################
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, ClassVar
 
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, model_serializer
 
 from flink_agents.api.memory_object import MemoryType
 
@@ -30,6 +30,9 @@ if TYPE_CHECKING:
 class MemoryRef(BaseModel):
     """Reference to a specific data item in the Short-Term Memory."""
 
+    TYPE_FIELD: ClassVar[str] = "@type"
+    TYPE_VALUE: ClassVar[str] = "memory_ref"
+
     memory_type: MemoryType = MemoryType.SHORT_TERM
     path: str
 
@@ -53,6 +56,11 @@ class MemoryRef(BaseModel):
         """
         return MemoryRef(memory_type=memory_type, path=path)
 
+    @model_serializer(mode="wrap")
+    def _serialize_with_discriminator(self, handler: Any) -> dict[str, Any]:
+        """Serialize this reference with its language-neutral type 
discriminator."""
+        return {self.TYPE_FIELD: self.TYPE_VALUE, **handler(self)}
+
     def resolve(self, ctx: RunnerContext) -> Any:
         """Resolve the reference to get the actual data.
 
diff --git 
a/python/flink_agents/api/tests/test_cross_language_event_snapshots.py 
b/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
index 545cbfdf..dc8c83e5 100644
--- a/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
+++ b/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
@@ -35,6 +35,8 @@ from flink_agents.api.events.event import Event, InputEvent, 
OutputEvent
 from flink_agents.api.events.memory_event import MemoryEvent, 
ShortTermWriteEvent
 from flink_agents.api.events.run_event import AgentRunBeginEvent
 from flink_agents.api.events.tool_event import ToolRequestEvent, 
ToolResponseEvent
+from flink_agents.api.memory_object import MemoryType
+from flink_agents.api.memory_reference import MemoryRef
 from flink_agents.api.vector_stores.vector_store import Document
 
 _REPO_ROOT = Path(__file__).resolve().parents[4]
@@ -560,6 +562,31 @@ def test_python_only_subclass_event_snapshot_is_stable() 
-> None:
     )
 
 
+def test_memory_ref_attachment_cross_language_snapshot_is_stable() -> None:
+    python_event = _force_id(
+        Event(
+            type="_memory_ref_attachment_event",
+            attributes={"value": "ping"},
+        ),
+        _FIXED_EVENT_ID,
+    )
+    python_event.set_attachment(
+        "payload", MemoryRef.create(MemoryType.SENSORY, "memory.path")
+    )
+    if _regenerate_enabled():
+        _write_python_snapshot("memory_ref_attachment_event.json", 
python_event)
+    _assert_python_snapshot_stable("memory_ref_attachment_event.json", 
python_event)
+
+    java_event = _read_java_snapshot("memory_ref_attachment_event.json")
+    assert java_event.id == _FIXED_EVENT_ID
+    assert java_event.type == "_memory_ref_attachment_event"
+    assert java_event.get_attr("value") == "ping"
+    reference = java_event.get_attachment("payload")
+    assert isinstance(reference, MemoryRef)
+    assert reference.memory_type == MemoryType.SENSORY
+    assert reference.path == "memory.path"
+
+
 # ── Smoke ───────────────────────────────────────────────────────────────
 
 
diff --git a/python/flink_agents/api/tests/test_event.py 
b/python/flink_agents/api/tests/test_event.py
index 4cd3cb56..ce945461 100644
--- a/python/flink_agents/api/tests/test_event.py
+++ b/python/flink_agents/api/tests/test_event.py
@@ -25,6 +25,8 @@ from pydantic_core import PydanticSerializationError
 from pyflink.common import Row
 
 from flink_agents.api.events.event import Event, InputEvent, OutputEvent
+from flink_agents.api.memory_object import MemoryType
+from flink_agents.api.memory_reference import MemoryRef
 
 
 class _CustomEvent(Event):
@@ -237,14 +239,31 @@ def test_output_event_from_event() -> None:
     assert reconstructed.type == "_output_event"
 
 
+def test_output_event_from_event_rejects_attachments() -> None:
+    """Test OutputEvent.from_event rejects an invalid attached source Event."""
+    base = Event(
+        type="_output_event",
+        attributes={"output": 99},
+        attachments={"payload": "attachment"},
+    )
+
+    with pytest.raises(ValueError, match="OutputEvent cannot carry 
attachments"):
+        OutputEvent.from_event(base)
+
+
 # ── Unified Event tests ──────────────────────────────────────────────────
 
 
 def test_unified_event_creation() -> None:
     """Test creating a unified event with type and attributes."""
-    event = Event(type="MyEvent", attributes={"field1": "test", "field2": 42})
+    event = Event(
+        type="MyEvent",
+        attributes={"field1": "test", "field2": 42},
+        attachments={"field3": "0105", "field4": 2004},
+    )
     assert event.type == "MyEvent"
     assert event.attributes == {"field1": "test", "field2": 42}
+    assert event.attachments == {"field3": "0105", "field4": 2004}
     assert event.get_type() == "MyEvent"
 
 
@@ -262,6 +281,14 @@ def test_unified_event_get_attr_set_attr() -> None:
     assert event.get_attr("missing") is None
 
 
+def test_unified_event_get_attachment_set_attachment() -> None:
+    """Test get_attachment and set_attachment convenience methods."""
+    event = Event(type="TestEvent")
+    event.set_attachment("key", "value")
+    assert event.get_attachment("key") == "value"
+    assert event.get_attachment("missing") is None
+
+
 def test_unified_event_from_json() -> None:
     """Test deserializing a unified event from JSON."""
     data = {"type": "MyEvent", "attributes": {"x": 1}}
@@ -309,6 +336,19 @@ def test_unified_event_serialization_roundtrip() -> None:
     assert restored.attributes == {"a": 1, "b": "two"}
 
 
+def test_unified_event_serialization_roundtrip_with_memory_ref_attachment() -> 
None:
+    """Test that tagged memory-reference attachments retain their concrete 
type."""
+    reference = MemoryRef.create(MemoryType.SENSORY, "memory.path")
+    original = Event(type="RoundTrip", attachments={"payload": reference})
+
+    parsed = json.loads(original.model_dump_json())
+    restored = Event.model_validate(parsed)
+
+    assert parsed["attachments"]["payload"][MemoryRef.TYPE_FIELD] == 
MemoryRef.TYPE_VALUE
+    assert restored.get_attachment("payload") == reference
+    assert isinstance(restored.get_attachment("payload"), MemoryRef)
+
+
 def test_event_lineage_json_roundtrip_uses_java_field_names() -> None:
     """Test framework-managed lineage has a stable cross-language JSON 
shape."""
     upstream_event_id = UUID("00000000-0000-0000-0000-000000000001")
diff --git 
a/python/flink_agents/e2e_tests/e2e_tests_integration/event_attachments_test.py 
b/python/flink_agents/e2e_tests/e2e_tests_integration/event_attachments_test.py
new file mode 100644
index 00000000..a1dc7041
--- /dev/null
+++ 
b/python/flink_agents/e2e_tests/e2e_tests_integration/event_attachments_test.py
@@ -0,0 +1,103 @@
+################################################################################
+#  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.
+#################################################################################
+import os
+import sys
+import sysconfig
+from pathlib import Path
+from typing import Any
+
+from pyflink.common import Configuration
+from pyflink.datastream import KeySelector, StreamExecutionEnvironment
+
+from flink_agents.api.agents.agent import Agent
+from flink_agents.api.decorators import action
+from flink_agents.api.events.event import Event, InputEvent, OutputEvent
+from flink_agents.api.execution_environment import AgentsExecutionEnvironment
+from flink_agents.api.runner_context import RunnerContext
+
+current_dir = Path(__file__).parent
+os.environ["PYTHONPATH"] = (
+    f"{current_dir.parent.parent.parent}:{sysconfig.get_paths()['purelib']}"
+)
+
+
+class _KeySelector(KeySelector):
+    def get_key(self, value: dict[str, Any]) -> str:
+        return str(value["key"])
+
+
+class EventAttachmentsAgent(Agent):
+    @action(InputEvent.EVENT_TYPE)
+    @staticmethod
+    def send_attachment(event: Event, ctx: RunnerContext) -> None:
+        value = InputEvent.from_event(event).input
+        ctx.send_event(
+            Event(
+                type="AttachmentStep",
+                attributes={"kind": "inline"},
+                attachments={
+                    "payload": {
+                        "value": value,
+                        "items": [1, 2, 3],
+                    }
+                },
+            )
+        )
+
+    @action("AttachmentStep")
+    @staticmethod
+    def receive_attachment(event: Event, ctx: RunnerContext) -> None:
+        attachment = event.get_attachment("payload")
+        ctx.send_event(
+            OutputEvent(
+                output={
+                    "kind": event.get_attr("kind"),
+                    "payload": {
+                        "value": attachment["value"],
+                        "items": attachment["items"],
+                    },
+                }
+            )
+        )
+
+
+def test_python_event_attachments_roundtrip_on_flink() -> None:
+    config = Configuration()
+    config.set_string("python.pythonpath", os.environ["PYTHONPATH"])
+    env = StreamExecutionEnvironment.get_execution_environment(config)
+    env.set_python_executable(sys.executable)
+    env.set_parallelism(1)
+    input_stream = env.from_collection(
+        [{"key": "k1", "value": {"message": "hello"}}]
+    )
+    agents_env = AgentsExecutionEnvironment.get_execution_environment(env=env)
+    output = (
+        agents_env.from_datastream(input_stream, _KeySelector())
+        .apply(EventAttachmentsAgent())
+        .to_datastream()
+    )
+
+    assert list(output.execute_and_collect()) == [
+        {
+            "kind": "inline",
+            "payload": {
+                "value": {"key": "k1", "value": {"message": "hello"}},
+                "items": [1, 2, 3],
+            },
+        }
+    ]
diff --git a/python/flink_agents/runtime/flink_runner_context.py 
b/python/flink_agents/runtime/flink_runner_context.py
index 4e9fefa8..8fab1e7f 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -55,6 +55,7 @@ from flink_agents.runtime.durable_execution import (
 )
 from flink_agents.runtime.flink_memory_object import FlinkMemoryObject
 from flink_agents.runtime.flink_metric_group import FlinkMetricGroup
+from flink_agents.runtime.memory.event_attachment_utils import 
store_event_attachments
 from flink_agents.runtime.memory.internal_base_long_term_memory import (
     InternalBaseLongTermMemory,
 )
@@ -514,6 +515,7 @@ class FlinkRunnerContext(RunnerContext, ExecutionReporter):
         event : Event
             The event to be processed by the agent system.
         """
+        store_event_attachments(event, self)
         event_json = event.model_dump_json()
         try:
             self._j_runner_context.sendEventJson(event_json)
diff --git a/python/flink_agents/runtime/memory/event_attachment_utils.py 
b/python/flink_agents/runtime/memory/event_attachment_utils.py
new file mode 100644
index 00000000..c7b555c2
--- /dev/null
+++ b/python/flink_agents/runtime/memory/event_attachment_utils.py
@@ -0,0 +1,108 @@
+################################################################################
+#  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.
+#################################################################################
+from __future__ import annotations
+
+import hashlib
+from typing import TYPE_CHECKING, Any
+
+from flink_agents.api.events.event import OutputEvent
+from flink_agents.api.memory_object import MemoryType, validate_memory_value
+from flink_agents.api.memory_reference import MemoryRef
+
+if TYPE_CHECKING:
+    from uuid import UUID
+
+    from flink_agents.api.events.event import Event
+    from flink_agents.api.runner_context import RunnerContext
+
+_ATTACHMENT_ROOT = "__event_attachments__"
+
+
+class EventAttachmentError(RuntimeError):
+    """Raised when an Event attachment cannot be stored or loaded."""
+
+
+def _hash_attachment_key(key: str) -> str:
+    return hashlib.sha256(key.encode("UTF-8")).hexdigest()
+
+
+def build_attachment_path(event_id: UUID, key: str) -> str:
+    """Build the canonical SensoryMemory path for one attachment."""
+    return f"{_ATTACHMENT_ROOT}.{event_id}.{_hash_attachment_key(key)}"
+
+
+def _attachment_context(event: Event, key: str, path: str | None = None) -> 
str:
+    suffix = f", path={path}" if path is not None else ""
+    return f"event_id={event.id}, event_type={event.type}, key={key}{suffix}"
+
+
+def store_event_attachments(event: Event, ctx: RunnerContext) -> None:
+    """Store concrete attachment values in SensoryMemory and replace them with 
refs."""
+    if not event.attachments:
+        return
+
+    if event.type == OutputEvent.EVENT_TYPE:
+        keys = ", ".join(sorted(event.attachments))
+        msg = f"Output events cannot carry attachments: 
{_attachment_context(event, keys)}"
+        raise EventAttachmentError(msg)
+
+    pending: list[tuple[str, str, Any]] = []
+    for key, value in event.attachments.items():
+        if isinstance(value, MemoryRef):
+            if value.memory_type != MemoryType.SENSORY:
+                msg = (
+                    "Event attachments must use sensory memory references: "
+                    f"{_attachment_context(event, key, value.path)}"
+                )
+                raise EventAttachmentError(msg)
+            continue
+        path = build_attachment_path(event.id, key)
+        try:
+            validate_memory_value(path, value)
+        except Exception as exc:
+            msg = f"Invalid event attachment value: 
{_attachment_context(event, key, path)}"
+            raise EventAttachmentError(msg) from exc
+        pending.append((key, path, value))
+
+    for key, path, value in pending:
+        event.attachments[key] = ctx.sensory_memory.set(path, value)
+
+
+def load_event_attachments(event: Event, ctx: RunnerContext) -> None:
+    """Load sensory refs in place immediately before a Python Action runs."""
+    for key, value in list(event.attachments.items()):
+        if not isinstance(value, MemoryRef):
+            continue
+
+        try:
+            sensory_memory = ctx.sensory_memory
+            attachment = sensory_memory.get(value)
+            attachment_missing = attachment is None and not 
sensory_memory.is_exist(
+                value.path
+            )
+        except Exception as exc:
+            msg = f"Failed to load event attachment: 
{_attachment_context(event, key, value.path)}"
+            raise EventAttachmentError(msg) from exc
+
+        if attachment_missing:
+            msg = (
+                "Event attachment does not exist in sensory memory: "
+                f"{_attachment_context(event, key, value.path)}"
+            )
+            raise EventAttachmentError(msg)
+        event.attachments[key] = attachment
diff --git a/python/flink_agents/runtime/python_java_utils.py 
b/python/flink_agents/runtime/python_java_utils.py
index d13db0b7..22f21353 100644
--- a/python/flink_agents/runtime/python_java_utils.py
+++ b/python/flink_agents/runtime/python_java_utils.py
@@ -42,6 +42,7 @@ from flink_agents.runtime.java.java_resource_wrapper import (
     JavaResourceContextWrapper,
     JavaTool,
 )
+from flink_agents.runtime.memory.event_attachment_utils import 
load_event_attachments
 
 
 def convert_to_python_object(bytesObject: bytes) -> Any:
@@ -67,6 +68,12 @@ def convert_json_to_python_event(event_json: str) -> Event:
     return Event.from_json(event_json)
 
 
+def load_event_attachments_for_action(event: Event, ctx: Any) -> Event:
+    """Load attachment values before invoking a Python action."""
+    load_event_attachments(event, ctx)
+    return event
+
+
 def wrap_to_input_event(bytesObject: bytes) -> str:
     """Wrap data to python input event and serialize as JSON.
 
diff --git a/python/flink_agents/runtime/tests/test_event_attachment_utils.py 
b/python/flink_agents/runtime/tests/test_event_attachment_utils.py
new file mode 100644
index 00000000..6b871f6a
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_event_attachment_utils.py
@@ -0,0 +1,178 @@
+################################################################################
+#  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.
+#################################################################################
+import uuid
+
+import pytest
+
+from flink_agents.api.events.event import Event, OutputEvent
+from flink_agents.api.memory_object import MemoryType
+from flink_agents.api.memory_reference import MemoryRef
+from flink_agents.runtime.memory.event_attachment_utils import (
+    EventAttachmentError,
+    build_attachment_path,
+    load_event_attachments,
+    store_event_attachments,
+)
+from flink_agents.runtime.tests.local_memory_object import LocalMemoryObject
+
+
+class MockRunnerContext:
+    def __init__(self, memory: LocalMemoryObject) -> None:
+        self._memory = memory
+
+    @property
+    def sensory_memory(self) -> LocalMemoryObject:
+        return self._memory
+
+
+def test_store_event_attachments() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event_id = uuid.uuid4()
+    payload = {"value": "original"}
+    event = Event.model_construct(
+        id=event_id,
+        type="AttachmentStep",
+        attributes={},
+        attachments={"payload": payload},
+    )
+
+    store_event_attachments(event, ctx)
+
+    attachment = event.get_attachment("payload")
+    assert isinstance(attachment, MemoryRef)
+    assert attachment.path == build_attachment_path(event_id, "payload")
+    assert sensory_memory.get(attachment) == payload
+
+
+def test_store_offloads_non_utf8_bytes_from_regular_event() -> None:
+    """Test that raw bytes are offloaded before JSON serialization is 
required."""
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    payload = b"\xff\x00"
+    event = Event(type="AttachmentStep", attachments={"payload": payload})
+
+    store_event_attachments(event, ctx)
+
+    attachment = event.get_attachment("payload")
+    assert isinstance(attachment, MemoryRef)
+    assert sensory_memory.get(attachment) == payload
+    event.model_dump_json()
+
+
+def test_store_rejects_non_sensory_memory_references() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event = Event(
+        type="AttachmentStep",
+        attachments={
+            "payload": MemoryRef.create(MemoryType.SHORT_TERM, 
"attachment.path")
+        },
+    )
+
+    with pytest.raises(EventAttachmentError) as exc_info:
+        store_event_attachments(event, ctx)
+
+    assert str(exc_info.value).startswith(
+        "Event attachments must use sensory memory references:"
+    )
+
+
+def test_store_rejects_output_event_attachments_before_storing_them() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event_id = uuid.uuid4()
+    attachments = {"zeta": {"value": 2}, "alpha": {"value": 1}}
+    event = Event.model_construct(
+        id=event_id,
+        type=OutputEvent.EVENT_TYPE,
+        attributes={"output": "result"},
+        attachments=dict(attachments),
+    )
+
+    with pytest.raises(EventAttachmentError) as exc_info:
+        store_event_attachments(event, ctx)
+
+    message = str(exc_info.value)
+    assert message.startswith("Output events cannot carry attachments:")
+
+
+def test_load_event_attachments() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event_id = uuid.uuid4()
+    payload = {"value": "original"}
+    reference = sensory_memory.set(build_attachment_path(event_id, "payload"), 
payload)
+    event = Event.model_construct(
+        id=event_id,
+        type="AttachmentStep",
+        attributes={},
+        attachments={"payload": reference},
+    )
+
+    load_event_attachments(event, ctx)
+
+    assert event.get_attachment("payload") == payload
+
+
+def test_load_preserves_none_event_attachment() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event_id = uuid.uuid4()
+    reference = sensory_memory.set(build_attachment_path(event_id, "payload"), 
None)
+    event = Event.model_construct(
+        id=event_id,
+        type="AttachmentStep",
+        attributes={},
+        attachments={"payload": reference},
+    )
+
+    load_event_attachments(event, ctx)
+
+    assert event.get_attachment("payload") is None
+
+
+def test_load_rejects_missing_event_attachment() -> None:
+    sensory_memory = LocalMemoryObject(MemoryType.SENSORY, {})
+    ctx = MockRunnerContext(sensory_memory)
+    event = Event(
+        type="AttachmentStep",
+        attachments={
+            "payload": MemoryRef.create(MemoryType.SENSORY, 
"missing.attachment")
+        },
+    )
+
+    with pytest.raises(EventAttachmentError) as exc_info:
+        load_event_attachments(event, ctx)
+
+    assert str(exc_info.value).startswith(
+        "Event attachment does not exist in sensory memory:"
+    )
+
+
+def test_build_attachment_path() -> None:
+    event_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
+
+    path = build_attachment_path(event_id, "payload")
+
+    assert (
+        path
+        == "__event_attachments__."
+        + str(event_id)
+        + ".239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5"
+    )
diff --git a/python/flink_agents/runtime/tests/test_flink_runner_context.py 
b/python/flink_agents/runtime/tests/test_flink_runner_context.py
new file mode 100644
index 00000000..f283bab0
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_flink_runner_context.py
@@ -0,0 +1,68 @@
+################################################################################
+#  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.
+#################################################################################
+from typing import Any
+
+from flink_agents.api.events.event import Event
+from flink_agents.api.memory_reference import MemoryRef
+from flink_agents.runtime.flink_runner_context import FlinkRunnerContext
+
+
+class _FakeJavaMemoryRef:
+    def __init__(self, path: str) -> None:
+        self._path = path
+
+    def getPath(self) -> str:
+        return self._path
+
+
+class _FakeJavaSensoryMemory:
+    def __init__(self) -> None:
+        self.values: dict[str, Any] = {}
+
+    def set(self, path: str, value: Any) -> _FakeJavaMemoryRef:
+        self.values[path] = value
+        return _FakeJavaMemoryRef(path)
+
+
+class _FakeJavaRunnerContext:
+    def __init__(self) -> None:
+        self.sensory_memory = _FakeJavaSensoryMemory()
+        self.sent_event_json: str | None = None
+
+    def getSensoryMemory(self) -> _FakeJavaSensoryMemory:
+        return self.sensory_memory
+
+    def sendEventJson(self, event_json: str) -> None:
+        self.sent_event_json = event_json
+
+
+def test_send_event_offloads_attachments_before_forwarding() -> None:
+    java_context = _FakeJavaRunnerContext()
+    ctx = FlinkRunnerContext.__new__(FlinkRunnerContext)
+    ctx._j_runner_context = java_context
+    payload = {"value": "original"}
+    event = Event(type="AttachmentStep", attachments={"payload": payload})
+
+    ctx.send_event(event)
+
+    attachment = event.get_attachment("payload")
+    assert isinstance(attachment, MemoryRef)
+    assert java_context.sensory_memory.values[attachment.path] == payload
+    assert java_context.sent_event_json is not None
+    forwarded_event = Event.from_json(java_context.sent_event_json)
+    assert forwarded_event.get_attachment("payload") == attachment
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
index 49d56e37..fe1c4ccf 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
@@ -41,6 +41,7 @@ import 
org.apache.flink.agents.runtime.actionstate.ActionState;
 import org.apache.flink.agents.runtime.actionstate.CallResult;
 import org.apache.flink.agents.runtime.lifecycle.ComponentExecutionListener;
 import org.apache.flink.agents.runtime.memory.CachedMemoryStore;
+import org.apache.flink.agents.runtime.memory.EventAttachmentUtils;
 import org.apache.flink.agents.runtime.memory.InteranlBaseLongTermMemory;
 import org.apache.flink.agents.runtime.memory.MemoryEventBuilder;
 import org.apache.flink.agents.runtime.memory.MemoryEventSettings;
@@ -215,6 +216,11 @@ public class RunnerContextImpl implements RunnerContext, 
ExecutionReporter {
     @Override
     public void sendEvent(Event event) {
         mailboxThreadChecker.run();
+        try {
+            EventAttachmentUtils.storeEventAttachments(event, this);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("Failed to store event 
attachments.", e);
+        }
         try {
             JsonUtils.checkSerializable(event);
         } catch (JsonProcessingException e) {
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtils.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtils.java
new file mode 100644
index 00000000..6928d7b7
--- /dev/null
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtils.java
@@ -0,0 +1,146 @@
+/*
+ * 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.runtime.memory;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/** Stores event attachments in sensory memory while events cross action 
boundaries. */
+public final class EventAttachmentUtils {
+
+    private static final String ATTACHMENT_ROOT = "__event_attachments__";
+
+    private EventAttachmentUtils() {}
+
+    /** Stores concrete attachment values and replaces them with 
sensory-memory references. */
+    public static void storeEventAttachments(Event event, RunnerContext 
context) throws Exception {
+        if (event.getAttachments().isEmpty()) {
+            return;
+        }
+
+        if (OutputEvent.EVENT_TYPE.equals(event.getType())) {
+            String keys =
+                    event.getAttachments().keySet().stream()
+                            .sorted()
+                            .collect(Collectors.joining(", "));
+            throw new IllegalArgumentException(
+                    "Output events cannot carry attachments: event_id="
+                            + event.getId()
+                            + ", event_type="
+                            + event.getType()
+                            + ", key="
+                            + keys);
+        }
+
+        for (Map.Entry<String, Object> entry : 
event.getAttachments().entrySet()) {
+            String key = entry.getKey();
+            Object value = entry.getValue();
+            if (value instanceof MemoryRef) {
+                MemoryRef reference = (MemoryRef) value;
+                if 
(!MemoryObject.MemoryType.SENSORY.equals(reference.getType())) {
+                    throw new IllegalArgumentException(
+                            "Event attachments must use sensory memory 
references: event_id="
+                                    + event.getId()
+                                    + ", event_type="
+                                    + event.getType()
+                                    + ", key="
+                                    + key
+                                    + ", memory_type="
+                                    + reference.getType());
+                }
+                continue;
+            }
+
+            MemoryRef reference =
+                    
context.getSensoryMemory().set(buildAttachmentPath(event.getId(), key), value);
+
+            event.getAttachments().put(key, reference);
+        }
+    }
+
+    /** Returns an Event with sensory-memory references resolved on a copy 
when necessary. */
+    public static Event loadEventAttachments(
+            Event event, RunnerContext context, TypeSerializer<Event> 
eventSerializer)
+            throws Exception {
+        boolean requiresResolution =
+                
event.getAttachments().values().stream().anyMatch(MemoryRef.class::isInstance);
+        if (!requiresResolution) {
+            return event;
+        }
+
+        Event actionEvent =
+                Objects.requireNonNull(
+                                eventSerializer,
+                                "Event serializer is required to resolve 
attachment references.")
+                        .copy(event);
+        for (Map.Entry<String, Object> entry : 
actionEvent.getAttachments().entrySet()) {
+            Object value = entry.getValue();
+            if (!(value instanceof MemoryRef)) {
+                continue;
+            }
+            MemoryRef reference = (MemoryRef) value;
+
+            MemoryObject attachment = 
context.getSensoryMemory().get(reference);
+            if (attachment == null) {
+                throw new IllegalStateException(
+                        "Event attachment does not exist in sensory memory: "
+                                + reference.getPath());
+            }
+            actionEvent.getAttachments().put(entry.getKey(), 
attachment.getValue());
+        }
+        return actionEvent;
+    }
+
+    /** Builds the sensory-memory path for one event attachment. */
+    public static String buildAttachmentPath(UUID eventId, String key) {
+        if (eventId == null) {
+            throw new IllegalArgumentException("Event attachment requires a 
non-null event id.");
+        }
+        if (key == null) {
+            throw new IllegalArgumentException("Event attachment key must not 
be null.");
+        }
+        return ATTACHMENT_ROOT + "." + eventId + "." + hashAttachmentKey(key);
+    }
+
+    private static String hashAttachmentKey(String key) {
+        try {
+            byte[] digest =
+                    MessageDigest.getInstance("SHA-256")
+                            .digest(key.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder(digest.length * 2);
+            for (byte value : digest) {
+                sb.append(String.format("%02x", value));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 is not available.", e);
+        }
+    }
+}
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
index 94c050a9..4d8d5d84 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
@@ -50,6 +50,7 @@ import 
org.apache.flink.agents.runtime.trace.ExecutionEventLogger;
 import org.apache.flink.agents.runtime.utils.EventUtil;
 import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.api.common.operators.MailboxExecutor;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.common.typeutils.TypeSerializer;
 import org.apache.flink.runtime.state.KeyGroupRange;
 import org.apache.flink.runtime.state.StateInitializationContext;
@@ -137,6 +138,8 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
 
     private transient OperatorStateManager stateManager;
 
+    private transient TypeSerializer<Event> eventSerializer;
+
     // Each job can only have one identifier and this identifier must be 
consistent across restarts.
     // We cannot use job id as the identifier here because user may change job 
id by
     // creating a savepoint, stop the job and then resume from savepoint.
@@ -189,6 +192,10 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
     public void open() throws Exception {
         super.open();
 
+        eventSerializer =
+                TypeInformation.of(Event.class)
+                        
.createSerializer(getExecutionConfig().getSerializerConfig());
+
         stateManager.initializeKeyedStates(getRuntimeContext(), 
agentPlan.getConfig());
         stateManager.initializeOperatorStates(getOperatorStateBackend());
 
@@ -510,6 +517,9 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
 
                 ActionTask.ActionTaskResult actionTaskResult;
                 try {
+                    if (actionTask instanceof JavaActionTask) {
+                        ((JavaActionTask) 
actionTask).setEventSerializer(eventSerializer);
+                    }
                     actionTaskResult =
                             actionTask.invoke(
                                     
getRuntimeContext().getUserCodeClassLoader(),
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/JavaActionTask.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/JavaActionTask.java
index 702d82a8..f6b509ad 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/JavaActionTask.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/JavaActionTask.java
@@ -22,7 +22,9 @@ import 
org.apache.flink.agents.api.trace.ExecutionTraceContext;
 import org.apache.flink.agents.plan.JavaFunction;
 import org.apache.flink.agents.plan.actions.Action;
 import org.apache.flink.agents.runtime.context.JavaRunnerContextImpl;
+import org.apache.flink.agents.runtime.memory.EventAttachmentUtils;
 import org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
 
 import java.util.Collections;
 
@@ -39,7 +41,10 @@ import static org.apache.flink.util.Preconditions.checkState;
  */
 public class JavaActionTask extends ActionTask {
 
-    private boolean executionStarted = false;
+    /** Event passed to the action, copied when attachment references need 
resolution. */
+    private transient Event invocationEvent;
+
+    private transient TypeSerializer<Event> eventSerializer;
 
     public JavaActionTask(Object key, Event event, Action action, long 
sequenceNumber) {
         super(key, event, action, sequenceNumber);
@@ -56,6 +61,10 @@ public class JavaActionTask extends ActionTask {
         checkState(action.getExec() instanceof JavaFunction);
     }
 
+    void setEventSerializer(TypeSerializer<Event> eventSerializer) {
+        this.eventSerializer = eventSerializer;
+    }
+
     @Override
     public ActionTaskResult invoke(ClassLoader userCodeClassLoader, 
PythonActionExecutor executor)
             throws Exception {
@@ -65,12 +74,15 @@ public class JavaActionTask extends ActionTask {
                 event,
                 key);
 
-        if (!executionStarted) {
+        if (invocationEvent == null) {
             runnerContext.checkNoPendingEvents();
-            executionStarted = true;
+            invocationEvent =
+                    EventAttachmentUtils.loadEventAttachments(
+                            event, runnerContext, eventSerializer);
         }
 
         JavaRunnerContextImpl javaRunnerContext = (JavaRunnerContextImpl) 
runnerContext;
+        Event actionEvent = invocationEvent;
 
         ClassLoader cl = Thread.currentThread().getContextClassLoader();
         boolean finished;
@@ -83,7 +95,7 @@ public class JavaActionTask extends ActionTask {
                                     javaRunnerContext.getContinuationContext(),
                                     () -> {
                                         try {
-                                            action.getExec().call(event, 
runnerContext);
+                                            action.getExec().call(actionEvent, 
runnerContext);
                                         } catch (Exception e) {
                                             throw new RuntimeException(e);
                                         }
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
index 3f97b961..0828f2db 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
@@ -88,6 +88,8 @@ public class PythonActionExecutor implements AutoCloseable {
     // =========== PYTHON AND JAVA OBJECT CONVERT ===========
     private static final String CONVERT_JSON_TO_PYTHON_EVENT =
             "python_java_utils.convert_json_to_python_event";
+    private static final String LOAD_EVENT_ATTACHMENTS_FOR_ACTION =
+            "python_java_utils.load_event_attachments_for_action";
     private static final String CONVERT_TO_PYTHON_KEY_TEXT =
             "python_java_utils.convert_to_python_key_text";
     private static final String PICKLED_KEY_SERIALIZATION = "pickled";
@@ -244,6 +246,10 @@ public class PythonActionExecutor implements AutoCloseable 
{
                                                     interpreter.invoke(
                                                             
CONVERT_JSON_TO_PYTHON_EVENT,
                                                             eventJson));
+                            interpreter.invoke(
+                                    LOAD_EVENT_ATTACHMENTS_FOR_ACTION,
+                                    pythonEventObject,
+                                    pythonRunnerContext);
                             PyObject calledResult =
                                     scope.own(
                                             (PyObject)
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
index c1f23504..9afebd73 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
@@ -25,6 +25,8 @@ import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
 import org.apache.flink.agents.api.context.MemoryUpdate;
 import org.apache.flink.agents.api.event.AgentRunBeginEvent;
 import org.apache.flink.agents.api.event.ChatRequestEvent;
@@ -58,6 +60,8 @@ public class ActionStateSerdeTest {
         // Create test data
         InputEvent inputEvent = new InputEvent("test input");
         inputEvent.setAttr("testAttr", "testValue");
+        MemoryRef reference = 
MemoryRef.create(MemoryObject.MemoryType.SENSORY, "attachment.path");
+        inputEvent.setAttachment("payload", reference);
 
         OutputEvent outputEvent = new OutputEvent("test output");
         outputEvent.setAttr("outputAttr", 123);
@@ -88,6 +92,10 @@ public class ActionStateSerdeTest {
         InputEvent deserializedInputEvent = (InputEvent) 
deserializedState.getTaskEvent();
         assertEquals("test input", deserializedInputEvent.getInput());
         assertEquals("testValue", deserializedInputEvent.getAttr("testAttr"));
+        Object restoredAttachment = 
deserializedInputEvent.getAttachment("payload");
+        assertInstanceOf(MemoryRef.class, restoredAttachment);
+        assertEquals(reference, restoredAttachment);
+        assertEquals(MemoryObject.MemoryType.SENSORY, ((MemoryRef) 
restoredAttachment).getType());
 
         // Verify memoryUpdates
         assertEquals(1, deserializedState.getSensoryMemoryUpdates().size());
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtilsTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtilsTest.java
new file mode 100644
index 00000000..662b6061
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/EventAttachmentUtilsTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.runtime.memory;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.configuration.ReadableConfiguration;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
+import org.apache.flink.agents.api.context.Outcome;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.memory.BaseLongTermMemory;
+import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.resource.Resource;
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.api.common.ExecutionConfig;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class EventAttachmentUtilsTest {
+
+    private MemoryObject sensoryMemory;
+    private RunnerContext context;
+    private TypeSerializer<Event> eventSerializer;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        sensoryMemory =
+                new MemoryObjectImpl(
+                        MemoryObject.MemoryType.SENSORY,
+                        new CachedMemoryStore(new ForTestMemoryMapState<>()),
+                        MemoryObjectImpl.ROOT_KEY,
+                        new LinkedList<>());
+        context = new MockRunnerContext(sensoryMemory);
+        eventSerializer =
+                TypeInformation.of(Event.class)
+                        .createSerializer(new 
ExecutionConfig().getSerializerConfig());
+    }
+
+    @Test
+    void storesEventAttachments() throws Exception {
+        UUID eventId = UUID.randomUUID();
+        Map<String, Object> payload = Map.of("value", "original");
+        Event event =
+                new Event(
+                        eventId,
+                        "AttachmentStep",
+                        Map.of(),
+                        new HashMap<>(Map.of("payload", payload)));
+
+        EventAttachmentUtils.storeEventAttachments(event, context);
+
+        Object attachment = event.getAttachment("payload");
+        assertTrue(attachment instanceof MemoryRef);
+        MemoryRef reference = (MemoryRef) attachment;
+        assertEquals(
+                EventAttachmentUtils.buildAttachmentPath(eventId, "payload"), 
reference.getPath());
+        assertEquals(payload, sensoryMemory.get(reference).getValue());
+    }
+
+    @Test
+    void storesAttachmentsFromImmutableMap() throws Exception {
+        UUID eventId = UUID.randomUUID();
+        Map<String, Object> payload = Map.of("value", "original");
+        Map<String, Object> attachments = Map.of("payload", payload);
+        Event event = new Event(eventId, "AttachmentStep", Map.of(), 
attachments);
+
+        EventAttachmentUtils.storeEventAttachments(event, context);
+
+        assertTrue(event.getAttachment("payload") instanceof MemoryRef);
+        assertEquals(payload, attachments.get("payload"));
+    }
+
+    @Test
+    void rejectsNonSensoryMemoryReferencesWhenStoringAttachments() {
+        Event event =
+                new Event(
+                        UUID.randomUUID(),
+                        "AttachmentStep",
+                        Map.of(),
+                        new HashMap<>(
+                                Map.of(
+                                        "payload",
+                                        MemoryRef.create(
+                                                
MemoryObject.MemoryType.SHORT_TERM,
+                                                "attachment.path"))));
+
+        IllegalArgumentException error =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> 
EventAttachmentUtils.storeEventAttachments(event, context));
+
+        assertTrue(
+                error.getMessage()
+                        .startsWith("Event attachments must use sensory memory 
references:"));
+    }
+
+    @Test
+    void rejectsOutputEventAttachmentsBeforeStoringThem() throws Exception {
+        UUID eventId = UUID.randomUUID();
+        Map<String, Object> attachments =
+                Map.of("zeta", Map.of("value", 2), "alpha", Map.of("value", 
1));
+        Event event =
+                new Event(
+                        eventId,
+                        OutputEvent.EVENT_TYPE,
+                        Map.of("output", "result"),
+                        new HashMap<>(attachments));
+
+        IllegalArgumentException error =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> 
EventAttachmentUtils.storeEventAttachments(event, context));
+
+        assertTrue(error.getMessage().startsWith("Output events cannot carry 
attachments:"));
+    }
+
+    @Test
+    void loadsEventAttachments() throws Exception {
+        UUID eventId = UUID.randomUUID();
+        Map<String, Object> payload = Map.of("value", "original");
+        MemoryRef reference =
+                sensoryMemory.set(
+                        EventAttachmentUtils.buildAttachmentPath(eventId, 
"payload"), payload);
+        Event event =
+                new Event(
+                        eventId,
+                        "AttachmentStep",
+                        Map.of(),
+                        new HashMap<>(Map.of("payload", reference)));
+
+        Event actionEvent =
+                EventAttachmentUtils.loadEventAttachments(event, context, 
eventSerializer);
+
+        assertNotSame(event, actionEvent);
+        assertNotSame(event.getAttachments(), actionEvent.getAttachments());
+        assertEquals(payload, actionEvent.getAttachment("payload"));
+        assertSame(reference, event.getAttachment("payload"));
+    }
+
+    @Test
+    void rejectsMissingEventAttachment() {
+        Event event = new Event("AttachmentStep");
+        event.setAttachment(
+                "payload", MemoryRef.create(MemoryObject.MemoryType.SENSORY, 
"missing.attachment"));
+
+        IllegalStateException error =
+                assertThrows(
+                        IllegalStateException.class,
+                        () ->
+                                EventAttachmentUtils.loadEventAttachments(
+                                        event, context, eventSerializer));
+
+        assertTrue(
+                error.getMessage()
+                        .startsWith("Event attachment does not exist in 
sensory memory:"));
+    }
+
+    @Test
+    void loadsNullEventAttachment() throws Exception {
+        UUID eventId = UUID.randomUUID();
+        MemoryRef reference =
+                sensoryMemory.set(
+                        EventAttachmentUtils.buildAttachmentPath(eventId, 
"payload"), null);
+        Map<String, Object> attachments = new HashMap<>();
+        attachments.put("payload", reference);
+        Event event = new Event(eventId, "AttachmentStep", Map.of(), 
attachments);
+
+        Event actionEvent =
+                EventAttachmentUtils.loadEventAttachments(event, context, 
eventSerializer);
+
+        assertNull(actionEvent.getAttachment("payload"));
+    }
+
+    @Test
+    void returnsOriginalEventWhenNoReferencesNeedResolution() throws Exception 
{
+        Event event = new Event("AttachmentStep", Map.of("value", "original"));
+        event.setAttachment("payload", Map.of("value", "inline"));
+
+        Event actionEvent =
+                EventAttachmentUtils.loadEventAttachments(event, context, 
eventSerializer);
+
+        assertSame(event, actionEvent);
+    }
+
+    @Test
+    void buildsAttachmentPath() {
+        UUID eventId = UUID.fromString("00000000-0000-0000-0000-000000000001");
+
+        String path = EventAttachmentUtils.buildAttachmentPath(eventId, 
"payload");
+
+        assertEquals(
+                "__event_attachments__."
+                        + eventId
+                        + 
".239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5",
+                path);
+    }
+
+    /** Mock RunnerContext for testing resolve(). */
+    static class MockRunnerContext implements RunnerContext {
+        private final MemoryObject memoryObject;
+
+        MockRunnerContext(MemoryObject memoryObject) {
+            this.memoryObject = memoryObject;
+        }
+
+        @Override
+        public MemoryObject getShortTermMemory() {
+            return null;
+        }
+
+        @Override
+        public BaseLongTermMemory getLongTermMemory() throws Exception {
+            return null;
+        }
+
+        @Override
+        public MemoryObject getSensoryMemory() {
+            return memoryObject;
+        }
+
+        @Override
+        public void sendEvent(org.apache.flink.agents.api.Event event) {}
+
+        @Override
+        public FlinkAgentsMetricGroup getAgentMetricGroup() {
+            return null;
+        }
+
+        @Override
+        public FlinkAgentsMetricGroup getActionMetricGroup() {
+            return null;
+        }
+
+        @Override
+        public Resource getResource(String name, ResourceType type) throws 
Exception {
+            return null;
+        }
+
+        @Override
+        public ReadableConfiguration getConfig() {
+            return null;
+        }
+
+        @Override
+        public Map<String, Object> getActionConfig() {
+            return Map.of();
+        }
+
+        @Override
+        public Object getActionConfigValue(String key) {
+            return null;
+        }
+
+        @Override
+        public <T> T durableExecute(DurableCallable<T> callable) throws 
Exception {
+            return callable.call();
+        }
+
+        @Override
+        public <T> T durableExecuteAsync(DurableCallable<T> callable) throws 
Exception {
+            return callable.call();
+        }
+
+        @Override
+        public <T> List<Outcome<T>> 
durableExecuteAllAsync(List<DurableCallable<T>> callables)
+                throws Exception {
+            List<Outcome<T>> outcomes = new ArrayList<>(callables.size());
+            for (DurableCallable<T> callable : callables) {
+                outcomes.add(Outcome.success(callable.call()));
+            }
+            return outcomes;
+        }
+
+        @Override
+        public void close() throws Exception {}
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
index 1eaba97c..a135af77 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
@@ -26,6 +26,7 @@ import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.configuration.AgentConfigOptions;
 import org.apache.flink.agents.api.context.DurableCallable;
 import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
 import org.apache.flink.agents.api.context.RunnerContext;
 import org.apache.flink.agents.api.event.ShortTermWriteEvent;
 import org.apache.flink.agents.api.event.ToolRequestEvent;
@@ -192,6 +193,41 @@ public class ActionExecutionOperatorTest {
         }
     }
 
+    @Test
+    void testJavaEventAttachmentsAreOffloadedAndResolvedBetweenActions() 
throws Exception {
+        AgentPlan agentPlan = TestAgent.getEventAttachmentAgentPlan();
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
testHarness =
+                new KeyedOneInputStreamOperatorTestHarness<>(
+                        new ActionExecutionOperatorFactory<>(agentPlan, true),
+                        (KeySelector<Long, Long>) value -> value,
+                        TypeInformation.of(Long.class))) {
+            testHarness.open();
+            ActionExecutionOperator<Long, Object> operator =
+                    (ActionExecutionOperator<Long, Object>) 
testHarness.getOperator();
+
+            List<Event> eventsAtSendBoundary = new ArrayList<>();
+            operator.getEventRouter()
+                    .addEventListener(
+                            (context, event) -> {
+                                if 
(TestAgent.ATTACHMENT_EVENT_TYPE.equals(event.getType())) {
+                                    eventsAtSendBoundary.add(event);
+                                }
+                            });
+
+            testHarness.processElement(new StreamRecord<>(1L));
+            operator.waitInFlightEventsFinished();
+
+            assertThat(eventsAtSendBoundary).hasSize(1);
+            Event runtimeEvent = eventsAtSendBoundary.get(0);
+            Object reference = 
runtimeEvent.getAttachment(TestAgent.ATTACHMENT_KEY);
+            assertThat(reference).isInstanceOf(MemoryRef.class);
+            List<StreamRecord<Object>> recordOutput =
+                    (List<StreamRecord<Object>>) testHarness.getRecordOutput();
+            assertThat(recordOutput).hasSize(1);
+            
assertThat(recordOutput.get(0).getValue()).isEqualTo(Map.of("value", 1L));
+        }
+    }
+
     @Test
     void testSameKeyDataAreProcessedInOrder() throws Exception {
         try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
testHarness =
@@ -2963,6 +2999,9 @@ public class ActionExecutionOperatorTest {
 
     public static class TestAgent {
 
+        private static final String ATTACHMENT_EVENT_TYPE = "AttachmentEvent";
+        private static final String ATTACHMENT_KEY = "payload";
+
         /** Counter to track how many times the durable supplier is executed. 
*/
         public static final java.util.concurrent.atomic.AtomicInteger 
DURABLE_CALL_COUNTER =
                 new java.util.concurrent.atomic.AtomicInteger(0);
@@ -3008,6 +3047,19 @@ public class ActionExecutionOperatorTest {
             context.sendEvent(new MiddleEvent(inputData + 1));
         }
 
+        public static void sendEventAttachment(Event event, RunnerContext 
context) {
+            Event attachmentEvent = new Event(ATTACHMENT_EVENT_TYPE);
+            attachmentEvent.setAttachment(
+                    ATTACHMENT_KEY, Map.of("value", 
InputEvent.fromEvent(event).getInput()));
+            context.sendEvent(attachmentEvent);
+        }
+
+        public static void receiveEventAttachment(Event event, RunnerContext 
context) {
+            Object attachment = event.getAttachment(ATTACHMENT_KEY);
+            event.setAttachment(ATTACHMENT_KEY, "mutated-by-action");
+            context.sendEvent(new OutputEvent(attachment));
+        }
+
         public static void action2(MiddleEvent event, RunnerContext context) {
             ACTION2_CALL_COUNTER.incrementAndGet();
             try {
@@ -3355,6 +3407,34 @@ public class ActionExecutionOperatorTest {
             return getAgentPlanWithConfig(new AgentConfiguration(), 
testMemoryAccessOutOfMailbox);
         }
 
+        public static AgentPlan getEventAttachmentAgentPlan() {
+            try {
+                Action sendAction =
+                        new Action(
+                                "sendEventAttachment",
+                                new JavaFunction(
+                                        TestAgent.class,
+                                        "sendEventAttachment",
+                                        new Class<?>[] {Event.class, 
RunnerContext.class}),
+                                
Collections.singletonList(InputEvent.EVENT_TYPE));
+                Action receiveAction =
+                        new Action(
+                                "receiveEventAttachment",
+                                new JavaFunction(
+                                        TestAgent.class,
+                                        "receiveEventAttachment",
+                                        new Class<?>[] {Event.class, 
RunnerContext.class}),
+                                
Collections.singletonList(ATTACHMENT_EVENT_TYPE));
+                return new AgentPlan(
+                        Map.of(
+                                sendAction.getName(), sendAction,
+                                receiveAction.getName(), receiveAction));
+            } catch (Exception e) {
+                ExceptionUtils.rethrow(e);
+            }
+            return null;
+        }
+
         public static AgentPlan getAgentPlanWithConfig(AgentConfiguration 
config) {
             return getAgentPlanWithConfig(config, false);
         }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/JavaActionTaskTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/JavaActionTaskTest.java
new file mode 100644
index 00000000..466f90a4
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/JavaActionTaskTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.runtime.operator;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.MemoryRef;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.plan.JavaFunction;
+import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.agents.runtime.actionstate.ActionState;
+import org.apache.flink.agents.runtime.actionstate.InMemoryActionStateStore;
+import org.apache.flink.agents.runtime.async.ContinuationActionExecutor;
+import org.apache.flink.agents.runtime.async.ContinuationContext;
+import org.apache.flink.agents.runtime.context.JavaRunnerContextImpl;
+import org.apache.flink.agents.runtime.context.RunnerContextImpl;
+import org.apache.flink.agents.runtime.memory.CachedMemoryStore;
+import org.apache.flink.agents.runtime.memory.ForTestMemoryMapState;
+import org.apache.flink.agents.runtime.metrics.FlinkAgentsMetricGroupImpl;
+import org.apache.flink.api.common.ExecutionConfig;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.runtime.metrics.groups.UnregisteredMetricGroups;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link JavaActionTask}. */
+public class JavaActionTaskTest {
+
+    private static final String ACTION_NAME = "capture-attachment";
+    private static final String KEY = "key";
+    private static final long SEQUENCE_NUMBER = 0L;
+
+    private static Event invokedEvent;
+    private static Object invokedAttachment;
+
+    private ContinuationActionExecutor continuationExecutor;
+    private DurableExecutionManager durableExecutionManager;
+    private JavaRunnerContextImpl runnerContext;
+    private Action action;
+    private TypeSerializer<Event> eventSerializer;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        invokedEvent = null;
+        invokedAttachment = null;
+        continuationExecutor = new ContinuationActionExecutor(1);
+        durableExecutionManager = new DurableExecutionManager(new 
InMemoryActionStateStore(false));
+        runnerContext = createContext();
+        action = createAction();
+        eventSerializer =
+                TypeInformation.of(Event.class)
+                        .createSerializer(new 
ExecutionConfig().getSerializerConfig());
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        try {
+            continuationExecutor.close();
+        } finally {
+            durableExecutionManager.close();
+        }
+    }
+
+    public static void captureAttachment(InputEvent event, RunnerContext 
context) throws Exception {
+        invokedEvent = event;
+        invokedAttachment = event.getAttachment("payload");
+        event.setAttachment("payload", "mutated-by-action");
+        context.durableExecute(
+                new TestDurableCallable<>(
+                        "persist-attachment-state", String.class, () -> 
"persisted"));
+    }
+
+    @Test
+    void resolvesAttachmentsWithoutMutatingDurableEvent() throws Exception {
+        Map<String, Object> payload = Map.of("value", "original");
+        MemoryRef reference = 
runnerContext.getSensoryMemory().set("attachment.payload", payload);
+        InputEvent runtimeEvent = new InputEvent(1L);
+        runtimeEvent.setAttachment("payload", reference);
+        JavaActionTask task = new JavaActionTask(KEY, runtimeEvent, action, 
1L);
+        task.setRunnerContext(runnerContext);
+        task.setEventSerializer(eventSerializer);
+        durableExecutionManager.maybeInitActionState(KEY, SEQUENCE_NUMBER, 
action, runtimeEvent);
+        ActionState actionState = getPersistedState(runtimeEvent);
+        durableExecutionManager.setupDurableExecutionContext(task, 
actionState, SEQUENCE_NUMBER);
+
+        task.invoke(getClass().getClassLoader(), null);
+
+        ActionState persistedState = getPersistedState(runtimeEvent);
+        
assertThat(invokedEvent).isInstanceOf(InputEvent.class).isNotSameAs(runtimeEvent);
+        assertThat(((InputEvent) invokedEvent).getInput()).isEqualTo(1L);
+        assertThat(invokedAttachment).isSameAs(payload);
+        assertThat(persistedState.getCallResultCount()).isOne();
+        
assertThat(persistedState.getTaskEvent().getAttachment("payload")).isSameAs(reference);
+    }
+
+    private JavaRunnerContextImpl createContext() throws Exception {
+        JavaRunnerContextImpl context =
+                new JavaRunnerContextImpl(
+                        new FlinkAgentsMetricGroupImpl(
+                                
UnregisteredMetricGroups.createUnregisteredOperatorMetricGroup()),
+                        () -> {},
+                        new AgentPlan(new HashMap<>(), new HashMap<>()),
+                        null,
+                        "test-job",
+                        continuationExecutor);
+        context.setContinuationContext(new ContinuationContext());
+        context.switchActionContext(
+                ACTION_NAME,
+                new RunnerContextImpl.MemoryContext(
+                        new CachedMemoryStore(new ForTestMemoryMapState<>()),
+                        new CachedMemoryStore(new ForTestMemoryMapState<>())),
+                new ArrayList<>(),
+                KEY,
+                "observation",
+                false,
+                null);
+        return context;
+    }
+
+    private Action createAction() throws Exception {
+        return new Action(
+                ACTION_NAME,
+                new JavaFunction(
+                        JavaActionTaskTest.class,
+                        "captureAttachment",
+                        new Class<?>[] {InputEvent.class, 
RunnerContext.class}),
+                List.of(InputEvent.EVENT_TYPE));
+    }
+
+    private ActionState getPersistedState(Event event) throws Exception {
+        return durableExecutionManager.maybeGetActionState(KEY, 
SEQUENCE_NUMBER, action, event);
+    }
+
+    private static final class TestDurableCallable<T> implements 
DurableCallable<T> {
+
+        private final String id;
+        private final Class<T> resultClass;
+        private final Callable<T> callSupplier;
+
+        private TestDurableCallable(String id, Class<T> resultClass, 
Callable<T> callSupplier) {
+            this.id = id;
+            this.resultClass = resultClass;
+            this.callSupplier = callSupplier;
+        }
+
+        @Override
+        public String getId() {
+            return id;
+        }
+
+        @Override
+        public Class<T> getResultClass() {
+            return resultClass;
+        }
+
+        @Override
+        public T call() throws Exception {
+            return callSupplier.call();
+        }
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
index 63575170..a17e9dc7 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
@@ -127,6 +127,42 @@ class PythonActionExecutorTest {
         }
     }
 
+    @Test
+    void loadsEventAttachmentsBeforeCallingPythonFunction() throws Exception {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonActionExecutor executor =
+                newExecutor(interpreter, mock(PythonRunnerContextImpl.class));
+        PythonFunction function = new PythonFunction("test_module", 
"test_action");
+        PyObject runnerContext = mock(PyObject.class);
+        PyObject pythonEvent = mock(PyObject.class);
+        setField(executor, "pythonRunnerContext", runnerContext);
+        when(interpreter.invoke(same(CONVERT_JSON_TO_PYTHON_EVENT), 
anyString()))
+                .thenReturn(pythonEvent);
+        when(interpreter.invoke(
+                        CALL_PYTHON_FUNCTION,
+                        "test_module",
+                        "test_action",
+                        new Object[] {pythonEvent, runnerContext}))
+                .thenReturn(null);
+
+        executor.executePythonFunction(function, new Event("test-event"));
+
+        InOrder invocationOrder = inOrder(interpreter);
+        invocationOrder
+                .verify(interpreter)
+                .invoke(
+                        "python_java_utils.load_event_attachments_for_action",
+                        pythonEvent,
+                        runnerContext);
+        invocationOrder
+                .verify(interpreter)
+                .invoke(
+                        CALL_PYTHON_FUNCTION,
+                        "test_module",
+                        "test_action",
+                        new Object[] {pythonEvent, runnerContext});
+    }
+
     @Test
     void resolvesPickledPythonKeyTextFromPyFlinkKeyRow() throws Exception {
         PythonInterpreter interpreter = mock(PythonInterpreter.class);

Reply via email to