Skip to content

[api][java][python] Introduce EventType constants and unify Action trigger entry#756

Open
rosemarYuan wants to merge 8 commits into
apache:mainfrom
rosemarYuan:feature/event-type-and-action-trigger-unification
Open

[api][java][python] Introduce EventType constants and unify Action trigger entry#756
rosemarYuan wants to merge 8 commits into
apache:mainfrom
rosemarYuan:feature/event-type-and-action-trigger-unification

Conversation

@rosemarYuan

@rosemarYuan rosemarYuan commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Linked issue: #754

Purpose of change

This PR introduces EventType constants and a registry for built-in and user-defined event types, and unifies the Action trigger entry point across Java and Python:

  • Java: @Action(value = ...)
  • Python: @action(*trigger_conditions)

This is a preparatory API and plan change for the CEL Action Condition filtering feature tracked in #754. Follow-up PRs will add Java runtime CEL evaluation, Python runtime CEL evaluation, and documentation.

Scope

Since #709, @Action / @action carry two orthogonal concerns:

concern Java element Python API decides
trigger value() *trigger_conditions when the action fires
dispatch target() target= where it runs

This PR only changes the trigger side:

  • Java: listenEventTypes is renamed to value.
  • Python: *listen_events is renamed to *trigger_conditions.

The dispatch side is unchanged. target, PythonFunction, and cross-language dispatch semantics are reused as-is.

Tests

  • Java: EventTypeTest, AgentPlan*Test, Action*SerializerTest — all green
  • Python: api/tests + plan/tests — 218 passed, 11 skipped
  • Verified that cross-language target dispatch still works after the trigger rename

API

This is a source-level API rename on the trigger side.

Java:

  • @Action(listenEventTypes = {X.EVENT_TYPE})@Action(EventType.X)
  • @Action(listenEventTypes =..., target = @PythonFunction(...))@Action(value = ..., target = @PythonFunction(...))

Python:

  • @action(*listen_events, target=None)@action(*trigger_conditions, target=None)
  • target= remains unchanged.

All in-tree Java and Python callers have been migrated.

  • doc-included

Updated 10 markdown files under docs/content/docs/ to use the new EventType.X / @Action(EventType.X) form while preserving the existing cross-language target examples.

@rosemarYuan rosemarYuan changed the title [api][java][python] Introduce EventType constants and unify Action trigger entry [Feature] Introduce EventType constants and unify Action trigger entry Jun 4, 2026
@rosemarYuan rosemarYuan changed the title [Feature] Introduce EventType constants and unify Action trigger entry [api][java][python] Introduce EventType constants and unify Action trigger entry Jun 4, 2026
@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. fixVersion/0.3.0 The feature or bug should be implemented/fixed in the 0.3.0 version. priority/major Default priority of the PR or issue. and removed doc-included Your PR already contains the necessary documentation updates. labels Jun 4, 2026
# TODO: Raise a warning when the action has a return value, as it will be ignored.
exec: PythonFunction | JavaFunction
listen_event_types: List[str]
trigger_conditions: List[str]

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.

Python Action model lacks backward compat for old JSON key

Java ActionJsonDeserializer (L71-73) falls back to listen_event_types when trigger_conditions is absent. The Python pydantic Action model only declares trigger_conditions: List[str] with no alias or model_validator fallback — deserializing old-format plan JSON from persisted Flink state will raise ValidationError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in lines 84-88. Added a model_validator fallback that mirrors the Java ActionJsonDeserializer behavior:

 # Legacy fallback: listen_event_types → trigger_conditions
        if "trigger_conditions" not in self or self.get("trigger_conditions") is None:
            if self.get("listen_event_types"):
                self["trigger_conditions"] = list(self["listen_event_types"])
            self.pop("listen_event_types", None)

Old-format plan JSON with listen_event_types will now deserialize without ValidationError.

private final String name;
private final Function exec;
private final List<String> listenEventTypes;
private final List<String> triggerConditions;

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.

