Skip to content

[api][plan][java][python] Support direct declaration of cross-language actions - #1159

Merged
wenjin272 merged 2 commits into
apache:mainfrom
yunfengzhou-hub:issue-1085-action-descriptor
Oct 1, 2026
Merged

wenjin272 merged 2 commits into
apache:mainfrom
yunfengzhou-hub:issue-1085-action-descriptor

Conversation

@yunfengzhou-hub

@yunfengzhou-hub yunfengzhou-hub commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue: close #1085

Purpose of change

A cross-language action is now declared directly on the member that is its target. In Java, @Action on a static final field holding an api-layer Function descriptor declares that descriptor as the cross-language target; in Python, applying action(...) to a Function descriptor does the same. The same annotation on a method, or the same decorator on a function, declares the native implementation. No member is a placeholder, and there is no separate target naming something the annotation merely sits beside. The member name is the action name; an optional name overrides it. Malformed declarations now fail when the plan is built.

Runtime flow

Plan construction walks the concrete agent's own declared members. Java AgentPlan reads @Action methods as native implementations and @Action fields through readActionDescriptorField (which requires static final, non-null, an api-layer Function), then lifts the descriptor with toPlanFunction. Python action(...) marks a function native, or wraps a descriptor in an immutable ActionDeclaration that from_agent collects. Both resolve the name the same way and reject duplicates and inherited actions as they register.

Key decisions

The target is declared, not mirrored. A Java annotation attribute cannot hold an arbitrary object (JLS 9.6.1), which is what forced the old mirror @PythonFunction; a static final field holds a real api.function.PythonFunction/JavaFunction instead, the same object the plan already compiles. PythonFunction.of(module, qualName) is added as the readable Java-side factory for a Python target; its Python-side counterpart for declaring a Java target is JavaFunction.for_action.

Python wraps rather than mutates. action(...)(descriptor) returns a frozen ActionDeclaration, so a descriptor shared by two actions keeps each one's trigger conditions and name separate.

Empty name means unset, in both languages. A Java annotation cannot tell its "" default from an explicit "", so the name falls back to the member; Python normalizes "" to None to match.

Identifiers are validated at construction. A descriptor names a cross-language target, so an empty module/qualName/methodName can never resolve. Both languages reject it when the descriptor is built, which makes every declaration path fail up front instead of deferring the failure to dispatch.

Behavioral Semantics

Interaction decisions

Member name Placement Result
Java static method / Python function unset or "" own class native action, name = member
Java static final Function field / Python descriptor unset or "" own class cross-language action, name = member
either of the above non-empty own class name = override
Java field not static final / null / not a Function any own class plan build fails
Python target neither callable nor descriptor any own class action() raises TypeError
Java/Python descriptor with an empty module/qualName/methodName any own class descriptor construction fails, in both languages
any action any inherited from a superclass plan build fails
two members resolving to one name any own class plan build fails

Behavioral contracts

  1. A Java @Action method, or a Python action-decorated function, is the native implementation; no stub body is required.
  2. A Java @Action static final field holding an api-layer Function, or a Python action(...) applied to a descriptor, makes that descriptor the cross-language target.
  3. Python action(...)(descriptor) returns an immutable ActionDeclaration; a shared descriptor is not mutated, so each declaration keeps its own trigger conditions and name.
  4. The action name is the member name unless a non-empty name overrides it; "" means "no override" in both languages.
  5. A malformed Java action field, one that is not static final, is null, or is not a Function, is rejected at plan construction.
  6. An action inherited from a superclass is rejected; it must sit on the concrete agent.
  7. Two actions resolving to the same name are rejected.
  8. A descriptor action compiles to the same exec as before, so the plan wire format and the YAML API are unchanged.
  9. A descriptor with an empty required identifier is rejected when it is constructed, in both languages, so no path (of(), new, Jackson; for_action(), direct, deserialization) can defer a partially specified target to dispatch.

Failure behavior

Every malformed declaration fails fast at build time; none is silently dropped or deferred to dispatch. The behavior change worth naming: an inherited action used to be skipped silently, and now raises.

  • Java plan construction raises IllegalStateException for a non-static-final / null / non-Function field, an inherited action, and a duplicate name.
  • The api descriptors reject an empty identifier at construction in both languages: Java PythonFunction/JavaFunction raise IllegalArgumentException (a requireNonEmpty check alongside the existing requireNonNull), and Python PythonFunction/JavaFunction raise a pydantic ValidationError. This covers every construction path, so a partially specified target can no longer be deferred to dispatch.
  • Python action() raises TypeError at decoration time for a target that is neither callable nor a descriptor. The earlier decorator-level empty-identifier check is removed as redundant now that the constructors reject it.
  • Python from_agent raises RuntimeError for an inherited action and a duplicate name.
  • An empty-string name is not a failure; it is normalized to the member name.

