Repository navigation
[api][plan][java][python] Support direct declaration of cross-language actions - #1159
Conversation
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
wenjin272
left a comment
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for addressing my comments. LGTM
Linked issue: close #1085
Purpose of change
A cross-language action is now declared directly on the member that is its target. In Java,
@Actionon astatic finalfield holding an api-layerFunctiondescriptor declares that descriptor as the cross-language target; in Python, applyingaction(...)to aFunctiondescriptor 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 separatetargetnaming something the annotation merely sits beside. The member name is the action name; an optionalnameoverrides it. Malformed declarations now fail when the plan is built.Runtime flow
Plan construction walks the concrete agent's own declared members. Java
AgentPlanreads@Actionmethods as native implementations and@Actionfields throughreadActionDescriptorField(which requiresstatic final, non-null, an api-layerFunction), then lifts the descriptor withtoPlanFunction. Pythonaction(...)marks a function native, or wraps a descriptor in an immutableActionDeclarationthatfrom_agentcollects. 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; astatic finalfield holds a realapi.function.PythonFunction/JavaFunctioninstead, 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 isJavaFunction.for_action.Python wraps rather than mutates.
action(...)(descriptor)returns a frozenActionDeclaration, 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""toNoneto match.Identifiers are validated at construction. A descriptor names a cross-language target, so an empty
module/qualName/methodNamecan 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
namestaticmethod / Python function""static finalFunctionfield / Python descriptor""static final/null/ not aFunctionaction()raisesTypeErrormodule/qualName/methodNameBehavioral contracts
@Actionmethod, or a Pythonaction-decorated function, is the native implementation; no stub body is required.@Actionstatic finalfield holding an api-layerFunction, or a Pythonaction(...)applied to a descriptor, makes that descriptor the cross-language target.action(...)(descriptor)returns an immutableActionDeclaration; a shared descriptor is not mutated, so each declaration keeps its own trigger conditions and name.nameoverrides it;""means "no override" in both languages.static final, isnull, or is not aFunction, is rejected at plan construction.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.
IllegalStateExceptionfor a non-static-final/null/ non-Functionfield, an inherited action, and a duplicate name.PythonFunction/JavaFunctionraiseIllegalArgumentException(arequireNonEmptycheck alongside the existingrequireNonNull), and PythonPythonFunction/JavaFunctionraise a pydanticValidationError. This covers every construction path, so a partially specified target can no longer be deferred to dispatch.action()raisesTypeErrorat 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.from_agentraisesRuntimeErrorfor an inherited action and a duplicate name.nameis not a failure; it is normalized to the member name.Tests
Both plan suites run offline. Java:
AgentPlanTest, plusPythonFunctionTest/JavaFunctionTestfor 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:ActionDeclaration, shared descriptor not mutatedThe 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_actionto the declarative form and drive a live cross-language dispatch in both directions: Java-to-Python (JavaAgentWithPythonActionTest, an@ActionPythonFunctionfield) 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):
testNativeMethodActionCompilesToJavaFunctionExec/test_native_action_does_not_produce_declarationtestFieldDescriptorCompilesToPythonFunctionExec/test_descriptor_action_compiles_to_plan_java_function,test_compile_agent_with_descriptor_action_class_attributetest_action_applied_to_descriptor_returns_immutable_declaration,test_action_declaration_does_not_mutate_descriptor,test_action_declaration_keeps_shared_descriptor_declarations_distincttestNameOverrideReplacesMemberName,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_attributetestNonStaticFinalActionFieldIsRejected,testNullActionFieldIsRejected,testNonDescriptorActionFieldIsRejected/test_action_rejects_non_callable_non_descriptortestActionFieldInheritedFromParentAgentClassIsRejected/test_descriptor_action_inherited_from_parent_agent_class_is_rejectedtestDuplicateActionNameIsRejected/test_duplicate_action_name_is_rejectedtestNativeMethodActionCompilesToJavaFunctionExec,testFieldDescriptorCompilesToPythonFunctionExec/test_descriptor_action_compiles_to_plan_java_functionPythonFunctionTest.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_qualnameMigration. Java
@Action(EventType.InputEvent) private static final PythonFunction handleInput = PythonFunction.of("my_pkg.handlers", "handle_input");replaces the@Action(target = @PythonFunction(...))stub method. Pythonhandle_input = action(InputEvent.EVENT_TYPE)(JavaFunction.for_action("com.example.MyHandlers", "handleInput"))replacesaction(..., target=...).Implementation invariants, not caller-observable:
resolveActionNamereturns the member name when the override isnullor empty; the field and method branches both funnel throughtoPlanFunction.ActionDeclarationis a frozen dataclass over(trigger_conditions, func, name); only the native path sets_action_name/_trigger_conditionson the function.registerAction(Java) andfrom_agent(Python) key actions by resolved name and reject a collision on insert.PythonFunction/JavaFunctionconstructors reject an empty identifier in addition tonullvia a shared check;PythonFunction.of()(Java) andJavaFunction.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 nestedapi.annotation.PythonFunction; Python dropsaction(..., target=...). Migration examples are in the collapsed block under Tests.Hardened: the api
PythonFunction/JavaFunctionconstructors now reject an empty identifier in addition tonull, in both languages; the wire form is unchanged. Additive:PythonFunction.of()(the Java-side factory for a Python target) and thenameoverride.Unchanged: native method/function actions; Java's
JavaFunction.fromMethodand Python'sJavaFunction.for_action; trigger-condition (value=) semantics; the compiled plan and the YAML API.Documentation
doc-neededdoc-not-neededdoc-includeddocs/content/docs/development/workflow_agent.mdis 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?
Generated-by: Qoder 1.32.0 (Qwen3.8-Max)