From ba0d6f9a69797173c8829beb10eaaad66ba500df Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:10:36 +0800 Subject: [PATCH 01/13] [api] Introduce EventType constants for built-in event types --- .../apache/flink/agents/api/EventType.java | 167 ++++++++++ .../flink/agents/api/EventTypeTest.java | 301 ++++++++++++++++++ python/flink_agents/api/events/event_type.py | 157 +++++++++ .../flink_agents/api/tests/test_event_type.py | 66 ++++ 4 files changed, 691 insertions(+) create mode 100644 api/src/main/java/org/apache/flink/agents/api/EventType.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java create mode 100644 python/flink_agents/api/events/event_type.py create mode 100644 python/flink_agents/api/tests/test_event_type.py diff --git a/api/src/main/java/org/apache/flink/agents/api/EventType.java b/api/src/main/java/org/apache/flink/agents/api/EventType.java new file mode 100644 index 000000000..e0e825ad0 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/EventType.java @@ -0,0 +1,167 @@ +/* + * 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; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Compile-time constants for built-in event types and a runtime registry for user-defined events. + * + *

Usage in {@code @Action}: + * + *

+ * + *

Resolution via {@link #lookupOrSelf}: built-in → user-registered → passthrough. + */ +public final class EventType { + + public static final String InputEvent = "_input_event"; + public static final String OutputEvent = "_output_event"; + public static final String ChatRequestEvent = "_chat_request_event"; + public static final String ChatResponseEvent = "_chat_response_event"; + public static final String ToolRequestEvent = "_tool_request_event"; + public static final String ToolResponseEvent = "_tool_response_event"; + public static final String ContextRetrievalRequestEvent = "_context_retrieval_request_event"; + public static final String ContextRetrievalResponseEvent = "_context_retrieval_response_event"; + + private static final Map BUILTIN; + + static { + Map m = new HashMap<>(); + m.put("InputEvent", InputEvent); + m.put("OutputEvent", OutputEvent); + m.put("ChatRequestEvent", ChatRequestEvent); + m.put("ChatResponseEvent", ChatResponseEvent); + m.put("ToolRequestEvent", ToolRequestEvent); + m.put("ToolResponseEvent", ToolResponseEvent); + m.put("ContextRetrievalRequestEvent", ContextRetrievalRequestEvent); + m.put("ContextRetrievalResponseEvent", ContextRetrievalResponseEvent); + BUILTIN = Collections.unmodifiableMap(m); + } + + private static final ConcurrentMap USER_REGISTERED = new ConcurrentHashMap<>(); + + private EventType() {} + + /** + * Registers a user-defined event class so that its simple name resolves to its {@code + * EVENT_TYPE} value. The class must declare {@code public static final String EVENT_TYPE}. + * Idempotent for the same {@code (name, EVENT_TYPE)} pair. + * + * @param eventClass the event class to register + */ + public static void register(Class eventClass) { + if (eventClass == null) { + throw new IllegalArgumentException("eventClass must not be null"); + } + String name = eventClass.getSimpleName(); + if (BUILTIN.containsKey(name)) { + throw new IllegalArgumentException( + "Short name '" + name + "' collides with a built-in EventType"); + } + String eventType = readEventTypeField(eventClass); + String previous = USER_REGISTERED.putIfAbsent(name, eventType); + if (previous != null && !previous.equals(eventType)) { + throw new IllegalStateException( + "Short name '" + + name + + "' already registered with EVENT_TYPE='" + + previous + + "', cannot re-register with EVENT_TYPE='" + + eventType + + "'"); + } + } + + /** + * Returns the {@code EVENT_TYPE} for a registered short name, or {@code null} if unknown. + * Built-in names take precedence over user-registered ones. + */ + public static String lookup(String name) { + if (name == null) { + return null; + } + String v = BUILTIN.get(name); + if (v != null) { + return v; + } + return USER_REGISTERED.get(name); + } + + /** Like {@link #lookup}, but returns {@code name} unchanged if not registered. */ + public static String lookupOrSelf(String name) { + String v = lookup(name); + return v != null ? v : name; + } + + /** Returns {@code true} if {@code name} is a registered short name. */ + public static boolean isKnown(String name) { + return lookup(name) != null; + } + + /** Returns an unmodifiable snapshot of all registrations (built-in + user-registered). */ + public static Map all() { + Map snapshot = new HashMap<>(BUILTIN); + snapshot.putAll(USER_REGISTERED); + return Collections.unmodifiableMap(snapshot); + } + + private static String readEventTypeField(Class eventClass) { + Field field; + try { + field = eventClass.getDeclaredField("EVENT_TYPE"); + } catch (NoSuchFieldException e) { + throw new IllegalArgumentException( + eventClass.getName() + " must declare 'static final String EVENT_TYPE'", e); + } + int mods = field.getModifiers(); + if (!Modifier.isStatic(mods) + || !Modifier.isFinal(mods) + || field.getType() != String.class) { + throw new IllegalArgumentException( + eventClass.getName() + ".EVENT_TYPE must be static final String"); + } + try { + field.setAccessible(true); + Object value = field.get(null); + if (!(value instanceof String) || ((String) value).isEmpty()) { + throw new IllegalArgumentException( + eventClass.getName() + ".EVENT_TYPE must be a non-empty String"); + } + return (String) value; + } catch (IllegalAccessException e) { + throw new IllegalArgumentException( + "Cannot read " + eventClass.getName() + ".EVENT_TYPE", e); + } + } + + /** Test-only: reset user registrations between unit tests. */ + static void clearUserRegisteredForTesting() { + USER_REGISTERED.clear(); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java new file mode 100644 index 000000000..dd3a02a5b --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java @@ -0,0 +1,301 @@ +/* + * 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; + +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; +import org.apache.flink.agents.api.event.ContextRetrievalRequestEvent; +import org.apache.flink.agents.api.event.ContextRetrievalResponseEvent; +import org.apache.flink.agents.api.event.ToolRequestEvent; +import org.apache.flink.agents.api.event.ToolResponseEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests for {@link EventType}. */ +class EventTypeTest { + + @BeforeEach + @AfterEach + void resetUserRegistry() { + EventType.clearUserRegisteredForTesting(); + } + + // ----------------------------------------------------------------------- + // Built-in constants must agree byte-for-byte with XxxEvent.EVENT_TYPE. + // This is the consistency invariant called out in the EventType Javadoc. + // ----------------------------------------------------------------------- + + @Test + void builtInConstantsMatchEventClassConstants() { + assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent); + assertEquals(OutputEvent.EVENT_TYPE, EventType.OutputEvent); + assertEquals(ChatRequestEvent.EVENT_TYPE, EventType.ChatRequestEvent); + assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.ChatResponseEvent); + assertEquals(ToolRequestEvent.EVENT_TYPE, EventType.ToolRequestEvent); + assertEquals(ToolResponseEvent.EVENT_TYPE, EventType.ToolResponseEvent); + assertEquals( + ContextRetrievalRequestEvent.EVENT_TYPE, EventType.ContextRetrievalRequestEvent); + assertEquals( + ContextRetrievalResponseEvent.EVENT_TYPE, EventType.ContextRetrievalResponseEvent); + } + + @Test + void lookupReturnsBuiltInsByShortName() { + assertEquals(InputEvent.EVENT_TYPE, EventType.lookup("InputEvent")); + assertEquals(OutputEvent.EVENT_TYPE, EventType.lookup("OutputEvent")); + assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.lookup("ChatResponseEvent")); + } + + @Test + void lookupReturnsNullForUnknown() { + assertNull(EventType.lookup("DoesNotExist")); + assertNull(EventType.lookup("")); + assertNull(EventType.lookup(null)); + } + + @Test + void lookupOrSelfReturnsInputForUnknown() { + // Raw EVENT_TYPE strings pass through unchanged. + assertEquals("_input_event", EventType.lookupOrSelf("_input_event")); + // Custom strings pass through unchanged. + assertEquals("MyCustomEvent", EventType.lookupOrSelf("MyCustomEvent")); + // CEL expressions pass through unchanged. + String cel = "type == '_input_event' && price >= 125"; + assertEquals(cel, EventType.lookupOrSelf(cel)); + } + + @Test + void isKnownTracksBuiltins() { + assertTrue(EventType.isKnown("InputEvent")); + assertTrue(EventType.isKnown("ChatResponseEvent")); + assertFalse(EventType.isKnown("_input_event")); + assertFalse(EventType.isKnown("DoesNotExist")); + assertFalse(EventType.isKnown("")); + assertFalse(EventType.isKnown(null)); + } + + @Test + void allReturnsSnapshotIncludingBuiltins() { + Map all = EventType.all(); + assertEquals(8, all.size()); + assertEquals("_input_event", all.get("InputEvent")); + assertEquals("_chat_response_event", all.get("ChatResponseEvent")); + // Snapshot is unmodifiable. + assertThrows(UnsupportedOperationException.class, () -> all.put("Foo", "bar")); + } + + // ----------------------------------------------------------------------- + // User registration. + // ----------------------------------------------------------------------- + + public static class MyCustomEvent extends Event { + public static final String EVENT_TYPE = "_my_custom_event"; + + public MyCustomEvent() { + super(EVENT_TYPE); + } + } + + public static class AnotherEvent extends Event { + public static final String EVENT_TYPE = "_another_event"; + + public AnotherEvent() { + super(EVENT_TYPE); + } + } + + @Test + void registerExposesUserDefinedEvent() { + EventType.register(MyCustomEvent.class); + assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); + assertTrue(EventType.isKnown("MyCustomEvent")); + assertEquals(9, EventType.all().size()); + } + + @Test + void registerIsIdempotentForSamePair() { + EventType.register(MyCustomEvent.class); + EventType.register(MyCustomEvent.class); // must not throw + assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); + } + + /** Re-registering the same simple name with a different EVENT_TYPE must fail loudly. */ + public static class CollidingEvent extends Event { + public static final String EVENT_TYPE = "_different_event"; + + public CollidingEvent() { + super(EVENT_TYPE); + } + } + + public static class ShadowingMyCustomEvent extends Event { + public static final String EVENT_TYPE = "_shadow_event"; + + public ShadowingMyCustomEvent() { + super(EVENT_TYPE); + } + } + + @Test + void registerDifferentClassesWithDifferentSimpleNamesCoexist() { + EventType.register(MyCustomEvent.class); + EventType.register(ShadowingMyCustomEvent.class); + assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); + assertEquals("_shadow_event", EventType.lookup("ShadowingMyCustomEvent")); + } + + /** + * Direct test of the conflict path: register one class, then register a second class whose + * {@code getSimpleName()} happens to collide, with a different EVENT_TYPE. We construct the + * collision by using two top-level-style nested classes whose simple names match. + */ + @Test + void registerRejectsSameNameDifferentEventType() { + EventType.register(MyCustomEvent.class); + // Manually trigger the conflict by re-using the registry with a synthetic mapping. + // Easiest path: declare a second class with the same simple name in a different + // enclosing scope. + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> EventType.register(Nested.MyCustomEvent.class)); + assertTrue(ex.getMessage().contains("already registered")); + } + + static class Nested { + public static class MyCustomEvent extends Event { + public static final String EVENT_TYPE = "_nested_my_custom_event"; + + public MyCustomEvent() { + super(EVENT_TYPE); + } + } + } + + @Test + void registerRejectsCollisionWithBuiltIn() { + // A user-defined class whose simple name equals a built-in must be rejected. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> EventType.register(MyInputEvent.InputEvent.class)); + assertTrue(ex.getMessage().contains("collides with a built-in")); + } + + // Wrap in an enclosing class so we can name the inner class "InputEvent" without colliding + // with the api package's InputEvent at the source level. + static class MyInputEvent { + public static class InputEvent extends Event { + public static final String EVENT_TYPE = "_user_input_event"; + + public InputEvent() { + super(EVENT_TYPE); + } + } + } + + public static class NoEventTypeField extends Event { + public NoEventTypeField() { + super("_x"); + } + } + + @Test + void registerRejectsClassWithoutEventTypeField() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> EventType.register(NoEventTypeField.class)); + assertTrue(ex.getMessage().contains("EVENT_TYPE")); + } + + @Test + void registerRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> EventType.register(null)); + } + + // ----------------------------------------------------------------------- + // Concurrency: two threads registering the same class should both succeed + // (idempotent), and the registry should hold exactly one entry. + // ----------------------------------------------------------------------- + + @Test + void concurrentRegisterIsSafe() throws InterruptedException { + int threads = 16; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + AtomicInteger errors = new AtomicInteger(); + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + for (int i = 0; i < threads; i++) { + pool.submit( + () -> { + try { + start.await(); + EventType.register(MyCustomEvent.class); + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + assertEquals(0, errors.get()); + assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); + // Built-in count (8) + our one registration = 9 + assertEquals(9, EventType.all().size()); + } finally { + pool.shutdownNow(); + } + } + + // ----------------------------------------------------------------------- + // Snapshot independence. + // ----------------------------------------------------------------------- + + @Test + void allReturnsSnapshotNotLiveView() { + Map before = EventType.all(); + EventType.register(MyCustomEvent.class); + Map after = EventType.all(); + assertNotNull(before); + // Snapshot before registration must not contain the new entry. + assertFalse(before.containsKey("MyCustomEvent")); + assertTrue(after.containsKey("MyCustomEvent")); + assertEquals(new HashMap<>(before).size() + 1, after.size()); + } +} diff --git a/python/flink_agents/api/events/event_type.py b/python/flink_agents/api/events/event_type.py new file mode 100644 index 000000000..e36655ac9 --- /dev/null +++ b/python/flink_agents/api/events/event_type.py @@ -0,0 +1,157 @@ +################################################################################ +# 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. +################################################################################# +"""IDE-discoverable constants for built-in event types, plus a tiny registry +for user-defined event classes. + +Built-in events are exposed as :class:`EventType` attributes; user events +declare ``EVENT_TYPE: ClassVar[str]`` and are registered via +:func:`EventType.register`:: + + @action(EventType.InputEvent, EventType.OutputEvent) + def handle(...): ... + + EventType.register(MyCustomEvent) + EventType.lookup("MyCustomEvent") # -> "_my_custom_event" +""" + +from __future__ import annotations + +import threading +from typing import Dict, Optional, Type + + +# Hard-coded to avoid an event_type -> event -> event_type circular import. +# A consistency test asserts each value matches XxxEvent.EVENT_TYPE. +_BUILTIN: Dict[str, str] = { + "InputEvent": "_input_event", + "OutputEvent": "_output_event", + "ChatRequestEvent": "_chat_request_event", + "ChatResponseEvent": "_chat_response_event", + "ToolRequestEvent": "_tool_request_event", + "ToolResponseEvent": "_tool_response_event", + "ContextRetrievalRequestEvent": "_context_retrieval_request_event", + "ContextRetrievalResponseEvent": "_context_retrieval_response_event", +} + +_USER_REGISTERED: Dict[str, str] = {} +_LOCK = threading.Lock() + + +def register(event_class: Type) -> None: + """Register a user-defined event class. + + The class must declare ``EVENT_TYPE: ClassVar[str]`` as a non-empty string. + Re-registering the same ``(class_name, EVENT_TYPE)`` pair is a no-op. + + Raises: + ValueError: if ``event_class`` is None, lacks a non-empty ``EVENT_TYPE``, + or its ``__name__`` collides with a built-in. + RuntimeError: if the same name is already bound to a different + ``EVENT_TYPE`` value. + """ + if event_class is None: + msg = "event_class must not be None" + raise ValueError(msg) + name = event_class.__name__ + if name in _BUILTIN: + msg = f"Short name {name!r} collides with a built-in EventType" + raise ValueError(msg) + event_type = getattr(event_class, "EVENT_TYPE", None) + if not isinstance(event_type, str) or not event_type: + msg = ( + f"{event_class.__module__}.{event_class.__name__} must declare " + f"EVENT_TYPE as a non-empty string" + ) + raise ValueError(msg) + with _LOCK: + existing = _USER_REGISTERED.get(name) + if existing is None: + _USER_REGISTERED[name] = event_type + return + if existing != event_type: + msg = ( + f"Short name {name!r} already registered with EVENT_TYPE={existing!r}; " + f"cannot re-register with EVENT_TYPE={event_type!r}" + ) + raise RuntimeError(msg) + + +def lookup(name: Optional[str]) -> Optional[str]: + """Return the ``EVENT_TYPE`` string for a registered short name, else ``None``. + + Built-in names take precedence over user-registered ones. + """ + if name is None: + return None + builtin = _BUILTIN.get(name) + if builtin is not None: + return builtin + return _USER_REGISTERED.get(name) + + +def lookup_or_self(name: str) -> str: + """Like :func:`lookup`, but returns ``name`` unchanged when not registered.""" + v = lookup(name) + return v if v is not None else name + + +def is_known(name: Optional[str]) -> bool: + """Return ``True`` if ``name`` is a registered short name.""" + return lookup(name) is not None + + +def all_registered() -> Dict[str, str]: + """Return a snapshot of all registrations (built-in + user-registered).""" + snapshot = dict(_BUILTIN) + snapshot.update(_USER_REGISTERED) + return snapshot + + +def _clear_user_registered_for_testing() -> None: + """Test-only: drop user registrations between unit tests.""" + with _LOCK: + _USER_REGISTERED.clear() + + +class EventType: + """Namespace of built-in event-type constants, byte-equal to each + ``XxxEvent.EVENT_TYPE``. Use inside ``trigger_conditions``:: + + @action(EventType.InputEvent) + + For user-defined events, call :func:`register` first, then :func:`lookup`. + """ + + InputEvent: str = _BUILTIN["InputEvent"] + OutputEvent: str = _BUILTIN["OutputEvent"] + ChatRequestEvent: str = _BUILTIN["ChatRequestEvent"] + ChatResponseEvent: str = _BUILTIN["ChatResponseEvent"] + ToolRequestEvent: str = _BUILTIN["ToolRequestEvent"] + ToolResponseEvent: str = _BUILTIN["ToolResponseEvent"] + ContextRetrievalRequestEvent: str = _BUILTIN["ContextRetrievalRequestEvent"] + ContextRetrievalResponseEvent: str = _BUILTIN["ContextRetrievalResponseEvent"] + + register = staticmethod(register) + lookup = staticmethod(lookup) + lookup_or_self = staticmethod(lookup_or_self) + is_known = staticmethod(is_known) + all_registered = staticmethod(all_registered) + + def __init__(self) -> None: + msg = "EventType is a namespace; do not instantiate" + raise TypeError(msg) diff --git a/python/flink_agents/api/tests/test_event_type.py b/python/flink_agents/api/tests/test_event_type.py new file mode 100644 index 000000000..76ef904b9 --- /dev/null +++ b/python/flink_agents/api/tests/test_event_type.py @@ -0,0 +1,66 @@ +################################################################################ +# 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. +################################################################################# +"""Python smoke tests for :mod:`flink_agents.api.events.event_type`. + +The authoritative test suite lives in Java +(``api/src/test/java/.../EventTypeTest.java``) per cross-language test policy +(issue #006, Q5). These two cases only verify the Python language-binding +layer; full semantic coverage belongs on the Java side. +""" + +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from flink_agents.api.events.event import Event, InputEvent +from flink_agents.api.events.event_type import ( + EventType, + _clear_user_registered_for_testing, + lookup_or_self, + register, +) + + +@pytest.fixture(autouse=True) +def _reset_user_registry() -> None: + _clear_user_registered_for_testing() + yield + _clear_user_registered_for_testing() + + +def test_builtin_lookup_and_constant_alignment() -> None: + """Smoke: built-in EventType constants resolve and match XxxEvent.EVENT_TYPE.""" + assert EventType.InputEvent == InputEvent.EVENT_TYPE + assert lookup_or_self("InputEvent") == InputEvent.EVENT_TYPE + # Unknown short name passes through unchanged. + assert lookup_or_self("NotRegistered") == "NotRegistered" + + +def test_register_user_event_then_lookup_resolves_short_name() -> None: + """Smoke: register a user event and lookup the short name.""" + + class MyOrderEvent(Event): + EVENT_TYPE: ClassVar[str] = "_my_order_event" + + def __init__(self) -> None: + super().__init__(type=MyOrderEvent.EVENT_TYPE) + + register(MyOrderEvent) + assert lookup_or_self("MyOrderEvent") == "_my_order_event" From b8fe3a7b4ea840caa52a51b61502edb22f7adbec Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:10:06 +0800 Subject: [PATCH 02/13] [api][plan] Unify @Action trigger entry as value(), migrate Java callers --- .../flink/agents/api/agents/ReActAgent.java | 3 +- .../flink/agents/api/annotation/Action.java | 35 +++++++++---------- docs/content/docs/development/chat_models.md | 8 ++--- .../docs/development/embedding_models.md | 4 +-- .../development/memory/long_term_memory.md | 4 +-- .../memory/sensory_and_short_term_memory.md | 6 ++-- docs/content/docs/development/prompts.md | 2 +- .../content/docs/development/vector_stores.md | 6 ++-- .../docs/development/workflow_agent.md | 27 +++++++------- .../get-started/quickstart/skills_agent.md | 4 +-- .../get-started/quickstart/workflow_agent.md | 4 +-- docs/content/docs/operations/monitoring.md | 2 +- .../java/agent_plan_with_python_action.json | 8 ++--- .../python/agent_plan_with_java_action.json | 8 ++--- .../integration/test/AsyncExecutionAgent.java | 11 +++--- .../test/ChatModelIntegrationAgent.java | 5 +-- .../test/EmbeddingIntegrationAgent.java | 3 +- .../test/FlinkIntegrationAgent.java | 9 ++--- .../integration/test/MemoryObjectAgent.java | 3 +- .../test/SkillsIntegrationAgent.java | 5 +-- .../test/VectorStoreIntegrationAgent.java | 5 +-- .../test/ChatModelCrossLanguageAgent.java | 5 +-- .../test/EmbeddingCrossLanguageAgent.java | 3 +- .../resource/test/MCPCrossLanguageAgent.java | 3 +- .../test/Mem0LongTermMemoryAgent.java | 5 +-- .../test/VectorStoreCrossLanguageAgent.java | 5 +-- .../agents/examples/agents/MathAgent.java | 5 +-- .../agents/ProductSuggestionAgent.java | 5 +-- .../examples/agents/ReviewAnalysisAgent.java | 5 +-- .../agents/TableReviewAnalysisAgent.java | 5 +-- .../apache/flink/agents/plan/AgentPlan.java | 16 ++++----- .../flink/agents/plan/actions/Action.java | 33 +++++++++++------ .../serializer/ActionJsonDeserializer.java | 15 +++++--- .../plan/serializer/ActionJsonSerializer.java | 8 ++--- .../plan/AgentPlanDeclareChatModelTest.java | 4 +-- .../plan/AgentPlanDeclareMCPServerTest.java | 4 +-- .../plan/AgentPlanDeclareToolFieldTest.java | 4 +-- .../plan/AgentPlanDeclareToolMethodTest.java | 4 +-- .../flink/agents/plan/AgentPlanTest.java | 21 ++++++----- .../compatibility/GenerateAgentPlanJson.java | 6 ++-- .../serializer/ActionJsonSerializerTest.java | 16 ++++----- .../AgentPlanJsonSerializerTest.java | 13 ++++--- .../actions/action_java_function.json | 2 +- .../actions/action_python_function.json | 2 +- .../resources/agent_plans/agent_plan.json | 4 +-- ...t_plan_with_python_resource_providers.json | 8 ++--- .../flink/agents/runtime/RescalingTest.java | 5 +-- .../agents/runtime/ResourceCacheTest.java | 3 +- .../ShortTermMemoryTTLIntegrationTest.java | 3 +- 49 files changed, 207 insertions(+), 167 deletions(-) diff --git a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java index 0b394baab..e13661ee9 100644 --- a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java +++ b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.ClassUtils; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.annotation.Action; @@ -169,7 +170,7 @@ public static void startAction(Event event, RunnerContext ctx) { ctx.sendEvent(new ChatRequestEvent(DEFAULT_CHAT_MODEL, inputMessages, outputSchema)); } - @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE}) + @Action(EventType.ChatResponseEvent) public static void stopAction(Event event, RunnerContext ctx) { ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event); ChatMessage response = chatResponse.getResponse(); diff --git a/api/src/main/java/org/apache/flink/agents/api/annotation/Action.java b/api/src/main/java/org/apache/flink/agents/api/annotation/Action.java index fd7f62c92..ae11de6b2 100644 --- a/api/src/main/java/org/apache/flink/agents/api/annotation/Action.java +++ b/api/src/main/java/org/apache/flink/agents/api/annotation/Action.java @@ -24,23 +24,24 @@ import java.lang.annotation.Target; /** - * Annotation for marking a method as an agent action. + * Marks a method as an agent action triggered by matching events. * - *

This annotation specifies which event types the action should respond to. The annotated method - * will be triggered when any of the specified event types occur. + *

Each {@link #value()} entry is an event type name string. Use the {@code EVENT_TYPE} constants + * on built-in event classes, the {@link org.apache.flink.agents.api.EventType} constants, or plain + * strings for custom events. Multiple entries combine with OR. * - *

Events are specified as type strings via {@link #listenEventTypes()}. Use the {@code - * EVENT_TYPE} constants on built-in event classes for standard events, or plain strings for custom - * events. + *

{@code
+ * // Built-in event type via the EventType constant
+ * @Action(EventType.InputEvent)
  *
- * 

Example usage: + * // Equivalent via the legacy class constant + * @Action(InputEvent.EVENT_TYPE) * - *

{@code
- * @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
- * public void handleInput(Event event, RunnerContext ctx) { ... }
+ * // User-defined event type
+ * @Action("MyCustomEvent")
  *
- * @Action(listenEventTypes = {InputEvent.EVENT_TYPE, "MyCustomEvent"})
- * public void handleMultiple(Event event, RunnerContext ctx) { ... }
+ * // Multiple types (OR semantics)
+ * @Action({EventType.InputEvent, "MyCustomEvent"})
  * }
* *

For a cross-language action, set {@link #target()} to a {@link PythonFunction} with a @@ -49,7 +50,7 @@ * *

{@code
  * @Action(
- *     listenEventTypes = {InputEvent.EVENT_TYPE},
+ *     value = EventType.InputEvent,
  *     target = @PythonFunction(module = "my_pkg.handlers", qualname = "handle_input"))
  * public void handleInput(Event event, RunnerContext ctx) {
  *     throw new UnsupportedOperationException("cross-language stub");
@@ -59,12 +60,8 @@
 @Target(ElementType.METHOD)
 @Retention(RetentionPolicy.RUNTIME)
 public @interface Action {
-    /**
-     * List of event type strings that this action should respond to.
-     *
-     * @return Array of event type strings
-     */
-    String[] listenEventTypes();
+    /** Event type name strings; multiple entries have OR semantics. */
+    String[] value();
 
     /**
      * Cross-language target. When {@link PythonFunction#module()} is non-empty, dispatch routes to
diff --git a/docs/content/docs/development/chat_models.md b/docs/content/docs/development/chat_models.md
index f6bfe7b77..0e7f40239 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -121,7 +121,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         ChatMessage userMessage =
@@ -129,7 +129,7 @@ public class MyAgent extends Agent {
         ctx.sendEvent(new ChatRequestEvent("ollamaChatModel", List.of(userMessage)));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processResponse(Event event, RunnerContext ctx)
             throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
@@ -1237,7 +1237,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         ChatMessage userMessage =
@@ -1245,7 +1245,7 @@ public class MyAgent extends Agent {
         ctx.sendEvent(new ChatRequestEvent("pythonChatModel", List.of(userMessage)));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processResponse(Event event, RunnerContext ctx)
             throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
diff --git a/docs/content/docs/development/embedding_models.md b/docs/content/docs/development/embedding_models.md
index 3646bcf52..c3f789836 100644
--- a/docs/content/docs/development/embedding_models.md
+++ b/docs/content/docs/development/embedding_models.md
@@ -165,7 +165,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processText(Event event, RunnerContext ctx)
             throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
@@ -657,7 +657,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         // Use the Python embedding model from Java
diff --git a/docs/content/docs/development/memory/long_term_memory.md b/docs/content/docs/development/memory/long_term_memory.md
index eeb4c79d7..f3ad2ee74 100644
--- a/docs/content/docs/development/memory/long_term_memory.md
+++ b/docs/content/docs/development/memory/long_term_memory.md
@@ -162,7 +162,7 @@ def process_event(event: Event, ctx: RunnerContext) -> None:
 {{< tab "Java" >}}
 
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processEvent(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     BaseLongTermMemory ltm = ctx.getLongTermMemory();
@@ -501,7 +501,7 @@ agents_config.set(LongTermMemoryOptions.Mem0.VECTOR_STORE, "my_vector_store")
 {{< tab "Java" >}}
 
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processEvent(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     BaseLongTermMemory ltm = ctx.getLongTermMemory();
diff --git a/docs/content/docs/development/memory/sensory_and_short_term_memory.md b/docs/content/docs/development/memory/sensory_and_short_term_memory.md
index f3aa38105..34c47b314 100644
--- a/docs/content/docs/development/memory/sensory_and_short_term_memory.md
+++ b/docs/content/docs/development/memory/sensory_and_short_term_memory.md
@@ -103,7 +103,7 @@ def process_event(event: Event, ctx: RunnerContext) -> None:
 
 {{< tab "Java" >}}
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processEvent(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     MemoryObject memory = ctx.getSensoryMemory(); // ctx.getShortTermMemory();
@@ -225,7 +225,7 @@ def second_action(event: Event, ctx: RunnerContext):
 
 {{< tab "Java" >}}
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void firstAction(Event event, RunnerContext ctx) throws Exception {
     ...
     MemoryObject sensoryMemory = ctx.getSensoryMemory();
@@ -235,7 +235,7 @@ public static void firstAction(Event event, RunnerContext ctx) throws Exception
     ...
 }
 
-@Action(listenEventTypes = {MyEvent.EVENT_TYPE})
+@Action("MyEvent")
 public static void secondAction(Event event, RunnerContext ctx) throws Exception {
     MyEvent myEvent = MyEvent.fromEvent(event);
     ...
diff --git a/docs/content/docs/development/prompts.md b/docs/content/docs/development/prompts.md
index 6bf9e77bb..231b1cf85 100644
--- a/docs/content/docs/development/prompts.md
+++ b/docs/content/docs/development/prompts.md
@@ -308,7 +308,7 @@ public class ReviewAnalysisAgent extends Agent {
     }
 
     /** Process input event and send chat request for review analysis. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
diff --git a/docs/content/docs/development/vector_stores.md b/docs/content/docs/development/vector_stores.md
index a00b0e359..8124ba91c 100644
--- a/docs/content/docs/development/vector_stores.md
+++ b/docs/content/docs/development/vector_stores.md
@@ -416,7 +416,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void searchDocuments(Event event, RunnerContext ctx) {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         // Option 1: Manual search via the vector store
@@ -429,7 +429,7 @@ public class MyAgent extends Agent {
         ctx.sendEvent(new ContextRetrievalRequestEvent(queryText, "vectorStore"));
     }
 
-    @Action(listenEventTypes = {ContextRetrievalResponseEvent.EVENT_TYPE})
+    @Action(EventType.ContextRetrievalResponseEvent)
     public static void onSearchResponse(Event event, RunnerContext ctx) {
         ContextRetrievalResponseEvent response = ContextRetrievalResponseEvent.fromEvent(event);
         List documents = response.getDocuments();
@@ -1096,7 +1096,7 @@ public class MyAgent extends Agent {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         // Use Python vector store from Java
diff --git a/docs/content/docs/development/workflow_agent.md b/docs/content/docs/development/workflow_agent.md
index b8b7e860d..8ba4ae4ec 100644
--- a/docs/content/docs/development/workflow_agent.md
+++ b/docs/content/docs/development/workflow_agent.md
@@ -26,7 +26,7 @@ under the License.
 
 A workflow style agent in Flink-Agents is an agent whose reasoning and behavior are organized as a directed workflow of modular steps, called actions, connected by events. This design is inspired by the need to orchestrate complex, multi-stage tasks in a transparent, extensible, and data-centric way, leveraging Apache Flink's streaming architecture.
 
-In Flink-Agents, a workflow agent is defined as a class that inherits from the `Agent` base class. The agent's logic is expressed as a set of actions, each of which is a function decorated with `@action(EventType)` in python (or a method annotated with `@Action(listenEventTypes = {})` in java). Actions consume events, perform reasoning or tool calls, and emit new events, which may trigger downstream actions. This event-driven workflow forms a directed cyclic graph of computation, where each node is an action and each edge is an event type.
+In Flink-Agents, a workflow agent is defined as a class that inherits from the `Agent` base class. The agent's logic is expressed as a set of actions, each of which is a function decorated with `@action(EventType.X)` in python (or a method annotated with `@Action(EventType.X)` in java). Actions consume events, perform reasoning or tool calls, and emit new events, which may trigger downstream actions. This event-driven workflow forms a directed cyclic graph of computation, where each node is an action and each edge is an event type.
 
 A workflow agent is well-suited for scenarios where the solution requires explicit orchestration, branching, or multi-step reasoning, such as data enrichment, multi-tool pipelines, or complex business logic.
 
@@ -176,7 +176,7 @@ public class ReviewAnalysisAgent extends Agent {
     }
 
     /** Process input event and send chat request for review analysis. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
@@ -197,7 +197,7 @@ public class ReviewAnalysisAgent extends Agent {
                         "reviewAnalysisModel", List.of(msg), Map.of("input", content), null));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx)
             throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
@@ -253,7 +253,7 @@ class ReviewAnalysisAgent(Agent):
 ```java
 public class ReviewAnalysisAgent extends Agent {
     /** Process input event and send chat request for review analysis. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         // the action logic
@@ -282,9 +282,10 @@ def process_input(event: Event, ctx: RunnerContext) -> None:
 
 {{< tab "Java" >}}
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
-public static void processInput(Event event, RunnerContext ctx) {
-    // send a ChatRequestEvent to trigger the built-in chat-model action
+@Action(EventType.InputEvent)
+public static void processInput(Event event, RunnerContext ctx) throws Exception {
+    InputEvent inputEvent = InputEvent.fromEvent(event);
+    // send ChatRequestEvent
     ctx.sendEvent(new ChatRequestEvent("my_model", messages));
 }
 ```
@@ -400,7 +401,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None:
 {{< tab "Java" >}}
 Java actions use `DurableCallable` with `ctx.durableExecute(...)`, where `getId()` must be stable and `getResultClass()` supports recovery deserialization.
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processInput(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     DurableCallable call = new DurableCallable<>() {
@@ -428,7 +429,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception
 
 Java actions can also override `reconciler()` to recover an execution outcome during recovery.
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processInput(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     DurableCallable call = new DurableCallable<>() {
@@ -498,7 +499,7 @@ functions like `asyncio.gather`, `asyncio.wait`, `asyncio.create_task`, and
 Use `ctx.durableExecuteAsync(DurableCallable)`; on **JDK 21+** it yields using Continuation,
 and on **JDK < 21** it falls back to synchronous execution. The same optional `reconciler()` hook can be used for recovery.
 ```java
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void processInput(Event event, RunnerContext ctx) throws Exception {
     InputEvent inputEvent = InputEvent.fromEvent(event);
     DurableCallable call = new DurableCallable<>() {
@@ -557,7 +558,7 @@ class MyAgent(Agent):
 ```java
 public class MyAgent extends Agent {
     @Action(
-            listenEventTypes = {InputEvent.EVENT_TYPE},
+            value = EventType.InputEvent,
             target = @PythonFunction(
                     module = "my_pkg.handlers",
                     qualname = "handle_input"))
@@ -614,13 +615,13 @@ def handle_my_event(event: Event, ctx: RunnerContext) -> None:
 {{< tab "Java" >}}
 ```java
 // Send a unified event from one action
-@Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+@Action(EventType.InputEvent)
 public static void createMyEvent(Event event, RunnerContext ctx) {
     ctx.sendEvent(new Event("my_event", Map.of("field1", "test", "field2", 42)));
 }
 
 // Consume it in another action
-@Action(listenEventTypes = {"my_event"})
+@Action("my_event")
 public static void handleMyEvent(Event event, RunnerContext ctx) {
     String field1 = (String) event.getAttr("field1");
     int field2 = (int) event.getAttr("field2");
diff --git a/docs/content/docs/get-started/quickstart/skills_agent.md b/docs/content/docs/get-started/quickstart/skills_agent.md
index 9a15b3707..677d8c6aa 100644
--- a/docs/content/docs/get-started/quickstart/skills_agent.md
+++ b/docs/content/docs/get-started/quickstart/skills_agent.md
@@ -167,7 +167,7 @@ public class MathAgent extends Agent {
     }
 
     /** Process input event and send a chat request to evaluate the question. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(InputEvent event, RunnerContext ctx) {
         ctx.sendEvent(
                 new ChatRequestEvent(
@@ -177,7 +177,7 @@ public class MathAgent extends Agent {
     }
 
     /** Process chat response event and send the answer as output. */
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(ChatResponseEvent event, RunnerContext ctx) {
         ctx.sendEvent(new OutputEvent(event.getResponse().getContent()));
     }
diff --git a/docs/content/docs/get-started/quickstart/workflow_agent.md b/docs/content/docs/get-started/quickstart/workflow_agent.md
index 3fc3d81da..4e0e97869 100644
--- a/docs/content/docs/get-started/quickstart/workflow_agent.md
+++ b/docs/content/docs/get-started/quickstart/workflow_agent.md
@@ -218,7 +218,7 @@ public class ReviewAnalysisAgent extends Agent {
     }
 
     /** Process input event and send chat request for review analysis. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
@@ -239,7 +239,7 @@ public class ReviewAnalysisAgent extends Agent {
                         "reviewAnalysisModel", List.of(msg), Map.of("input", content), null));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx)
             throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
diff --git a/docs/content/docs/operations/monitoring.md b/docs/content/docs/operations/monitoring.md
index eb6570237..c24a07051 100644
--- a/docs/content/docs/operations/monitoring.md
+++ b/docs/content/docs/operations/monitoring.md
@@ -88,7 +88,7 @@ class MyAgent(Agent):
 ```java
 public class MyAgent extends Agent {
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void firstAction(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         long startTime = System.currentTimeMillis();
diff --git a/e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_python_action.json b/e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_python_action.json
index fd662469f..8b6e28867 100644
--- a/e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_python_action.json
+++ b/e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_python_action.json
@@ -8,7 +8,7 @@
         "method_name" : "processChatRequestOrToolResponse",
         "parameter_types" : [ "org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext" ]
       },
-      "listen_event_types" : [ "_chat_request_event", "_tool_response_event" ],
+      "trigger_conditions" : [ "_chat_request_event", "_tool_response_event" ],
       "config" : null
     },
     "context_retrieval_action" : {
@@ -19,7 +19,7 @@
         "method_name" : "processContextRetrievalRequest",
         "parameter_types" : [ "org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext" ]
       },
-      "listen_event_types" : [ "_context_retrieval_request_event" ],
+      "trigger_conditions" : [ "_context_retrieval_request_event" ],
       "config" : null
     },
     "handle" : {
@@ -29,7 +29,7 @@
         "module" : "flink_agents.plan.tests.test_agent_plan_cross_language",
         "qualname" : "_dummy_action"
       },
-      "listen_event_types" : [ "_input_event" ],
+      "trigger_conditions" : [ "_input_event" ],
       "config" : null
     },
     "tool_call_action" : {
@@ -40,7 +40,7 @@
         "method_name" : "processToolRequest",
         "parameter_types" : [ "org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext" ]
       },
-      "listen_event_types" : [ "_tool_request_event" ],
+      "trigger_conditions" : [ "_tool_request_event" ],
       "config" : null
     }
   },
diff --git a/e2e-test/cross-language-agent-plan-snapshots/python/agent_plan_with_java_action.json b/e2e-test/cross-language-agent-plan-snapshots/python/agent_plan_with_java_action.json
index dda6405d1..19dee444a 100644
--- a/e2e-test/cross-language-agent-plan-snapshots/python/agent_plan_with_java_action.json
+++ b/e2e-test/cross-language-agent-plan-snapshots/python/agent_plan_with_java_action.json
@@ -11,7 +11,7 @@
           "org.apache.flink.agents.api.context.RunnerContext"
         ]
       },
-      "listen_event_types": [
+      "trigger_conditions": [
         "_input_event"
       ],
       "config": null
@@ -23,7 +23,7 @@
         "module": "flink_agents.plan.actions.chat_model_action",
         "qualname": "process_chat_request_or_tool_response"
       },
-      "listen_event_types": [
+      "trigger_conditions": [
         "_chat_request_event",
         "_tool_response_event"
       ],
@@ -36,7 +36,7 @@
         "module": "flink_agents.plan.actions.tool_call_action",
         "qualname": "process_tool_request"
       },
-      "listen_event_types": [
+      "trigger_conditions": [
         "_tool_request_event"
       ],
       "config": null
@@ -48,7 +48,7 @@
         "module": "flink_agents.plan.actions.context_retrieval_action",
         "qualname": "process_context_retrieval_request"
       },
-      "listen_event_types": [
+      "trigger_conditions": [
         "_context_retrieval_request_event"
       ],
       "config": null
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/AsyncExecutionAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/AsyncExecutionAgent.java
index 6d7fbd26f..c8df332f5 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/AsyncExecutionAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/AsyncExecutionAgent.java
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -94,7 +95,7 @@ public String getProcessedResult() {
      */
     public static class SimpleAsyncAgent extends Agent {
 
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processInput(Event event, RunnerContext ctx) throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
             AsyncRequest request = (AsyncRequest) inputEvent.getInput();
@@ -135,7 +136,7 @@ public String call() {
          * @param event The processed event
          * @param ctx The runner context for sending events
          */
-        @Action(listenEventTypes = {AsyncProcessedEvent.EVENT_TYPE})
+        @Action(AsyncProcessedEvent.EVENT_TYPE)
         public static void generateOutput(Event event, RunnerContext ctx) throws Exception {
             AsyncProcessedEvent processedEvent = (AsyncProcessedEvent) event;
 
@@ -153,7 +154,7 @@ public static void generateOutput(Event event, RunnerContext ctx) throws Excepti
     /** Agent that chains multiple durableExecuteAsync calls. */
     public static class MultiAsyncAgent extends Agent {
 
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processWithMultipleAsync(Event event, RunnerContext ctx)
                 throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
@@ -261,7 +262,7 @@ public String getTimestampDir() {
             return timestampDir;
         }
 
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processWithTiming(Event event, RunnerContext ctx) throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
             AsyncRequest request = (AsyncRequest) inputEvent.getInput();
@@ -303,7 +304,7 @@ public String call() {
     /** Agent that uses durableExecute (sync) for simulating slow operations. */
     public static class SyncDurableAgent extends Agent {
 
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processInputSync(Event event, RunnerContext ctx) throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
             AsyncRequest request = (AsyncRequest) inputEvent.getInput();
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ChatModelIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ChatModelIntegrationAgent.java
index 4492a8f48..ee64d1dff 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ChatModelIntegrationAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/ChatModelIntegrationAgent.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -217,7 +218,7 @@ public static double createRandomNumber() {
         return Math.random();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void process(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         ctx.sendEvent(
@@ -228,7 +229,7 @@ public static void process(Event event, RunnerContext ctx) throws Exception {
                                         MessageRole.USER, (String) inputEvent.getInput()))));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx) {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
         ctx.sendEvent(new OutputEvent(chatResponse.getResponse().getContent()));
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java
index 08fa3a1cc..2167d1332 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java
@@ -20,6 +20,7 @@
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -130,7 +131,7 @@ public static float validateSimilarityCalculation(
     }
 
     /** Main test action that processes input and validates embedding generation. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void testEmbeddingGeneration(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
index 47cbd5924..bacb4aa0c 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/FlinkIntegrationAgent.java
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -89,7 +90,7 @@ public static class DataStreamAgent extends Agent {
          * @param event The input event to process
          * @param ctx The runner context for sending events
          */
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processInput(Event event, RunnerContext ctx) throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
             ItemData item = (ItemData) inputEvent.getInput();
@@ -114,7 +115,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception
          * @param event The processed event
          * @param ctx The runner context for sending events
          */
-        @Action(listenEventTypes = {ProcessedEvent.EVENT_TYPE})
+        @Action(ProcessedEvent.EVENT_TYPE)
         public static void generateOutput(Event event, RunnerContext ctx) throws Exception {
             ProcessedEvent processedEvent = (ProcessedEvent) event;
             MemoryRef itemRef = processedEvent.getItemRef();
@@ -143,7 +144,7 @@ public static class TableAgent extends Agent {
          * @param event The input event to process
          * @param ctx The runner context for sending events
          */
-        @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+        @Action(EventType.InputEvent)
         public static void processInput(Event event, RunnerContext ctx) throws Exception {
             InputEvent inputEvent = InputEvent.fromEvent(event);
             Object input = inputEvent.getInput();
@@ -168,7 +169,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception
          * @param event The processed event
          * @param ctx The runner context for sending events
          */
-        @Action(listenEventTypes = {ProcessedEvent.EVENT_TYPE})
+        @Action(ProcessedEvent.EVENT_TYPE)
         public static void generateOutput(Event event, RunnerContext ctx) throws Exception {
             ProcessedEvent processedEvent = (ProcessedEvent) event;
             MemoryRef inputRef = processedEvent.getItemRef();
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/MemoryObjectAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/MemoryObjectAgent.java
index d8ec4e7f0..094daf270 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/MemoryObjectAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/MemoryObjectAgent.java
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -76,7 +77,7 @@ public int hashCode() {
         }
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void testMemoryObject(Event event, RunnerContext ctx) throws Exception {
         MemoryObject stm = ctx.getShortTermMemory();
         MemoryObject sm = ctx.getSensoryMemory();
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java
index b35338242..858f58d1a 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java
@@ -18,6 +18,7 @@
 
 package org.apache.flink.agents.integration.test;
 
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -103,7 +104,7 @@ public static org.apache.flink.agents.api.prompt.Prompt systemPrompt() {
                                         + "first and strictly follow the instructions of the skill.")));
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void process(InputEvent event, RunnerContext ctx) throws Exception {
         ctx.sendEvent(
                 new ChatRequestEvent(
@@ -112,7 +113,7 @@ public static void process(InputEvent event, RunnerContext ctx) throws Exception
                                 new ChatMessage(MessageRole.USER, (String) event.getInput()))));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(ChatResponseEvent event, RunnerContext ctx) {
         ctx.sendEvent(new OutputEvent(event.getResponse().getContent()));
     }
diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/VectorStoreIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/VectorStoreIntegrationAgent.java
index 9740eddb3..ad48d5eac 100644
--- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/VectorStoreIntegrationAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/VectorStoreIntegrationAgent.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.integration.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -93,7 +94,7 @@ public static ResourceDescriptor vectorStore() {
         }
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void inputEvent(Event event, RunnerContext ctx) {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         final String input = (String) inputEvent.getInput();
@@ -101,7 +102,7 @@ public static void inputEvent(Event event, RunnerContext ctx) {
         ctx.sendEvent(new ContextRetrievalRequestEvent(input, "vectorStore"));
     }
 
-    @Action(listenEventTypes = {ContextRetrievalResponseEvent.EVENT_TYPE})
+    @Action(EventType.ContextRetrievalResponseEvent)
     public static void contextRetrievalResponseEvent(Event event, RunnerContext ctx) {
         ContextRetrievalResponseEvent responseEvent =
                 ContextRetrievalResponseEvent.fromEvent(event);
diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ChatModelCrossLanguageAgent.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ChatModelCrossLanguageAgent.java
index dec657042..a2d78ecfb 100644
--- a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ChatModelCrossLanguageAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ChatModelCrossLanguageAgent.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.resource.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -148,7 +149,7 @@ public static double createRandomNumber() {
         return Math.random();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void process(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String model;
@@ -166,7 +167,7 @@ public static void process(Event event, RunnerContext ctx) throws Exception {
                                         MessageRole.USER, (String) inputEvent.getInput()))));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx) {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
         ctx.sendEvent(new OutputEvent(chatResponse.getResponse().getContent()));
diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/EmbeddingCrossLanguageAgent.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/EmbeddingCrossLanguageAgent.java
index 84ed246c1..be5f8d494 100644
--- a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/EmbeddingCrossLanguageAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/EmbeddingCrossLanguageAgent.java
@@ -20,6 +20,7 @@
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -68,7 +69,7 @@ public static ResourceDescriptor embeddingModel() {
     }
 
     /** Main test action that processes input and validates embedding generation. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void testEmbeddingGeneration(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/MCPCrossLanguageAgent.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/MCPCrossLanguageAgent.java
index 03893eda9..30b921e8c 100644
--- a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/MCPCrossLanguageAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/MCPCrossLanguageAgent.java
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.resource.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -47,7 +48,7 @@ public static ResourceDescriptor pythonMCPServer() {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void process(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         Map testResult = new HashMap<>();
diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryAgent.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryAgent.java
index 9a7655b61..04e1970ae 100644
--- a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryAgent.java
@@ -19,6 +19,7 @@
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -172,7 +173,7 @@ public static ResourceDescriptor milvusLtmStore() {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void addItems(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         ItemData input = (ItemData) inputEvent.getInput();
@@ -213,7 +214,7 @@ public Void call() throws Exception {
     }
 
     @SuppressWarnings("unchecked")
-    @Action(listenEventTypes = {MyEvent})
+    @Action(MyEvent)
     public static void retrieveItems(Event event, RunnerContext ctx) throws Exception {
         Map record = (Map) event.getAttr("value");
         record.put("timestamp_second_action", Instant.now().toString());
diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/VectorStoreCrossLanguageAgent.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/VectorStoreCrossLanguageAgent.java
index cacf62444..5518978ab 100644
--- a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/VectorStoreCrossLanguageAgent.java
+++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/VectorStoreCrossLanguageAgent.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.resource.test;
 
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -106,7 +107,7 @@ public static ResourceDescriptor vectorStore() {
                 .build();
     }
 
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void inputEvent(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         final String input = (String) inputEvent.getInput();
@@ -203,7 +204,7 @@ public static void inputEvent(Event event, RunnerContext ctx) throws Exception {
         ctx.sendEvent(new ContextRetrievalRequestEvent(input, "vectorStore"));
     }
 
-    @Action(listenEventTypes = {ContextRetrievalResponseEvent.EVENT_TYPE})
+    @Action(EventType.ContextRetrievalResponseEvent)
     public static void contextRetrievalResponseEvent(Event event, RunnerContext ctx) {
         ContextRetrievalResponseEvent responseEvent =
                 ContextRetrievalResponseEvent.fromEvent(event);
diff --git a/examples/src/main/java/org/apache/flink/agents/examples/agents/MathAgent.java b/examples/src/main/java/org/apache/flink/agents/examples/agents/MathAgent.java
index 1056f877b..ba1ece089 100644
--- a/examples/src/main/java/org/apache/flink/agents/examples/agents/MathAgent.java
+++ b/examples/src/main/java/org/apache/flink/agents/examples/agents/MathAgent.java
@@ -17,6 +17,7 @@
  */
 package org.apache.flink.agents.examples.agents;
 
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -84,7 +85,7 @@ public static ResourceDescriptor mathModel() {
     }
 
     /** Process input event and send a chat request to evaluate the question. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(InputEvent event, RunnerContext ctx) {
         ctx.sendEvent(
                 new ChatRequestEvent(
@@ -94,7 +95,7 @@ public static void processInput(InputEvent event, RunnerContext ctx) {
     }
 
     /** Process chat response event and send the answer as output. */
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(ChatResponseEvent event, RunnerContext ctx) {
         ctx.sendEvent(new OutputEvent(event.getResponse().getContent()));
     }
diff --git a/examples/src/main/java/org/apache/flink/agents/examples/agents/ProductSuggestionAgent.java b/examples/src/main/java/org/apache/flink/agents/examples/agents/ProductSuggestionAgent.java
index 2d1e12225..8d5d0b90f 100644
--- a/examples/src/main/java/org/apache/flink/agents/examples/agents/ProductSuggestionAgent.java
+++ b/examples/src/main/java/org/apache/flink/agents/examples/agents/ProductSuggestionAgent.java
@@ -21,6 +21,7 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -72,7 +73,7 @@ public static org.apache.flink.agents.api.prompt.Prompt productSuggestionPrompt(
     }
 
     /** Process input event. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
@@ -95,7 +96,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception
     }
 
     /** Process chat response event. */
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx) throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
         // Fail fast on a malformed LLM response: a parse error here propagates and fails the
diff --git a/examples/src/main/java/org/apache/flink/agents/examples/agents/ReviewAnalysisAgent.java b/examples/src/main/java/org/apache/flink/agents/examples/agents/ReviewAnalysisAgent.java
index 7fbfe752a..2862e0ae4 100644
--- a/examples/src/main/java/org/apache/flink/agents/examples/agents/ReviewAnalysisAgent.java
+++ b/examples/src/main/java/org/apache/flink/agents/examples/agents/ReviewAnalysisAgent.java
@@ -21,6 +21,7 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -87,7 +88,7 @@ public static void notifyShippingManager(
     }
 
     /** Process input event and send chat request for review analysis. */
-    @Action(listenEventTypes = {InputEvent.EVENT_TYPE})
+    @Action(EventType.InputEvent)
     public static void processInput(Event event, RunnerContext ctx) throws Exception {
         InputEvent inputEvent = InputEvent.fromEvent(event);
         String input = (String) inputEvent.getInput();
@@ -108,7 +109,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception
                         "reviewAnalysisModel", List.of(msg), Map.of("input", content), null));
     }
 
-    @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE})
+    @Action(EventType.ChatResponseEvent)
     public static void processChatResponse(Event event, RunnerContext ctx) throws Exception {
         ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event);
         // Fail fast on a malformed LLM response: a parse error here propagates and fails the
diff --git a/examples/src/main/java/org/apache/flink/agents/examples/agents/TableReviewAnalysisAgent.java b/examples/src/main/java/org/apache/flink/agents/examples/agents/TableReviewAnalysisAgent.java
index 18d754be2..a692b3e9b 100644
--- a/examples/src/main/java/org/apache/flink/agents/examples/agents/TableReviewAnalysisAgent.java
+++ b/examples/src/main/java/org/apache/flink/agents/examples/agents/TableReviewAnalysisAgent.java
@@ -20,6 +20,7 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.api.OutputEvent;
 import org.apache.flink.agents.api.agents.Agent;
@@ -108,7 +109,7 @@ public static void notifyShippingManager(
      * 

When using {@code fromTable()}, the input is a {@link Row} with fields matching the table * column names. */ - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public static void processInput(Event event, RunnerContext ctx) throws Exception { InputEvent inputEvent = InputEvent.fromEvent(event); Row row = (Row) inputEvent.getInput(); @@ -128,7 +129,7 @@ public static void processInput(Event event, RunnerContext ctx) throws Exception "reviewAnalysisModel", List.of(msg), Map.of("input", content), null)); } - @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE}) + @Action(EventType.ChatResponseEvent) public static void processChatResponse(Event event, RunnerContext ctx) throws Exception { ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event); // Fail fast on a malformed LLM response: a parse error here propagates and fails the diff --git a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java index 1bd647764..e9d59083a 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java @@ -184,27 +184,27 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE private void extractActions( String actionName, - String[] listenEventTypeStrings, + String[] triggerEntries, org.apache.flink.agents.plan.Function function, Map config) throws Exception { - List eventTypeNames = new ArrayList<>(Arrays.asList(listenEventTypeStrings)); + List triggerConditions = new ArrayList<>(Arrays.asList(triggerEntries)); - if (eventTypeNames.isEmpty()) { + if (triggerConditions.isEmpty()) { throw new IllegalArgumentException( "Action " + actionName - + " must specify at least one event type via listenEventTypes."); + + " must specify at least one trigger entry via @Action(value = ...)."); } // Create an Action - Action action = new Action(actionName, function, eventTypeNames, config); + Action action = new Action(actionName, function, triggerConditions, config); // Add to actions map actions.put(action.getName(), action); // Add to actionsByEvent map - for (String eventTypeName : eventTypeNames) { + for (String eventTypeName : triggerConditions) { actionsByEvent.computeIfAbsent(eventTypeName, k -> new ArrayList<>()).add(action); } } @@ -251,7 +251,7 @@ private void extractActionsFromAgent(Agent agent) throws Exception { Objects.requireNonNull( method.getAnnotation( org.apache.flink.agents.api.annotation.Action.class)); - String[] listenEventTypeStrings = actionAnnotation.listenEventTypes(); + String[] triggerEntries = actionAnnotation.value(); org.apache.flink.agents.api.annotation.PythonFunction target = actionAnnotation.target(); String targetModule = target.module(); @@ -276,7 +276,7 @@ private void extractActionsFromAgent(Agent agent) throws Exception { + method.getName() + "' must set both module and qualname"); } - extractActions(method.getName(), listenEventTypeStrings, execFunction, null); + extractActions(method.getName(), triggerEntries, execFunction, null); } for (Map.Entry< diff --git a/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java b/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java index 859795a69..18e3b83b4 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java @@ -33,17 +33,17 @@ import java.util.Objects; /** - * Representation of an agent action with event listening and function execution. + * Representation of an agent action with unified trigger conditions. * - *

This class encapsulates a named agent action that listens for specific event types and - * executes an associated function when those events occur. + *

Each entry of {@code triggerConditions} is an event type name string. Multiple entries combine + * with OR. */ @JsonSerialize(using = ActionJsonSerializer.class) @JsonDeserialize(using = ActionJsonDeserializer.class) public class Action { private final String name; private final Function exec; - private final List listenEventTypes; + private final List triggerConditions; // TODO: support nested map/list with non primitive type value. @Nullable private final Map config; @@ -51,18 +51,18 @@ public class Action { public Action( String name, Function exec, - List listenEventTypes, + List triggerConditions, @Nullable Map config) throws Exception { this.name = name; this.exec = exec; - this.listenEventTypes = listenEventTypes; + this.triggerConditions = triggerConditions; this.config = config; exec.checkSignature(new Class[] {Event.class, RunnerContext.class}); } - public Action(String name, Function exec, List listenEventTypes) throws Exception { - this(name, exec, listenEventTypes, null); + public Action(String name, Function exec, List triggerConditions) throws Exception { + this(name, exec, triggerConditions, null); } public String getName() { @@ -73,8 +73,19 @@ public Function getExec() { return exec; } + /** Returns the full trigger conditions list. */ + public List getTriggerConditions() { + return triggerConditions; + } + + /** + * Returns event-type names. Kept for callers that still consume the old naming; in this PR all + * trigger entries are plain event-type names so the list is identical to {@link + * #getTriggerConditions()}. A follow-up PR introduces CEL expressions and overrides this to + * filter out non-type entries. + */ public List getListenEventTypes() { - return listenEventTypes; + return triggerConditions; } @Nullable @@ -89,11 +100,11 @@ public boolean equals(Object o) { Action other = (Action) o; return name.equals(other.name) && exec.equals(other.exec) - && listenEventTypes.equals(other.listenEventTypes); + && Objects.equals(triggerConditions, other.triggerConditions); } @Override public int hashCode() { - return Objects.hash(name, exec, listenEventTypes); + return Objects.hash(name, exec, triggerConditions); } } diff --git a/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializer.java b/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializer.java index fec2f126f..c6a87fa46 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializer.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializer.java @@ -66,10 +66,15 @@ public Action deserialize(JsonParser jsonParser, DeserializationContext deserial throw new IOException("Unsupported function type: " + funcType); } - // Deserialize listenEventTypes - List listenEventTypes = new ArrayList<>(); - node.get("listen_event_types") - .forEach(eventTypeNode -> listenEventTypes.add(eventTypeNode.asText())); + // Deserialize trigger_conditions (fall back to legacy listen_event_types for older JSONs) + List triggerConditions = new ArrayList<>(); + JsonNode triggerNode = node.get("trigger_conditions"); + if (triggerNode == null) { + triggerNode = node.get("listen_event_types"); + } + if (triggerNode != null) { + triggerNode.forEach(entryNode -> triggerConditions.add(entryNode.asText())); + } // Deserialize params Map config = null; @@ -88,7 +93,7 @@ public Action deserialize(JsonParser jsonParser, DeserializationContext deserial } try { - return new Action(name, func, listenEventTypes, config); + return new Action(name, func, triggerConditions, config); } catch (Exception e) { throw new RuntimeException( String.format("Failed to create Action with name \"%s\"", name), e); diff --git a/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializer.java b/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializer.java index 77581dadf..85e46823e 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializer.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializer.java @@ -62,11 +62,11 @@ public void serialize( "Unsupported function type: " + action.getExec().getClass().getName()); } - // Write listenEventTypes field - jsonGenerator.writeFieldName("listen_event_types"); + // Write trigger_conditions field + jsonGenerator.writeFieldName("trigger_conditions"); jsonGenerator.writeStartArray(); - for (String eventType : action.getListenEventTypes()) { - jsonGenerator.writeString(eventType); + for (String entry : action.getTriggerConditions()) { + jsonGenerator.writeString(entry); } jsonGenerator.writeEndArray(); diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareChatModelTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareChatModelTest.java index 0ac51609e..561b2b5c6 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareChatModelTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareChatModelTest.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.agents.api.Event; -import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.Action; import org.apache.flink.agents.api.annotation.ChatModelSetup; @@ -85,7 +85,7 @@ public static ResourceDescriptor testChatModel() { .build(); } - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public void onInput(Event e, RunnerContext ctx) { // no-op for this test; validates action registration signature } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareMCPServerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareMCPServerTest.java index 719f195f5..70c8f7aa2 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareMCPServerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareMCPServerTest.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.agents.api.Event; -import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.Action; import org.apache.flink.agents.api.context.RunnerContext; @@ -80,7 +80,7 @@ public static ResourceDescriptor testMcpServer() { .build(); } - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public void process(Event event, RunnerContext ctx) { // no-op } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolFieldTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolFieldTest.java index c87cc53f3..76f4f3ca5 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolFieldTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolFieldTest.java @@ -21,7 +21,7 @@ package org.apache.flink.agents.plan; import org.apache.flink.agents.api.Event; -import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.Action; import org.apache.flink.agents.api.annotation.ToolParam; @@ -86,7 +86,7 @@ static class TestAgent extends Agent { @org.apache.flink.agents.api.annotation.Tool private final Tool weather = createWeatherTool(); - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public void onInput(Event e, RunnerContext ctx) { /* no-op */ } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolMethodTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolMethodTest.java index a3784be59..a24cfbc76 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolMethodTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareToolMethodTest.java @@ -22,7 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.agents.api.Event; -import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.Action; import org.apache.flink.agents.api.annotation.ToolParam; @@ -84,7 +84,7 @@ public static String getWeather( location, temp, "fahrenheit".equals(units) ? "F" : "C"); } - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public void process(Event event, RunnerContext ctx) { // no-op } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanTest.java index acdf14343..230eb75f2 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanTest.java @@ -19,6 +19,7 @@ package org.apache.flink.agents.plan; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.agents.Agent; @@ -126,13 +127,15 @@ public Object getPythonResource() { /** Test agent class with annotated methods. */ public static class TestAgent extends Agent { - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public void handleInputEvent(Event event, RunnerContext context) { InputEvent inputEvent = InputEvent.fromEvent(event); } - @org.apache.flink.agents.api.annotation.Action( - listenEventTypes = {TestEvent.EVENT_TYPE, OutputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action({ + TestEvent.EVENT_TYPE, + EventType.OutputEvent + }) public void handleMultipleEvents(Event event, RunnerContext context) { // Test action implementation } @@ -161,7 +164,7 @@ public static ResourceDescriptor pythonChatModel() { @Tool private TestTool anotherTool = new TestTool("anotherTool"); - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public void handleInputEvent(Event event, RunnerContext context) { InputEvent inputEvent = InputEvent.fromEvent(event); } @@ -262,7 +265,7 @@ public void testBuiltInActionsAreJavaNativeAfterCompile() throws Exception { /** Cross-language action via {@code @Action(target = @PythonFunction(...))}. */ public static class AgentWithCrossLanguageAction extends Agent { @org.apache.flink.agents.api.annotation.Action( - listenEventTypes = {InputEvent.EVENT_TYPE}, + value = EventType.InputEvent, target = @org.apache.flink.agents.api.annotation.PythonFunction( module = "my_pkg.handlers", @@ -291,7 +294,7 @@ public void testActionWithPythonTargetCompilesToPythonFunctionExec() throws Exce /** Plain {@code @Action} (no {@code target}) compiles to a native Java exec. */ public static class AgentWithNativeJavaAction extends Agent { - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public static void handle(Event event, RunnerContext ctx) { // intentionally empty } @@ -311,7 +314,7 @@ public void testActionWithEmptyTargetCompilesToJavaFunctionExec() throws Excepti /** Partially-set target (module without qualname) — must be rejected at compile. */ public static class AgentWithHalfSetPythonTargetMissingQualname extends Agent { @org.apache.flink.agents.api.annotation.Action( - listenEventTypes = {InputEvent.EVENT_TYPE}, + value = EventType.InputEvent, target = @org.apache.flink.agents.api.annotation.PythonFunction(module = "pkg")) public static void handle(Event event, RunnerContext ctx) { throw new UnsupportedOperationException("cross-language stub"); @@ -329,7 +332,7 @@ public void testActionWithPythonTargetMissingQualnameIsRejected() { /** Partially-set target (qualname without module) — must be rejected at compile. */ public static class AgentWithHalfSetPythonTargetMissingModule extends Agent { @org.apache.flink.agents.api.annotation.Action( - listenEventTypes = {InputEvent.EVENT_TYPE}, + value = EventType.InputEvent, target = @org.apache.flink.agents.api.annotation.PythonFunction( qualname = "handle_input")) @@ -350,7 +353,7 @@ public void testActionWithPythonTargetMissingModuleIsRejected() { * @Action declared on a parent agent class — must be rejected loudly, not silently dropped. */ public abstract static class BaseAgentWithInheritedAction extends Agent { - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public static void sharedAction(Event event, RunnerContext ctx) { throw new UnsupportedOperationException("test stub"); } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/compatibility/GenerateAgentPlanJson.java b/plan/src/test/java/org/apache/flink/agents/plan/compatibility/GenerateAgentPlanJson.java index 5051966ed..e657378d9 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/compatibility/GenerateAgentPlanJson.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/compatibility/GenerateAgentPlanJson.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.agents.api.Event; -import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.Action; import org.apache.flink.agents.api.context.RunnerContext; @@ -48,12 +48,12 @@ public MyEvent() { /** Agent class for generating java agent plan json. */ public static class JavaAgentPlanCompatibilityTestAgent extends Agent { - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public void firstAction(Event event, RunnerContext context) { // Test action implementation } - @Action(listenEventTypes = {InputEvent.EVENT_TYPE, MyEvent.EVENT_TYPE}) + @Action({EventType.InputEvent, MyEvent.EVENT_TYPE}) public void secondAction(Event event, RunnerContext context) { // Test action implementation } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java index 6247b88c3..3f25cc127 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java @@ -68,8 +68,8 @@ public void testSerializeJavaFunction() throws Exception { assertTrue( json.contains("\"method_name\":\"legal\""), "JSON should contain the method name"); assertTrue( - json.contains("\"listen_event_types\":["), - "JSON should contain the listen event types"); + json.contains("\"trigger_conditions\":["), + "JSON should contain the trigger conditions"); assertTrue( json.contains("\"" + InputEvent.EVENT_TYPE + "\""), "JSON should contain the event type string"); @@ -103,8 +103,8 @@ public void testSerializePythonFunction() throws Exception { json.contains("\"qualname\":\"test_function\""), "JSON should contain the qualified name"); assertTrue( - json.contains("\"listen_event_types\":["), - "JSON should contain the listen event types"); + json.contains("\"trigger_conditions\":["), + "JSON should contain the trigger conditions"); assertTrue( json.contains("\"" + InputEvent.EVENT_TYPE + "\""), "JSON should contain the event type string"); @@ -133,8 +133,8 @@ public void testSerializeMultipleEventTypes() throws Exception { json.contains("\"name\":\"multiEventAction\""), "JSON should contain the action name"); assertTrue( - json.contains("\"listen_event_types\":["), - "JSON should contain the listen event types"); + json.contains("\"trigger_conditions\":["), + "JSON should contain the trigger conditions"); assertTrue( json.contains("\"" + InputEvent.EVENT_TYPE + "\""), "JSON should contain the InputEvent type string"); @@ -166,8 +166,8 @@ public void testSerializeEmptyEventTypes() throws Exception { json.contains("\"name\":\"emptyEventsAction\""), "JSON should contain the action name"); assertTrue( - json.contains("\"listen_event_types\":[]"), - "JSON should contain an empty listen event types array"); + json.contains("\"trigger_conditions\":[]"), + "JSON should contain an empty trigger conditions array"); } @Test diff --git a/plan/src/test/java/org/apache/flink/agents/plan/serializer/AgentPlanJsonSerializerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/serializer/AgentPlanJsonSerializerTest.java index 2b9a91484..c8ba012b1 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/serializer/AgentPlanJsonSerializerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/serializer/AgentPlanJsonSerializerTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.agents.Agent; @@ -42,18 +43,20 @@ public class AgentPlanJsonSerializerTest { /** Test Agent class with @Action annotated methods. */ public static class TestAgent extends Agent { - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public void handleInputEvent(Event event, RunnerContext context) { InputEvent inputEvent = InputEvent.fromEvent(event); } - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {OutputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.OutputEvent) public void processOutputEvent(Event event, RunnerContext context) { OutputEvent outputEvent = OutputEvent.fromEvent(event); } - @org.apache.flink.agents.api.annotation.Action( - listenEventTypes = {InputEvent.EVENT_TYPE, OutputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action({ + EventType.InputEvent, + EventType.OutputEvent + }) public void handleMultipleEvents(Event event, RunnerContext context) { // Test action logic for multiple event types } @@ -94,7 +97,7 @@ public void testSerializeAgentPlanWithActions() throws Exception { assertThat(json).contains("\"func_type\":\"JavaFunction\""); assertThat(json).contains("\"qualname\":\"org.apache.flink.agents.plan.TestAction\""); assertThat(json).contains("\"method_name\":\"legal\""); - assertThat(json).contains("\"listen_event_types\":["); + assertThat(json).contains("\"trigger_conditions\":["); assertThat(json).contains("\"" + InputEvent.EVENT_TYPE + "\""); assertThat(json).contains("\"actions_by_event\":{}"); } diff --git a/plan/src/test/resources/actions/action_java_function.json b/plan/src/test/resources/actions/action_java_function.json index 35fcbeedd..6b73f324a 100644 --- a/plan/src/test/resources/actions/action_java_function.json +++ b/plan/src/test/resources/actions/action_java_function.json @@ -6,5 +6,5 @@ "method_name": "legal", "parameter_types": ["org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext"] }, - "listen_event_types": ["_input_event"] + "trigger_conditions": ["_input_event"] } diff --git a/plan/src/test/resources/actions/action_python_function.json b/plan/src/test/resources/actions/action_python_function.json index 70fdf3aca..5711b873d 100644 --- a/plan/src/test/resources/actions/action_python_function.json +++ b/plan/src/test/resources/actions/action_python_function.json @@ -5,5 +5,5 @@ "module": "test_module", "qualname": "test_function" }, - "listen_event_types": ["_input_event"] + "trigger_conditions": ["_input_event"] } diff --git a/plan/src/test/resources/agent_plans/agent_plan.json b/plan/src/test/resources/agent_plans/agent_plan.json index 3cb8dcb25..52a1b5ad8 100644 --- a/plan/src/test/resources/agent_plans/agent_plan.json +++ b/plan/src/test/resources/agent_plans/agent_plan.json @@ -8,7 +8,7 @@ "parameter_types": ["org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext"], "func_type": "JavaFunction" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event" ] }, @@ -20,7 +20,7 @@ "parameter_types": ["org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext"], "func_type": "JavaFunction" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event", "MyEvent" ] diff --git a/plan/src/test/resources/agent_plans/agent_plan_with_python_resource_providers.json b/plan/src/test/resources/agent_plans/agent_plan_with_python_resource_providers.json index cb7e9170d..abd08e2d3 100644 --- a/plan/src/test/resources/agent_plans/agent_plan_with_python_resource_providers.json +++ b/plan/src/test/resources/agent_plans/agent_plan_with_python_resource_providers.json @@ -7,7 +7,7 @@ "module": "flink_agents.plan.tests.compatibility.python_agent_plan_compatibility_test_agent", "qualname": "PythonAgentPlanCompatibilityTestAgent.first_action" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event" ] }, @@ -18,7 +18,7 @@ "module": "flink_agents.plan.tests.compatibility.python_agent_plan_compatibility_test_agent", "qualname": "PythonAgentPlanCompatibilityTestAgent.second_action" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event", "_my_event" ] @@ -30,7 +30,7 @@ "module": "flink_agents.plan.actions.chat_model_action", "qualname": "process_chat_request_or_tool_response" }, - "listen_event_types": [ + "trigger_conditions": [ "_chat_request_event", "_tool_response_event" ] @@ -42,7 +42,7 @@ "module": "flink_agents.plan.actions.tool_call_action", "qualname": "process_tool_request" }, - "listen_event_types": [ + "trigger_conditions": [ "_tool_request_event" ] } diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/RescalingTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/RescalingTest.java index a829e62d2..bdd5efe1b 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/RescalingTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/RescalingTest.java @@ -19,6 +19,7 @@ package org.apache.flink.agents.runtime; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.agents.Agent; @@ -504,14 +505,14 @@ public static class TestAgent extends Agent { public static final AtomicInteger numProcessedEvent = new AtomicInteger(0); - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public static void handleInputEvent(Event event, RunnerContext context) { InputEvent inputEvent = InputEvent.fromEvent(event); numProcessedEvent.incrementAndGet(); context.sendEvent(new TestEvent((Integer) inputEvent.getInput())); } - @Action(listenEventTypes = {TestEvent.EVENT_TYPE}) + @Action(TestEvent.EVENT_TYPE) public static void handleTestEvent(Event event, RunnerContext context) { TestEvent testEvent = (TestEvent) event; numProcessedEvent.incrementAndGet(); diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java index a48f618e5..f17e16d48 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java @@ -19,6 +19,7 @@ package org.apache.flink.agents.runtime; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.annotation.ChatModelSetup; @@ -124,7 +125,7 @@ public static ResourceDescriptor pythonChatModel() { @Tool private TestTool anotherTool = new TestTool("anotherTool"); - @org.apache.flink.agents.api.annotation.Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent) public void handleInputEvent(Event event, RunnerContext context) { InputEvent inputEvent = InputEvent.fromEvent(event); } diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/ShortTermMemoryTTLIntegrationTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/ShortTermMemoryTTLIntegrationTest.java index 1e9113840..97e4099e2 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/ShortTermMemoryTTLIntegrationTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/ShortTermMemoryTTLIntegrationTest.java @@ -18,6 +18,7 @@ package org.apache.flink.agents.runtime.memory; import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.agents.Agent; @@ -56,7 +57,7 @@ private TestInput(String eventKey, long sleepMs) { public static class TTLTestAgent extends Agent { - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public static void input(org.apache.flink.agents.api.Event event, RunnerContext ctx) throws Exception { InputEvent inputEvent = (InputEvent) event; From 5217fce7bbbff1ffa7e5c3245d1d90f42585df94 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:13:11 +0800 Subject: [PATCH 03/13] [python][plan] Unify @action trigger entry as trigger_conditions, migrate callers --- docs/content/docs/development/chat_models.md | 8 ++-- .../docs/development/embedding_models.md | 4 +- .../development/memory/long_term_memory.md | 4 +- .../memory/sensory_and_short_term_memory.md | 4 +- docs/content/docs/development/prompts.md | 2 +- .../content/docs/development/vector_stores.md | 4 +- .../docs/development/workflow_agent.md | 16 ++++---- .../get-started/quickstart/skills_agent.md | 4 +- .../get-started/quickstart/workflow_agent.md | 4 +- docs/content/docs/operations/monitoring.md | 4 +- python/flink_agents/api/agents/agent.py | 2 +- python/flink_agents/api/agents/react_agent.py | 3 +- python/flink_agents/api/decorators.py | 29 +++++++++------ python/flink_agents/api/events/event_type.py | 14 ++++--- .../flink_agents/api/tests/test_decorators.py | 37 ++++++++++--------- .../agent_skills_test.py | 5 ++- .../built_in_action_async_execution_test.py | 5 ++- .../chat_model_integration_agent.py | 5 ++- .../e2e_tests_mcp/mcp_test.py | 5 ++- .../execute_test_agent.py | 9 +++-- .../flink_integration_agent.py | 7 ++-- .../long_term_memory_test.py | 3 +- .../python_event_logging_test.py | 3 +- .../short_term_memory_ttl_test.py | 3 +- .../e2e_tests_integration/workflow_test.py | 3 +- .../chat_model_cross_language_agent.py | 5 ++- .../embedding_model_cross_language_agent.py | 3 +- .../vector_store_cross_language_agent.py | 5 ++- .../examples/quickstart/agents/math_agent.py | 5 ++- .../agents/product_suggestion_agent.py | 5 ++- .../agents/review_analysis_agent.py | 5 ++- .../agents/table_review_analysis_agent.py | 5 ++- .../examples/rag/rag_agent_example.py | 7 ++-- python/flink_agents/plan/actions/action.py | 26 +++++++++---- .../plan/actions/chat_model_action.py | 2 +- .../plan/actions/context_retrieval_action.py | 2 +- .../plan/actions/tool_call_action.py | 2 +- python/flink_agents/plan/agent_plan.py | 20 +++++----- .../create_python_agent_plan_from_json.py | 4 +- ...hon_agent_plan_compatibility_test_agent.py | 5 ++- .../plan/tests/resources/action.json | 2 +- .../plan/tests/resources/agent_plan.json | 10 ++--- python/flink_agents/plan/tests/test_action.py | 10 ++--- .../tests/test_agent_plan_cross_language.py | 10 ++--- .../runtime/tests/test_built_in_actions.py | 5 ++- .../tests/test_get_resource_in_action.py | 3 +- .../tests/test_local_execution_environment.py | 11 +++--- .../tests/test_runner_context_execute.py | 11 +++--- 48 files changed, 197 insertions(+), 153 deletions(-) diff --git a/docs/content/docs/development/chat_models.md b/docs/content/docs/development/chat_models.md index 0e7f40239..c7b40ae05 100644 --- a/docs/content/docs/development/chat_models.md +++ b/docs/content/docs/development/chat_models.md @@ -80,7 +80,7 @@ class MyAgent(Agent): temperature=0.7 ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -93,7 +93,7 @@ class MyAgent(Agent): ChatRequestEvent(model="ollama_chat_model", messages=[user_message]) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_response(event: Event, ctx: RunnerContext) -> None: chat_response = ChatResponseEvent.from_event(event) @@ -1178,7 +1178,7 @@ class MyAgent(Agent): extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -1191,7 +1191,7 @@ class MyAgent(Agent): ChatRequestEvent(model="java_chat_model", messages=[user_message]) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_response(event: Event, ctx: RunnerContext) -> None: chat_response = ChatResponseEvent.from_event(event) diff --git a/docs/content/docs/development/embedding_models.md b/docs/content/docs/development/embedding_models.md index c3f789836..a745dd154 100644 --- a/docs/content/docs/development/embedding_models.md +++ b/docs/content/docs/development/embedding_models.md @@ -129,7 +129,7 @@ class MyAgent(Agent): model="your-embedding-model-here" ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_text(event: Event, ctx: RunnerContext) -> None: # Get the embedding model from the runtime context @@ -612,7 +612,7 @@ class MyAgent(Agent): model="nomic-embed-text" ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: # Use the Java embedding model from Python diff --git a/docs/content/docs/development/memory/long_term_memory.md b/docs/content/docs/development/memory/long_term_memory.md index f3ad2ee74..46ffc3ac6 100644 --- a/docs/content/docs/development/memory/long_term_memory.md +++ b/docs/content/docs/development/memory/long_term_memory.md @@ -148,7 +148,7 @@ from flink_agents.api.decorators import action from flink_agents.api.events.event import InputEvent, Event from flink_agents.api.runner_context import RunnerContext -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def process_event(event: Event, ctx: RunnerContext) -> None: ltm = ctx.long_term_memory @@ -464,7 +464,7 @@ from flink_agents.api.runner_context import RunnerContext class PersonalizedAssistant: - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_event(event: Event, ctx: RunnerContext) -> None: """Respond to user using long-term memory.""" diff --git a/docs/content/docs/development/memory/sensory_and_short_term_memory.md b/docs/content/docs/development/memory/sensory_and_short_term_memory.md index 34c47b314..7d92452a9 100644 --- a/docs/content/docs/development/memory/sensory_and_short_term_memory.md +++ b/docs/content/docs/development/memory/sensory_and_short_term_memory.md @@ -79,7 +79,7 @@ The key of the pairs store in `MemoryObject` must be string, and the value can b {{< tab "Python" >}} ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) def process_event(event: Event, ctx: RunnerContext) -> None: memory: MemoryObject = ctx.sensory_memory # or ctx.short_term_memory # store primitive @@ -211,7 +211,7 @@ def first_action(event: Event, ctx: RunnerContext): ctx.send_event(MyEvent(value=data_ref)) ... -@action(MyEvent.EVENT_TYPE) +@action("MyEvent") @staticmethod def second_action(event: Event, ctx: RunnerContext): my_event = MyEvent.from_event(event) diff --git a/docs/content/docs/development/prompts.md b/docs/content/docs/development/prompts.md index 231b1cf85..8c57b5b0e 100644 --- a/docs/content/docs/development/prompts.md +++ b/docs/content/docs/development/prompts.md @@ -243,7 +243,7 @@ class ReviewAnalysisAgent(Agent): extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send chat request for review analysis.""" diff --git a/docs/content/docs/development/vector_stores.md b/docs/content/docs/development/vector_stores.md index 8124ba91c..07d5eafa7 100644 --- a/docs/content/docs/development/vector_stores.md +++ b/docs/content/docs/development/vector_stores.md @@ -362,7 +362,7 @@ class MyAgent(Agent): collection="my_chroma_store" ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def search_documents(event: Event, ctx: RunnerContext) -> None: # Get the vector store from the runtime context @@ -1049,7 +1049,7 @@ class MyAgent(Agent): dims=768 ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: # Use Java vector store from Python diff --git a/docs/content/docs/development/workflow_agent.md b/docs/content/docs/development/workflow_agent.md index 8ba4ae4ec..133ca9291 100644 --- a/docs/content/docs/development/workflow_agent.md +++ b/docs/content/docs/development/workflow_agent.md @@ -85,7 +85,7 @@ class ReviewAnalysisAgent(Agent): extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send chat request for review analysis.""" @@ -106,7 +106,7 @@ class ReviewAnalysisAgent(Agent): ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send output event.""" @@ -242,7 +242,7 @@ The decorated/annotated function signature should be `(Event, RunnerContext) -> {{< tab "Python" >}} ```python class ReviewAnalysisAgent(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: # the action logic @@ -272,7 +272,7 @@ In the function, user can also send new events, to trigger other actions, or out {{< tab "Python" >}} ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: # send a ChatRequestEvent to trigger the built-in chat-model action @@ -360,7 +360,7 @@ Use a reconciler for durable calls when the original call may already have compl {{< tab "Python" >}} Python actions can call `ctx.durable_execute(...)` to run a synchronous durable code block. ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -375,7 +375,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: You can also pass an optional `reconciler` callable to recover an execution outcome during recovery. ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -477,7 +477,7 @@ Async execution uses the same durable semantics but yields while waiting for a t {{< tab "Python" >}} Define an `async def` action and `await ctx.durable_execute_async(...)`. The same optional `reconciler=...` argument is available for recovery. ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod async def process_with_async(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -596,7 +596,7 @@ For simple cases, users can pass data between actions directly using `Event` wit {{< tab "Python" >}} ```python # Send a unified event from one action -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def create_my_event(event: Event, ctx: RunnerContext) -> None: ctx.send_event( diff --git a/docs/content/docs/get-started/quickstart/skills_agent.md b/docs/content/docs/get-started/quickstart/skills_agent.md index 677d8c6aa..a95a276a9 100644 --- a/docs/content/docs/get-started/quickstart/skills_agent.md +++ b/docs/content/docs/get-started/quickstart/skills_agent.md @@ -107,7 +107,7 @@ class MathAgent(Agent): allowed_commands=["echo", "bc"], ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send a chat request to evaluate the question.""" @@ -119,7 +119,7 @@ class MathAgent(Agent): ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send the answer as output.""" diff --git a/docs/content/docs/get-started/quickstart/workflow_agent.md b/docs/content/docs/get-started/quickstart/workflow_agent.md index 4e0e97869..c3c442fcb 100644 --- a/docs/content/docs/get-started/quickstart/workflow_agent.md +++ b/docs/content/docs/get-started/quickstart/workflow_agent.md @@ -128,7 +128,7 @@ class ReviewAnalysisAgent(Agent): extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send chat request for review analysis.""" @@ -148,7 +148,7 @@ class ReviewAnalysisAgent(Agent): ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send output event.""" diff --git a/docs/content/docs/operations/monitoring.md b/docs/content/docs/operations/monitoring.md index c24a07051..021452125 100644 --- a/docs/content/docs/operations/monitoring.md +++ b/docs/content/docs/operations/monitoring.md @@ -62,7 +62,7 @@ Here is the user case example: {{< tab "Python" >}} ```python class MyAgent(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def first_action(event: Event, ctx: RunnerContext): start_time = time.time_ns() @@ -130,7 +130,7 @@ The Flink Agents' log system uses Flink's logging framework. For more details, p For adding logs in Java code, you can refer to [Flink documentation](https://nightlies.apache.org/flink/flink-docs-master/docs/deployment/advanced/logging/#best-practices-for-developers). In Python, you can add logs using `logging`. Here is a specific example: ```python -@action(InputEvent.EVENT_TYPE) +@action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: logging.info("Processing input event: %s", event) diff --git a/python/flink_agents/api/agents/agent.py b/python/flink_agents/api/agents/agent.py index 3a6aed852..b5b7de607 100644 --- a/python/flink_agents/api/agents/agent.py +++ b/python/flink_agents/api/agents/agent.py @@ -39,7 +39,7 @@ class Agent(ABC): :: class MyAgent(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def my_action(event: Event, ctx: RunnerContext) -> None: action logic diff --git a/python/flink_agents/api/agents/react_agent.py b/python/flink_agents/api/agents/react_agent.py index cef651a1d..ca69b2b8a 100644 --- a/python/flink_agents/api/agents/react_agent.py +++ b/python/flink_agents/api/agents/react_agent.py @@ -33,6 +33,7 @@ from flink_agents.api.decorators import action from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceType from flink_agents.api.runner_context import RunnerContext @@ -205,7 +206,7 @@ def start_action(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def stop_action(event: Event, ctx: RunnerContext) -> None: """Stop action to output result.""" diff --git a/python/flink_agents/api/decorators.py b/python/flink_agents/api/decorators.py index 6025ac393..5c3fd47dc 100644 --- a/python/flink_agents/api/decorators.py +++ b/python/flink_agents/api/decorators.py @@ -33,17 +33,19 @@ def _validate_target(target: Function, owner: str) -> None: def action( - *listen_events: str, + *trigger_conditions: str, target: Function | None = None, ) -> Callable: """Decorator for marking a function as an agent action. - Each argument is a type-identifier string that this action responds to. + Each argument is an event-type name string that this action responds to. + Multiple entries combine with OR semantics — the action triggers if any + one matches. Parameters ---------- - listen_events : str - Type-identifier strings that this action responds to. + trigger_conditions : str + Event-type name strings that this action responds to. target : Function, optional Cross-language function descriptor dispatched instead of the decorated body. The body becomes a stub — raise @@ -52,22 +54,25 @@ def action( Returns: ------- Callable - Decorator function that marks the target function with event listeners. + Decorator function that marks the target function with trigger conditions. Raises: ------ AssertionError - If no events are provided or if an argument is not a string. + If no conditions are given or any entry is not a non-empty string. TypeError If ``target`` is provided but is not a :class:`Function` descriptor. """ - assert len(listen_events) > 0, ( - "action must have at least one event type to listen to" + assert len(trigger_conditions) > 0, ( + "action must have at least one trigger condition (event-type name)" ) - for evt in listen_events: - assert isinstance(evt, str), ( - f"action must listen to string type identifiers, got {evt!r}" + for entry in trigger_conditions: + assert isinstance(entry, str), ( + f"action trigger condition must be a string, got {entry!r}" + ) + assert entry != "", ( + f"action trigger condition must be non-empty, got {entry!r}" ) if target is not None and not isinstance(target, Function): @@ -81,7 +86,7 @@ def decorator(func: Callable) -> Callable: if target is not None: _validate_target(target, func.__qualname__) func._target = target - func._listen_events = listen_events + func._trigger_conditions = trigger_conditions return func return decorator diff --git a/python/flink_agents/api/events/event_type.py b/python/flink_agents/api/events/event_type.py index e36655ac9..7cea2add4 100644 --- a/python/flink_agents/api/events/event_type.py +++ b/python/flink_agents/api/events/event_type.py @@ -32,8 +32,7 @@ def handle(...): ... from __future__ import annotations import threading -from typing import Dict, Optional, Type - +from typing import Dict, Type # Hard-coded to avoid an event_type -> event -> event_type circular import. # A consistency test asserts each value matches XxxEvent.EVENT_TYPE. @@ -91,7 +90,7 @@ def register(event_class: Type) -> None: raise RuntimeError(msg) -def lookup(name: Optional[str]) -> Optional[str]: +def lookup(name: str | None) -> str | None: """Return the ``EVENT_TYPE`` string for a registered short name, else ``None``. Built-in names take precedence over user-registered ones. @@ -110,7 +109,7 @@ def lookup_or_self(name: str) -> str: return v if v is not None else name -def is_known(name: Optional[str]) -> bool: +def is_known(name: str | None) -> bool: """Return ``True`` if ``name`` is a registered short name.""" return lookup(name) is not None @@ -129,8 +128,10 @@ def _clear_user_registered_for_testing() -> None: class EventType: - """Namespace of built-in event-type constants, byte-equal to each - ``XxxEvent.EVENT_TYPE``. Use inside ``trigger_conditions``:: + """Namespace of built-in event-type constants. + + Each constant is byte-equal to the corresponding ``XxxEvent.EVENT_TYPE`` + and is meant to be used inside ``trigger_conditions``:: @action(EventType.InputEvent) @@ -153,5 +154,6 @@ class EventType: all_registered = staticmethod(all_registered) def __init__(self) -> None: + """Reject instantiation; ``EventType`` is a namespace, not a class.""" msg = "EventType is a namespace; do not instantiate" raise TypeError(msg) diff --git a/python/flink_agents/api/tests/test_decorators.py b/python/flink_agents/api/tests/test_decorators.py index 94ff8d86f..ccd58241b 100644 --- a/python/flink_agents/api/tests/test_decorators.py +++ b/python/flink_agents/api/tests/test_decorators.py @@ -19,29 +19,30 @@ from flink_agents.api.decorators import action from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.function import JavaFunction, PythonFunction from flink_agents.api.runner_context import RunnerContext def test_action_decorator() -> None: - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) def forward_action(event: Event, ctx: RunnerContext) -> None: input = InputEvent.from_event(event).input ctx.send_event(OutputEvent(output=input)) - assert hasattr(forward_action, "_listen_events") - listen_events = forward_action._listen_events + assert hasattr(forward_action, "_trigger_conditions") + listen_events = forward_action._trigger_conditions assert listen_events == (InputEvent.EVENT_TYPE,) def test_action_decorator_listen_multi_events() -> None: - @action(InputEvent.EVENT_TYPE, OutputEvent.EVENT_TYPE) + @action(EventType.InputEvent, EventType.OutputEvent) def forward_action(event: Event, ctx: RunnerContext) -> None: input = InputEvent.from_event(event).input ctx.send_event(OutputEvent(output=input)) - assert hasattr(forward_action, "_listen_events") - listen_events = forward_action._listen_events + assert hasattr(forward_action, "_trigger_conditions") + listen_events = forward_action._trigger_conditions assert listen_events == (InputEvent.EVENT_TYPE, OutputEvent.EVENT_TYPE) @@ -70,8 +71,8 @@ def test_action_decorator_with_string_identifier() -> None: def my_handler(event: Event, ctx: RunnerContext) -> None: pass - assert hasattr(my_handler, "_listen_events") - assert my_handler._listen_events == ("MyCustomEvent",) + assert hasattr(my_handler, "_trigger_conditions") + assert my_handler._trigger_conditions == ("MyCustomEvent",) def test_action_decorator_multiple_strings() -> None: @@ -81,7 +82,7 @@ def test_action_decorator_multiple_strings() -> None: def mixed_handler(event: Event, ctx: RunnerContext) -> None: pass - assert mixed_handler._listen_events == ("_input_event", "AnotherEvent") + assert mixed_handler._trigger_conditions == ("_input_event", "AnotherEvent") def test_action_decorator_rejects_invalid_types() -> None: @@ -100,25 +101,25 @@ def _java_target() -> JavaFunction: def test_action_decorator_with_cross_language_target() -> None: target = _java_target() - @action(InputEvent.EVENT_TYPE, target=target) + @action(EventType.InputEvent, target=target) def stub(event: Event, ctx: RunnerContext) -> None: msg = "cross-language stub" raise NotImplementedError(msg) - assert stub._listen_events == (InputEvent.EVENT_TYPE,) + assert stub._trigger_conditions == (InputEvent.EVENT_TYPE,) assert stub._target is target def test_action_decorator_rejects_non_function_target() -> None: with pytest.raises(TypeError, match="api-layer Function descriptor"): - @action(InputEvent.EVENT_TYPE, target="not a function") # type: ignore[arg-type] + @action(EventType.InputEvent, target="not a function") # type: ignore[arg-type] def stub(event: Event, ctx: RunnerContext) -> None: pass def test_action_decorator_without_target_does_not_set_attribute() -> None: - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) def regular(event: Event, ctx: RunnerContext) -> None: pass @@ -129,7 +130,7 @@ def test_action_decorator_rejects_java_target_with_empty_qualname() -> None: bad = JavaFunction(qualname="", method_name="handle", parameter_types=[]) with pytest.raises(ValueError, match="qualname"): - @action(InputEvent.EVENT_TYPE, target=bad) + @action(EventType.InputEvent, target=bad) def stub(event: Event, ctx: RunnerContext) -> None: pass @@ -138,7 +139,7 @@ def test_action_decorator_rejects_java_target_with_empty_method_name() -> None: bad = JavaFunction(qualname="com.example.X", method_name="", parameter_types=[]) with pytest.raises(ValueError, match="method_name"): - @action(InputEvent.EVENT_TYPE, target=bad) + @action(EventType.InputEvent, target=bad) def stub(event: Event, ctx: RunnerContext) -> None: pass @@ -147,7 +148,7 @@ def test_action_decorator_rejects_python_target_with_empty_module() -> None: bad = PythonFunction(module="", qualname="handle") with pytest.raises(ValueError, match="module"): - @action(InputEvent.EVENT_TYPE, target=bad) + @action(EventType.InputEvent, target=bad) def stub(event: Event, ctx: RunnerContext) -> None: pass @@ -156,7 +157,7 @@ def test_action_decorator_rejects_python_target_with_empty_qualname() -> None: bad = PythonFunction(module="pkg.mod", qualname="") with pytest.raises(ValueError, match="qualname"): - @action(InputEvent.EVENT_TYPE, target=bad) + @action(EventType.InputEvent, target=bad) def stub(event: Event, ctx: RunnerContext) -> None: pass @@ -165,6 +166,6 @@ def test_action_decorator_target_error_names_decorated_function() -> None: bad = PythonFunction(module="pkg.mod", qualname="") with pytest.raises(ValueError, match="my_named_stub"): - @action(InputEvent.EVENT_TYPE, target=bad) + @action(EventType.InputEvent, target=bad) def my_named_stub(event: Event, ctx: RunnerContext) -> None: pass diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/agent_skills_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/agent_skills_test.py index 8e5d13f8a..2fdc42354 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/agent_skills_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/agent_skills_test.py @@ -42,6 +42,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceName, ResourceType @@ -107,7 +108,7 @@ def system_prompt() -> Prompt: ], ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -138,7 +139,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: chat_response = ChatResponseEvent.from_event(event) diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/built_in_action_async_execution_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/built_in_action_async_execution_test.py index f59d25f00..cb0de86ee 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/built_in_action_async_execution_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/built_in_action_async_execution_test.py @@ -28,6 +28,7 @@ from flink_agents.api.decorators import action, chat_model_setup, tool from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.resource import ResourceDescriptor from flink_agents.api.runner_context import RunnerContext @@ -83,7 +84,7 @@ def add(a: int, b: int) -> int: time.sleep(5) # Simulate slow tool execution return a + b - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: input_event = InputEvent.from_event(event) @@ -96,7 +97,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: input = ChatResponseEvent.from_event(event).response diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/chat_model_integration_agent.py b/python/flink_agents/e2e_tests/e2e_tests_integration/chat_model_integration_agent.py index 2b9b7da70..c7158dbd1 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/chat_model_integration_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/chat_model_integration_agent.py @@ -27,6 +27,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.resource import ( ResourceDescriptor, ResourceName, @@ -162,7 +163,7 @@ def add(a: int, b: int) -> int: """ return a + b - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """User defined action for processing input. @@ -183,7 +184,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """User defined action for processing chat model response.""" diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/e2e_tests_mcp/mcp_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/e2e_tests_mcp/mcp_test.py index b617e825a..2c994f1fb 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/e2e_tests_mcp/mcp_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/e2e_tests_mcp/mcp_test.py @@ -46,6 +46,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.resource import ( ResourceDescriptor, @@ -106,7 +107,7 @@ def math_chat_model() -> ResourceDescriptor: ) return ResourceDescriptor(**descriptor_kwargs) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input and send chat request. @@ -136,7 +137,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ctx.send_event(ChatRequestEvent(model="math_chat_model", messages=[msg])) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response and output result.""" diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/execute_test_agent.py b/python/flink_agents/e2e_tests/e2e_tests_integration/execute_test_agent.py index c1f5aa50b..6bd662f41 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/execute_test_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/execute_test_agent.py @@ -23,6 +23,7 @@ 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.events.event_type import EventType from flink_agents.api.runner_context import RunnerContext @@ -97,7 +98,7 @@ def raise_exception(message: str) -> None: class ExecuteTestAgent(Agent): """Agent that uses synchronous durable_execute() method for testing.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event using durable_execute().""" @@ -112,7 +113,7 @@ def process(event: Event, ctx: RunnerContext) -> None: class ExecuteMultipleTestAgent(Agent): """Agent that makes multiple durable_execute() calls.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event with multiple durable_execute() calls.""" @@ -127,7 +128,7 @@ def process(event: Event, ctx: RunnerContext) -> None: class ExecuteWithAsyncTestAgent(Agent): """Agent that uses both durable_execute() and durable_execute_async().""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def process(event: Event, ctx: RunnerContext) -> None: """Process an event using both durable_execute() and durable_execute_async().""" @@ -144,7 +145,7 @@ async def process(event: Event, ctx: RunnerContext) -> None: class ExecuteWithAsyncExceptionTestAgent(Agent): """Agent that tests exception handling in durable_execute_async().""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def process(event: Event, ctx: RunnerContext) -> None: """Process an event and capture durable_execute_async() exceptions.""" diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/flink_integration_agent.py b/python/flink_agents/e2e_tests/e2e_tests_integration/flink_integration_agent.py index 2982c5aa3..c584570a5 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/flink_integration_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/flink_integration_agent.py @@ -27,6 +27,7 @@ from flink_agents.api.agents.agent import Agent from flink_agents.api.decorators import action, tool from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.resource import ResourceType from flink_agents.api.runner_context import RunnerContext @@ -105,7 +106,7 @@ def my_tool(input: str) -> str: """ return input + " call my tool" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def first_action(event: Event, ctx: RunnerContext) -> None: def log_to_stdout(input: Any, total: int) -> bool: @@ -152,7 +153,7 @@ class TableAgent(Agent): to __main__. """ - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def first_action(event: Event, ctx: RunnerContext) -> None: content = InputEvent.from_event(event).input @@ -175,7 +176,7 @@ class DataStreamToTableAgent(Agent): to __main__. """ - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def first_action(event: Event, ctx: RunnerContext) -> None: content = ItemData.model_validate(InputEvent.from_event(event).input) diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/long_term_memory_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/long_term_memory_test.py index a5dc32836..3db2ae010 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/long_term_memory_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/long_term_memory_test.py @@ -53,6 +53,7 @@ vector_store, ) from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.memory.long_term_memory import ( LongTermMemoryOptions, @@ -189,7 +190,7 @@ def chroma_ltm_store() -> ResourceDescriptor: collection="context", ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def add_items(event: Event, ctx: RunnerContext) -> None: input_data = ItemData.model_validate(InputEvent.from_event(event).input) diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/python_event_logging_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/python_event_logging_test.py index 898079276..2aade7d80 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/python_event_logging_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/python_event_logging_test.py @@ -36,6 +36,7 @@ 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.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.runner_context import RunnerContext @@ -53,7 +54,7 @@ def get_key(self, value: dict) -> int: class PythonEventLoggingAgent(Agent): """Agent for testing Python event logging.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send an output event.""" diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/short_term_memory_ttl_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/short_term_memory_ttl_test.py index 19c149fc9..472c12ae5 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/short_term_memory_ttl_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/short_term_memory_ttl_test.py @@ -33,6 +33,7 @@ ) from flink_agents.api.decorators import action from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.runner_context import RunnerContext @@ -53,7 +54,7 @@ def get_key(self, value: TtlTestInput) -> str: class ShortTermMemoryTtlTestAgent(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def input(event: Event, ctx: RunnerContext) -> None: input_data = TtlTestInput.model_validate(InputEvent.from_event(event).input) diff --git a/python/flink_agents/e2e_tests/e2e_tests_integration/workflow_test.py b/python/flink_agents/e2e_tests/e2e_tests_integration/workflow_test.py index ab71fa7b1..8195de44e 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_integration/workflow_test.py +++ b/python/flink_agents/e2e_tests/e2e_tests_integration/workflow_test.py @@ -24,6 +24,7 @@ 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.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.runner_context import RunnerContext @@ -68,7 +69,7 @@ class MyAgent(Agent): validation. """ - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def first_action(event: Event, ctx: RunnerContext) -> None: key = ctx.key diff --git a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/chat_model_cross_language_agent.py b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/chat_model_cross_language_agent.py index 2aca6a2bd..3920f2389 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/chat_model_cross_language_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/chat_model_cross_language_agent.py @@ -28,6 +28,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ( ResourceDescriptor, @@ -129,7 +130,7 @@ def add(a: int, b: int) -> int: """ return a + b - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """User defined action for processing input. @@ -150,7 +151,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """User defined action for processing chat model response.""" diff --git a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/embedding_model_cross_language_agent.py b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/embedding_model_cross_language_agent.py index 9a2ba4ed9..dbce4891b 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/embedding_model_cross_language_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/embedding_model_cross_language_agent.py @@ -24,6 +24,7 @@ embedding_model_setup, ) from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.resource import ( ResourceDescriptor, ResourceName, @@ -56,7 +57,7 @@ def embedding_model() -> ResourceDescriptor: model=os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text:latest"), ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """User defined action for processing input. diff --git a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/vector_store_cross_language_agent.py b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/vector_store_cross_language_agent.py index 18671bea2..52000147a 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/vector_store_cross_language_agent.py +++ b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/vector_store_cross_language_agent.py @@ -30,6 +30,7 @@ ContextRetrievalResponseEvent, ) from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.resource import ( ResourceDescriptor, ResourceName, @@ -153,7 +154,7 @@ def vector_store() -> ResourceDescriptor: msg = f"Unsupported vector store backend: {backend}" raise ValueError(msg) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """User defined action for processing input. @@ -273,7 +274,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ContextRetrievalResponseEvent.EVENT_TYPE) + @action(EventType.ContextRetrievalResponseEvent) @staticmethod def contextRetrievalResponseEvent(event: Event, ctx: RunnerContext) -> None: """User defined action for processing context retrieval response. diff --git a/python/flink_agents/examples/quickstart/agents/math_agent.py b/python/flink_agents/examples/quickstart/agents/math_agent.py index 20e3b8acf..77f177607 100644 --- a/python/flink_agents/examples/quickstart/agents/math_agent.py +++ b/python/flink_agents/examples/quickstart/agents/math_agent.py @@ -20,6 +20,7 @@ from flink_agents.api.decorators import action, chat_model_setup, prompt, skills from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceName from flink_agents.api.runner_context import RunnerContext @@ -79,7 +80,7 @@ def math_model() -> ResourceDescriptor: allowed_commands=["echo", "bc"], ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send a chat request to evaluate the question.""" @@ -91,7 +92,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send the answer as output.""" diff --git a/python/flink_agents/examples/quickstart/agents/product_suggestion_agent.py b/python/flink_agents/examples/quickstart/agents/product_suggestion_agent.py index 20350835a..b0979fa76 100644 --- a/python/flink_agents/examples/quickstart/agents/product_suggestion_agent.py +++ b/python/flink_agents/examples/quickstart/agents/product_suggestion_agent.py @@ -26,6 +26,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceName from flink_agents.api.runner_context import RunnerContext @@ -66,7 +67,7 @@ def generate_suggestion_model() -> ResourceDescriptor: extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event.""" @@ -87,7 +88,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event.""" diff --git a/python/flink_agents/examples/quickstart/agents/review_analysis_agent.py b/python/flink_agents/examples/quickstart/agents/review_analysis_agent.py index 8bd3b74dc..8e7f9a105 100644 --- a/python/flink_agents/examples/quickstart/agents/review_analysis_agent.py +++ b/python/flink_agents/examples/quickstart/agents/review_analysis_agent.py @@ -27,6 +27,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceName from flink_agents.api.runner_context import RunnerContext @@ -83,7 +84,7 @@ def review_analysis_model() -> ResourceDescriptor: extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event and send chat request for review analysis.""" @@ -103,7 +104,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send output event.""" diff --git a/python/flink_agents/examples/quickstart/agents/table_review_analysis_agent.py b/python/flink_agents/examples/quickstart/agents/table_review_analysis_agent.py index 1ee1f801c..7ddf94390 100644 --- a/python/flink_agents/examples/quickstart/agents/table_review_analysis_agent.py +++ b/python/flink_agents/examples/quickstart/agents/table_review_analysis_agent.py @@ -31,6 +31,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceName from flink_agents.api.runner_context import RunnerContext @@ -108,7 +109,7 @@ def review_analysis_model() -> ResourceDescriptor: extract_reasoning=True, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process input event from Table data (dictionary format). @@ -135,7 +136,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat response event and send output event.""" diff --git a/python/flink_agents/examples/rag/rag_agent_example.py b/python/flink_agents/examples/rag/rag_agent_example.py index de1d9ebe6..6972cdf43 100644 --- a/python/flink_agents/examples/rag/rag_agent_example.py +++ b/python/flink_agents/examples/rag/rag_agent_example.py @@ -32,6 +32,7 @@ ContextRetrievalResponseEvent, ) from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ( @@ -103,7 +104,7 @@ def chat_model() -> ResourceDescriptor: model=OLLAMA_CHAT_MODEL, ) - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """Process user input and retrieve relevant context.""" @@ -116,7 +117,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ContextRetrievalResponseEvent.EVENT_TYPE) + @action(EventType.ContextRetrievalResponseEvent) @staticmethod def process_retrieved_context( event: Event, ctx: RunnerContext @@ -147,7 +148,7 @@ def process_retrieved_context( ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """Process chat model response and generate output.""" diff --git a/python/flink_agents/plan/actions/action.py b/python/flink_agents/plan/actions/action.py index 96f2f5f0a..97f1f1b88 100644 --- a/python/flink_agents/plan/actions/action.py +++ b/python/flink_agents/plan/actions/action.py @@ -29,10 +29,10 @@ class Action(BaseModel): - """Representation of an agent action with event listening and function execution. + """Representation of an agent action with unified trigger conditions. - This class encapsulates a named agent action that listens for specific event - types and executes an associated function when those events occur. + This class encapsulates a named agent action that triggers on matching + events and executes an associated function. Attributes: ---------- @@ -40,8 +40,9 @@ class Action(BaseModel): Name/identifier of the agent Action. exec : Function To be executed when the Action is triggered. - listen_event_types : List[str] - List of event types that will trigger this Action's execution. + trigger_conditions : List[str] + Event-type name strings that will trigger this Action. Multiple + entries combine with OR semantics. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -49,9 +50,18 @@ class Action(BaseModel): name: str # 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] config: Dict[str, Any] | None = None + @property + def listen_event_types(self) -> List[str]: + """Event-type names. Kept for callers that still consume the old naming; + in this PR all entries are plain event-type names so the list is + identical to ``trigger_conditions``. A follow-up PR introduces CEL + expressions and overrides this to filter out non-type entries. + """ + return self.trigger_conditions + @field_serializer("config") def __serialize_config(self, config: Dict[str, Any]) -> Dict[str, Any] | None: if config is None: @@ -97,12 +107,12 @@ def __init__( self, name: str, exec: Function, - listen_event_types: List[str], + trigger_conditions: List[str], config: Dict[str, Any] | None = None, ) -> None: """Action will check function signature when init.""" super().__init__( - name=name, exec=exec, listen_event_types=listen_event_types, config=config + name=name, exec=exec, trigger_conditions=trigger_conditions, config=config ) # TODO: Update expected signature after import State and Context. self.exec.check_signature(Event, RunnerContext) diff --git a/python/flink_agents/plan/actions/chat_model_action.py b/python/flink_agents/plan/actions/chat_model_action.py index 188eb9e71..56ebf084e 100644 --- a/python/flink_agents/plan/actions/chat_model_action.py +++ b/python/flink_agents/plan/actions/chat_model_action.py @@ -454,7 +454,7 @@ async def process_chat_request_or_tool_response( CHAT_MODEL_ACTION = Action( name="chat_model_action", exec=PythonFunction.from_callable(process_chat_request_or_tool_response), - listen_event_types=[ + trigger_conditions=[ ChatRequestEvent.EVENT_TYPE, ToolResponseEvent.EVENT_TYPE, ], diff --git a/python/flink_agents/plan/actions/context_retrieval_action.py b/python/flink_agents/plan/actions/context_retrieval_action.py index d65f6d7f1..70d45ec3f 100644 --- a/python/flink_agents/plan/actions/context_retrieval_action.py +++ b/python/flink_agents/plan/actions/context_retrieval_action.py @@ -64,5 +64,5 @@ async def process_context_retrieval_request(event: Event, ctx: RunnerContext) -> CONTEXT_RETRIEVAL_ACTION = Action( name="context_retrieval_action", exec=PythonFunction.from_callable(process_context_retrieval_request), - listen_event_types=[ContextRetrievalRequestEvent.EVENT_TYPE], + trigger_conditions=[ContextRetrievalRequestEvent.EVENT_TYPE], ) diff --git a/python/flink_agents/plan/actions/tool_call_action.py b/python/flink_agents/plan/actions/tool_call_action.py index 6c7aaf270..1c8a8ce7d 100644 --- a/python/flink_agents/plan/actions/tool_call_action.py +++ b/python/flink_agents/plan/actions/tool_call_action.py @@ -63,5 +63,5 @@ async def process_tool_request(event: Event, ctx: RunnerContext) -> None: TOOL_CALL_ACTION = Action( name="tool_call_action", exec=PythonFunction.from_callable(process_tool_request), - listen_event_types=[ToolRequestEvent.EVENT_TYPE], + trigger_conditions=[ToolRequestEvent.EVENT_TYPE], ) diff --git a/python/flink_agents/plan/agent_plan.py b/python/flink_agents/plan/agent_plan.py index 24aca0caf..749a83f3f 100644 --- a/python/flink_agents/plan/agent_plan.py +++ b/python/flink_agents/plan/agent_plan.py @@ -145,7 +145,7 @@ def from_agent(agent: Agent, config: AgentConfiguration) -> "AgentPlan": for action in _get_actions(agent) + BUILT_IN_ACTIONS: assert action.name not in actions, f"Duplicate action name: {action.name}" actions[action.name] = action - for event_type in action.listen_event_types: + for event_type in action.trigger_conditions: if event_type not in actions_by_event: actions_by_event[event_type] = [] actions_by_event[event_type].append(action.name) @@ -227,9 +227,9 @@ def _resolve_event_type(evt: Any) -> str: def _action_marker(value: Any) -> tuple | None: - """Return ``(inner_callable, listen_events, target)`` if ``value`` is an @action. + """Return ``(inner_callable, trigger_conditions, target)`` if ``value`` is @action. - ``@action`` may set ``_listen_events`` on the outer wrapper (when ``@action`` + ``@action`` may set ``_trigger_conditions`` on the outer wrapper (when ``@action`` is the outer decorator) or on ``__func__`` (when ``@staticmethod`` is outer and ``@action`` inner). Accept either by checking both candidates. """ @@ -238,14 +238,14 @@ def _action_marker(value: Any) -> tuple | None: return None marker = ( value - if hasattr(value, "_listen_events") + if hasattr(value, "_trigger_conditions") else inner - if hasattr(inner, "_listen_events") + if hasattr(inner, "_trigger_conditions") else None ) if marker is None: return None - return inner, marker._listen_events, getattr(marker, "_target", None) + return inner, marker._trigger_conditions, getattr(marker, "_target", None) def _get_actions(agent: Agent) -> List[Action]: @@ -279,7 +279,7 @@ def _get_actions(agent: Agent) -> List[Action]: marker = _action_marker(value) if marker is None: continue - inner, listen_events, target = marker + inner, trigger_conditions, target = marker exec_ = ( _to_plan_function(target) if target is not None @@ -289,7 +289,9 @@ def _get_actions(agent: Agent) -> List[Action]: Action( name=name, exec=exec_, - listen_event_types=[_resolve_event_type(et) for et in listen_events], + trigger_conditions=[ + _resolve_event_type(et) for et in trigger_conditions + ], ) ) for name, action_tuple in agent.actions.items(): @@ -297,7 +299,7 @@ def _get_actions(agent: Agent) -> List[Action]: Action( name=name, exec=_to_plan_function(action_tuple[1]), - listen_event_types=[ + trigger_conditions=[ _resolve_event_type(et) for et in action_tuple[0] ], diff --git a/python/flink_agents/plan/tests/compatibility/create_python_agent_plan_from_json.py b/python/flink_agents/plan/tests/compatibility/create_python_agent_plan_from_json.py index c4397a84c..21096bc3d 100644 --- a/python/flink_agents/plan/tests/compatibility/create_python_agent_plan_from_json.py +++ b/python/flink_agents/plan/tests/compatibility/create_python_agent_plan_from_json.py @@ -51,7 +51,7 @@ event, runner_context, ] - listen_event_types1 = action1.listen_event_types + listen_event_types1 = action1.trigger_conditions assert listen_event_types1 == [input_event] # check the second action @@ -65,7 +65,7 @@ event, runner_context, ] - listen_event_types2 = action2.listen_event_types + listen_event_types2 = action2.trigger_conditions assert sorted(listen_event_types2) == [ my_event, input_event, diff --git a/python/flink_agents/plan/tests/compatibility/python_agent_plan_compatibility_test_agent.py b/python/flink_agents/plan/tests/compatibility/python_agent_plan_compatibility_test_agent.py index 5080cefbe..dca9197ca 100644 --- a/python/flink_agents/plan/tests/compatibility/python_agent_plan_compatibility_test_agent.py +++ b/python/flink_agents/plan/tests/compatibility/python_agent_plan_compatibility_test_agent.py @@ -21,7 +21,8 @@ from flink_agents.api.chat_message import ChatMessage from flink_agents.api.chat_models.chat_model import BaseChatModelSetup from flink_agents.api.decorators import action, chat_model_setup, tool -from flink_agents.api.events.event import Event, InputEvent +from flink_agents.api.events.event import Event +from flink_agents.api.events.event_type import EventType from flink_agents.api.resource import ResourceDescriptor from flink_agents.api.runner_context import RunnerContext @@ -51,7 +52,7 @@ def chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatMessage: class PythonAgentPlanCompatibilityTestAgent(Agent): """Agent for generating python agent plan json.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def first_action(event: Event, ctx: RunnerContext) -> None: """Test implementation.""" diff --git a/python/flink_agents/plan/tests/resources/action.json b/python/flink_agents/plan/tests/resources/action.json index 7ec09d6c0..09393e475 100644 --- a/python/flink_agents/plan/tests/resources/action.json +++ b/python/flink_agents/plan/tests/resources/action.json @@ -5,7 +5,7 @@ "module": "flink_agents.plan.tests.test_action", "qualname": "legal_signature" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event" ], "config": { diff --git a/python/flink_agents/plan/tests/resources/agent_plan.json b/python/flink_agents/plan/tests/resources/agent_plan.json index 9f9a3f416..6e545ab1d 100644 --- a/python/flink_agents/plan/tests/resources/agent_plan.json +++ b/python/flink_agents/plan/tests/resources/agent_plan.json @@ -7,7 +7,7 @@ "module": "flink_agents.plan.tests.test_agent_plan", "qualname": "MyAgent.first_action" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event" ], "config": null @@ -19,7 +19,7 @@ "module": "flink_agents.plan.tests.test_agent_plan", "qualname": "MyAgent.second_action" }, - "listen_event_types": [ + "trigger_conditions": [ "_input_event", "_my_event" ], @@ -32,7 +32,7 @@ "module": "flink_agents.plan.actions.chat_model_action", "qualname": "process_chat_request_or_tool_response" }, - "listen_event_types": [ + "trigger_conditions": [ "_chat_request_event", "_tool_response_event" ], @@ -45,7 +45,7 @@ "module": "flink_agents.plan.actions.tool_call_action", "qualname": "process_tool_request" }, - "listen_event_types": [ + "trigger_conditions": [ "_tool_request_event" ], "config": null @@ -57,7 +57,7 @@ "module": "flink_agents.plan.actions.context_retrieval_action", "qualname": "process_context_retrieval_request" }, - "listen_event_types": [ + "trigger_conditions": [ "_context_retrieval_request_event" ], "config": null diff --git a/python/flink_agents/plan/tests/test_action.py b/python/flink_agents/plan/tests/test_action.py index 5a75f7186..f93ee4531 100644 --- a/python/flink_agents/plan/tests/test_action.py +++ b/python/flink_agents/plan/tests/test_action.py @@ -40,7 +40,7 @@ def test_action_signature_legal() -> None: Action( name="legal", exec=PythonFunction.from_callable(legal_signature), - listen_event_types=[InputEvent.EVENT_TYPE], + trigger_conditions=[InputEvent.EVENT_TYPE], ) @@ -49,7 +49,7 @@ def test_action_signature_illegal() -> None: Action( name="illegal", exec=PythonFunction.from_callable(illegal_signature), - listen_event_types=[InputEvent.EVENT_TYPE], + trigger_conditions=[InputEvent.EVENT_TYPE], ) @@ -59,7 +59,7 @@ def action() -> Action: return Action( name="legal", exec=func, - listen_event_types=[InputEvent.EVENT_TYPE], + trigger_conditions=[InputEvent.EVENT_TYPE], config={ "output_schema": OutputSchema( output_schema=RowTypeInfo( @@ -88,7 +88,7 @@ def test_action_deserialize(action: Action) -> None: expected_json = f.read() action = Action.model_validate_json(expected_json) assert action.name == "legal" - assert action.listen_event_types == ["_input_event"] + assert action.trigger_conditions== ["_input_event"] func = action.exec assert func.module == "flink_agents.plan.tests.test_action" assert func.qualname == "legal_signature" @@ -103,7 +103,7 @@ def test_action_deserialize_java_shape_config_unwraps_primitives() -> None: "module": "flink_agents.plan.tests.test_action", "qualname": "legal_signature", }, - "listen_event_types": ["_input_event"], + "trigger_conditions": ["_input_event"], "config": { "__config_type__": "java", "timeout_sec": {"@class": "java.lang.Integer", "value": 30}, diff --git a/python/flink_agents/plan/tests/test_agent_plan_cross_language.py b/python/flink_agents/plan/tests/test_agent_plan_cross_language.py index fbf467116..7b144e57b 100644 --- a/python/flink_agents/plan/tests/test_agent_plan_cross_language.py +++ b/python/flink_agents/plan/tests/test_agent_plan_cross_language.py @@ -87,7 +87,7 @@ def test_compile_agent_with_python_function_descriptor() -> None: ) assert action.exec.module == pf.module assert action.exec.qualname == pf.qualname - assert action.listen_event_types == [InputEvent.EVENT_TYPE] + assert action.trigger_conditions== [InputEvent.EVENT_TYPE] def test_compile_agent_with_java_function_descriptor() -> None: @@ -110,7 +110,7 @@ def test_compile_agent_with_java_function_descriptor() -> None: assert action.exec.qualname == jf.qualname assert action.exec.method_name == jf.method_name assert list(action.exec.parameter_types) == list(jf.parameter_types) - assert action.listen_event_types == [InputEvent.EVENT_TYPE] + assert action.trigger_conditions== [InputEvent.EVENT_TYPE] def test_python_plan_compile_does_not_validate_java_class_exists() -> None: @@ -318,7 +318,7 @@ def test_python_plan_with_java_action_matches_runtime_operator_wire_shape() -> N handle_block = emitted["actions"]["handle"] assert handle_block["name"] == "handle" - assert handle_block["listen_event_types"] == [InputEvent.EVENT_TYPE] + assert handle_block["trigger_conditions"] == [InputEvent.EVENT_TYPE] assert handle_block["config"] is None assert handle_block["exec"] == { "func_type": "JavaFunction", @@ -340,7 +340,7 @@ def test_python_preserves_conf_data_types_and_event_ordering() -> None: "module": _dummy_action.__module__, "qualname": _dummy_action.__qualname__, }, - "listen_event_types": [InputEvent.EVENT_TYPE], + "trigger_conditions": [InputEvent.EVENT_TYPE], "config": None, }, "second": { @@ -350,7 +350,7 @@ def test_python_preserves_conf_data_types_and_event_ordering() -> None: "module": _dummy_action.__module__, "qualname": _dummy_action.__qualname__, }, - "listen_event_types": [InputEvent.EVENT_TYPE], + "trigger_conditions": [InputEvent.EVENT_TYPE], "config": None, }, }, diff --git a/python/flink_agents/runtime/tests/test_built_in_actions.py b/python/flink_agents/runtime/tests/test_built_in_actions.py index b7eb5a294..057291c15 100644 --- a/python/flink_agents/runtime/tests/test_built_in_actions.py +++ b/python/flink_agents/runtime/tests/test_built_in_actions.py @@ -33,6 +33,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.prompts.prompt import Prompt from flink_agents.api.resource import ResourceDescriptor, ResourceType @@ -171,7 +172,7 @@ def add(a: int, b: int) -> int: """ return a + b - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process_input(event: Event, ctx: RunnerContext) -> None: """User defined action for processing input. @@ -187,7 +188,7 @@ def process_input(event: Event, ctx: RunnerContext) -> None: ) ) - @action(ChatResponseEvent.EVENT_TYPE) + @action(EventType.ChatResponseEvent) @staticmethod def process_chat_response(event: Event, ctx: RunnerContext) -> None: """User defined action for processing chat model response.""" diff --git a/python/flink_agents/runtime/tests/test_get_resource_in_action.py b/python/flink_agents/runtime/tests/test_get_resource_in_action.py index f1f1e8e15..8c5988bb1 100644 --- a/python/flink_agents/runtime/tests/test_get_resource_in_action.py +++ b/python/flink_agents/runtime/tests/test_get_resource_in_action.py @@ -22,6 +22,7 @@ from flink_agents.api.chat_models.chat_model import BaseChatModelSetup from flink_agents.api.decorators import action, chat_model_setup, tool from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.resource import ResourceDescriptor, ResourceType from flink_agents.api.runner_context import RunnerContext @@ -74,7 +75,7 @@ def mock_tool(input: str) -> str: """ return input + " mock tools just for testing." - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def mock_action(event: Event, ctx: RunnerContext) -> None: input = InputEvent.from_event(event).input diff --git a/python/flink_agents/runtime/tests/test_local_execution_environment.py b/python/flink_agents/runtime/tests/test_local_execution_environment.py index 1c861b359..294549d22 100644 --- a/python/flink_agents/runtime/tests/test_local_execution_environment.py +++ b/python/flink_agents/runtime/tests/test_local_execution_environment.py @@ -35,6 +35,7 @@ ) from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.resource import ResourceDescriptor, ResourceType from flink_agents.api.runner_context import RunnerContext @@ -42,7 +43,7 @@ class Agent1(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def increment(event: Event, ctx: RunnerContext): # noqa D102 input = InputEvent.from_event(event).input @@ -51,7 +52,7 @@ def increment(event: Event, ctx: RunnerContext): # noqa D102 class Agent1WithAsync(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def increment(event: Event, ctx: RunnerContext): # noqa D102 def my_func(value: int) -> int: @@ -64,7 +65,7 @@ def my_func(value: int) -> int: class Agent2(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def decrease(event: Event, ctx: RunnerContext): # noqa D102 input = InputEvent.from_event(event).input @@ -145,7 +146,7 @@ def test_local_execution_environment_call_from_list_twice() -> None: class UnifiedEventAgent(Agent): - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def on_input(event: Event, ctx: RunnerContext) -> None: ctx.send_event( @@ -199,7 +200,7 @@ def data(self) -> str: class MixedEventAgent(Agent): """Agent mixing subclassed and string-based event routing.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def start(event: Event, ctx: RunnerContext) -> None: ctx.send_event(Step1Event(data=str(InputEvent.from_event(event).input))) diff --git a/python/flink_agents/runtime/tests/test_runner_context_execute.py b/python/flink_agents/runtime/tests/test_runner_context_execute.py index fd2a54733..abde15a63 100644 --- a/python/flink_agents/runtime/tests/test_runner_context_execute.py +++ b/python/flink_agents/runtime/tests/test_runner_context_execute.py @@ -20,6 +20,7 @@ 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.events.event_type import EventType from flink_agents.api.execution_environment import AgentsExecutionEnvironment from flink_agents.api.runner_context import RunnerContext @@ -42,7 +43,7 @@ def raise_exception(msg: str) -> None: class AgentWithDurableExecute(Agent): """Agent that uses synchronous durable_execute() method.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event using durable_execute().""" @@ -54,7 +55,7 @@ def process(event: Event, ctx: RunnerContext) -> None: class AgentWithMultipleDurableExecute(Agent): """Agent that makes multiple durable_execute() calls.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event with multiple durable_execute() calls.""" @@ -67,7 +68,7 @@ def process(event: Event, ctx: RunnerContext) -> None: class AgentWithDurableExecuteAndAsync(Agent): """Agent that uses both durable_execute() and durable_execute_async().""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod async def process(event: Event, ctx: RunnerContext) -> None: """Process an event using both durable_execute() and durable_execute_async().""" @@ -82,7 +83,7 @@ async def process(event: Event, ctx: RunnerContext) -> None: class AgentWithDurableExecuteException(Agent): """Agent that uses durable_execute() with a function that raises an exception.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event where durable_execute() raises an exception.""" @@ -96,7 +97,7 @@ def process(event: Event, ctx: RunnerContext) -> None: class AgentWithKwargs(Agent): """Agent that uses durable_execute() with keyword arguments.""" - @action(InputEvent.EVENT_TYPE) + @action(EventType.InputEvent) @staticmethod def process(event: Event, ctx: RunnerContext) -> None: """Process an event using durable_execute() with kwargs.""" From edceda5b3b90956cd13e2e4f6e1e48f98a7b53e3 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:01:17 +0800 Subject: [PATCH 04/13] [api][python] Slim EventType to constants + add Action legacy JSON fallback --- .../apache/flink/agents/api/EventType.java | 155 +--------- .../flink/agents/api/EventTypeTest.java | 280 +----------------- python/flink_agents/api/events/event_type.py | 161 +++------- .../flink_agents/api/tests/test_event_type.py | 64 ++-- python/flink_agents/plan/actions/action.py | 8 +- 5 files changed, 84 insertions(+), 584 deletions(-) diff --git a/api/src/main/java/org/apache/flink/agents/api/EventType.java b/api/src/main/java/org/apache/flink/agents/api/EventType.java index e0e825ad0..f486a6d0e 100644 --- a/api/src/main/java/org/apache/flink/agents/api/EventType.java +++ b/api/src/main/java/org/apache/flink/agents/api/EventType.java @@ -18,150 +18,27 @@ package org.apache.flink.agents.api; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - /** - * Compile-time constants for built-in event types and a runtime registry for user-defined events. - * - *

Usage in {@code @Action}: + * Compile-time constants for built-in event types, sourced from each {@code XxxEvent.EVENT_TYPE}. * - *

    - *
  • Built-in events: {@code @Action(EventType.InputEvent)} - *
  • User-defined events: {@code @Action("MyCustomEvent")} - *
- * - *

Resolution via {@link #lookupOrSelf}: built-in → user-registered → passthrough. + *

Usage: {@code @Action(EventType.InputEvent)}. */ public final class EventType { - public static final String InputEvent = "_input_event"; - public static final String OutputEvent = "_output_event"; - public static final String ChatRequestEvent = "_chat_request_event"; - public static final String ChatResponseEvent = "_chat_response_event"; - public static final String ToolRequestEvent = "_tool_request_event"; - public static final String ToolResponseEvent = "_tool_response_event"; - public static final String ContextRetrievalRequestEvent = "_context_retrieval_request_event"; - public static final String ContextRetrievalResponseEvent = "_context_retrieval_response_event"; - - private static final Map BUILTIN; - - static { - Map m = new HashMap<>(); - m.put("InputEvent", InputEvent); - m.put("OutputEvent", OutputEvent); - m.put("ChatRequestEvent", ChatRequestEvent); - m.put("ChatResponseEvent", ChatResponseEvent); - m.put("ToolRequestEvent", ToolRequestEvent); - m.put("ToolResponseEvent", ToolResponseEvent); - m.put("ContextRetrievalRequestEvent", ContextRetrievalRequestEvent); - m.put("ContextRetrievalResponseEvent", ContextRetrievalResponseEvent); - BUILTIN = Collections.unmodifiableMap(m); - } - - private static final ConcurrentMap USER_REGISTERED = new ConcurrentHashMap<>(); + public static final String InputEvent = org.apache.flink.agents.api.InputEvent.EVENT_TYPE; + public static final String OutputEvent = org.apache.flink.agents.api.OutputEvent.EVENT_TYPE; + public static final String ChatRequestEvent = + org.apache.flink.agents.api.event.ChatRequestEvent.EVENT_TYPE; + public static final String ChatResponseEvent = + org.apache.flink.agents.api.event.ChatResponseEvent.EVENT_TYPE; + public static final String ToolRequestEvent = + org.apache.flink.agents.api.event.ToolRequestEvent.EVENT_TYPE; + public static final String ToolResponseEvent = + org.apache.flink.agents.api.event.ToolResponseEvent.EVENT_TYPE; + public static final String ContextRetrievalRequestEvent = + org.apache.flink.agents.api.event.ContextRetrievalRequestEvent.EVENT_TYPE; + public static final String ContextRetrievalResponseEvent = + org.apache.flink.agents.api.event.ContextRetrievalResponseEvent.EVENT_TYPE; private EventType() {} - - /** - * Registers a user-defined event class so that its simple name resolves to its {@code - * EVENT_TYPE} value. The class must declare {@code public static final String EVENT_TYPE}. - * Idempotent for the same {@code (name, EVENT_TYPE)} pair. - * - * @param eventClass the event class to register - */ - public static void register(Class eventClass) { - if (eventClass == null) { - throw new IllegalArgumentException("eventClass must not be null"); - } - String name = eventClass.getSimpleName(); - if (BUILTIN.containsKey(name)) { - throw new IllegalArgumentException( - "Short name '" + name + "' collides with a built-in EventType"); - } - String eventType = readEventTypeField(eventClass); - String previous = USER_REGISTERED.putIfAbsent(name, eventType); - if (previous != null && !previous.equals(eventType)) { - throw new IllegalStateException( - "Short name '" - + name - + "' already registered with EVENT_TYPE='" - + previous - + "', cannot re-register with EVENT_TYPE='" - + eventType - + "'"); - } - } - - /** - * Returns the {@code EVENT_TYPE} for a registered short name, or {@code null} if unknown. - * Built-in names take precedence over user-registered ones. - */ - public static String lookup(String name) { - if (name == null) { - return null; - } - String v = BUILTIN.get(name); - if (v != null) { - return v; - } - return USER_REGISTERED.get(name); - } - - /** Like {@link #lookup}, but returns {@code name} unchanged if not registered. */ - public static String lookupOrSelf(String name) { - String v = lookup(name); - return v != null ? v : name; - } - - /** Returns {@code true} if {@code name} is a registered short name. */ - public static boolean isKnown(String name) { - return lookup(name) != null; - } - - /** Returns an unmodifiable snapshot of all registrations (built-in + user-registered). */ - public static Map all() { - Map snapshot = new HashMap<>(BUILTIN); - snapshot.putAll(USER_REGISTERED); - return Collections.unmodifiableMap(snapshot); - } - - private static String readEventTypeField(Class eventClass) { - Field field; - try { - field = eventClass.getDeclaredField("EVENT_TYPE"); - } catch (NoSuchFieldException e) { - throw new IllegalArgumentException( - eventClass.getName() + " must declare 'static final String EVENT_TYPE'", e); - } - int mods = field.getModifiers(); - if (!Modifier.isStatic(mods) - || !Modifier.isFinal(mods) - || field.getType() != String.class) { - throw new IllegalArgumentException( - eventClass.getName() + ".EVENT_TYPE must be static final String"); - } - try { - field.setAccessible(true); - Object value = field.get(null); - if (!(value instanceof String) || ((String) value).isEmpty()) { - throw new IllegalArgumentException( - eventClass.getName() + ".EVENT_TYPE must be a non-empty String"); - } - return (String) value; - } catch (IllegalAccessException e) { - throw new IllegalArgumentException( - "Cannot read " + eventClass.getName() + ".EVENT_TYPE", e); - } - } - - /** Test-only: reset user registrations between unit tests. */ - static void clearUserRegisteredForTesting() { - USER_REGISTERED.clear(); - } } diff --git a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java index dd3a02a5b..aad93c6b1 100644 --- a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java +++ b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java @@ -18,284 +18,22 @@ package org.apache.flink.agents.api; -import org.apache.flink.agents.api.event.ChatRequestEvent; -import org.apache.flink.agents.api.event.ChatResponseEvent; -import org.apache.flink.agents.api.event.ContextRetrievalRequestEvent; -import org.apache.flink.agents.api.event.ContextRetrievalResponseEvent; -import org.apache.flink.agents.api.event.ToolRequestEvent; -import org.apache.flink.agents.api.event.ToolResponseEvent; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; /** Tests for {@link EventType}. */ class EventTypeTest { - @BeforeEach - @AfterEach - void resetUserRegistry() { - EventType.clearUserRegisteredForTesting(); - } - - // ----------------------------------------------------------------------- - // Built-in constants must agree byte-for-byte with XxxEvent.EVENT_TYPE. - // This is the consistency invariant called out in the EventType Javadoc. - // ----------------------------------------------------------------------- - - @Test - void builtInConstantsMatchEventClassConstants() { - assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent); - assertEquals(OutputEvent.EVENT_TYPE, EventType.OutputEvent); - assertEquals(ChatRequestEvent.EVENT_TYPE, EventType.ChatRequestEvent); - assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.ChatResponseEvent); - assertEquals(ToolRequestEvent.EVENT_TYPE, EventType.ToolRequestEvent); - assertEquals(ToolResponseEvent.EVENT_TYPE, EventType.ToolResponseEvent); - assertEquals( - ContextRetrievalRequestEvent.EVENT_TYPE, EventType.ContextRetrievalRequestEvent); - assertEquals( - ContextRetrievalResponseEvent.EVENT_TYPE, EventType.ContextRetrievalResponseEvent); - } - - @Test - void lookupReturnsBuiltInsByShortName() { - assertEquals(InputEvent.EVENT_TYPE, EventType.lookup("InputEvent")); - assertEquals(OutputEvent.EVENT_TYPE, EventType.lookup("OutputEvent")); - assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.lookup("ChatResponseEvent")); - } - - @Test - void lookupReturnsNullForUnknown() { - assertNull(EventType.lookup("DoesNotExist")); - assertNull(EventType.lookup("")); - assertNull(EventType.lookup(null)); - } - - @Test - void lookupOrSelfReturnsInputForUnknown() { - // Raw EVENT_TYPE strings pass through unchanged. - assertEquals("_input_event", EventType.lookupOrSelf("_input_event")); - // Custom strings pass through unchanged. - assertEquals("MyCustomEvent", EventType.lookupOrSelf("MyCustomEvent")); - // CEL expressions pass through unchanged. - String cel = "type == '_input_event' && price >= 125"; - assertEquals(cel, EventType.lookupOrSelf(cel)); - } - - @Test - void isKnownTracksBuiltins() { - assertTrue(EventType.isKnown("InputEvent")); - assertTrue(EventType.isKnown("ChatResponseEvent")); - assertFalse(EventType.isKnown("_input_event")); - assertFalse(EventType.isKnown("DoesNotExist")); - assertFalse(EventType.isKnown("")); - assertFalse(EventType.isKnown(null)); - } - - @Test - void allReturnsSnapshotIncludingBuiltins() { - Map all = EventType.all(); - assertEquals(8, all.size()); - assertEquals("_input_event", all.get("InputEvent")); - assertEquals("_chat_response_event", all.get("ChatResponseEvent")); - // Snapshot is unmodifiable. - assertThrows(UnsupportedOperationException.class, () -> all.put("Foo", "bar")); - } - - // ----------------------------------------------------------------------- - // User registration. - // ----------------------------------------------------------------------- - - public static class MyCustomEvent extends Event { - public static final String EVENT_TYPE = "_my_custom_event"; - - public MyCustomEvent() { - super(EVENT_TYPE); - } - } - - public static class AnotherEvent extends Event { - public static final String EVENT_TYPE = "_another_event"; - - public AnotherEvent() { - super(EVENT_TYPE); - } - } - - @Test - void registerExposesUserDefinedEvent() { - EventType.register(MyCustomEvent.class); - assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); - assertTrue(EventType.isKnown("MyCustomEvent")); - assertEquals(9, EventType.all().size()); - } - - @Test - void registerIsIdempotentForSamePair() { - EventType.register(MyCustomEvent.class); - EventType.register(MyCustomEvent.class); // must not throw - assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); - } - - /** Re-registering the same simple name with a different EVENT_TYPE must fail loudly. */ - public static class CollidingEvent extends Event { - public static final String EVENT_TYPE = "_different_event"; - - public CollidingEvent() { - super(EVENT_TYPE); - } - } - - public static class ShadowingMyCustomEvent extends Event { - public static final String EVENT_TYPE = "_shadow_event"; - - public ShadowingMyCustomEvent() { - super(EVENT_TYPE); - } - } - - @Test - void registerDifferentClassesWithDifferentSimpleNamesCoexist() { - EventType.register(MyCustomEvent.class); - EventType.register(ShadowingMyCustomEvent.class); - assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); - assertEquals("_shadow_event", EventType.lookup("ShadowingMyCustomEvent")); - } - - /** - * Direct test of the conflict path: register one class, then register a second class whose - * {@code getSimpleName()} happens to collide, with a different EVENT_TYPE. We construct the - * collision by using two top-level-style nested classes whose simple names match. - */ - @Test - void registerRejectsSameNameDifferentEventType() { - EventType.register(MyCustomEvent.class); - // Manually trigger the conflict by re-using the registry with a synthetic mapping. - // Easiest path: declare a second class with the same simple name in a different - // enclosing scope. - IllegalStateException ex = - assertThrows( - IllegalStateException.class, - () -> EventType.register(Nested.MyCustomEvent.class)); - assertTrue(ex.getMessage().contains("already registered")); - } - - static class Nested { - public static class MyCustomEvent extends Event { - public static final String EVENT_TYPE = "_nested_my_custom_event"; - - public MyCustomEvent() { - super(EVENT_TYPE); - } - } - } - - @Test - void registerRejectsCollisionWithBuiltIn() { - // A user-defined class whose simple name equals a built-in must be rejected. - IllegalArgumentException ex = - assertThrows( - IllegalArgumentException.class, - () -> EventType.register(MyInputEvent.InputEvent.class)); - assertTrue(ex.getMessage().contains("collides with a built-in")); - } - - // Wrap in an enclosing class so we can name the inner class "InputEvent" without colliding - // with the api package's InputEvent at the source level. - static class MyInputEvent { - public static class InputEvent extends Event { - public static final String EVENT_TYPE = "_user_input_event"; - - public InputEvent() { - super(EVENT_TYPE); - } - } - } - - public static class NoEventTypeField extends Event { - public NoEventTypeField() { - super("_x"); - } - } - - @Test - void registerRejectsClassWithoutEventTypeField() { - IllegalArgumentException ex = - assertThrows( - IllegalArgumentException.class, - () -> EventType.register(NoEventTypeField.class)); - assertTrue(ex.getMessage().contains("EVENT_TYPE")); - } - - @Test - void registerRejectsNull() { - assertThrows(IllegalArgumentException.class, () -> EventType.register(null)); - } - - // ----------------------------------------------------------------------- - // Concurrency: two threads registering the same class should both succeed - // (idempotent), and the registry should hold exactly one entry. - // ----------------------------------------------------------------------- - - @Test - void concurrentRegisterIsSafe() throws InterruptedException { - int threads = 16; - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(threads); - AtomicInteger errors = new AtomicInteger(); - ExecutorService pool = Executors.newFixedThreadPool(threads); - try { - for (int i = 0; i < threads; i++) { - pool.submit( - () -> { - try { - start.await(); - EventType.register(MyCustomEvent.class); - } catch (Exception e) { - errors.incrementAndGet(); - } finally { - done.countDown(); - } - }); - } - start.countDown(); - assertTrue(done.await(10, TimeUnit.SECONDS)); - assertEquals(0, errors.get()); - assertEquals("_my_custom_event", EventType.lookup("MyCustomEvent")); - // Built-in count (8) + our one registration = 9 - assertEquals(9, EventType.all().size()); - } finally { - pool.shutdownNow(); - } - } - - // ----------------------------------------------------------------------- - // Snapshot independence. - // ----------------------------------------------------------------------- - @Test - void allReturnsSnapshotNotLiveView() { - Map before = EventType.all(); - EventType.register(MyCustomEvent.class); - Map after = EventType.all(); - assertNotNull(before); - // Snapshot before registration must not contain the new entry. - assertFalse(before.containsKey("MyCustomEvent")); - assertTrue(after.containsKey("MyCustomEvent")); - assertEquals(new HashMap<>(before).size() + 1, after.size()); + void builtInConstantsAreNonNull() { + assertNotNull(EventType.InputEvent); + assertNotNull(EventType.OutputEvent); + assertNotNull(EventType.ChatRequestEvent); + assertNotNull(EventType.ChatResponseEvent); + assertNotNull(EventType.ToolRequestEvent); + assertNotNull(EventType.ToolResponseEvent); + assertNotNull(EventType.ContextRetrievalRequestEvent); + assertNotNull(EventType.ContextRetrievalResponseEvent); } } diff --git a/python/flink_agents/api/events/event_type.py b/python/flink_agents/api/events/event_type.py index 7cea2add4..d3ef005e8 100644 --- a/python/flink_agents/api/events/event_type.py +++ b/python/flink_agents/api/events/event_type.py @@ -15,143 +15,50 @@ # See the License for the specific language governing permissions and # limitations under the License. ################################################################################# -"""IDE-discoverable constants for built-in event types, plus a tiny registry -for user-defined event classes. - -Built-in events are exposed as :class:`EventType` attributes; user events -declare ``EVENT_TYPE: ClassVar[str]`` and are registered via -:func:`EventType.register`:: - - @action(EventType.InputEvent, EventType.OutputEvent) - def handle(...): ... - - EventType.register(MyCustomEvent) - EventType.lookup("MyCustomEvent") # -> "_my_custom_event" -""" +"""Built-in event-type constants, sourced from each ``XxxEvent.EVENT_TYPE``.""" from __future__ import annotations -import threading -from typing import Dict, Type - -# Hard-coded to avoid an event_type -> event -> event_type circular import. -# A consistency test asserts each value matches XxxEvent.EVENT_TYPE. -_BUILTIN: Dict[str, str] = { - "InputEvent": "_input_event", - "OutputEvent": "_output_event", - "ChatRequestEvent": "_chat_request_event", - "ChatResponseEvent": "_chat_response_event", - "ToolRequestEvent": "_tool_request_event", - "ToolResponseEvent": "_tool_response_event", - "ContextRetrievalRequestEvent": "_context_retrieval_request_event", - "ContextRetrievalResponseEvent": "_context_retrieval_response_event", -} - -_USER_REGISTERED: Dict[str, str] = {} -_LOCK = threading.Lock() - - -def register(event_class: Type) -> None: - """Register a user-defined event class. - - The class must declare ``EVENT_TYPE: ClassVar[str]`` as a non-empty string. - Re-registering the same ``(class_name, EVENT_TYPE)`` pair is a no-op. - - Raises: - ValueError: if ``event_class`` is None, lacks a non-empty ``EVENT_TYPE``, - or its ``__name__`` collides with a built-in. - RuntimeError: if the same name is already bound to a different - ``EVENT_TYPE`` value. - """ - if event_class is None: - msg = "event_class must not be None" - raise ValueError(msg) - name = event_class.__name__ - if name in _BUILTIN: - msg = f"Short name {name!r} collides with a built-in EventType" - raise ValueError(msg) - event_type = getattr(event_class, "EVENT_TYPE", None) - if not isinstance(event_type, str) or not event_type: - msg = ( - f"{event_class.__module__}.{event_class.__name__} must declare " - f"EVENT_TYPE as a non-empty string" - ) - raise ValueError(msg) - with _LOCK: - existing = _USER_REGISTERED.get(name) - if existing is None: - _USER_REGISTERED[name] = event_type - return - if existing != event_type: - msg = ( - f"Short name {name!r} already registered with EVENT_TYPE={existing!r}; " - f"cannot re-register with EVENT_TYPE={event_type!r}" - ) - raise RuntimeError(msg) - - -def lookup(name: str | None) -> str | None: - """Return the ``EVENT_TYPE`` string for a registered short name, else ``None``. - - Built-in names take precedence over user-registered ones. - """ - if name is None: - return None - builtin = _BUILTIN.get(name) - if builtin is not None: - return builtin - return _USER_REGISTERED.get(name) - - -def lookup_or_self(name: str) -> str: - """Like :func:`lookup`, but returns ``name`` unchanged when not registered.""" - v = lookup(name) - return v if v is not None else name - - -def is_known(name: str | None) -> bool: - """Return ``True`` if ``name`` is a registered short name.""" - return lookup(name) is not None - - -def all_registered() -> Dict[str, str]: - """Return a snapshot of all registrations (built-in + user-registered).""" - snapshot = dict(_BUILTIN) - snapshot.update(_USER_REGISTERED) - return snapshot - - -def _clear_user_registered_for_testing() -> None: - """Test-only: drop user registrations between unit tests.""" - with _LOCK: - _USER_REGISTERED.clear() +from flink_agents.api.events.chat_event import ( + ChatRequestEvent as _ChatRequestEvent, +) +from flink_agents.api.events.chat_event import ( + ChatResponseEvent as _ChatResponseEvent, +) +from flink_agents.api.events.context_retrieval_event import ( + ContextRetrievalRequestEvent as _ContextRetrievalRequestEvent, +) +from flink_agents.api.events.context_retrieval_event import ( + ContextRetrievalResponseEvent as _ContextRetrievalResponseEvent, +) +from flink_agents.api.events.event import ( + InputEvent as _InputEvent, +) +from flink_agents.api.events.event import ( + OutputEvent as _OutputEvent, +) +from flink_agents.api.events.tool_event import ( + ToolRequestEvent as _ToolRequestEvent, +) +from flink_agents.api.events.tool_event import ( + ToolResponseEvent as _ToolResponseEvent, +) class EventType: """Namespace of built-in event-type constants. - Each constant is byte-equal to the corresponding ``XxxEvent.EVENT_TYPE`` - and is meant to be used inside ``trigger_conditions``:: - - @action(EventType.InputEvent) - - For user-defined events, call :func:`register` first, then :func:`lookup`. + Usage: ``@action(EventType.InputEvent)``. """ - InputEvent: str = _BUILTIN["InputEvent"] - OutputEvent: str = _BUILTIN["OutputEvent"] - ChatRequestEvent: str = _BUILTIN["ChatRequestEvent"] - ChatResponseEvent: str = _BUILTIN["ChatResponseEvent"] - ToolRequestEvent: str = _BUILTIN["ToolRequestEvent"] - ToolResponseEvent: str = _BUILTIN["ToolResponseEvent"] - ContextRetrievalRequestEvent: str = _BUILTIN["ContextRetrievalRequestEvent"] - ContextRetrievalResponseEvent: str = _BUILTIN["ContextRetrievalResponseEvent"] - - register = staticmethod(register) - lookup = staticmethod(lookup) - lookup_or_self = staticmethod(lookup_or_self) - is_known = staticmethod(is_known) - all_registered = staticmethod(all_registered) + InputEvent: str = _InputEvent.EVENT_TYPE + OutputEvent: str = _OutputEvent.EVENT_TYPE + ChatRequestEvent: str = _ChatRequestEvent.EVENT_TYPE + ChatResponseEvent: str = _ChatResponseEvent.EVENT_TYPE + ToolRequestEvent: str = _ToolRequestEvent.EVENT_TYPE + ToolResponseEvent: str = _ToolResponseEvent.EVENT_TYPE + ContextRetrievalRequestEvent: str = _ContextRetrievalRequestEvent.EVENT_TYPE + ContextRetrievalResponseEvent: str = _ContextRetrievalResponseEvent.EVENT_TYPE def __init__(self) -> None: """Reject instantiation; ``EventType`` is a namespace, not a class.""" diff --git a/python/flink_agents/api/tests/test_event_type.py b/python/flink_agents/api/tests/test_event_type.py index 76ef904b9..520edc5f7 100644 --- a/python/flink_agents/api/tests/test_event_type.py +++ b/python/flink_agents/api/tests/test_event_type.py @@ -15,52 +15,24 @@ # See the License for the specific language governing permissions and # limitations under the License. ################################################################################# -"""Python smoke tests for :mod:`flink_agents.api.events.event_type`. - -The authoritative test suite lives in Java -(``api/src/test/java/.../EventTypeTest.java``) per cross-language test policy -(issue #006, Q5). These two cases only verify the Python language-binding -layer; full semantic coverage belongs on the Java side. -""" +"""Smoke tests for :mod:`flink_agents.api.events.event_type`.""" from __future__ import annotations -from typing import ClassVar - -import pytest - -from flink_agents.api.events.event import Event, InputEvent -from flink_agents.api.events.event_type import ( - EventType, - _clear_user_registered_for_testing, - lookup_or_self, - register, -) - - -@pytest.fixture(autouse=True) -def _reset_user_registry() -> None: - _clear_user_registered_for_testing() - yield - _clear_user_registered_for_testing() - - -def test_builtin_lookup_and_constant_alignment() -> None: - """Smoke: built-in EventType constants resolve and match XxxEvent.EVENT_TYPE.""" - assert EventType.InputEvent == InputEvent.EVENT_TYPE - assert lookup_or_self("InputEvent") == InputEvent.EVENT_TYPE - # Unknown short name passes through unchanged. - assert lookup_or_self("NotRegistered") == "NotRegistered" - - -def test_register_user_event_then_lookup_resolves_short_name() -> None: - """Smoke: register a user event and lookup the short name.""" - - class MyOrderEvent(Event): - EVENT_TYPE: ClassVar[str] = "_my_order_event" - - def __init__(self) -> None: - super().__init__(type=MyOrderEvent.EVENT_TYPE) - - register(MyOrderEvent) - assert lookup_or_self("MyOrderEvent") == "_my_order_event" +from flink_agents.api.events.event_type import EventType + + +def test_builtin_constants_are_non_empty_strings() -> None: + for name in ( + "InputEvent", + "OutputEvent", + "ChatRequestEvent", + "ChatResponseEvent", + "ToolRequestEvent", + "ToolResponseEvent", + "ContextRetrievalRequestEvent", + "ContextRetrievalResponseEvent", + ): + value = getattr(EventType, name) + assert isinstance(value, str) + assert value diff --git a/python/flink_agents/plan/actions/action.py b/python/flink_agents/plan/actions/action.py index 97f1f1b88..15ec7dc8a 100644 --- a/python/flink_agents/plan/actions/action.py +++ b/python/flink_agents/plan/actions/action.py @@ -81,7 +81,13 @@ def __serialize_config(self, config: Dict[str, Any]) -> Dict[str, Any] | None: @model_validator(mode="before") def __custom_deserialize(self) -> "Action": - config = self["config"] + # 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) + + config = self.get("config") if config is None or _CONFIG_TYPE not in config: return self config_type = self["config"].pop(_CONFIG_TYPE) From 2d477bfae455e14fcf55d8e03dc4b1d25ad58f44 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:44:27 +0800 Subject: [PATCH 05/13] [test] Strengthen EventType and legacy-key tests per review feedback --- .../flink/agents/api/EventTypeTest.java | 28 ++++++++++------- .../serializer/ActionJsonSerializerTest.java | 24 +++++++++++++++ .../flink_agents/api/tests/test_event_type.py | 30 ++++++++++--------- python/flink_agents/plan/actions/action.py | 5 +++- python/flink_agents/plan/tests/test_action.py | 17 +++++++++++ 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java index aad93c6b1..4ac5aa728 100644 --- a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java +++ b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java @@ -18,22 +18,30 @@ package org.apache.flink.agents.api; +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; +import org.apache.flink.agents.api.event.ContextRetrievalRequestEvent; +import org.apache.flink.agents.api.event.ContextRetrievalResponseEvent; +import org.apache.flink.agents.api.event.ToolRequestEvent; +import org.apache.flink.agents.api.event.ToolResponseEvent; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; /** Tests for {@link EventType}. */ class EventTypeTest { @Test - void builtInConstantsAreNonNull() { - assertNotNull(EventType.InputEvent); - assertNotNull(EventType.OutputEvent); - assertNotNull(EventType.ChatRequestEvent); - assertNotNull(EventType.ChatResponseEvent); - assertNotNull(EventType.ToolRequestEvent); - assertNotNull(EventType.ToolResponseEvent); - assertNotNull(EventType.ContextRetrievalRequestEvent); - assertNotNull(EventType.ContextRetrievalResponseEvent); + void builtInConstantsMatchEventClassConstants() { + assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent); + assertEquals(OutputEvent.EVENT_TYPE, EventType.OutputEvent); + assertEquals(ChatRequestEvent.EVENT_TYPE, EventType.ChatRequestEvent); + assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.ChatResponseEvent); + assertEquals(ToolRequestEvent.EVENT_TYPE, EventType.ToolRequestEvent); + assertEquals(ToolResponseEvent.EVENT_TYPE, EventType.ToolResponseEvent); + assertEquals( + ContextRetrievalRequestEvent.EVENT_TYPE, EventType.ContextRetrievalRequestEvent); + assertEquals( + ContextRetrievalResponseEvent.EVENT_TYPE, EventType.ContextRetrievalResponseEvent); } } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java index 3f25cc127..766776eca 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java @@ -37,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** Test for {@link ActionJsonSerializer}. */ @@ -234,4 +235,27 @@ public void testSerializeDeserializeConfig() throws Exception { Assertions.assertEquals(arg1, deserializeConfig.get("arg1")); Assertions.assertEquals(arg2, deserializeConfig.get("arg2")); } + + @Test + public void testDeserializeLegacyListenEventTypesKey() throws Exception { + String legacyJson = + "{" + + "\"name\":\"legacyAction\"," + + "\"exec\":{" + + "\"func_type\":\"PythonFunction\"," + + "\"module\":\"test_module\"," + + "\"qualname\":\"test_function\"" + + "}," + + "\"listen_event_types\":[\"_input_event\",\"_output_event\"]" + + "}"; + + ObjectMapper mapper = new ObjectMapper(); + Action action = mapper.readValue(legacyJson, Action.class); + + assertEquals("legacyAction", action.getName()); + assertNotNull(action.getTriggerConditions()); + assertEquals( + List.of(InputEvent.EVENT_TYPE, OutputEvent.EVENT_TYPE), + action.getTriggerConditions()); + } } diff --git a/python/flink_agents/api/tests/test_event_type.py b/python/flink_agents/api/tests/test_event_type.py index 520edc5f7..a25e23fc8 100644 --- a/python/flink_agents/api/tests/test_event_type.py +++ b/python/flink_agents/api/tests/test_event_type.py @@ -19,20 +19,22 @@ from __future__ import annotations +from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent +from flink_agents.api.events.context_retrieval_event import ( + ContextRetrievalRequestEvent, + ContextRetrievalResponseEvent, +) +from flink_agents.api.events.event import InputEvent, OutputEvent from flink_agents.api.events.event_type import EventType +from flink_agents.api.events.tool_event import ToolRequestEvent, ToolResponseEvent -def test_builtin_constants_are_non_empty_strings() -> None: - for name in ( - "InputEvent", - "OutputEvent", - "ChatRequestEvent", - "ChatResponseEvent", - "ToolRequestEvent", - "ToolResponseEvent", - "ContextRetrievalRequestEvent", - "ContextRetrievalResponseEvent", - ): - value = getattr(EventType, name) - assert isinstance(value, str) - assert value +def test_builtin_constants_match_event_class_constants() -> None: + assert EventType.InputEvent == InputEvent.EVENT_TYPE + assert EventType.OutputEvent == OutputEvent.EVENT_TYPE + assert EventType.ChatRequestEvent == ChatRequestEvent.EVENT_TYPE + assert EventType.ChatResponseEvent == ChatResponseEvent.EVENT_TYPE + assert EventType.ToolRequestEvent == ToolRequestEvent.EVENT_TYPE + assert EventType.ToolResponseEvent == ToolResponseEvent.EVENT_TYPE + assert EventType.ContextRetrievalRequestEvent == ContextRetrievalRequestEvent.EVENT_TYPE + assert EventType.ContextRetrievalResponseEvent == ContextRetrievalResponseEvent.EVENT_TYPE diff --git a/python/flink_agents/plan/actions/action.py b/python/flink_agents/plan/actions/action.py index 15ec7dc8a..3c69d9ca2 100644 --- a/python/flink_agents/plan/actions/action.py +++ b/python/flink_agents/plan/actions/action.py @@ -113,10 +113,13 @@ def __init__( self, name: str, exec: Function, - trigger_conditions: List[str], + trigger_conditions: List[str] | None = None, config: Dict[str, Any] | None = None, + **kwargs: Any, ) -> None: """Action will check function signature when init.""" + if trigger_conditions is None: + trigger_conditions = kwargs.pop("listen_event_types", []) super().__init__( name=name, exec=exec, trigger_conditions=trigger_conditions, config=config ) diff --git a/python/flink_agents/plan/tests/test_action.py b/python/flink_agents/plan/tests/test_action.py index f93ee4531..3e7f4bb01 100644 --- a/python/flink_agents/plan/tests/test_action.py +++ b/python/flink_agents/plan/tests/test_action.py @@ -120,3 +120,20 @@ def test_action_deserialize_java_shape_config_unwraps_primitives() -> None: "rate": 1.5, "label": "fast", } + + +def test_action_deserialize_legacy_listen_event_types_key() -> None: + legacy_json = json.dumps( + { + "name": "legacy", + "exec": { + "func_type": "PythonFunction", + "module": "flink_agents.plan.tests.test_action", + "qualname": "legal_signature", + }, + "listen_event_types": [InputEvent.EVENT_TYPE], + } + ) + action = Action.model_validate_json(legacy_json) + assert action.name == "legacy" + assert action.trigger_conditions == [InputEvent.EVENT_TYPE] From df858aba500be40186ed4c99aaddea838b95b899 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:24:54 +0800 Subject: [PATCH 06/13] [e2e] Migrate TokenMetricsE2EAgent to @Action(value) API --- .../flink/agents/integration/test/TokenMetricsE2EAgent.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/TokenMetricsE2EAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/TokenMetricsE2EAgent.java index 9cefc284c..22608a2c0 100644 --- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/TokenMetricsE2EAgent.java +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/TokenMetricsE2EAgent.java @@ -18,6 +18,7 @@ package org.apache.flink.agents.integration.test; +import org.apache.flink.agents.api.EventType; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; import org.apache.flink.agents.api.agents.Agent; @@ -60,7 +61,7 @@ public static ResourceDescriptor chatModel() { .build(); } - @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + @Action(EventType.InputEvent) public static void process(InputEvent event, RunnerContext ctx) throws Exception { ctx.sendEvent( new ChatRequestEvent( @@ -69,7 +70,7 @@ public static void process(InputEvent event, RunnerContext ctx) throws Exception new ChatMessage(MessageRole.USER, (String) event.getInput())))); } - @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE}) + @Action(EventType.ChatResponseEvent) public static void processChatResponse(ChatResponseEvent event, RunnerContext ctx) { ctx.sendEvent(new OutputEvent(event.getResponse().getContent())); } From cbfea417b59bbf419c7acdb905dc4edcce58da52 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:10:56 +0800 Subject: [PATCH 07/13] [api] Add EventType compile-time constants for built-in event types --- .../apache/flink/agents/api/EventType.java | 32 ++++++++++++++++++- .../flink/agents/api/EventTypeTest.java | 24 ++++++++------ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/api/src/main/java/org/apache/flink/agents/api/EventType.java b/api/src/main/java/org/apache/flink/agents/api/EventType.java index f486a6d0e..0c02be2e3 100644 --- a/api/src/main/java/org/apache/flink/agents/api/EventType.java +++ b/api/src/main/java/org/apache/flink/agents/api/EventType.java @@ -18,10 +18,20 @@ package org.apache.flink.agents.api; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + /** - * Compile-time constants for built-in event types, sourced from each {@code XxxEvent.EVENT_TYPE}. + * Compile-time constants for built-in event types. * *

Usage: {@code @Action(EventType.InputEvent)}. + * + *

In CEL trigger conditions, {@code EventType} is exposed as a top-level map variable so {@code + * type == EventType.InputEvent} resolves the constant at evaluation time. {@link #allConstants()} + * enumerates the constant fields for that activation map and for plan-load validation. */ public final class EventType { @@ -40,5 +50,25 @@ public final class EventType { public static final String ContextRetrievalResponseEvent = org.apache.flink.agents.api.event.ContextRetrievalResponseEvent.EVENT_TYPE; + /** + * Returns all built-in constants as an unmodifiable {@code name → event-type value} map. + * Enumerated reflectively from the {@code public static final String} fields of this class so + * newly added constants are picked up automatically. Iteration order is unspecified. + */ + public static Map allConstants() { + Map constants = new LinkedHashMap<>(); + for (Field field : EventType.class.getFields()) { + if (Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) { + try { + constants.put(field.getName(), (String) field.get(null)); + } catch (IllegalAccessException e) { + // Unreachable: getFields() only returns public fields. + throw new IllegalStateException("Cannot read EventType." + field.getName(), e); + } + } + } + return Collections.unmodifiableMap(constants); + } + private EventType() {} } diff --git a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java index 4ac5aa728..08c29f958 100644 --- a/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java +++ b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java @@ -26,22 +26,28 @@ import org.apache.flink.agents.api.event.ToolResponseEvent; import org.junit.jupiter.api.Test; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.assertEquals; /** Tests for {@link EventType}. */ class EventTypeTest { @Test - void builtInConstantsMatchEventClassConstants() { - assertEquals(InputEvent.EVENT_TYPE, EventType.InputEvent); - assertEquals(OutputEvent.EVENT_TYPE, EventType.OutputEvent); - assertEquals(ChatRequestEvent.EVENT_TYPE, EventType.ChatRequestEvent); - assertEquals(ChatResponseEvent.EVENT_TYPE, EventType.ChatResponseEvent); - assertEquals(ToolRequestEvent.EVENT_TYPE, EventType.ToolRequestEvent); - assertEquals(ToolResponseEvent.EVENT_TYPE, EventType.ToolResponseEvent); + void allConstantsEnumeratesEveryBuiltInConstant() { + Map constants = EventType.allConstants(); + assertEquals(8, constants.size()); + assertEquals(InputEvent.EVENT_TYPE, constants.get("InputEvent")); + assertEquals(OutputEvent.EVENT_TYPE, constants.get("OutputEvent")); + assertEquals(ChatRequestEvent.EVENT_TYPE, constants.get("ChatRequestEvent")); + assertEquals(ChatResponseEvent.EVENT_TYPE, constants.get("ChatResponseEvent")); + assertEquals(ToolRequestEvent.EVENT_TYPE, constants.get("ToolRequestEvent")); + assertEquals(ToolResponseEvent.EVENT_TYPE, constants.get("ToolResponseEvent")); assertEquals( - ContextRetrievalRequestEvent.EVENT_TYPE, EventType.ContextRetrievalRequestEvent); + ContextRetrievalRequestEvent.EVENT_TYPE, + constants.get("ContextRetrievalRequestEvent")); assertEquals( - ContextRetrievalResponseEvent.EVENT_TYPE, EventType.ContextRetrievalResponseEvent); + ContextRetrievalResponseEvent.EVENT_TYPE, + constants.get("ContextRetrievalResponseEvent")); } } From 7a34461f38106a8597a85574351f8f90bd72ccde Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:11:25 +0800 Subject: [PATCH 08/13] [plan][runtime] Implement CEL-based @Action triggerConditions --- examples/pom.xml | 7 + plan/pom.xml | 5 + .../apache/flink/agents/plan/AgentPlan.java | 60 ++++- .../flink/agents/plan/actions/Action.java | 78 +++++- .../agents/plan/condition/CelMacroPolicy.java | 126 +++++++++ .../plan/condition/ParsedCondition.java | 140 ++++++++++ pom.xml | 1 + runtime/pom.xml | 13 + .../runtime/condition/ActionRouter.java | 124 +++++++++ .../condition/CelConditionEvaluator.java | 252 ++++++++++++++++++ .../condition/CelExpressionFacade.java | 216 +++++++++++++++ .../operator/ActionExecutionOperator.java | 2 +- .../agents/runtime/operator/EventRouter.java | 15 +- 13 files changed, 1015 insertions(+), 24 deletions(-) create mode 100644 plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java create mode 100644 plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java create mode 100644 runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java create mode 100644 runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java create mode 100644 runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelExpressionFacade.java diff --git a/examples/pom.xml b/examples/pom.xml index 26f7f9f20..8fdaa7ad0 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -65,6 +65,13 @@ under the License. ${project.version} provided + + + + dev.cel + cel + ${cel.version} + \ No newline at end of file diff --git a/plan/pom.xml b/plan/pom.xml index 02df3c2c3..8281fd1ab 100644 --- a/plan/pom.xml +++ b/plan/pom.xml @@ -50,6 +50,11 @@ under the License. com.fasterxml.jackson.core jackson-databind + + dev.cel + cel + ${cel.version} + io.github.bonede diff --git a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java index e9d59083a..e76a6c76a 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java @@ -85,6 +85,13 @@ public class AgentPlan implements Serializable { /** Mapping from event type string to list of actions that should be triggered by the event. */ private Map> actionsByEvent; + /** + * Actions that carry at least one CEL expression in their trigger conditions and therefore + * require runtime CEL evaluation in addition to (or instead of) the actionsByEvent reverse map. + * Rebuilt by {@link #rebuildActionsWithCel()} after construction and deserialization. + */ + private transient List actionsWithCel = new ArrayList<>(); + /** Two-level mapping of resource type to resource name to resource provider. */ private Map> resourceProviders; @@ -95,6 +102,7 @@ public AgentPlan(Map actions, Map> actionsB this.actionsByEvent = actionsByEvent; this.resourceProviders = new HashMap<>(); this.config = new AgentConfiguration(); + rebuildActionsWithCel(); } public AgentPlan( @@ -105,6 +113,7 @@ public AgentPlan( this.actionsByEvent = actionsByEvent; this.resourceProviders = resourceProviders; this.config = new AgentConfiguration(); + rebuildActionsWithCel(); } public AgentPlan( @@ -116,6 +125,7 @@ public AgentPlan( this.actionsByEvent = actionsByEvent; this.resourceProviders = resourceProviders; this.config = config; + rebuildActionsWithCel(); } /** @@ -180,6 +190,7 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE this.actionsByEvent = agentPlan.getActionsByEvent(); this.resourceProviders = agentPlan.getResourceProviders(); this.config = agentPlan.getConfig(); + rebuildActionsWithCel(); } private void extractActions( @@ -199,24 +210,51 @@ private void extractActions( // Create an Action Action action = new Action(actionName, function, triggerConditions, config); - - // Add to actions map - actions.put(action.getName(), action); - - // Add to actionsByEvent map - for (String eventTypeName : triggerConditions) { - actionsByEvent.computeIfAbsent(eventTypeName, k -> new ArrayList<>()).add(action); - } + registerAction(action); } private void addBuiltAction(Action action) { - // Add to actions map - actions.put(action.getName(), action); + registerAction(action); + } - // Add to actionsByEvent map + /** + * Registers an action into both {@link #actions}, {@link #actionsByEvent} (using type-only + * entries from {@link Action#getListenEventTypes()}), and {@link #actionsWithCel} if the action + * carries any CEL expression. + */ + private void registerAction(Action action) { + actions.put(action.getName(), action); for (String eventTypeName : action.getListenEventTypes()) { actionsByEvent.computeIfAbsent(eventTypeName, k -> new ArrayList<>()).add(action); } + if (action.hasCelCondition()) { + actionsWithCel.add(action); + } + } + + /** + * Rebuilds {@link #actionsWithCel} from {@link #actions}. Used after deserialization (where + * actionsWithCel is transient). + */ + private void rebuildActionsWithCel() { + if (actionsWithCel == null) { + actionsWithCel = new ArrayList<>(); + } else { + actionsWithCel.clear(); + } + if (actions == null) { + return; + } + for (Action action : actions.values()) { + if (action.hasCelCondition()) { + actionsWithCel.add(action); + } + } + } + + /** Returns the list of actions that require CEL evaluation. */ + public List getActionsWithCel() { + return actionsWithCel; } private void extractActionsFromAgent(Agent agent) throws Exception { diff --git a/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java b/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java index 18e3b83b4..4f8da0155 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/actions/Action.java @@ -23,11 +23,16 @@ import org.apache.flink.agents.api.Event; import org.apache.flink.agents.api.context.RunnerContext; import org.apache.flink.agents.plan.Function; +import org.apache.flink.agents.plan.condition.ParsedCondition; +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; +import org.apache.flink.agents.plan.condition.ParsedCondition.TypeMatch; import org.apache.flink.agents.plan.serializer.ActionJsonDeserializer; import org.apache.flink.agents.plan.serializer.ActionJsonSerializer; import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -35,8 +40,8 @@ /** * Representation of an agent action with unified trigger conditions. * - *

Each entry of {@code triggerConditions} is an event type name string. Multiple entries combine - * with OR. + *

Each entry of {@code triggerConditions} is either a plain event-type name (matched against + * {@code event.getType()}) or a CEL expression. Multiple entries combine with OR. */ @JsonSerialize(using = ActionJsonSerializer.class) @JsonDeserialize(using = ActionJsonDeserializer.class) @@ -45,6 +50,13 @@ public class Action { private final Function exec; private final List triggerConditions; + /** + * Derived from {@link #triggerConditions}; not part of the persisted state because the CEL AST + * objects inside CelExpression are not Kryo-serialisable. Rebuilt lazily on first access after + * deserialization via {@link #parsedConditions()}. + */ + private transient List parsedConditions; + // TODO: support nested map/list with non primitive type value. @Nullable private final Map config; @@ -54,13 +66,35 @@ public Action( List triggerConditions, @Nullable Map config) throws Exception { + if (triggerConditions == null || triggerConditions.isEmpty()) { + throw new IllegalArgumentException( + "Action '" + name + "' must have at least one entry in 'triggerConditions'"); + } this.name = name; this.exec = exec; this.triggerConditions = triggerConditions; this.config = config; + + // Eagerly build (and validate) parsedConditions at construction time so any classifier + // error fires early. Stored transiently; rebuilt on first access after deserialization. + this.parsedConditions = buildParsedConditions(name, triggerConditions); + exec.checkSignature(new Class[] {Event.class, RunnerContext.class}); } + private static List buildParsedConditions( + String name, List triggerConditions) { + List parsed = new ArrayList<>(triggerConditions.size()); + for (String entry : triggerConditions) { + if (entry == null || entry.isEmpty()) { + throw new IllegalArgumentException( + "Action '" + name + "' has a null/empty trigger entry"); + } + parsed.add(ParsedCondition.classify(entry)); + } + return Collections.unmodifiableList(parsed); + } + public Action(String name, Function exec, List triggerConditions) throws Exception { this(name, exec, triggerConditions, null); } @@ -73,19 +107,43 @@ public Function getExec() { return exec; } - /** Returns the full trigger conditions list. */ + /** Returns the full trigger conditions list (type names and CEL expressions). */ public List getTriggerConditions() { return triggerConditions; } - /** - * Returns event-type names. Kept for callers that still consume the old naming; in this PR all - * trigger entries are plain event-type names so the list is identical to {@link - * #getTriggerConditions()}. A follow-up PR introduces CEL expressions and overrides this to - * filter out non-type entries. - */ + /** Returns parsed conditions in declaration order (unmodifiable). */ + public List getParsedConditions() { + return parsedConditions(); + } + + /** Lazily rebuilds parsedConditions on first access after deserialization. */ + private synchronized List parsedConditions() { + if (parsedConditions == null) { + parsedConditions = buildParsedConditions(name, triggerConditions); + } + return parsedConditions; + } + + /** Returns event-type names extracted from {@link TypeMatch} entries (CEL entries skipped). */ public List getListenEventTypes() { - return triggerConditions; + List typeNames = new ArrayList<>(); + for (ParsedCondition pc : parsedConditions()) { + if (pc instanceof TypeMatch) { + typeNames.add(pc.source()); + } + } + return typeNames; + } + + /** Returns whether this action carries at least one CEL expression entry. */ + public boolean hasCelCondition() { + for (ParsedCondition pc : parsedConditions()) { + if (pc instanceof CelExpression) { + return true; + } + } + return false; } @Nullable diff --git a/plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java b/plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java new file mode 100644 index 000000000..46597b91c --- /dev/null +++ b/plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java @@ -0,0 +1,126 @@ +/* + * 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.plan.condition; + +import com.google.common.collect.ImmutableList; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.ast.CelExpr; +import dev.cel.common.navigation.CelNavigableAst; +import dev.cel.parser.CelMacro; +import dev.cel.parser.CelMacroExprFactory; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +/** + * CEL macro rules for trigger conditions: the custom {@code has()} macro, the macro whitelist, and + * the reserved identifiers. + */ +public final class CelMacroPolicy { + + /** + * Custom parse-time {@code has()} macro: {@code has(a.b)} tests field presence on {@code a}; + * {@code has(score)} tests presence of key {@code score} in the {@code attributes} map. + */ + public static final CelMacro HAS = CelMacro.newGlobalMacro("has", 1, CelMacroPolicy::expandHas); + + private static Optional expandHas( + CelMacroExprFactory exprFactory, CelExpr target, ImmutableList arguments) { + CelExpr arg = arguments.get(0); + if (arg.exprKind().getKind() == CelExpr.ExprKind.Kind.SELECT && !arg.select().testOnly()) { + // has(a.b) → field presence on operand a. + return Optional.of( + exprFactory.newSelect(arg.select().operand(), arg.select().field(), true)); + } + if (arg.exprKind().getKind() == CelExpr.ExprKind.Kind.IDENT + && !RESERVED_IDENTIFIERS.contains(arg.ident().name())) { + // has(score) → key presence in the attributes map. + return Optional.of( + exprFactory.newSelect( + exprFactory.newIdentifier("attributes"), arg.ident().name(), true)); + } + return Optional.of( + exprFactory.reportError( + "invalid argument to has() macro: expected a field selection like" + + " has(a.b) or an attribute name like has(score)")); + } + + /** The complete set of CEL standard macro names. */ + public static final Set CEL_STANDARD_MACROS = + Set.of("has", "exists", "exists_one", "all", "filter", "map"); + + /** Macros allowed in trigger condition expressions. */ + public static final Set ALLOWED_MACROS = Set.of("has"); + + /** Returns the first disallowed macro call found in {@code ast}, or empty if none. */ + public static Optional findFirstDisallowedMacro(CelAbstractSyntaxTree ast) { + return CelNavigableAst.fromAst(ast) + .getRoot() + .allNodes() + .filter(node -> node.getKind() == CelExpr.ExprKind.Kind.CALL) + .map(node -> node.expr().call().function()) + .filter(fn -> CEL_STANDARD_MACROS.contains(fn) && !ALLOWED_MACROS.contains(fn)) + .findFirst(); + } + + /** Formats the disallowed-macro error message; kept aligned with the Python template. */ + public static String formatDisallowedMessage(String macro, String source) { + return "CEL expression uses disallowed macro '" + + macro + + "': \"" + + source + + "\". Only allows: " + + new TreeSet<>(ALLOWED_MACROS) + + "."; + } + + /** Names rejected as bare event-type aliases and never shadowed by user attributes. */ + public static final Set RESERVED_IDENTIFIERS; + + static { + Set set = + new HashSet<>( + Set.of( + // Framework-owned activation variables. + "type", + "attributes", + "EventType", + // CEL literals. + "true", + "false", + "null", + // CEL operators / type-conversion functions / container types. + "in", + "int", + "uint", + "double", + "string", + "bool", + "bytes", + "list")); + // All CEL standard macro names. + set.addAll(CEL_STANDARD_MACROS); + RESERVED_IDENTIFIERS = Collections.unmodifiableSet(set); + } + + private CelMacroPolicy() {} +} diff --git a/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java b/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java new file mode 100644 index 000000000..3e5d903da --- /dev/null +++ b/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java @@ -0,0 +1,140 @@ +/* + * 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.plan.condition; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.ast.CelExpr; +import dev.cel.parser.CelParser; +import dev.cel.parser.CelParserFactory; + +import java.util.Objects; + +/** + * A parsed {@code Action.triggerConditions} entry — either {@link TypeMatch} or {@link + * CelExpression}. {@link #classify} turns a raw entry string into one of the two. + */ +public interface ParsedCondition { + + /** Original user-written entry string. */ + String source(); + + /** Parser with the custom {@code has()} macro; same dialect as the runtime facade parser. */ + CelParser CEL_PARSER = + CelParserFactory.standardCelParserBuilder().addMacros(CelMacroPolicy.HAS).build(); + + /** + * Parses a {@code triggerConditions} entry: a non-reserved bare-identifier root becomes a + * {@link TypeMatch}, everything else a {@link CelExpression}. + */ + static ParsedCondition classify(String source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException( + "ParsedCondition.classify: source must be non-null and non-empty"); + } + CelAbstractSyntaxTree ast; + try { + ast = CEL_PARSER.parse(source).getAst(); + } catch (CelValidationException e) { + throw new IllegalArgumentException( + "Invalid CEL expression: \"" + source + "\" — " + e.getMessage(), e); + } + CelExpr root = ast.getExpr(); + if (root.exprKind().getKind() == CelExpr.ExprKind.Kind.IDENT) { + String name = root.ident().name(); + if (CelMacroPolicy.RESERVED_IDENTIFIERS.contains(name)) { + throw new IllegalArgumentException( + "'" + + name + + "' is a CEL reserved keyword and cannot be used as an " + + "event type name. Did you mean: @action(\"" + + name + + " == 'xxx'\") or @action(\"attributes." + + name + + "\")?"); + } + return new TypeMatch(name); + } + return new CelExpression(source); + } + + /** A plain event-type match. {@link #source()} is compared against {@code Event.getType()}. */ + final class TypeMatch implements ParsedCondition { + + private final String source; + + public TypeMatch(String source) { + this.source = source; + } + + @Override + public String source() { + return source; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TypeMatch)) return false; + return source.equals(((TypeMatch) o).source); + } + + @Override + public int hashCode() { + return Objects.hash(source); + } + + @Override + public String toString() { + return "TypeMatch{source=" + source + "}"; + } + } + + /** A CEL expression. Source-only; compiled elsewhere. */ + final class CelExpression implements ParsedCondition { + + private final String source; + + public CelExpression(String source) { + this.source = source; + } + + @Override + public String source() { + return source; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CelExpression)) return false; + return source.equals(((CelExpression) o).source); + } + + @Override + public int hashCode() { + return Objects.hash(source); + } + + @Override + public String toString() { + return "CelExpression{source=" + source + "}"; + } + } +} diff --git a/pom.xml b/pom.xml index e77b0a51c..916ac91c2 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,7 @@ under the License. 3.27.7 5.14.2 1.15.4 + 0.12.0 true diff --git a/runtime/pom.xml b/runtime/pom.xml index 422c5c320..c7b78ccab 100644 --- a/runtime/pom.xml +++ b/runtime/pom.xml @@ -193,9 +193,22 @@ under the License. jackson-datatype-jsr310 ${jackson.version} + + dev.cel + cel + ${cel.version} + + + + src/test/resources + + + ${project.basedir}/../e2e-test/cel-fixtures + + diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java new file mode 100644 index 000000000..3b941a7dc --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java @@ -0,0 +1,124 @@ +/* + * 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.condition; + +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.plan.AgentPlan; +import org.apache.flink.agents.plan.actions.Action; +import org.apache.flink.agents.plan.condition.ParsedCondition; +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** + * Routes an event to matching actions: type-index fast path first, then CEL slow path. + * + *

Each action fires at most once per event; typed hits ordered before CEL hits. + */ +public final class ActionRouter { + + private final AgentPlan agentPlan; + + /** Null when the plan contains no CEL expressions. */ + private CelConditionEvaluator conditionEvaluator; + + public ActionRouter(AgentPlan agentPlan) { + if (agentPlan == null) { + throw new IllegalArgumentException("ActionRouter: agentPlan must not be null"); + } + this.agentPlan = agentPlan; + } + + /** Pre-compiles all CEL expressions in the plan. */ + public void open() { + List celExpressions = new ArrayList<>(); + for (Action action : agentPlan.getActions().values()) { + for (ParsedCondition pc : action.getParsedConditions()) { + if (pc instanceof CelExpression) { + celExpressions.add((CelExpression) pc); + } + } + } + if (celExpressions.isEmpty()) { + return; + } + conditionEvaluator = new CelConditionEvaluator(); + conditionEvaluator.initPrograms(celExpressions); + } + + /** Returns actions to fire for {@code event}: typed hits first, then CEL hits. */ + public List route(Event event) { + List typedHits = + agentPlan + .getActionsByEvent() + .getOrDefault(event.getType(), Collections.emptyList()); + + // CEL candidates = actions with at least one CEL entry, excluding those already + // matched by typed routing for this event type. This avoids double-firing. + List celCandidates; + List withCel = agentPlan.getActionsWithCel(); + if (withCel.isEmpty()) { + celCandidates = Collections.emptyList(); + } else { + celCandidates = new ArrayList<>(); + for (Action a : withCel) { + if (!typedHits.contains(a)) { + celCandidates.add(a); + } + } + } + + if (celCandidates.isEmpty()) { + return typedHits; + } + + // Preserves typed-first ordering and deduplicates. + LinkedHashSet matched = new LinkedHashSet<>(typedHits); + + Map activation = null; + for (Action a : celCandidates) { + // Within-action OR: first matching CEL expression admits the action. + for (ParsedCondition pc : a.getParsedConditions()) { + if (!(pc instanceof CelExpression)) { + continue; + } + if (activation == null) { + activation = conditionEvaluator.createActivation(event); + } + if (conditionEvaluator.evaluate((CelExpression) pc, activation)) { + matched.add(a); + break; + } + } + } + return new ArrayList<>(matched); + } + + /** Idempotent. */ + public void close() { + if (conditionEvaluator != null) { + conditionEvaluator.close(); + conditionEvaluator = null; + } + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java new file mode 100644 index 000000000..3734b2e15 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java @@ -0,0 +1,252 @@ +/* + * 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.condition; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Evaluates CEL condition expressions against event data. */ +public class CelConditionEvaluator { + + private static final Logger LOG = LoggerFactory.getLogger(CelConditionEvaluator.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Behaviour for both runtime exceptions and non-Boolean return values — both mean "no verdict". + */ + public enum EvaluationFailurePolicy { + /** Log WARN and treat the action as not matching. */ + WARN_AND_SKIP, + /** Rethrow as {@link IllegalStateException}; triggers Flink task failover. */ + FAIL + } + + /** Frozen after {@link #initPrograms}; cleared by {@link #close}. */ + @Nullable private Map programCache; + + private final EvaluationFailurePolicy failurePolicy; + + public CelConditionEvaluator() { + this(EvaluationFailurePolicy.WARN_AND_SKIP); + } + + public CelConditionEvaluator(EvaluationFailurePolicy failurePolicy) { + this.failurePolicy = failurePolicy; + } + + /** Pre-compiles {@code expressions} and freezes the cache. Nulls are skipped. */ + public void initPrograms(Collection expressions) { + Map programs = new HashMap<>(); + for (CelExpression expression : expressions) { + if (expression == null) { + continue; + } + String source = expression.source(); + programs.computeIfAbsent(source, CelExpressionFacade::toProgram); + } + this.programCache = Collections.unmodifiableMap(programs); + } + + public void close() { + programCache = null; + } + + /** Evaluates {@code expression} (which must have been pre-compiled). Null returns true. */ + public boolean evaluate(@Nullable CelExpression expression, Map activation) { + if (expression == null) { + return true; + } + String source = expression.source(); + try { + CelRuntime.Program program = programCache.get(source); + if (program == null) { + throw new IllegalStateException( + "CEL condition was not pre-compiled via initPrograms(): \"" + + source + + "\""); + } + return evaluateProgram(source, program, activation); + } catch (CelEvaluationException e) { + if (failurePolicy == EvaluationFailurePolicy.FAIL) { + throw new IllegalStateException( + "CEL condition evaluation failed for '" + source + "'", e); + } + LOG.warn("CEL condition evaluation failed for '{}', skipping action", source, e); + return false; + } + } + + private boolean evaluateProgram( + String condition, CelRuntime.Program program, Map activation) + throws CelEvaluationException { + Object result = program.eval(activation); + if (result instanceof Boolean) { + return (Boolean) result; + } + String msg = + String.format( + "CEL condition '%s' returned non-boolean type %s, treating as false", + condition, result == null ? "null" : result.getClass().getName()); + if (failurePolicy == EvaluationFailurePolicy.FAIL) { + throw new IllegalStateException(msg); + } + LOG.warn(msg); + return false; + } + + /** + * Builds the CEL activation. Contract (mirror of Python {@code cel_facade}): + * + *

    + *
  • {@code type} and {@code EventType} are framework-owned and always win. + *
  • {@code attributes} holds the single-level merge of user data: {@code output.*} subkeys, + * then root attribute fields, then {@code input.*} subkeys ({@code output > root > input} + * on collision, via {@link Map#putIfAbsent}). Only one level is flattened — nested fields + * stay nested ({@code mylist.name}, not {@code name}). + *
  • Every merged attribute is also promoted to the activation top level, so conditions can + * use bare identifiers ({@code score > 0.8}) without any AST rewriting. Framework keys + * are never shadowed. + *
  • {@code id} is the user-supplied {@code id} attribute when present, otherwise falls back + * to the event UUID. + *
+ * + *

JSON-shaped strings auto-parse first; narrow numerics widen to long/double. + */ + @SuppressWarnings("unchecked") + public Map createActivation(Event event) { + Map activation = new HashMap<>(); + activation.put("type", event.getType()); + activation.put("EventType", CelExpressionFacade.EVENT_TYPE_CONSTANTS); + + Object normalizedAttrs = normalizeValue(event.getAttributes(), 0); + Map merged = new HashMap<>(); + if (normalizedAttrs instanceof Map) { + Map attrs = (Map) normalizedAttrs; + + // Precedence: output subkeys > root attributes > input subkeys (putIfAbsent keeps the + // earliest insertion). Root iteration includes the "input"/"output" maps themselves, + // so nested paths like input.region.width keep working. + Object outputObj = attrs.get("output"); + if (outputObj instanceof Map) { + ((Map) outputObj).forEach(merged::putIfAbsent); + } + attrs.forEach(merged::putIfAbsent); + Object inputObj = attrs.get("input"); + if (inputObj instanceof Map) { + ((Map) inputObj).forEach(merged::putIfAbsent); + } + } + + activation.put("attributes", merged); + // Promote to top level for bare-identifier access; framework keys win on collision. + merged.forEach(activation::putIfAbsent); + // Event UUID only as fallback — a user-supplied id attribute takes precedence. + activation.putIfAbsent("id", event.getId().toString()); + + return activation; + } + + /** + * Maximum recursion depth for {@link #normalizeValue}. Past this depth, strings are kept as + * plain strings rather than parsed as JSON (graceful degrade, mirror of Python {@code + * _MAX_NORMALIZE_DEPTH}). Prevents stack blow-up on adversarial nested JSON input. + */ + static final int MAX_NORMALIZE_DEPTH = 16; + + /** JSON-looking strings → Map/List; narrow numerics widened to long/double for CEL. */ + @SuppressWarnings("unchecked") + private static Object normalizeValue(Object value, int depth) { + if (value == null) { + return null; + } + if (value instanceof String) { + // Past MAX_NORMALIZE_DEPTH we stop expanding and keep the raw string, + // matching Python's _MAX_NORMALIZE_DEPTH graceful-degrade policy. + if (depth >= MAX_NORMALIZE_DEPTH) { + return value; + } + String s = ((String) value).trim(); + if (s.length() >= 2 + && ((s.charAt(0) == '{' && s.charAt(s.length() - 1) == '}') + || (s.charAt(0) == '[' && s.charAt(s.length() - 1) == ']'))) { + try { + return normalizeValue(MAPPER.readValue(s, Object.class), depth + 1); + } catch (Exception ignored) { + // Not valid JSON — fall through as plain string. + } + } + return value; + } + if (value instanceof Map) { + Map src = (Map) value; + Map dst = new HashMap<>(src.size()); + for (Map.Entry entry : src.entrySet()) { + dst.put(entry.getKey(), normalizeValue(entry.getValue(), depth + 1)); + } + return dst; + } + if (value instanceof List) { + List src = (List) value; + List dst = new ArrayList<>(src.size()); + for (Object item : src) { + dst.add(normalizeValue(item, depth + 1)); + } + return dst; + } + if (value instanceof Byte || value instanceof Short || value instanceof Integer) { + return ((Number) value).longValue(); + } + if (value instanceof Float) { + return ((Float) value).doubleValue(); + } + if (value instanceof BigInteger) { + BigInteger bigInt = (BigInteger) value; + if (bigInt.bitLength() < 64) { + return bigInt.longValue(); + } + throw new IllegalArgumentException( + "CEL normalizeValue: BigInteger value overflows int64: " + bigInt); + } + if (value instanceof BigDecimal) { + BigDecimal bigDec = (BigDecimal) value; + LOG.debug( + "CEL normalizeValue: converting BigDecimal to double (possible precision loss): {}", + bigDec); + return bigDec.doubleValue(); + } + return value; + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelExpressionFacade.java b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelExpressionFacade.java new file mode 100644 index 000000000..350fef4ab --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelExpressionFacade.java @@ -0,0 +1,216 @@ +/* + * 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.condition; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.CelValidationException; +import dev.cel.common.ast.CelExpr; +import dev.cel.common.navigation.CelNavigableAst; +import dev.cel.common.types.CelType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerBuilder; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.parser.CelParser; +import dev.cel.parser.CelParserFactory; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import org.apache.flink.agents.api.EventType; +import org.apache.flink.agents.plan.condition.CelMacroPolicy; +import org.apache.flink.annotation.VisibleForTesting; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * CEL Parse → Validate → Check → Program pipeline. Compiled {@link CelRuntime.Program} instances + * are cached process-wide by source string. + */ +public final class CelExpressionFacade { + + /** Immutable {@code constant name → value} map bound to the CEL {@code EventType} variable. */ + static final Map EVENT_TYPE_CONSTANTS = Map.copyOf(EventType.allConstants()); + + /** Cel-java has no wall-clock timeout, so these caps bound expression size/depth. */ + private static final CelOptions CEL_OPTIONS = + CelOptions.current() + .maxExpressionCodePointSize(8_192) + .maxParseRecursionDepth(32) + .comprehensionMaxIterations(1_000) + .build(); + + /** + * Only the custom has() macro is enabled; all others are rejected by {@link CelMacroPolicy}. + */ + private static final CelParser CEL_PARSER = + CelParserFactory.standardCelParserBuilder() + .setOptions(CEL_OPTIONS) + .addMacros(CelMacroPolicy.HAS) + .build(); + + /** + * Vars always declared at type-check; mirrors {@link CelConditionEvaluator#createActivation}. + */ + private static final Map BASE_VARS = + Map.of( + "type", + SimpleType.STRING, + "id", + SimpleType.DYN, + "EventType", + MapType.create(SimpleType.STRING, SimpleType.STRING), + "attributes", + MapType.create(SimpleType.STRING, SimpleType.DYN)); + + private static final CelRuntime CEL_RUNTIME = + CelRuntimeFactory.standardCelRuntimeBuilder().setOptions(CEL_OPTIONS).build(); + + /** Process-wide bounded LRU cache of compiled CEL programs, keyed by source string. */ + static final int PROGRAM_CACHE_MAX_SIZE = 1024; + + private static final Map PROGRAM_CACHE = + Collections.synchronizedMap( + new LinkedHashMap( + 256, 0.75f, /* accessOrder */ true) { + @Override + protected boolean removeEldestEntry( + Map.Entry eldest) { + return size() > PROGRAM_CACHE_MAX_SIZE; + } + }); + + private CelExpressionFacade() {} + + /** Parses {@code source} into an untyped AST (no type-check). */ + public static CelAbstractSyntaxTree parse(String source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException( + "CelExpressionFacade.parse: source must be non-null and non-empty"); + } + try { + return CEL_PARSER.parse(source).getAst(); + } catch (CelValidationException e) { + throw new IllegalArgumentException( + "Invalid CEL expression: \"" + source + "\" — " + e.getMessage(), e); + } + } + + /** Compiles {@code source} into a cached, thread-safe program. */ + public static CelRuntime.Program toProgram(String source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException( + "CelExpressionFacade.toProgram: source must be non-null and non-empty"); + } + return PROGRAM_CACHE.computeIfAbsent(source, CelExpressionFacade::compile); + } + + private static CelRuntime.Program compile(String source) { + CelAbstractSyntaxTree parsed; + try { + parsed = parse(source); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid CEL condition expression: \"" + source + "\" — " + e.getMessage(), e); + } + return compileFromAst(source, parsed); + } + + private static CelRuntime.Program compileFromAst(String source, CelAbstractSyntaxTree parsed) { + CelMacroPolicy.findFirstDisallowedMacro(parsed) + .ifPresent( + macro -> { + throw new IllegalArgumentException( + CelMacroPolicy.formatDisallowedMessage(macro, source)); + }); + + validateEventTypeReferences(parsed); + + try { + CelAbstractSyntaxTree checked = compilerFor(parsed).check(parsed).getAst(); + return CEL_RUNTIME.createProgram(checked); + } catch (CelValidationException | CelEvaluationException e) { + throw new IllegalArgumentException( + "Invalid CEL condition expression: \"" + source + "\" — " + e.getMessage(), e); + } + } + + /** + * Builds a type-checker for {@code parsed}: base vars plus every identifier appearing in the + * expression declared as DYN. + */ + private static CelCompiler compilerFor(CelAbstractSyntaxTree parsed) { + CelCompilerBuilder builder = + CelCompilerFactory.standardCelCompilerBuilder().setOptions(CEL_OPTIONS); + BASE_VARS.forEach(builder::addVar); + for (String ident : collectIdentifiers(parsed)) { + if (!BASE_VARS.containsKey(ident)) { + builder.addVar(ident, SimpleType.DYN); + } + } + return builder.build(); + } + + /** + * Throws {@link IllegalArgumentException} when any {@code EventType.X} in {@code ast} names an + * unknown constant. + */ + private static void validateEventTypeReferences(CelAbstractSyntaxTree ast) { + CelNavigableAst.fromAst(ast) + .getRoot() + .allNodes() + .filter(node -> node.getKind() == CelExpr.ExprKind.Kind.SELECT) + .map(node -> node.expr().select()) + .filter( + select -> + select.operand().getKind() == CelExpr.ExprKind.Kind.IDENT + && "EventType".equals(select.operand().ident().name())) + .forEach( + select -> { + if (!EVENT_TYPE_CONSTANTS.containsKey(select.field())) { + throw new IllegalArgumentException( + "Unknown EventType constant: EventType." + select.field()); + } + }); + } + + private static Set collectIdentifiers(CelAbstractSyntaxTree ast) { + return CelNavigableAst.fromAst(ast) + .getRoot() + .allNodes() + .filter(node -> node.getKind() == CelExpr.ExprKind.Kind.IDENT) + .map(node -> node.expr().ident().name()) + .collect(Collectors.toSet()); + } + + @VisibleForTesting + static void clearProgramCacheForTests() { + PROGRAM_CACHE.clear(); + } + + @VisibleForTesting + static long programCacheSizeForTests() { + return PROGRAM_CACHE.size(); + } +} 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 665551bba..11f0d4216 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 @@ -266,7 +266,7 @@ private void processEvent(Object key, Event event) throws Exception { } // We then obtain the triggered action and add ActionTasks to the waiting processing // queue. - List triggerActions = eventRouter.getActionsTriggeredBy(event, agentPlan); + List triggerActions = eventRouter.getActionsTriggeredBy(event); if (triggerActions != null && !triggerActions.isEmpty()) { for (Action triggerAction : triggerActions) { stateManager.addActionTask(createActionTask(key, triggerAction, event)); diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/EventRouter.java b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/EventRouter.java index 7eecc5d26..ab6350ecc 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/EventRouter.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/EventRouter.java @@ -30,6 +30,7 @@ import org.apache.flink.agents.api.logger.LoggerType; import org.apache.flink.agents.plan.AgentPlan; import org.apache.flink.agents.plan.actions.Action; +import org.apache.flink.agents.runtime.condition.ActionRouter; import org.apache.flink.agents.runtime.eventlog.FileEventLogger; import org.apache.flink.agents.runtime.eventlog.Slf4jEventLogger; import org.apache.flink.agents.runtime.metrics.BuiltInMetrics; @@ -87,6 +88,13 @@ class EventRouter implements AutoCloseable { private final EventLogger eventLogger; private final List eventListeners; private final AgentPlan agentPlan; + + /** + * CEL-aware router; owns event → action resolution including CEL conditions. Built and opened + * at construction. + */ + private final ActionRouter actionRouter; + private StreamRecord reusedStreamRecord; private SegmentedQueue keySegmentQueue; private BuiltInMetrics builtInMetrics; @@ -101,6 +109,8 @@ class EventRouter implements AutoCloseable { this.inputIsJava = inputIsJava; this.eventLogger = eventLogger; this.eventListeners = new ArrayList<>(); + this.actionRouter = new ActionRouter(agentPlan); + this.actionRouter.open(); } /** @@ -216,8 +226,8 @@ OUT getOutputFromOutputEvent(Event event, PythonActionExecutor pythonActionExecu } } - List getActionsTriggeredBy(Event event, AgentPlan agentPlan) { - return agentPlan.getActionsTriggeredBy(event.getType()); + List getActionsTriggeredBy(Event event) { + return actionRouter.route(event); } /** @@ -308,6 +318,7 @@ private static EventLogger createEventLogger(AgentPlan agentPlan) { @Override public void close() throws Exception { + actionRouter.close(); if (eventLogger != null) { eventLogger.close(); } From 546906af10658f1bff499522f32618a8185693b8 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:11:49 +0800 Subject: [PATCH 09/13] [plan][runtime] Add tests + conformance fixtures for CEL triggerConditions --- .github/workflows/cel-conformance.yml | 137 +++++++++ .../cel-fixtures/cel_conformance_cases.yaml | 253 ++++++++++++++++ e2e-test/cel-fixtures/disallowed_macros.yaml | 39 +++ .../ActionConstructionFailureTest.java | 126 ++++++++ .../actions/ActionParsedConditionsTest.java | 188 ++++++++++++ .../plan/condition/ParsedConditionTest.java | 266 +++++++++++++++++ .../ActionJsonDeserializerTest.java | 14 + .../serializer/ActionJsonSerializerTest.java | 35 +-- .../action_legacy_listen_event_types.json | 10 + .../runtime/condition/ActionRouterTest.java | 202 +++++++++++++ .../condition/CelConditionEvaluatorTest.java | 211 ++++++++++++++ .../condition/CelExpressionFacadeTest.java | 274 ++++++++++++++++++ .../condition/CelResourceLimitsTest.java | 97 +++++++ .../runtime/operator/EventRouterTest.java | 2 +- 14 files changed, 1832 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/cel-conformance.yml create mode 100644 e2e-test/cel-fixtures/cel_conformance_cases.yaml create mode 100644 e2e-test/cel-fixtures/disallowed_macros.yaml create mode 100644 plan/src/test/java/org/apache/flink/agents/plan/actions/ActionConstructionFailureTest.java create mode 100644 plan/src/test/java/org/apache/flink/agents/plan/actions/ActionParsedConditionsTest.java create mode 100644 plan/src/test/java/org/apache/flink/agents/plan/condition/ParsedConditionTest.java create mode 100644 plan/src/test/resources/actions/action_legacy_listen_event_types.json create mode 100644 runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java create mode 100644 runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java create mode 100644 runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelExpressionFacadeTest.java create mode 100644 runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelResourceLimitsTest.java diff --git a/.github/workflows/cel-conformance.yml b/.github/workflows/cel-conformance.yml new file mode 100644 index 000000000..1178ce547 --- /dev/null +++ b/.github/workflows/cel-conformance.yml @@ -0,0 +1,137 @@ +# 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. + +name: CEL Cross-Language Conformance + +on: + push: + branches: [ main, 'release-*' ] + paths: + - 'runtime/src/main/java/org/apache/flink/agents/runtime/condition/**' + - 'runtime/src/test/java/org/apache/flink/agents/runtime/condition/**' + - 'e2e-test/cel-fixtures/**' + - 'plan/src/main/java/org/apache/flink/agents/plan/condition/**' + - 'python/flink_agents/runtime/condition/**' + - 'python/flink_agents/plan/condition/**' + - 'python/flink_agents/runtime/tests/test_local_runner_condition.py' + - 'python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py' + - '.github/workflows/cel-conformance.yml' + pull_request: + branches: [ main, 'release-*' ] + paths: + - 'runtime/src/main/java/org/apache/flink/agents/runtime/condition/**' + - 'runtime/src/test/java/org/apache/flink/agents/runtime/condition/**' + - 'e2e-test/cel-fixtures/**' + - 'plan/src/main/java/org/apache/flink/agents/plan/condition/**' + - 'python/flink_agents/runtime/condition/**' + - 'python/flink_agents/plan/condition/**' + - 'python/flink_agents/runtime/tests/test_local_runner_condition.py' + - 'python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py' + - '.github/workflows/cel-conformance.yml' + workflow_dispatch: + +jobs: + conformance: + name: cel-conformance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Fixtures live in e2e-test/cel-fixtures/ (single source of truth). + # Java reads them via pom.xml ; Python via symlink. + # No diff step needed — both sides reference the same physical files. + + # Cross-language check: CEL reserved keyword sets must be identical. + # Skipped automatically if the Python side hasn't landed yet (PR2 alone). + - name: Ensure CEL reserved keywords are identical across languages + run: | + set -euo pipefail + if [ ! -f python/flink_agents/plan/condition/cel_reserved.py ]; then + echo "Python cel_reserved.py not present yet; skipping cross-language check." + exit 0 + fi + python3 << 'PY' + import re, sys + + java_file = 'plan/src/main/java/org/apache/flink/agents/plan/condition/CelReserved.java' + macro_file = 'plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java' + py_file = 'python/flink_agents/plan/condition/cel_reserved.py' + + java_src = open(java_file).read() + java_names = set(re.findall(r'set\.add\("([^"]+)"\)', java_src)) + if 'CelMacroPolicy.DISALLOWED_MACROS' in java_src: + macro_src = open(macro_file).read() + m = re.search(r'DISALLOWED_MACROS\s*=\s*Set\.of\(([^)]+)\)', macro_src) + if m: + java_names |= set(re.findall(r'"([^"]+)"', m.group(1))) + + py_src = open(py_file).read() + fs = re.search(r'frozenset\s*\(\s*\{([^}]+)\}', py_src) + py_names = set(re.findall(r'"([^"]+)"', fs.group(1))) if fs else set() + + if java_names != py_names: + print('::error::CEL RESERVED_IDENTIFIERS differ between Java and Python.', file=sys.stderr) + print(f'Java only: {sorted(java_names - py_names)}', file=sys.stderr) + print(f'Python only: {sorted(py_names - java_names)}', file=sys.stderr) + sys.exit(1) + print(f'OK: {len(java_names)} reserved identifiers aligned across languages.') + PY + + # Java evaluator tests. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Maven repo + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2- + + - name: Run Java CEL tests + run: | + mvn -pl runtime -am -DskipTests=false \ + -Dtest='CelConditionEvaluatorTest,CelExpressionFacadeTest,AstRewriterTest,ActionRouterTest,CelResourceLimitsTest' \ + -Dsurefire.failIfNoSpecifiedTests=false \ + test + + # Python evaluator tests. + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Python project + run: | + python -m pip install --upgrade pip + python -m pip install -e 'python' pytest + + - name: Run Python CEL tests + run: | + set -euo pipefail + if [ ! -d python/flink_agents/runtime/condition/tests ]; then + echo "Python CEL test suite not present yet; skipping." + exit 0 + fi + pytest -q \ + python/flink_agents/plan/condition/tests \ + python/flink_agents/runtime/condition/tests \ + python/flink_agents/runtime/tests/test_local_runner_condition.py \ + python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py diff --git a/e2e-test/cel-fixtures/cel_conformance_cases.yaml b/e2e-test/cel-fixtures/cel_conformance_cases.yaml new file mode 100644 index 000000000..c99be4652 --- /dev/null +++ b/e2e-test/cel-fixtures/cel_conformance_cases.yaml @@ -0,0 +1,253 @@ +# CEL trigger condition conformance test corpus. +# Shared by Java (CelConditionEvaluatorTest) and Python (test_cel_pipeline_conformance). +# Each case is evaluated end-to-end: parse → validate → check → eval (no AST rewriting). +# +# Activation contract: type/EventType are framework-owned; attributes is the single-level +# merge of output.* > root > input.*; merged keys are also promoted to the top level; id is +# the user attribute when present, else the event UUID. + +- name: null_condition_passes + condition: null + event: { id: abc, type: test, attributes: {} } + expected: true + +- name: empty_condition_passes + condition: '' + event: { id: abc, type: test, attributes: {} } + expected: true + +- name: filter_by_type_match + condition: 'type == "_input_event"' + event: { id: abc, type: _input_event, attributes: {} } + expected: true + +- name: filter_by_type_mismatch + condition: 'type == "other"' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: filter_by_id + condition: 'id == "550e8400-e29b-41d4-a716-446655440000"' + event: { id: '550e8400-e29b-41d4-a716-446655440000', type: test, attributes: {} } + expected: true + +- name: in_operator_for_map_key + condition: '"score" in attributes && attributes["score"] >= 3' + event: { id: abc, type: test, attributes: { score: 5 } } + expected: true + +- name: in_operator_missing_key + condition: '"score" in attributes && attributes["score"] >= 3' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: has_macro_on_map + condition: 'has(attributes.score) && attributes.score >= 3' + event: { id: abc, type: test, attributes: { score: 5 } } + expected: true + +- name: has_macro_missing_key + condition: 'has(attributes.missing) && attributes.missing == true' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: nested_map_access + condition: 'has(attributes.meta) && attributes.meta.source == "api"' + event: { id: abc, type: test, attributes: { meta: { source: api } } } + expected: true + +- name: combined_type_and_attribute + condition: 'type == "review" && has(attributes.score) && attributes.score >= 3' + event: { id: abc, type: review, attributes: { score: 4 } } + expected: true + +- name: boolean_attribute + condition: 'has(attributes.success) && attributes.success == true' + event: { id: abc, type: test, attributes: { success: true } } + expected: true + +- name: string_attribute + condition: 'has(attributes.model) && attributes.model == "gpt-4"' + event: { id: abc, type: _chat_request_event, attributes: { model: gpt-4 } } + expected: true + +- name: numeric_comparison_less_than + condition: 'has(attributes.retry_count) && attributes.retry_count < 3' + event: { id: abc, type: test, attributes: { retry_count: 1 } } + expected: true + +- name: numeric_comparison_fails + condition: 'has(attributes.retry_count) && attributes.retry_count < 3' + event: { id: abc, type: test, attributes: { retry_count: 5 } } + expected: false + +- name: json_string_auto_parse_object + condition: 'has(attributes.input) && has(attributes.input.id) && attributes.input.id == "B000YFSR4W"' + event: + id: abc + type: _input_event + attributes: + input: '{"id": "B000YFSR4W", "review": "comfy fit"}' + expected: true + +- name: json_string_auto_parse_nested_mismatch + condition: 'has(attributes.input) && has(attributes.input.id) && attributes.input.id == "OTHER"' + event: + id: abc + type: _input_event + attributes: + input: '{"id": "B000YFSR4W", "review": "comfy fit"}' + expected: false + +- name: json_string_auto_parse_array + condition: 'has(attributes.tags) && attributes.tags[0] == "sports"' + event: + id: abc + type: test + attributes: + tags: '["sports", "shoes"]' + expected: true + +- name: plain_string_not_parsed + condition: 'has(attributes.name) && attributes.name == "hello world"' + event: { id: abc, type: test, attributes: { name: hello world } } + expected: true + +- name: invalid_json_string_kept_as_string + condition: 'has(attributes.broken) && attributes.broken == "{not json"' + event: { id: abc, type: test, attributes: { broken: '{not json' } } + expected: true + +- name: empty_attributes_has_returns_false + condition: 'has(attributes.key)' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: deeply_nested_json_string_3_levels + condition: 'has(attributes.data) && has(attributes.data.order) && attributes.data.order.id == "O100"' + event: + id: abc + type: test + attributes: + data: '{"order": {"id": "O100", "amount": 500}}' + expected: true + +- name: numeric_int_equals_int + condition: 'has(attributes.count) && attributes.count == 42' + event: { id: abc, type: test, attributes: { count: 42 } } + expected: true + +- name: or_condition_first_branch_true + condition: 'has(attributes.a) && attributes.a == 1 || has(attributes.b) && attributes.b == 2' + event: { id: abc, type: test, attributes: { a: 1 } } + expected: true + +- name: or_condition_second_branch_true + condition: '(has(attributes.a) && attributes.a == 1) || (has(attributes.b) && attributes.b == 2)' + event: { id: abc, type: test, attributes: { b: 2 } } + expected: true + +- name: dot_chain_deep_nested_access + condition: 'has(region.width) && region.width.score >= 80' + event: { id: abc, type: test, attributes: { region: { width: { score: 95 } } } } + expected: true + +- name: dot_chain_deep_nested_mismatch + condition: 'has(region.width) && region.width.score >= 80' + event: { id: abc, type: test, attributes: { region: { width: { score: 50 } } } } + expected: false + +- name: dot_chain_six_levels + condition: 'has(input.region) && input.region.width.score.month12 == 88' + event: + id: abc + type: test + attributes: + input: { region: { width: { score: { month12: 88 } } } } + expected: true + +- name: or_short_circuit_left_true_skips_right_error + condition: '(type == "hit") || (attributes.nope.deep > 3)' + event: { id: abc, type: hit, attributes: {} } + expected: true + +- name: and_short_circuit_has_guards_missing_field_access + condition: 'has(attributes.missing) && (attributes.missing.deep > 3)' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: empty_json_object_string_parsed_has_returns_false + condition: 'has(attributes.config.missing_key)' + event: { id: abc, type: test, attributes: { config: '{}' } } + expected: false + +- name: nested_json_string_recursively_parsed + condition: 'has(attributes.outer.inner) && attributes.outer.inner.id == 1' + event: + id: abc + type: test + attributes: + outer: '{"inner": "{\"id\": 1}"}' + expected: true + +- name: event_type_select_expr_folded_to_literal + condition: 'type == EventType.InputEvent' + event: { id: abc, type: _input_event, attributes: {} } + expected: true + +- name: int64_max_in_range + condition: 'attributes.x == 9223372036854775807' + event: { id: abc, type: test, attributes: { x: 9223372036854775807 } } + expected: true + +# ----- Flattened-activation contract (Method D) ----- + +- name: bare_root_attribute_access + condition: 'score >= 3' + event: { id: abc, type: test, attributes: { score: 5 } } + expected: true + +- name: has_on_bare_attribute_present + condition: 'has(score) && score >= 3' + event: { id: abc, type: test, attributes: { score: 5 } } + expected: true + +- name: has_on_bare_attribute_missing + condition: 'has(score) && score >= 3' + event: { id: abc, type: test, attributes: {} } + expected: false + +- name: output_subkey_wins_over_root_attribute + condition: 'k == "out"' + event: { id: abc, type: test, attributes: { k: root, output: { k: out } } } + expected: true + +- name: root_attribute_wins_over_input_subkey + condition: 'k == "root"' + event: { id: abc, type: test, attributes: { k: root, input: { k: in } } } + expected: true + +- name: user_id_attribute_overrides_event_uuid + condition: 'id == "tenant-42"' + event: { id: abc, type: test, attributes: { id: tenant-42 } } + expected: true + +- name: framework_type_wins_over_attribute_type + condition: 'type == "real"' + event: { id: abc, type: real, attributes: { type: fake } } + expected: true + +- name: attribute_type_still_reachable_via_attributes + condition: 'attributes.type == "fake"' + event: { id: abc, type: real, attributes: { type: fake } } + expected: true + +- name: flatten_is_single_level_only + condition: 'mylist.name == "x"' + event: { id: abc, type: test, attributes: { mylist: { name: x } } } + expected: true + +- name: event_type_constant_combined_with_bare_attribute + condition: 'type == EventType.InputEvent && score > 0.8' + event: { id: abc, type: _input_event, attributes: { score: 0.9 } } + expected: true diff --git a/e2e-test/cel-fixtures/disallowed_macros.yaml b/e2e-test/cel-fixtures/disallowed_macros.yaml new file mode 100644 index 000000000..1a755945b --- /dev/null +++ b/e2e-test/cel-fixtures/disallowed_macros.yaml @@ -0,0 +1,39 @@ +# CEL macro whitelist test fixture. +# Shared by Java (CelExpressionFacadeTest) and Python (test_cel_facade). +# Phase-1 policy: only `has()` is allowed; all others are rejected. + +reject: + # Free-form calls + - "exists(x, x > 0)" + - "exists_one(x, x == 1)" + - "all(x, x > 0)" + - "filter(x, x > 0)" + - "map(x, x + 1)" + # Method-style calls (most common CEL usage) + - "attributes.tags.exists(x, x == 'a')" + - "attributes.scores.all(s, s >= 60)" + - "attributes.items.filter(i, i.active == true)" + - "attributes.items.map(i, i.name)" + - "attributes.tags.exists_one(t, t == 'urgent')" + # Nested in logical operators + - "has(attributes.x) && attributes.list.all(t, t > 0)" + - "type == '_input_event' || attributes.items.exists(i, i.price > 100)" + # Nested macros inside macro arguments + - "attributes.groups.exists(g, g.members.all(m, m.active == true))" + +accept: + # has() is allowed + - "has(attributes.score)" + - "has(attributes.user_id) && attributes.score > 10" + # Basic operators, no macros + - "type == '_input_event'" + - "attributes.score >= 90" + - "attributes.count != 0 && type == '_output_event'" + # Macro name inside string literal (not a call) + - "type == 'exists_check_event'" + - "attributes.name == 'filter_result'" + - "attributes.label == 'has all filter map exists'" + # Macro name as identifier substring (not a call) + - "attributes.existing == true" + - "attributes.mapKey == 'value'" + - "attributes.filter_count > 5" diff --git a/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionConstructionFailureTest.java b/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionConstructionFailureTest.java new file mode 100644 index 000000000..bda5a6fb3 --- /dev/null +++ b/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionConstructionFailureTest.java @@ -0,0 +1,126 @@ +/* + * 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.plan.actions; + +import org.apache.flink.agents.plan.Function; +import org.apache.flink.agents.plan.JavaFunction; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** Pin the "silent → loud" contract: invalid CEL fails at Action construction time. */ +class ActionConstructionFailureTest { + + /** Reusable executor — Action's constructor only validates the signature. */ + private static Function execFn() throws Exception { + return new JavaFunction( + "org.apache.flink.agents.plan.actions.ActionConstructionFailureTest", + "noopExec", + new Class[] { + org.apache.flink.agents.api.Event.class, + org.apache.flink.agents.api.context.RunnerContext.class, + }); + } + + /** Dummy executor referenced reflectively from {@link #execFn()}. */ + public static void noopExec( + org.apache.flink.agents.api.Event e, + org.apache.flink.agents.api.context.RunnerContext c) {} + + // -- Loud failure on syntactically invalid CEL -- + + @Test + void construction_throwsOnInvalidCel_trailingOperator() throws Exception { + Function fn = execFn(); + assertThatThrownBy(() -> new Action("bad", fn, Collections.singletonList("type =="))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("type =="); + } + + @Test + void construction_throwsOnInvalidCel_danglingPlus() throws Exception { + Function fn = execFn(); + assertThatThrownBy(() -> new Action("bad", fn, Collections.singletonList("event.size +"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("event.size +"); + } + + @Test + void construction_throwsOnInvalidCel_unbalancedParen() throws Exception { + Function fn = execFn(); + assertThatThrownBy( + () -> new Action("bad", fn, Collections.singletonList("has(attributes.k"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void construction_throwsOnInvalidCel_inMixedList() throws Exception { + // The bad entry is sandwiched between two well-formed ones; the loud-failure contract + // says we don't silently drop it just because the rest of the list is fine. + Function fn = execFn(); + assertThatThrownBy( + () -> + new Action( + "bad", + fn, + Arrays.asList( + "InputEvent", "type ==", "has(attributes.k)"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("type =="); + } + + // -- Pre-existing structural validations -- + + @Test + void construction_throwsOnEmptyContains() throws Exception { + Function fn = execFn(); + assertThatThrownBy(() -> new Action("bad", fn, Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must have at least one entry"); + } + + @Test + void construction_throwsOnNullContains() throws Exception { + Function fn = execFn(); + assertThatThrownBy(() -> new Action("bad", fn, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must have at least one entry"); + } + + // -- Negative control: well-formed inputs -- + + @Test + void construction_succeedsForWellFormedInputs() throws Exception { + Function fn = execFn(); + assertDoesNotThrow( + () -> + new Action( + "ok", + fn, + Arrays.asList( + "InputEvent", + "_input_event", + "type == 'x' && id > 0", + "has(attributes.user_id)"))); + } +} diff --git a/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionParsedConditionsTest.java b/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionParsedConditionsTest.java new file mode 100644 index 000000000..0f829b018 --- /dev/null +++ b/plan/src/test/java/org/apache/flink/agents/plan/actions/ActionParsedConditionsTest.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.plan.actions; + +import org.apache.flink.agents.api.EventType; +import org.apache.flink.agents.plan.Function; +import org.apache.flink.agents.plan.JavaFunction; +import org.apache.flink.agents.plan.condition.ParsedCondition; +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; +import org.apache.flink.agents.plan.condition.ParsedCondition.TypeMatch; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests for parsedConditions: classify, round-trip, and derived views. */ +class ActionParsedConditionsTest { + + /** Reusable executor — Action's constructor only validates the signature. */ + private static Function execFn() throws Exception { + return new JavaFunction( + "org.apache.flink.agents.plan.actions.ActionParsedConditionsTest", + "noopExec", + new Class[] { + org.apache.flink.agents.api.Event.class, + org.apache.flink.agents.api.context.RunnerContext.class, + }); + } + + /** Dummy executor referenced reflectively from {@link #execFn()}. */ + public static void noopExec( + org.apache.flink.agents.api.Event e, + org.apache.flink.agents.api.context.RunnerContext c) {} + + private static List celSources(Action action) { + return action.getParsedConditions().stream() + .filter(pc -> pc instanceof CelExpression) + .map(pc -> ((CelExpression) pc).source()) + .collect(Collectors.toList()); + } + + @Test + void parsedConditions_singleTypeName_resolvesToTypeMatch() throws Exception { + Action a = new Action("a", execFn(), Collections.singletonList(EventType.InputEvent)); + + List pcs = a.getParsedConditions(); + assertEquals(1, pcs.size()); + assertTrue(pcs.get(0) instanceof TypeMatch); + + TypeMatch tm = (TypeMatch) pcs.get(0); + assertEquals(EventType.InputEvent, tm.source()); + assertEquals(EventType.InputEvent, tm.source()); + + assertEquals(Collections.singletonList(EventType.InputEvent), a.getListenEventTypes()); + assertEquals(Collections.emptyList(), celSources(a)); + } + + @Test + void parsedConditions_rawEventTypeString_isPreservedUnchanged() throws Exception { + Action a = new Action("a", execFn(), Collections.singletonList("_input_event")); + + ParsedCondition pc = a.getParsedConditions().get(0); + assertTrue(pc instanceof TypeMatch); + TypeMatch tm = (TypeMatch) pc; + assertEquals("_input_event", tm.source()); + assertEquals("_input_event", tm.source()); + } + + @Test + void parsedConditions_celExpression_resolvesToCelExpression() throws Exception { + Action a = + new Action( + "a", + execFn(), + Collections.singletonList("type == EventType.InputEvent && id > 0")); + + List pcs = a.getParsedConditions(); + assertEquals(1, pcs.size()); + assertTrue(pcs.get(0) instanceof CelExpression); + assertEquals("type == EventType.InputEvent && id > 0", pcs.get(0).source()); + + assertEquals(Collections.emptyList(), a.getListenEventTypes()); + assertEquals( + Collections.singletonList("type == EventType.InputEvent && id > 0"), celSources(a)); + } + + @Test + void parsedConditions_hasCall_isClassifiedAsCel() throws Exception { + Action a = new Action("a", execFn(), Collections.singletonList("has(attributes.user_id)")); + assertTrue(a.getParsedConditions().get(0) instanceof CelExpression); + } + + @Test + void parsedConditions_mixedList_preservesOrderAndKinds() throws Exception { + List contains = + Arrays.asList( + EventType.InputEvent, // TypeMatch + "_chat_response_event", // TypeMatch (raw) + "type == 'x' && id > 0", // CelExpression + EventType.OutputEvent, // TypeMatch + "has(attributes.k)"); // CelExpression + Action a = new Action("a", execFn(), contains); + + List pcs = a.getParsedConditions(); + assertEquals(5, pcs.size()); + + assertTrue(pcs.get(0) instanceof TypeMatch); + assertEquals(EventType.InputEvent, ((TypeMatch) pcs.get(0)).source()); + + assertTrue(pcs.get(1) instanceof TypeMatch); + assertEquals("_chat_response_event", ((TypeMatch) pcs.get(1)).source()); + + assertTrue(pcs.get(2) instanceof CelExpression); + + assertTrue(pcs.get(3) instanceof TypeMatch); + assertEquals(EventType.OutputEvent, ((TypeMatch) pcs.get(3)).source()); + + assertTrue(pcs.get(4) instanceof CelExpression); + + assertEquals( + Arrays.asList(EventType.InputEvent, "_chat_response_event", EventType.OutputEvent), + a.getListenEventTypes()); + assertEquals(Arrays.asList("type == 'x' && id > 0", "has(attributes.k)"), celSources(a)); + } + + @Test + void parsedConditions_rejectsNullOrEmptyEntry() throws Exception { + List withEmpty = Arrays.asList(EventType.InputEvent, "", "type == 'x'"); + assertThrows(IllegalArgumentException.class, () -> new Action("a", execFn(), withEmpty)); + + List withNull = Arrays.asList(EventType.InputEvent, null, "type == 'x'"); + assertThrows(IllegalArgumentException.class, () -> new Action("a", execFn(), withNull)); + } + + @Test + void parsedConditions_listIsUnmodifiable() throws Exception { + Action a = new Action("a", execFn(), Collections.singletonList(EventType.InputEvent)); + List pcs = a.getParsedConditions(); + assertNotNull(pcs); + assertThrows( + UnsupportedOperationException.class, + () -> pcs.add(new CelExpression("type == 'y'"))); + } + + @Test + void parsedConditions_hasCelCondition_isTrueWhenAnyCelEntryPresent() throws Exception { + Action a = new Action("a", execFn(), Arrays.asList(EventType.InputEvent, "type == 'x'")); + assertTrue(a.hasCelCondition()); + + Action b = new Action("b", execFn(), Collections.singletonList(EventType.InputEvent)); + assertEquals(false, b.hasCelCondition()); + } + + @Test + void parsedConditions_listenEventTypes_returnsTypeMatchNamesOnly() throws Exception { + Action a = + new Action( + "a", + execFn(), + Arrays.asList(EventType.InputEvent, "type == 'x'", EventType.OutputEvent)); + assertEquals( + Arrays.asList(EventType.InputEvent, EventType.OutputEvent), + a.getListenEventTypes()); + } +} diff --git a/plan/src/test/java/org/apache/flink/agents/plan/condition/ParsedConditionTest.java b/plan/src/test/java/org/apache/flink/agents/plan/condition/ParsedConditionTest.java new file mode 100644 index 000000000..a05f4bc4d --- /dev/null +++ b/plan/src/test/java/org/apache/flink/agents/plan/condition/ParsedConditionTest.java @@ -0,0 +1,266 @@ +/* + * 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.plan.condition; + +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; +import org.apache.flink.agents.plan.condition.ParsedCondition.TypeMatch; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ParsedCondition}: value semantics of TypeMatch/CelExpression, and {@link + * ParsedCondition#classify} (corpus equivalence, regression guards, and routing). + */ +class ParsedConditionTest { + + // ================================================================== + // Value semantics + // ================================================================== + + @Test + void typeMatch_storesSource() { + TypeMatch tm = new TypeMatch("_input_event"); + assertEquals("_input_event", tm.source()); + assertTrue(((ParsedCondition) tm) instanceof TypeMatch); + assertFalse(((ParsedCondition) tm) instanceof CelExpression); + } + + @Test + void typeMatch_equalityAndHash() { + TypeMatch a = new TypeMatch("_input_event"); + TypeMatch b = new TypeMatch("_input_event"); + TypeMatch c = new TypeMatch("_output_event"); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + } + + @Test + void celExpression_storesSource() { + CelExpression ce = new CelExpression("type == EventType.InputEvent && id > 0"); + assertEquals("type == EventType.InputEvent && id > 0", ce.source()); + assertFalse(((ParsedCondition) ce) instanceof TypeMatch); + assertTrue(((ParsedCondition) ce) instanceof CelExpression); + } + + @Test + void celExpression_equalityAndHash() { + CelExpression a = new CelExpression("type == 'x'"); + CelExpression b = new CelExpression("type == 'x'"); + CelExpression c = new CelExpression("type == 'y'"); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + } + + @Test + void celExpression_isNotEqualToTypeMatchWithSameSource() { + CelExpression ce = new CelExpression("InputEvent"); + TypeMatch tm = new TypeMatch("InputEvent"); + assertNotEquals(ce, tm); + assertNotEquals(tm, ce); + } + + // ================================================================== + // classify(): corpus. Format: input, expected_kind, expected_event_type (null for CEL). + // ================================================================== + + static Stream classifierCorpus() { + return Stream.of( + // ----- bare identifiers are TypeMatch (no short-name translation) ----- + Arguments.of("InputEvent", "TypeMatch", "InputEvent"), + Arguments.of("OutputEvent", "TypeMatch", "OutputEvent"), + Arguments.of("ChatRequestEvent", "TypeMatch", "ChatRequestEvent"), + Arguments.of("ChatResponseEvent", "TypeMatch", "ChatResponseEvent"), + Arguments.of("ToolRequestEvent", "TypeMatch", "ToolRequestEvent"), + Arguments.of("ToolResponseEvent", "TypeMatch", "ToolResponseEvent"), + Arguments.of( + "ContextRetrievalRequestEvent", + "TypeMatch", + "ContextRetrievalRequestEvent"), + Arguments.of( + "ContextRetrievalResponseEvent", + "TypeMatch", + "ContextRetrievalResponseEvent"), + + // ----- raw event-type strings & passthrough ----- + Arguments.of("_input_event", "TypeMatch", "_input_event"), + Arguments.of("_my_custom_event", "TypeMatch", "_my_custom_event"), + Arguments.of("CustomEvent", "TypeMatch", "CustomEvent"), + Arguments.of("in_progress_event", "TypeMatch", "in_progress_event"), + + // ----- equality / inequality ----- + Arguments.of("type == 'x'", "CelExpression", null), + Arguments.of("type != 'x'", "CelExpression", null), + + // ----- numeric comparisons ----- + Arguments.of("id > 0", "CelExpression", null), + Arguments.of("id < 0", "CelExpression", null), + Arguments.of("id >= 0", "CelExpression", null), + Arguments.of("id <= 0", "CelExpression", null), + + // ----- logical combinators ----- + Arguments.of("a == 1 && b == 2", "CelExpression", null), + Arguments.of("a == 1 || b == 2", "CelExpression", null), + + // ----- has() macro & in keyword ----- + Arguments.of("has(attributes.user_id)", "CelExpression", null), + Arguments.of("type in ['a', 'b']", "CelExpression", null), + + // ----- attribute access shapes ----- + Arguments.of("attributes.score >= 3", "CelExpression", null), + Arguments.of("attributes.score < 0", "CelExpression", null), + Arguments.of("attributes['k'] == 'v'", "CelExpression", null), + Arguments.of("type in ['_input_event']", "CelExpression", null), + + // ----- size / null / has compounds ----- + Arguments.of("size(attributes) > 0", "CelExpression", null), + Arguments.of("category == 'premium' && score > 5", "CelExpression", null), + Arguments.of("id != null", "CelExpression", null), + Arguments.of("has(attributes.k)", "CelExpression", null), + + // ----- EventType-qualified comparisons ----- + Arguments.of("type == EventType.InputEvent", "CelExpression", null), + Arguments.of("type == EventType.InputEvent && id > 0", "CelExpression", null)); + } + + @ParameterizedTest(name = "[{index}] classify(\"{0}\") -> {1}") + @MethodSource("classifierCorpus") + void astClassifierMatchesCorpus(String input, String expectedKind, String expectedEventType) { + ParsedCondition pc = ParsedCondition.classify(input); + + switch (expectedKind) { + case "TypeMatch": + assertThat(pc) + .as("expected TypeMatch for input %s", input) + .isInstanceOf(TypeMatch.class); + assertThat(pc.source()) + .as("source must round-trip unchanged for input %s", input) + .isEqualTo(input); + assertThat(((TypeMatch) pc).source()) + .as("eventType disagreement on input %s", input) + .isEqualTo(expectedEventType); + break; + case "CelExpression": + assertThat(pc) + .as("expected CelExpression for input %s", input) + .isInstanceOf(CelExpression.class); + assertThat(pc.source()) + .as("source must round-trip unchanged for input %s", input) + .isEqualTo(input); + assertThat(expectedEventType) + .as( + "CelExpression cases must declare null expected_event_type (input %s)", + input) + .isNull(); + break; + default: + throw new IllegalStateException( + "Unknown expected_kind '" + expectedKind + "' for input " + input); + } + } + + // Regression: inputs the old regex classifier silently mis-routed as TypeMatch. + + static Stream formerlySilentRegressions() { + return Stream.of( + "event.someFlag", // SELECT root + "!event.flag", // CALL root, op `!_` + "myBoolFunc()", // CALL root, function call + "'OrderEvent'", // CONSTANT root, string literal + "true", // CONSTANT root, boolean literal + "event.size + 1 > 0" // CALL root, comparison + ); + } + + @ParameterizedTest(name = "[{index}] {0} -> CelExpression (was silent TypeMatch)") + @MethodSource("formerlySilentRegressions") + void fixesSilentRegexMisclassifications(String input) { + ParsedCondition pc = ParsedCondition.classify(input); + assertThat(pc) + .as( + "input %s must classify as CelExpression — was silently TypeMatch under the" + + " regex classifier", + input) + .isInstanceOf(CelExpression.class); + assertThat(pc.source()).isEqualTo(input); + } + + // -- Failure mode: silent → loud -- + + @Test + void invalidCelThrows_atClassifyTime() { + assertThatThrownBy(() -> ParsedCondition.classify("type ==")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CEL expression"); + assertThatThrownBy(() -> ParsedCondition.classify("event.size +")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CEL expression"); + } + + @Test + void rejectsNullOrEmptyInput() { + assertThatThrownBy(() -> ParsedCondition.classify(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-null"); + assertThatThrownBy(() -> ParsedCondition.classify("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-null"); + } + + // -- Reserved-keyword rejection -- + + @Test + void rejectsReservedKeywordAsEventTypeName() { + // "type" is a framework-owned activation variable; using it as a bare event type must + // fail loudly. + assertThatThrownBy(() -> ParsedCondition.classify("type")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved keyword") + .hasMessageContaining("@action(\"type == "); + } + + @Test + void idIsNotReservedAndClassifiesAsTypeMatch() { + // "id" is user-overridable, not framework-reserved — a bare "id" entry is a legal event + // type alias. + ParsedCondition pc = ParsedCondition.classify("id"); + assertThat(pc).isInstanceOf(TypeMatch.class); + assertThat(pc.source()).isEqualTo("id"); + } + + @Test + void hasOnBareAttributeNameClassifiesAsCelExpression() { + // Custom has() macro accepts bare attribute names at parse time. + ParsedCondition pc = ParsedCondition.classify("has(score)"); + assertThat(pc).isInstanceOf(CelExpression.class); + assertThat(pc.source()).isEqualTo("has(score)"); + } +} diff --git a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializerTest.java index 8f32d512b..cecdff190 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonDeserializerTest.java @@ -136,4 +136,18 @@ public void testDeserializePythonConfigPreservesPrimitiveTypes() throws IOExcept .containsKey("extra"); assertNull(result.get("extra")); } + + @Test + public void testDeserializeLegacyListenEventTypes() throws Exception { + String json = Utils.readJsonFromResource("actions/action_legacy_listen_event_types.json"); + + ObjectMapper mapper = new ObjectMapper(); + Action action = mapper.readValue(json, Action.class); + + assertEquals("legacyAction", action.getName()); + assertInstanceOf(JavaFunction.class, action.getExec()); + assertEquals(1, action.getListenEventTypes().size()); + assertEquals(InputEvent.EVENT_TYPE, action.getListenEventTypes().get(0)); + assertThat(action.getTriggerConditions()).containsExactly(InputEvent.EVENT_TYPE); + } } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java index 766776eca..2f604abe1 100644 --- a/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java +++ b/plan/src/test/java/org/apache/flink/agents/plan/serializer/ActionJsonSerializerTest.java @@ -38,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** Test for {@link ActionJsonSerializer}. */ @@ -148,27 +149,19 @@ public void testSerializeMultipleEventTypes() throws Exception { } @Test - public void testSerializeEmptyEventTypes() throws Exception { - // Create a JavaFunction - JavaFunction function = - new JavaFunction( - "org.apache.flink.agents.plan.TestAction", - "legal", - new Class[] {Event.class, RunnerContext.class}); - - // Create an Action with an empty event types list - Action action = new Action("emptyEventsAction", function, Collections.emptyList()); - - // Serialize the action to JSON - String json = new ObjectMapper().writeValueAsString(action); - - // Verify the JSON contains the expected fields - assertTrue( - json.contains("\"name\":\"emptyEventsAction\""), - "JSON should contain the action name"); - assertTrue( - json.contains("\"trigger_conditions\":[]"), - "JSON should contain an empty trigger conditions array"); + public void testSerializeEmptyEventTypes() { + // Empty trigger conditions are now rejected at Action construction time + // (the CEL feature requires at least one entry to evaluate against). + assertThrows( + IllegalArgumentException.class, + () -> { + JavaFunction function = + new JavaFunction( + "org.apache.flink.agents.plan.TestAction", + "legal", + new Class[] {Event.class, RunnerContext.class}); + new Action("emptyEventsAction", function, Collections.emptyList()); + }); } @Test diff --git a/plan/src/test/resources/actions/action_legacy_listen_event_types.json b/plan/src/test/resources/actions/action_legacy_listen_event_types.json new file mode 100644 index 000000000..87a60c05f --- /dev/null +++ b/plan/src/test/resources/actions/action_legacy_listen_event_types.json @@ -0,0 +1,10 @@ +{ + "name": "legacyAction", + "exec": { + "func_type": "JavaFunction", + "qualname": "org.apache.flink.agents.plan.TestAction", + "method_name": "legal", + "parameter_types": ["org.apache.flink.agents.api.Event", "org.apache.flink.agents.api.context.RunnerContext"] + }, + "listen_event_types": ["_input_event"] +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java new file mode 100644 index 000000000..4f1b5ac41 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java @@ -0,0 +1,202 @@ +/* + * 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.condition; + +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Unit tests for ActionRouter: typed-only, CEL-only, mixed-dedup, and lifecycle. */ +class ActionRouterTest { + + /** Stub handler — only its signature matters for {@link Action} construction. */ + public static void handlerStub(Event event, RunnerContext ctx) {} + + private ActionRouter router; + + @AfterEach + void tearDown() { + if (router != null) { + router.close(); + router = null; + } + // Reset facade cache so the "no CEL in plan ⇔ evaluator is null" invariant stays clean + // under repeated runs. + CelExpressionFacade.clearProgramCacheForTests(); + } + + // -- Helpers -- + + private static JavaFunction execStub() throws Exception { + return new JavaFunction( + ActionRouterTest.class.getName(), + "handlerStub", + new Class[] {Event.class, RunnerContext.class}); + } + + /** Builds an AgentPlan mirroring extraction-time indexing. */ + private static AgentPlan planOf(Action... actions) { + Map byName = new HashMap<>(); + Map> byType = new HashMap<>(); + for (Action a : actions) { + byName.put(a.getName(), a); + List typeNames = a.getListenEventTypes(); + for (String t : typeNames) { + byType.computeIfAbsent(t, k -> new java.util.ArrayList<>()).add(a); + } + } + return new AgentPlan(byName, byType); + } + + // -- Constructor + lifecycle -- + + @Test + void constructor_rejectsNullPlan() { + assertThatThrownBy(() -> new ActionRouter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("agentPlan"); + } + + @Test + void close_isIdempotent() throws Exception { + Action a = new Action("celOnly", execStub(), List.of("type == 'x'"), null); + router = new ActionRouter(planOf(a)); + router.open(); + router.close(); + router.close(); // must not throw + } + + // -- Routing: typed-only fast path -- + + @Test + void route_typedOnly_returnsTypedHitsWithoutCelEvaluation() throws Exception { + Action onInput = new Action("onInput", execStub(), List.of(InputEvent.EVENT_TYPE), null); + Action onOutput = new Action("onOutput", execStub(), List.of(OutputEvent.EVENT_TYPE), null); + router = new ActionRouter(planOf(onInput, onOutput)); + router.open(); + + List matched = router.route(new InputEvent("hello")); + assertThat(matched).containsExactly(onInput); + } + + @Test + void route_typedOnly_returnsEmptyListForUnknownEventType() throws Exception { + Action onInput = new Action("onInput", execStub(), List.of(InputEvent.EVENT_TYPE), null); + router = new ActionRouter(planOf(onInput)); + router.open(); + + // Unknown event type, no CEL candidates → empty trigger set. + Event other = new Event("_some_other_event") {}; + assertThat(router.route(other)).isEmpty(); + } + + // -- Routing: CEL-only slow path -- + + @Test + void route_celOnly_firesActionWhenAnyCelExpressionMatches() throws Exception { + // Two CEL exprs on one action — within-action OR semantics; first matches. + Action onPriceOrType = + new Action( + "onPriceOrType", + execStub(), + Arrays.asList( + "type == '_input_event'", + "has(attributes.priceX) && attributes.priceX > 100"), + null); + router = new ActionRouter(planOf(onPriceOrType)); + router.open(); + + assertThat(router.route(new InputEvent("anything"))).containsExactly(onPriceOrType); + } + + @Test + void route_celOnly_doesNotFireWhenNoCelExpressionMatches() throws Exception { + // CEL gates on attributes.priceX — field absent in InputEvent attributes. + Action onPrice = + new Action( + "onPrice", + execStub(), + List.of("has(attributes.priceX) && attributes.priceX > 100"), + null); + router = new ActionRouter(planOf(onPrice)); + router.open(); + + assertThat(router.route(new InputEvent("anything"))).isEmpty(); + } + + @Test + void route_celOnly_shortCircuitsOnFirstMatchingExpression() throws Exception { + // Two matching exprs on one action — still fires it exactly once (OR semantics). + Action onMulti = + new Action( + "onMulti", + execStub(), + Arrays.asList("type == '_input_event'", "type == '_input_event'"), + null); + router = new ActionRouter(planOf(onMulti)); + router.open(); + + List matched = router.route(new InputEvent("x")); + assertThat(matched).containsExactly(onMulti); + } + + // -- Routing: mixed typed + CEL → dedup -- + + @Test + void route_mixedActionAppearsExactlyOnce() throws Exception { + // Type + CEL both match the same action — must dedupe to one entry (H1 regression). + Action mixed = + new Action( + "mixed", + execStub(), + Arrays.asList(InputEvent.EVENT_TYPE, "type == '_input_event'"), + null); + router = new ActionRouter(planOf(mixed)); + router.open(); + + List matched = router.route(new InputEvent("x")); + assertThat(matched).containsExactly(mixed); // exactly once + } + + @Test + void route_typedAndCelMix_preservesTypedFirstThenCelOrdering() throws Exception { + // Contract: typed hits ordered before CEL hits. + Action pureType = new Action("pureType", execStub(), List.of(InputEvent.EVENT_TYPE), null); + Action pureCel = new Action("pureCel", execStub(), List.of("type == '_input_event'"), null); + router = new ActionRouter(planOf(pureType, pureCel)); + router.open(); + + List matched = router.route(new InputEvent("x")); + assertThat(matched).containsExactly(pureType, pureCel); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java new file mode 100644 index 000000000..1f44e2c11 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java @@ -0,0 +1,211 @@ +/* + * 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.condition; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Unit tests for {@link CelConditionEvaluator}. */ +class CelConditionEvaluatorTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(new YAMLFactory()); + + private CelConditionEvaluator evaluator; + + /** Test case from conformance JSON. */ + static class ConformanceTestCase { + String name; + String condition; + Map event; + boolean expected; + + public String getName() { + return name; + } + + public String getCondition() { + return condition; + } + + public Map getEvent() { + return event; + } + + public boolean isExpected() { + return expected; + } + } + + @BeforeEach + void setUp() throws IOException { + evaluator = new CelConditionEvaluator(); + // Pre-compile every condition from the conformance JSON. + List testCases = loadConformanceCases(); + Collection conditions = new ArrayList<>(); + for (ConformanceTestCase tc : testCases) { + if (tc.getCondition() != null && !tc.getCondition().isEmpty()) { + conditions.add(new CelExpression(tc.getCondition())); + } + } + evaluator.initPrograms(conditions); + } + + private static List loadConformanceCases() throws IOException { + try (InputStream is = + CelConditionEvaluatorTest.class.getResourceAsStream( + "/cel_conformance_cases.yaml")) { + if (is == null) { + throw new IOException("cel_conformance_cases.yaml not found"); + } + return OBJECT_MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private Event buildEvent(Map eventData) { + String id = (String) eventData.get("id"); + String type = (String) eventData.get("type"); + @SuppressWarnings("unchecked") + Map attributes = + (Map) eventData.getOrDefault("attributes", new HashMap<>()); + UUID uuid; + try { + // Reuse the raw id when it's a valid UUID so id-based filters work in conformance + // cases. + uuid = UUID.fromString(id); + } catch (IllegalArgumentException e) { + uuid = UUID.nameUUIDFromBytes(id.getBytes()); + } + Event event = new Event(uuid, type, attributes); + return event; + } + + static Stream conformanceCases() throws IOException { + return loadConformanceCases().stream(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("conformanceCases") + void testConformanceCases(ConformanceTestCase testCase) { + Event event = buildEvent(testCase.getEvent()); + Map activation = evaluator.createActivation(event); + CelExpression expr = + (testCase.getCondition() == null || testCase.getCondition().isEmpty()) + ? null + : new CelExpression(testCase.getCondition()); + boolean result = evaluator.evaluate(expr, activation); + assertThat(result).isEqualTo(testCase.isExpected()); + } + + @Test + void testFailPolicyThrowsOnEvaluationError() { + CelConditionEvaluator failEvaluator = + new CelConditionEvaluator(CelConditionEvaluator.EvaluationFailurePolicy.FAIL); + // Pre-compile a condition that will fail at runtime + CelExpression cond = new CelExpression("attributes.nonexistent > 3"); + failEvaluator.initPrograms(List.of(cond)); + + Event event = new Event("test_type"); + Map activation = failEvaluator.createActivation(event); + + assertThatThrownBy(() -> failEvaluator.evaluate(cond, activation)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CEL condition evaluation failed"); + } + + @Test + void testInitProgramsInvalidExpressionThrows() { + CelConditionEvaluator testEvaluator = new CelConditionEvaluator(); + assertThatThrownBy(() -> testEvaluator.initPrograms(List.of(new CelExpression("type ==")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CEL condition expression"); + } + + @Test + void testNormalizeValueBigIntegerWithinLongRange() { + // BigInteger within Long range should pass through normalizeValue cleanly. + CelConditionEvaluator testEvaluator = new CelConditionEvaluator(); + CelExpression condition = new CelExpression("attributes.amount > 1000"); + testEvaluator.initPrograms(List.of(condition)); + + Event event = new Event("test_type"); + event.getAttributes().put("amount", java.math.BigInteger.valueOf(9999999999999L)); + + Map activation = testEvaluator.createActivation(event); + assertThat(testEvaluator.evaluate(condition, activation)).isTrue(); + } + + @Test + void testNormalizeValueBigIntegerOverflowThrows() { + // BigInteger exceeding Long.MAX_VALUE should throw IllegalArgumentException + Event event = new Event("test_type"); + java.math.BigInteger overflow = + java.math.BigInteger.valueOf(Long.MAX_VALUE).multiply(java.math.BigInteger.TEN); + event.getAttributes().put("amount", overflow); + + assertThatThrownBy(() -> evaluator.createActivation(event)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("overflows int64"); + } + + @Test + void testNormalizeValueBigDecimalConvertedToDouble() { + CelConditionEvaluator testEvaluator = new CelConditionEvaluator(); + CelExpression condition = new CelExpression("attributes.score > 3.14"); + testEvaluator.initPrograms(List.of(condition)); + + Event event = new Event("test_type"); + event.getAttributes().put("score", new java.math.BigDecimal("99.99")); + + Map activation = testEvaluator.createActivation(event); + assertThat(testEvaluator.evaluate(condition, activation)).isTrue(); + } + + @Test + void evaluate_warnAndSkipReturnsFalseOnRuntimeError() { + // Positive assertion for the default WARN_AND_SKIP policy: runtime errors are swallowed + // and the action is treated as not matching, with the failure surfaced via WARN log. + CelConditionEvaluator e = new CelConditionEvaluator(); + CelExpression cond = new CelExpression("attributes.nope > 3"); + e.initPrograms(List.of(cond)); + + Event event = new Event("test_type"); + Map activation = e.createActivation(event); + assertThat(e.evaluate(cond, activation)).isFalse(); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelExpressionFacadeTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelExpressionFacadeTest.java new file mode 100644 index 000000000..4eb41b8d0 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelExpressionFacadeTest.java @@ -0,0 +1,274 @@ +/* + * 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.condition; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import org.apache.flink.agents.plan.condition.CelMacroPolicy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for {@link CelExpressionFacade}: parse validation and toProgram compilation + caching. */ +class CelExpressionFacadeTest { + + @BeforeEach + void clearCache() { + // Per-process cache keyed by source string — clear between tests so cache assertions are + // deterministic. + CelExpressionFacade.clearProgramCacheForTests(); + } + + @ParameterizedTest(name = "rejects disallowed macro {0}") + @ValueSource( + strings = { + "[1, 2, 3].all(x, x > 0)", + "[1, 2, 3].exists(x, x > 0)", + "attributes.tags.exists(x, x == 'a')", + "has(attributes.x) && attributes.list.all(t, t > 0)" + }) + void toProgram_rejectsDisallowedMacroCalls(String source) { + assertThatThrownBy(() -> CelExpressionFacade.toProgram(source)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("disallowed macro"); + } + + @Test + void disallowedMacroMessage_isByteIdenticalToPython() { + // Must match Python ``cel_facade._format_disallowed_message`` byte-for-byte. CI + // ``cel-conformance.yml`` validates this alignment via diff. + String expected = + "CEL expression uses disallowed macro 'all': \"test_source\". " + + "Only allows: [has]."; + assertThat(CelMacroPolicy.formatDisallowedMessage("all", "test_source")) + .isEqualTo(expected); + } + + @Test + void toProgram_acceptsMacroNameInStringLiteral() { + // "exists" inside a string literal is not a macro call — should compile fine. + CelRuntime.Program program = CelExpressionFacade.toProgram("type == 'exists_check_event'"); + assertThat(program).isNotNull(); + } + + @Test + void toProgram_acceptsMacroNameAsFieldSubstring() { + // "existing" contains "exist" but is not a macro call. + CelRuntime.Program program = CelExpressionFacade.toProgram("existing == true"); + assertThat(program).isNotNull(); + } + + @Test + void toProgram_doesNotMisclassifyFieldNamedAllAsDisallowedMacro() { + // `attributes.all` is a field access, not a macro call. AST-based check (vs. old + // error-message string match) won't false-positive on the substring "all". + CelRuntime.Program program = CelExpressionFacade.toProgram("attributes.all == 'value'"); + assertThat(program).isNotNull(); + } + + @Test + void toProgram_findsDisallowedMacroInDeeplyNestedExpression() { + // Macro buried inside arg of another macro arg — AST walker must find it. + assertThatThrownBy( + () -> + CelExpressionFacade.toProgram( + "has(attributes.x) && (attributes.y > 0 || attributes.tags.exists(t, t == 'a'))")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("'exists'"); + } + + @Test + void parse_rejectsHasOnNonFieldArgument() { + // has(1 + 1) is neither a field selection nor an attribute name. + assertThatThrownBy(() -> CelExpressionFacade.toProgram("has(1 + 1)")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid argument to has() macro"); + } + + // -- EventType map access -- + + @Test + void toProgram_unknownEventTypeConstantFailsAtPlanLoad() { + assertThatThrownBy(() -> CelExpressionFacade.toProgram("type == EventType.NotARealEvent")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown EventType constant: EventType.NotARealEvent"); + } + + // -- Dynamic identifier declaration -- + + @Test + void toProgram_userSuppliedIdSupportsNonStringComparison() throws CelEvaluationException { + // id is declared DYN: users may supply numeric ids via attributes. + CelRuntime.Program program = CelExpressionFacade.toProgram("id > 0"); + Map activation = new HashMap<>(); + activation.put("id", 42L); + assertThat(program.eval(activation)).isEqualTo(true); + } + + // -- toProgram(String) -- + + @Test + void toProgram_fromString_returnsRunnableProgram() throws CelEvaluationException { + CelRuntime.Program program = CelExpressionFacade.toProgram("type == 'a'"); + Map activation = new HashMap<>(); + activation.put("id", "42"); + activation.put("type", "a"); + activation.put("attributes", new HashMap()); + Object result = program.eval(activation); + assertThat(result).isEqualTo(true); + } + + @Test + void toProgram_fromString_throwsOnInvalidSource() { + assertThatThrownBy(() -> CelExpressionFacade.toProgram("type ==")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CEL condition expression"); + } + + @Test + void toProgram_fromString_throwsOnNullOrEmpty() { + assertThatThrownBy(() -> CelExpressionFacade.toProgram((String) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CelExpressionFacade.toProgram("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void toProgram_fromString_isCachedBySource() { + CelRuntime.Program first = CelExpressionFacade.toProgram("type == 'x'"); + CelRuntime.Program second = CelExpressionFacade.toProgram("type == 'x'"); + assertThat(second).isSameAs(first); // cache hit + } + + // -- String functions: contains / startsWith / endsWith / matches -- + + static Stream stringFunctionCases() { + return Stream.of( + Arguments.of("name.contains(\"foo\")", "name", "hello_foo_bar", true), + Arguments.of("name.contains(\"xyz\")", "name", "hello_foo_bar", false), + Arguments.of("name.contains(\"\")", "name", "anything", true), + Arguments.of("name.startsWith(\"pre_\")", "name", "pre_order_123", true), + Arguments.of("name.startsWith(\"pre_\")", "name", "order_pre_123", false), + Arguments.of("name.startsWith(\"\")", "name", "anything", true), + Arguments.of("name.endsWith(\".json\")", "name", "config.json", true), + Arguments.of("name.endsWith(\".json\")", "name", "config.yaml", false), + Arguments.of("name.matches(\"^order_[0-9]+$\")", "name", "order_42", true), + Arguments.of("name.matches(\"^order_[0-9]+$\")", "name", "order_abc", false), + Arguments.of("name.matches(\"[\")", "name", "test", CelEvaluationException.class)); + } + + @ParameterizedTest(name = "{0} on {1}={2} → {3}") + @MethodSource("stringFunctionCases") + void stringFunctions_evaluateOrThrow( + String source, String attrName, String attrValue, Object expected) + throws CelEvaluationException { + CelRuntime.Program program = CelExpressionFacade.toProgram(source); + Map attrs = new HashMap<>(); + attrs.put(attrName, attrValue); + Map activation = new HashMap<>(); + activation.put("id", "1"); + activation.put("type", "t"); + activation.put("attributes", attrs); + // Flattened contract: bare identifiers read from the activation top level. + activation.put(attrName, attrValue); + + if (expected instanceof Class) { + @SuppressWarnings("unchecked") + Class exceptionClass = (Class) expected; + assertThatThrownBy(() -> program.eval(activation)).isInstanceOf(exceptionClass); + } else { + assertThat(program.eval(activation)).isEqualTo(expected); + } + } + + // -- Fixture-driven tests from disallowed_macros.yaml -- + + private static final String FIXTURE_RESOURCE = "/disallowed_macros.yaml"; + + @SuppressWarnings("unchecked") + private static Map> loadMacroFixture() throws IOException { + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + InputStream is = CelExpressionFacadeTest.class.getResourceAsStream(FIXTURE_RESOURCE); + assertThat(is) + .as("Shared fixture resource must exist on classpath: %s", FIXTURE_RESOURCE) + .isNotNull(); + return yamlMapper.readValue(is, Map.class); + } + + static Stream rejectFixtureCases() throws IOException { + return loadMacroFixture().get("reject").stream().map(Arguments::of); + } + + static Stream acceptFixtureCases() throws IOException { + return loadMacroFixture().get("accept").stream().map(Arguments::of); + } + + @ParameterizedTest(name = "REJECT: {0}") + @MethodSource("rejectFixtureCases") + void fixture_rejectDisallowedMacro(String expression) { + assertThatThrownBy(() -> CelExpressionFacade.toProgram(expression)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("disallowed macro"); + } + + @ParameterizedTest(name = "ACCEPT: {0}") + @MethodSource("acceptFixtureCases") + void fixture_acceptAllowedExpression(String expression) { + CelRuntime.Program program = CelExpressionFacade.toProgram(expression); + assertThat(program).isNotNull(); + } + + // -- Resource guards -- + + @Nested + class ResourceGuards { + @Test + void parse_oversizedSource_rejected() { + String huge = "true" + " || true".repeat(2000); // ~16K chars + assertThatThrownBy(() -> CelExpressionFacade.parse(huge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CEL expression"); + } + + @Test + void parse_overlyNestedExpression_rejected() { + // Parenthesis nesting reliably hits maxParseRecursionDepth (member-access chains + // are left-recursive in ANTLR4 and parsed iteratively, so they don't). + String deep = "(".repeat(40) + "true" + ")".repeat(40); + assertThatThrownBy(() -> CelExpressionFacade.parse(deep)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelResourceLimitsTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelResourceLimitsTest.java new file mode 100644 index 000000000..84b2960e2 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelResourceLimitsTest.java @@ -0,0 +1,97 @@ +/* + * 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.condition; + +import org.apache.flink.agents.api.Event; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Tests for CEL runtime resource limits: PROGRAM_CACHE LRU + normalize depth cap. */ +class CelResourceLimitsTest { + + @BeforeEach + void clearCache() { + CelExpressionFacade.clearProgramCacheForTests(); + } + + @Test + void programCache_evictsAtMaxSize() { + // Insert MAX_SIZE + 1 unique sources; cache must cap exactly at MAX_SIZE + // (LinkedHashMap.removeEldestEntry is strict, unlike Caffeine's approximate TinyLFU). + for (int i = 0; i <= CelExpressionFacade.PROGRAM_CACHE_MAX_SIZE; i++) { + CelExpressionFacade.toProgram("attributes.field_" + i + " == " + i); + } + long size = CelExpressionFacade.programCacheSizeForTests(); + assertThat(size) + .as( + "program cache must cap at exactly %d, got %d", + CelExpressionFacade.PROGRAM_CACHE_MAX_SIZE, size) + .isEqualTo(CelExpressionFacade.PROGRAM_CACHE_MAX_SIZE); + } + + @Test + @SuppressWarnings("unchecked") + void normalizeValue_preservesDeepJsonStringAsPlain() { + // Build a nested map of depth (MAX + 2) with a JSON-shaped string at the bottom. + String innerJson = "{\"deep_key\":\"deep_value\"}"; + Object inner = innerJson; + for (int i = 0; i <= CelConditionEvaluator.MAX_NORMALIZE_DEPTH; i++) { + Map wrap = new HashMap<>(); + wrap.put("wrap", inner); + inner = wrap; + } + Event event = new Event("_input_event", (Map) inner); + + CelConditionEvaluator evaluator = new CelConditionEvaluator(); + Map activation = evaluator.createActivation(event); + Map attrs = (Map) activation.get("attributes"); + + Object cur = attrs; + for (int i = 0; i <= CelConditionEvaluator.MAX_NORMALIZE_DEPTH; i++) { + assertThat(cur).as("layer %d must still be a Map", i).isInstanceOf(Map.class); + Map m = (Map) cur; + cur = m.get("wrap"); + } + assertThat(cur) + .as("deep JSON string must NOT be parsed once past depth limit") + .isInstanceOf(String.class) + .isEqualTo(innerJson); + } + + @Test + @SuppressWarnings("unchecked") + void normalizeValue_stillExpandsShallowJsonString() { + Map attrs = new HashMap<>(); + attrs.put("input", "{\"foo\":\"bar\",\"n\":42}"); + Event event = new Event("_input_event", attrs); + + CelConditionEvaluator evaluator = new CelConditionEvaluator(); + Map activation = evaluator.createActivation(event); + Map activationAttrs = (Map) activation.get("attributes"); + Object input = activationAttrs.get("input"); + assertThat(input) + .as("shallow JSON-shaped string must still be parsed into a Map") + .isInstanceOf(Map.class); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/EventRouterTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/EventRouterTest.java index 97f994b42..da6bafd14 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/EventRouterTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/EventRouterTest.java @@ -72,7 +72,7 @@ void getActionsTriggeredByReturnsActionsForJavaEventClass() throws Exception { EventRouter router = new EventRouter<>(plan, /* inputIsJava */ true); - List triggered = router.getActionsTriggeredBy(new InputEvent(0L), plan); + List triggered = router.getActionsTriggeredBy(new InputEvent(0L)); assertThat(triggered).containsExactly(action); } From abead34970e48d299802c554540f687312d4a4b7 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:50:43 +0800 Subject: [PATCH 10/13] [dist] Bundle dev.cel license entries; defer cross-language workflow to PR3 --- .github/workflows/cel-conformance.yml | 137 ------------------ dist/src/main/resources/META-INF/NOTICE | 4 + .../META-INF/licenses/LICENSE.antlr4-runtime | 28 ++++ .../resources/META-INF/licenses/LICENSE.re2j | 32 ++++ 4 files changed, 64 insertions(+), 137 deletions(-) delete mode 100644 .github/workflows/cel-conformance.yml create mode 100644 dist/src/main/resources/META-INF/licenses/LICENSE.antlr4-runtime create mode 100644 dist/src/main/resources/META-INF/licenses/LICENSE.re2j diff --git a/.github/workflows/cel-conformance.yml b/.github/workflows/cel-conformance.yml deleted file mode 100644 index 1178ce547..000000000 --- a/.github/workflows/cel-conformance.yml +++ /dev/null @@ -1,137 +0,0 @@ -# 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. - -name: CEL Cross-Language Conformance - -on: - push: - branches: [ main, 'release-*' ] - paths: - - 'runtime/src/main/java/org/apache/flink/agents/runtime/condition/**' - - 'runtime/src/test/java/org/apache/flink/agents/runtime/condition/**' - - 'e2e-test/cel-fixtures/**' - - 'plan/src/main/java/org/apache/flink/agents/plan/condition/**' - - 'python/flink_agents/runtime/condition/**' - - 'python/flink_agents/plan/condition/**' - - 'python/flink_agents/runtime/tests/test_local_runner_condition.py' - - 'python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py' - - '.github/workflows/cel-conformance.yml' - pull_request: - branches: [ main, 'release-*' ] - paths: - - 'runtime/src/main/java/org/apache/flink/agents/runtime/condition/**' - - 'runtime/src/test/java/org/apache/flink/agents/runtime/condition/**' - - 'e2e-test/cel-fixtures/**' - - 'plan/src/main/java/org/apache/flink/agents/plan/condition/**' - - 'python/flink_agents/runtime/condition/**' - - 'python/flink_agents/plan/condition/**' - - 'python/flink_agents/runtime/tests/test_local_runner_condition.py' - - 'python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py' - - '.github/workflows/cel-conformance.yml' - workflow_dispatch: - -jobs: - conformance: - name: cel-conformance - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - # Fixtures live in e2e-test/cel-fixtures/ (single source of truth). - # Java reads them via pom.xml ; Python via symlink. - # No diff step needed — both sides reference the same physical files. - - # Cross-language check: CEL reserved keyword sets must be identical. - # Skipped automatically if the Python side hasn't landed yet (PR2 alone). - - name: Ensure CEL reserved keywords are identical across languages - run: | - set -euo pipefail - if [ ! -f python/flink_agents/plan/condition/cel_reserved.py ]; then - echo "Python cel_reserved.py not present yet; skipping cross-language check." - exit 0 - fi - python3 << 'PY' - import re, sys - - java_file = 'plan/src/main/java/org/apache/flink/agents/plan/condition/CelReserved.java' - macro_file = 'plan/src/main/java/org/apache/flink/agents/plan/condition/CelMacroPolicy.java' - py_file = 'python/flink_agents/plan/condition/cel_reserved.py' - - java_src = open(java_file).read() - java_names = set(re.findall(r'set\.add\("([^"]+)"\)', java_src)) - if 'CelMacroPolicy.DISALLOWED_MACROS' in java_src: - macro_src = open(macro_file).read() - m = re.search(r'DISALLOWED_MACROS\s*=\s*Set\.of\(([^)]+)\)', macro_src) - if m: - java_names |= set(re.findall(r'"([^"]+)"', m.group(1))) - - py_src = open(py_file).read() - fs = re.search(r'frozenset\s*\(\s*\{([^}]+)\}', py_src) - py_names = set(re.findall(r'"([^"]+)"', fs.group(1))) if fs else set() - - if java_names != py_names: - print('::error::CEL RESERVED_IDENTIFIERS differ between Java and Python.', file=sys.stderr) - print(f'Java only: {sorted(java_names - py_names)}', file=sys.stderr) - print(f'Python only: {sorted(py_names - java_names)}', file=sys.stderr) - sys.exit(1) - print(f'OK: {len(java_names)} reserved identifiers aligned across languages.') - PY - - # Java evaluator tests. - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Maven repo - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-m2- - - - name: Run Java CEL tests - run: | - mvn -pl runtime -am -DskipTests=false \ - -Dtest='CelConditionEvaluatorTest,CelExpressionFacadeTest,AstRewriterTest,ActionRouterTest,CelResourceLimitsTest' \ - -Dsurefire.failIfNoSpecifiedTests=false \ - test - - # Python evaluator tests. - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install Python project - run: | - python -m pip install --upgrade pip - python -m pip install -e 'python' pytest - - - name: Run Python CEL tests - run: | - set -euo pipefail - if [ ! -d python/flink_agents/runtime/condition/tests ]; then - echo "Python CEL test suite not present yet; skipping." - exit 0 - fi - pytest -q \ - python/flink_agents/plan/condition/tests \ - python/flink_agents/runtime/condition/tests \ - python/flink_agents/runtime/tests/test_local_runner_condition.py \ - python/flink_agents/runtime/tests/test_local_runner_mixed_or_dedup.py diff --git a/dist/src/main/resources/META-INF/NOTICE b/dist/src/main/resources/META-INF/NOTICE index afc811a14..bee13e2e3 100644 --- a/dist/src/main/resources/META-INF/NOTICE +++ b/dist/src/main/resources/META-INF/NOTICE @@ -14,6 +14,8 @@ This project bundles the following dependencies under the Apache Software Licens - com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2 - com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2 - com.fasterxml:classmate:1.7.0 +- dev.cel:cel:0.12.0 +- dev.cel:protobuf:0.12.0 - org.apache.logging.log4j:log4j-api:2.23.1 - org.apache.logging.log4j:log4j-core:2.23.1 - org.apache.logging.log4j:log4j-slf4j-impl:2.23.1 @@ -182,6 +184,8 @@ This project bundles the following dependencies under the BSD 3-Clause license. See bundled license files for details. - com.google.protobuf:protobuf-java:3.25.5 +- com.google.re2j:re2j:1.8 +- org.antlr:antlr4-runtime:4.13.2 - org.ow2.asm:asm:9.3 This project bundles the following dependencies under the EPL2 license. diff --git a/dist/src/main/resources/META-INF/licenses/LICENSE.antlr4-runtime b/dist/src/main/resources/META-INF/licenses/LICENSE.antlr4-runtime new file mode 100644 index 000000000..5d2769415 --- /dev/null +++ b/dist/src/main/resources/META-INF/licenses/LICENSE.antlr4-runtime @@ -0,0 +1,28 @@ +Copyright (c) 2012-2022 The ANTLR Project. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither name of copyright holders nor the names of its contributors +may be used to endorse or promote products derived from this software +without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/dist/src/main/resources/META-INF/licenses/LICENSE.re2j b/dist/src/main/resources/META-INF/licenses/LICENSE.re2j new file mode 100644 index 000000000..b620ae68f --- /dev/null +++ b/dist/src/main/resources/META-INF/licenses/LICENSE.re2j @@ -0,0 +1,32 @@ +This is a work derived from Russ Cox's RE2 in Go, whose license +http://golang.org/LICENSE is as follows: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google Inc. nor the names of its contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From f311e45ceb985081d9492e204f439f495c915af7 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:51:24 +0800 Subject: [PATCH 11/13] [plan] Apply CelOptions resource caps to plan-side CEL parser --- .../agents/plan/condition/ParsedCondition.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java b/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java index 3e5d903da..123efb119 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java @@ -19,6 +19,7 @@ package org.apache.flink.agents.plan.condition; import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; import dev.cel.common.CelValidationException; import dev.cel.common.ast.CelExpr; import dev.cel.parser.CelParser; @@ -35,9 +36,22 @@ public interface ParsedCondition { /** Original user-written entry string. */ String source(); - /** Parser with the custom {@code has()} macro; same dialect as the runtime facade parser. */ + /** Mirrors the runtime facade caps so a too-deep / too-long expression fails at classify. */ + CelOptions CEL_OPTIONS = + CelOptions.current() + .maxParseRecursionDepth(32) + .maxExpressionCodePointSize(8_192) + .build(); + + /** + * Parser with the custom {@code has()} macro and the same resource caps as the runtime facade + * parser. + */ CelParser CEL_PARSER = - CelParserFactory.standardCelParserBuilder().addMacros(CelMacroPolicy.HAS).build(); + CelParserFactory.standardCelParserBuilder() + .setOptions(CEL_OPTIONS) + .addMacros(CelMacroPolicy.HAS) + .build(); /** * Parses a {@code triggerConditions} entry: a non-reserved bare-identifier root becomes a From 0a42523b8d920926086e02409819c30f44aab09a Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:54:15 +0800 Subject: [PATCH 12/13] [api][runtime] Wire celEvaluationFailurePolicy ConfigOption --- .../api/configuration/AgentConfigOptions.java | 11 ++++++++ .../CelEvaluationFailurePolicy.java | 26 +++++++++++++++++++ .../runtime/condition/ActionRouter.java | 7 ++++- .../condition/CelConditionEvaluator.java | 21 +++++---------- .../condition/CelConditionEvaluatorTest.java | 3 ++- 5 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 api/src/main/java/org/apache/flink/agents/api/configuration/CelEvaluationFailurePolicy.java diff --git a/api/src/main/java/org/apache/flink/agents/api/configuration/AgentConfigOptions.java b/api/src/main/java/org/apache/flink/agents/api/configuration/AgentConfigOptions.java index c39997da1..cb7de8069 100644 --- a/api/src/main/java/org/apache/flink/agents/api/configuration/AgentConfigOptions.java +++ b/api/src/main/java/org/apache/flink/agents/api/configuration/AgentConfigOptions.java @@ -33,6 +33,17 @@ public class AgentConfigOptions { public static final ConfigOption EVENT_LOGGER_TYPE = new ConfigOption<>("eventLoggerType", LoggerType.class, LoggerType.SLF4J); + /** + * Controls how the CEL condition evaluator handles runtime exceptions and non-Boolean results. + * Defaults to {@link CelEvaluationFailurePolicy#WARN_AND_SKIP} for streaming safety; set to + * {@link CelEvaluationFailurePolicy#FAIL} on strict-semantics pipelines to trigger failover. + */ + public static final ConfigOption CEL_EVALUATION_FAILURE_POLICY = + new ConfigOption<>( + "celEvaluationFailurePolicy", + CelEvaluationFailurePolicy.class, + CelEvaluationFailurePolicy.WARN_AND_SKIP); + /** The config parameter specifies the directory for the FileEvent file. */ public static final ConfigOption BASE_LOG_DIR = new ConfigOption<>("baseLogDir", String.class, null); diff --git a/api/src/main/java/org/apache/flink/agents/api/configuration/CelEvaluationFailurePolicy.java b/api/src/main/java/org/apache/flink/agents/api/configuration/CelEvaluationFailurePolicy.java new file mode 100644 index 000000000..8990d8224 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/configuration/CelEvaluationFailurePolicy.java @@ -0,0 +1,26 @@ +/* + * 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.configuration; + +/** Behaviour when a CEL trigger condition throws or returns a non-Boolean at evaluation time. */ +public enum CelEvaluationFailurePolicy { + /** Log WARN and treat the action as not matching. Streaming-safe default. */ + WARN_AND_SKIP, + /** Rethrow as {@code IllegalStateException}; triggers Flink task failover. */ + FAIL +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java index 3b941a7dc..d4e2f819f 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java @@ -19,6 +19,7 @@ package org.apache.flink.agents.runtime.condition; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.configuration.AgentConfigOptions; import org.apache.flink.agents.plan.AgentPlan; import org.apache.flink.agents.plan.actions.Action; import org.apache.flink.agents.plan.condition.ParsedCondition; @@ -62,7 +63,11 @@ public void open() { if (celExpressions.isEmpty()) { return; } - conditionEvaluator = new CelConditionEvaluator(); + conditionEvaluator = + new CelConditionEvaluator( + agentPlan + .getConfig() + .get(AgentConfigOptions.CEL_EVALUATION_FAILURE_POLICY)); conditionEvaluator.initPrograms(celExpressions); } diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java index 3734b2e15..a0d38a824 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java @@ -22,6 +22,7 @@ import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.configuration.CelEvaluationFailurePolicy; import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,26 +45,16 @@ public class CelConditionEvaluator { private static final ObjectMapper MAPPER = new ObjectMapper(); - /** - * Behaviour for both runtime exceptions and non-Boolean return values — both mean "no verdict". - */ - public enum EvaluationFailurePolicy { - /** Log WARN and treat the action as not matching. */ - WARN_AND_SKIP, - /** Rethrow as {@link IllegalStateException}; triggers Flink task failover. */ - FAIL - } - /** Frozen after {@link #initPrograms}; cleared by {@link #close}. */ @Nullable private Map programCache; - private final EvaluationFailurePolicy failurePolicy; + private final CelEvaluationFailurePolicy failurePolicy; public CelConditionEvaluator() { - this(EvaluationFailurePolicy.WARN_AND_SKIP); + this(CelEvaluationFailurePolicy.WARN_AND_SKIP); } - public CelConditionEvaluator(EvaluationFailurePolicy failurePolicy) { + public CelConditionEvaluator(CelEvaluationFailurePolicy failurePolicy) { this.failurePolicy = failurePolicy; } @@ -100,7 +91,7 @@ public boolean evaluate(@Nullable CelExpression expression, Map } return evaluateProgram(source, program, activation); } catch (CelEvaluationException e) { - if (failurePolicy == EvaluationFailurePolicy.FAIL) { + if (failurePolicy == CelEvaluationFailurePolicy.FAIL) { throw new IllegalStateException( "CEL condition evaluation failed for '" + source + "'", e); } @@ -120,7 +111,7 @@ private boolean evaluateProgram( String.format( "CEL condition '%s' returned non-boolean type %s, treating as false", condition, result == null ? "null" : result.getClass().getName()); - if (failurePolicy == EvaluationFailurePolicy.FAIL) { + if (failurePolicy == CelEvaluationFailurePolicy.FAIL) { throw new IllegalStateException(msg); } LOG.warn(msg); diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java index 1f44e2c11..e6342d351 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.configuration.CelEvaluationFailurePolicy; import org.apache.flink.agents.plan.condition.ParsedCondition.CelExpression; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -135,7 +136,7 @@ void testConformanceCases(ConformanceTestCase testCase) { @Test void testFailPolicyThrowsOnEvaluationError() { CelConditionEvaluator failEvaluator = - new CelConditionEvaluator(CelConditionEvaluator.EvaluationFailurePolicy.FAIL); + new CelConditionEvaluator(CelEvaluationFailurePolicy.FAIL); // Pre-compile a condition that will fail at runtime CelExpression cond = new CelExpression("attributes.nonexistent > 3"); failEvaluator.initPrograms(List.of(cond)); From bd9859f3a23b2e24ebe8f9a38cee0bdebe1ede11 Mon Sep 17 00:00:00 2001 From: rosemaryYuan <91046107+rosemarYuan@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:38:00 +0800 Subject: [PATCH 13/13] [runtime][test] Lock down celEvaluationFailurePolicy wiring with paired ActionRouterTest cases --- .../runtime/condition/ActionRouterTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java index 4f1b5ac41..e9b857a6e 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java @@ -21,6 +21,8 @@ import org.apache.flink.agents.api.Event; import org.apache.flink.agents.api.InputEvent; import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.configuration.AgentConfigOptions; +import org.apache.flink.agents.api.configuration.CelEvaluationFailurePolicy; import org.apache.flink.agents.api.context.RunnerContext; import org.apache.flink.agents.plan.AgentPlan; import org.apache.flink.agents.plan.JavaFunction; @@ -199,4 +201,36 @@ void route_typedAndCelMix_preservesTypedFirstThenCelOrdering() throws Exception List matched = router.route(new InputEvent("x")); assertThat(matched).containsExactly(pureType, pureCel); } + + // -- Routing: CEL failure policy from plan config -- + + @Test + void route_failPolicyFromConfig_throwsOnConditionEvaluationError() throws Exception { + // Compiles fine; errors at runtime because `attributes.nonexistent` is unset. + Action a = new Action("celFail", execStub(), List.of("attributes.nonexistent > 3"), null); + AgentPlan plan = planOf(a); + plan.getConfig() + .set( + AgentConfigOptions.CEL_EVALUATION_FAILURE_POLICY, + CelEvaluationFailurePolicy.FAIL); + + router = new ActionRouter(plan); + router.open(); + + assertThatThrownBy(() -> router.route(new Event("any_type") {})) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CEL condition evaluation failed"); + } + + @Test + void route_defaultWarnAndSkip_swallowsConditionEvaluationError() throws Exception { + // Same expression, same event — but no policy set in config. Default must remain + // WARN_AND_SKIP, so route() returns empty rather than throwing. + Action a = + new Action("celWarnSkip", execStub(), List.of("attributes.nonexistent > 3"), null); + router = new ActionRouter(planOf(a)); + router.open(); + + assertThat(router.route(new Event("any_type") {})).isEmpty(); + } }