From 34d6a2996aef08154608dbc840191c53a5b98456 Mon Sep 17 00:00:00 2001 From: WenjinXie Date: Tue, 19 May 2026 14:59:23 +0800 Subject: [PATCH 1/2] [api][plan][runtime] Cross-language Function descriptors and FunctionTool Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apache/flink/agents/api/agents/Agent.java | 34 +++- .../flink/agents/api/function/Function.java | 28 ++++ .../agents/api/function/JavaFunction.java | 113 +++++++++++++ .../agents/api/function/PythonFunction.java | 76 +++++++++ .../python/PythonResourceAdapter.java | 32 ++++ .../flink/agents/api/tools/FunctionTool.java | 29 +++- .../apache/flink/agents/api/tools/Tool.java | 13 +- .../agents/api/agents/AgentAddActionTest.java | 79 +++++++++ .../agents/api/function/FunctionTest.java | 34 ++++ .../agents/api/function/JavaFunctionTest.java | 78 +++++++++ .../api/function/PythonFunctionTest.java | 51 ++++++ .../agents/api/tools/FunctionToolTest.java | 61 +++++++ .../apache/flink/agents/plan/AgentPlan.java | 157 ++++++++++++++++-- .../flink/agents/plan/tools/FunctionTool.java | 101 ++++++++--- .../FunctionToolSetPythonAdapterTest.java | 84 ++++++++++ python/flink_agents/api/tools/utils.py | 9 +- .../flink_agents/runtime/python_java_utils.py | 43 +++++ .../flink/agents/runtime/ResourceCache.java | 6 + .../utils/PythonResourceAdapterImpl.java | 23 +++ .../agents/runtime/ResourceCacheTest.java | 10 ++ 20 files changed, 1000 insertions(+), 61 deletions(-) create mode 100644 api/src/main/java/org/apache/flink/agents/api/function/Function.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/agents/AgentAddActionTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/function/FunctionTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/tools/FunctionToolTest.java create mode 100644 plan/src/test/java/org/apache/flink/agents/plan/tools/FunctionToolSetPythonAdapterTest.java diff --git a/api/src/main/java/org/apache/flink/agents/api/agents/Agent.java b/api/src/main/java/org/apache/flink/agents/api/agents/Agent.java index 230e5a7bb..f73372488 100644 --- a/api/src/main/java/org/apache/flink/agents/api/agents/Agent.java +++ b/api/src/main/java/org/apache/flink/agents/api/agents/Agent.java @@ -18,6 +18,8 @@ package org.apache.flink.agents.api.agents; +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; import org.apache.flink.agents.api.resource.ResourceDescriptor; import org.apache.flink.agents.api.resource.ResourceType; import org.apache.flink.agents.api.resource.SerializableResource; @@ -31,7 +33,7 @@ /** Base class for defining agent logic. */ public class Agent { - private final Map>> actions; + private final Map>> actions; private final Map> resources; @@ -43,7 +45,7 @@ public Agent() { this.actions = new HashMap<>(); } - public Map>> getActions() { + public Map>> getActions() { return actions; } @@ -60,12 +62,7 @@ public Map> getResources() { */ public Agent addAction( String[] eventTypes, Method method, @Nullable Map config) { - String name = method.getName(); - if (actions.containsKey(name)) { - throw new IllegalArgumentException(String.format("Action %s already defined.", name)); - } - actions.put(name, new Tuple3<>(eventTypes, method, config)); - return this; + return addAction(method.getName(), eventTypes, JavaFunction.fromMethod(method), config); } /** @@ -78,6 +75,27 @@ public Agent addAction(String[] eventTypes, Method method) { return addAction(eventTypes, method, null); } + /** + * Add action to agent. + * + * @param name The action name. Must be unique within this agent. + * @param eventTypes The event type strings this action listens to. + * @param function The api-layer function descriptor; will be promoted to a plan-layer + * executable at {@code AgentPlan} construction. + * @param config Optional config for this action. + */ + public Agent addAction( + String name, + String[] eventTypes, + Function function, + @Nullable Map config) { + if (actions.containsKey(name)) { + throw new IllegalArgumentException(String.format("Action %s already defined.", name)); + } + actions.put(name, new Tuple3<>(eventTypes, function, config)); + return this; + } + public void addResourcesIfAbsent(Map> resources) { for (ResourceType type : resources.keySet()) { Map typedResources = resources.get(type); diff --git a/api/src/main/java/org/apache/flink/agents/api/function/Function.java b/api/src/main/java/org/apache/flink/agents/api/function/Function.java new file mode 100644 index 000000000..097a78ee3 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/function/Function.java @@ -0,0 +1,28 @@ +/* + * 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.function; + +/** + * Pure-data marker for user-defined function descriptors carried on the api layer. + * + *

Implementations describe which function ({@link PythonFunction}, {@link + * JavaFunction}) but do not execute it. The plan-layer twins ({@code + * org.apache.flink.agents.plan.Function} and friends) own execution; the conversion from api → plan + * happens during {@code AgentPlan} construction. + */ +public interface Function {} 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 new file mode 100644 index 000000000..2e438d9ce --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/function/JavaFunction.java @@ -0,0 +1,113 @@ +/* + * 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.function; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Pure-data descriptor for a Java method, identified by declaring class FQN, method name, and + * parameter types as strings. + * + *

Parameter types are strings — JVM primitive names ({@code int}, {@code long}, {@code boolean}, + * …) or fully-qualified reference type names ({@code java.lang.String}, {@code java.util.List}). No + * generic parameters. The wire form keeps the descriptor pure data; class resolution is deferred to + * the plan-layer twin. + */ +public final class JavaFunction implements Function, Serializable { + + private static final String FIELD_QUAL_NAME = "qualName"; + private static final String FIELD_METHOD_NAME = "methodName"; + private static final String FIELD_PARAMETER_TYPES = "parameterTypes"; + + @JsonProperty(FIELD_QUAL_NAME) + private final String qualName; + + @JsonProperty(FIELD_METHOD_NAME) + private final String methodName; + + @JsonProperty(FIELD_PARAMETER_TYPES) + private final List parameterTypes; + + @JsonCreator + public JavaFunction( + @JsonProperty(FIELD_QUAL_NAME) String qualName, + @JsonProperty(FIELD_METHOD_NAME) String methodName, + @JsonProperty(FIELD_PARAMETER_TYPES) List parameterTypes) { + this.qualName = Objects.requireNonNull(qualName, "qualName"); + this.methodName = Objects.requireNonNull(methodName, "methodName"); + this.parameterTypes = + parameterTypes == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(parameterTypes)); + } + + /** + * 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 + * descriptor is later promoted to its plan-layer twin. For primitives this is the keyword + * ({@code int}, {@code long}); for reference types the fully-qualified name; for array types + * the JVM-internal descriptor ({@code [I}, {@code [Ljava.lang.String;}). + */ + public static JavaFunction fromMethod(Method method) { + List params = new ArrayList<>(method.getParameterCount()); + for (Class p : method.getParameterTypes()) { + params.add(p.getName()); + } + return new JavaFunction(method.getDeclaringClass().getName(), method.getName(), params); + } + + public String getQualName() { + return qualName; + } + + public String getMethodName() { + return methodName; + } + + public List getParameterTypes() { + return parameterTypes; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof JavaFunction)) return false; + JavaFunction that = (JavaFunction) o; + return qualName.equals(that.qualName) + && methodName.equals(that.methodName) + && parameterTypes.equals(that.parameterTypes); + } + + @Override + public int hashCode() { + return Objects.hash(qualName, methodName, parameterTypes); + } + + @Override + public String toString() { + return "JavaFunction{" + qualName + "#" + methodName + parameterTypes + "}"; + } +} 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 new file mode 100644 index 000000000..e249a693f --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/function/PythonFunction.java @@ -0,0 +1,76 @@ +/* + * 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.function; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Pure-data descriptor for a Python callable, identified by its module and qualified name. + * + *

Carries no execution behavior — the plan-layer {@code + * org.apache.flink.agents.plan.PythonFunction} owns invocation via the Pemja interpreter. + */ +public final class PythonFunction implements Function, Serializable { + + private static final String FIELD_MODULE = "module"; + private static final String FIELD_QUAL_NAME = "qualName"; + + @JsonProperty(FIELD_MODULE) + private final String module; + + @JsonProperty(FIELD_QUAL_NAME) + private final String qualName; + + @JsonCreator + 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"); + } + + public String getModule() { + return module; + } + + public String getQualName() { + return qualName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PythonFunction)) return false; + PythonFunction that = (PythonFunction) o; + return module.equals(that.module) && qualName.equals(that.qualName); + } + + @Override + public int hashCode() { + return Objects.hash(module, qualName); + } + + @Override + public String toString() { + return "PythonFunction{" + module + ":" + qualName + "}"; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonResourceAdapter.java b/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonResourceAdapter.java index a28006f87..03eb8248c 100644 --- a/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonResourceAdapter.java +++ b/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonResourceAdapter.java @@ -128,4 +128,36 @@ public interface PythonResourceAdapter { * @return the result of the method invocation */ Object invoke(String name, Object... args); + + /** + * Look up tool metadata for a Python function across the JVM→Python bridge. + * + *

The Java side asks the Python side to introspect a callable identified by {@code module} + + * {@code qualName}, and returns a flat {@code Map} with keys {@code "name"}, + * {@code "description"}, and {@code "inputSchema"} (a JSON schema string compatible with {@code + * ToolMetadata.inputSchema}). + * + *

The return shape is intentionally flat — pemja can SIGSEGV when returning arbitrary Python + * objects to Java on non-main-interpreter threads. + * + * @param module the Python module containing the callable + * @param qualName the qualified name of the callable inside the module (e.g. {@code "fn"} or + * {@code "MyClass.method"}) + * @return flat map with keys "name", "description", "inputSchema" + */ + Map getPythonToolMetadata(String module, String qualName); + + /** + * Invoke a Python callable as a tool, passing keyword arguments. Used when a Java chat model's + * tool list contains a {@code plan.FunctionTool} whose function descriptor is a {@code + * PythonFunction}: instead of routing the invocation through Java reflection, dispatch it + * across the bridge so the underlying Python function runs in the Pemja interpreter. + * + * @param module the Python module containing the callable + * @param qualName the qualified name of the callable inside the module + * @param kwargs keyword arguments to pass to the callable; LLM tool calls always arrive as + * keyword arguments + * @return the raw return value from the Python callable + */ + Object invokePythonTool(String module, String qualName, Map kwargs); } diff --git a/api/src/main/java/org/apache/flink/agents/api/tools/FunctionTool.java b/api/src/main/java/org/apache/flink/agents/api/tools/FunctionTool.java index 3ccba6745..cf9dbd8a9 100644 --- a/api/src/main/java/org/apache/flink/agents/api/tools/FunctionTool.java +++ b/api/src/main/java/org/apache/flink/agents/api/tools/FunctionTool.java @@ -18,25 +18,38 @@ package org.apache.flink.agents.api.tools; +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; import org.apache.flink.agents.api.resource.ResourceType; import org.apache.flink.agents.api.resource.SerializableResource; import java.lang.reflect.Method; +import java.util.Objects; -/** Tool keeps a method, will be converted to tool after compile. */ +/** + * Pure-data tool descriptor: carries an {@link Function} reference. Used at agent-construction + * time; compiled to the plan-layer executable {@code plan.tools.FunctionTool} when the agent + * becomes an {@code AgentPlan}. + */ public class FunctionTool extends SerializableResource { - private final Method method; - public FunctionTool(Method method) { - this.method = method; + private final Function func; + + public FunctionTool(Function func) { + this.func = Objects.requireNonNull(func, "func"); + } + + /** Convenience factory: derive a {@link JavaFunction} from a reflected method. */ + public static FunctionTool fromMethod(Method method) { + return new FunctionTool(JavaFunction.fromMethod(method)); + } + + public Function getFunc() { + return func; } @Override public ResourceType getResourceType() { return ResourceType.TOOL; } - - public Method getMethod() { - return method; - } } diff --git a/api/src/main/java/org/apache/flink/agents/api/tools/Tool.java b/api/src/main/java/org/apache/flink/agents/api/tools/Tool.java index a02384005..11f0356d0 100644 --- a/api/src/main/java/org/apache/flink/agents/api/tools/Tool.java +++ b/api/src/main/java/org/apache/flink/agents/api/tools/Tool.java @@ -30,12 +30,21 @@ */ public abstract class Tool extends SerializableResource { - protected final ToolMetadata metadata; + protected ToolMetadata metadata; protected Tool(ToolMetadata metadata) { this.metadata = java.util.Objects.requireNonNull(metadata, "metadata cannot be null"); } + /** + * Replace this tool's metadata. Intended for subclasses that derive metadata lazily once a + * runtime bridge becomes available (e.g. {@code FunctionTool} backed by a {@code + * PythonFunction} refreshing placeholder metadata via the JVM→Python adapter). + */ + protected void setMetadata(ToolMetadata metadata) { + this.metadata = java.util.Objects.requireNonNull(metadata, "metadata cannot be null"); + } + /** Get the metadata of this tool. */ public final ToolMetadata getMetadata() { return metadata; @@ -68,6 +77,6 @@ public final String getDescription() { /** Get tool keeps a method. */ public static FunctionTool fromMethod(Method method) { - return new FunctionTool(method); + return FunctionTool.fromMethod(method); } } diff --git a/api/src/test/java/org/apache/flink/agents/api/agents/AgentAddActionTest.java b/api/src/test/java/org/apache/flink/agents/api/agents/AgentAddActionTest.java new file mode 100644 index 000000000..fd906bd51 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/agents/AgentAddActionTest.java @@ -0,0 +1,79 @@ +/* + * 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.agents; + +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.api.java.tuple.Tuple3; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentAddActionTest { + + public static void onInput(Object event, Object ctx) {} + + @Test + void newFunctionOverloadStoresApiFunction() { + Agent agent = new Agent(); + PythonFunction pf = new PythonFunction("pkg", "fn"); + agent.addAction("act", new String[] {"_input_event"}, pf, Map.of("k", "v")); + + Map>> actions = agent.getActions(); + Tuple3> entry = actions.get("act"); + assertThat(entry).isNotNull(); + assertThat(entry.f0).containsExactly("_input_event"); + assertThat(entry.f1).isSameAs(pf); + assertThat(entry.f2).containsEntry("k", "v"); + } + + @Test + void methodOverloadDelegatesToFunctionAsJavaFunction() throws Exception { + Method m = + AgentAddActionTest.class.getDeclaredMethod("onInput", Object.class, Object.class); + Agent agent = new Agent(); + agent.addAction(new String[] {"_input_event"}, m); + + Tuple3> entry = agent.getActions().get("onInput"); + assertThat(entry.f1).isInstanceOf(JavaFunction.class); + JavaFunction jf = (JavaFunction) entry.f1; + assertThat(jf.getQualName()).isEqualTo(AgentAddActionTest.class.getName()); + assertThat(jf.getMethodName()).isEqualTo("onInput"); + } + + @Test + void duplicateNameRejected() { + Agent agent = new Agent(); + agent.addAction("act", new String[] {"_input_event"}, new PythonFunction("p", "q"), null); + assertThatThrownBy( + () -> + agent.addAction( + "act", + new String[] {"_input_event"}, + new PythonFunction("p", "q"), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("act"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/function/FunctionTest.java b/api/src/test/java/org/apache/flink/agents/api/function/FunctionTest.java new file mode 100644 index 000000000..b10314caa --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/function/FunctionTest.java @@ -0,0 +1,34 @@ +/* + * 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.function; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FunctionTest { + + /** Function is a pure marker; the concrete data subtypes implement it. */ + @Test + void pythonAndJavaFunctionImplementMarker() { + Function py = new PythonFunction("pkg.mod", "fn"); + Function ja = new JavaFunction("com.example.X", "m", java.util.List.of()); + assertThat(py).isInstanceOf(Function.class); + assertThat(ja).isInstanceOf(Function.class); + } +} 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 new file mode 100644 index 000000000..b79eaa792 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/function/JavaFunctionTest.java @@ -0,0 +1,78 @@ +/* + * 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.function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaFunctionTest { + + public static int add(int a, int b) { + return a + b; + } + + @Test + void exposesAllFields() { + JavaFunction fn = + new JavaFunction("com.example.X", "add", List.of("int", "java.lang.String")); + assertThat(fn.getQualName()).isEqualTo("com.example.X"); + assertThat(fn.getMethodName()).isEqualTo("add"); + assertThat(fn.getParameterTypes()).containsExactly("int", "java.lang.String"); + } + + @Test + void fromMethodCapturesDeclaringClassAndPrimitiveAndReferenceParams() throws Exception { + Method m = JavaFunctionTest.class.getDeclaredMethod("add", int.class, int.class); + JavaFunction fn = JavaFunction.fromMethod(m); + assertThat(fn.getQualName()) + .isEqualTo("org.apache.flink.agents.api.function.JavaFunctionTest"); + assertThat(fn.getMethodName()).isEqualTo("add"); + assertThat(fn.getParameterTypes()).containsExactly("int", "int"); + } + + @Test + void parameterTypesListIsDefensiveCopy() { + var src = new java.util.ArrayList<>(List.of("int")); + JavaFunction fn = new JavaFunction("X", "m", src); + src.add("mutated"); + assertThat(fn.getParameterTypes()).containsExactly("int"); + } + + @Test + void equalsBasedOnAllFields() { + JavaFunction a = new JavaFunction("X", "m", List.of("int")); + JavaFunction b = new JavaFunction("X", "m", List.of("int")); + JavaFunction c = new JavaFunction("X", "m", List.of("long")); + assertThat(a).isEqualTo(b).isNotEqualTo(c); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + void jacksonRoundTrip() throws Exception { + ObjectMapper m = new ObjectMapper(); + JavaFunction fn = new JavaFunction("com.example.X", "m", List.of("int")); + String json = m.writeValueAsString(fn); + JavaFunction back = m.readValue(json, JavaFunction.class); + assertThat(back).isEqualTo(fn); + } +} 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 new file mode 100644 index 000000000..d2ae089ca --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/function/PythonFunctionTest.java @@ -0,0 +1,51 @@ +/* + * 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.function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PythonFunctionTest { + + @Test + void exposesModuleAndQualName() { + PythonFunction fn = new PythonFunction("pkg.mod", "MyClass.method"); + assertThat(fn.getModule()).isEqualTo("pkg.mod"); + assertThat(fn.getQualName()).isEqualTo("MyClass.method"); + } + + @Test + void equalsBasedOnModuleAndQualName() { + PythonFunction a = new PythonFunction("m", "q"); + PythonFunction b = new PythonFunction("m", "q"); + PythonFunction c = new PythonFunction("m", "other"); + assertThat(a).isEqualTo(b).isNotEqualTo(c); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + void jacksonRoundTrip() throws Exception { + ObjectMapper m = new ObjectMapper(); + PythonFunction fn = new PythonFunction("pkg.mod", "fn"); + String json = m.writeValueAsString(fn); + PythonFunction back = m.readValue(json, PythonFunction.class); + assertThat(back).isEqualTo(fn); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/tools/FunctionToolTest.java b/api/src/test/java/org/apache/flink/agents/api/tools/FunctionToolTest.java new file mode 100644 index 000000000..77e7eb997 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/tools/FunctionToolTest.java @@ -0,0 +1,61 @@ +/* + * 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.tools; + +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.agents.api.resource.ResourceType; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class FunctionToolTest { + + public static int demo(int a) { + return a; + } + + @Test + void holdsPythonFunction() { + PythonFunction pf = new PythonFunction("pkg.mod", "fn"); + FunctionTool tool = new FunctionTool(pf); + assertThat(tool.getFunc()).isSameAs(pf); + assertThat(tool.getResourceType()).isEqualTo(ResourceType.TOOL); + } + + @Test + void holdsJavaFunction() { + JavaFunction jf = new JavaFunction("X", "m", java.util.List.of("int")); + FunctionTool tool = new FunctionTool(jf); + assertThat(tool.getFunc()).isSameAs(jf); + } + + @Test + void fromMethodBuildsJavaFunction() throws Exception { + Method m = FunctionToolTest.class.getDeclaredMethod("demo", int.class); + FunctionTool tool = FunctionTool.fromMethod(m); + assertThat(tool.getFunc()).isInstanceOf(JavaFunction.class); + JavaFunction jf = (JavaFunction) tool.getFunc(); + assertThat(jf.getQualName()).isEqualTo(FunctionToolTest.class.getName()); + assertThat(jf.getMethodName()).isEqualTo("demo"); + assertThat(jf.getParameterTypes()).containsExactly("int"); + } +} 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 3a77f8668..62eb0876e 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 @@ -181,24 +181,22 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE } private void extractActions( - String[] listenEventTypeStrings, Method method, Map config) + String actionName, + String[] listenEventTypeStrings, + org.apache.flink.agents.plan.Function function, + Map config) throws Exception { List eventTypeNames = new ArrayList<>(Arrays.asList(listenEventTypeStrings)); if (eventTypeNames.isEmpty()) { throw new IllegalArgumentException( - "Action method " - + method.getName() + "Action " + + actionName + " must specify at least one event type via listenEventTypes."); } - // Create a JavaFunction for this method - JavaFunction javaFunction = - new JavaFunction( - method.getDeclaringClass(), method.getName(), method.getParameterTypes()); - // Create an Action - Action action = new Action(method.getName(), javaFunction, eventTypeNames, config); + Action action = new Action(actionName, function, eventTypeNames, config); // Add to actions map actions.put(action.getName(), action); @@ -235,14 +233,26 @@ private void extractActionsFromAgent(Agent agent) throws Exception { String[] listenEventTypeStrings = Objects.requireNonNull(actionAnnotation).listenEventTypes(); - extractActions(listenEventTypeStrings, method, null); + org.apache.flink.agents.plan.JavaFunction javaFunction = + new org.apache.flink.agents.plan.JavaFunction( + method.getDeclaringClass(), + method.getName(), + method.getParameterTypes()); + extractActions(method.getName(), listenEventTypeStrings, javaFunction, null); } } - for (Map.Entry>> action : - agent.getActions().entrySet()) { - Tuple3> tuple = action.getValue(); - extractActions(tuple.f0, tuple.f1, tuple.f2); + for (Map.Entry< + String, + Tuple3< + String[], + org.apache.flink.agents.api.function.Function, + Map>> + action : agent.getActions().entrySet()) { + String actionName = action.getKey(); + Tuple3> + tuple = action.getValue(); + extractActions(actionName, tuple.f0, toPlanFunction(tuple.f1), tuple.f2); } } @@ -484,9 +494,21 @@ private void extractResourceProvidersFromAgent(Agent agent) throws Exception { } } else if (type == TOOL) { for (Map.Entry kv : entry.getValue().entrySet()) { - extractTool( - ((org.apache.flink.agents.api.tools.FunctionTool) kv.getValue()) - .getMethod()); + String resourceName = kv.getKey(); + Object value = kv.getValue(); + if (value instanceof org.apache.flink.agents.api.tools.FunctionTool) { + registerApiFunctionTool( + resourceName, + (org.apache.flink.agents.api.tools.FunctionTool) value); + } else if (value instanceof SerializableResource) { + // Plan-layer tools added directly (MCP-generated, etc.) — pass through. + addResourceProvider( + JavaSerializableResourceProvider.createResourceProvider( + resourceName, TOOL, (SerializableResource) value)); + } else { + throw new IllegalStateException( + "Unsupported tool resource '" + resourceName + "': " + value); + } } } else if (type == ResourceType.SKILLS) { for (Map.Entry kv : entry.getValue().entrySet()) { @@ -584,4 +606,105 @@ private void addResourceProvider(ResourceProvider provider) { .computeIfAbsent(provider.getType(), k -> new HashMap<>()) .put(provider.getName(), provider); } + + /** + * Promote an api-layer {@link org.apache.flink.agents.api.function.Function} descriptor to its + * plan-layer twin. Java parameter type strings are resolved to {@link Class} here; Python + * descriptors pass through unchanged. + */ + private static org.apache.flink.agents.plan.Function toPlanFunction( + org.apache.flink.agents.api.function.Function f) throws Exception { + if (f instanceof org.apache.flink.agents.api.function.JavaFunction) { + org.apache.flink.agents.api.function.JavaFunction jf = + (org.apache.flink.agents.api.function.JavaFunction) f; + Class[] params = resolveParameterTypes(jf.getParameterTypes()); + Class clazz = + Class.forName( + jf.getQualName(), true, Thread.currentThread().getContextClassLoader()); + return new org.apache.flink.agents.plan.JavaFunction(clazz, jf.getMethodName(), params); + } + if (f instanceof org.apache.flink.agents.api.function.PythonFunction) { + org.apache.flink.agents.api.function.PythonFunction pf = + (org.apache.flink.agents.api.function.PythonFunction) f; + return new org.apache.flink.agents.plan.PythonFunction( + pf.getModule(), pf.getQualName()); + } + throw new IllegalStateException("Unknown api.function.Function: " + f); + } + + private static Class[] resolveParameterTypes(List names) + throws ClassNotFoundException { + Class[] out = new Class[names.size()]; + for (int i = 0; i < names.size(); i++) { + out[i] = resolveParameterType(names.get(i)); + } + return out; + } + + private static Class resolveParameterType(String name) throws ClassNotFoundException { + switch (name) { + case "boolean": + return boolean.class; + case "byte": + return byte.class; + case "short": + return short.class; + case "int": + return int.class; + case "long": + return long.class; + case "float": + return float.class; + case "double": + return double.class; + case "char": + return char.class; + case "void": + return void.class; + default: + return Class.forName(name, true, Thread.currentThread().getContextClassLoader()); + } + } + + /** + * Promote an api-layer {@link org.apache.flink.agents.api.tools.FunctionTool} to a plan-layer + * executable {@link FunctionTool} and register it under the YAML-declared resource name. + */ + private void registerApiFunctionTool( + String resourceName, org.apache.flink.agents.api.tools.FunctionTool apiTool) + throws Exception { + org.apache.flink.agents.api.function.Function func = apiTool.getFunc(); + if (func instanceof org.apache.flink.agents.api.function.JavaFunction) { + org.apache.flink.agents.api.function.JavaFunction jf = + (org.apache.flink.agents.api.function.JavaFunction) func; + Class[] params = resolveParameterTypes(jf.getParameterTypes()); + Class clazz = + Class.forName( + jf.getQualName(), true, Thread.currentThread().getContextClassLoader()); + Method method = clazz.getMethod(jf.getMethodName(), params); + ToolMetadata metadata = ToolMetadataFactory.fromStaticMethod(method); + org.apache.flink.agents.plan.JavaFunction planFunc = + new org.apache.flink.agents.plan.JavaFunction(clazz, method.getName(), params); + FunctionTool tool = new FunctionTool(metadata, planFunc); + addResourceProvider( + JavaSerializableResourceProvider.createResourceProvider( + resourceName, TOOL, tool)); + } else if (func instanceof org.apache.flink.agents.api.function.PythonFunction) { + org.apache.flink.agents.api.function.PythonFunction pf = + (org.apache.flink.agents.api.function.PythonFunction) func; + org.apache.flink.agents.plan.PythonFunction planFunc = + new org.apache.flink.agents.plan.PythonFunction( + pf.getModule(), pf.getQualName()); + // Placeholder metadata: ResourceCache will replace it with introspected values from + // the Python bridge via FunctionTool.setPythonResourceAdapter at first resolve. + ToolMetadata metadata = new ToolMetadata(resourceName, "", "{}"); + FunctionTool tool = new FunctionTool(metadata, planFunc); + addResourceProvider( + JavaSerializableResourceProvider.createResourceProvider( + resourceName, TOOL, tool)); + } else { + throw new IllegalStateException( + "Unknown api.function.Function for tool '" + resourceName + "': " + func); + } + } } diff --git a/plan/src/main/java/org/apache/flink/agents/plan/tools/FunctionTool.java b/plan/src/main/java/org/apache/flink/agents/plan/tools/FunctionTool.java index 66ef21a9e..692470049 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/tools/FunctionTool.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/tools/FunctionTool.java @@ -20,9 +20,11 @@ package org.apache.flink.agents.plan.tools; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.apache.flink.agents.api.annotation.ToolParam; +import org.apache.flink.agents.api.resource.python.PythonResourceAdapter; import org.apache.flink.agents.api.tools.Tool; import org.apache.flink.agents.api.tools.ToolMetadata; import org.apache.flink.agents.api.tools.ToolParameters; @@ -30,12 +32,15 @@ import org.apache.flink.agents.api.tools.ToolType; import org.apache.flink.agents.plan.Function; import org.apache.flink.agents.plan.JavaFunction; +import org.apache.flink.agents.plan.PythonFunction; import org.apache.flink.agents.plan.tools.serializer.FunctionToolJsonDeserializer; import org.apache.flink.agents.plan.tools.serializer.FunctionToolJsonSerializer; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; +import java.util.HashMap; +import java.util.Map; /** * Plan-level implementation of a tool that wraps a static Java method. This belongs in the plan @@ -48,6 +53,8 @@ public class FunctionTool extends Tool { private final Function function; + @JsonIgnore private transient PythonResourceAdapter pythonResourceAdapter; + /** Create a FunctionTool from ToolMetadata and Function */ public FunctionTool(ToolMetadata metadata, Function function) { super(metadata); @@ -104,38 +111,84 @@ public ToolType getToolType() { @Override public ToolResponse call(ToolParameters parameters) { try { - // Map ToolParameters to method arguments by name and type - Method method = ((JavaFunction) function).getMethod(); - Parameter[] methodParams = method.getParameters(); - Object[] args = new Object[methodParams.length]; - for (int i = 0; i < methodParams.length; i++) { - Parameter p = methodParams[i]; - String paramName = p.getName(); - if (p.isAnnotationPresent(ToolParam.class)) { - ToolParam ann = p.getAnnotation(ToolParam.class); - if (!ann.name().isEmpty()) { - paramName = ann.name(); - } + if (function instanceof PythonFunction) { + return callPython((PythonFunction) function, parameters); + } + return callJava(parameters); + } catch (Exception e) { + return ToolResponse.error(e); + } + } + + private ToolResponse callJava(ToolParameters parameters) throws Exception { + // Map ToolParameters to method arguments by name and type + Method method = ((JavaFunction) function).getMethod(); + Parameter[] methodParams = method.getParameters(); + Object[] args = new Object[methodParams.length]; + for (int i = 0; i < methodParams.length; i++) { + Parameter p = methodParams[i]; + String paramName = p.getName(); + if (p.isAnnotationPresent(ToolParam.class)) { + ToolParam ann = p.getAnnotation(ToolParam.class); + if (!ann.name().isEmpty()) { + paramName = ann.name(); } - Object value = parameters.getParameter(paramName, p.getType()); - if (value == null && p.isAnnotationPresent(ToolParam.class)) { - ToolParam ann = p.getAnnotation(ToolParam.class); - if (ann.required() && ann.defaultValue().isEmpty()) { - throw new IllegalArgumentException( - "Missing required parameter: " + paramName); - } + } + Object value = parameters.getParameter(paramName, p.getType()); + if (value == null && p.isAnnotationPresent(ToolParam.class)) { + ToolParam ann = p.getAnnotation(ToolParam.class); + if (ann.required() && ann.defaultValue().isEmpty()) { + throw new IllegalArgumentException("Missing required parameter: " + paramName); } - args[i] = value; } + args[i] = value; + } + Object result = function.call(args); + return ToolResponse.success(result); + } - Object result = function.call(args); - return ToolResponse.success(result); - } catch (Exception e) { - return ToolResponse.error(e); + private ToolResponse callPython(PythonFunction pf, ToolParameters parameters) { + if (pythonResourceAdapter == null) { + return ToolResponse.error( + new IllegalStateException( + "Python tool '" + + pf.getQualName() + + "' has no PythonResourceAdapter; runtime should inject one" + + " before invocation.")); + } + Map kwargs = new HashMap<>(); + for (String name : parameters.getParameterNames()) { + kwargs.put(name, parameters.getParameter(name)); } + Object result = + pythonResourceAdapter.invokePythonTool(pf.getModule(), pf.getQualName(), kwargs); + return ToolResponse.success(result); } public Function getFunction() { return function; } + + /** + * Refresh this tool's metadata via the Python bridge when the underlying function is a {@link + * PythonFunction}. No-op for Java-backed tools. + * + *

Called by the runtime resource cache the first time the tool is resolved, so the + * placeholder metadata that {@code AgentPlan.registerApiFunctionTool} writes for Python tools + * gets replaced with real introspected values (name, description, inputSchema) sourced from the + * Python callable's signature and docstring. + */ + public void setPythonResourceAdapter(PythonResourceAdapter adapter) { + if (!(function instanceof PythonFunction)) { + return; + } + this.pythonResourceAdapter = adapter; + PythonFunction pf = (PythonFunction) function; + Map flat = adapter.getPythonToolMetadata(pf.getModule(), pf.getQualName()); + setMetadata( + new ToolMetadata( + flat.get("name"), + flat.getOrDefault("description", ""), + flat.getOrDefault("inputSchema", "{}"))); + } } diff --git a/plan/src/test/java/org/apache/flink/agents/plan/tools/FunctionToolSetPythonAdapterTest.java b/plan/src/test/java/org/apache/flink/agents/plan/tools/FunctionToolSetPythonAdapterTest.java new file mode 100644 index 000000000..8e7d18767 --- /dev/null +++ b/plan/src/test/java/org/apache/flink/agents/plan/tools/FunctionToolSetPythonAdapterTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.flink.agents.plan.tools; + +import org.apache.flink.agents.api.resource.python.PythonResourceAdapter; +import org.apache.flink.agents.api.tools.ToolMetadata; +import org.apache.flink.agents.plan.JavaFunction; +import org.apache.flink.agents.plan.PythonFunction; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FunctionToolSetPythonAdapterTest { + + @Test + void replacesPlaceholderMetadataForPythonFunction() { + ToolMetadata placeholder = new ToolMetadata("notify", "", "{}"); + PythonFunction pf = new PythonFunction("pkg.mod", "notify"); + FunctionTool tool = new FunctionTool(placeholder, pf); + + PythonResourceAdapter adapter = Mockito.mock(PythonResourceAdapter.class); + when(adapter.getPythonToolMetadata("pkg.mod", "notify")) + .thenReturn( + Map.of( + "name", "notify", + "description", "Send a notification.", + "inputSchema", + "{\"properties\":{\"id\":{\"type\":\"string\"," + + "\"description\":\"recipient id\"}}}")); + + tool.setPythonResourceAdapter(adapter); + + assertThat(tool.getMetadata().getName()).isEqualTo("notify"); + assertThat(tool.getMetadata().getDescription()).isEqualTo("Send a notification."); + assertThat(tool.getMetadata().getInputSchema()).contains("recipient id"); + verify(adapter, times(1)).getPythonToolMetadata(eq("pkg.mod"), eq("notify")); + } + + @Test + void noOpForJavaFunction() throws Exception { + ToolMetadata original = new ToolMetadata("add", "Adds.", "{\"properties\":{}}"); + JavaFunction jf = + new JavaFunction( + FunctionToolSetPythonAdapterTest.class, + "stubMethod", + new Class[] {int.class}); + FunctionTool tool = new FunctionTool(original, jf); + + PythonResourceAdapter adapter = Mockito.mock(PythonResourceAdapter.class); + tool.setPythonResourceAdapter(adapter); + + // Metadata untouched + assertThat(tool.getMetadata()).isSameAs(original); + verify(adapter, never()).getPythonToolMetadata(Mockito.anyString(), Mockito.anyString()); + } + + /** Helper static method to back JavaFunction in the no-op test. */ + public static int stubMethod(int x) { + return x; + } +} diff --git a/python/flink_agents/api/tools/utils.py b/python/flink_agents/api/tools/utils.py index f94fc818a..f1145b5c6 100644 --- a/python/flink_agents/api/tools/utils.py +++ b/python/flink_agents/api/tools/utils.py @@ -18,7 +18,7 @@ import json import typing from inspect import signature -from typing import Any, Callable, Optional, Type, Union +from typing import Any, Callable, Dict, Optional, Type, Union from docstring_parser import parse from pydantic import BaseModel, create_model @@ -210,6 +210,7 @@ def create_java_tool_schema_str_from_model(model: type[BaseModel]) -> str: REVERSE_TYPE_MAPPING = {v: k for k, v in TYPE_MAPPING.items()} properties = {} + required = [] for field_name, field_info in model.model_fields.items(): field_type = field_info.annotation @@ -228,7 +229,11 @@ def create_java_tool_schema_str_from_model(model: type[BaseModel]) -> str: description = f"Parameter: {field_name}" properties[field_name] = {"type": json_type, "description": description} + if field_info.is_required(): + required.append(field_name) - json_schema = {"properties": properties} + json_schema: Dict[str, Any] = {"properties": properties} + if required: + json_schema["required"] = required return json.dumps(json_schema, ensure_ascii=False, indent=2) diff --git a/python/flink_agents/runtime/python_java_utils.py b/python/flink_agents/runtime/python_java_utils.py index 58389c82d..23ed4f5c1 100644 --- a/python/flink_agents/runtime/python_java_utils.py +++ b/python/flink_agents/runtime/python_java_utils.py @@ -126,6 +126,49 @@ def from_java_tool(j_tool: Any) -> JavaTool: return JavaTool(metadata=metadata) +def get_python_tool_metadata(module: str, qual_name: str) -> Dict[str, str]: + """Introspect a Python callable into the flat tool-metadata shape expected by + the Java-side ``PythonResourceAdapter.getPythonToolMetadata``. + + Mirrors the Python side's eager-metadata derivation for + ``PythonFunction``-backed ``FunctionTool``s. Returns the same three-key shape + ``JavaResourceAdapter.getJavaToolMetadata`` returns in the reverse direction + so the Java side can rebuild ``ToolMetadata`` from String fields only — + avoiding pemja's SIGSEGV when wrapping arbitrary Python objects on non-main + interpreter threads. + """ + from docstring_parser import parse + + from flink_agents.api.function import PythonFunction + from flink_agents.api.tools.utils import ( + create_java_tool_schema_str_from_model, + create_schema_from_function, + ) + + descriptor = PythonFunction(module=module, qualname=qual_name) + callable_ = descriptor.as_callable() + name = callable_.__name__ + description = (parse(callable_.__doc__).description or "") if callable_.__doc__ else "" + args_schema_model = create_schema_from_function(name, callable_) + input_schema = create_java_tool_schema_str_from_model(args_schema_model) + return {"name": name, "description": description, "inputSchema": input_schema} + + +def invoke_python_tool( + module: str, qual_name: str, kwargs: Dict[str, Any] +) -> Any: + """Invoke a Python callable as a tool, passing the provided keyword arguments. + + Used by the Java-side ``PythonResourceAdapter.invokePythonTool`` so a Java host can + dispatch a Python function tool from a Java chat model without the Python side + needing to know about Pemja's threading model. + """ + from flink_agents.api.function import PythonFunction + + descriptor = PythonFunction(module=module, qualname=qual_name) + return descriptor.as_callable()(**kwargs) + + def from_java_prompt(j_prompt: Any) -> JavaPrompt: """Convert a Java prompt object to a Python JavaPrompt instance. diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java b/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java index db8e5dc30..8e56bb988 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/ResourceCache.java @@ -23,6 +23,7 @@ import org.apache.flink.agents.api.resource.python.PythonResourceAdapter; import org.apache.flink.agents.plan.resourceprovider.PythonResourceProvider; import org.apache.flink.agents.plan.resourceprovider.ResourceProvider; +import org.apache.flink.agents.plan.tools.FunctionTool; import org.apache.flink.agents.runtime.resource.ResourceContextImpl; import java.util.HashMap; @@ -96,6 +97,11 @@ public synchronized Resource getResource(String name, ResourceType type) throws throw new RuntimeException(e); } })); + + if (pythonResourceAdapter != null && resource instanceof FunctionTool) { + ((FunctionTool) resource).setPythonResourceAdapter(pythonResourceAdapter); + } + resource.open(); cache.computeIfAbsent(type, k -> new ConcurrentHashMap<>()).put(name, resource); return resource; diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java index 238e0e8c9..f4284e48e 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java @@ -72,6 +72,11 @@ public class PythonResourceAdapterImpl implements PythonResourceAdapter { static final String FROM_JAVA_VECTOR_STORE_QUERY = PYTHON_MODULE_PREFIX + "from_java_vector_store_query"; + static final String GET_PYTHON_TOOL_METADATA = + PYTHON_MODULE_PREFIX + "get_python_tool_metadata"; + + static final String INVOKE_PYTHON_TOOL = PYTHON_MODULE_PREFIX + "invoke_python_tool"; + private final ResourceContext resourceContext; private final PythonInterpreter interpreter; private final JavaResourceAdapter javaResourceAdapter; @@ -199,4 +204,22 @@ public Object callMethod(Object obj, String methodName, Map kwar public Object invoke(String name, Object... args) { return interpreter.invoke(name, args); } + + @Override + public Map getPythonToolMetadata(String module, String qualName) { + @SuppressWarnings("unchecked") + Map result = + (Map) + interpreter.invoke(GET_PYTHON_TOOL_METADATA, module, qualName); + if (result == null) { + throw new IllegalStateException( + "Python get_python_tool_metadata returned null for " + module + ":" + qualName); + } + return result; + } + + @Override + public Object invokePythonTool(String module, String qualName, Map kwargs) { + return interpreter.invoke(INVOKE_PYTHON_TOOL, module, qualName, kwargs); + } } diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java index ecf8ef182..a48f618e5 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java @@ -188,6 +188,16 @@ public Object callMethod(Object obj, String methodName, Map kwar public Object invoke(String name, Object... args) { return null; } + + @Override + public Map getPythonToolMetadata(String module, String qualName) { + return Map.of("name", qualName, "description", "", "inputSchema", "{}"); + } + + @Override + public Object invokePythonTool(String module, String qualName, Map kwargs) { + return null; + } } @Test From f71f67af5f94c4bdb496cd387f1b113489aee754 Mon Sep 17 00:00:00 2001 From: WenjinXie Date: Tue, 19 May 2026 14:59:40 +0800 Subject: [PATCH 2/2] [api][java] Introduce YAML API for declaring agents Co-Authored-By: Claude Opus 4.7 (1M context) --- api/pom.xml | 4 + .../apache/flink/agents/api/AgentBuilder.java | 15 + .../api/AgentsExecutionEnvironment.java | 37 ++ .../apache/flink/agents/api/yaml/Aliases.java | 185 ++++++++ .../flink/agents/api/yaml/Language.java | 52 +++ .../flink/agents/api/yaml/YamlLoader.java | 416 ++++++++++++++++++ .../agents/api/yaml/spec/ActionSpec.java | 74 ++++ .../agents/api/yaml/spec/AgentActionRef.java | 73 +++ .../flink/agents/api/yaml/spec/AgentSpec.java | 124 ++++++ .../agents/api/yaml/spec/DescriptorSpec.java | 72 +++ .../agents/api/yaml/spec/PromptMessage.java | 47 ++ .../agents/api/yaml/spec/PromptSpec.java | 61 +++ .../agents/api/yaml/spec/SkillsSpec.java | 48 ++ .../flink/agents/api/yaml/spec/ToolSpec.java | 63 +++ .../api/yaml/spec/YamlAgentsDocument.java | 117 +++++ .../api/AgentBuilderApplyByNameTest.java | 112 +++++ .../flink/agents/api/yaml/AliasesTest.java | 77 ++++ .../flink/agents/api/yaml/LanguageTest.java | 50 +++ .../flink/agents/api/yaml/LoaderTargets.java | 38 ++ .../api/yaml/YamlLoaderBuildAgentsTest.java | 154 +++++++ .../api/yaml/YamlLoaderBuildersTest.java | 147 +++++++ .../api/yaml/YamlLoaderFunctionTest.java | 81 ++++ .../api/yaml/YamlLoaderLoadYamlTest.java | 145 ++++++ .../api/yaml/YamlPythonFixtureParityTest.java | 75 ++++ .../agents/api/yaml/spec/ActionSpecTest.java | 71 +++ .../agents/api/yaml/spec/AgentSpecTest.java | 67 +++ .../api/yaml/spec/DescriptorSpecTest.java | 67 +++ .../agents/api/yaml/spec/PromptSpecTest.java | 88 ++++ .../api/yaml/spec/SchemaParityTest.java | 249 +++++++++++ .../agents/api/yaml/spec/SkillsSpecTest.java | 43 ++ .../agents/api/yaml/spec/ToolSpecTest.java | 59 +++ .../api/yaml/spec/YamlAgentsDocumentTest.java | 49 +++ .../resources/yaml/fixtures/dup_agent.yaml | 13 + .../resources/yaml/fixtures/multi_agent.yaml | 13 + .../resources/yaml/fixtures/multi_file_a.yaml | 12 + .../resources/yaml/fixtures/multi_file_b.yaml | 12 + .../resources/yaml/fixtures/single_agent.yaml | 7 + .../yaml/fixtures/with_descriptors.yaml | 17 + .../resources/yaml/fixtures/with_shared.yaml | 23 + .../resources/yaml/fixtures/with_skills.yaml | 12 + .../yaml/fixtures/with_tools_and_prompts.yaml | 14 + .../yaml/python-parity/yaml_test_agent.yaml | 32 ++ .../test/yaml/YamlChatActions.java | 149 +++++++ .../test/yaml/YamlLoaderIntegrationTest.java | 188 ++++++++ .../test/resources/yaml/yaml_multi_agent.yaml | 54 +++ .../test/resources/yaml/yaml_test_agent.yaml | 41 ++ .../test/YamlCrossLanguageActions.java | 66 +++ .../resource/test/YamlCrossLanguageTest.java | 111 +++++ .../yaml/yaml_cross_language_agent.yaml | 56 +++ .../yaml_cross_language_actions.py | 16 + .../env/RemoteExecutionEnvironment.java | 26 +- 51 files changed, 3818 insertions(+), 4 deletions(-) create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/Language.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/YamlLoader.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/ActionSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentActionRef.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptMessage.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/SkillsSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/ToolSpec.java create mode 100644 api/src/main/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocument.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/LanguageTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/LoaderTargets.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildAgentsTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildersTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderFunctionTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderLoadYamlTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/YamlPythonFixtureParityTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/ActionSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/AgentSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/PromptSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/SchemaParityTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/SkillsSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/ToolSpecTest.java create mode 100644 api/src/test/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocumentTest.java create mode 100644 api/src/test/resources/yaml/fixtures/dup_agent.yaml create mode 100644 api/src/test/resources/yaml/fixtures/multi_agent.yaml create mode 100644 api/src/test/resources/yaml/fixtures/multi_file_a.yaml create mode 100644 api/src/test/resources/yaml/fixtures/multi_file_b.yaml create mode 100644 api/src/test/resources/yaml/fixtures/single_agent.yaml create mode 100644 api/src/test/resources/yaml/fixtures/with_descriptors.yaml create mode 100644 api/src/test/resources/yaml/fixtures/with_shared.yaml create mode 100644 api/src/test/resources/yaml/fixtures/with_skills.yaml create mode 100644 api/src/test/resources/yaml/fixtures/with_tools_and_prompts.yaml create mode 100644 api/src/test/resources/yaml/python-parity/yaml_test_agent.yaml create mode 100644 e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlChatActions.java create mode 100644 e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlLoaderIntegrationTest.java create mode 100644 e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_multi_agent.yaml create mode 100644 e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_test_agent.yaml create mode 100644 e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageActions.java create mode 100644 e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageTest.java create mode 100644 e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/resources/yaml/yaml_cross_language_agent.yaml diff --git a/api/pom.xml b/api/pom.xml index 2fb06f467..f7bdbbabd 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -38,6 +38,10 @@ under the License. com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + org.apache.flink flink-streaming-java diff --git a/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java b/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java index 662114274..4aac8b5ee 100644 --- a/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java +++ b/api/src/main/java/org/apache/flink/agents/api/AgentBuilder.java @@ -42,6 +42,21 @@ public interface AgentBuilder { */ AgentBuilder apply(Agent agent); + /** + * Apply an agent previously registered on the environment (typically via {@code + * env.loadYaml(...)}) by name. + * + *

Default implementation throws — concrete builders that have access to the environment + * override this to look up the named agent and delegate to {@link #apply(Agent)}. + * + * @param agentName the name under which the agent was registered on the environment. + * @return a configured AgentBuilder for method chaining. + */ + default AgentBuilder apply(String agentName) { + throw new UnsupportedOperationException( + "apply(String) is not supported by this AgentBuilder; only Agent instances accepted."); + } + /** * Get output list of agent execution. * diff --git a/api/src/main/java/org/apache/flink/agents/api/AgentsExecutionEnvironment.java b/api/src/main/java/org/apache/flink/agents/api/AgentsExecutionEnvironment.java index 37f2c3dce..b1555f069 100644 --- a/api/src/main/java/org/apache/flink/agents/api/AgentsExecutionEnvironment.java +++ b/api/src/main/java/org/apache/flink/agents/api/AgentsExecutionEnvironment.java @@ -18,6 +18,7 @@ package org.apache.flink.agents.api; +import org.apache.flink.agents.api.agents.Agent; import org.apache.flink.agents.api.configuration.Configuration; import org.apache.flink.agents.api.resource.ResourceDescriptor; import org.apache.flink.agents.api.resource.ResourceType; @@ -42,6 +43,7 @@ */ public abstract class AgentsExecutionEnvironment { protected final Map> resources; + protected final Map agents = new HashMap<>(); protected AgentsExecutionEnvironment() { this.resources = new HashMap<>(); @@ -50,6 +52,25 @@ protected AgentsExecutionEnvironment() { } } + /** + * Returns the agents registered on this environment, keyed by name. + * + *

Populated by {@link #loadYaml(java.nio.file.Path...)} and friends. + */ + public Map getAgents() { + return agents; + } + + /** + * Returns the resources registered on this environment, grouped by {@link ResourceType}. + * + *

Exposed primarily so YAML loading code (in a sibling package) and tests can inspect + * registered shared resources without subclassing. + */ + public Map> getResources() { + return resources; + } + /** * Get agents execution environment. * @@ -229,4 +250,20 @@ public AgentsExecutionEnvironment addResource(String name, ResourceType type, Ob } return this; } + + /** + * Load one or more YAML files and register their agents and shared resources on this + * environment. Duplicate names — both within a single file and across the current environment — + * raise {@link IllegalArgumentException}. + */ + public void loadYaml(java.nio.file.Path... paths) { + org.apache.flink.agents.api.yaml.YamlLoader.loadYaml(this, java.util.Arrays.asList(paths)); + } + + /** + * Load multiple YAML files and register their agents and shared resources on this environment. + */ + public void loadYaml(java.util.List paths) { + org.apache.flink.agents.api.yaml.YamlLoader.loadYaml(this, paths); + } } diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java new file mode 100644 index 000000000..f7b2479dd --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java @@ -0,0 +1,185 @@ +/* + * 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.yaml; + +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; +import org.apache.flink.agents.api.event.ContextRetrievalRequestEvent; +import org.apache.flink.agents.api.event.ContextRetrievalResponseEvent; +import org.apache.flink.agents.api.event.ToolRequestEvent; +import org.apache.flink.agents.api.event.ToolResponseEvent; +import org.apache.flink.agents.api.resource.ResourceName; +import org.apache.flink.agents.api.resource.ResourceType; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; + +/** + * Static alias tables for the YAML loader. + * + *

Two tables: + * + *

    + *
  • {@link #EVENT_ALIASES} maps short event names to {@code EVENT_TYPE} constants. + *
  • {@link #CLAZZ_ALIASES} maps short provider names to fully-qualified class paths, keyed on + * resource type and implementation language so the same alias (e.g. {@code ollama}) + * can refer to different classes across sections and languages. + *
+ * + *

For Python resources, the loader resolves the alias to the Python FQN and wraps it in a + * Java-side wrapper class (see {@link #PYTHON_WRAPPER_CLAZZ}). + */ +public final class Aliases { + + /** Short event alias to fully-qualified {@code EVENT_TYPE} string. */ + public static final Map EVENT_ALIASES; + + /** ResourceType to Language to alias to fully-qualified class path. */ + public static final Map>> CLAZZ_ALIASES; + + /** + * ResourceType to Java-side wrapper FQN that embeds a Python implementation. Used when a YAML + * resource declares {@code type: python} so the Java host wraps the Python class through an + * existing PythonResourceWrapper implementation. + */ + public static final Map PYTHON_WRAPPER_CLAZZ; + + static { + Map ev = new HashMap<>(); + ev.put("input", InputEvent.EVENT_TYPE); + ev.put("output", OutputEvent.EVENT_TYPE); + ev.put("chat_request", ChatRequestEvent.EVENT_TYPE); + ev.put("chat_response", ChatResponseEvent.EVENT_TYPE); + ev.put("tool_request", ToolRequestEvent.EVENT_TYPE); + ev.put("tool_response", ToolResponseEvent.EVENT_TYPE); + ev.put("context_retrieval_request", ContextRetrievalRequestEvent.EVENT_TYPE); + ev.put("context_retrieval_response", ContextRetrievalResponseEvent.EVENT_TYPE); + EVENT_ALIASES = Collections.unmodifiableMap(ev); + + Map>> ca = + new EnumMap<>(ResourceType.class); + + // CHAT_MODEL_CONNECTION + Map chatConnJava = new HashMap<>(); + chatConnJava.put("ollama", ResourceName.ChatModel.OLLAMA_CONNECTION); + chatConnJava.put( + "openai_completions", ResourceName.ChatModel.OPENAI_COMPLETIONS_CONNECTION); + chatConnJava.put("openai_responses", ResourceName.ChatModel.OPENAI_RESPONSES_CONNECTION); + chatConnJava.put("anthropic", ResourceName.ChatModel.ANTHROPIC_CONNECTION); + chatConnJava.put("azure", ResourceName.ChatModel.AZURE_CONNECTION); + Map chatConnPython = new HashMap<>(); + chatConnPython.put("ollama", ResourceName.ChatModel.Python.OLLAMA_CONNECTION); + chatConnPython.put("openai", ResourceName.ChatModel.Python.OPENAI_COMPLETIONS_CONNECTION); + chatConnPython.put("anthropic", ResourceName.ChatModel.Python.ANTHROPIC_CONNECTION); + chatConnPython.put("tongyi", ResourceName.ChatModel.Python.TONGYI_CONNECTION); + chatConnPython.put("azure_openai", ResourceName.ChatModel.Python.AZURE_OPENAI_CONNECTION); + ca.put(ResourceType.CHAT_MODEL_CONNECTION, buildLangBuckets(chatConnJava, chatConnPython)); + + // CHAT_MODEL + Map chatJava = new HashMap<>(); + chatJava.put("ollama", ResourceName.ChatModel.OLLAMA_SETUP); + chatJava.put("openai_completions", ResourceName.ChatModel.OPENAI_COMPLETIONS_SETUP); + chatJava.put("openai_responses", ResourceName.ChatModel.OPENAI_RESPONSES_SETUP); + chatJava.put("anthropic", ResourceName.ChatModel.ANTHROPIC_SETUP); + chatJava.put("azure", ResourceName.ChatModel.AZURE_SETUP); + Map chatPython = new HashMap<>(); + chatPython.put("ollama", ResourceName.ChatModel.Python.OLLAMA_SETUP); + chatPython.put("openai", ResourceName.ChatModel.Python.OPENAI_COMPLETIONS_SETUP); + chatPython.put("anthropic", ResourceName.ChatModel.Python.ANTHROPIC_SETUP); + chatPython.put("tongyi", ResourceName.ChatModel.Python.TONGYI_SETUP); + chatPython.put("azure_openai", ResourceName.ChatModel.Python.AZURE_OPENAI_SETUP); + ca.put(ResourceType.CHAT_MODEL, buildLangBuckets(chatJava, chatPython)); + + // EMBEDDING_MODEL_CONNECTION + Map embConnJava = new HashMap<>(); + embConnJava.put("ollama", ResourceName.EmbeddingModel.OLLAMA_CONNECTION); + Map embConnPython = new HashMap<>(); + embConnPython.put("ollama", ResourceName.EmbeddingModel.Python.OLLAMA_CONNECTION); + embConnPython.put("openai", ResourceName.EmbeddingModel.Python.OPENAI_CONNECTION); + embConnPython.put("tongyi", ResourceName.EmbeddingModel.Python.TONGYI_CONNECTION); + ca.put( + ResourceType.EMBEDDING_MODEL_CONNECTION, + buildLangBuckets(embConnJava, embConnPython)); + + // EMBEDDING_MODEL + Map embJava = new HashMap<>(); + embJava.put("ollama", ResourceName.EmbeddingModel.OLLAMA_SETUP); + Map embPython = new HashMap<>(); + embPython.put("ollama", ResourceName.EmbeddingModel.Python.OLLAMA_SETUP); + embPython.put("openai", ResourceName.EmbeddingModel.Python.OPENAI_SETUP); + embPython.put("tongyi", ResourceName.EmbeddingModel.Python.TONGYI_SETUP); + ca.put(ResourceType.EMBEDDING_MODEL, buildLangBuckets(embJava, embPython)); + + // VECTOR_STORE + Map vsJava = new HashMap<>(); + vsJava.put("elasticsearch", ResourceName.VectorStore.ELASTICSEARCH_VECTOR_STORE); + Map vsPython = new HashMap<>(); + vsPython.put("chroma", ResourceName.VectorStore.Python.CHROMA_VECTOR_STORE); + ca.put(ResourceType.VECTOR_STORE, buildLangBuckets(vsJava, vsPython)); + + CLAZZ_ALIASES = Collections.unmodifiableMap(ca); + + Map wrap = new EnumMap<>(ResourceType.class); + wrap.put( + ResourceType.CHAT_MODEL_CONNECTION, + ResourceName.ChatModel.PYTHON_WRAPPER_CONNECTION); + wrap.put(ResourceType.CHAT_MODEL, ResourceName.ChatModel.PYTHON_WRAPPER_SETUP); + wrap.put( + ResourceType.EMBEDDING_MODEL_CONNECTION, + ResourceName.EmbeddingModel.PYTHON_WRAPPER_CONNECTION); + wrap.put(ResourceType.EMBEDDING_MODEL, ResourceName.EmbeddingModel.PYTHON_WRAPPER_SETUP); + wrap.put(ResourceType.VECTOR_STORE, ResourceName.VectorStore.PYTHON_WRAPPER_VECTOR_STORE); + PYTHON_WRAPPER_CLAZZ = Collections.unmodifiableMap(wrap); + } + + private Aliases() {} + + private static Map> buildLangBuckets( + Map javaBucket, Map pythonBucket) { + Map> out = new EnumMap<>(Language.class); + out.put(Language.JAVA, Collections.unmodifiableMap(new HashMap<>(javaBucket))); + out.put(Language.PYTHON, Collections.unmodifiableMap(new HashMap<>(pythonBucket))); + return Collections.unmodifiableMap(out); + } + + /** Look up an event alias; return {@code name} unchanged on miss. */ + public static String resolveEventType(String name) { + return EVENT_ALIASES.getOrDefault(name, name); + } + + /** + * Look up a class alias for {@code (resourceType, language)}; return {@code name} unchanged on + * miss. + */ + public static String resolveClazz(String name, ResourceType resourceType, Language language) { + Map> byLang = CLAZZ_ALIASES.get(resourceType); + if (byLang == null) { + return name; + } + Map bucket = byLang.get(language); + if (bucket == null) { + return name; + } + return bucket.getOrDefault(name, name); + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/Language.java b/api/src/main/java/org/apache/flink/agents/api/yaml/Language.java new file mode 100644 index 000000000..5eaee39b7 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/Language.java @@ -0,0 +1,52 @@ +/* + * 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.yaml; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Implementation language of a YAML-declared resource, action, or tool. + * + *

The JSON/YAML wire form is the lowercase string ({@code "python"} or {@code "java"}); the + * loader supplies the host-default when the field is omitted. + */ +public enum Language { + PYTHON("python"), + JAVA("java"); + + private final String value; + + Language(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static Language fromValue(String value) { + for (Language l : values()) { + if (l.value.equals(value)) return l; + } + throw new IllegalArgumentException("Unknown language: " + value); + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/YamlLoader.java b/api/src/main/java/org/apache/flink/agents/api/yaml/YamlLoader.java new file mode 100644 index 000000000..8189c0d38 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/YamlLoader.java @@ -0,0 +1,416 @@ +/* + * 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.yaml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.chat.messages.ChatMessage; +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.agents.api.prompt.Prompt; +import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.api.tools.FunctionTool; +import org.apache.flink.agents.api.yaml.spec.ActionSpec; +import org.apache.flink.agents.api.yaml.spec.AgentActionRef; +import org.apache.flink.agents.api.yaml.spec.AgentSpec; +import org.apache.flink.agents.api.yaml.spec.DescriptorSpec; +import org.apache.flink.agents.api.yaml.spec.PromptSpec; +import org.apache.flink.agents.api.yaml.spec.SkillsSpec; +import org.apache.flink.agents.api.yaml.spec.ToolSpec; +import org.apache.flink.agents.api.yaml.spec.YamlAgentsDocument; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** YAML loader entry points and helpers. */ +public final class YamlLoader { + + private YamlLoader() {} + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + /** Default Java parameter types for an action method: (Event, RunnerContext). */ + private static final List JAVA_ACTION_PARAMETER_TYPES = + Collections.unmodifiableList( + List.of( + "org.apache.flink.agents.api.Event", + "org.apache.flink.agents.api.context.RunnerContext")); + + /** + * Resolve a YAML function reference into a pure-data {@link Function}. + * + *

{@code function} must be {@code :}. For {@link Language#PYTHON} + * the right side is a qualified Python name (which may contain dots for class methods). For + * {@link Language#JAVA} the left side is a class FQN and the right side is a method name; the + * caller must pass {@code parameterTypes}. + */ + public static Function resolveFunction( + String name, String function, Language language, List parameterTypes) { + if (function == null) { + throw new IllegalArgumentException( + "Action/tool '" + + name + + "': 'function' is required and must be of the form " + + "':'."); + } + int firstColon = function.indexOf(':'); + int lastColon = function.lastIndexOf(':'); + if (firstColon <= 0 || firstColon != lastColon || lastColon == function.length() - 1) { + String kind = language == Language.JAVA ? "java" : "python"; + throw new IllegalArgumentException( + "Action/tool '" + + name + + "': " + + kind + + " function '" + + function + + "' must be of the form ':' (e.g. " + + "'pkg.tools:add', 'pkg.tools:MyTools.add', 'com.example.X:method')."); + } + String left = function.substring(0, firstColon); + String right = function.substring(firstColon + 1); + if (language == Language.JAVA) { + return new JavaFunction( + left, right, parameterTypes == null ? Collections.emptyList() : parameterTypes); + } + return new PythonFunction(left, right); + } + + /** + * Build a {@link ResourceDescriptor} from a parsed {@link DescriptorSpec}, resolving the alias + * and applying cross-language wrapping when {@code type: python}. + * + *

For {@code type: python} the resulting descriptor's {@code clazz} is the Java-side wrapper + * FQN (looked up in {@link Aliases#PYTHON_WRAPPER_CLAZZ}) and a {@code pythonClazz} init + * argument carries the Python implementation FQN — matching what {@code PythonResourceProvider} + * already expects. + */ + public static ResourceDescriptor buildDescriptor( + DescriptorSpec spec, ResourceType resourceType) { + Language language = spec.getType() == null ? Language.PYTHON : spec.getType(); + Map extras = new LinkedHashMap<>(spec.getExtras()); + + if (language == Language.PYTHON) { + String wrapper = Aliases.PYTHON_WRAPPER_CLAZZ.get(resourceType); + if (wrapper == null) { + throw new IllegalArgumentException( + "Resource '" + + spec.getName() + + "': type='python' is not supported for " + + resourceType.getValue() + + " (no Java-side Python wrapper)."); + } + String pythonFqn = Aliases.resolveClazz(spec.getClazz(), resourceType, Language.PYTHON); + extras.put("pythonClazz", pythonFqn); + return new ResourceDescriptor(wrapper, extras); + } + String javaFqn = Aliases.resolveClazz(spec.getClazz(), resourceType, Language.JAVA); + return new ResourceDescriptor(javaFqn, extras); + } + + /** Build a {@link FunctionTool} from a parsed {@link ToolSpec}. */ + public static FunctionTool buildTool(ToolSpec spec) { + Language language = spec.getType() == null ? Language.PYTHON : spec.getType(); + if (language == Language.JAVA && spec.getParameterTypes() == null) { + throw new IllegalArgumentException( + "Tool '" + + spec.getName() + + "': java tools must declare 'parameter_types' in YAML."); + } + Function fn = + resolveFunction( + spec.getName(), spec.getFunction(), language, spec.getParameterTypes()); + return new FunctionTool(fn); + } + + /** Build a {@link Prompt} from a parsed {@link PromptSpec}. */ + public static Prompt buildPrompt(PromptSpec spec) { + if (spec.getText() != null) { + return Prompt.fromText(spec.getText()); + } + List messages = + spec.getMessages().stream() + .map(m -> new ChatMessage(m.getRole(), m.getContent())) + .collect(Collectors.toList()); + return Prompt.fromMessages(messages); + } + + /** Build a {@link Skills} resource from a parsed {@link SkillsSpec}. */ + public static Skills buildSkills(SkillsSpec spec) { + return new Skills(new ArrayList<>(spec.getPaths())); + } + + /** + * Output of {@link #buildAgents(Path)}. Holds in-file state without touching any environment. + */ + public static final class LoadedFile { + private final Map agents; + private final Map> sharedResources; + private final Map sharedActions; + private final Map agentSpecs; + + LoadedFile( + Map agents, + Map> sharedResources, + Map sharedActions, + Map agentSpecs) { + this.agents = agents; + this.sharedResources = sharedResources; + this.sharedActions = sharedActions; + this.agentSpecs = agentSpecs; + } + + public Map getAgents() { + return agents; + } + + public Map> getSharedResources() { + return sharedResources; + } + + public Map getSharedActions() { + return sharedActions; + } + + /** Package-private — used by {@code loadYaml(...)} when it lands. */ + Map getAgentSpecs() { + return agentSpecs; + } + } + + /** Parse one YAML file and build the agents it declares. */ + public static LoadedFile buildAgents(Path path) { + YamlAgentsDocument doc; + try { + doc = YAML_MAPPER.readValue(Files.readString(path), YamlAgentsDocument.class); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read YAML " + path, e); + } + if (doc == null) { + throw new IllegalArgumentException("YAML file " + path + " is empty"); + } + + Map agents = new LinkedHashMap<>(); + Map agentSpecs = new LinkedHashMap<>(); + for (AgentSpec spec : doc.getAgents()) { + if (agents.containsKey(spec.getName())) { + throw new IllegalArgumentException( + "Duplicate agent name '" + spec.getName() + "' in " + path); + } + agentSpecs.put(spec.getName(), spec); + agents.put(spec.getName(), buildAgent(spec)); + } + + Map> sharedResources = new EnumMap<>(ResourceType.class); + for (ResourceType t : ResourceType.values()) { + sharedResources.put(t, new LinkedHashMap<>()); + } + + addSharedDescriptors( + sharedResources, + ResourceType.CHAT_MODEL_CONNECTION, + doc.getChatModelConnections(), + path); + addSharedDescriptors( + sharedResources, ResourceType.CHAT_MODEL, doc.getChatModelSetups(), path); + addSharedDescriptors( + sharedResources, + ResourceType.EMBEDDING_MODEL_CONNECTION, + doc.getEmbeddingModelConnections(), + path); + addSharedDescriptors( + sharedResources, ResourceType.EMBEDDING_MODEL, doc.getEmbeddingModelSetups(), path); + addSharedDescriptors( + sharedResources, ResourceType.VECTOR_STORE, doc.getVectorStores(), path); + addSharedDescriptors(sharedResources, ResourceType.MCP_SERVER, doc.getMcpServers(), path); + + for (ToolSpec t : doc.getTools()) { + if (sharedResources.get(ResourceType.TOOL).put(t.getName(), buildTool(t)) != null) { + throw new IllegalArgumentException( + "Duplicate shared tool name '" + t.getName() + "' in " + path); + } + } + for (PromptSpec p : doc.getPrompts()) { + if (sharedResources.get(ResourceType.PROMPT).put(p.getName(), buildPrompt(p)) != null) { + throw new IllegalArgumentException( + "Duplicate shared prompt name '" + p.getName() + "' in " + path); + } + } + for (SkillsSpec s : doc.getSkills()) { + if (sharedResources.get(ResourceType.SKILLS).put(s.getName(), buildSkills(s)) != null) { + throw new IllegalArgumentException( + "Duplicate shared skills name '" + s.getName() + "' in " + path); + } + } + + Map sharedActions = new LinkedHashMap<>(); + for (ActionSpec a : doc.getActions()) { + if (sharedActions.containsKey(a.getName())) { + throw new IllegalArgumentException( + "Duplicate shared action name '" + a.getName() + "' in " + path); + } + sharedActions.put(a.getName(), a); + } + + return new LoadedFile(agents, sharedResources, sharedActions, agentSpecs); + } + + /** Load one YAML file and register its agents and shared resources on the environment. */ + public static void loadYaml(AgentsExecutionEnvironment env, Path path) { + loadYaml(env, List.of(path)); + } + + /** + * Load multiple YAML files. Multiple calls accumulate. Duplicate names — both within a single + * file (caught by {@link #buildAgents(Path)}) and across the current environment — raise {@link + * IllegalArgumentException}. + */ + public static void loadYaml(AgentsExecutionEnvironment env, List paths) { + for (Path path : paths) { + LoadedFile loaded = buildAgents(path); + + // Resolve shared-action string refs first so a bad ref doesn't leave partial state on + // the env. + for (Map.Entry entry : loaded.getAgents().entrySet()) { + AgentSpec spec = loaded.getAgentSpecs().get(entry.getKey()); + for (AgentActionRef ref : spec.getActions()) { + if (!ref.isReference()) { + continue; + } + ActionSpec shared = loaded.getSharedActions().get(ref.getReference()); + if (shared == null) { + throw new IllegalArgumentException( + "Agent '" + + entry.getKey() + + "' references shared action '" + + ref.getReference() + + "' in " + + path + + ", but no shared action with that name is defined at" + + " the file level."); + } + addActionToAgent(entry.getValue(), shared); + } + } + + // Cross-environment agent name uniqueness check. + for (String name : loaded.getAgents().keySet()) { + if (env.getAgents().containsKey(name)) { + throw new IllegalArgumentException( + "Duplicate agent name '" + name + "' (loading " + path + ")"); + } + } + + // Commit shared resources — env.addResource enforces dedup with its own message. + for (Map.Entry> e : + loaded.getSharedResources().entrySet()) { + for (Map.Entry r : e.getValue().entrySet()) { + env.addResource(r.getKey(), e.getKey(), r.getValue()); + } + } + env.getAgents().putAll(loaded.getAgents()); + } + } + + private static void addSharedDescriptors( + Map> sharedResources, + ResourceType type, + List specs, + Path path) { + Map bucket = sharedResources.get(type); + for (DescriptorSpec s : specs) { + if (bucket.put(s.getName(), buildDescriptor(s, type)) != null) { + throw new IllegalArgumentException( + "Duplicate shared resource name '" + s.getName() + "' in " + path); + } + } + } + + private static Agent buildAgent(AgentSpec spec) { + Agent agent = new Agent(); + addAgentDescriptors( + agent, ResourceType.CHAT_MODEL_CONNECTION, spec.getChatModelConnections()); + addAgentDescriptors(agent, ResourceType.CHAT_MODEL, spec.getChatModelSetups()); + addAgentDescriptors( + agent, + ResourceType.EMBEDDING_MODEL_CONNECTION, + spec.getEmbeddingModelConnections()); + addAgentDescriptors(agent, ResourceType.EMBEDDING_MODEL, spec.getEmbeddingModelSetups()); + addAgentDescriptors(agent, ResourceType.VECTOR_STORE, spec.getVectorStores()); + addAgentDescriptors(agent, ResourceType.MCP_SERVER, spec.getMcpServers()); + + for (ToolSpec t : spec.getTools()) { + agent.addResource(t.getName(), ResourceType.TOOL, buildTool(t)); + } + for (PromptSpec p : spec.getPrompts()) { + agent.addResource(p.getName(), ResourceType.PROMPT, buildPrompt(p)); + } + for (SkillsSpec s : spec.getSkills()) { + agent.addResource(s.getName(), ResourceType.SKILLS, buildSkills(s)); + } + for (AgentActionRef ref : spec.getActions()) { + if (ref.isReference()) { + continue; // resolved later in loadYaml + } + addActionToAgent(agent, ref.getSpec()); + } + return agent; + } + + private static void addAgentDescriptors( + Agent agent, ResourceType type, List specs) { + for (DescriptorSpec s : specs) { + agent.addResource(s.getName(), type, buildDescriptor(s, type)); + } + } + + /** + * Resolve an ActionSpec's function reference. Java actions always use the standard {@code + * (Event, RunnerContext)} parameter types — ActionSpec has no {@code parameter_types} field + * because action method signatures are fixed by the framework. + */ + static Function resolveActionFunction(ActionSpec action) { + Language language = action.getType() == null ? Language.PYTHON : action.getType(); + List paramTypes = language == Language.JAVA ? JAVA_ACTION_PARAMETER_TYPES : null; + return resolveFunction(action.getName(), action.getFunction(), language, paramTypes); + } + + /** Register an action on the agent with event aliases resolved. */ + static void addActionToAgent(Agent agent, ActionSpec action) { + Function fn = resolveActionFunction(action); + String[] events = + action.getListenTo().stream().map(Aliases::resolveEventType).toArray(String[]::new); + Map config = action.getConfig(); + agent.addAction(action.getName(), events, fn, config); + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ActionSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ActionSpec.java new file mode 100644 index 000000000..4a8b07cf6 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ActionSpec.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.flink.agents.api.yaml.Language; + +import java.util.List; +import java.util.Map; + +/** Action referencing a user function plus the event types it listens to. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class ActionSpec { + private final String name; + private final String function; + private final List listenTo; + private final Map config; + private final Language type; + + @JsonCreator + public ActionSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty("function") String function, + @JsonProperty(value = "listen_to", required = true) List listenTo, + @JsonProperty("config") Map config, + @JsonProperty("type") Language type) { + if (listenTo == null || listenTo.isEmpty()) { + throw new IllegalArgumentException("listen_to must not be empty"); + } + this.name = name; + this.function = function; + this.listenTo = listenTo; + this.config = config; + this.type = type; + } + + public String getName() { + return name; + } + + public String getFunction() { + return function; + } + + public List getListenTo() { + return listenTo; + } + + public Map getConfig() { + return config; + } + + public Language getType() { + return type; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentActionRef.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentActionRef.java new file mode 100644 index 000000000..a7fdbff26 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentActionRef.java @@ -0,0 +1,73 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.io.IOException; + +/** + * An item under {@code agents[].actions:} — either a string reference to a shared action, or a full + * {@link ActionSpec}. + */ +@JsonDeserialize(using = AgentActionRef.Deserializer.class) +public final class AgentActionRef { + private final String reference; + private final ActionSpec spec; + + private AgentActionRef(String reference, ActionSpec spec) { + this.reference = reference; + this.spec = spec; + } + + public static AgentActionRef of(String reference) { + return new AgentActionRef(reference, null); + } + + public static AgentActionRef of(ActionSpec spec) { + return new AgentActionRef(null, spec); + } + + public boolean isReference() { + return reference != null; + } + + public String getReference() { + return reference; + } + + public ActionSpec getSpec() { + return spec; + } + + static final class Deserializer extends JsonDeserializer { + @Override + public AgentActionRef deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + if (p.currentToken().isScalarValue()) { + return AgentActionRef.of(p.getValueAsString()); + } + ActionSpec spec = ctxt.readValue(p, ActionSpec.class); + return AgentActionRef.of(spec); + } + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentSpec.java new file mode 100644 index 000000000..0e37a8aad --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/AgentSpec.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.List; + +/** One agent inside a YAML file's {@code agents:} list. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class AgentSpec { + private final String name; + private final String description; + private final List prompts; + private final List tools; + private final List skills; + private final List actions; + private final List chatModelConnections; + private final List chatModelSetups; + private final List embeddingModelConnections; + private final List embeddingModelSetups; + private final List vectorStores; + private final List mcpServers; + + @JsonCreator + public AgentSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty("description") String description, + @JsonProperty("prompts") List prompts, + @JsonProperty("tools") List tools, + @JsonProperty("skills") List skills, + @JsonProperty("actions") List actions, + @JsonProperty("chat_model_connections") List chatModelConnections, + @JsonProperty("chat_model_setups") List chatModelSetups, + @JsonProperty("embedding_model_connections") + List embeddingModelConnections, + @JsonProperty("embedding_model_setups") List embeddingModelSetups, + @JsonProperty("vector_stores") List vectorStores, + @JsonProperty("mcp_servers") List mcpServers) { + this.name = name; + this.description = description; + this.prompts = orEmpty(prompts); + this.tools = orEmpty(tools); + this.skills = orEmpty(skills); + this.actions = orEmpty(actions); + this.chatModelConnections = orEmpty(chatModelConnections); + this.chatModelSetups = orEmpty(chatModelSetups); + this.embeddingModelConnections = orEmpty(embeddingModelConnections); + this.embeddingModelSetups = orEmpty(embeddingModelSetups); + this.vectorStores = orEmpty(vectorStores); + this.mcpServers = orEmpty(mcpServers); + } + + private static List orEmpty(List list) { + return list == null ? Collections.emptyList() : list; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public List getPrompts() { + return prompts; + } + + public List getTools() { + return tools; + } + + public List getSkills() { + return skills; + } + + public List getActions() { + return actions; + } + + public List getChatModelConnections() { + return chatModelConnections; + } + + public List getChatModelSetups() { + return chatModelSetups; + } + + public List getEmbeddingModelConnections() { + return embeddingModelConnections; + } + + public List getEmbeddingModelSetups() { + return embeddingModelSetups; + } + + public List getVectorStores() { + return vectorStores; + } + + public List getMcpServers() { + return mcpServers; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpec.java new file mode 100644 index 000000000..8a72c7aba --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpec.java @@ -0,0 +1,72 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.flink.agents.api.yaml.Language; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Schema for any ResourceDescriptor-backed resource. Required: {@code name} and {@code clazz}. + * {@code type} optionally pins the language. All remaining properties are captured into {@link + * #getExtras()} and forwarded as ResourceDescriptor init args by the loader. + */ +public final class DescriptorSpec { + private final String name; + private final String clazz; + private final Language type; + private final Map extras = new LinkedHashMap<>(); + + @JsonCreator + public DescriptorSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty(value = "clazz", required = true) String clazz, + @JsonProperty("type") Language type) { + this.name = name; + this.clazz = clazz; + this.type = type; + } + + @JsonAnySetter + void putExtra(String key, Object value) { + extras.put(key, value); + } + + public String getName() { + return name; + } + + public String getClazz() { + return clazz; + } + + public Language getType() { + return type; + } + + @JsonAnyGetter + public Map getExtras() { + return extras; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptMessage.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptMessage.java new file mode 100644 index 000000000..c2887539b --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptMessage.java @@ -0,0 +1,47 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.flink.agents.api.chat.messages.MessageRole; + +/** One message in a multi-turn prompt template. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class PromptMessage { + private final MessageRole role; + private final String content; + + @JsonCreator + public PromptMessage( + @JsonProperty("role") String role, + @JsonProperty(value = "content", required = true) String content) { + this.role = role == null ? MessageRole.USER : MessageRole.fromValue(role); + this.content = content; + } + + public MessageRole getRole() { + return role; + } + + public String getContent() { + return content; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptSpec.java new file mode 100644 index 000000000..54a5ee2e7 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/PromptSpec.java @@ -0,0 +1,61 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** Declarative prompt: either a single text template or a list of role-tagged messages. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class PromptSpec { + private final String name; + private final String text; + private final List messages; + + @JsonCreator + public PromptSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty("text") String text, + @JsonProperty("messages") List messages) { + this.name = name; + this.text = text; + this.messages = messages; + boolean hasText = text != null && !text.isEmpty(); + boolean hasMessages = messages != null && !messages.isEmpty(); + if (hasText == hasMessages) { + throw new IllegalArgumentException( + "prompt must define exactly one non-empty 'text' or 'messages'"); + } + } + + public String getName() { + return name; + } + + public String getText() { + return text; + } + + public List getMessages() { + return messages; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/SkillsSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/SkillsSpec.java new file mode 100644 index 000000000..61136e049 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/SkillsSpec.java @@ -0,0 +1,48 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** Declarative Skills resource. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class SkillsSpec { + private final String name; + private final List paths; + + @JsonCreator + public SkillsSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty(value = "paths", required = true) List paths) { + this.name = name; + this.paths = paths; + } + + public String getName() { + return name; + } + + public List getPaths() { + return paths; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ToolSpec.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ToolSpec.java new file mode 100644 index 000000000..b42f90cf2 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/ToolSpec.java @@ -0,0 +1,63 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.flink.agents.api.yaml.Language; + +import java.util.List; + +/** Declarative function tool. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class ToolSpec { + private final String name; + private final String function; + private final Language type; + private final List parameterTypes; + + @JsonCreator + public ToolSpec( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty("function") String function, + @JsonProperty("type") Language type, + @JsonProperty("parameter_types") List parameterTypes) { + this.name = name; + this.function = function; + this.type = type; + this.parameterTypes = parameterTypes; + } + + public String getName() { + return name; + } + + public String getFunction() { + return function; + } + + public Language getType() { + return type; + } + + public List getParameterTypes() { + return parameterTypes; + } +} diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocument.java b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocument.java new file mode 100644 index 000000000..cc0874d05 --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocument.java @@ -0,0 +1,117 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.List; + +/** Top-level YAML document. */ +@JsonIgnoreProperties(ignoreUnknown = false) +public final class YamlAgentsDocument { + private final List agents; + private final List prompts; + private final List tools; + private final List skills; + private final List actions; + private final List chatModelConnections; + private final List chatModelSetups; + private final List embeddingModelConnections; + private final List embeddingModelSetups; + private final List vectorStores; + private final List mcpServers; + + @JsonCreator + public YamlAgentsDocument( + @JsonProperty(value = "agents", required = true) List agents, + @JsonProperty("prompts") List prompts, + @JsonProperty("tools") List tools, + @JsonProperty("skills") List skills, + @JsonProperty("actions") List actions, + @JsonProperty("chat_model_connections") List chatModelConnections, + @JsonProperty("chat_model_setups") List chatModelSetups, + @JsonProperty("embedding_model_connections") + List embeddingModelConnections, + @JsonProperty("embedding_model_setups") List embeddingModelSetups, + @JsonProperty("vector_stores") List vectorStores, + @JsonProperty("mcp_servers") List mcpServers) { + this.agents = agents; + this.prompts = orEmpty(prompts); + this.tools = orEmpty(tools); + this.skills = orEmpty(skills); + this.actions = orEmpty(actions); + this.chatModelConnections = orEmpty(chatModelConnections); + this.chatModelSetups = orEmpty(chatModelSetups); + this.embeddingModelConnections = orEmpty(embeddingModelConnections); + this.embeddingModelSetups = orEmpty(embeddingModelSetups); + this.vectorStores = orEmpty(vectorStores); + this.mcpServers = orEmpty(mcpServers); + } + + private static List orEmpty(List list) { + return list == null ? Collections.emptyList() : list; + } + + public List getAgents() { + return agents; + } + + public List getPrompts() { + return prompts; + } + + public List getTools() { + return tools; + } + + public List getSkills() { + return skills; + } + + public List getActions() { + return actions; + } + + public List getChatModelConnections() { + return chatModelConnections; + } + + public List getChatModelSetups() { + return chatModelSetups; + } + + public List getEmbeddingModelConnections() { + return embeddingModelConnections; + } + + public List getEmbeddingModelSetups() { + return embeddingModelSetups; + } + + public List getVectorStores() { + return vectorStores; + } + + public List getMcpServers() { + return mcpServers; + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java b/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java new file mode 100644 index 000000000..8e3d6d3fd --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/AgentBuilderApplyByNameTest.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api; + +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.table.api.Schema; +import org.apache.flink.table.api.Table; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentBuilderApplyByNameTest { + + @Test + void defaultApplyByNameThrows() { + AgentBuilder b = new SimpleStubBuilder(); + assertThatThrownBy(() -> b.apply("any")).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void overrideResolvesFromEnv() { + Agent inc = new Agent(); + AgentBuilder b = new RegistryStubBuilder("inc", inc); + b.apply("inc"); + assertThatThrownBy(() -> b.apply("ghost")).hasMessageContaining("ghost"); + } + + /** Stub builder that doesn't override apply(String). */ + private static final class SimpleStubBuilder implements AgentBuilder { + @Override + public AgentBuilder apply(Agent agent) { + return this; + } + + @Override + public List> toList() { + return List.of(); + } + + @Override + public DataStream toDataStream() { + return null; + } + + @Override + public Table toTable(Schema schema) { + return null; + } + } + + /** Stub builder that overrides apply(String) to resolve from a one-agent registry. */ + private static final class RegistryStubBuilder implements AgentBuilder { + private final String registeredName; + private final Agent registeredAgent; + Agent applied; + + RegistryStubBuilder(String name, Agent agent) { + this.registeredName = name; + this.registeredAgent = agent; + } + + @Override + public AgentBuilder apply(Agent agent) { + this.applied = agent; + return this; + } + + @Override + public AgentBuilder apply(String name) { + if (!registeredName.equals(name)) { + throw new IllegalArgumentException( + "Unknown agent '" + name + "'; no agent with that name is registered."); + } + return apply(registeredAgent); + } + + @Override + public List> toList() { + return List.of(); + } + + @Override + public DataStream toDataStream() { + return null; + } + + @Override + public Table toTable(Schema schema) { + return null; + } + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java new file mode 100644 index 000000000..38211cb0f --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java @@ -0,0 +1,77 @@ +/* + * 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.yaml; + +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; +import org.apache.flink.agents.api.resource.ResourceName; +import org.apache.flink.agents.api.resource.ResourceType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AliasesTest { + + @Test + void resolveEventTypeHits() { + assertThat(Aliases.resolveEventType("input")).isEqualTo(InputEvent.EVENT_TYPE); + assertThat(Aliases.resolveEventType("output")).isEqualTo(OutputEvent.EVENT_TYPE); + assertThat(Aliases.resolveEventType("chat_request")).isEqualTo(ChatRequestEvent.EVENT_TYPE); + assertThat(Aliases.resolveEventType("chat_response")) + .isEqualTo(ChatResponseEvent.EVENT_TYPE); + } + + @Test + void resolveEventTypeMissPassesThrough() { + assertThat(Aliases.resolveEventType("_already_qualified_event")) + .isEqualTo("_already_qualified_event"); + } + + @Test + void clazzAliasJavaBucket() { + String fqn = + Aliases.resolveClazz("ollama", ResourceType.CHAT_MODEL_CONNECTION, Language.JAVA); + assertThat(fqn).isEqualTo(ResourceName.ChatModel.OLLAMA_CONNECTION); + } + + @Test + void clazzAliasPythonBucket() { + String fqn = + Aliases.resolveClazz("ollama", ResourceType.CHAT_MODEL_CONNECTION, Language.PYTHON); + assertThat(fqn).isEqualTo(ResourceName.ChatModel.Python.OLLAMA_CONNECTION); + } + + @Test + void clazzAliasMissPassesThrough() { + String fqn = + Aliases.resolveClazz( + "com.example.MyChat", ResourceType.CHAT_MODEL_CONNECTION, Language.JAVA); + assertThat(fqn).isEqualTo("com.example.MyChat"); + } + + @Test + void pythonWrapperLookup() { + assertThat(Aliases.PYTHON_WRAPPER_CLAZZ.get(ResourceType.CHAT_MODEL_CONNECTION)) + .isEqualTo(ResourceName.ChatModel.PYTHON_WRAPPER_CONNECTION); + assertThat(Aliases.PYTHON_WRAPPER_CLAZZ.get(ResourceType.VECTOR_STORE)) + .isEqualTo(ResourceName.VectorStore.PYTHON_WRAPPER_VECTOR_STORE); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/LanguageTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/LanguageTest.java new file mode 100644 index 000000000..c6151489c --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/LanguageTest.java @@ -0,0 +1,50 @@ +/* + * 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.yaml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LanguageTest { + + @Test + void jsonValueIsLowercase() { + assertThat(Language.PYTHON.getValue()).isEqualTo("python"); + assertThat(Language.JAVA.getValue()).isEqualTo("java"); + } + + @Test + void deserializesFromLowercaseString() throws Exception { + ObjectMapper m = new ObjectMapper(new YAMLFactory()); + Language py = m.readValue("\"python\"", Language.class); + Language ja = m.readValue("\"java\"", Language.class); + assertThat(py).isEqualTo(Language.PYTHON); + assertThat(ja).isEqualTo(Language.JAVA); + } + + @Test + void rejectsUnknownString() { + ObjectMapper m = new ObjectMapper(new YAMLFactory()); + assertThatThrownBy(() -> m.readValue("\"go\"", Language.class)).hasMessageContaining("go"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/LoaderTargets.java b/api/src/test/java/org/apache/flink/agents/api/yaml/LoaderTargets.java new file mode 100644 index 000000000..c4e38dec9 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/LoaderTargets.java @@ -0,0 +1,38 @@ +/* + * 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.yaml; + +/** Static targets referenced by YAML fixtures. */ +public final class LoaderTargets { + private LoaderTargets() {} + + public static void increment(Object event, Object ctx) {} + + public static void decrement(Object event, Object ctx) {} + + public static String notify(String id, String message) { + return "notified " + id + ": " + message; + } + + public static final class Counter { + private Counter() {} + + public static void bump(Object event, Object ctx) {} + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildAgentsTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildAgentsTest.java new file mode 100644 index 000000000..2209dafda --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildAgentsTest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api.yaml; + +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.event.ChatResponseEvent; +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.agents.api.prompt.Prompt; +import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.api.tools.FunctionTool; +import org.apache.flink.agents.api.yaml.YamlLoader.LoadedFile; +import org.apache.flink.api.java.tuple.Tuple3; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class YamlLoaderBuildAgentsTest { + + private static final Path FIXTURES = Paths.get("src/test/resources/yaml/fixtures"); + + @Test + void singleAgent() { + LoadedFile out = YamlLoader.buildAgents(FIXTURES.resolve("single_agent.yaml")); + assertThat(out.getAgents()).containsOnlyKeys("incrementer"); + Agent agent = out.getAgents().get("incrementer"); + + Map>> actions = agent.getActions(); + Tuple3> entry = actions.get("increment"); + assertThat(entry.f0).containsExactly(InputEvent.EVENT_TYPE); + assertThat(entry.f1).isInstanceOf(JavaFunction.class); + JavaFunction jf = (JavaFunction) entry.f1; + assertThat(jf.getQualName()).isEqualTo("org.apache.flink.agents.api.yaml.LoaderTargets"); + assertThat(jf.getMethodName()).isEqualTo("increment"); + // Action parameter types default to (Event, RunnerContext) regardless of YAML hint. + assertThat(jf.getParameterTypes()) + .containsExactly( + "org.apache.flink.agents.api.Event", + "org.apache.flink.agents.api.context.RunnerContext"); + } + + @Test + void resolveEventAliasAndClazzAlias() { + LoadedFile out = YamlLoader.buildAgents(FIXTURES.resolve("with_descriptors.yaml")); + Agent agent = out.getAgents().get("chat_agent"); + + assertThat(agent.getActions().get("increment").f0).containsExactly(InputEvent.EVENT_TYPE); + assertThat(agent.getActions().get("decrement").f0) + .containsExactly(ChatResponseEvent.EVENT_TYPE); + + ResourceDescriptor d = + (ResourceDescriptor) + agent.getResources() + .get(ResourceType.CHAT_MODEL_CONNECTION) + .get("ollama_conn"); + assertThat(d.getClazz()).contains("OllamaChatModelConnection"); + assertThat(d.getInitialArguments()) + .containsEntry("base_url", "http://localhost:11434") + .containsEntry("request_timeout", 30); + } + + @Test + void toolsAndPrompts() { + LoadedFile out = YamlLoader.buildAgents(FIXTURES.resolve("with_tools_and_prompts.yaml")); + Agent agent = out.getAgents().get("tool_agent"); + + FunctionTool tool = + (FunctionTool) agent.getResources().get(ResourceType.TOOL).get("notify"); + assertThat(tool.getFunc()).isInstanceOf(JavaFunction.class); + + Prompt p1 = (Prompt) agent.getResources().get(ResourceType.PROMPT).get("text_prompt"); + assertThat(p1.formatString(Map.of("name", "x"))).isEqualTo("hello x"); + } + + @Test + void sharedSectionsExposedSeparatelyFromAgents() { + LoadedFile out = YamlLoader.buildAgents(FIXTURES.resolve("with_shared.yaml")); + + assertThat(out.getSharedResources().get(ResourceType.CHAT_MODEL_CONNECTION)) + .containsKey("shared_conn"); + assertThat(out.getSharedActions()).containsKey("shared_inc"); + + // buildAgents does not merge shared actions into agents — that's loadYaml's job. + Agent a1 = out.getAgents().get("a1"); + assertThat(a1.getActions()).doesNotContainKey("shared_inc").containsKey("own_dec"); + } + + @Test + void duplicateAgentInFile() { + assertThatThrownBy(() -> YamlLoader.buildAgents(FIXTURES.resolve("dup_agent.yaml"))) + .hasMessageContaining("dup"); + } + + @Test + void skillsPerAgentAndShared() { + LoadedFile out = YamlLoader.buildAgents(FIXTURES.resolve("with_skills.yaml")); + Agent agent = out.getAgents().get("skills_agent"); + Skills own = (Skills) agent.getResources().get(ResourceType.SKILLS).get("agent_skills"); + assertThat(own.getPaths()).containsExactly("./agent_skill_dir"); + + Skills shared = + (Skills) out.getSharedResources().get(ResourceType.SKILLS).get("shared_skills"); + assertThat(shared.getPaths()).containsExactly("./shared_skill_dir", "./more"); + } + + @Test + void actionDefaultsToPython(@TempDir Path tmp) throws Exception { + // Action with no `type:` field defaults to Python (host-neutral default — matches the + // Python loader so the same YAML behaves identically on either side). + Path file = tmp.resolve("py_default.yaml"); + Files.writeString( + file, + "agents:\n" + + " - name: a\n" + + " actions:\n" + + " - name: act\n" + + " function: pkg.mod:fn\n" + + " listen_to: [input]\n"); + LoadedFile out = YamlLoader.buildAgents(file); + Tuple3> entry = + out.getAgents().get("a").getActions().get("act"); + assertThat(entry.f1).isInstanceOf(PythonFunction.class); + PythonFunction pf = (PythonFunction) entry.f1; + assertThat(pf.getModule()).isEqualTo("pkg.mod"); + assertThat(pf.getQualName()).isEqualTo("fn"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildersTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildersTest.java new file mode 100644 index 000000000..84560008f --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderBuildersTest.java @@ -0,0 +1,147 @@ +/* + * 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.yaml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.agents.api.prompt.Prompt; +import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.resource.ResourceName; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.api.tools.FunctionTool; +import org.apache.flink.agents.api.yaml.spec.DescriptorSpec; +import org.apache.flink.agents.api.yaml.spec.PromptSpec; +import org.apache.flink.agents.api.yaml.spec.SkillsSpec; +import org.apache.flink.agents.api.yaml.spec.ToolSpec; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class YamlLoaderBuildersTest { + + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void descriptorJavaResolvesAlias() throws Exception { + DescriptorSpec spec = + M.readValue( + "name: c\nclazz: ollama\ntype: java\nendpoint: http://x\n", + DescriptorSpec.class); + ResourceDescriptor d = YamlLoader.buildDescriptor(spec, ResourceType.CHAT_MODEL_CONNECTION); + assertThat(d.getClazz()).isEqualTo(ResourceName.ChatModel.OLLAMA_CONNECTION); + assertThat(d.getInitialArguments()).containsEntry("endpoint", "http://x"); + } + + @Test + void descriptorPythonWrapsInPythonWrapper() throws Exception { + DescriptorSpec spec = + M.readValue( + "name: c\nclazz: ollama\ntype: python\nbase_url: http://x\n", + DescriptorSpec.class); + ResourceDescriptor d = YamlLoader.buildDescriptor(spec, ResourceType.CHAT_MODEL_CONNECTION); + assertThat(d.getClazz()).isEqualTo(ResourceName.ChatModel.PYTHON_WRAPPER_CONNECTION); + assertThat(d.getInitialArguments()) + .containsEntry("pythonClazz", ResourceName.ChatModel.Python.OLLAMA_CONNECTION) + .containsEntry("base_url", "http://x"); + } + + @Test + void descriptorDefaultIsPython() throws Exception { + DescriptorSpec spec = M.readValue("name: c\nclazz: ollama\n", DescriptorSpec.class); + ResourceDescriptor d = YamlLoader.buildDescriptor(spec, ResourceType.CHAT_MODEL_CONNECTION); + // Default is Python (host-neutral) — wrapper FQN with Python class in pythonClazz arg. + assertThat(d.getClazz()).isEqualTo(ResourceName.ChatModel.PYTHON_WRAPPER_CONNECTION); + assertThat(d.getInitialArguments()) + .containsEntry("pythonClazz", ResourceName.ChatModel.Python.OLLAMA_CONNECTION); + } + + @Test + void buildToolDefaultsToPython() throws Exception { + ToolSpec spec = M.readValue("name: t\nfunction: pkg:fn\n", ToolSpec.class); + FunctionTool tool = YamlLoader.buildTool(spec); + // No type → Python default; Python tools don't need parameter_types. + assertThat(tool.getFunc()).isInstanceOf(PythonFunction.class); + } + + @Test + void descriptorPythonRejectedForUnsupportedKind() throws Exception { + DescriptorSpec spec = + M.readValue("name: x\nclazz: anything\ntype: python\n", DescriptorSpec.class); + assertThatThrownBy(() -> YamlLoader.buildDescriptor(spec, ResourceType.MCP_SERVER)) + .hasMessageContaining("python"); + } + + @Test + void buildToolPython() throws Exception { + ToolSpec spec = M.readValue("name: t\nfunction: pkg:fn\ntype: python\n", ToolSpec.class); + FunctionTool tool = YamlLoader.buildTool(spec); + assertThat(tool.getFunc()).isInstanceOf(PythonFunction.class); + assertThat(((PythonFunction) tool.getFunc()).getQualName()).isEqualTo("fn"); + } + + @Test + void buildToolJavaRequiresParameterTypes() throws Exception { + ToolSpec spec = + M.readValue("name: t\nfunction: com.example.X:m\ntype: java\n", ToolSpec.class); + assertThatThrownBy(() -> YamlLoader.buildTool(spec)) + .hasMessageContaining("parameter_types"); + } + + @Test + void buildToolJava() throws Exception { + ToolSpec spec = + M.readValue( + "name: t\nfunction: com.example.X:m\ntype: java\nparameter_types: [int]\n", + ToolSpec.class); + FunctionTool tool = YamlLoader.buildTool(spec); + assertThat(tool.getFunc()).isInstanceOf(JavaFunction.class); + assertThat(((JavaFunction) tool.getFunc()).getParameterTypes()).containsExactly("int"); + } + + @Test + void buildPromptText() throws Exception { + PromptSpec spec = M.readValue("name: p\ntext: hi\n", PromptSpec.class); + Prompt prompt = YamlLoader.buildPrompt(spec); + assertThat(prompt.formatString(Map.of())).isEqualTo("hi"); + } + + @Test + void buildPromptMessages() throws Exception { + PromptSpec spec = + M.readValue( + "name: p\nmessages:\n - {role: system, content: hi}\n", PromptSpec.class); + Prompt prompt = YamlLoader.buildPrompt(spec); + // Both message-templated and text-templated prompts produce LocalPrompt instances; + // confirm the constructed prompt formats without error. + assertThat(prompt).isNotNull(); + } + + @Test + void buildSkills() throws Exception { + SkillsSpec spec = M.readValue("name: s\npaths: [./a]\n", SkillsSpec.class); + Skills s = YamlLoader.buildSkills(spec); + assertThat(s.getPaths()).containsExactly("./a"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderFunctionTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderFunctionTest.java new file mode 100644 index 000000000..b1194f6aa --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderFunctionTest.java @@ -0,0 +1,81 @@ +/* + * 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.yaml; + +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.function.PythonFunction; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class YamlLoaderFunctionTest { + + @Test + void pythonRef() { + Object fn = YamlLoader.resolveFunction("a", "pkg.mod:fn", Language.PYTHON, null); + assertThat(fn).isInstanceOf(PythonFunction.class); + PythonFunction pf = (PythonFunction) fn; + assertThat(pf.getModule()).isEqualTo("pkg.mod"); + assertThat(pf.getQualName()).isEqualTo("fn"); + } + + @Test + void javaRef() { + Object fn = + YamlLoader.resolveFunction( + "a", + "com.example.X:m", + Language.JAVA, + List.of("org.apache.flink.agents.api.Event")); + assertThat(fn).isInstanceOf(JavaFunction.class); + JavaFunction jf = (JavaFunction) fn; + assertThat(jf.getQualName()).isEqualTo("com.example.X"); + assertThat(jf.getMethodName()).isEqualTo("m"); + assertThat(jf.getParameterTypes()).containsExactly("org.apache.flink.agents.api.Event"); + } + + @Test + void rejectsMissingFunction() { + assertThatThrownBy(() -> YamlLoader.resolveFunction("a", null, Language.PYTHON, null)) + .hasMessageContaining("'function' is required"); + } + + @Test + void rejectsMissingColon() { + assertThatThrownBy(() -> YamlLoader.resolveFunction("a", "pkg.fn", Language.PYTHON, null)) + .hasMessageContaining("module-or-class"); + } + + @Test + void rejectsMultipleColons() { + assertThatThrownBy(() -> YamlLoader.resolveFunction("a", "a:b:c", Language.PYTHON, null)) + .hasMessageContaining("module-or-class"); + } + + @Test + void rejectsEmptyLeftOrRight() { + assertThatThrownBy(() -> YamlLoader.resolveFunction("a", ":fn", Language.PYTHON, null)) + .hasMessageContaining("module-or-class"); + assertThatThrownBy(() -> YamlLoader.resolveFunction("a", "pkg:", Language.PYTHON, null)) + .hasMessageContaining("module-or-class"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderLoadYamlTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderLoadYamlTest.java new file mode 100644 index 000000000..5101d068c --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlLoaderLoadYamlTest.java @@ -0,0 +1,145 @@ +/* + * 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.yaml; + +import org.apache.flink.agents.api.AgentBuilder; +import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.configuration.Configuration; +import org.apache.flink.agents.api.function.Function; +import org.apache.flink.agents.api.function.JavaFunction; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.api.java.tuple.Tuple3; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.table.api.Table; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class YamlLoaderLoadYamlTest { + + private static final Path FIXTURES = Paths.get("src/test/resources/yaml/fixtures"); + + @Test + void registersSingleAgentOnEnv() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml(FIXTURES.resolve("single_agent.yaml")); + assertThat(env.getAgents()).containsKey("incrementer"); + } + + @Test + void mergesSharedActionIntoEachReferencingAgent() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml(FIXTURES.resolve("with_shared.yaml")); + + Agent a1 = env.getAgents().get("a1"); + Agent a2 = env.getAgents().get("a2"); + + Map>> a1Actions = a1.getActions(); + Map>> a2Actions = a2.getActions(); + assertThat(a1Actions).containsKey("shared_inc").containsKey("own_dec"); + assertThat(a2Actions).containsKey("shared_inc"); + + assertThat(a1Actions.get("shared_inc").f1).isInstanceOf(JavaFunction.class); + } + + @Test + void registersSharedResourcesOnEnv() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml(FIXTURES.resolve("with_shared.yaml")); + assertThat(env.getResources().get(ResourceType.CHAT_MODEL_CONNECTION)) + .containsKey("shared_conn"); + } + + @Test + void multipleFilesAccumulate() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml( + List.of( + FIXTURES.resolve("multi_file_a.yaml"), + FIXTURES.resolve("multi_file_b.yaml"))); + assertThat(env.getAgents()).containsKeys("file_a_agent", "file_b_agent"); + assertThat(env.getResources().get(ResourceType.CHAT_MODEL_CONNECTION)) + .containsKeys("conn_from_a", "conn_from_b"); + } + + @Test + void duplicateAgentAcrossCallsRejected() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml(FIXTURES.resolve("multi_file_a.yaml")); + assertThatThrownBy(() -> env.loadYaml(FIXTURES.resolve("multi_file_a.yaml"))) + .hasMessageContaining("file_a_agent"); + } + + @Test + void missingSharedActionRejected(@TempDir Path tmp) throws Exception { + Path bad = tmp.resolve("bad.yaml"); + Files.writeString(bad, "agents:\n - name: a\n actions:\n - undefined_action\n"); + AgentsExecutionEnvironment env = new TestEnv(); + assertThatThrownBy(() -> env.loadYaml(bad)).hasMessageContaining("undefined_action"); + } + + @Test + void sharedSkillsRegistered() { + AgentsExecutionEnvironment env = new TestEnv(); + env.loadYaml(FIXTURES.resolve("with_skills.yaml")); + Skills shared = (Skills) env.getResources().get(ResourceType.SKILLS).get("shared_skills"); + assertThat(shared.getPaths()).containsExactly("./shared_skill_dir", "./more"); + } + + /** Minimal env stub — we can't instantiate LocalExecutionEnvironment from the api module. */ + private static final class TestEnv extends AgentsExecutionEnvironment { + @Override + public Configuration getConfig() { + return null; + } + + @Override + public AgentBuilder fromList(List input) { + return null; + } + + @Override + public AgentBuilder fromDataStream( + DataStream input, KeySelector keySelector) { + return null; + } + + @Override + public AgentBuilder fromTable(Table input, KeySelector keySelector) { + return null; + } + + @Override + public void execute() {} + + @Override + public void execute(String jobName) {} + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/YamlPythonFixtureParityTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlPythonFixtureParityTest.java new file mode 100644 index 000000000..20c36e7e8 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/YamlPythonFixtureParityTest.java @@ -0,0 +1,75 @@ +/* + * 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.yaml; + +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.function.PythonFunction; +import org.apache.flink.agents.api.resource.ResourceName; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.tools.FunctionTool; +import org.apache.flink.agents.api.yaml.YamlLoader.LoadedFile; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Loads a Python-authored YAML fixture verbatim through the Java loader and confirms it builds a + * working Agent tree. With the host-neutral Python default for {@code type:}, the same file behaves + * identically on either side: omitted {@code type:} fields are interpreted as Python on both + * loaders. + */ +class YamlPythonFixtureParityTest { + + private static final Path FIXTURE = + Path.of("src/test/resources/yaml/python-parity/yaml_test_agent.yaml"); + + @Test + void buildsPythonAuthoredYamlVerbatim() { + LoadedFile out = YamlLoader.buildAgents(FIXTURE); + + Agent agent = out.getAgents().get("yaml_test_agent"); + assertThat(agent).isNotNull(); + + // Action `function:` with no `type:` lands as a PythonFunction (no parameter_types needed). + PythonFunction processInput = (PythonFunction) agent.getActions().get("process_input").f1; + assertThat(processInput.getQualName()).isEqualTo("process_input"); + + // Tool `function:` with no `type:` lands as a PythonFunction-backed FunctionTool. + FunctionTool addTool = + (FunctionTool) agent.getResources().get(ResourceType.TOOL).get("add"); + assertThat(addTool.getFunc()).isInstanceOf(PythonFunction.class); + + // Descriptor `clazz: ollama` with no `type:` is wrapped in the Java-side Python + // wrapper class, with the resolved Python FQN passed as the `pythonClazz` init arg. + Object connection = + agent.getResources() + .get(ResourceType.CHAT_MODEL_CONNECTION) + .get("ollama_connection"); + assertThat(connection) + .isInstanceOf(org.apache.flink.agents.api.resource.ResourceDescriptor.class); + org.apache.flink.agents.api.resource.ResourceDescriptor descriptor = + (org.apache.flink.agents.api.resource.ResourceDescriptor) connection; + assertThat(descriptor.getClazz()) + .isEqualTo(ResourceName.ChatModel.PYTHON_WRAPPER_CONNECTION); + assertThat(descriptor.getInitialArguments()) + .containsEntry("pythonClazz", ResourceName.ChatModel.Python.OLLAMA_CONNECTION); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ActionSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ActionSpecTest.java new file mode 100644 index 000000000..3143f5623 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ActionSpecTest.java @@ -0,0 +1,71 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.yaml.Language; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ActionSpecTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void minimal() throws Exception { + ActionSpec spec = + M.readValue("name: a\nfunction: pkg:fn\nlisten_to: [input]\n", ActionSpec.class); + assertThat(spec.getName()).isEqualTo("a"); + assertThat(spec.getFunction()).isEqualTo("pkg:fn"); + assertThat(spec.getListenTo()).containsExactly("input"); + assertThat(spec.getConfig()).isNull(); + assertThat(spec.getType()).isNull(); + } + + @Test + void rejectsEmptyListenTo() { + assertThatThrownBy( + () -> + M.readValue( + "name: a\nfunction: x:y\nlisten_to: []\n", + ActionSpec.class)) + .hasMessageContaining("listen_to"); + } + + @Test + void typeJava() throws Exception { + ActionSpec spec = + M.readValue( + "name: a\nfunction: X:m\nlisten_to: [input]\ntype: java\n", + ActionSpec.class); + assertThat(spec.getType()).isEqualTo(Language.JAVA); + } + + @Test + void rejectsUnknownProperty() { + assertThatThrownBy( + () -> + M.readValue( + "name: a\nfunction: x:y\nlisten_to: [input]\nextra: 1\n", + ActionSpec.class)) + .hasMessageContaining("extra"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/AgentSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/AgentSpecTest.java new file mode 100644 index 000000000..a7840ed74 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/AgentSpecTest.java @@ -0,0 +1,67 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgentSpecTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void minimalAgentParses() throws Exception { + AgentSpec spec = M.readValue("name: a\n", AgentSpec.class); + assertThat(spec.getName()).isEqualTo("a"); + assertThat(spec.getActions()).isEmpty(); + assertThat(spec.getTools()).isEmpty(); + assertThat(spec.getChatModelConnections()).isEmpty(); + } + + @Test + void actionEntryCanBeStringOrFullSpec() throws Exception { + String yaml = + "name: a\n" + + "actions:\n" + + " - shared_one\n" + + " - name: own\n" + + " function: pkg:fn\n" + + " listen_to: [input]\n"; + AgentSpec spec = M.readValue(yaml, AgentSpec.class); + assertThat(spec.getActions()).hasSize(2); + assertThat(spec.getActions().get(0).isReference()).isTrue(); + assertThat(spec.getActions().get(0).getReference()).isEqualTo("shared_one"); + assertThat(spec.getActions().get(1).isReference()).isFalse(); + assertThat(spec.getActions().get(1).getSpec().getName()).isEqualTo("own"); + } + + @Test + void descriptorSectionsParse() throws Exception { + String yaml = + "name: a\n" + + "chat_model_connections:\n" + + " - name: c1\n" + + " clazz: ollama\n"; + AgentSpec spec = M.readValue(yaml, AgentSpec.class); + assertThat(spec.getChatModelConnections()).hasSize(1); + assertThat(spec.getChatModelConnections().get(0).getName()).isEqualTo("c1"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpecTest.java new file mode 100644 index 000000000..e667a3929 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/DescriptorSpecTest.java @@ -0,0 +1,67 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.yaml.Language; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DescriptorSpecTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void requiresNameAndClazz() { + assertThatThrownBy(() -> M.readValue("clazz: x.Y\n", DescriptorSpec.class)) + .hasMessageContaining("name"); + assertThatThrownBy(() -> M.readValue("name: n\n", DescriptorSpec.class)) + .hasMessageContaining("clazz"); + } + + @Test + void passesExtrasThrough() throws Exception { + DescriptorSpec spec = + M.readValue( + "name: n\nclazz: x.Y\nbase_url: http://x\ntimeout: 5\n", + DescriptorSpec.class); + assertThat(spec.getName()).isEqualTo("n"); + assertThat(spec.getClazz()).isEqualTo("x.Y"); + Map extras = spec.getExtras(); + assertThat(extras).containsEntry("base_url", "http://x").containsEntry("timeout", 5); + } + + @Test + void typeAcceptsPythonAndJava() throws Exception { + DescriptorSpec py = M.readValue("name: n\nclazz: x\ntype: python\n", DescriptorSpec.class); + DescriptorSpec ja = M.readValue("name: n\nclazz: x\ntype: java\n", DescriptorSpec.class); + assertThat(py.getType()).isEqualTo(Language.PYTHON); + assertThat(ja.getType()).isEqualTo(Language.JAVA); + } + + @Test + void typeOptional() throws Exception { + DescriptorSpec spec = M.readValue("name: n\nclazz: x\n", DescriptorSpec.class); + assertThat(spec.getType()).isNull(); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/PromptSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/PromptSpecTest.java new file mode 100644 index 000000000..270385e5a --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/PromptSpecTest.java @@ -0,0 +1,88 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.chat.messages.MessageRole; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PromptSpecTest { + + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void promptMessageDefaultsToUser() throws Exception { + PromptMessage msg = M.readValue("content: hi\n", PromptMessage.class); + assertThat(msg.getRole()).isEqualTo(MessageRole.USER); + assertThat(msg.getContent()).isEqualTo("hi"); + } + + @Test + void promptWithText() throws Exception { + PromptSpec spec = M.readValue("name: p1\ntext: \"hello {x}\"\n", PromptSpec.class); + assertThat(spec.getName()).isEqualTo("p1"); + assertThat(spec.getText()).isEqualTo("hello {x}"); + assertThat(spec.getMessages()).isNull(); + } + + @Test + void promptWithMessages() throws Exception { + String yaml = + "name: p1\n" + + "messages:\n" + + " - {role: system, content: be brief}\n" + + " - {role: user, content: \"{q}\"}\n"; + PromptSpec spec = M.readValue(yaml, PromptSpec.class); + assertThat(spec.getText()).isNull(); + assertThat(spec.getMessages()).hasSize(2); + assertThat(spec.getMessages().get(0).getRole()).isEqualTo(MessageRole.SYSTEM); + assertThat(spec.getMessages().get(1).getContent()).isEqualTo("{q}"); + } + + @Test + void rejectsBothTextAndMessages() { + String yaml = "name: p1\ntext: x\nmessages: [{content: y}]\n"; + assertThatThrownBy(() -> M.readValue(yaml, PromptSpec.class)) + .hasMessageContaining("exactly one"); + } + + @Test + void rejectsNeitherTextNorMessages() { + assertThatThrownBy(() -> M.readValue("name: p1\n", PromptSpec.class)) + .hasMessageContaining("exactly one"); + } + + @Test + void rejectsEmptyTextOrEmptyMessages() { + assertThatThrownBy(() -> M.readValue("name: p1\ntext: \"\"\n", PromptSpec.class)) + .hasMessageContaining("exactly one"); + assertThatThrownBy(() -> M.readValue("name: p1\nmessages: []\n", PromptSpec.class)) + .hasMessageContaining("exactly one"); + } + + @Test + void rejectsUnknownProperty() { + assertThatThrownBy(() -> M.readValue("name: p1\ntext: x\nextra: y\n", PromptSpec.class)) + .hasMessageContaining("extra"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SchemaParityTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SchemaParityTest.java new file mode 100644 index 000000000..ba733cf98 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SchemaParityTest.java @@ -0,0 +1,249 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.flink.agents.api.chat.messages.MessageRole; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Structural parity check between the checked-in {@code docs/yaml-schema.json} and the Java YAML + * spec DTOs. The Pydantic models are the wire-format ground truth; this test ensures the Java POJOs + * that consume the same wire format don't silently drift in either direction (missing property, + * wrong required field, additionalProperties strictness mismatch). + * + *

Reads each {@code $defs} entry from the schema, finds the matching Java class by simple name, + * and asserts: same property names, same {@code required} set, same {@code additionalProperties} + * stance. Also checks the top-level document and the {@code MessageRole} enum. + * + *

Counterpart of Python's {@code test_checked_in_schema_matches_pydantic_models} in {@code + * python/flink_agents/api/yaml/tests/test_specs.py}. + */ +class SchemaParityTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** $defs simple-name → Java spec class. Top-level document handled separately. */ + private static final Map> SPEC_CLASSES; + + static { + Map> m = new HashMap<>(); + m.put("ActionSpec", ActionSpec.class); + m.put("AgentSpec", AgentSpec.class); + m.put("DescriptorSpec", DescriptorSpec.class); + m.put("PromptMessage", PromptMessage.class); + m.put("PromptSpec", PromptSpec.class); + m.put("SkillsSpec", SkillsSpec.class); + m.put("ToolSpec", ToolSpec.class); + SPEC_CLASSES = Map.copyOf(m); + } + + @Test + void checkedInSchemaMatchesJavaSpecs() throws IOException { + JsonNode schema = MAPPER.readTree(Files.readString(schemaFile())); + JsonNode defs = schema.get("$defs"); + assertThat(defs).as("docs/yaml-schema.json missing $defs").isNotNull(); + + Set seen = new HashSet<>(); + for (Iterator it = defs.fieldNames(); it.hasNext(); ) { + String name = it.next(); + seen.add(name); + JsonNode def = defs.get(name); + if ("MessageRole".equals(name)) { + assertMessageRoleEnum(def); + continue; + } + Class javaClass = SPEC_CLASSES.get(name); + assertThat(javaClass).as("no Java spec for $defs/%s", name).isNotNull(); + assertDefMatchesClass(name, def, javaClass); + } + + // Every Java spec in the map must have appeared in $defs. + Set expectedSchemaDefs = new HashSet<>(SPEC_CLASSES.keySet()); + expectedSchemaDefs.add("MessageRole"); + assertThat(seen).as("$defs entries").isEqualTo(expectedSchemaDefs); + + // Top-level YAML document. + assertDefMatchesClass("YamlAgentsDocument", schema, YamlAgentsDocument.class); + } + + private static void assertMessageRoleEnum(JsonNode def) { + Set schemaValues = new HashSet<>(); + for (JsonNode v : def.get("enum")) { + schemaValues.add(v.asText()); + } + Set javaValues = + Arrays.stream(MessageRole.values()) + .map(MessageRole::getValue) + .collect(Collectors.toSet()); + assertThat(schemaValues).as("MessageRole enum values").isEqualTo(javaValues); + } + + private static void assertDefMatchesClass(String defName, JsonNode def, Class clz) { + Map creatorProps = creatorProperties(clz); + Set jacksonNames = creatorProps.keySet(); + + Set schemaProps = new HashSet<>(); + JsonNode propsNode = def.get("properties"); + if (propsNode != null) { + for (Iterator it = propsNode.fieldNames(); it.hasNext(); ) { + schemaProps.add(it.next()); + } + } + + if (hasJsonAnySetter(clz)) { + // DescriptorSpec captures arbitrary extras via @JsonAnySetter; Jackson's known + // properties are a strict subset of what the schema declares. + assertThat(jacksonNames) + .as("%s: Java POJO is missing schema-declared properties", defName) + .containsAll(schemaProps); + } else { + assertThat(jacksonNames) + .as("%s: Java POJO properties drift from schema", defName) + .isEqualTo(schemaProps); + } + + Set schemaRequired = new HashSet<>(); + JsonNode requiredNode = def.get("required"); + if (requiredNode != null) { + for (JsonNode r : requiredNode) { + schemaRequired.add(r.asText()); + } + } + Set jacksonRequired = + creatorProps.entrySet().stream() + .filter(Map.Entry::getValue) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + assertThat(jacksonRequired) + .as("%s: required fields drift from schema", defName) + .isEqualTo(schemaRequired); + + // additionalProperties: schema is the source of truth. Pydantic emits ``false`` for + // strict specs and ``true`` for ``extra="allow"`` (DescriptorSpec). The top-level + // YamlAgentsDocument omits the key (Pydantic default = strict), so treat absence as + // false too. + boolean schemaAllowsExtra = + def.has("additionalProperties") && def.get("additionalProperties").asBoolean(); + boolean javaAllowsExtra = javaAllowsAdditional(clz); + assertThat(javaAllowsExtra) + .as("%s: additionalProperties drift from schema", defName) + .isEqualTo(schemaAllowsExtra); + } + + /** + * Collect the wire-format property names + required flag declared on the {@code @JsonCreator} + * constructor of {@code clz}. Reading from the creator (rather than serialization-time bean + * introspection) avoids picking up the Java camelCase field/getter names as duplicate aliases + * of the snake_case JSON keys. + */ + private static Map creatorProperties(Class clz) { + Constructor creator = null; + for (Constructor c : clz.getDeclaredConstructors()) { + if (c.isAnnotationPresent(JsonCreator.class)) { + creator = c; + break; + } + } + if (creator == null) { + throw new IllegalStateException("No @JsonCreator constructor on " + clz.getName()); + } + Map out = new LinkedHashMap<>(); + Parameter[] params = creator.getParameters(); + Annotation[][] parameterAnnotations = creator.getParameterAnnotations(); + for (int i = 0; i < params.length; i++) { + JsonProperty jp = null; + for (Annotation a : parameterAnnotations[i]) { + if (a instanceof JsonProperty) { + jp = (JsonProperty) a; + break; + } + } + if (jp == null) { + throw new IllegalStateException( + "Creator parameter " + + params[i].getName() + + " on " + + clz.getName() + + " is missing @JsonProperty — Jackson can't deserialize it"); + } + out.put(jp.value(), jp.required()); + } + return out; + } + + private static boolean hasJsonAnySetter(Class clz) { + for (Method m : clz.getDeclaredMethods()) { + if (m.isAnnotationPresent(JsonAnySetter.class)) { + return true; + } + } + return false; + } + + private static boolean javaAllowsAdditional(Class clz) { + if (hasJsonAnySetter(clz)) { + return true; + } + JsonIgnoreProperties annot = clz.getAnnotation(JsonIgnoreProperties.class); + return annot != null && annot.ignoreUnknown(); + } + + private static Path schemaFile() { + // Resolve docs/yaml-schema.json by walking up from the current working directory until + // the docs/ folder appears. Maven runs the test with cwd = api/ module dir; the schema + // lives at /docs/yaml-schema.json. + Path dir = Paths.get("").toAbsolutePath(); + for (int i = 0; i < 6; i++) { + Path candidate = dir.resolve("docs").resolve("yaml-schema.json"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + if (dir == null) { + break; + } + } + throw new IllegalStateException("Could not locate docs/yaml-schema.json from cwd"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SkillsSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SkillsSpecTest.java new file mode 100644 index 000000000..388e33033 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/SkillsSpecTest.java @@ -0,0 +1,43 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillsSpecTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void parsesNameAndPaths() throws Exception { + SkillsSpec spec = M.readValue("name: s\npaths: [./a, ./b]\n", SkillsSpec.class); + assertThat(spec.getName()).isEqualTo("s"); + assertThat(spec.getPaths()).containsExactly("./a", "./b"); + } + + @Test + void rejectsUnknownProperty() { + assertThatThrownBy(() -> M.readValue("name: s\npaths: []\nextra: 1\n", SkillsSpec.class)) + .hasMessageContaining("extra"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ToolSpecTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ToolSpecTest.java new file mode 100644 index 000000000..8f8ff5e96 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/ToolSpecTest.java @@ -0,0 +1,59 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.apache.flink.agents.api.yaml.Language; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ToolSpecTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void minimalPythonTool() throws Exception { + ToolSpec spec = M.readValue("name: t\nfunction: pkg:fn\n", ToolSpec.class); + assertThat(spec.getName()).isEqualTo("t"); + assertThat(spec.getFunction()).isEqualTo("pkg:fn"); + assertThat(spec.getType()).isNull(); + assertThat(spec.getParameterTypes()).isNull(); + } + + @Test + void javaToolWithParameterTypes() throws Exception { + String yaml = + "name: t\n" + + "function: com.example.X:m\n" + + "type: java\n" + + "parameter_types: [int, java.lang.String]\n"; + ToolSpec spec = M.readValue(yaml, ToolSpec.class); + assertThat(spec.getType()).isEqualTo(Language.JAVA); + assertThat(spec.getParameterTypes()).containsExactly("int", "java.lang.String"); + } + + @Test + void rejectsUnknownProperty() { + assertThatThrownBy( + () -> M.readValue("name: t\nfunction: pkg:fn\nextra: 1\n", ToolSpec.class)) + .hasMessageContaining("extra"); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocumentTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocumentTest.java new file mode 100644 index 000000000..e5326826b --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/spec/YamlAgentsDocumentTest.java @@ -0,0 +1,49 @@ +/* + * 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.yaml.spec; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class YamlAgentsDocumentTest { + private static final ObjectMapper M = new ObjectMapper(new YAMLFactory()); + + @Test + void singleAgentOnly() throws Exception { + YamlAgentsDocument doc = M.readValue("agents:\n - name: a\n", YamlAgentsDocument.class); + assertThat(doc.getAgents()).hasSize(1); + assertThat(doc.getActions()).isEmpty(); + assertThat(doc.getChatModelConnections()).isEmpty(); + } + + @Test + void sharedSectionsAtFileLevel() throws Exception { + String yaml = + "agents:\n - name: a\n" + + "chat_model_connections:\n - name: shared\n clazz: x.Y\n" + + "actions:\n - name: shared_a\n function: pkg:fn\n listen_to: [input]\n"; + YamlAgentsDocument doc = M.readValue(yaml, YamlAgentsDocument.class); + assertThat(doc.getChatModelConnections()).hasSize(1); + assertThat(doc.getActions()).hasSize(1); + assertThat(doc.getActions().get(0).getName()).isEqualTo("shared_a"); + } +} diff --git a/api/src/test/resources/yaml/fixtures/dup_agent.yaml b/api/src/test/resources/yaml/fixtures/dup_agent.yaml new file mode 100644 index 000000000..d3b340b4b --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/dup_agent.yaml @@ -0,0 +1,13 @@ +agents: + - name: dup + actions: + - name: increment + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] + - name: dup + actions: + - name: decrement + function: org.apache.flink.agents.api.yaml.LoaderTargets:decrement + type: java + listen_to: [input] diff --git a/api/src/test/resources/yaml/fixtures/multi_agent.yaml b/api/src/test/resources/yaml/fixtures/multi_agent.yaml new file mode 100644 index 000000000..b2d4fde78 --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/multi_agent.yaml @@ -0,0 +1,13 @@ +agents: + - name: a1 + actions: + - name: increment + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] + - name: a2 + actions: + - name: decrement + function: org.apache.flink.agents.api.yaml.LoaderTargets:decrement + type: java + listen_to: [input] diff --git a/api/src/test/resources/yaml/fixtures/multi_file_a.yaml b/api/src/test/resources/yaml/fixtures/multi_file_a.yaml new file mode 100644 index 000000000..d9c29439a --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/multi_file_a.yaml @@ -0,0 +1,12 @@ +agents: + - name: file_a_agent + actions: + - name: increment + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] +chat_model_connections: + - name: conn_from_a + clazz: ollama + type: java + base_url: http://a diff --git a/api/src/test/resources/yaml/fixtures/multi_file_b.yaml b/api/src/test/resources/yaml/fixtures/multi_file_b.yaml new file mode 100644 index 000000000..d53c5b34e --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/multi_file_b.yaml @@ -0,0 +1,12 @@ +agents: + - name: file_b_agent + actions: + - name: decrement + function: org.apache.flink.agents.api.yaml.LoaderTargets:decrement + type: java + listen_to: [input] +chat_model_connections: + - name: conn_from_b + clazz: ollama + type: java + base_url: http://b diff --git a/api/src/test/resources/yaml/fixtures/single_agent.yaml b/api/src/test/resources/yaml/fixtures/single_agent.yaml new file mode 100644 index 000000000..8a497f675 --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/single_agent.yaml @@ -0,0 +1,7 @@ +agents: + - name: incrementer + actions: + - name: increment + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] diff --git a/api/src/test/resources/yaml/fixtures/with_descriptors.yaml b/api/src/test/resources/yaml/fixtures/with_descriptors.yaml new file mode 100644 index 000000000..834819c75 --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/with_descriptors.yaml @@ -0,0 +1,17 @@ +agents: + - name: chat_agent + actions: + - name: increment + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] + - name: decrement + function: org.apache.flink.agents.api.yaml.LoaderTargets:decrement + type: java + listen_to: [chat_response] + chat_model_connections: + - name: ollama_conn + clazz: ollama + type: java + base_url: http://localhost:11434 + request_timeout: 30 diff --git a/api/src/test/resources/yaml/fixtures/with_shared.yaml b/api/src/test/resources/yaml/fixtures/with_shared.yaml new file mode 100644 index 000000000..48581b549 --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/with_shared.yaml @@ -0,0 +1,23 @@ +agents: + - name: a1 + actions: + - shared_inc + - name: own_dec + function: org.apache.flink.agents.api.yaml.LoaderTargets:decrement + type: java + listen_to: [chat_response] + - name: a2 + actions: + - shared_inc + +chat_model_connections: + - name: shared_conn + clazz: ollama + type: java + base_url: http://example + +actions: + - name: shared_inc + function: org.apache.flink.agents.api.yaml.LoaderTargets:increment + type: java + listen_to: [input] diff --git a/api/src/test/resources/yaml/fixtures/with_skills.yaml b/api/src/test/resources/yaml/fixtures/with_skills.yaml new file mode 100644 index 000000000..3aaa9ea85 --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/with_skills.yaml @@ -0,0 +1,12 @@ +agents: + - name: skills_agent + skills: + - name: agent_skills + paths: + - ./agent_skill_dir + +skills: + - name: shared_skills + paths: + - ./shared_skill_dir + - ./more diff --git a/api/src/test/resources/yaml/fixtures/with_tools_and_prompts.yaml b/api/src/test/resources/yaml/fixtures/with_tools_and_prompts.yaml new file mode 100644 index 000000000..bc5faa54c --- /dev/null +++ b/api/src/test/resources/yaml/fixtures/with_tools_and_prompts.yaml @@ -0,0 +1,14 @@ +agents: + - name: tool_agent + tools: + - name: notify + function: org.apache.flink.agents.api.yaml.LoaderTargets:notify + type: java + parameter_types: [java.lang.String, java.lang.String] + prompts: + - name: text_prompt + text: "hello {name}" + - name: messages_prompt + messages: + - {role: system, content: "be brief"} + - {role: user, content: "{q}"} diff --git a/api/src/test/resources/yaml/python-parity/yaml_test_agent.yaml b/api/src/test/resources/yaml/python-parity/yaml_test_agent.yaml new file mode 100644 index 000000000..6c29f5089 --- /dev/null +++ b/api/src/test/resources/yaml/python-parity/yaml_test_agent.yaml @@ -0,0 +1,32 @@ +agents: + - name: yaml_test_agent + description: YAML-driven e2e agent — chat model with a function tool. + + chat_model_connections: + - name: ollama_connection + clazz: ollama + request_timeout: 240.0 + + chat_model_setups: + - name: math_chat_model + clazz: ollama + connection: ollama_connection + model: qwen3:1.7b + tools: [add] + extract_reasoning: true + - name: creative_chat_model + clazz: ollama + connection: ollama_connection + model: qwen3:1.7b + extract_reasoning: true + + tools: + - name: add + function: flink_agents.e2e_tests.e2e_tests_integration.yaml_test_actions:add + actions: + - name: process_input + function: flink_agents.e2e_tests.e2e_tests_integration.yaml_test_actions:process_input + listen_to: [input] + - name: process_chat_response + function: flink_agents.e2e_tests.e2e_tests_integration.yaml_test_actions:process_chat_response + listen_to: [chat_response] \ No newline at end of file diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlChatActions.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlChatActions.java new file mode 100644 index 000000000..d625cf898 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlChatActions.java @@ -0,0 +1,149 @@ +/* + * 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.integration.test.yaml; + +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.annotation.Tool; +import org.apache.flink.agents.api.annotation.ToolParam; +import org.apache.flink.agents.api.chat.messages.ChatMessage; +import org.apache.flink.agents.api.chat.messages.MessageRole; +import org.apache.flink.agents.api.context.RunnerContext; +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; + +import java.io.Serializable; +import java.util.Collections; + +/** + * Functions and POJOs referenced by the e2e YAML fixtures under {@code src/test/resources/yaml/}. + * Each {@code function:} entry in the YAML points its dotted path at one of the static methods + * below. + */ +public final class YamlChatActions { + + private YamlChatActions() {} + + /** Input record: a question routed to a chat model. */ + public static final class YamlChatInput implements Serializable { + public int id; + public String text; + + public YamlChatInput() {} + + public YamlChatInput(int id, String text) { + this.id = id; + this.text = text; + } + } + + /** Output record: the chat model's textual answer, tagged with the original input id. */ + public static final class YamlChatOutput implements Serializable { + public int id; + public String answer; + + public YamlChatOutput() {} + + public YamlChatOutput(int id, String answer) { + this.id = id; + this.answer = answer; + } + } + + /** + * Sum two integers. Referenced by the single-agent fixture as the {@code add} tool — the math + * chat model is wired to call this when the user asks for a calculation. + */ + @Tool(description = "Calculate the sum of two integers") + public static int add( + @ToolParam(name = "a", description = "The first operand") Integer a, + @ToolParam(name = "b", description = "The second operand") Integer b) { + return a + b; + } + + /** + * Route the incoming text to the math or creative chat model. The math model has access to the + * {@code add} tool; the creative model does not. Routing is a simple keyword check on the + * input. Stash the record's {@code id} in short-term memory so {@code processChatResponse} can + * re-attach it to the output. + */ + public static void processInput(Event event, RunnerContext ctx) throws Exception { + YamlChatInput data = (YamlChatInput) InputEvent.fromEvent(event).getInput(); + ctx.getShortTermMemory().set("input_id", data.id); + String lower = data.text.toLowerCase(); + String modelName = + (lower.contains("calculate") || lower.contains("sum")) + ? "math_chat_model" + : "creative_chat_model"; + ctx.sendEvent( + new ChatRequestEvent( + modelName, + Collections.singletonList(new ChatMessage(MessageRole.USER, data.text)), + null)); + } + + /** + * Forward the input text to the agent-local {@code chat_model}. Used by the multi-agent fixture + * where each agent declares its own {@code chat_model} and the action simply pushes the user + * message through. + */ + public static void chatRequest(Event event, RunnerContext ctx) throws Exception { + YamlChatInput data = (YamlChatInput) InputEvent.fromEvent(event).getInput(); + ctx.getShortTermMemory().set("input_id", data.id); + ctx.sendEvent( + new ChatRequestEvent( + "chat_model", + Collections.singletonList(new ChatMessage(MessageRole.USER, data.text)), + null)); + } + + /** Emit the model's text response, tagged with the original input id. */ + public static void processChatResponse(Event event, RunnerContext ctx) throws Exception { + ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event); + ChatMessage response = chatResponse.getResponse(); + if (response == null || response.getContent() == null) { + return; + } + Integer inputId = (Integer) ctx.getShortTermMemory().get("input_id").getValue(); + ctx.sendEvent(new OutputEvent(new YamlChatOutput(inputId, response.getContent()))); + } + + /** + * Stage-2 action for the chained fixture: feed the upstream answer to a second chat model. + * + *

The upstream record is a {@link YamlChatOutput} produced by the math agent. Prompt the + * model to restate the same numeric answer — the test only needs the chain to actually pass + * through stage 2 (verifiable by the math digit surviving the second LLM hop). Stash the id in + * short-term memory so the shared {@code processChatResponse} action can re-attach it. + */ + public static void commentaryRequest(Event event, RunnerContext ctx) throws Exception { + YamlChatOutput data = (YamlChatOutput) InputEvent.fromEvent(event).getInput(); + ctx.getShortTermMemory().set("input_id", data.id); + String prompt = + "Here is a math answer from another assistant: '" + + data.answer + + "'. Reply with the numeric result only."; + ctx.sendEvent( + new ChatRequestEvent( + "chat_model", + Collections.singletonList(new ChatMessage(MessageRole.USER, prompt)), + null)); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlLoaderIntegrationTest.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlLoaderIntegrationTest.java new file mode 100644 index 000000000..f362a4e31 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/yaml/YamlLoaderIntegrationTest.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.integration.test.yaml; + +import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.agents.integration.test.OllamaPreparationUtils; +import org.apache.flink.agents.integration.test.yaml.YamlChatActions.YamlChatInput; +import org.apache.flink.agents.integration.test.yaml.YamlChatActions.YamlChatOutput; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.util.CloseableIterator; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * End-to-end tests for the Java YAML loader. Each test loads a YAML file via {@link + * AgentsExecutionEnvironment#loadYaml(Path...)}, dispatches the declared agent(s) by name through + * the Flink runner, and asserts on the LLM outputs. + * + *

Gated on a local Ollama serving {@code qwen3:1.7b}. + */ +public class YamlLoaderIntegrationTest extends OllamaPreparationUtils { + + private static final Logger LOG = LoggerFactory.getLogger(YamlLoaderIntegrationTest.class); + private static final String OLLAMA_MODEL = "qwen3:1.7b"; + + private final boolean ollamaReady; + + public YamlLoaderIntegrationTest() throws IOException { + ollamaReady = pullModel(OLLAMA_MODEL); + } + + /** + * Single agent loaded from {@code yaml_test_agent.yaml} and applied by name: math path goes + * through the {@code add} tool; creative path is a plain chat call. Asserts the math digit + * survives and the creative answer mentions cats. + */ + @Test + public void testSingleYamlAgent() throws Exception { + Assumptions.assumeTrue(ollamaReady, "Ollama unavailable; skipping YAML e2e test."); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream inputStream = + env.fromData( + new YamlChatInput(1, "calculate the sum of 1 and 2."), + new YamlChatInput(2, "Tell me a joke about cats.")) + .returns(TypeInformation.of(YamlChatInput.class)); + + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); + agentsEnv.loadYaml(yamlFixture("yaml_test_agent.yaml")); + + DataStream outputStream = + agentsEnv + .fromDataStream( + inputStream, (KeySelector) v -> v.id) + .apply("yaml_test_agent") + .toDataStream(); + + CloseableIterator results = outputStream.collectAsync(); + agentsEnv.execute(); + + Map answers = collectAnswers(results); + + String mathAnswer = answers.get(1); + Assertions.assertNotNull(mathAnswer, "math answer missing"); + Assertions.assertTrue( + mathAnswer.contains("3"), String.format("math answer missing '3': %s", mathAnswer)); + + String creativeAnswer = answers.get(2); + Assertions.assertNotNull(creativeAnswer, "creative answer missing"); + Assertions.assertTrue( + creativeAnswer.toLowerCase().contains("cat"), + String.format("creative answer missing 'cat': %s", creativeAnswer)); + } + + /** + * Two agents declared in one YAML file, chained as a single Flink pipeline: + * + *
{@code
+     * fromData → math_agent → commentator_agent → collect
+     * }
+ * + *

Both agents reuse a file-level {@code ollama_connection} and the file-level {@code + * process_chat_response} action. The test exercises chaining two YAML-loaded agents, proves the + * file-level shared connection + shared action are reusable across both agents, and asserts the + * math digit survives the second LLM hop. + */ + @Test + public void testChainedYamlAgents() throws Exception { + Assumptions.assumeTrue(ollamaReady, "Ollama unavailable; skipping YAML e2e test."); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); + agentsEnv.loadYaml(yamlFixture("yaml_multi_agent.yaml")); + + DataStream inputStream = + env.fromData(new YamlChatInput(1, "calculate the sum of 1 and 2.")) + .returns(TypeInformation.of(YamlChatInput.class)); + + DataStream mathOutput = + agentsEnv + .fromDataStream( + inputStream, (KeySelector) v -> v.id) + .apply("math_agent") + .toDataStream(); + + DataStream mathOutputTyped = + mathOutput + .map(o -> (YamlChatOutput) o) + .returns(TypeInformation.of(YamlChatOutput.class)); + + DataStream finalOutput = + agentsEnv + .fromDataStream( + mathOutputTyped, (KeySelector) v -> v.id) + .apply("commentator_agent") + .toDataStream(); + + CloseableIterator results = finalOutput.collectAsync(); + agentsEnv.execute(); + + Map answers = collectAnswers(results); + String finalAnswer = answers.get(1); + Assertions.assertNotNull(finalAnswer, "final answer missing from chained output"); + Assertions.assertTrue( + finalAnswer.contains("3"), + String.format("math result missing from chained output: %s", finalAnswer)); + } + + private static Path yamlFixture(String name) { + URL resource = YamlLoaderIntegrationTest.class.getClassLoader().getResource("yaml/" + name); + Objects.requireNonNull(resource, "fixture not found on classpath: yaml/" + name); + return Paths.get(resource.getPath()); + } + + /** Collect {@code {id: answer}} from a finished output stream of {@link YamlChatOutput}. */ + private static Map collectAnswers(CloseableIterator results) { + Map answers = new HashMap<>(); + List raw = new ArrayList<>(); + while (results.hasNext()) { + Object next = results.next(); + raw.add(next); + if (next instanceof YamlChatOutput) { + YamlChatOutput output = (YamlChatOutput) next; + answers.put(output.id, output.answer); + } + } + LOG.info("Collected {} raw results: {}", raw.size(), raw); + return answers; + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_multi_agent.yaml b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_multi_agent.yaml new file mode 100644 index 000000000..179df06a1 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_multi_agent.yaml @@ -0,0 +1,54 @@ +agents: + - name: math_agent + description: Stage 1 — solves the math question via the ``add`` tool. + chat_model_setups: + - name: chat_model + clazz: ollama + type: java + connection: ollama_connection + model: qwen3:1.7b + tools: [add] + extract_reasoning: true + tools: + - name: add + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:add + parameter_types: [java.lang.Integer, java.lang.Integer] + actions: + - name: chat_request + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:chatRequest + listen_to: [input] + - process_chat_response + + - name: commentator_agent + description: | + Stage 2 — takes the upstream answer and asks a second chat model to restate it. + Reuses the file-level ollama_connection and the file-level process_chat_response action. + chat_model_setups: + - name: chat_model + clazz: ollama + type: java + connection: ollama_connection + model: qwen3:1.7b + extract_reasoning: true + actions: + - name: commentary_request + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:commentaryRequest + listen_to: [input] + - process_chat_response + +# File-level shared resources reused by every agent above. +chat_model_connections: + - name: ollama_connection + clazz: ollama + type: java + endpoint: http://localhost:11434 + requestTimeout: 240 + +actions: + - name: process_chat_response + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:processChatResponse + listen_to: [chat_response] diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_test_agent.yaml b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_test_agent.yaml new file mode 100644 index 000000000..549c38551 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/yaml/yaml_test_agent.yaml @@ -0,0 +1,41 @@ +agents: + - name: yaml_test_agent + description: YAML-driven e2e agent — chat model with a function tool. + + chat_model_connections: + - name: ollama_connection + clazz: ollama + type: java + endpoint: http://localhost:11434 + requestTimeout: 240 + + chat_model_setups: + - name: math_chat_model + clazz: ollama + type: java + connection: ollama_connection + model: qwen3:1.7b + tools: [add] + extract_reasoning: true + - name: creative_chat_model + clazz: ollama + type: java + connection: ollama_connection + model: qwen3:1.7b + extract_reasoning: true + + tools: + - name: add + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:add + parameter_types: [java.lang.Integer, java.lang.Integer] + + actions: + - name: process_input + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:processInput + listen_to: [input] + - name: process_chat_response + type: java + function: org.apache.flink.agents.integration.test.yaml.YamlChatActions:processChatResponse + listen_to: [chat_response] diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageActions.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageActions.java new file mode 100644 index 000000000..1dfd5109b --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageActions.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.resource.test; + +import org.apache.flink.agents.api.Event; +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.chat.messages.ChatMessage; +import org.apache.flink.agents.api.chat.messages.MessageRole; +import org.apache.flink.agents.api.context.RunnerContext; +import org.apache.flink.agents.api.event.ChatRequestEvent; +import org.apache.flink.agents.api.event.ChatResponseEvent; + +import java.util.Collections; + +/** + * Java actions referenced by {@code resources/yaml/yaml_cross_language_agent.yaml}. + * + *

{@code processInput} keyword-routes the incoming text to either {@code math_chat_model} + * (Python wrapper + Java {@code calculateBMI} tool) or {@code creative_chat_model} (Java native + * chat model without tools). {@code processChatResponse} forwards the model's content as a + * plain-string {@link OutputEvent} so the test sink can match on substrings. + */ +public final class YamlCrossLanguageActions { + + private YamlCrossLanguageActions() {} + + public static void processInput(Event event, RunnerContext ctx) throws Exception { + String text = (String) InputEvent.fromEvent(event).getInput(); + String lower = text.toLowerCase(); + String model = + (lower.contains("calculate") || lower.contains("bmi")) + ? "math_chat_model" + : "creative_chat_model"; + ctx.sendEvent( + new ChatRequestEvent( + model, + Collections.singletonList(new ChatMessage(MessageRole.USER, text)), + null)); + } + + public static void processChatResponse(Event event, RunnerContext ctx) { + ChatResponseEvent chatResponse = ChatResponseEvent.fromEvent(event); + ChatMessage response = chatResponse.getResponse(); + if (response == null || response.getContent() == null) { + return; + } + ctx.sendEvent(new OutputEvent(response.getContent())); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageTest.java b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageTest.java new file mode 100644 index 000000000..1cc5eb78f --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/YamlCrossLanguageTest.java @@ -0,0 +1,111 @@ +/* + * 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.resource.test; + +import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.util.CloseableIterator; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.apache.flink.agents.resource.test.ChatModelCrossLanguageAgent.OLLAMA_MODEL; +import static org.apache.flink.agents.resource.test.CrossLanguageTestPreparationUtils.pullModel; + +/** + * End-to-end test for the Java YAML loader in a cross-language setting: a single YAML file declares + * a Python-wrapped chat model that uses a Java function tool ({@code calculateBMI}) and a + * Java-native chat model with no tools. The Java host loads it via {@link + * AgentsExecutionEnvironment#loadYaml(Path...)} and dispatches the agent by name. + * + *

Math input exercises the Python→Java tool bridge originating from a Java loader entry; + * creative input exercises the same YAML mixing Python-wrapped and Java-native chat models on a + * Java host. + */ +public class YamlCrossLanguageTest { + + private static final Logger LOG = LoggerFactory.getLogger(YamlCrossLanguageTest.class); + + private final boolean ollamaReady; + + public YamlCrossLanguageTest() throws IOException { + ollamaReady = pullModel(OLLAMA_MODEL); + } + + @Test + public void testYamlCrossLanguageAgent() throws Exception { + Assumptions.assumeTrue(ollamaReady, "Ollama Server information is not provided"); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream inputStream = + env.fromData( + "Calculate BMI for someone who is 1.75 meters tall and weighs 70 kg", + "Tell me a joke about cats."); + + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); + agentsEnv.loadYaml(yamlFixture("yaml_cross_language_agent.yaml")); + + DataStream outputStream = + agentsEnv + .fromDataStream( + inputStream, (KeySelector) value -> "orderKey") + .apply("yaml_cross_language_agent") + .toDataStream(); + + CloseableIterator results = outputStream.collectAsync(); + agentsEnv.execute(); + + List responses = new ArrayList<>(); + while (results.hasNext()) { + responses.add(String.valueOf(results.next())); + } + LOG.info("Cross-language YAML agent responses: {}", responses); + + Assertions.assertEquals( + 2, responses.size(), "expected 2 responses, got " + responses.size()); + + String joined = String.join("\n", responses).toLowerCase(); + Assertions.assertTrue( + joined.contains("22"), String.format("math answer missing '22': %s", responses)); + Assertions.assertTrue( + joined.contains("cat"), + String.format("creative answer missing 'cat': %s", responses)); + } + + private static Path yamlFixture(String name) { + URL resource = YamlCrossLanguageTest.class.getClassLoader().getResource("yaml/" + name); + Objects.requireNonNull(resource, "fixture not found on classpath: yaml/" + name); + return Paths.get(resource.getPath()); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/resources/yaml/yaml_cross_language_agent.yaml b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/resources/yaml/yaml_cross_language_agent.yaml new file mode 100644 index 000000000..783989075 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/resources/yaml/yaml_cross_language_agent.yaml @@ -0,0 +1,56 @@ +agents: + - name: yaml_cross_language_agent + description: | + Java-host cross-language YAML e2e agent. + + - math path: Java Ollama chat model calling a Python function tool + (``calculate_bmi``) — exercises the Java→Python tool bridge: the Java chat + model resolves the tool, plan.FunctionTool detects the PythonFunction + backing, and dispatches the invocation through PythonResourceAdapter. + - creative path: Python Ollama chat model (Java host wraps it via the Python + resource wrapper) with no tools — same YAML mixes a Java-native chat model + and a Python-wrapped chat model on the same Java host. + + chat_model_connections: + # Java-native connection. + - name: ollama_connection_java + clazz: ollama + type: java + endpoint: http://localhost:11434 + requestTimeout: 240 + # Python-wrapped connection on the Java host. Default ``type:`` is python; + # the loader resolves the alias to the Python FQN and embeds it through the + # Java-side Python wrapper. + - name: ollama_connection_python + clazz: ollama + request_timeout: 240 + + chat_model_setups: + - name: math_chat_model + clazz: ollama + type: java + connection: ollama_connection_java + model: qwen3:1.7b + tools: [calculate_bmi] + extract_reasoning: true + - name: creative_chat_model + clazz: ollama + connection: ollama_connection_python + model: qwen3:1.7b + extract_reasoning: true + + tools: + # Python function tool — default ``type:`` is python; the Java host resolves + # the underlying callable through the Pemja bridge at first invocation. + - name: calculate_bmi + function: flink_agents.e2e_tests.e2e_tests_resource_cross_language.yaml_cross_language_actions:calculate_bmi + + actions: + - name: process_input + type: java + function: org.apache.flink.agents.resource.test.YamlCrossLanguageActions:processInput + listen_to: [input] + - name: process_chat_response + type: java + function: org.apache.flink.agents.resource.test.YamlCrossLanguageActions:processChatResponse + listen_to: [chat_response] diff --git a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/yaml_cross_language_actions.py b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/yaml_cross_language_actions.py index d8a891683..d1fea1e1f 100644 --- a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/yaml_cross_language_actions.py +++ b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/yaml_cross_language_actions.py @@ -52,3 +52,19 @@ def process_chat_response(event: Event, ctx: RunnerContext) -> None: response = chat_response.response if response and response.content: ctx.send_event(OutputEvent(output=response.content)) + + +def calculate_bmi(weight_kg: float, height_m: float) -> float: + """Calculate Body Mass Index. + + Args: + weight_kg: Weight in kilograms. + height_m: Height in meters. + + Returns: + BMI = weight_kg / (height_m ** 2). + """ + if weight_kg <= 0 or height_m <= 0: + msg = "weight_kg and height_m must be positive" + raise ValueError(msg) + return weight_kg / (height_m * height_m) diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java b/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java index f8542001b..80d15f6c8 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/env/RemoteExecutionEnvironment.java @@ -84,13 +84,13 @@ public AgentBuilder fromList(List input) { @Override public AgentBuilder fromDataStream(DataStream input, KeySelector keySelector) { - return new RemoteAgentBuilder<>(input, tEnv, keySelector, env, config, resources); + return new RemoteAgentBuilder<>(input, tEnv, keySelector, env, config, resources, agents); } @Override public AgentBuilder fromTable(Table input, KeySelector keySelector) { return new RemoteAgentBuilder<>( - input, getTableEnvironment(), keySelector, env, config, resources); + input, getTableEnvironment(), keySelector, env, config, resources, agents); } @Override @@ -130,6 +130,7 @@ private static class RemoteAgentBuilder implements AgentBuilder { private @Nullable StreamTableEnvironment tableEnv; private final AgentConfiguration config; private final Map> resources; + private final Map agents; private AgentPlan agentPlan; private DataStream outputDataStream; @@ -141,13 +142,15 @@ public RemoteAgentBuilder( KeySelector keySelector, StreamExecutionEnvironment env, AgentConfiguration config, - Map> resources) { + Map> resources, + Map agents) { this.inputDataStream = inputDataStream; this.keySelector = keySelector; this.env = env; this.tableEnv = tableEnv; this.config = config; this.resources = resources; + this.agents = agents; } // Constructor for Table input @@ -158,13 +161,15 @@ public RemoteAgentBuilder( KeySelector keySelector, StreamExecutionEnvironment env, AgentConfiguration config, - Map> resources) { + Map> resources, + Map agents) { this.inputDataStream = (DataStream) tableEnv.toDataStream(inputTable); this.keySelector = (KeySelector) keySelector; this.env = env; this.tableEnv = tableEnv; this.config = config; this.resources = resources; + this.agents = agents; } private StreamTableEnvironment getTableEnvironment() { @@ -186,6 +191,19 @@ public AgentBuilder apply(Agent agent) { } } + @Override + public AgentBuilder apply(String agentName) { + Agent agent = agents.get(agentName); + if (agent == null) { + throw new IllegalArgumentException( + "Unknown agent '" + + agentName + + "'; no agent with that name is registered on the environment. " + + "Did you forget to call env.loadYaml(...) or env.getAgents().put(...) first?"); + } + return apply(agent); + } + @Override public List> toList() { throw new UnsupportedOperationException(