From a3bf935c5c8befe738ad49390eef20484864dfcd Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Sun, 10 May 2026 21:53:06 +0800 Subject: [PATCH] feat(agent): track loaded skills --- .../agent/skill/SkillTrackingHook.java | 86 ++++++++++ .../ReActAgentSkillTrackingHookTest.java | 151 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillTrackingHookTest.java diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java new file mode 100644 index 000000000..b521fef36 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java @@ -0,0 +1,86 @@ +package com.yomahub.liteflow.agent.skill; + +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Tracks skills loaded by agentscope's skill-loading tool during a ReAct session. + */ +public class SkillTrackingHook implements Hook { + + public static final String LOAD_SKILL_TOOL_NAME = "load_skill_through_path"; + private static final String SKILL_ID_INPUT_KEY = "skillId"; + + private final Map skillIdToName; + private final Set usedSkills = Collections.synchronizedSet(new LinkedHashSet<>()); + + public SkillTrackingHook(Map skillIdToName) { + this.skillIdToName = skillIdToName == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName)); + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PostActingEvent postActingEvent) { + recordSkillLoad(postActingEvent.getToolUse(), postActingEvent.getToolResult()); + } + return Mono.just(event); + } + + public List getUsedSkills() { + synchronized (usedSkills) { + return List.copyOf(usedSkills); + } + } + + public void clear() { + usedSkills.clear(); + } + + private void recordSkillLoad(ToolUseBlock toolUse, ToolResultBlock toolResult) { + if (toolUse == null || !LOAD_SKILL_TOOL_NAME.equals(toolUse.getName()) || isErrorResult(toolResult)) { + return; + } + Map input = toolUse.getInput(); + if (input == null || !input.containsKey(SKILL_ID_INPUT_KEY)) { + return; + } + Object skillId = input.get(SKILL_ID_INPUT_KEY); + if (skillId == null) { + return; + } + String skillName = skillIdToName.get(String.valueOf(skillId)); + if (skillName != null) { + usedSkills.add(skillName); + } + } + + private boolean isErrorResult(ToolResultBlock toolResult) { + if (toolResult == null || toolResult.getOutput() == null) { + return false; + } + for (ContentBlock block : toolResult.getOutput()) { + if (block instanceof TextBlock textBlock) { + String text = textBlock.getText(); + if (text != null && text.startsWith("Error:")) { + return true; + } + } + } + return false; + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillTrackingHookTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillTrackingHookTest.java new file mode 100644 index 000000000..8c5ee35ee --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillTrackingHookTest.java @@ -0,0 +1,151 @@ +package com.yomahub.liteflow.test.agent; + +import com.yomahub.liteflow.agent.skill.SkillTrackingHook; +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.Toolkit; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class ReActAgentSkillTrackingHookTest { + + private static final Agent TEST_AGENT = new Agent() { + @Override + public String getAgentId() { + return "test-agent-id"; + } + + @Override + public String getName() { + return "test-agent"; + } + + @Override + public void interrupt() { + } + + @Override + public void interrupt(Msg msg) { + } + + @Override + public Mono call(List messages) { + return Mono.empty(); + } + + @Override + public Mono call(List messages, Class responseType) { + return Mono.empty(); + } + + @Override + public Mono call(List messages, JsonNode schema) { + return Mono.empty(); + } + + @Override + public Flux stream(List messages, StreamOptions options) { + return Flux.empty(); + } + + @Override + public Flux stream(List messages, StreamOptions options, Class responseType) { + return Flux.empty(); + } + + @Override + public Flux stream(List messages, StreamOptions options, JsonNode schema) { + return Flux.empty(); + } + + @Override + public Mono observe(Msg msg) { + return Mono.empty(); + } + + @Override + public Mono observe(List messages) { + return Mono.empty(); + } + }; + + @Test + public void testTracksLoadSkillToolUseByMappedSkillName() { + SkillTrackingHook hook = new SkillTrackingHook(new LinkedHashMap<>(Map.of("skill-1", "demo"))); + + hook.onEvent(postActingEvent(SkillTrackingHook.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "skill-1"))).block(); + + Assertions.assertEquals(List.of("demo"), hook.getUsedSkills()); + } + + @Test + public void testDeduplicatesAndClearsUsedSkills() { + Map skillIdToName = new LinkedHashMap<>(); + skillIdToName.put("skill-1", "demo"); + skillIdToName.put("skill-2", "research"); + SkillTrackingHook hook = new SkillTrackingHook(skillIdToName); + + hook.onEvent(postActingEvent(SkillTrackingHook.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "skill-1"))).block(); + hook.onEvent(postActingEvent(SkillTrackingHook.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "skill-1"))).block(); + hook.onEvent(postActingEvent(SkillTrackingHook.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "skill-2"))).block(); + + Assertions.assertEquals(List.of("demo", "research"), hook.getUsedSkills()); + + hook.clear(); + + Assertions.assertEquals(List.of(), hook.getUsedSkills()); + } + + @Test + public void testIgnoresNonSkillTools() { + SkillTrackingHook hook = new SkillTrackingHook(Map.of("skill-1", "demo")); + + hook.onEvent(postActingEvent("search", Map.of("skillId", "skill-1"))).block(); + + Assertions.assertEquals(List.of(), hook.getUsedSkills()); + } + + @Test + public void testIgnoresUnknownSkillId() { + SkillTrackingHook hook = new SkillTrackingHook(Map.of("skill-1", "demo")); + + Assertions.assertDoesNotThrow(() -> + hook.onEvent(postActingEvent(SkillTrackingHook.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "unknown-skill"))).block()); + + Assertions.assertEquals(List.of(), hook.getUsedSkills()); + } + + @Test + public void testIgnoresErrorResultForKnownSkillId() { + SkillTrackingHook hook = new SkillTrackingHook(Map.of("skill-1", "demo")); + + hook.onEvent(postActingEvent( + SkillTrackingHook.LOAD_SKILL_TOOL_NAME, + Map.of("skillId", "skill-1"), + ToolResultBlock.error("failed to load skill"))).block(); + + Assertions.assertEquals(List.of(), hook.getUsedSkills()); + } + + private static PostActingEvent postActingEvent(String toolName, Map input) { + return postActingEvent(toolName, input, null); + } + + private static PostActingEvent postActingEvent( + String toolName, Map input, ToolResultBlock toolResult) { + ToolUseBlock toolUse = new ToolUseBlock("tool-call-1", toolName, input); + return new PostActingEvent(TEST_AGENT, new Toolkit(), toolUse, toolResult); + } +}