Discuss:Keep listen_event_types, add trigger_conditions as a new field

Rather than renaming listen_event_typestrigger_conditions, I'd suggest keeping both:

  • listen_event_types: which events route to this action (static dispatch)
  • trigger_conditions: CEL expression filter for whether to process a matched event (dynamic filtering)

These are orthogonal concerns — routing vs. filtering. Keeping listen_event_types also avoids JSON backward-compat issues entirely (the Python pydantic model currently lacks the fallback that the Java deserializer has for the old key).

@rosemarYuan rosemarYuan Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the comment — this was actually my first instinct too: a clean routing-vs-filtering separation. The more I worked through the problem though, the more I came to think it doesn't quite map onto what users actually want to express.

Event filtering is just event routing at a finer granularity, not a separate concern. Each entry in trigger_conditions answers exactly one question — "should this action fire for this event?" — and a bare event type is simply the most common (and most degenerate) form of that condition.

The case that really pins this down: for a single action, users often want per-event-type conditions, with different conditions for different types — and sometimes multiple conditions on the same type. With a unified list this falls out naturally. #726 shows the canonical shape:

// Mixed triggers. Entries are OR'ed.
@Action({
    EventType.InputEvent,                                                           // direct type
    "type == EventType.ChatResponseEvent && retryCount > 0",                        // metadata condition
    "type == EventType.ToolResponseEvent && response.success == false",             // payload condition
    "type == EventType.ChatResponseEvent && response.plantype.contains('Day')"      // metadata + payload
})

Split into listen_event_types + trigger_conditions, there's no clean way to express any of this. The two fields are parallel lists with no shared index, so "this condition belongs to that type" has no representation — and "ChatResponseEvent has two distinct conditions" is even less expressible. You'd end up either forcing one CEL expression to gate all listened types (loses per-type granularity), or inventing a multi-map like {ChatResponseEvent: [cond1, cond2], ToolResponseEvent: [cond3]} — which is just the unified list with extra ceremony, plus an awkward way to represent the "no condition" case for direct-type entries. The unified field is what naturally fits the actual shape of user intent.

Curious if you have a concrete scenario in mind where the two-field split would be cleaner — happy to revisit if there's a use case I'm missing.