Tests

Both plan suites run offline. Java: AgentPlanTest, plus PythonFunctionTest/JavaFunctionTest for the descriptor constructors. Python: test_decorators.py, test_function.py, test_agent_plan.py, test_agent_plan_cross_language.py. Every contract is pinned on both sides where the language exposes it:

Contract Java Python
1 native member is the implementation ✅ ✅
2 descriptor member is the target ✅ ✅
3 immutable ActionDeclaration, shared descriptor not mutated — ✅
4 name default / override / empty ✅ ✅
5 malformed field rejected ✅ ✅
6 inherited action rejected ✅ ✅
7 duplicate name rejected ✅ ✅
8 exec and wire format unchanged ✅ ✅
9 empty identifier rejected at construction ✅ ✅

The failure paths carry the risk and are asserted on the raised exception and its message, not merely on "did not register."

End-to-end coverage. The cross-language E2E agents are migrated off addAction/add_action to the declarative form and drive a live cross-language dispatch in both directions: Java-to-Python (JavaAgentWithPythonActionTest, an @Action PythonFunction field) and Python-to-Java (python_agent_with_java_action_test.py, action(...)(JavaFunction.for_action(...))), each asserting the overlapping-condition output [6, 10, 14]. The compiled exec is still asserted equal to the pre-change form, so the plan wire format is unchanged.

Contracts-to-tests mapping, migration, and implementation invariants

Contract to tests (Java / Python):

  1. testNativeMethodActionCompilesToJavaFunctionExec / test_native_action_does_not_produce_declaration
  2. testFieldDescriptorCompilesToPythonFunctionExec / test_descriptor_action_compiles_to_plan_java_function, test_compile_agent_with_descriptor_action_class_attribute
  3. (Java has no descriptor-mutation surface) / test_action_applied_to_descriptor_returns_immutable_declaration, test_action_declaration_does_not_mutate_descriptor, test_action_declaration_keeps_shared_descriptor_declarations_distinct
  4. testNameOverrideReplacesMemberName, testNameOverrideReplacesMethodName / test_native_action_records_name_override, test_empty_name_override_is_normalized_to_none, test_empty_name_override_falls_back_to_attribute_name, test_compile_agent_with_named_descriptor_action_class_attribute
  5. testNonStaticFinalActionFieldIsRejected, testNullActionFieldIsRejected, testNonDescriptorActionFieldIsRejected / test_action_rejects_non_callable_non_descriptor
  6. testActionFieldInheritedFromParentAgentClassIsRejected / test_descriptor_action_inherited_from_parent_agent_class_is_rejected
  7. testDuplicateActionNameIsRejected / test_duplicate_action_name_is_rejected
  8. testNativeMethodActionCompilesToJavaFunctionExec, testFieldDescriptorCompilesToPythonFunctionExec / test_descriptor_action_compiles_to_plan_java_function
  9. PythonFunctionTest.rejectsEmptyModule/rejectsEmptyQualName/factoryRejectsEmptyIdentifiers, JavaFunctionTest.rejectsEmptyQualName/rejectsEmptyMethodName / test_function.py: test_python_function_rejects_empty_module, test_python_function_rejects_empty_qualname, test_java_function_rejects_empty_qualname, test_java_function_rejects_empty_method_name, test_java_function_for_action_rejects_empty_qualname

Migration. Java @Action(EventType.InputEvent) private static final PythonFunction handleInput = PythonFunction.of("my_pkg.handlers", "handle_input"); replaces the @Action(target = @PythonFunction(...)) stub method. Python handle_input = action(InputEvent.EVENT_TYPE)(JavaFunction.for_action("com.example.MyHandlers", "handleInput")) replaces action(..., target=...).

Implementation invariants, not caller-observable:

  • Java resolveActionName returns the member name when the override is null or empty; the field and method branches both funnel through toPlanFunction.
  • Python ActionDeclaration is a frozen dataclass over (trigger_conditions, func, name); only the native path sets _action_name/_trigger_conditions on the function.
  • registerAction (Java) and from_agent (Python) key actions by resolved name and reject a collision on insert.
  • The api PythonFunction/JavaFunction constructors reject an empty identifier in addition to null via a shared check; PythonFunction.of() (Java) and JavaFunction.for_action (Python) route through the same constructors, so the rejection covers every factory. The serialized wire form is unchanged.

