Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions api/src/main/java/org/apache/flink/agents/api/Event.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
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 java.io.IOException;
import java.util.HashMap;
Expand All @@ -38,6 +44,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;

/**
* Runtime-internal timestamp from the source record. Not part of the cross-language event
* contract; used by the Flink runtime for timestamp propagation.
Expand All @@ -46,25 +57,36 @@ 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. */
public Event(String type) {
this(type, new HashMap<>());
}

@JsonCreator
public Event(
@JsonProperty("id") UUID id,
@JsonProperty("type") String type,
@JsonProperty("attributes") Map<String, Object> attributes) {
this(id, type, attributes, new HashMap<>());
}

@JsonCreator
public 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) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("Event 'type' must not be null or empty.");
}
this.id = id;
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<>();
}

public UUID getId() {
Expand All @@ -81,6 +103,10 @@ public Map<String, Object> getAttributes() {
return attributes;
}

public Map<String, Object> getAttachments() {
return attachments;
}

public Object getAttr(String name) {
return attributes.get(name);
}
Expand All @@ -89,6 +115,14 @@ public void setAttr(String name, Object value) {
attributes.put(name, value);
}

public Object getAttachment(String name) {
Comment thread
JinkunLiu marked this conversation as resolved.
return attachments.get(name);
}

public void setAttachment(String name, Object value) {
attachments.put(name, value);
}

@JsonIgnore
public boolean hasSourceTimestamp() {
return sourceTimestamp != null;
Expand All @@ -105,12 +139,17 @@ public void setSourceTimestamp(long timestamp) {
}

/**
* Creates a base Event from another Event, copying id, type, and attributes. Subclasses
* override this to reconstruct typed event objects with proper field deserialization.
* Creates a base Event from another Event, copying id, type, attributes, and attachments.
* Subclasses override this to reconstruct typed event objects with proper field
* deserialization.
*/
public static Event fromEvent(Event event) {
Event copy =
new Event(event.getId(), event.getType(), new HashMap<>(event.getAttributes()));
new Event(
event.getId(),
event.getType(),
new HashMap<>(event.getAttributes()),
new HashMap<>(event.attachments));
if (event.hasSourceTimestamp()) {
copy.setSourceTimestamp(event.getSourceTimestamp());
}
Expand All @@ -128,18 +167,34 @@ public static Event fromJson(String json) throws IOException {
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;
if (o == null || getClass() != o.getClass()) return false;
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including attachments here, and in equals at :175, makes them part of event identity. The durable action-state key does not follow: ActionStateUtil.generateUUIDForEvent hashes event.getAttributes() only, so two events this line now distinguishes can still land on one ActionState.

A fan-out with durable execution on is where that shows: ctx.sendEvent(new Event("WorkItem", new HashMap<>(), Map.of("payload", item))) in a loop gives every sibling the same empty attributes, the same seqNum and the same action, so one state key covers all of them. Item 1 completes, item 2's lookup returns item 1's completed state, and ActionExecutionOperator.java:341 skips execution and replays item 1's output in its place.

Adding attachments to the key may just trade one problem for another, since a ref's path embeds the random event id the key deliberately avoids (buildAttachmentPath). I'm confident on the mechanism, less so on the odds, since it needs ACTION_STATE_STORE_BACKEND set plus siblings with equal attributes. Does that combination look reachable in practice?

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public InputEvent(
*/
public static InputEvent fromEvent(Event event) {
InputEvent result = new InputEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public OutputEvent(
*/
public static OutputEvent fromEvent(Event event) {
OutputEvent result = new OutputEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both runtimes reject an OutputEvent carrying attachments before storing them (EventAttachmentUtils.java:46-58, event_attachment_utils.py:59-62), so there is no Java/Python gap here to close. What is left is internal: this copy, and the one at event.py:254, only ever build an object sendEvent refuses, and the output_event.json snapshots now pin that shape as a fixture.

What is the intended contract for attachments on OutputEvent? That answer decides whether the rejection moves or the copy does.

if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
@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;

Expand Down Expand Up @@ -67,6 +88,55 @@ public String getPath() {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private static Map<String, Object> normalizeAttributes(Map<String, Object> attri
public static ChatRequestEvent fromEvent(Event event) {
ChatRequestEvent result =
new ChatRequestEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ private static Map<String, Object> normalizeAttributes(Map<String, Object> attri
public static ChatResponseEvent fromEvent(Event event) {
ChatResponseEvent result =
new ChatResponseEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static ContextRetrievalRequestEvent fromEvent(Event event) {
ContextRetrievalRequestEvent result =
new ContextRetrievalRequestEvent(
event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public static ContextRetrievalResponseEvent fromEvent(Event event) {
ContextRetrievalResponseEvent result =
new ContextRetrievalResponseEvent(
event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public ToolRequestEvent(
public static ToolRequestEvent fromEvent(Event event) {
ToolRequestEvent result =
new ToolRequestEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ private static Map<String, Object> normalizeAttributes(Map<String, Object> attri
public static ToolResponseEvent fromEvent(Event event) {
ToolResponseEvent result =
new ToolResponseEvent(event.getId(), new HashMap<>(event.getAttributes()));
result.getAttachments().putAll(event.getAttachments());
if (event.hasSourceTimestamp()) {
result.setSourceTimestamp(event.getSourceTimestamp());
}
Expand Down
Loading