This is an automated email from the ASF dual-hosted git repository. wenjin272 pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/flink-agents.git
commit eca17418481aae6fb7547bc81c4d01f80fd253ec Author: WenjinXie <[email protected]> AuthorDate: Wed May 6 17:54:29 2026 +0800 [api][runtime][java] Support agent skills in Java. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --- .../apache/flink/agents/api/annotation/Skills.java | 39 ++++ .../agents/api/chat/model/BaseChatModelSetup.java | 52 +++++ .../org/apache/flink/agents/api/skills/Skills.java | 86 ++++++++ .../chat/model/BaseChatModelSetupSkillsTest.java | 179 ++++++++++++++++ .../agents/api/skills/SkillsResourceTest.java | 53 +++++ .../integration/test/SkillsIntegrationAgent.java | 119 +++++++++++ .../integration/test/SkillsIntegrationTest.java | 226 +++++++++++++++++++++ .../test/resources/skills/joke-generator/SKILL.md | 18 ++ .../skills/joke-generator/scripts/gen_joke.py | 20 ++ .../test/resources/skills/math-calculator/SKILL.md | 57 ++++++ .../org/apache/flink/agents/plan/AgentPlan.java | 62 ++++++ .../flink/agents/plan/actions/ChatModelAction.java | 47 ++++- .../agents/plan/AgentPlanDeclareSkillsTest.java | 131 ++++++++++++ .../runtime/java/java_resource_wrapper.py | 14 +- runtime/pom.xml | 6 + .../runtime/python/utils/JavaResourceAdapter.java | 15 ++ .../runtime/resource/ResourceContextImpl.java | 46 ++++- .../flink/agents/runtime/skill/AgentSkill.java | 147 ++++++++++++++ .../flink/agents/runtime/skill/LoadSkillTool.java | 146 +++++++++++++ .../flink/agents/runtime/skill/SkillManager.java | 148 ++++++++++++++ .../flink/agents/runtime/skill/SkillParser.java | 121 +++++++++++ .../agents/runtime/skill/SkillPromptProvider.java | 50 +++++ .../agents/runtime/skill/SkillRepository.java | 44 ++++ .../repository/FileSystemSkillRepository.java | 184 +++++++++++++++++ .../skill/FileSystemSkillRepositoryTest.java | 105 ++++++++++ .../agents/runtime/skill/LoadSkillToolTest.java | 117 +++++++++++ .../agents/runtime/skill/SkillManagerTest.java | 92 +++++++++ .../agents/runtime/skill/SkillParserTest.java | 122 +++++++++++ .../src/test/resources/skill_discovery_prompt.txt | 24 +++ runtime/src/test/resources/skills/github/SKILL.md | 47 +++++ .../test/resources/skills/nano-banana-pro/SKILL.md | 130 ++++++++++++ .../resources/skills/nano-banana-pro/_meta.json | 6 + .../nano-banana-pro/scripts/generate_image.py | 165 +++++++++++++++ 33 files changed, 2809 insertions(+), 9 deletions(-) diff --git a/api/src/main/java/org/apache/flink/agents/api/annotation/Skills.java b/api/src/main/java/org/apache/flink/agents/api/annotation/Skills.java new file mode 100644 index 00000000..072d360c --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/annotation/Skills.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a static method that returns an {@link org.apache.flink.agents.api.skills.Skills} resource + * describing where to load agent skills from. + * + * <p>Mirrors the Python {@code @skills} decorator. Multiple {@code @Skills} methods on the same + * agent are merged at plan-build time. + * + * <p>Note: this annotation shares its simple name with {@link + * org.apache.flink.agents.api.skills.Skills} (different package). When importing both, one of them + * must be referenced by its fully-qualified name. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Skills {} diff --git a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java index b0f73d80..af7ed10b 100644 --- a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java +++ b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java @@ -25,6 +25,7 @@ import org.apache.flink.agents.api.resource.Resource; import org.apache.flink.agents.api.resource.ResourceContext; 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.Tool; import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.util.Preconditions; @@ -42,6 +43,10 @@ public abstract class BaseChatModelSetup extends Resource { protected String model; protected Object prompt; protected List<String> toolNames; + @Nullable protected List<String> skills; + @Nullable protected String skillDiscoveryPrompt; + protected List<String> allowedCommands; + protected List<String> allowedScriptDirs; @Nullable protected BaseChatModelConnection connection; protected final List<Tool> tools = new ArrayList<>(); @@ -52,6 +57,15 @@ public abstract class BaseChatModelSetup extends Resource { this.model = descriptor.getArgument("model"); this.prompt = descriptor.getArgument("prompt"); this.toolNames = descriptor.getArgument("tools"); + this.skills = descriptor.getArgument("skills"); + List<String> declaredCommands = descriptor.getArgument("allowed_commands"); + this.allowedCommands = + declaredCommands == null ? new ArrayList<>() : new ArrayList<>(declaredCommands); + List<String> declaredScriptDirs = descriptor.getArgument("allowed_script_dirs"); + this.allowedScriptDirs = + declaredScriptDirs == null + ? new ArrayList<>() + : new ArrayList<>(declaredScriptDirs); } /** @@ -71,6 +85,19 @@ public abstract class BaseChatModelSetup extends Resource { this.prompt = this.resourceContext.getResource((String) this.prompt, ResourceType.PROMPT); } + if (this.skills != null) { + this.skillDiscoveryPrompt = + this.resourceContext.generateAvailableSkillsPrompt(this.skills); + List<String> mutable = + this.toolNames == null ? new ArrayList<>() : new ArrayList<>(this.toolNames); + if (!mutable.contains(Skills.LOAD_SKILL_TOOL)) { + mutable.add(Skills.LOAD_SKILL_TOOL); + } + if (!mutable.contains(Skills.BASH_TOOL)) { + mutable.add(Skills.BASH_TOOL); + } + this.toolNames = mutable; + } if (this.toolNames != null) { for (String name : this.toolNames) { this.tools.add((Tool) this.resourceContext.getResource(name, ResourceType.TOOL)); @@ -115,6 +142,13 @@ public abstract class BaseChatModelSetup extends Resource { messages = promptMessages; } + if (this.skillDiscoveryPrompt != null && !this.skillDiscoveryPrompt.isEmpty()) { + int idx = ChatMessage.findFirstSystemMessage(messages); + List<ChatMessage> mutated = new ArrayList<>(messages); + mutated.add(idx + 1, new ChatMessage(MessageRole.SYSTEM, this.skillDiscoveryPrompt)); + messages = mutated; + } + Map<String, Object> params = this.getParameters(); params.putAll(parameters); return connection.chat(messages, tools, params); @@ -144,4 +178,22 @@ public abstract class BaseChatModelSetup extends Resource { public List<String> getToolNames() { return toolNames; } + + @Nullable + public List<String> getSkills() { + return skills; + } + + @Nullable + public String getSkillDiscoveryPrompt() { + return skillDiscoveryPrompt; + } + + public List<String> getAllowedCommands() { + return allowedCommands; + } + + public List<String> getAllowedScriptDirs() { + return allowedScriptDirs; + } } diff --git a/api/src/main/java/org/apache/flink/agents/api/skills/Skills.java b/api/src/main/java/org/apache/flink/agents/api/skills/Skills.java new file mode 100644 index 00000000..c4a8de7b --- /dev/null +++ b/api/src/main/java/org/apache/flink/agents/api/skills/Skills.java @@ -0,0 +1,86 @@ +/* + * 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.skills; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.resource.SerializableResource; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Configuration resource describing where to load agent skills from. + * + * <p>Mirrors the Python {@code flink_agents.api.skills.Skills}. Use {@link + * #fromLocalDir(String...)} to construct. + * + * <p>Multiple {@code @Skills} declarations on the same agent are merged at plan-build time. + */ +@JsonIgnoreProperties( + ignoreUnknown = true, + value = {"metricGroup", "resourceType"}) +public class Skills extends SerializableResource { + + /** Reserved resource name under which AgentPlan registers the merged Skills config. */ + public static final String SKILLS_CONFIG = "_skills_config"; + + /** Reserved name of the built-in skill loader tool. */ + public static final String LOAD_SKILL_TOOL = "load_skill"; + + /** Reserved name of the built-in bash tool used to execute skill scripts. */ + public static final String BASH_TOOL = "bash"; + + private List<String> paths; + + /** Required by Jackson. */ + public Skills() { + this.paths = Collections.emptyList(); + } + + @JsonCreator + public Skills(@JsonProperty("paths") List<String> paths) { + this.paths = paths == null ? Collections.emptyList() : List.copyOf(paths); + } + + /** + * Create a {@link Skills} resource from one or more local filesystem directories. + * + * <p>Each path points to a directory whose immediate subdirectories each contain a {@code + * SKILL.md} file. + */ + public static Skills fromLocalDir(String... paths) { + return new Skills(Arrays.asList(paths)); + } + + @JsonProperty("paths") + public List<String> getPaths() { + return paths; + } + + @JsonIgnore + @Override + public ResourceType getResourceType() { + return ResourceType.SKILLS; + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSkillsTest.java b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSkillsTest.java new file mode 100644 index 00000000..a31d9971 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSkillsTest.java @@ -0,0 +1,179 @@ +/* + * 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.chat.model; + +import org.apache.flink.agents.api.chat.messages.ChatMessage; +import org.apache.flink.agents.api.chat.messages.MessageRole; +import org.apache.flink.agents.api.resource.Resource; +import org.apache.flink.agents.api.resource.ResourceContext; +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.Tool; +import org.apache.flink.agents.api.tools.ToolMetadata; +import org.apache.flink.agents.api.tools.ToolParameters; +import org.apache.flink.agents.api.tools.ToolResponse; +import org.apache.flink.agents.api.tools.ToolType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BaseChatModelSetupSkillsTest { + + /** Stub chat model setup that exposes a configurable parameters map. */ + private static class StubChatSetup extends BaseChatModelSetup { + public StubChatSetup(ResourceDescriptor descriptor, ResourceContext resourceContext) { + super(descriptor, resourceContext); + } + + @Override + public Map<String, Object> getParameters() { + return new HashMap<>(); + } + } + + private static class StubConnection extends BaseChatModelConnection { + List<ChatMessage> capturedMessages; + List<Tool> capturedTools; + + StubConnection(ResourceDescriptor d, ResourceContext c) { + super(d, c); + } + + @Override + public ChatMessage chat( + List<ChatMessage> messages, List<Tool> tools, Map<String, Object> arguments) { + this.capturedMessages = new ArrayList<>(messages); + this.capturedTools = new ArrayList<>(tools); + return new ChatMessage(MessageRole.ASSISTANT, "ok"); + } + } + + private static class StubTool extends Tool { + public StubTool(String name) { + super(new ToolMetadata(name, "stub", "{}")); + } + + @Override + public ToolType getToolType() { + return ToolType.FUNCTION; + } + + @Override + public ToolResponse call(ToolParameters parameters) { + return ToolResponse.success(""); + } + } + + @Test + void openInjectsSkillToolsAndDiscoveryPrompt() throws Exception { + Map<String, Resource> store = new HashMap<>(); + StubConnection connection = new StubConnection(new ResourceDescriptor("X", Map.of()), null); + Tool loadSkillTool = new StubTool(Skills.LOAD_SKILL_TOOL); + Tool bashTool = new StubTool(Skills.BASH_TOOL); + store.put("conn", connection); + store.put(Skills.LOAD_SKILL_TOOL, loadSkillTool); + store.put(Skills.BASH_TOOL, bashTool); + ResourceContext ctx = + new ResourceContext() { + @Override + public Resource getResource(String name, ResourceType type) { + return store.get(name); + } + + @Override + public String generateAvailableSkillsPrompt(List<String> skillNames) { + return "<available_skills>\n<skill>\n<name>" + + skillNames.get(0) + + "</name>\n</skill>\n</available_skills>"; + } + + @Override + public List<String> getSkillDirs(List<String> skillNames) { + return List.of(); + } + }; + + Map<String, Object> args = new HashMap<>(); + args.put("connection", "conn"); + args.put("skills", Arrays.asList("github")); + ResourceDescriptor descriptor = new ResourceDescriptor("X", args); + + StubChatSetup setup = new StubChatSetup(descriptor, ctx); + setup.open(); + + assertNotNull(setup.getSkillDiscoveryPrompt()); + assertTrue(setup.getSkillDiscoveryPrompt().contains("<available_skills>")); + assertTrue(setup.getToolNames().contains(Skills.LOAD_SKILL_TOOL)); + assertTrue(setup.getToolNames().contains(Skills.BASH_TOOL)); + } + + @Test + void chatInjectsSkillPromptAfterFirstSystemMessage() throws Exception { + Map<String, Resource> store = new HashMap<>(); + StubConnection connection = new StubConnection(new ResourceDescriptor("X", Map.of()), null); + store.put("conn", connection); + store.put(Skills.LOAD_SKILL_TOOL, new StubTool(Skills.LOAD_SKILL_TOOL)); + store.put(Skills.BASH_TOOL, new StubTool(Skills.BASH_TOOL)); + ResourceContext ctx = + new ResourceContext() { + @Override + public Resource getResource(String name, ResourceType type) { + return store.get(name); + } + + @Override + public String generateAvailableSkillsPrompt(List<String> skillNames) { + return "<available_skills>marker</available_skills>"; + } + + @Override + public List<String> getSkillDirs(List<String> skillNames) { + return List.of(); + } + }; + Map<String, Object> args = new HashMap<>(); + args.put("connection", "conn"); + args.put("skills", Arrays.asList("github")); + StubChatSetup setup = new StubChatSetup(new ResourceDescriptor("X", args), ctx); + setup.open(); + + List<ChatMessage> input = + Arrays.asList( + new ChatMessage(MessageRole.SYSTEM, "you are an agent"), + new ChatMessage(MessageRole.USER, "hi")); + setup.chat(input); + + // Expected: SYSTEM, SYSTEM(skill_prompt), USER + assertEquals(3, connection.capturedMessages.size()); + assertEquals(MessageRole.SYSTEM, connection.capturedMessages.get(0).getRole()); + assertEquals("you are an agent", connection.capturedMessages.get(0).getContent()); + assertEquals(MessageRole.SYSTEM, connection.capturedMessages.get(1).getRole()); + assertTrue(connection.capturedMessages.get(1).getContent().contains("<available_skills>")); + assertEquals(MessageRole.USER, connection.capturedMessages.get(2).getRole()); + } +} diff --git a/api/src/test/java/org/apache/flink/agents/api/skills/SkillsResourceTest.java b/api/src/test/java/org/apache/flink/agents/api/skills/SkillsResourceTest.java new file mode 100644 index 00000000..864eae42 --- /dev/null +++ b/api/src/test/java/org/apache/flink/agents/api/skills/SkillsResourceTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.api.skills; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.flink.agents.api.resource.ResourceType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SkillsResourceTest { + + @Test + void fromLocalDirCarriesPaths() { + Skills skills = Skills.fromLocalDir("/tmp/a", "/tmp/b"); + assertEquals(List.of("/tmp/a", "/tmp/b"), skills.getPaths()); + assertEquals(ResourceType.SKILLS, skills.getResourceType()); + } + + @Test + void roundTripsThroughJackson() throws Exception { + Skills original = Skills.fromLocalDir("/tmp/skill1", "/tmp/skill2"); + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(original); + Skills restored = mapper.readValue(json, Skills.class); + assertEquals(original.getPaths(), restored.getPaths()); + } + + @Test + void reservedNamesMatchPython() { + assertEquals("_skills_config", Skills.SKILLS_CONFIG); + assertEquals("load_skill", Skills.LOAD_SKILL_TOOL); + assertEquals("bash", Skills.BASH_TOOL); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java new file mode 100644 index 00000000..b3533824 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationAgent.java @@ -0,0 +1,119 @@ +/* + * 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; + +import org.apache.flink.agents.api.InputEvent; +import org.apache.flink.agents.api.OutputEvent; +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.annotation.Action; +import org.apache.flink.agents.api.annotation.ChatModelConnection; +import org.apache.flink.agents.api.annotation.ChatModelSetup; +import org.apache.flink.agents.api.annotation.Prompt; +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 org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.resource.ResourceName; +import org.apache.flink.agents.api.skills.Skills; + +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Agent that exercises the agent-skills feature end-to-end. Mirrors the Python {@code + * agent_skills_test.SkillTestAgent}: declares two skills (math-calculator, joke-generator) and a + * system prompt that instructs the model to load the skill before answering. + */ +public class SkillsIntegrationAgent extends Agent { + + /** Same model name as the Python {@code agent_skills_test} (a dashscope-hosted Qwen). */ + public static final String MODEL = "qwen3.6-plus"; + + /** + * Same default endpoint as the Python test β overridden by the {@code ACTION_BASE_URL} CI env + * var. + */ + public static final String DEFAULT_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; + + @ChatModelConnection + public static ResourceDescriptor openaiConnection() { + String apiKey = System.getenv("ACTION_API_KEY"); + String baseUrl = System.getenv().getOrDefault("ACTION_BASE_URL", DEFAULT_BASE_URL); + return ResourceDescriptor.Builder.newBuilder( + ResourceName.ChatModel.OPENAI_COMPLETIONS_CONNECTION) + .addInitialArgument("api_key", apiKey) + .addInitialArgument("api_base_url", baseUrl) + .build(); + } + + @ChatModelSetup + public static ResourceDescriptor openaiSetup() { + return ResourceDescriptor.Builder.newBuilder( + ResourceName.ChatModel.OPENAI_COMPLETIONS_SETUP) + .addInitialArgument("connection", "openaiConnection") + .addInitialArgument("model", MODEL) + .addInitialArgument("skills", List.of("math-calculator", "joke-generator")) + .addInitialArgument("allowed_commands", List.of("echo", "bc", "python", "python3")) + .addInitialArgument("prompt", "systemPrompt") + .build(); + } + + /** Resolve the {@code skills/} test resource directory to an absolute filesystem path. */ + @org.apache.flink.agents.api.annotation.Skills + public static Skills mySkills() { + URL url = + Objects.requireNonNull( + SkillsIntegrationAgent.class.getClassLoader().getResource("skills"), + "skills/ test resources are missing"); + Path path = Paths.get(url.getPath()); + return Skills.fromLocalDir(path.toString()); + } + + @Prompt + public static org.apache.flink.agents.api.prompt.Prompt systemPrompt() { + return org.apache.flink.agents.api.prompt.Prompt.fromMessages( + Collections.singletonList( + new ChatMessage( + MessageRole.SYSTEM, + "You are a helpful assistant. Use the math-calculator skill when " + + "asked to evaluate an expression, and the joke-generator " + + "skill when asked for a joke. You must load the skill " + + "first and strictly follow the instructions of the skill."))); + } + + @Action(listenEventTypes = {InputEvent.EVENT_TYPE}) + public static void process(InputEvent event, RunnerContext ctx) throws Exception { + ctx.sendEvent( + new ChatRequestEvent( + "openaiSetup", + Collections.singletonList( + new ChatMessage(MessageRole.USER, (String) event.getInput())))); + } + + @Action(listenEventTypes = {ChatResponseEvent.EVENT_TYPE}) + public static void processChatResponse(ChatResponseEvent event, RunnerContext ctx) { + ctx.sendEvent(new OutputEvent(event.getResponse().getContent())); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java new file mode 100644 index 00000000..65360910 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/SkillsIntegrationTest.java @@ -0,0 +1,226 @@ +/* + * 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; + +import org.apache.flink.agents.api.AgentsExecutionEnvironment; +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.agents.ReActAgent; +import org.apache.flink.agents.api.chat.messages.ChatMessage; +import org.apache.flink.agents.api.chat.messages.MessageRole; +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.api.common.functions.MapFunction; +import org.apache.flink.api.common.typeinfo.BasicTypeInfo; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.api.java.typeutils.RowTypeInfo; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.api.Schema; +import org.apache.flink.table.api.Table; +import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; +import org.apache.flink.types.Row; +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 java.net.URL; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.apache.flink.agents.api.agents.AgentExecutionOptions.ERROR_HANDLING_STRATEGY; +import static org.apache.flink.agents.api.agents.AgentExecutionOptions.MAX_RETRIES; + +/** + * End-to-end tests for agent skills. Mirrors the Python {@code + * python/flink_agents/e2e_tests/e2e_tests_integration/agent_skills_test.py}, including both the + * workflow-style agent and the {@link ReActAgent} variant. + * + * <ul> + * <li>{@link #testWorkflowWithSkills()} β feeds prompts through {@link SkillsIntegrationAgent} + * (workflow agent) and asserts on the math/joke responses. + * <li>{@link #testReActAgentWithSkills()} β uses {@link ReActAgent} with a structured output + * schema; asserts the parsed {@code result} field equals 8 ({@code 2 ^ 3}). + * </ul> + * + * <p>Skipped unless {@code ACTION_API_KEY} (the GitHub Actions-injected env var, mirroring the + * Python test) is set; small local models do not reliably handle the multi-turn skill-loading flow. + * {@code ACTION_BASE_URL} optionally overrides the default dashscope endpoint. + */ +public class SkillsIntegrationTest { + + @Test + public void testWorkflowWithSkills() throws Exception { + Assumptions.assumeTrue( + System.getenv().get("ACTION_API_KEY") != null, + "ACTION_API_KEY is required for the skills end-to-end test."); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream<String> inputStream = + env.fromData( + "Please evaluate the expression: (2 ^ 3)", "Tell me a joke about cat."); + + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); + + DataStream<Object> outputStream = + agentsEnv + .fromDataStream(inputStream, (KeySelector<String, String>) value -> value) + .apply(new SkillsIntegrationAgent()) + .toDataStream(); + + CloseableIterator<Object> results = outputStream.collectAsync(); + agentsEnv.execute(); + + List<String> responses = new ArrayList<>(); + while (results.hasNext()) { + responses.add(String.valueOf(results.next())); + } + + Assertions.assertEquals( + 2, responses.size(), String.format("Expected 2 responses, got: %s", responses)); + + String text = String.join("\n", responses); + Assertions.assertTrue( + text.contains("8"), + String.format("Math response should contain '8'. Full responses: %s", text)); + Assertions.assertTrue( + text.contains("Too many cheetahs"), + String.format( + "Joke response should contain script punchline 'Too many cheetahs'. " + + "Full responses: %s", + text)); + } + + @Test + public void testReActAgentWithSkills() throws Exception { + Assumptions.assumeTrue( + System.getenv().get("ACTION_API_KEY") != null, + "ACTION_API_KEY is required for the skills end-to-end test."); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); + + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env, tableEnv); + + String apiKey = System.getenv("ACTION_API_KEY"); + String baseUrl = + System.getenv() + .getOrDefault("ACTION_BASE_URL", SkillsIntegrationAgent.DEFAULT_BASE_URL); + + // Resolve the bundled skills/ test resource directory (same fixtures as the workflow test). + URL url = + Objects.requireNonNull( + SkillsIntegrationTest.class.getClassLoader().getResource("skills"), + "skills/ test resources are missing"); + String skillsPath = Paths.get(url.getPath()).toString(); + + agentsEnv + .addResource( + "openai", + ResourceType.CHAT_MODEL_CONNECTION, + ResourceDescriptor.Builder.newBuilder( + ResourceName.ChatModel.OPENAI_COMPLETIONS_CONNECTION) + .addInitialArgument("api_key", apiKey) + .addInitialArgument("api_base_url", baseUrl) + .build()) + .addResource("my_skill", ResourceType.SKILLS, Skills.fromLocalDir(skillsPath)); + + agentsEnv.getConfig().set(ERROR_HANDLING_STRATEGY, ReActAgent.ErrorHandlingStrategy.RETRY); + agentsEnv.getConfig().set(MAX_RETRIES, 3); + + ResourceDescriptor chatModelDescriptor = + ResourceDescriptor.Builder.newBuilder( + ResourceName.ChatModel.OPENAI_COMPLETIONS_SETUP) + .addInitialArgument("connection", "openai") + .addInitialArgument("model", SkillsIntegrationAgent.MODEL) + .addInitialArgument("skills", List.of("math-calculator")) + .addInitialArgument("allowed_commands", List.of("echo", "bc")) + .build(); + + Prompt prompt = + Prompt.fromMessages( + List.of( + new ChatMessage( + MessageRole.SYSTEM, + "You are a math calculate assistant. Use the math-calculator " + + "skill when asked to evaluate an expression. You " + + "must load the skill first and strictly follow the " + + "instructions of the skill."), + new ChatMessage( + MessageRole.USER, + "Please evaluate the expression: {a} ^ {b}"))); + + RowTypeInfo outputTypeInfo = + new RowTypeInfo( + new TypeInformation[] {BasicTypeInfo.INT_TYPE_INFO}, + new String[] {"result"}); + + Agent agent = new ReActAgent(chatModelDescriptor, prompt, outputTypeInfo); + + Table inputTable = + tableEnv.fromValues( + DataTypes.ROW( + DataTypes.FIELD("a", DataTypes.INT()), + DataTypes.FIELD("b", DataTypes.INT())), + Row.of(2, 3)); + + Schema outputSchema = + Schema.newBuilder() + .column("f0", DataTypes.ROW(DataTypes.FIELD("result", DataTypes.INT()))) + .build(); + + Table outputTable = + agentsEnv + .fromTable( + inputTable, + (KeySelector<Object, Integer>) + value -> (Integer) ((Row) value).getField("a")) + .apply(agent) + .toTable(outputSchema); + + CloseableIterator<Row> results = + tableEnv.toDataStream(outputTable) + .map((MapFunction<Row, Row>) x -> (Row) x.getField("f0")) + .collectAsync(); + + env.execute(); + + Assertions.assertTrue( + results.hasNext(), + "ReAct agent did not produce any output β the LLM response may not have matched the " + + "output schema; rerun if so."); + Row row = (Row) results.next(); + Object result = row.getField("result"); + Assertions.assertNotNull(result, String.format("Missing result field in row %s", row)); + Assertions.assertEquals( + 8, ((Integer) result).intValue(), String.format("Expected 2 ^ 3 = 8, got %s", row)); + } +} diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/SKILL.md b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/SKILL.md new file mode 100644 index 00000000..eadf07ab --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/SKILL.md @@ -0,0 +1,18 @@ +--- +name: joke-generator +description: Tell a joke about cat. +--- + +# Math Calculator Skill + +This skill provides the ability to tell a joke about cat. + +## When to Use + +Use this skill when user want to get a joke about cat. + +## Methods + +```bash +python scripts/gen_joke.py +``` diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/scripts/gen_joke.py b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/scripts/gen_joke.py new file mode 100755 index 00000000..3c681eba --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/joke-generator/scripts/gen_joke.py @@ -0,0 +1,20 @@ +################################################################################ +# 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. +################################################################################# + +if __name__ == "__main__": + print("Why don't cats play poker in the jungle? Too many cheetahs. π±") diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/math-calculator/SKILL.md b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/math-calculator/SKILL.md new file mode 100644 index 00000000..b00a1592 --- /dev/null +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/resources/skills/math-calculator/SKILL.md @@ -0,0 +1,57 @@ +--- +name: math-calculator +description: Calculate simple mathematical expressions using shell commands. Use when the user asks to perform arithmetic calculations like addition, subtraction, multiplication, division, or more complex math expressions. +license: Apache-2.0 +compatibility: Requires bash with bc (basic calculator) +--- + +# Math Calculator Skill + +This skill provides the ability to calculate mathematical expressions using shell commands. + +## When to Use + +Use this skill when: +- Performing arithmetic calculations (add, subtract, multiply, divide) +- Evaluating mathematical expressions with parentheses +- Computing percentages or powers +- Any numeric computation requested by the user + +## Methods + +### Using `bc` (Basic Calculator) + +The `bc` command is a powerful calculator that supports: +- Basic arithmetic: `+`, `-`, `*`, `/` +- Power: `^` +- Parentheses for grouping +- Scale for decimal precision + +**Example:** +```bash +echo "2 + 3 * 4" | bc +# Output: 14 + +echo "scale=2; 10 / 3" | bc +# Output: 3.33 + +echo "(2 + 3) * 4" | bc +# Output: 20 +``` + +## Supported Operations + +| Operation | Symbol | Example | +|-----------|--------|---------| +| Addition | `+` | `5 + 3 = 8` | +| Subtraction | `-` | `10 - 4 = 6` | +| Multiplication | `*` | `6 * 7 = 42` | +| Division | `/` | `15 / 3 = 5` | +| Power | `^` (bc) or `**` (Python) | `2 ^ 3 = 8` | +| Modulo | `%` | `17 % 5 = 2` | +| Square Root | `sqrt()` (bc) | `sqrt(16) = 4` | + +## Notes + +- Use `scale=N` in `bc` to set decimal precision (default is 0, integer only) +- For floating-point division, always set `scale` in `bc` 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 0dde929e..3fb15109 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 @@ -28,6 +28,7 @@ import org.apache.flink.agents.api.resource.ResourceDescriptor; import org.apache.flink.agents.api.resource.ResourceType; import org.apache.flink.agents.api.resource.SerializableResource; import org.apache.flink.agents.api.resource.python.PythonResourceWrapper; +import org.apache.flink.agents.api.skills.Skills; import org.apache.flink.agents.api.tools.ToolMetadata; import org.apache.flink.agents.plan.actions.Action; import org.apache.flink.agents.plan.actions.ChatModelAction; @@ -42,6 +43,7 @@ import org.apache.flink.agents.plan.serializer.AgentPlanJsonDeserializer; import org.apache.flink.agents.plan.serializer.AgentPlanJsonSerializer; import org.apache.flink.agents.plan.tools.FunctionTool; import org.apache.flink.agents.plan.tools.ToolMetadataFactory; +import org.apache.flink.agents.plan.tools.bash.BashTool; import org.apache.flink.api.java.tuple.Tuple3; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +58,8 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -337,6 +341,9 @@ public class AgentPlan implements Serializable { private void extractResourceProvidersFromAgent(Agent agent) throws Exception { Class<?> agentClass = agent.getClass(); + // Collect Skills declarations from both @Skills methods and Agent.addResource(SKILLS, ...) + Map<String, Skills> skillsObjects = new LinkedHashMap<>(); + // Scan all fields in the agent class for @Tool and @ChatModel annotations for (Field field : agentClass.getDeclaredFields()) { field.setAccessible(true); // Allow access to private fields @@ -411,6 +418,17 @@ public class AgentPlan implements Serializable { extractResource(ResourceType.EMBEDDING_MODEL_CONNECTION, method); } else if (method.isAnnotationPresent(VectorStore.class)) { extractResource(ResourceType.VECTOR_STORE, method); + } else if (method.isAnnotationPresent( + org.apache.flink.agents.api.annotation.Skills.class) + && Modifier.isStatic(method.getModifiers())) { + Object value = method.invoke(null); + if (!(value instanceof Skills)) { + throw new IllegalStateException( + "@Skills method " + + method.getName() + + " must return org.apache.flink.agents.api.skills.Skills"); + } + skillsObjects.put(method.getName(), (Skills) value); } else if (method.isAnnotationPresent(MCPServer.class)) { // Check the MCPServer annotation version to determine which version to use. MCPServer MCPServerAnnotation = method.getAnnotation(MCPServer.class); @@ -476,8 +494,52 @@ public class AgentPlan implements Serializable { ((org.apache.flink.agents.api.tools.FunctionTool) kv.getValue()) .getMethod()); } + } else if (type == ResourceType.SKILLS) { + for (Map.Entry<String, Object> kv : entry.getValue().entrySet()) { + if (kv.getValue() instanceof Skills) { + skillsObjects.put(kv.getKey(), (Skills) kv.getValue()); + } + } } } + + addSkills(skillsObjects); + } + + /** + * Mirror of Python {@code _add_skills}: register the merged Skills config under {@link + * Skills#SKILLS_CONFIG} plus the built-in {@code load_skill} and {@code bash} tools. + * + * <p>{@link BashTool} lives in this module so we can reference its class directly; {@code + * LoadSkillTool} lives in the runtime module and is referenced by FQN string to avoid a reverse + * dependency. + */ + private void addSkills(Map<String, Skills> skillsObjects) throws Exception { + if (skillsObjects.isEmpty()) { + return; + } + + addResourceProvider( + new JavaResourceProvider( + Skills.LOAD_SKILL_TOOL, + ResourceType.TOOL, + new ResourceDescriptor( + "org.apache.flink.agents.runtime.skill.LoadSkillTool", + new HashMap<>()))); + addResourceProvider( + new JavaResourceProvider( + Skills.BASH_TOOL, + ResourceType.TOOL, + new ResourceDescriptor(BashTool.class.getName(), new HashMap<>()))); + + LinkedHashSet<String> paths = new LinkedHashSet<>(); + for (Skills s : skillsObjects.values()) { + paths.addAll(s.getPaths()); + } + Skills merged = Skills.fromLocalDir(paths.toArray(new String[0])); + addResourceProvider( + JavaSerializableResourceProvider.createResourceProvider( + Skills.SKILLS_CONFIG, ResourceType.SKILLS, merged)); } /** diff --git a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java index becec471..997fb28b 100644 --- a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java +++ b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java @@ -36,6 +36,7 @@ import org.apache.flink.agents.api.event.ToolRequestEvent; import org.apache.flink.agents.api.event.ToolResponseEvent; import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup; import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; import org.apache.flink.agents.api.tools.ToolResponse; import org.apache.flink.agents.plan.JavaFunction; import org.apache.flink.api.java.typeutils.RowTypeInfo; @@ -182,6 +183,7 @@ public class ChatModelAction { ChatMessage response, UUID initialRequestId, String model, + BaseChatModelSetup chatModel, List<ChatMessage> messages, Object outputSchema, RunnerContext ctx) @@ -192,6 +194,8 @@ public class ChatModelAction { messages, Collections.singletonList(response)); + injectBashToolArgs(response.getToolCalls(), chatModel); + ToolRequestEvent toolRequestEvent = new ToolRequestEvent(model, response.getToolCalls()); saveToolRequestEventContext( @@ -204,6 +208,46 @@ public class ChatModelAction { ctx.sendEvent(toolRequestEvent); } + /** + * Inject framework-controlled args ({@code allowed_commands}, {@code allowed_script_dirs}) into + * bash tool calls so they remain hidden from the LLM. Mirrors Python {@code + * _inject_bash_tool_args}. + */ + @SuppressWarnings("unchecked") + private static void injectBashToolArgs( + List<Map<String, Object>> toolCalls, BaseChatModelSetup chatModel) throws Exception { + if (toolCalls == null || toolCalls.isEmpty()) { + return; + } + List<String> scriptDirs = new ArrayList<>(chatModel.getAllowedScriptDirs()); + List<String> declaredSkills = chatModel.getSkills(); + if (declaredSkills != null + && !declaredSkills.isEmpty() + && chatModel.getResourceContext() != null) { + scriptDirs.addAll(chatModel.getResourceContext().getSkillDirs(declaredSkills)); + } + for (Map<String, Object> call : toolCalls) { + Object function = call.get("function"); + if (!(function instanceof Map)) { + continue; + } + Map<String, Object> functionMap = (Map<String, Object>) function; + if (!Skills.BASH_TOOL.equals(functionMap.get("name"))) { + continue; + } + Object argsObj = functionMap.get("arguments"); + Map<String, Object> args; + if (argsObj instanceof Map) { + args = (Map<String, Object>) argsObj; + } else { + args = new HashMap<>(); + functionMap.put("arguments", args); + } + args.put("allowed_commands", new ArrayList<>(chatModel.getAllowedCommands())); + args.put("allowed_script_dirs", scriptDirs); + } + } + static String cleanLlmResponse(String rawResponse) { String trimmed = rawResponse.trim(); if (trimmed.startsWith("```")) { @@ -348,7 +392,8 @@ public class ChatModelAction { } if (!Objects.requireNonNull(response).getToolCalls().isEmpty()) { - handleToolCalls(response, initialRequestId, model, messages, outputSchema, ctx); + handleToolCalls( + response, initialRequestId, model, chatModel, messages, outputSchema, ctx); } else { Map<String, Long> retryStats = getRetryStats(ctx.getSensoryMemory(), initialRequestId); int totalRetryCount = retryStats.get(TOTAL_RETRY_COUNT).intValue(); diff --git a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareSkillsTest.java b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareSkillsTest.java new file mode 100644 index 00000000..e7605368 --- /dev/null +++ b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanDeclareSkillsTest.java @@ -0,0 +1,131 @@ +/* + * 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; + +import org.apache.flink.agents.api.agents.Agent; +import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.plan.resourceprovider.JavaResourceProvider; +import org.apache.flink.agents.plan.resourceprovider.JavaSerializableResourceProvider; +import org.apache.flink.agents.plan.resourceprovider.ResourceProvider; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentPlanDeclareSkillsTest { + + public static class SingleSkillsAgent extends Agent { + @org.apache.flink.agents.api.annotation.Skills + public static Skills mySkills() { + return Skills.fromLocalDir("/tmp/skill-a", "/tmp/skill-b"); + } + } + + public static class MultiSkillsAgent extends Agent { + @org.apache.flink.agents.api.annotation.Skills + public static Skills first() { + return Skills.fromLocalDir("/tmp/skill-a", "/tmp/skill-b"); + } + + @org.apache.flink.agents.api.annotation.Skills + public static Skills second() { + return Skills.fromLocalDir("/tmp/skill-b", "/tmp/skill-c"); + } + } + + public static class NoSkillsAgent extends Agent {} + + @Test + void singleSkillsRegistersConfigAndBuiltInTools() throws Exception { + AgentPlan plan = new AgentPlan(new SingleSkillsAgent()); + Map<ResourceType, Map<String, ResourceProvider>> providers = plan.getResourceProviders(); + + // Skills config under reserved name + assertNotNull(providers.get(ResourceType.SKILLS)); + ResourceProvider configProvider = + providers.get(ResourceType.SKILLS).get(Skills.SKILLS_CONFIG); + assertNotNull(configProvider); + assertTrue(configProvider instanceof JavaSerializableResourceProvider); + + // load_skill + bash tools as JavaResourceProviders pointing at runtime / plan classes + Map<String, ResourceProvider> tools = providers.get(ResourceType.TOOL); + assertNotNull(tools); + assertTrue(tools.get(Skills.LOAD_SKILL_TOOL) instanceof JavaResourceProvider); + assertEquals( + "org.apache.flink.agents.runtime.skill.LoadSkillTool", + ((JavaResourceProvider) tools.get(Skills.LOAD_SKILL_TOOL)) + .getDescriptor() + .getClazz()); + assertEquals( + "org.apache.flink.agents.plan.tools.bash.BashTool", + ((JavaResourceProvider) tools.get(Skills.BASH_TOOL)).getDescriptor().getClazz()); + } + + @Test + void multipleSkillsMethodsMergePathsWithDeduplication() throws Exception { + AgentPlan plan = new AgentPlan(new MultiSkillsAgent()); + ResourceProvider configProvider = + plan.getResourceProviders().get(ResourceType.SKILLS).get(Skills.SKILLS_CONFIG); + Skills merged = + (Skills) + ((JavaSerializableResourceProvider) configProvider) + .provide( + org.apache.flink.agents.api.resource.ResourceContext + .fromGetResource((n, t) -> null)); + // Order is preserved; "/tmp/skill-b" appears once. + assertEquals(3, merged.getPaths().size()); + assertTrue(merged.getPaths().contains("/tmp/skill-a")); + assertTrue(merged.getPaths().contains("/tmp/skill-b")); + assertTrue(merged.getPaths().contains("/tmp/skill-c")); + } + + @Test + void noSkillsLeavesNoConfigProvider() throws Exception { + AgentPlan plan = new AgentPlan(new NoSkillsAgent()); + Map<String, ResourceProvider> skillsMap = + plan.getResourceProviders().getOrDefault(ResourceType.SKILLS, Map.of()); + assertNull(skillsMap.get(Skills.SKILLS_CONFIG)); + Map<String, ResourceProvider> tools = + plan.getResourceProviders().getOrDefault(ResourceType.TOOL, Map.of()); + assertNull(tools.get(Skills.LOAD_SKILL_TOOL)); + assertNull(tools.get(Skills.BASH_TOOL)); + } + + @Test + void programmaticSkillsAddResourceParticipates() throws Exception { + Agent agent = new NoSkillsAgent(); + agent.addResource("more", ResourceType.SKILLS, Skills.fromLocalDir("/tmp/skill-d")); + AgentPlan plan = new AgentPlan(agent); + ResourceProvider configProvider = + plan.getResourceProviders().get(ResourceType.SKILLS).get(Skills.SKILLS_CONFIG); + assertNotNull(configProvider); + Skills merged = + (Skills) + ((JavaSerializableResourceProvider) configProvider) + .provide( + org.apache.flink.agents.api.resource.ResourceContext + .fromGetResource((n, t) -> null)); + assertEquals(java.util.List.of("/tmp/skill-d"), merged.getPaths()); + } +} diff --git a/python/flink_agents/runtime/java/java_resource_wrapper.py b/python/flink_agents/runtime/java/java_resource_wrapper.py index cc929b6f..83305731 100644 --- a/python/flink_agents/runtime/java/java_resource_wrapper.py +++ b/python/flink_agents/runtime/java/java_resource_wrapper.py @@ -93,11 +93,17 @@ class JavaResourceContextWrapper(ResourceContext): @override def generate_available_skills_prompt(self, *skill_names: str) -> str: - """Generate the skill discovery prompt for the given skill names.""" - # TODO: Implement after java supports agent skills. + """Generate the skill discovery prompt for the given skill names. + + Forwards to the Java ``JavaResourceAdapter#generateAvailableSkillsPrompt`` + so that a Python chat model running inside a Java agent can use skills + declared on the Java side. + """ + result = self._j_resource_adapter.generateAvailableSkillsPrompt(list(skill_names)) + return result if result is not None else "" @override def get_skill_dirs(self, *skill_names: str) -> List[str]: """Return absolute directory paths for the given skill names.""" - # TODO: Implement after java supports agent skills. - return [] + result = self._j_resource_adapter.getSkillDirs(list(skill_names)) + return list(result) if result is not None else [] diff --git a/runtime/pom.xml b/runtime/pom.xml index 39679389..168fb2be 100644 --- a/runtime/pom.xml +++ b/runtime/pom.xml @@ -41,6 +41,12 @@ under the License. <version>${project.version}</version> </dependency> + <!-- YAML parsing for SKILL.md frontmatter --> + <dependency> + <groupId>com.fasterxml.jackson.dataformat</groupId> + <artifactId>jackson-dataformat-yaml</artifactId> + </dependency> + <!-- flink --> <dependency> <groupId>org.apache.flink</groupId> diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java index 81336ecb..fbb0365e 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java @@ -28,6 +28,7 @@ import org.apache.flink.agents.api.vectorstores.VectorStoreQueryMode; import pemja.core.PythonInterpreter; import pemja.core.object.PyObject; +import java.util.List; import java.util.Map; /** Adapter for managing Java resources and facilitating Python-Java interoperability. */ @@ -54,6 +55,20 @@ public class JavaResourceAdapter { return resourceContext.getResource(name, ResourceType.fromValue(typeValue)); } + /** + * Generate the available skills prompt for the given skill names. Used by the Python {@code + * JavaResourceContextWrapper} when a Python chat model running in a Java agent needs the skill + * discovery prompt. + */ + public String generateAvailableSkillsPrompt(List<String> skillNames) throws Exception { + return resourceContext.generateAvailableSkillsPrompt(skillNames); + } + + /** Return absolute directory paths for the given skill names. */ + public List<String> getSkillDirs(List<String> skillNames) throws Exception { + return resourceContext.getSkillDirs(skillNames); + } + /** * Convert a Python chat message to a Java chat message. This method is intended for use by the * Python interpreter. diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/resource/ResourceContextImpl.java b/runtime/src/main/java/org/apache/flink/agents/runtime/resource/ResourceContextImpl.java index 11c1cb63..b7dc0eed 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/resource/ResourceContextImpl.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/resource/ResourceContextImpl.java @@ -21,6 +21,10 @@ package org.apache.flink.agents.runtime.resource; import org.apache.flink.agents.api.resource.Resource; import org.apache.flink.agents.api.resource.ResourceContext; import org.apache.flink.agents.api.resource.ResourceType; +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.runtime.skill.SkillManager; + +import javax.annotation.Nullable; import java.util.Collections; import java.util.List; @@ -30,13 +34,17 @@ import java.util.function.BiFunction; * Default {@link ResourceContext} implementation that delegates resource lookup to a {@link * BiFunction} (typically the underlying {@code ResourceCache::getResource}). * - * <p>Mirrors the Python {@code flink_agents.runtime.resource_context.ResourceContextImpl}. Skill - * methods return safe defaults; callers without skills configured see empty values. + * <p>Mirrors the Python {@code flink_agents.runtime.resource_context.ResourceContextImpl}. The + * skill methods lazily build a {@link SkillManager} from the {@code _skills_config} resource β if + * no such resource is registered they return safe defaults (empty string / empty list). */ public class ResourceContextImpl implements ResourceContext { private final BiFunction<String, ResourceType, Resource> getResource; + @Nullable private volatile SkillManager skillManager; + @Nullable private volatile Skills cachedSkillsConfig; + public ResourceContextImpl(BiFunction<String, ResourceType, Resource> getResource) { this.getResource = getResource; } @@ -55,11 +63,41 @@ public class ResourceContextImpl implements ResourceContext { @Override public String generateAvailableSkillsPrompt(List<String> skillNames) throws Exception { - return ""; + SkillManager manager = ensureSkillManager(); + return manager == null ? "" : manager.generateDiscoveryPrompt(skillNames); } @Override public List<String> getSkillDirs(List<String> skillNames) throws Exception { - return Collections.emptyList(); + SkillManager manager = ensureSkillManager(); + return manager == null ? Collections.emptyList() : manager.getSkillDirs(skillNames); + } + + /** + * Returns the cached {@link SkillManager} for this context, or {@code null} if not configured. + */ + @Nullable + public synchronized SkillManager getSkillManager() throws Exception { + return ensureSkillManager(); + } + + @Nullable + private synchronized SkillManager ensureSkillManager() throws Exception { + Skills config; + try { + Resource r = getResource(Skills.SKILLS_CONFIG, ResourceType.SKILLS); + if (!(r instanceof Skills)) { + return null; + } + config = (Skills) r; + } catch (Exception e) { + // No skills config registered β that's fine, return null. + return null; + } + if (config != cachedSkillsConfig) { + cachedSkillsConfig = config; + skillManager = new SkillManager(config); + } + return skillManager; } } diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/AgentSkill.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/AgentSkill.java new file mode 100644 index 00000000..7b32ade7 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/AgentSkill.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.runtime.skill; + +import org.apache.flink.util.Preconditions; + +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Runtime representation of one parsed {@code SKILL.md}. + * + * <p>Mirrors the Python {@code flink_agents.runtime.skill.agent_skill.AgentSkill}. Resources are + * lazily loaded on first access. + */ +public final class AgentSkill { + + private final String name; + private final String description; + private final String content; + @Nullable private final String license; + @Nullable private final String compatibility; + @Nullable private final Map<String, String> metadata; + @Nullable private volatile Map<String, String> resources; + @Nullable private Supplier<Map<String, String>> resourceLoader; + private volatile boolean activated; + + public AgentSkill( + String name, + String description, + String content, + @Nullable String license, + @Nullable String compatibility, + @Nullable Map<String, String> metadata) { + this(name, description, content, license, compatibility, metadata, null); + } + + public AgentSkill( + String name, + String description, + String content, + @Nullable String license, + @Nullable String compatibility, + @Nullable Map<String, String> metadata, + @Nullable Map<String, String> resources) { + Preconditions.checkArgument( + name != null && !name.isEmpty() && name.length() <= 64, + "Skill name must be 1..64 characters: %s", + name); + Preconditions.checkArgument( + description != null && !description.isEmpty() && description.length() <= 1024, + "Skill description must be 1..1024 characters"); + Preconditions.checkArgument( + content != null && !content.isEmpty(), "Skill content must not be empty"); + Preconditions.checkArgument( + compatibility == null || compatibility.length() <= 500, + "Skill compatibility must be at most 500 characters"); + this.name = name; + this.description = description; + this.content = content; + this.license = license; + this.compatibility = compatibility; + this.metadata = metadata; + this.resources = resources; + this.activated = resources != null; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getContent() { + return content; + } + + @Nullable + public String getLicense() { + return license; + } + + @Nullable + public String getCompatibility() { + return compatibility; + } + + @Nullable + public Map<String, String> getMetadata() { + return metadata; + } + + /** Set a lazy resource loader. Must be called before the first {@link #getResource(String)}. */ + public void setResourceLoader(Supplier<Map<String, String>> loader) { + this.resourceLoader = loader; + } + + /** + * Return the content of the named resource (relative path from the skill root) or {@code null} + * if no such resource is registered. + */ + @Nullable + public String getResource(String relativePath) { + activate(); + return resources == null ? null : resources.get(relativePath); + } + + /** Return all registered resource relative paths (sorted, may be empty). */ + public List<String> getResourcePaths() { + activate(); + if (resources == null) { + return List.of(); + } + List<String> keys = new ArrayList<>(resources.keySet()); + keys.sort(String::compareTo); + return keys; + } + + private synchronized void activate() { + if (!activated && resourceLoader != null) { + resources = resourceLoader.get(); + activated = true; + } + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/LoadSkillTool.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/LoadSkillTool.java new file mode 100644 index 00000000..6e08bd76 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/LoadSkillTool.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import org.apache.flink.agents.api.resource.ResourceContext; +import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.tools.Tool; +import org.apache.flink.agents.api.tools.ToolMetadata; +import org.apache.flink.agents.api.tools.ToolParameters; +import org.apache.flink.agents.api.tools.ToolResponse; +import org.apache.flink.agents.api.tools.ToolType; +import org.apache.flink.agents.runtime.resource.ResourceContextImpl; + +import java.nio.file.Path; +import java.util.List; + +/** + * Built-in tool that returns the body of a SKILL.md (default) or a specific bundled resource. + * + * <p>Mirrors the Python {@code flink_agents.runtime.skill.skill_tools.LoadSkillTool}. Auto-loaded + * by {@code AgentPlan.addSkills} when an agent declares any {@code @Skills} method. + */ +public class LoadSkillTool extends Tool { + + private static final String DESCRIPTION = + "Load a skill's content or a specific resource. Use this to access skill instructions and resources."; + + private static final String INPUT_SCHEMA = + "{\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"," + + "\"description\":\"The name of the skill to load (e.g., 'pdf-processing').\"}," + + "\"path\":{\"type\":\"string\"," + + "\"description\":\"Optional path to a specific resource within the skill. If not provided, returns the full SKILL.md content.\"," + + "\"default\":\"SKILL.md\"}}," + + "\"required\":[\"name\"]}"; + + public LoadSkillTool(ResourceDescriptor descriptor, ResourceContext resourceContext) { + super(new ToolMetadata("load_skill", DESCRIPTION, INPUT_SCHEMA)); + this.resourceContext = resourceContext; + } + + @Override + public ToolType getToolType() { + return ToolType.FUNCTION; + } + + @Override + public ToolResponse call(ToolParameters parameters) { + String name = parameters.getParameter("name", String.class); + String path = + parameters.hasParameter("path") + ? parameters.getParameter("path", String.class) + : "SKILL.md"; + + SkillManager manager; + try { + manager = resolveSkillManager(); + } catch (Exception e) { + return ToolResponse.success( + "Skill manager not available. No skills have been registered."); + } + if (manager == null) { + return ToolResponse.success( + "Skill manager not available. No skills have been registered."); + } + + AgentSkill skill; + try { + skill = manager.getSkill(name); + } catch (IllegalArgumentException e) { + List<String> available = manager.getAllSkillNames(); + String availableStr = + available.isEmpty() ? "No skills available." : String.join(", ", available); + return ToolResponse.success( + "Skill '" + name + "' not found. Available skills: " + availableStr); + } + + if (path == null || "SKILL.md".equals(path)) { + Path skillDir = manager.getSkillDir(name); + if (skillDir != null) { + StringBuilder files = new StringBuilder(); + for (String rel : skill.getResourcePaths()) { + files.append("<file>") + .append(skillDir.resolve(rel)) + .append("</file>") + .append('\n'); + } + String filesSection = files.length() == 0 ? "" : files.toString().stripTrailing(); + return ToolResponse.success( + "<skill_content name=\"" + + name + + "\">\n" + + "# Skill: " + + name + + "\n\n" + + skill.getContent().strip() + + "\n\n" + + "Base directory for this skill: " + + skillDir + + "\n" + + "Relative paths in this skill are relative to this base directory.\n" + + "<skill_files>\n" + + filesSection + + "\n</skill_files>\n" + + "</skill_content>"); + } + return ToolResponse.success(skill.getContent()); + } + + String content = skill.getResource(path); + if (content == null) { + return ToolResponse.success( + "Resource '" + + path + + "' not found in skill '" + + name + + "', Available resources: " + + skill.getResourcePaths()); + } + return ToolResponse.success(content); + } + + private SkillManager resolveSkillManager() throws Exception { + if (resourceContext instanceof ResourceContextImpl) { + return ((ResourceContextImpl) resourceContext).getSkillManager(); + } + return null; + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java new file mode 100644 index 00000000..8778bd0e --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import org.apache.flink.agents.api.skills.Skills; +import org.apache.flink.agents.runtime.skill.repository.FileSystemSkillRepository; + +import javax.annotation.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Loads and indexes all skills referenced by a {@link Skills} configuration. + * + * <p>Mirrors the Python {@code flink_agents.runtime.skill.skill_manager.SkillManager}. + */ +public class SkillManager { + + private final Skills config; + private final Map<String, AgentSkill> skills = new LinkedHashMap<>(); + private final Map<String, SkillRepository> repos = new HashMap<>(); + + public SkillManager(Skills config) { + this.config = config; + loadFromPaths(); + } + + public int size() { + return skills.size(); + } + + public AgentSkill getSkill(String name) { + AgentSkill skill = skills.get(name); + if (skill == null) { + throw new IllegalArgumentException( + "Skill " + + name + + " not found, available skill names are: " + + getAllSkillNames()); + } + return skill; + } + + public List<String> getAllSkillNames() { + return new ArrayList<>(skills.keySet()); + } + + @Nullable + public String loadSkillResource(String skillName, String resourcePath) { + return getSkill(skillName).getResource(resourcePath); + } + + /** Build the {@code <available_skills>} system prompt for the given skill names. */ + public String generateDiscoveryPrompt(List<String> names) { + if (size() == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(SkillPromptProvider.SKILL_DISCOVERY_PROMPT); + for (String name : names) { + AgentSkill skill = getSkill(name); + sb.append( + String.format( + SkillPromptProvider.AVAILABLE_SKILL_TEMPLATE, + skill.getName(), + skill.getDescription())); + } + sb.append(SkillPromptProvider.AVAILABLE_SKILLS_TAG_END); + return sb.toString(); + } + + /** + * Absolute directory paths for the listed skill names (filesystem-backed only). When called + * with an empty or {@code null} list, returns directories for all filesystem-backed skills. + */ + public List<String> getSkillDirs(List<String> names) { + Iterable<String> selected = (names == null || names.isEmpty()) ? repos.keySet() : names; + List<String> dirs = new ArrayList<>(); + for (String skillName : selected) { + SkillRepository repo = repos.get(skillName); + if (repo instanceof FileSystemSkillRepository) { + Path dir = ((FileSystemSkillRepository) repo).getBaseDir().resolve(skillName); + dirs.add(dir.toString()); + } + } + return dirs; + } + + /** Return absolute directory path for a single skill, if filesystem-backed. */ + @Nullable + public Path getSkillDir(String skillName) { + SkillRepository repo = repos.get(skillName); + if (repo instanceof FileSystemSkillRepository) { + return ((FileSystemSkillRepository) repo).getBaseDir().resolve(skillName); + } + return null; + } + + /** Resolve a skill resource's relative path to an absolute path, or {@code null} if missing. */ + @Nullable + public Path resolveResourcePath(String skillName, String resourcePath) { + SkillRepository repo = repos.get(skillName); + if (repo instanceof FileSystemSkillRepository) { + Path resolved = + ((FileSystemSkillRepository) repo) + .getBaseDir() + .resolve(skillName) + .resolve(resourcePath); + if (Files.isRegularFile(resolved)) { + return resolved; + } + } + return null; + } + + private void loadFromPaths() { + for (String path : config.getPaths()) { + FileSystemSkillRepository repo = new FileSystemSkillRepository(path); + for (AgentSkill skill : repo.getSkills()) { + final String skillName = skill.getName(); + skill.setResourceLoader(() -> repo.getResources(skillName)); + skills.put(skillName, skill); + repos.put(skillName, repo); + } + } + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillParser.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillParser.java new file mode 100644 index 00000000..02dcdfda --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillParser.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser that splits a {@code SKILL.md} file into YAML frontmatter and markdown body, then + * constructs an {@link AgentSkill}. + * + * <p>Mirrors the Python {@code flink_agents.runtime.skill.skill_parser}. + */ +public final class SkillParser { + + private static final Pattern FRONTMATTER = + Pattern.compile( + "^---\\s*[\\r\\n]+(.*?)[\\r\\n]*---(?:\\s*[\\r\\n]+)?(.*)", Pattern.DOTALL); + + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + + private SkillParser() {} + + /** Result of splitting a markdown file. */ + public static final class ParsedMarkdown { + public final Map<String, Object> metadata; + public final String content; + + public ParsedMarkdown(Map<String, Object> metadata, String content) { + this.metadata = metadata == null ? Collections.emptyMap() : metadata; + this.content = content == null ? "" : content; + } + } + + /** Split the markdown into YAML frontmatter and the remaining body. */ + public static ParsedMarkdown parseMarkdown(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return new ParsedMarkdown(Collections.emptyMap(), ""); + } + Matcher m = FRONTMATTER.matcher(markdown); + if (!m.matches()) { + return new ParsedMarkdown(Collections.emptyMap(), markdown); + } + String yaml = m.group(1).trim(); + String body = m.group(2); + if (yaml.isEmpty()) { + return new ParsedMarkdown(Collections.emptyMap(), body); + } + try { + Map<String, Object> metadata = + YAML.readValue(yaml, new TypeReference<Map<String, Object>>() {}); + return new ParsedMarkdown(metadata, body); + } catch (Exception e) { + throw new IllegalArgumentException( + "Invalid YAML frontmatter syntax: " + e.getMessage(), e); + } + } + + /** Parse a SKILL.md content into an {@link AgentSkill}. */ + public static AgentSkill parseSkill(String skillMdContent) { + ParsedMarkdown parsed = parseMarkdown(skillMdContent); + Map<String, Object> metadata = parsed.metadata; + + Object name = metadata.get("name"); + if (!(name instanceof String) || ((String) name).trim().isEmpty()) { + throw new IllegalArgumentException( + "The SKILL.md must have a YAML frontmatter including 'name' field."); + } + Object description = metadata.get("description"); + if (!(description instanceof String) || ((String) description).trim().isEmpty()) { + throw new IllegalArgumentException( + "The SKILL.md must have a YAML frontmatter including 'description' field."); + } + if (parsed.content == null || parsed.content.isEmpty()) { + throw new IllegalArgumentException( + "The SKILL.md must have a markdown content after YAML frontmatter."); + } + + Object license = metadata.get("license"); + Object compatibility = metadata.get("compatibility"); + Object inner = metadata.get("metadata"); + Map<String, String> innerMetadata = null; + if (inner instanceof Map) { + innerMetadata = new HashMap<>(); + for (Map.Entry<?, ?> e : ((Map<?, ?>) inner).entrySet()) { + innerMetadata.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + + return new AgentSkill( + ((String) name).trim(), + ((String) description).trim(), + parsed.content, + license == null ? null : license.toString(), + compatibility == null ? null : compatibility.toString(), + innerMetadata); + } +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillPromptProvider.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillPromptProvider.java new file mode 100644 index 00000000..2cf84386 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillPromptProvider.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.runtime.skill; + +/** + * System-prompt templates for skill discovery and activation. + * + * <p>Mirrors the Python {@code flink_agents.runtime.skill.skill_prompt_provider} byte-for-byte. + * Descriptions injected into {@link #AVAILABLE_SKILL_TEMPLATE} must not contain {@code %} (no skill + * fixture currently does β the SKILL.md schema bounds descriptions to plain text). + */ +public final class SkillPromptProvider { + + public static final String SKILL_DISCOVERY_PROMPT = + "## Available Skills\n" + + "\n" + + "<usage>\n" + + "Skills provide specialized capabilities and domain knowledge. Use them when they match your current task.\n" + + "\n" + + "Load a skill with `load_skill(name=\"<skill-name>\")` to get its full instructions.\n" + + "Individual resources (scripts, references, assets) can be loaded with a `path` argument.\n" + + "\n" + + "The loaded content includes the skill's base directory and the absolute paths of its resources.\n" + + "</usage>\n" + + "\n" + + "<available_skills>\n"; + + public static final String AVAILABLE_SKILL_TEMPLATE = + "\n<skill>\n<name>%s</name>\n<description>%s</description>\n</skill>\n"; + + public static final String AVAILABLE_SKILLS_TAG_END = "\n</available_skills>\n"; + + private SkillPromptProvider() {} +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillRepository.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillRepository.java new file mode 100644 index 00000000..9d0e48b8 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillRepository.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import javax.annotation.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * Source of skills. Mirrors the Python {@code + * flink_agents.runtime.skill.skill_repository.SkillRepository}. + */ +public interface SkillRepository { + + /** Return the named skill, or {@code null} if not found. */ + @Nullable + AgentSkill getSkill(String name); + + /** Return all skills in the repository (order is implementation-specific). */ + List<AgentSkill> getSkills(); + + /** + * Return the resource map for the named skill β keys are relative paths from the skill root, + * values are the file contents. + */ + Map<String, String> getResources(String name); +} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/FileSystemSkillRepository.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/FileSystemSkillRepository.java new file mode 100644 index 00000000..91e16df4 --- /dev/null +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/FileSystemSkillRepository.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill.repository; + +import org.apache.flink.agents.runtime.skill.AgentSkill; +import org.apache.flink.agents.runtime.skill.SkillParser; +import org.apache.flink.agents.runtime.skill.SkillRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.nio.charset.MalformedInputException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Filesystem-backed {@link SkillRepository}. Each immediate subdirectory of the configured base + * directory that contains a {@code SKILL.md} file is treated as a skill. + * + * <p>Mirrors the Python {@code + * flink_agents.runtime.skill.repository.filesystem_repository.FileSystemSkillRepository}. + */ +public class FileSystemSkillRepository implements SkillRepository { + + private static final Logger LOG = LoggerFactory.getLogger(FileSystemSkillRepository.class); + + public static final String SKILL_MD_FILE = "SKILL.md"; + + private final Path baseDir; + + public FileSystemSkillRepository(Path baseDir) { + if (baseDir == null) { + throw new IllegalArgumentException("Base directory cannot be null"); + } + Path resolved = baseDir.toAbsolutePath().normalize(); + if (!Files.exists(resolved)) { + throw new IllegalArgumentException("Base directory does not exist: " + resolved); + } + if (!Files.isDirectory(resolved)) { + throw new IllegalArgumentException("Base directory is not a directory: " + resolved); + } + this.baseDir = resolved; + } + + public FileSystemSkillRepository(String baseDir) { + this(Path.of(baseDir)); + } + + public Path getBaseDir() { + return baseDir; + } + + @Override + @Nullable + public AgentSkill getSkill(String name) { + Path skillDir = baseDir.resolve(name); + Path skillMd = skillDir.resolve(SKILL_MD_FILE); + if (!Files.exists(skillMd)) { + return null; + } + return loadSkill(skillDir); + } + + @Override + public List<AgentSkill> getSkills() { + List<AgentSkill> skills = new ArrayList<>(); + for (String skillName : listSkillNames()) { + AgentSkill skill = getSkill(skillName); + if (skill != null) { + skills.add(skill); + } + } + return skills; + } + + @Override + public Map<String, String> getResources(String name) { + Path skillDir = baseDir.resolve(name); + if (!Files.isDirectory(skillDir)) { + return Collections.emptyMap(); + } + return loadResources(skillDir); + } + + private List<String> listSkillNames() { + List<String> names = new ArrayList<>(); + try (Stream<Path> entries = Files.list(baseDir)) { + entries.forEach( + entry -> { + if (Files.isDirectory(entry) + && Files.exists(entry.resolve(SKILL_MD_FILE))) { + names.add(entry.getFileName().toString()); + } + }); + } catch (IOException e) { + throw new IllegalStateException("Failed to list skills under " + baseDir, e); + } + names.sort(String::compareTo); + return names; + } + + private AgentSkill loadSkill(Path skillDir) { + Path skillMd = skillDir.resolve(SKILL_MD_FILE); + if (!Files.exists(skillMd)) { + return null; + } + try { + String content = Files.readString(skillMd, StandardCharsets.UTF_8); + AgentSkill skill = SkillParser.parseSkill(content); + if (!skill.getName().equals(skillDir.getFileName().toString())) { + LOG.warn( + "The skill name {} is different from the base directory {}.", + skill.getName(), + skillDir.getFileName()); + } + return skill; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to load skill from " + skillDir, e); + } + } + + private Map<String, String> loadResources(Path skillDir) { + Map<String, String> resources = new HashMap<>(); + try (Stream<Path> walk = Files.walk(skillDir)) { + walk.filter(Files::isRegularFile) + .forEach( + file -> { + if (file.getFileName().toString().equals(SKILL_MD_FILE)) { + return; + } + String rel = skillDir.relativize(file).toString(); + try { + resources.put( + rel, Files.readString(file, StandardCharsets.UTF_8)); + } catch (MalformedInputException mie) { + try { + byte[] bytes = Files.readAllBytes(file); + resources.put( + rel, + "base64: " + + Base64.getEncoder() + .encodeToString(bytes)); + } catch (IOException e) { + LOG.warn( + "Failed to read resource file {} as binary.", + file, + e); + } + } catch (IOException e) { + LOG.warn("Failed to read resource file {}.", file, e); + } + }); + } catch (IOException e) { + throw new IllegalStateException("Failed to walk skill dir " + skillDir, e); + } + return resources; + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/FileSystemSkillRepositoryTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/FileSystemSkillRepositoryTest.java new file mode 100644 index 00000000..b4c3a778 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/FileSystemSkillRepositoryTest.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import org.apache.flink.agents.runtime.skill.repository.FileSystemSkillRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FileSystemSkillRepositoryTest { + + private static Path resourcesRoot() { + return Path.of("src/test/resources/skills").toAbsolutePath(); + } + + @Test + void getSkillsReturnsSortedSkillNames() { + FileSystemSkillRepository repo = new FileSystemSkillRepository(resourcesRoot()); + List<AgentSkill> skills = repo.getSkills(); + assertEquals(2, skills.size()); + assertEquals("github", skills.get(0).getName()); + assertEquals("nano-banana-pro", skills.get(1).getName()); + } + + @Test + void getSkillReturnsNullForUnknown() { + FileSystemSkillRepository repo = new FileSystemSkillRepository(resourcesRoot()); + assertNull(repo.getSkill("does-not-exist")); + } + + @Test + void getResourcesReadsBundledFiles() { + FileSystemSkillRepository repo = new FileSystemSkillRepository(resourcesRoot()); + Map<String, String> resources = repo.getResources("nano-banana-pro"); + assertNotNull(resources); + assertTrue( + resources.containsKey("scripts/generate_image.py"), + "expected scripts/generate_image.py to be loaded as a resource"); + assertTrue(resources.containsKey("_meta.json")); + } + + @Test + void resourceLoaderIsLazy() { + FileSystemSkillRepository repo = new FileSystemSkillRepository(resourcesRoot()); + AgentSkill skill = repo.getSkill("nano-banana-pro"); + assertNotNull(skill); + // resources are not loaded until requested through the loader hook. + skill.setResourceLoader(() -> repo.getResources("nano-banana-pro")); + assertEquals(2, skill.getResourcePaths().size()); + } + + @Test + void missingBaseDirRaises(@TempDir Path tempDir) { + Path missing = tempDir.resolve("missing"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> new FileSystemSkillRepository(missing)); + assertTrue(ex.getMessage().contains("does not exist")); + } + + @Test + void binaryResourceFallsBackToBase64(@TempDir Path tempDir) throws IOException { + Path skillDir = Files.createDirectory(tempDir.resolve("binary-skill")); + Files.writeString( + skillDir.resolve("SKILL.md"), + "---\nname: binary-skill\ndescription: holds a binary resource\n---\n# Body\n", + StandardCharsets.UTF_8); + // Bytes that are not valid UTF-8 (start of a 4-byte sequence with no continuation bytes). + byte[] bad = new byte[] {(byte) 0xF8, (byte) 0x88, (byte) 0x80, (byte) 0x80}; + Files.write(skillDir.resolve("blob.bin"), bad); + + FileSystemSkillRepository repo = new FileSystemSkillRepository(tempDir); + Map<String, String> resources = repo.getResources("binary-skill"); + assertTrue(resources.get("blob.bin").startsWith("base64: ")); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/LoadSkillToolTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/LoadSkillToolTest.java new file mode 100644 index 00000000..f048920d --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/LoadSkillToolTest.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.runtime.skill; + +import org.apache.flink.agents.api.resource.Resource; +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.ToolParameters; +import org.apache.flink.agents.api.tools.ToolResponse; +import org.apache.flink.agents.runtime.resource.ResourceContextImpl; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LoadSkillToolTest { + + private static ResourceContextImpl contextWithSkills() { + Skills skills = + Skills.fromLocalDir( + Path.of("src/test/resources/skills").toAbsolutePath().toString()); + Map<String, Resource> store = new HashMap<>(); + store.put(Skills.SKILLS_CONFIG, skills); + return new ResourceContextImpl( + (name, type) -> { + if (type == ResourceType.SKILLS) { + return store.get(name); + } + return null; + }); + } + + private static LoadSkillTool tool(ResourceContextImpl ctx) { + return new LoadSkillTool( + new ResourceDescriptor(LoadSkillTool.class.getName(), Map.of()), ctx); + } + + private static ToolParameters args(String name, String path) { + Map<String, Object> m = new HashMap<>(); + m.put("name", name); + if (path != null) { + m.put("path", path); + } + return new ToolParameters(m); + } + + @Test + void unknownSkillReturnsAvailableList() { + LoadSkillTool t = tool(contextWithSkills()); + ToolResponse resp = t.call(args("does-not-exist", null)); + String out = (String) resp.getResult(); + assertTrue(out.contains("not found")); + assertTrue(out.contains("github")); + assertTrue(out.contains("nano-banana-pro")); + } + + @Test + void defaultPathReturnsSkillContentEnvelope() { + LoadSkillTool t = tool(contextWithSkills()); + ToolResponse resp = t.call(args("github", null)); + String out = (String) resp.getResult(); + assertTrue(out.startsWith("<skill_content name=\"github\">")); + assertTrue(out.contains("# Skill: github")); + assertTrue(out.contains("Base directory for this skill: ")); + assertTrue(out.contains("</skill_content>")); + } + + @Test + void resourcePathReturnsRawContent() { + LoadSkillTool t = tool(contextWithSkills()); + ToolResponse resp = t.call(args("nano-banana-pro", "scripts/generate_image.py")); + String out = (String) resp.getResult(); + // The script file should be returned verbatim (not wrapped in <skill_content>). + assertTrue(!out.startsWith("<skill_content")); + assertTrue(out.length() > 0); + } + + @Test + void missingResourceReportsAvailable() { + LoadSkillTool t = tool(contextWithSkills()); + ToolResponse resp = t.call(args("nano-banana-pro", "no-such.txt")); + String out = (String) resp.getResult(); + assertTrue(out.contains("Resource 'no-such.txt' not found")); + assertTrue(out.contains("Available resources")); + } + + @Test + void noSkillsRegisteredReturnsFriendlyMessage() { + // Empty resource context β no _skills_config registered. + ResourceContextImpl ctx = new ResourceContextImpl((name, type) -> null); + LoadSkillTool t = tool(ctx); + ToolResponse resp = t.call(args("anything", null)); + assertEquals( + "Skill manager not available. No skills have been registered.", resp.getResult()); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java new file mode 100644 index 00000000..54e8178e --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import org.apache.flink.agents.api.skills.Skills; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillManagerTest { + + private static Skills configFromResources() { + return Skills.fromLocalDir( + Path.of("src/test/resources/skills").toAbsolutePath().toString()); + } + + @Test + void sizeAndAllSkillNames() { + SkillManager manager = new SkillManager(configFromResources()); + assertEquals(2, manager.size()); + assertEquals(List.of("github", "nano-banana-pro"), manager.getAllSkillNames()); + } + + @Test + void getSkillThrowsWithAvailableNames() { + SkillManager manager = new SkillManager(configFromResources()); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> manager.getSkill("missing")); + assertTrue(ex.getMessage().contains("github")); + assertTrue(ex.getMessage().contains("nano-banana-pro")); + } + + @Test + void generateDiscoveryPromptMatchesGoldenFile() throws IOException { + SkillManager manager = new SkillManager(configFromResources()); + String prompt = manager.generateDiscoveryPrompt(List.of("github", "nano-banana-pro")); + String expected = + Files.readString( + Path.of("src/test/resources/skill_discovery_prompt.txt"), + StandardCharsets.UTF_8); + assertEquals(expected, prompt); + } + + @Test + void getSkillDirsEmptyArgumentReturnsAllFsBacked() { + SkillManager manager = new SkillManager(configFromResources()); + List<String> dirs = manager.getSkillDirs(List.of()); + assertEquals(2, dirs.size()); + assertTrue(dirs.get(0).endsWith("github") || dirs.get(0).endsWith("nano-banana-pro")); + } + + @Test + void getSkillDirsReturnsNamedSkillsInOrder() { + SkillManager manager = new SkillManager(configFromResources()); + List<String> dirs = manager.getSkillDirs(List.of("github")); + assertEquals(1, dirs.size()); + assertTrue(dirs.get(0).endsWith("github")); + } + + @Test + void resolveResourcePathLocatesBundledFile() { + SkillManager manager = new SkillManager(configFromResources()); + Path resolved = manager.resolveResourcePath("nano-banana-pro", "scripts/generate_image.py"); + assertNotNull(resolved); + assertTrue(Files.isRegularFile(resolved)); + } +} diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillParserTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillParserTest.java new file mode 100644 index 00000000..270e1374 --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillParserTest.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.agents.runtime.skill; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillParserTest { + + @Test + void parseMarkdownSplitsFrontmatterFromBody() { + String md = + "---\n" + + "name: my-skill\n" + + "description: Does X\n" + + "---\n" + + "# Body\n" + + "Some markdown.\n"; + SkillParser.ParsedMarkdown parsed = SkillParser.parseMarkdown(md); + assertEquals("my-skill", parsed.metadata.get("name")); + assertEquals("Does X", parsed.metadata.get("description")); + assertTrue(parsed.content.startsWith("# Body")); + } + + @Test + void parseMarkdownNoFrontmatterReturnsRawContent() { + String md = "# Just a body\nNo frontmatter."; + SkillParser.ParsedMarkdown parsed = SkillParser.parseMarkdown(md); + assertTrue(parsed.metadata.isEmpty()); + assertEquals(md, parsed.content); + } + + @Test + void parseMarkdownHandlesCrlfLineEndings() { + String md = "---\r\nname: x\r\ndescription: y\r\n---\r\nBody\r\n"; + SkillParser.ParsedMarkdown parsed = SkillParser.parseMarkdown(md); + assertEquals("x", parsed.metadata.get("name")); + assertTrue(parsed.content.contains("Body")); + } + + @Test + void parseSkillRequiresName() { + String md = "---\ndescription: y\n---\nBody\n"; + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> SkillParser.parseSkill(md)); + assertTrue(ex.getMessage().contains("name")); + } + + @Test + void parseSkillRequiresDescription() { + String md = "---\nname: x\n---\nBody\n"; + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> SkillParser.parseSkill(md)); + assertTrue(ex.getMessage().contains("description")); + } + + @Test + void parseSkillRequiresBody() { + String md = "---\nname: x\ndescription: y\n---\n"; + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> SkillParser.parseSkill(md)); + assertTrue(ex.getMessage().contains("markdown content")); + } + + @Test + void parseSkillSurfacesYamlSyntaxError() { + String md = "---\nname: x\n bad-indent: : :\n---\nBody\n"; + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> SkillParser.parseSkill(md)); + assertTrue(ex.getMessage().startsWith("Invalid YAML frontmatter syntax")); + } + + @Test + void parseSkillTrimsNameAndDescription() { + String md = + "---\nname: \" trimmed-name \"\ndescription: \" trimmed desc \"\n---\nBody\n"; + AgentSkill skill = SkillParser.parseSkill(md); + assertEquals("trimmed-name", skill.getName()); + assertEquals("trimmed desc", skill.getDescription()); + } + + @Test + void parseSkillCarriesOptionalFields() { + String md = + "---\n" + + "name: test\n" + + "description: Does X\n" + + "license: Apache-2.0\n" + + "compatibility: macOS, Linux\n" + + "metadata:\n" + + " author: alice\n" + + " version: \"1.2\"\n" + + "---\n" + + "# Body\n"; + AgentSkill skill = SkillParser.parseSkill(md); + assertEquals("Apache-2.0", skill.getLicense()); + assertEquals("macOS, Linux", skill.getCompatibility()); + assertNotNull(skill.getMetadata()); + assertEquals("alice", skill.getMetadata().get("author")); + assertEquals("1.2", skill.getMetadata().get("version")); + } +} diff --git a/runtime/src/test/resources/skill_discovery_prompt.txt b/runtime/src/test/resources/skill_discovery_prompt.txt new file mode 100644 index 00000000..11d2e6ac --- /dev/null +++ b/runtime/src/test/resources/skill_discovery_prompt.txt @@ -0,0 +1,24 @@ +## Available Skills + +<usage> +Skills provide specialized capabilities and domain knowledge. Use them when they match your current task. + +Load a skill with `load_skill(name="<skill-name>")` to get its full instructions. +Individual resources (scripts, references, assets) can be loaded with a `path` argument. + +The loaded content includes the skill's base directory and the absolute paths of its resources. +</usage> + +<available_skills> + +<skill> +<name>github</name> +<description>Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.</description> +</skill> + +<skill> +<name>nano-banana-pro</name> +<description>Generate/edit images with Nano Banana Pro (Gemini 3 Pro Image). Use for image create/modify requests incl. edits. Supports text-to-image + image-to-image; 1K/2K/4K; use --input-image.</description> +</skill> + +</available_skills> diff --git a/runtime/src/test/resources/skills/github/SKILL.md b/runtime/src/test/resources/skills/github/SKILL.md new file mode 100644 index 00000000..2c9356ce --- /dev/null +++ b/runtime/src/test/resources/skills/github/SKILL.md @@ -0,0 +1,47 @@ +--- +name: github +description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries." +--- + +# GitHub Skill + +Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly. + +## Pull Requests + +Check CI status on a PR: +```bash +gh pr checks 55 --repo owner/repo +``` + +List recent workflow runs: +```bash +gh run list --repo owner/repo --limit 10 +``` + +View a run and see which steps failed: +```bash +gh run view <run-id> --repo owner/repo +``` + +View logs for failed steps only: +```bash +gh run view <run-id> --repo owner/repo --log-failed +``` + +## API for Advanced Queries + +The `gh api` command is useful for accessing data not available through other subcommands. + +Get PR with specific fields: +```bash +gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login' +``` + +## JSON Output + +Most commands support `--json` for structured output. You can use `--jq` to filter: + +```bash +gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' +``` \ No newline at end of file diff --git a/runtime/src/test/resources/skills/nano-banana-pro/SKILL.md b/runtime/src/test/resources/skills/nano-banana-pro/SKILL.md new file mode 100644 index 00000000..711ee3ff --- /dev/null +++ b/runtime/src/test/resources/skills/nano-banana-pro/SKILL.md @@ -0,0 +1,130 @@ +--- +name: nano-banana-pro +description: Generate/edit images with Nano Banana Pro (Gemini 3 Pro Image). Use for image create/modify requests incl. edits. Supports text-to-image + image-to-image; 1K/2K/4K; use --input-image. +--- + +# Nano Banana Pro Image Generation & Editing + +Generate new images or edit existing ones using Google's Nano Banana Pro API (Gemini 3 Pro Image). + +## Usage + +Run the script using absolute path (do NOT cd to skill directory first): + +**Generate new image:** +```bash +uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "your image description" --filename "output-name.png" [--resolution 1K|2K|4K] [--api-key KEY] +``` + +**Edit existing image:** +```bash +uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "editing instructions" --filename "output-name.png" --input-image "path/to/input.png" [--resolution 1K|2K|4K] [--api-key KEY] +``` + +**Important:** Always run from the user's current working directory so images are saved where the user is working, not in the skill directory. + +## Default Workflow (draft β iterate β final) + +Goal: fast iteration without burning time on 4K until the prompt is correct. + +- Draft (1K): quick feedback loop + - `uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "<draft prompt>" --filename "yyyy-mm-dd-hh-mm-ss-draft.png" --resolution 1K` +- Iterate: adjust prompt in small diffs; keep filename new per run + - If editing: keep the same `--input-image` for every iteration until youβre happy. +- Final (4K): only when prompt is locked + - `uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "<final prompt>" --filename "yyyy-mm-dd-hh-mm-ss-final.png" --resolution 4K` + +## Resolution Options + +The Gemini 3 Pro Image API supports three resolutions (uppercase K required): + +- **1K** (default) - ~1024px resolution +- **2K** - ~2048px resolution +- **4K** - ~4096px resolution + +Map user requests to API parameters: +- No mention of resolution β `1K` +- "low resolution", "1080", "1080p", "1K" β `1K` +- "2K", "2048", "normal", "medium resolution" β `2K` +- "high resolution", "high-res", "hi-res", "4K", "ultra" β `4K` + +## API Key + +The script checks for API key in this order: +1. `--api-key` argument (use if user provided key in chat) +2. `GEMINI_API_KEY` environment variable + +If neither is available, the script exits with an error message. + +## Preflight + Common Failures (fast fixes) + +- Preflight: + - `command -v uv` (must exist) + - `test -n \"$GEMINI_API_KEY\"` (or pass `--api-key`) + - If editing: `test -f \"path/to/input.png\"` + +- Common failures: + - `Error: No API key provided.` β set `GEMINI_API_KEY` or pass `--api-key` + - `Error loading input image:` β wrong path / unreadable file; verify `--input-image` points to a real image + - βquota/permission/403β style API errors β wrong key, no access, or quota exceeded; try a different key/account + +## Filename Generation + +Generate filenames with the pattern: `yyyy-mm-dd-hh-mm-ss-name.png` + +**Format:** `{timestamp}-{descriptive-name}.png` +- Timestamp: Current date/time in format `yyyy-mm-dd-hh-mm-ss` (24-hour format) +- Name: Descriptive lowercase text with hyphens +- Keep the descriptive part concise (1-5 words typically) +- Use context from user's prompt or conversation +- If unclear, use random identifier (e.g., `x9k2`, `a7b3`) + +Examples: +- Prompt "A serene Japanese garden" β `2025-11-23-14-23-05-japanese-garden.png` +- Prompt "sunset over mountains" β `2025-11-23-15-30-12-sunset-mountains.png` +- Prompt "create an image of a robot" β `2025-11-23-16-45-33-robot.png` +- Unclear context β `2025-11-23-17-12-48-x9k2.png` + +## Image Editing + +When the user wants to modify an existing image: +1. Check if they provide an image path or reference an image in the current directory +2. Use `--input-image` parameter with the path to the image +3. The prompt should contain editing instructions (e.g., "make the sky more dramatic", "remove the person", "change to cartoon style") +4. Common editing tasks: add/remove elements, change style, adjust colors, blur background, etc. + +## Prompt Handling + +**For generation:** Pass user's image description as-is to `--prompt`. Only rework if clearly insufficient. + +**For editing:** Pass editing instructions in `--prompt` (e.g., "add a rainbow in the sky", "make it look like a watercolor painting") + +Preserve user's creative intent in both cases. + +## Prompt Templates (high hit-rate) + +Use templates when the user is vague or when edits must be precise. + +- Generation template: + - βCreate an image of: <subject>. Style: <style>. Composition: <camera/shot>. Lighting: <lighting>. Background: <background>. Color palette: <palette>. Avoid: <list>.β + +- Editing template (preserve everything else): + - βChange ONLY: <single change>. Keep identical: subject, composition/crop, pose, lighting, color palette, background, text, and overall style. Do not add new objects. If text exists, keep it unchanged.β + +## Output + +- Saves PNG to current directory (or specified path if filename includes directory) +- Script outputs the full path to the generated image +- **Do not read the image back** - just inform the user of the saved path + +## Examples + +**Generate new image:** +```bash +uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "A serene Japanese garden with cherry blossoms" --filename "2025-11-23-14-23-05-japanese-garden.png" --resolution 4K +``` + +**Edit existing image:** +```bash +uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "make the sky more dramatic with storm clouds" --filename "2025-11-23-14-25-30-dramatic-sky.png" --input-image "original-photo.jpg" --resolution 2K +``` diff --git a/runtime/src/test/resources/skills/nano-banana-pro/_meta.json b/runtime/src/test/resources/skills/nano-banana-pro/_meta.json new file mode 100644 index 00000000..9d100c1d --- /dev/null +++ b/runtime/src/test/resources/skills/nano-banana-pro/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26", + "slug": "nano-banana-pro", + "version": "1.0.1", + "publishedAt": 1767651987917 +} \ No newline at end of file diff --git a/runtime/src/test/resources/skills/nano-banana-pro/scripts/generate_image.py b/runtime/src/test/resources/skills/nano-banana-pro/scripts/generate_image.py new file mode 100644 index 00000000..84176c3e --- /dev/null +++ b/runtime/src/test/resources/skills/nano-banana-pro/scripts/generate_image.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "google-genai>=1.0.0", +# "pillow>=10.0.0", +# ] +# /// +"""Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. + +Usage: + uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] +""" + +import argparse +import os +import sys +from pathlib import Path + + +def get_api_key(provided_key: str | None) -> str | None: + """Get API key from argument first, then environment.""" + if provided_key: + return provided_key + return os.environ.get("GEMINI_API_KEY") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" + ) + parser.add_argument( + "--prompt", "-p", required=True, help="Image description/prompt" + ) + parser.add_argument( + "--filename", + "-f", + required=True, + help="Output filename (e.g., sunset-mountains.png)", + ) + parser.add_argument( + "--input-image", "-i", help="Optional input image path for editing/modification" + ) + parser.add_argument( + "--resolution", + "-r", + choices=["1K", "2K", "4K"], + default="1K", + help="Output resolution: 1K (default), 2K, or 4K", + ) + parser.add_argument( + "--api-key", "-k", help="Gemini API key (overrides GEMINI_API_KEY env var)" + ) + + args = parser.parse_args() + + # Get API key + api_key = get_api_key(args.api_key) + if not api_key: + print("Error: No API key provided.", file=sys.stderr) + print("Please either:", file=sys.stderr) + print(" 1. Provide --api-key argument", file=sys.stderr) + print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr) + sys.exit(1) + + # Import here after checking API key to avoid slow import on error + from google import genai + from google.genai import types + from PIL import Image as PILImage + + # Initialise client + client = genai.Client(api_key=api_key) + + # Set up output path + output_path = Path(args.filename) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Load input image if provided + input_image = None + output_resolution = args.resolution + if args.input_image: + try: + input_image = PILImage.open(args.input_image) + print(f"Loaded input image: {args.input_image}") + + # Auto-detect resolution if not explicitly set by user + if args.resolution == "1K": # Default value + # Map input image size to resolution + width, height = input_image.size + max_dim = max(width, height) + if max_dim >= 3000: + output_resolution = "4K" + elif max_dim >= 1500: + output_resolution = "2K" + else: + output_resolution = "1K" + print( + f"Auto-detected resolution: {output_resolution} (from input {width}x{height})" + ) + except Exception as e: + print(f"Error loading input image: {e}", file=sys.stderr) + sys.exit(1) + + # Build contents (image first if editing, prompt only if generating) + if input_image: + contents = [input_image, args.prompt] + print(f"Editing image with resolution {output_resolution}...") + else: + contents = args.prompt + print(f"Generating image with resolution {output_resolution}...") + + try: + response = client.models.generate_content( + model="gemini-3-pro-image-preview", + contents=contents, + config=types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig(image_size=output_resolution), + ), + ) + + # Process response and convert to PNG + image_saved = False + for part in response.parts: + if part.text is not None: + print(f"Model response: {part.text}") + elif part.inline_data is not None: + # Convert inline data to PIL Image and save as PNG + from io import BytesIO + + # inline_data.data is already bytes, not base64 + image_data = part.inline_data.data + if isinstance(image_data, str): + # If it's a string, it might be base64 + import base64 + + image_data = base64.b64decode(image_data) + + image = PILImage.open(BytesIO(image_data)) + + # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed) + if image.mode == "RGBA": + rgb_image = PILImage.new("RGB", image.size, (255, 255, 255)) + rgb_image.paste(image, mask=image.split()[3]) + rgb_image.save(str(output_path), "PNG") + elif image.mode == "RGB": + image.save(str(output_path), "PNG") + else: + image.convert("RGB").save(str(output_path), "PNG") + image_saved = True + + if image_saved: + full_path = output_path.resolve() + print(f"\nImage saved: {full_path}") + else: + print("Error: No image was generated in the response.", file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"Error generating image: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()
