This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 1bf3d1c1a [api][plan][java][python] Support direct declaration of 
cross-language actions (#1159)
1bf3d1c1a is described below

commit 1bf3d1c1a11b8ab8d64a8f03fc2aa2b4dcb7ced5
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Thu Oct 1 14:52:59 2026 +0800

    [api][plan][java][python] Support direct declaration of cross-language 
actions (#1159)
    
    Generated-by: Qoder
---
 .../apache/flink/agents/api/annotation/Action.java |  25 ++--
 .../agents/api/annotation/PythonFunction.java      |  31 -----
 .../flink/agents/api/function/JavaFunction.java    |  17 ++-
 .../flink/agents/api/function/PythonFunction.java  |  25 +++-
 .../agents/api/function/JavaFunctionTest.java      |  15 +++
 .../agents/api/function/PythonFunctionTest.java    |  21 +++
 docs/content/docs/development/workflow_agent.md    |  30 ++---
 .../test/JavaAgentWithPythonActionAgent.java       |  20 +--
 .../org/apache/flink/agents/plan/AgentPlan.java    | 145 ++++++++++++++-------
 .../apache/flink/agents/plan/AgentPlanTest.java    | 142 +++++++++++++++-----
 python/flink_agents/api/decorators.py              | 106 ++++++++++-----
 python/flink_agents/api/function.py                |  20 ++-
 python/flink_agents/api/tests/test_decorators.py   |  97 +++++++-------
 python/flink_agents/api/tests/test_function.py     |  64 +++++++++
 .../python_agent_with_java_action.py               |  17 +--
 python/flink_agents/plan/agent_plan.py             |  45 ++++---
 python/flink_agents/plan/tests/test_agent_plan.py  |  68 ++++++++--
 .../plan/tests/test_agent_plan_cross_language.py   |  39 ++++++
 18 files changed, 651 insertions(+), 276 deletions(-)

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 256879bf5..57c0c0b55 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,7 +24,18 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Marks a Java method as an agent action triggered by event types or 
condition expressions.
+ * Marks a Java member as an agent action triggered by event types or 
condition expressions.
+ *
+ * <p>The annotated member is always the action's real definition:
+ *
+ * <ul>
+ *   <li>On a {@code static} method with the {@code (Event, RunnerContext)} 
signature, the method is
+ *       the native Java implementation.
+ *   <li>On a {@code static final} field whose value is an api-layer {@link
+ *       org.apache.flink.agents.api.function.Function} descriptor (for 
example {@link
+ *       org.apache.flink.agents.api.function.PythonFunction}), the field is 
the cross-language
+ *       target and no placeholder method body is required.
+ * </ul>
  *
  * <p>Each {@link #value()} entry is either an exact event-type name or a 
condition expression that
  * evaluates to boolean. Multiple entries use OR semantics. An entry matching 
the event-type syntax
@@ -38,11 +49,8 @@ import java.lang.annotation.Target;
  *
  * <p>The API preserves entries as raw strings. Entries are classified and 
condition expressions are
  * validated when the agent plan is built.
- *
- * <p>Set {@link #target()} to a {@link PythonFunction} configured with a 
module for a
- * cross-language action. The annotated Java method is not invoked for such 
actions.
  */
-@Target(ElementType.METHOD)
+@Target({ElementType.METHOD, ElementType.FIELD})
 @Retention(RetentionPolicy.RUNTIME)
 public @interface Action {
     /**
@@ -53,9 +61,8 @@ public @interface Action {
     String[] value();
 
     /**
-     * Cross-language target. When {@link PythonFunction#module()} is 
non-empty, dispatch routes to
-     * the Python target and the annotated Java body is unused. Default (empty 
{@code module}) keeps
-     * the action native Java.
+     * Optional action name override. When empty (the default), the annotated 
method or field name
+     * is used as the action name.
      */
-    PythonFunction target() default @PythonFunction;
+    String name() default "";
 }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/annotation/PythonFunction.java 
b/api/src/main/java/org/apache/flink/agents/api/annotation/PythonFunction.java
deleted file mode 100644
index 424b1077f..000000000
--- 
a/api/src/main/java/org/apache/flink/agents/api/annotation/PythonFunction.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.flink.agents.api.annotation;
-
-/**
- * Python target descriptor used inside {@link Action#target()}. Empty {@link 
#module()} = no
- * cross-language target (action stays native Java). When non-empty, the Java 
method body is never
- * invoked — throw {@link UnsupportedOperationException} from the stub so 
direct calls outside the
- * framework fail loud.
- */
-public @interface PythonFunction {
-    String module() default "";
-
-    String qualname() default "";
-}
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java 
b/api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java
index 2e438d9ce..a5f348c24 100644
--- a/api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java
+++ b/api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java
@@ -56,14 +56,27 @@ public final class JavaFunction implements Function, 
Serializable {
             @JsonProperty(FIELD_QUAL_NAME) String qualName,
             @JsonProperty(FIELD_METHOD_NAME) String methodName,
             @JsonProperty(FIELD_PARAMETER_TYPES) List<String> parameterTypes) {
-        this.qualName = Objects.requireNonNull(qualName, "qualName");
-        this.methodName = Objects.requireNonNull(methodName, "methodName");
+        this.qualName = requireNonEmpty(qualName, FIELD_QUAL_NAME);
+        this.methodName = requireNonEmpty(methodName, FIELD_METHOD_NAME);
         this.parameterTypes =
                 parameterTypes == null
                         ? Collections.emptyList()
                         : Collections.unmodifiableList(new 
ArrayList<>(parameterTypes));
     }
 
+    /**
+     * Rejects a null or empty identifier. A descriptor names a cross-language 
target, so an empty
+     * identifier can never resolve; failing here makes every construction 
path ({@link
+     * #fromMethod}, {@code new}, and Jackson deserialization) reject a 
partially specified target
+     * up front rather than deferring the failure to dispatch time.
+     */
+    private static String requireNonEmpty(String value, String name) {
+        if (Objects.requireNonNull(value, name).isEmpty()) {
+            throw new IllegalArgumentException(name + " must not be empty");
+        }
+        return value;
+    }
+
     /**
      * Build a descriptor from a reflected {@link Method}. Each parameter type 
is captured via
      * {@link Class#getName()} — the same form {@link Class#forName(String)} 
accepts when the api
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java 
b/api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java
index e249a693f..eebb9e88d 100644
--- a/api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java
+++ b/api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java
@@ -44,8 +44,29 @@ public final class PythonFunction implements Function, 
Serializable {
     public PythonFunction(
             @JsonProperty(FIELD_MODULE) String module,
             @JsonProperty(FIELD_QUAL_NAME) String qualName) {
-        this.module = Objects.requireNonNull(module, "module");
-        this.qualName = Objects.requireNonNull(qualName, "qualName");
+        this.module = requireNonEmpty(module, FIELD_MODULE);
+        this.qualName = requireNonEmpty(qualName, FIELD_QUAL_NAME);
+    }
+
+    /**
+     * Rejects a null or empty identifier. A descriptor names a cross-language 
target, so an empty
+     * identifier can never resolve; failing here makes every construction 
path ({@link #of}, {@code
+     * new}, and Jackson deserialization) reject a partially specified target 
up front rather than
+     * deferring the failure to dispatch time.
+     */
+    private static String requireNonEmpty(String value, String name) {
+        if (Objects.requireNonNull(value, name).isEmpty()) {
+            throw new IllegalArgumentException(name + " must not be empty");
+        }
+        return value;
+    }
+
+    /**
+     * Convenience factory for a Python cross-language target. Reads naturally 
in the
+     * annotated-field form: {@code PythonFunction.of("my_pkg.handlers", 
"handle_input")}.
+     */
+    public static PythonFunction of(String module, String qualName) {
+        return new PythonFunction(module, qualName);
     }
 
     public String getModule() {
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java 
b/api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java
index b79eaa792..c3c73cd57 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java
@@ -24,6 +24,7 @@ import java.lang.reflect.Method;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class JavaFunctionTest {
 
@@ -75,4 +76,18 @@ class JavaFunctionTest {
         JavaFunction back = m.readValue(json, JavaFunction.class);
         assertThat(back).isEqualTo(fn);
     }
+
+    @Test
+    void rejectsEmptyQualName() {
+        assertThatThrownBy(() -> new JavaFunction("", "add", List.of("int")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("qualName");
+    }
+
+    @Test
+    void rejectsEmptyMethodName() {
+        assertThatThrownBy(() -> new JavaFunction("com.example.X", "", 
List.of("int")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("methodName");
+    }
 }
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java
index d2ae089ca..b21746bef 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class PythonFunctionTest {
 
@@ -48,4 +49,24 @@ class PythonFunctionTest {
         PythonFunction back = m.readValue(json, PythonFunction.class);
         assertThat(back).isEqualTo(fn);
     }
+
+    @Test
+    void rejectsEmptyModule() {
+        assertThatThrownBy(() -> new PythonFunction("", "MyClass.method"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("module");
+    }
+
+    @Test
+    void rejectsEmptyQualName() {
+        assertThatThrownBy(() -> new PythonFunction("pkg.mod", ""))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("qualName");
+    }
+
+    @Test
+    void factoryRejectsEmptyIdentifiers() {
+        assertThatThrownBy(() -> PythonFunction.of("", ""))
+                .isInstanceOf(IllegalArgumentException.class);
+    }
 }
diff --git a/docs/content/docs/development/workflow_agent.md 
b/docs/content/docs/development/workflow_agent.md
index 85c3a94b2..565a35aca 100644
--- a/docs/content/docs/development/workflow_agent.md
+++ b/docs/content/docs/development/workflow_agent.md
@@ -238,7 +238,7 @@ public class ReviewAnalysisAgent extends Agent {
 An action is a piece of code that can be executed. It declares one or more 
trigger conditions and is
 triggered when an event matches one of them.
 
-Use `@action(*trigger_conditions, target=None)` to decorate a Python function 
or `@Action({...})` to
+Use `@action(*trigger_conditions, name=None)` to decorate a Python function or 
`@Action({...})` to
 annotate a Java method. The function or method accepts the triggering `Event` 
and a `RunnerContext`.
 It sends events through the context rather than returning a result, so declare 
its return type as
 `None` in Python or `void` in Java. A native Java action must be `public 
static`. Python actions can
@@ -675,7 +675,7 @@ when an outer call occupies an async or interpreter worker 
while waiting for ano
 
 ### Cross-language Actions
 
-An action declared in one language can dispatch its body to the other language 
by setting a `target` on the decorator/annotation. The decorated function or 
annotated method then acts as a stub — it should raise so direct calls outside 
the framework fail loud.
+An action declared in one language can dispatch its body to the other 
language. The declared member *is* the executable target, so no placeholder 
body is needed: apply `action(...)` to a `Function` descriptor in Python, or 
annotate a `static final` `Function` field in Java. The member name becomes the 
action name, and can be overridden with `name=` (Python) or `name` (Java).
 
 {{< tabs "Cross-language Actions" >}}
 
@@ -684,29 +684,21 @@ An action declared in one language can dispatch its body 
to the other language b
 from flink_agents.api.function import JavaFunction
 
 class MyAgent(Agent):
-    @action(
-        InputEvent.EVENT_TYPE,
-        # Action signatures are fixed (Event, RunnerContext), so for_action
-        # fills the Java parameter types for you — only the class and method.
-        target=JavaFunction.for_action("com.example.MyHandlers", 
"handleInput"),
-    )
-    @staticmethod
-    def handle_input(event: Event, ctx: RunnerContext) -> None:
-        raise NotImplementedError("cross-language stub")
+    # Action signatures are fixed (Event, RunnerContext), so for_action
+    # fills the Java parameter types for you — only the class and method.
+    handle_input = action(InputEvent.EVENT_TYPE)(
+        JavaFunction.for_action("com.example.MyHandlers", "handleInput"))
 ```
 {{< /tab >}}
 
 {{< tab "Java" >}}
 ```java
+import org.apache.flink.agents.api.function.PythonFunction;
+
 public class MyAgent extends Agent {
-    @Action(
-            value = EventType.InputEvent,
-            target = @PythonFunction(
-                    module = "my_pkg.handlers",
-                    qualname = "handle_input"))
-    public static void handleInput(Event event, RunnerContext ctx) {
-        throw new UnsupportedOperationException("cross-language stub");
-    }
+    @Action(EventType.InputEvent)
+    private static final PythonFunction handleInput =
+            PythonFunction.of("my_pkg.handlers", "handle_input");
 }
 ```
 {{< /tab >}}
diff --git 
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/JavaAgentWithPythonActionAgent.java
 
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/JavaAgentWithPythonActionAgent.java
index b74ba3f60..fc1cbe4a8 100644
--- 
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/JavaAgentWithPythonActionAgent.java
+++ 
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/JavaAgentWithPythonActionAgent.java
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.resource.test;
 
 import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.annotation.Action;
 import org.apache.flink.agents.api.function.PythonFunction;
 import org.apache.flink.api.java.functions.KeySelector;
 
@@ -27,16 +28,15 @@ public class JavaAgentWithPythonActionAgent extends Agent {
             
"flink_agents.e2e_tests.e2e_tests_resource_cross_language.python_action_handler";
     public static final String PYTHON_QUALNAME = "multiply_by_two";
 
-    public JavaAgentWithPythonActionAgent() {
-        addAction(
-                "multiply_by_two",
-                new String[] {
-                    "type == EventType.InputEvent && input > 1 && input < 7",
-                    "type == EventType.InputEvent && input > 3 && input < 9"
-                },
-                new PythonFunction(PYTHON_MODULE, PYTHON_QUALNAME),
-                null);
-    }
+    /** Cross-language action declared on the field that is its Python target. 
*/
+    @Action(
+            value = {
+                "type == EventType.InputEvent && input > 1 && input < 7",
+                "type == EventType.InputEvent && input > 3 && input < 9"
+            },
+            name = "multiply_by_two")
+    private static final PythonFunction multiplyByTwo =
+            PythonFunction.of(PYTHON_MODULE, PYTHON_QUALNAME);
 
     public static class SingleKeySelector implements KeySelector<Long, Long> {
         @Override
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 74fbc5274..4d99e3d32 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
@@ -66,6 +66,7 @@ import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -230,6 +231,9 @@ public class AgentPlan implements Serializable {
     }
 
     private void registerAction(Action action) {
+        if (actions.containsKey(action.getName())) {
+            throw new IllegalStateException("Duplicate action name '" + 
action.getName() + "'.");
+        }
         actions.put(action.getName(), action);
     }
 
@@ -239,58 +243,43 @@ public class AgentPlan implements Serializable {
         addBuiltAction(ToolCallAction.getToolCallAction());
         addBuiltAction(ContextRetrievalAction.getContextRetrievalAction());
 
-        // Scan the agent class for methods annotated with @Action
         Class<?> agentClass = agent.getClass();
-        // getDeclaredMethods() skips inherited @Action methods; reject loudly.
-        for (Class<?> parent = agentClass.getSuperclass();
-                parent != null && parent != Agent.class;
-                parent = parent.getSuperclass()) {
-            for (Method inherited : parent.getDeclaredMethods()) {
-                if (inherited.isAnnotationPresent(
-                        org.apache.flink.agents.api.annotation.Action.class)) {
-                    throw new IllegalStateException(
-                            "Inherited @Action '"
-                                    + parent.getName()
-                                    + "#"
-                                    + inherited.getName()
-                                    + "' is not supported; declare on the 
concrete agent.");
-                }
-            }
-        }
+        // getDeclaredMethods()/getDeclaredFields() skip inherited @Action 
members; reject loudly.
+        rejectInheritedActions(agentClass);
+
+        // @Action on a method: the method body is the native Java 
implementation.
         for (Method method : agentClass.getDeclaredMethods()) {
-            if 
(!method.isAnnotationPresent(org.apache.flink.agents.api.annotation.Action.class))
 {
+            org.apache.flink.agents.api.annotation.Action actionAnnotation =
+                    
method.getAnnotation(org.apache.flink.agents.api.annotation.Action.class);
+            if (actionAnnotation == null) {
                 continue;
             }
+            org.apache.flink.agents.plan.Function execFunction =
+                    new org.apache.flink.agents.plan.JavaFunction(
+                            method.getDeclaringClass(),
+                            method.getName(),
+                            method.getParameterTypes());
+            extractActions(
+                    resolveActionName(actionAnnotation.name(), 
method.getName()),
+                    actionAnnotation.value(),
+                    execFunction,
+                    null);
+        }
+
+        // @Action on a field: the field value is the cross-language target 
descriptor.
+        for (Field field : agentClass.getDeclaredFields()) {
             org.apache.flink.agents.api.annotation.Action actionAnnotation =
-                    Objects.requireNonNull(
-                            method.getAnnotation(
-                                    
org.apache.flink.agents.api.annotation.Action.class));
-            String[] triggerConditions = actionAnnotation.value();
-            org.apache.flink.agents.api.annotation.PythonFunction target =
-                    actionAnnotation.target();
-            String targetModule = target.module();
-            String targetQualname = target.qualname();
-            boolean moduleSet = !targetModule.isEmpty();
-            boolean qualnameSet = !targetQualname.isEmpty();
-
-            org.apache.flink.agents.plan.Function execFunction;
-            if (!moduleSet && !qualnameSet) {
-                execFunction =
-                        new org.apache.flink.agents.plan.JavaFunction(
-                                method.getDeclaringClass(),
-                                method.getName(),
-                                method.getParameterTypes());
-            } else if (moduleSet && qualnameSet) {
-                execFunction =
-                        new org.apache.flink.agents.plan.PythonFunction(
-                                targetModule, targetQualname);
-            } else {
-                throw new IllegalStateException(
-                        "PythonFunction target on '"
-                                + method.getName()
-                                + "' must set both module and qualname");
+                    
field.getAnnotation(org.apache.flink.agents.api.annotation.Action.class);
+            if (actionAnnotation == null) {
+                continue;
             }
-            extractActions(method.getName(), triggerConditions, execFunction, 
null);
+            org.apache.flink.agents.api.function.Function descriptor =
+                    readActionDescriptorField(field);
+            extractActions(
+                    resolveActionName(actionAnnotation.name(), 
field.getName()),
+                    actionAnnotation.value(),
+                    toPlanFunction(descriptor),
+                    null);
         }
 
         for (var action : agent.getActions().entrySet()) {
@@ -300,6 +289,70 @@ public class AgentPlan implements Serializable {
         }
     }
 
+    private static String resolveActionName(String override, String 
memberName) {
+        return override == null || override.isEmpty() ? memberName : override;
+    }
+
+    /** Reject any {@code @Action} declared on a superclass; it must sit on 
the concrete agent. */
+    private static void rejectInheritedActions(Class<?> agentClass) {
+        for (Class<?> parent = agentClass.getSuperclass();
+                parent != null && parent != Agent.class;
+                parent = parent.getSuperclass()) {
+            for (Method inherited : parent.getDeclaredMethods()) {
+                rejectInheritedAction(parent, inherited.getName(), inherited);
+            }
+            for (Field inherited : parent.getDeclaredFields()) {
+                rejectInheritedAction(parent, inherited.getName(), inherited);
+            }
+        }
+    }
+
+    private static void rejectInheritedAction(
+            Class<?> parent, String memberName, AnnotatedElement element) {
+        if 
(element.isAnnotationPresent(org.apache.flink.agents.api.annotation.Action.class))
 {
+            throw new IllegalStateException(
+                    "Inherited @Action '"
+                            + parent.getName()
+                            + "#"
+                            + memberName
+                            + "' is not supported; declare on the concrete 
agent.");
+        }
+    }
+
+    /**
+     * Read an {@code @Action}-annotated field as an api-layer {@link
+     * org.apache.flink.agents.api.function.Function} descriptor. The field 
must be {@code static
+     * final} and hold a non-null descriptor; anything else fails with an 
actionable error at plan
+     * construction. Cross-language target resolution stays deferred to the 
runtime boundary that
+     * owns the real user-code classloader or Python interpreter.
+     */
+    private static org.apache.flink.agents.api.function.Function 
readActionDescriptorField(
+            Field field) throws IllegalAccessException {
+        int modifiers = field.getModifiers();
+        if (!Modifier.isStatic(modifiers) || !Modifier.isFinal(modifiers)) {
+            throw new IllegalStateException(
+                    "@Action field '"
+                            + field.getName()
+                            + "' must be declared 'static final' and hold a 
Function descriptor.");
+        }
+        field.setAccessible(true);
+        Object value = field.get(null);
+        if (value == null) {
+            throw new IllegalStateException(
+                    "@Action field '" + field.getName() + "' must hold a 
non-null descriptor.");
+        }
+        if (!(value instanceof org.apache.flink.agents.api.function.Function)) 
{
+            throw new IllegalStateException(
+                    "@Action field '"
+                            + field.getName()
+                            + "' must hold an api-layer Function descriptor 
(for example "
+                            + "PythonFunction or JavaFunction), but got "
+                            + value.getClass().getName()
+                            + ".");
+        }
+        return (org.apache.flink.agents.api.function.Function) value;
+    }
+
     private static ResourceDescriptor requireResourceDescriptor(
             String name, ResourceType type, Object value) {
         if (!(value instanceof ResourceDescriptor)) {
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 3a0a2a3fa..f4e16e074 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
@@ -294,27 +294,21 @@ public class AgentPlanTest {
         }
     }
 
-    /** Cross-language action via {@code @Action(target = 
@PythonFunction(...))}. */
+    /** Cross-language action via an {@code @Action}-annotated descriptor 
field. */
     public static class AgentWithCrossLanguageAction extends Agent {
-        @org.apache.flink.agents.api.annotation.Action(
-                value = EventType.InputEvent,
-                target =
-                        @org.apache.flink.agents.api.annotation.PythonFunction(
-                                module = "my_pkg.handlers",
-                                qualname = "handle_input"))
-        public static void handle(Event event, RunnerContext ctx) {
-            throw new UnsupportedOperationException("cross-language stub");
-        }
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        private static final PythonFunction handle =
+                PythonFunction.of("my_pkg.handlers", "handle_input");
     }
 
     @Test
-    public void testActionWithPythonTargetCompilesToPythonFunctionExec() 
throws Exception {
+    public void testFieldDescriptorCompilesToPythonFunctionExec() throws 
Exception {
         AgentPlan plan = new AgentPlan(new AgentWithCrossLanguageAction());
 
         Action action = plan.getActions().get("handle");
         assertThat(action).isNotNull();
         assertThat(action.getExec())
-                .as("non-empty target.module() must compile to a plan 
PythonFunction exec")
+                .as("a PythonFunction descriptor field must compile to a plan 
PythonFunction exec")
                 
.isInstanceOf(org.apache.flink.agents.plan.PythonFunction.class);
 
         org.apache.flink.agents.plan.PythonFunction exec =
@@ -324,7 +318,7 @@ public class AgentPlanTest {
         
assertThat(action.getTriggerConditions()).containsExactly(InputEvent.EVENT_TYPE);
     }
 
-    /** Plain {@code @Action} (no {@code target}) compiles to a native Java 
exec. */
+    /** Plain {@code @Action} on a method compiles to a native Java exec. */
     public static class AgentWithNativeJavaAction extends Agent {
         @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
         public static void handle(Event event, RunnerContext ctx) {
@@ -333,52 +327,89 @@ public class AgentPlanTest {
     }
 
     @Test
-    public void testActionWithEmptyTargetCompilesToJavaFunctionExec() throws 
Exception {
+    public void testNativeMethodActionCompilesToJavaFunctionExec() throws 
Exception {
         AgentPlan plan = new AgentPlan(new AgentWithNativeJavaAction());
 
         Action action = plan.getActions().get("handle");
         assertThat(action).isNotNull();
         assertThat(action.getExec())
-                .as("empty target.module() must compile to a plan JavaFunction 
exec")
+                .as("an @Action method must compile to a plan JavaFunction 
exec")
                 .isInstanceOf(JavaFunction.class);
     }
 
-    /** Partially-set target (module without qualname) — must be rejected at 
compile. */
-    public static class AgentWithHalfSetPythonTargetMissingQualname extends 
Agent {
+    /** {@code @Action(name = ...)} overrides the default member name. */
+    public static class AgentWithRenamedFieldAction extends Agent {
         @org.apache.flink.agents.api.annotation.Action(
                 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");
-        }
+                name = "renamed")
+        private static final PythonFunction handle =
+                PythonFunction.of("my_pkg.handlers", "handle_input");
     }
 
     @Test
-    public void testActionWithPythonTargetMissingQualnameIsRejected() {
-        assertThatThrownBy(() -> new AgentPlan(new 
AgentWithHalfSetPythonTargetMissingQualname()))
-                .isInstanceOf(IllegalStateException.class)
-                .hasMessageContaining("handle")
-                .hasMessageContaining("qualname");
+    public void testNameOverrideReplacesMemberName() throws Exception {
+        AgentPlan plan = new AgentPlan(new AgentWithRenamedFieldAction());
+        assertThat(plan.getActions()).containsKey("renamed");
+        assertThat(plan.getActions()).doesNotContainKey("handle");
     }
 
-    /** Partially-set target (qualname without module) — must be rejected at 
compile. */
-    public static class AgentWithHalfSetPythonTargetMissingModule extends 
Agent {
+    /** {@code @Action(name = ...)} on a method overrides the default method 
name. */
+    public static class AgentWithRenamedMethodAction extends Agent {
         @org.apache.flink.agents.api.annotation.Action(
                 value = EventType.InputEvent,
-                target =
-                        @org.apache.flink.agents.api.annotation.PythonFunction(
-                                qualname = "handle_input"))
+                name = "renamedMethod")
         public static void handle(Event event, RunnerContext ctx) {
-            throw new UnsupportedOperationException("cross-language stub");
+            // intentionally empty
         }
     }
 
     @Test
-    public void testActionWithPythonTargetMissingModuleIsRejected() {
-        assertThatThrownBy(() -> new AgentPlan(new 
AgentWithHalfSetPythonTargetMissingModule()))
+    public void testNameOverrideReplacesMethodName() throws Exception {
+        AgentPlan plan = new AgentPlan(new AgentWithRenamedMethodAction());
+        assertThat(plan.getActions()).containsKey("renamedMethod");
+        assertThat(plan.getActions()).doesNotContainKey("handle");
+    }
+
+    /** {@code @Action} on a non-{@code static final} field is rejected at 
compile. */
+    public static class AgentWithNonFinalActionField extends Agent {
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        private PythonFunction handle = PythonFunction.of("my_pkg.handlers", 
"handle_input");
+    }
+
+    @Test
+    public void testNonStaticFinalActionFieldIsRejected() {
+        assertThatThrownBy(() -> new AgentPlan(new 
AgentWithNonFinalActionField()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("handle")
+                .hasMessageContaining("static final");
+    }
+
+    /** {@code @Action} on a field holding a non-descriptor value is rejected 
at compile. */
+    public static class AgentWithNonDescriptorActionField extends Agent {
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        private static final String handle = "not a descriptor";
+    }
+
+    @Test
+    public void testNonDescriptorActionFieldIsRejected() {
+        assertThatThrownBy(() -> new AgentPlan(new 
AgentWithNonDescriptorActionField()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("handle")
+                .hasMessageContaining("Function descriptor");
+    }
+
+    /** {@code @Action} on a field holding a null descriptor is rejected at 
compile. */
+    public static class AgentWithNullActionField extends Agent {
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        private static final PythonFunction handle = null;
+    }
+
+    @Test
+    public void testNullActionFieldIsRejected() {
+        assertThatThrownBy(() -> new AgentPlan(new AgentWithNullActionField()))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("handle")
-                .hasMessageContaining("module");
+                .hasMessageContaining("non-null");
     }
 
     /**
@@ -402,6 +433,47 @@ public class AgentPlanTest {
                 .hasMessageContaining("Inherited @Action");
     }
 
+    /** An {@code @Action} descriptor field on a parent class is rejected too. 
*/
+    public abstract static class BaseAgentWithInheritedActionField extends 
Agent {
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        protected static final PythonFunction sharedAction =
+                PythonFunction.of("my_pkg.handlers", "handle_input");
+    }
+
+    public static class ConcreteAgentInheritingActionField
+            extends BaseAgentWithInheritedActionField {}
+
+    @Test
+    public void testActionFieldInheritedFromParentAgentClassIsRejected() {
+        assertThatThrownBy(() -> new AgentPlan(new 
ConcreteAgentInheritingActionField()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("sharedAction")
+                .hasMessageContaining("BaseAgentWithInheritedActionField")
+                .hasMessageContaining("Inherited @Action");
+    }
+
+    /** Two members resolving to the same action name are rejected at compile. 
*/
+    public static class AgentWithDuplicateActionName extends Agent {
+        @org.apache.flink.agents.api.annotation.Action(EventType.InputEvent)
+        public static void handle(Event event, RunnerContext ctx) {
+            // intentionally empty
+        }
+
+        @org.apache.flink.agents.api.annotation.Action(
+                value = EventType.OutputEvent,
+                name = "handle")
+        private static final PythonFunction other =
+                PythonFunction.of("my_pkg.handlers", "handle_input");
+    }
+
+    @Test
+    public void testDuplicateActionNameIsRejected() {
+        assertThatThrownBy(() -> new AgentPlan(new 
AgentWithDuplicateActionName()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("Duplicate action name")
+                .hasMessageContaining("handle");
+    }
+
     @Test
     public void testAgentPlanResourceProviders() throws Exception {
         // Test that AgentPlan initializes resource providers correctly
diff --git a/python/flink_agents/api/decorators.py 
b/python/flink_agents/api/decorators.py
index 14e2227fb..aaf914e5c 100644
--- a/python/flink_agents/api/decorators.py
+++ b/python/flink_agents/api/decorators.py
@@ -15,9 +15,10 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+from dataclasses import dataclass
 from typing import Callable, Type
 
-from flink_agents.api.function import Function, JavaFunction, PythonFunction
+from flink_agents.api.function import Function
 from flink_agents.api.tools.tool_parameter_injection import (
     InjectedArg,
     normalize_injected_args,
@@ -25,25 +26,49 @@ from flink_agents.api.tools.tool_parameter_injection import 
(
 )
 
 
-def _validate_target(target: Function, owner: str) -> None:
-    """Reject targets with empty required identifiers, attributed to 
``owner``."""
-    if isinstance(target, PythonFunction):
-        if not target.module or not target.qualname:
-            msg = (
-                f"PythonFunction target on '{owner}' must set both module and 
qualname"
-            )
-            raise ValueError(msg)
-    elif isinstance(target, JavaFunction):
-        if not target.qualname or not target.method_name:
-            msg = f"JavaFunction target on '{owner}' must set both qualname 
and method_name"
-            raise ValueError(msg)
+@dataclass(frozen=True)
+class ActionDeclaration:
+    """Immutable action declaration produced by applying ``action`` to a 
descriptor.
+
+    Applying ``action`` to a cross-language :class:`Function` descriptor 
returns
+    this wrapper instead of mutating the descriptor, so a descriptor shared by
+    multiple declarations keeps each declaration's trigger conditions and name
+    separate.
+
+    Attributes:
+    ----------
+    trigger_conditions : tuple[str, ...]
+        Raw event-type names or Boolean condition expressions.
+    func : Function
+        The api-layer executable descriptor dispatched for this action.
+    name : str | None
+        Optional action name override; ``None`` means use the attribute name.
+    """
+
+    trigger_conditions: tuple
+    func: Function
+    name: str | None = None
 
 
 def action(
     *trigger_conditions: str,
-    target: Function | None = None,
+    name: str | None = None,
 ) -> Callable:
-    """Decorator for marking a function as an agent action.
+    """Mark a function or a cross-language descriptor as an agent action.
+
+    Two declaration forms, mirroring Java's ``@Action`` on a method or a field:
+
+    * Native — decorate a callable; the decorated function is the action body::
+
+          @action(EventType.InputEvent)
+          @staticmethod
+          def handle(event: Event, ctx: RunnerContext) -> None: ...
+
+    * Cross-language — apply to a :class:`Function` descriptor; the decorated
+      attribute is the executable target, so no placeholder body is needed::
+
+          handle = action(EventType.InputEvent)(
+              JavaFunction.for_action("com.example.Handlers", "handle"))
 
     Each trigger condition is an event-type name or Boolean condition
     expression. Multiple conditions combine with OR semantics. To combine an
@@ -55,40 +80,51 @@ def action(
     ----------
     trigger_conditions : str
         Raw event-type names or Boolean condition expressions.
-    target : Function, optional
-        Cross-language function descriptor dispatched instead of the
-        decorated body. The body becomes a stub — raise
-        ``NotImplementedError`` so direct calls fail loud.
+    name : str, optional
+        Action name override. An empty string or ``None`` falls back to the
+        decorated member's attribute name (mirroring Java's ``@Action.name``).
 
     Returns:
     -------
     Callable
-        Decorator function that marks the target function with trigger 
conditions.
+        Decorator that, for a callable target, returns the tagged function, and
+        for a :class:`Function` descriptor target, returns an immutable
+        :class:`ActionDeclaration`.
 
     Raises:
     ------
     TypeError
-        If an entry is not a string, or ``target`` is not a
-        :class:`Function` descriptor.
+        If a trigger condition is not a string, or the decorated object is
+        neither a callable nor an api-layer :class:`Function` descriptor.
     """
     for entry in trigger_conditions:
         if not isinstance(entry, str):
             msg = f"action trigger condition must be a string, got {entry!r}"
             raise TypeError(msg)
 
-    if target is not None and not isinstance(target, Function):
-        msg = (
-            f"action(target=...) must be an api-layer Function descriptor, "
-            f"got {type(target).__name__}"
-        )
-        raise TypeError(msg)
-
-    def decorator(func: Callable) -> Callable:
-        if target is not None:
-            _validate_target(target, func.__qualname__)
-            func._target = target
-        func._trigger_conditions = trigger_conditions
-        return func
+    # Mirror Java's resolveActionName: an empty-string override means "no
+    # override" (a Java annotation cannot distinguish its "" default from an
+    # explicit ""), so the action falls back to its attribute name.
+    if name == "":
+        name = None
+
+    def decorator(target: Callable | Function) -> Callable | ActionDeclaration:
+        if isinstance(target, Function):
+            return ActionDeclaration(
+                trigger_conditions=trigger_conditions,
+                func=target,
+                name=name,
+            )
+        if not callable(target):
+            msg = (
+                f"action() must decorate a callable or an api-layer Function "
+                f"descriptor, got {type(target).__name__}"
+            )
+            raise TypeError(msg)
+        target._trigger_conditions = trigger_conditions
+        if name is not None:
+            target._action_name = name
+        return target
 
     return decorator
 
diff --git a/python/flink_agents/api/function.py 
b/python/flink_agents/api/function.py
index d95375a40..0263d8482 100644
--- a/python/flink_agents/api/function.py
+++ b/python/flink_agents/api/function.py
@@ -27,7 +27,7 @@ import inspect
 from abc import ABC
 from typing import Any, Callable, List, Tuple
 
-from pydantic import BaseModel, model_serializer
+from pydantic import BaseModel, ValidationInfo, field_validator, 
model_serializer
 
 #: Java parameter types of an action method. Action signatures are fixed
 #: ``(Event, RunnerContext)``, so callers never have to spell them out. A tuple
@@ -59,6 +59,15 @@ class PythonFunction(Function):
     module: str
     qualname: str
 
+    @field_validator("module", "qualname")
+    @classmethod
+    def _identifiers_must_not_be_empty(cls, value: str, info: ValidationInfo) 
-> str:
+        """Reject an empty identifier at construction so every path fails 
fast."""
+        if not value:
+            msg = f"{info.field_name} must not be empty"
+            raise ValueError(msg)
+        return value
+
     @model_serializer
     def __serialize(self) -> dict[str, Any]:
         return {
@@ -107,6 +116,15 @@ class JavaFunction(Function):
     method_name: str
     parameter_types: List[str]
 
+    @field_validator("qualname", "method_name")
+    @classmethod
+    def _identifiers_must_not_be_empty(cls, value: str, info: ValidationInfo) 
-> str:
+        """Reject an empty identifier at construction so every path fails 
fast."""
+        if not value:
+            msg = f"{info.field_name} must not be empty"
+            raise ValueError(msg)
+        return value
+
     @classmethod
     def for_action(cls, qualname: str, method_name: str) -> "JavaFunction":
         """Build a descriptor for a Java action, filling the fixed signature.
diff --git a/python/flink_agents/api/tests/test_decorators.py 
b/python/flink_agents/api/tests/test_decorators.py
index 882f5ea2b..07ff823ec 100644
--- a/python/flink_agents/api/tests/test_decorators.py
+++ b/python/flink_agents/api/tests/test_decorators.py
@@ -17,10 +17,10 @@
 
#################################################################################
 import pytest
 
-from flink_agents.api.decorators import action, tool
+from flink_agents.api.decorators import ActionDeclaration, 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.function import JavaFunction, PythonFunction
+from flink_agents.api.function import JavaFunction
 from flink_agents.api.runner_context import RunnerContext
 from flink_agents.api.tools import InjectedArg
 
@@ -104,77 +104,74 @@ def _java_target() -> JavaFunction:
     return JavaFunction.for_action("com.example.Handlers", "handle")
 
 
-def test_action_decorator_with_cross_language_target() -> None:
+def test_action_applied_to_descriptor_returns_immutable_declaration() -> None:
     target = _java_target()
 
-    @action(EventType.InputEvent, target=target)
-    def stub(event: Event, ctx: RunnerContext) -> None:
-        msg = "cross-language stub"
-        raise NotImplementedError(msg)
+    declaration = action(EventType.InputEvent)(target)
 
-    assert stub._trigger_conditions == (InputEvent.EVENT_TYPE,)
-    assert stub._target is target
+    assert isinstance(declaration, ActionDeclaration)
+    assert declaration.trigger_conditions == (InputEvent.EVENT_TYPE,)
+    assert declaration.func is target
+    assert declaration.name is None
 
 
-def test_action_decorator_rejects_non_function_target() -> None:
-    with pytest.raises(TypeError, match="api-layer Function descriptor"):
-
-        @action(EventType.InputEvent, target="not a function")  # type: 
ignore[arg-type]
-        def stub(event: Event, ctx: RunnerContext) -> None:
-            pass
+def test_action_declaration_does_not_mutate_descriptor() -> None:
+    target = _java_target()
 
+    action(EventType.InputEvent)(target)
 
-def test_action_decorator_without_target_does_not_set_attribute() -> None:
-    @action(EventType.InputEvent)
-    def regular(event: Event, ctx: RunnerContext) -> None:
-        pass
+    # The descriptor is pure data and must stay free of declaration metadata,
+    # so the same descriptor can back multiple independent declarations.
+    assert not hasattr(target, "_trigger_conditions")
+    assert not hasattr(target, "_action_name")
 
-    assert not hasattr(regular, "_target")
 
+def test_action_declaration_keeps_shared_descriptor_declarations_distinct() -> 
None:
+    shared = _java_target()
 
-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"):
+    first = action(EventType.InputEvent)(shared)
+    second = action(EventType.OutputEvent, name="renamed")(shared)
 
-        @action(EventType.InputEvent, target=bad)
-        def stub(event: Event, ctx: RunnerContext) -> None:
-            pass
+    assert first.trigger_conditions == (InputEvent.EVENT_TYPE,)
+    assert second.trigger_conditions == (OutputEvent.EVENT_TYPE,)
+    assert second.name == "renamed"
+    assert first.func is second.func is shared
 
 
-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"):
+def test_action_rejects_non_callable_non_descriptor() -> None:
+    with pytest.raises(TypeError, match="callable or an api-layer Function"):
+        action(EventType.InputEvent)("not a function")
 
-        @action(EventType.InputEvent, target=bad)
-        def stub(event: Event, ctx: RunnerContext) -> None:
-            pass
 
+def test_native_action_does_not_produce_declaration() -> None:
+    @action(EventType.InputEvent)
+    def regular(event: Event, ctx: RunnerContext) -> None:
+        pass
 
-def test_action_decorator_rejects_python_target_with_empty_module() -> None:
-    bad = PythonFunction(module="", qualname="handle")
-    with pytest.raises(ValueError, match="module"):
+    assert not isinstance(regular, ActionDeclaration)
+    assert regular._trigger_conditions == (InputEvent.EVENT_TYPE,)
+    assert not hasattr(regular, "_action_name")
 
-        @action(EventType.InputEvent, target=bad)
-        def stub(event: Event, ctx: RunnerContext) -> None:
-            pass
 
+def test_native_action_records_name_override() -> None:
+    @action(EventType.InputEvent, name="renamed")
+    def regular(event: Event, ctx: RunnerContext) -> None:
+        pass
 
-def test_action_decorator_rejects_python_target_with_empty_qualname() -> None:
-    bad = PythonFunction(module="pkg.mod", qualname="")
-    with pytest.raises(ValueError, match="qualname"):
+    assert regular._action_name == "renamed"
 
-        @action(EventType.InputEvent, target=bad)
-        def stub(event: Event, ctx: RunnerContext) -> None:
-            pass
 
+def test_empty_name_override_is_normalized_to_none() -> None:
+    # An empty-string override means "no override" (mirrors Java 
resolveActionName):
+    # it must not be recorded on a native action nor stored on a declaration.
+    @action(EventType.InputEvent, name="")
+    def regular(event: Event, ctx: RunnerContext) -> None:
+        pass
 
-def test_action_decorator_target_error_names_decorated_function() -> None:
-    bad = PythonFunction(module="pkg.mod", qualname="")
-    with pytest.raises(ValueError, match="my_named_stub"):
+    assert not hasattr(regular, "_action_name")
 
-        @action(EventType.InputEvent, target=bad)
-        def my_named_stub(event: Event, ctx: RunnerContext) -> None:
-            pass
+    declaration = action(EventType.InputEvent, name="")(_java_target())
+    assert declaration.name is None
 
 
 def test_tool_decorator_supports_injected_args() -> None:
diff --git a/python/flink_agents/api/tests/test_function.py 
b/python/flink_agents/api/tests/test_function.py
new file mode 100644
index 000000000..3e3ba6b05
--- /dev/null
+++ b/python/flink_agents/api/tests/test_function.py
@@ -0,0 +1,64 @@
+################################################################################
+#  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.
+#################################################################################
+"""Construction-time validation for the api-layer Function descriptors.
+
+A descriptor identifies a cross-language target by its required identifiers, so
+an empty identifier can never resolve. Rejecting it at construction makes every
+declaration path fail fast -- ``action()``, ``add_action``, direct construction
+and deserialization alike -- instead of deferring the failure to runtime.
+"""
+import pytest
+
+from flink_agents.api.function import JavaFunction, PythonFunction
+
+
+def test_python_function_accepts_valid_identifiers() -> None:
+    fn = PythonFunction(module="pkg.mod", qualname="MyClass.method")
+    assert fn.module == "pkg.mod"
+    assert fn.qualname == "MyClass.method"
+
+
+def test_python_function_rejects_empty_module() -> None:
+    with pytest.raises(ValueError, match="module"):
+        PythonFunction(module="", qualname="handle")
+
+
+def test_python_function_rejects_empty_qualname() -> None:
+    with pytest.raises(ValueError, match="qualname"):
+        PythonFunction(module="pkg.mod", qualname="")
+
+
+def test_java_function_accepts_valid_identifiers() -> None:
+    fn = JavaFunction.for_action("com.example.Handlers", "handle")
+    assert fn.qualname == "com.example.Handlers"
+    assert fn.method_name == "handle"
+
+
+def test_java_function_rejects_empty_qualname() -> None:
+    with pytest.raises(ValueError, match="qualname"):
+        JavaFunction(qualname="", method_name="handle", parameter_types=[])
+
+
+def test_java_function_rejects_empty_method_name() -> None:
+    with pytest.raises(ValueError, match="method_name"):
+        JavaFunction(qualname="com.example.X", method_name="", 
parameter_types=[])
+
+
+def test_java_function_for_action_rejects_empty_qualname() -> None:
+    with pytest.raises(ValueError, match="qualname"):
+        JavaFunction.for_action("", "handle")
diff --git 
a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/python_agent_with_java_action.py
 
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/python_agent_with_java_action.py
index 981beb487..3a9f2125c 100644
--- 
a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/python_agent_with_java_action.py
+++ 
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/python_agent_with_java_action.py
@@ -32,17 +32,12 @@ JAVA_HANDLER_METHOD = "multiplyByTwo"
 class PythonAgentWithJavaActionAgent(Agent):
     """Python agent whose overlapping expressions dispatch one Java action."""
 
-    def __init__(self) -> None:
-        """Create a PythonAgentWithJavaActionAgent."""
-        super().__init__()
-        self.add_action(
-            name="multiply_by_two",
-            trigger_conditions=[
-                "type == EventType.InputEvent && input > 1 && input < 7",
-                "type == EventType.InputEvent && input > 3 && input < 9",
-            ],
-            func=JavaFunction.for_action(JAVA_HANDLER_QUALNAME, 
JAVA_HANDLER_METHOD),
-        )
+    # Cross-language action declared on the attribute that is its Java target;
+    # the attribute name is the action name.
+    multiply_by_two = action(
+        "type == EventType.InputEvent && input > 1 && input < 7",
+        "type == EventType.InputEvent && input > 3 && input < 9",
+    )(JavaFunction.for_action(JAVA_HANDLER_QUALNAME, JAVA_HANDLER_METHOD))
 
 
 class InvalidTriggerConditionAgent(Agent):
diff --git a/python/flink_agents/plan/agent_plan.py 
b/python/flink_agents/plan/agent_plan.py
index a8034b168..79a2e8e89 100644
--- a/python/flink_agents/plan/agent_plan.py
+++ b/python/flink_agents/plan/agent_plan.py
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, cast
 from pydantic import BaseModel, field_serializer, model_validator
 
 from flink_agents.api.agents.agent import Agent
+from flink_agents.api.decorators import ActionDeclaration
 from flink_agents.api.function import Function as ApiFunction
 from flink_agents.api.function import JavaFunction as ApiJavaFunction
 from flink_agents.api.function import PythonFunction as ApiPythonFunction
@@ -143,7 +144,9 @@ class AgentPlan(BaseModel):
         """Build a AgentPlan from user defined agent."""
         actions = {}
         for action in _get_actions(agent) + BUILT_IN_ACTIONS:
-            assert action.name not in actions, f"Duplicate action name: 
{action.name}"
+            if action.name in actions:
+                msg = f"Duplicate action name: {action.name}"
+                raise RuntimeError(msg)
             actions[action.name] = action
 
         resource_providers = {}
@@ -196,9 +199,10 @@ class AgentPlan(BaseModel):
         return self.actions[action_name].config.get(key, None)
 
 
-def _action_marker(value: Any) -> tuple | None:
-    """Return ``(inner_callable, trigger_conditions, target)`` if ``value`` is 
@action.
+def _native_action_marker(value: Any) -> tuple | None:
+    """Return the native ``@action`` marker as a 3-tuple, or ``None``.
 
+    The tuple is ``(inner_callable, trigger_conditions, name_override)``.
     ``@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.
@@ -215,7 +219,12 @@ def _action_marker(value: Any) -> tuple | None:
     )
     if marker is None:
         return None
-    return inner, marker._trigger_conditions, getattr(marker, "_target", None)
+    return inner, marker._trigger_conditions, getattr(marker, "_action_name", 
None)
+
+
+def _is_action_attr(value: Any) -> bool:
+    """True if ``value`` is an @action member: a declaration or a tagged 
callable."""
+    return isinstance(value, ActionDeclaration) or 
_native_action_marker(value) is not None
 
 
 def _get_actions(agent: Agent) -> List[Action]:
@@ -231,13 +240,13 @@ def _get_actions(agent: Agent) -> List[Action]:
     List[Action]
         List of Action defined in the agent.
     """
-    # __dict__ skips inherited @action methods; reject loudly.
+    # __dict__ skips inherited @action members; reject loudly.
     agent_class = agent.__class__
     for parent in agent_class.__mro__[1:]:
         if parent is Agent or parent is object:
             break
         for parent_name, parent_value in parent.__dict__.items():
-            if _action_marker(parent_value) is not None:
+            if _is_action_attr(parent_value):
                 msg = (
                     f"Inherited @action '{parent.__qualname__}.{parent_name}' 
is "
                     f"not supported; declare on the concrete agent."
@@ -246,19 +255,25 @@ def _get_actions(agent: Agent) -> List[Action]:
 
     actions = []
     for name, value in agent_class.__dict__.items():
-        marker = _action_marker(value)
+        if isinstance(value, ActionDeclaration):
+            # Cross-language: the attribute holds an immutable declaration that
+            # wraps the executable descriptor.
+            actions.append(
+                Action(
+                    name=value.name if value.name is not None else name,
+                    exec=_to_plan_function(value.func),
+                    trigger_conditions=list(value.trigger_conditions),
+                )
+            )
+            continue
+        marker = _native_action_marker(value)
         if marker is None:
             continue
-        inner, trigger_conditions, target = marker
-        exec_ = (
-            _to_plan_function(target)
-            if target is not None
-            else PythonFunction.from_callable(inner)
-        )
+        inner, trigger_conditions, name_override = marker
         actions.append(
             Action(
-                name=name,
-                exec=exec_,
+                name=name_override if name_override is not None else name,
+                exec=PythonFunction.from_callable(inner),
                 trigger_conditions=list(trigger_conditions),
             )
         )
diff --git a/python/flink_agents/plan/tests/test_agent_plan.py 
b/python/flink_agents/plan/tests/test_agent_plan.py
index b040acdaf..ae8d1f857 100644
--- a/python/flink_agents/plan/tests/test_agent_plan.py
+++ b/python/flink_agents/plan/tests/test_agent_plan.py
@@ -183,20 +183,15 @@ _JAVA_HANDLER_QUALNAME = (
 )
 
 
-class AgentWithCrossLanguageDecoratedAction(Agent):
-    @action(
-        EventType.InputEvent,
-        target=JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput"),
+class AgentWithCrossLanguageDescriptorAction(Agent):
+    handle = action(EventType.InputEvent)(
+        JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput")
     )
-    @staticmethod
-    def handle(event: Event, ctx: RunnerContext) -> None:
-        msg = "cross-language stub"
-        raise NotImplementedError(msg)
 
 
-def test_decorated_action_with_target_compiles_to_plan_java_function() -> None:
+def test_descriptor_action_compiles_to_plan_java_function() -> None:
     plan = AgentPlan.from_agent(
-        AgentWithCrossLanguageDecoratedAction(), AgentConfiguration()
+        AgentWithCrossLanguageDescriptorAction(), AgentConfiguration()
     )
     action = plan.actions["handle"]
     assert action.exec.qualname == _JAVA_HANDLER_QUALNAME
@@ -204,6 +199,59 @@ def 
test_decorated_action_with_target_compiles_to_plan_java_function() -> None:
     assert action.trigger_conditions == [InputEvent.EVENT_TYPE]
 
 
+class _BaseAgentWithInheritedDescriptorAction(Agent):
+    """Base with a cross-language descriptor @action — verifies the inheritance
+    guard also covers ``ActionDeclaration`` members, not just native callables.
+    """
+
+    shared = action(EventType.InputEvent)(
+        JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput")
+    )
+
+
+class 
_ConcreteAgentInheritingDescriptorAction(_BaseAgentWithInheritedDescriptorAction):
+    """Concrete agent inheriting the descriptor ``shared`` action."""
+
+
+def test_descriptor_action_inherited_from_parent_agent_class_is_rejected() -> 
None:
+    with pytest.raises(RuntimeError, match="Inherited @action") as exc:
+        AgentPlan.from_agent(
+            _ConcreteAgentInheritingDescriptorAction(), AgentConfiguration()
+        )
+    assert "shared" in str(exc.value)
+    assert "_BaseAgentWithInheritedDescriptorAction" in str(exc.value)
+
+
+class AgentWithDuplicateActionName(Agent):
+    """Two members whose ``name`` overrides collide — must fail fast."""
+
+    first = action(EventType.InputEvent, name="dup")(
+        JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput")
+    )
+    second = action(EventType.InputEvent, name="dup")(
+        JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput")
+    )
+
+
+def test_duplicate_action_name_is_rejected() -> None:
+    with pytest.raises(RuntimeError, match="Duplicate action name"):
+        AgentPlan.from_agent(AgentWithDuplicateActionName(), 
AgentConfiguration())
+
+
+class AgentWithEmptyNameOverride(Agent):
+    """An empty ``name`` override falls back to the attribute name (Java 
parity)."""
+
+    handle = action(EventType.InputEvent, name="")(
+        JavaFunction.for_action(_JAVA_HANDLER_QUALNAME, "handleInput")
+    )
+
+
+def test_empty_name_override_falls_back_to_attribute_name() -> None:
+    plan = AgentPlan.from_agent(AgentWithEmptyNameOverride(), 
AgentConfiguration())
+    assert "handle" in plan.actions
+    assert "" not in plan.actions
+
+
 class MyEvent(Event):
     """Event for testing purposes."""
 
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 93e652811..4e27f0cfb 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
@@ -28,6 +28,7 @@ from flink_agents.api.core_options import (
     AgentConfigOptions,
     ConditionEvaluationFailureStrategy,
 )
+from flink_agents.api.decorators import action
 from flink_agents.api.events.event import Event, InputEvent
 from flink_agents.api.function import (
     JavaFunction as ApiJavaFunction,
@@ -119,6 +120,44 @@ def test_compile_agent_with_java_function_descriptor() -> 
None:
     assert action.trigger_conditions== [InputEvent.EVENT_TYPE]
 
 
+def test_compile_agent_with_descriptor_action_class_attribute() -> None:
+    """``action(...)(descriptor)`` as a class attribute becomes a plan action.
+
+    The descriptor-first declaration form mirrors Java's ``@Action`` field: the
+    attribute name is the action name and the wrapped descriptor is the
+    executable target, with no placeholder body required.
+    """
+
+    class DescriptorAgent(Agent):
+        handle = action(InputEvent.EVENT_TYPE)(
+            ApiJavaFunction.for_action("com.example.Handlers", "handle")
+        )
+
+    plan = AgentPlan.from_agent(DescriptorAgent(), AgentConfiguration())
+    compiled = plan.actions["handle"]
+
+    assert isinstance(compiled.exec, PlanJavaFunction), (
+        f"Expected plan JavaFunction, got {type(compiled.exec).__name__}"
+    )
+    assert compiled.exec.qualname == "com.example.Handlers"
+    assert compiled.exec.method_name == "handle"
+    assert compiled.trigger_conditions == [InputEvent.EVENT_TYPE]
+
+
+def test_compile_agent_with_named_descriptor_action_class_attribute() -> None:
+    """``name=`` on the descriptor form overrides the attribute name."""
+
+    class NamedDescriptorAgent(Agent):
+        handle = action(InputEvent.EVENT_TYPE, name="renamed")(
+            ApiJavaFunction.for_action("com.example.Handlers", "handle")
+        )
+
+    plan = AgentPlan.from_agent(NamedDescriptorAgent(), AgentConfiguration())
+
+    assert "renamed" in plan.actions
+    assert "handle" not in plan.actions
+
+
 def test_python_plan_compile_does_not_validate_java_class_exists() -> None:
     """Python plan compile must not require the Java class to exist locally.
 

Reply via email to