API

Breaking, within the 0.4.0 window; the old placeholder forms are removed outright rather than deprecated. Java drops @Action.target() and the nested api.annotation.PythonFunction; Python drops action(..., target=...). Migration examples are in the collapsed block under Tests.

Hardened: the api PythonFunction/JavaFunction constructors now reject an empty identifier in addition to null, in both languages; the wire form is unchanged. Additive: PythonFunction.of() (the Java-side factory for a Python target) and the name override.

Unchanged: native method/function actions; Java's JavaFunction.fromMethod and Python's JavaFunction.for_action; trigger-condition (value=) semantics; the compiled plan and the YAML API.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

docs/content/docs/development/workflow_agent.md is migrated to the new forms, and the "stub that should raise" narrative is replaced by "the declared member is the executable target".

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Qoder 1.32.0 (Qwen3.8-Max)

The annotated/decorated member is now the actual cross-language target
rather than a placeholder stub body.

Java: @action accepts a `static final` field holding an api-layer Function
descriptor (e.g. PythonFunction) as the cross-language target, and a method
as the native implementation. Drop the `target()` attribute and the nested
`annotation.PythonFunction`; add `PythonFunction.of()` and an optional
`name()` override.

Python: `action(...)` applied to a Function descriptor returns an immutable
ActionDeclaration instead of mutating the shared descriptor. Drop `target=`;
add `name=`.

Plan construction fails fast on duplicate/inherited action names and on
fields that are non-static-final, null, or not a Function descriptor. Tests
and the workflow_agent cross-language docs are migrated to the new forms.

The plan wire format and YAML API are unchanged. This is a breaking API
change for the 0.4.0 window; the old placeholder forms are removed outright.

Test: api+plan Java tests (297 passed), Python api/plan suites, full reactor
test-compile, ruff check and spotless:check all green.

Generated-by: Qoder

AI-Contributed/Feature: 0/389
AI-Contributed/UT: 0/285
@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Sep 25, 2026

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for simplifying cross-language action declarations! Please also cover the new declaration forms in both Java-to-Python and Python-to-Java E2E tests. The added tests verify plan compilation, while the existing cross-language E2E tests still use addAction / add_action.

*/
public static PythonFunction of(String module, String qualName) {
return new PythonFunction(module, qualName);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we reject empty module and qualName in the constructor, alongside the null checks, and add regression tests? The old annotation path rejected partially specified targets, but the new field declaration accepts them and defers the failure to runtime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — both PythonFunction and JavaFunction now reject an empty identifier in the constructor alongside the null checks, so every construction path fails up front rather than at dispatch. Regression tests added on both sides.

Besides, both cross-language E2E agents now use the declarative form instead of addAction/add_action: JavaAgentWithPythonActionAgent declares an @action PythonFunction field, and PythonAgentWithJavaActionAgent declares action(...)(JavaFunction.for_action(...)). Both run in the real cross-language E2E now, not just plan compilation.

…2E actions

Address review feedback on the descriptor-form cross-language actions.

The api descriptors now reject an empty identifier at construction in both
languages, so a partially specified target fails up front instead of at
dispatch. The Java PythonFunction/JavaFunction constructors add a
requireNonEmpty check alongside the existing requireNonNull, covering of(),
new, and Jackson deserialization. The Python PythonFunction/JavaFunction add
a pydantic field_validator covering for_action(), direct construction, and
deserialization; the now-redundant descriptor check in action() is removed.

The cross-language E2E agents move from addAction/add_action to the
documented declarative form: JavaAgentWithPythonActionAgent declares an
@action PythonFunction field, and PythonAgentWithJavaActionAgent declares
action(...)(JavaFunction.for_action(...)). Both directions run at runtime and
assert the same overlapping-condition output.

Also drops a dangling javadoc reference in PythonFunction.of().

Test: Java api (PythonFunctionTest, JavaFunctionTest) and plan suites, Python
api/plan suites, ruff check, spotless:check, and both cross-language E2E
directions all green.

Generated-by: Qoder

Co-Authored-By: Qoder <noreply@qoder.com>
AI-Contributed/Feature: 95/95
AI-Contributed/UT: 152/152
@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. and removed doc-included Your PR already contains the necessary documentation updates. labels Sep 30, 2026

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for addressing my comments. LGTM

@wenjin272
wenjin272 merged commit 1bf3d1c into apache:main Oct 1, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Tech Debt][API][Action] Improve annotation-based cross-language Action declarations

2 participants