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..0c02be2e3 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/EventType.java @@ -0,0 +1,74 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; + +/** + * 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 { + + 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; + + /** + * 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/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/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/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..08c29f958
--- /dev/null
+++ b/api/src/test/java/org/apache/flink/agents/api/EventTypeTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Tests for {@link EventType}. */
+class EventTypeTest {
+
+    @Test
+    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,
+                constants.get("ContextRetrievalRequestEvent"));
+        assertEquals(
+                ContextRetrievalResponseEvent.EVENT_TYPE,
+                constants.get("ContextRetrievalResponseEvent"));
+    }
+}
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.
diff --git a/docs/content/docs/development/chat_models.md b/docs/content/docs/development/chat_models.md
index f6bfe7b77..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)
@@ -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);
@@ -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)
@@ -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..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
@@ -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);
@@ -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
@@ -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..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
@@ -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();
@@ -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."""
@@ -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..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
@@ -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();
@@ -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)
@@ -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..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."""
@@ -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..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
@@ -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();
@@ -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
@@ -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..133ca9291 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.
 
@@ -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."""
@@ -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);
@@ -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
@@ -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
@@ -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
@@ -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));
 }
 ```
@@ -359,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)
@@ -374,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)
@@ -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<>() {
@@ -476,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)
@@ -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"))
@@ -595,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(
@@ -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..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."""
@@ -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..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."""
@@ -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..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()
@@ -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();
@@ -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/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/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/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()));
     }
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/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/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/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 1bd647764..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,43 +190,71 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE this.actionsByEvent = agentPlan.getActionsByEvent(); this.resourceProviders = agentPlan.getResourceProviders(); this.config = agentPlan.getConfig(); + rebuildActionsWithCel(); } 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); - - // Add to actions map - actions.put(action.getName(), action); - - // Add to actionsByEvent map - for (String eventTypeName : eventTypeNames) { - actionsByEvent.computeIfAbsent(eventTypeName, k -> new ArrayList<>()).add(action); - } + Action action = new Action(actionName, function, triggerConditions, config); + 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 { @@ -251,7 +289,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 +314,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..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,27 +23,39 @@ 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; /** - * 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 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) public class Action { private final String name; private final Function exec; - private final List listenEventTypes; + 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; @@ -51,18 +63,40 @@ public class Action { public Action( String name, Function exec, - List listenEventTypes, + 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.listenEventTypes = listenEventTypes; + 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}); } - public Action(String name, Function exec, List listenEventTypes) throws Exception { - this(name, exec, listenEventTypes, null); + 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); } public String getName() { @@ -73,8 +107,43 @@ public Function getExec() { return exec; } + /** Returns the full trigger conditions list (type names and CEL expressions). */ + public List getTriggerConditions() { + return triggerConditions; + } + + /** 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 listenEventTypes; + 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 @@ -89,11 +158,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/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..123efb119 --- /dev/null +++ b/plan/src/main/java/org/apache/flink/agents/plan/condition/ParsedCondition.java @@ -0,0 +1,154 @@ +/* + * 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.CelOptions; +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(); + + /** 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() + .setOptions(CEL_OPTIONS) + .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/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/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/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/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 6247b88c3..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 @@ -37,6 +37,8 @@ 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}. */ @@ -68,8 +70,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 +105,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 +135,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"); @@ -147,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("\"listen_event_types\":[]"), - "JSON should contain an empty listen event types 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 @@ -234,4 +228,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/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_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/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/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/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 new file mode 100644 index 000000000..d3ef005e8 --- /dev/null +++ b/python/flink_agents/api/events/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. +################################################################################# +"""Built-in event-type constants, sourced from each ``XxxEvent.EVENT_TYPE``.""" + +from __future__ import annotations + +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. + + Usage: ``@action(EventType.InputEvent)``. + """ + + 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.""" + 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/api/tests/test_event_type.py b/python/flink_agents/api/tests/test_event_type.py new file mode 100644 index 000000000..a25e23fc8 --- /dev/null +++ b/python/flink_agents/api/tests/test_event_type.py @@ -0,0 +1,40 @@ +################################################################################ +# 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. +################################################################################# +"""Smoke tests for :mod:`flink_agents.api.events.event_type`.""" + +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_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/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..3c69d9ca2 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: @@ -71,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) @@ -97,12 +113,15 @@ def __init__( self, name: str, exec: Function, - listen_event_types: 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, 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..3e7f4bb01 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}, @@ -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] 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.""" 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..d4e2f819f --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/ActionRouter.java @@ -0,0 +1,129 @@ +/* + * 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.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; +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( + agentPlan + .getConfig() + .get(AgentConfigOptions.CEL_EVALUATION_FAILURE_POLICY)); + 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..a0d38a824 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluator.java @@ -0,0 +1,243 @@ +/* + * 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.api.configuration.CelEvaluationFailurePolicy; +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(); + + /** Frozen after {@link #initPrograms}; cleared by {@link #close}. */ + @Nullable private Map programCache; + + private final CelEvaluationFailurePolicy failurePolicy; + + public CelConditionEvaluator() { + this(CelEvaluationFailurePolicy.WARN_AND_SKIP); + } + + public CelConditionEvaluator(CelEvaluationFailurePolicy 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 == CelEvaluationFailurePolicy.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 == CelEvaluationFailurePolicy.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(); } 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/condition/ActionRouterTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java new file mode 100644 index 000000000..e9b857a6e --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/ActionRouterTest.java @@ -0,0 +1,236 @@ +/* + * 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.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; +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); + } + + // -- 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(); + } +} 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..e6342d351 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/condition/CelConditionEvaluatorTest.java @@ -0,0 +1,212 @@ +/* + * 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.api.configuration.CelEvaluationFailurePolicy; +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(CelEvaluationFailurePolicy.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/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; 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); }