*
* <p>Resolution via {@link #lookupOrSelf}: built-in &rarr; user-registered &rarr; passthrough.
*/
public final class EventType {

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.

Reconsider the EventType aggregation class

EventType.InputEvent duplicates InputEvent.EVENT_TYPE — two sources of truth for the same value. The register() / lookup() / lookupOrSelf() registry has zero callers in production code today.

InputEvent.EVENT_TYPE is already self-documenting and lives in its natural namespace. Suggest deferring the registry until the CEL PR actually needs it, and just using the event class constants directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I've refactored EventType to be a pure namespace of constants — each one simply re-exports the value from the corresponding event class (e.g., EventType.InputEvent = _InputEvent.EVENT_TYPE), so there's a single source of truth. The register() / lookup() / lookupOrSelf() registry has been removed from this PR and will be introduced later when the CEL PR actually requires dynamic resolution.

Meanwhile, I've also slimmed down the EventType class by removing the internal utility functions — their concrete implementations are deferred to a follow-up PR.

@weiqingy weiqingy left a comment

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.

Thanks for taking this on — the rename is clean, and I appreciate that you kept a backward-compat fallback for the old JSON key on both sides (the listen_event_types branch in ActionJsonDeserializer and the model_validator in action.py), so plan JSON persisted in older Flink state still deserializes. That's an easy thing to miss in a rename PR. A few questions inline.

List<String> triggerConditions = new ArrayList<>();
JsonNode triggerNode = node.get("trigger_conditions");
if (triggerNode == null) {
triggerNode = node.get("listen_event_types");

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.

This fallback to the legacy listen_event_types key is the load-bearing guarantee that plan JSON persisted in older Flink state still deserializes after the rename — but nothing exercises it. The cross-version snapshot resources were all regenerated to trigger_conditions in this PR, so the compat tests now compare new-format against new-format and this branch never fires under test. A future refactor could delete the fallback and every test would stay green.

Worth a small deserialize test that feeds a JSON blob carrying listen_event_types and asserts it lands in getTriggerConditions() / getListenEventTypes()? That pins the old-key path so it can't be silently dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — you're right that the regenerated snapshot fixtures mean the legacy branch is never exercised under test. I'll add a focused deserialization test that feeds a JSON blob with the old listen_event_types key and asserts it maps correctly to getTriggerConditions().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Although both @weiqingy and @wzhero1 agree on handling backward compatibility, I believe it is unnecessary at this stage. Flink-Agents is still in the 0.x series, where API compatibility is not guaranteed. In fact, we have already introduced some breaking API changes in version 0.3 (e.g., #631), so maintaining backward compatibility specifically for listen_event_types offers limited value.

We will begin preparing for API stability and compatibility commitments starting with the next release (0.4), and formally commit to backward compatibility from version 1.0 onwards. Until then, I consider keeping the codebase clean and simple to be a higher priority.

if "trigger_conditions" not in self or self.get("trigger_conditions") is None:
if self.get("listen_event_types"):
self["trigger_conditions"] = list(self["listen_event_types"])
self.pop("listen_event_types", None)

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.

Same thought as the Java side: this model_validator is the only thing keeping older persisted plans (with listen_event_types) deserializable, but no test feeds it the old key — the regenerated snapshot fixtures all use trigger_conditions now, so this branch is untested.

Would a small round-trip test help here — construct an Action from a dict with listen_event_types and assert it surfaces as trigger_conditions? That guards the fallback against a future cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same idea on the Python side. While adding the test I found that the custom init was rejecting the old key before model_validator(mode="before") had a chance to rename it — so the fallback was silently broken. Fixed init to accept **kwargs and handle the legacy key, and added a matching deserialization test.

class EventTypeTest {

@Test
void builtInConstantsAreNonNull() {

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.

assertNotNull on inlined String constants can't really fail — the constants are non-null by construction — so this test passes regardless of what each constant holds. A copy-paste slip like OutputEvent = InputEvent.EVENT_TYPE would sail through.

Since the point of EventType is to be a single source of truth, would asserting value-equality against each source (e.g. assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent)) be a stronger check? That actually guards the mapping. Same applies to the Python test_event_type.py smoke test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right — assertNotNull on inlined constants is vacuous. Originally addressed in a follow-up PR; now pulled forward into this commit with assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent) and similar for all 8 built-ins.

from flink_agents.api.events.event_type import EventType


def test_builtin_constants_are_non_empty_strings() -> None:

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.

This asserts each constant is a non-empty string, which holds for any string value and so won't catch a wrong mapping (e.g. two constants pointing at the same source EVENT_TYPE). Would asserting each constant against its source event's EVENT_TYPE be a better guard on the single-source-of-truth contract? Mirrors the suggestion on the Java EventTypeTest, so whatever shape you land on there can carry over here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mirrors the Java fix — the Python test now asserts EventType.InputEvent == InputEvent.EVENT_TYPE directly for all 8 constants. Consistent shape across both languages.

@rosemarYuan rosemarYuan force-pushed the feature/event-type-and-action-trigger-unification branch from 41c17f2 to af7b6a3 Compare June 5, 2026 09:13

@weiqingy weiqingy left a comment

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.

Thanks for the quick turnaround — all four threads are addressed and CI is green. The legacy-key fallbacks now have focused tests on both sides, the EventType tests assert value-equality against each source, and the __init__ fix closes a real gap, not just a coverage one. One forward-looking question inline.


// Add to actionsByEvent map
for (String eventTypeName : eventTypeNames) {
for (String eventTypeName : triggerConditions) {

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.

This site builds the routing map from the raw triggerConditions, but the built-in path just below (addBuiltAction, L217) builds it through action.getListenEventTypes() — and that accessor's javadoc says a follow-up overrides it to "filter out non-type entries" once CEL lands. Python's agent_plan.py:148 also reads the raw field. So of the three routing sites, only the built-in one — which never carries CEL — goes through the filtering seam; the two that handle user-supplied conditions bypass it.

Today they're equivalent, since every entry is a plain event-type name. But once CEL expressions enter trigger_conditions, these two sites would register strings like "type == EventType.ChatResponseEvent && retryCount > 0" as literal keys in actionsByEvent — exactly what the accessor exists to strip. Would it make sense to route all three through the accessor now, or to defer the accessor until the CEL PR and read the raw field everywhere in the meantime? Either way the three sites agreeing seems worth locking in. Not a blocker for this PR — flagging while the context is fresh.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for flagging this. The inconsistency wasn't intentional — when writing extractActions, the triggerConditions local variable was right at hand so I used it directly; same on the Python. Since all three return identical values in this PR, the divergence slipped through unnoticed. A simple oversight on my part.

The follow-up CEL PR consolidates all three sites into a single registerAction() method that routes exclusively via getListenEventTypes(), which by then performs real filtering. So the three sites end up in lockstep once CEL lands.

@weiqingy weiqingy Jun 6, 2026

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.

Agreed, fine to leave as-is — the three sites are equivalent today and nothing can carry a CEL string yet. Thanks for the context, resolved on my end.

@xintongsong xintongsong added fixVersion/0.4.0 and removed fixVersion/0.3.0 The feature or bug should be implemented/fixed in the 0.3.0 version. labels Jun 6, 2026

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ty for taking this on @rosemarYuan. I think overall the work is clear. but I have two main questions:

  • It appears that the YAML API and the addAction/add_action paths are not covered.
  • I don't see a need to keep the backward-compatibility fallback. WDYT @xintongsong?

Additionally, there are some conflicts with the main branch that can be resolved after cutting the release-0.3 branch.

ContextRetrievalRequestEvent: str = _ContextRetrievalRequestEvent.EVENT_TYPE
ContextRetrievalResponseEvent: str = _ContextRetrievalResponseEvent.EVENT_TYPE

def __init__(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the init method is unnecessary. We don’t have this design in ResourceName either.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for your suggestions regarding the review. Dropped the __init__ guard. Agreed it adds little value here, so removing it aligns with that idiom.

List<String> triggerConditions = new ArrayList<>();
JsonNode triggerNode = node.get("trigger_conditions");
if (triggerNode == null) {
triggerNode = node.get("listen_event_types");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Although both @weiqingy and @wzhero1 agree on handling backward compatibility, I believe it is unnecessary at this stage. Flink-Agents is still in the 0.x series, where API compatibility is not guaranteed. In fact, we have already introduced some breaking API changes in version 0.3 (e.g., #631), so maintaining backward compatibility specifically for listen_event_types offers limited value.

We will begin preparing for API stability and compatibility commitments starting with the next release (0.4), and formally commit to backward compatibility from version 1.0 onwards. Until then, I consider keeping the codebase clean and simple to be a higher priority.

*/
String[] listenEventTypes();
/** Event type name strings; multiple entries have OR semantics. */
String[] value();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here, we use value as the parameter name, whereas Python’s Action decorator uses trigger_conditions.

I understand this discrepancy arises because in Java annotations, only a member named value can be used without explicitly specifying the parameter name during declaration.

Therefore, to improve usability for users, I believe this inconsistency is acceptable. However, we should probably explain this in the comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point — added a one-sentence note on explaining:

  • (1) The JLS §9.7.3 reason for the name.
  • (2) Its mapping to Python's *trigger_conditions.
    Kept it tight to avoid bloating the annotation javadoc.

@rosemarYuan

Copy link
Copy Markdown
Contributor Author

Hi @xintongsong, need a policy call from you.

This PR renames listen_event_types -> trigger_conditions. I added a fallback on both Java (ActionJsonDeserializer) and Python (Action.model_validator), so old plan JSON still deserializes.

@wenjin272 pointed out we're still in 0.x and don't owe API/state compatibility yet (#631 already broke API in 0.3), so the fallback may be debt we shouldn't take on until the 0.4 / 1.0 commitment.

That policy angle wasn't on the table in the earlier review — we only discussed whether the fallback was correctly implemented. Could you decide:

(A) keep the fallback + tests, or
(B) drop them under the 0.x "no compat" stance?

Thanks!

@rosemarYuan

rosemarYuan commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

YAML trigger_conditions: Should CEL Also Accept YAML Event Aliases?

I have a local implementation for the YAML API path, but there is one syntax question I would like to align on before pushing it.

When condition-expression support was originally designed, the YAML API had not yet been introduced. As a result, the interaction between YAML event aliases and CEL expressions was not considered.

Current Behavior

The YAML loader already supports short aliases for built-in event types. For example:

trigger_conditions:
  - input

The YAML layer resolves input to the corresponding built-in event type string before constructing plan.Action.

CEL conditions, however, use the EventType.X namespace, consistent with the existing Java and Python APIs:

trigger_conditions:
  - >-
    type == EventType.ChatResponseEvent
    && priority == 'high'

A mixed condition list can therefore contain two syntactic forms for built-in event types:

actions:
  - name: handle_priority_input
    type: java
    function: com.example.MyActions:handlePriorityInput
    trigger_conditions:
      - input
      - >-
        type == EventType.ChatResponseEvent
        && priority == 'high'

For example, input and EventType.InputEvent refer to the same conceptual event type, but they look unrelated in the YAML configuration.

Concern: Overloading the Alias Name

I do not think short aliases should be introduced into CEL.

Consider a natural-looking condition:

type == chat_request && chat_request.id > 10

Here, chat_request appears to play two different roles:

  • on the left, it is an event-type constant;
  • on the right, it appears to be an event or payload object.

These roles are conceptually distinct. Mainstream languages normally make the distinction explicit—for example, a type check and an object reference are represented using different syntax.

Using the EventType.X namespace keeps the roles unambiguous:

type == EventType.ChatRequestEvent
&& chat_request > 10

The EventType. prefix makes the identifier's role visible directly from the syntax. Readers do not need to infer from context whether chat_request represents a type constant, an event object, or an attribute.

Binding aliases as top-level CEL variables would also introduce a silent name-collision risk. User events may legitimately expose fields or variables named input, output, or chat_request, especially in LLM and RAG workloads. Reserving those common names as CEL variables could make expressions harder to understand and evolve.

Proposed Boundary

My preference is to keep the current boundary explicit:

  • YAML short aliases are supported only for standalone event-type conditions;
  • CEL expressions continue to use EventType.X for built-in event types.

For example:

trigger_conditions:
  - input
  - >-
    type == EventType.ChatResponseEvent
    && priority == 'high'

This does mean that YAML may contain two notations for event types, but each notation has a clear scope:

  • input is YAML shorthand for a standalone type match;
  • EventType.InputEvent is a typed constant inside a CEL expression.

Question

Does the proposed boundary look acceptable for the YAML API?

In particular:

  • should aliases remain YAML-only shorthand for standalone type matches;
  • or should CEL also expose them as top-level variables?

Feedback from anyone who has written non-trivial YAML agents would be especially useful, particularly regarding which form feels more natural in practice.

/cc @wenjin272

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. The point about backward compatibility may need to be confirmed. PLTA @xintongsong

@xintongsong

Copy link
Copy Markdown
Contributor

I think there's no need to stay compatible for agent plans from older version. First of all, we are still in 0.x versions and should avoid carry any burdens due to trying to stay compatible with immature legacy apis. And secondly, the json agent plan should not be considered as an public API. It's internal. We should not support executing a json agent plan generated with an older version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-included Your PR already contains the necessary documentation updates. fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants