diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java index 9e3a0791e..dfa28bf38 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java @@ -2,9 +2,11 @@ package com.yomahub.liteflow.agent.component; import com.yomahub.liteflow.agent.exception.AgentConfigException; import com.yomahub.liteflow.agent.hook.ReActLoggingHook; +import com.yomahub.liteflow.agent.skill.SkillBoxFactory; +import com.yomahub.liteflow.agent.skill.SkillLoadResult; +import com.yomahub.liteflow.agent.skill.SkillTrackingHook; import com.yomahub.liteflow.agent.session.AgentSession; import com.yomahub.liteflow.agent.session.AgentSessionManager; -import com.yomahub.liteflow.util.ConversationIdGenerator; import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool; import com.yomahub.liteflow.agent.tool.WorkspaceFileTools; import com.yomahub.liteflow.core.NodeComponent; @@ -13,12 +15,14 @@ import com.yomahub.liteflow.property.agent.AgentConfig; import com.yomahub.liteflow.property.agent.MemoryStorageConfig; import com.yomahub.liteflow.property.agent.ShellMode; import com.yomahub.liteflow.slot.Slot; +import com.yomahub.liteflow.util.ConversationIdGenerator; import io.agentscope.core.ReActAgent; import io.agentscope.core.hook.Hook; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import com.yomahub.liteflow.agent.model.ModelSpec; import io.agentscope.core.model.Model; +import io.agentscope.core.skill.SkillBox; import io.agentscope.core.tool.Toolkit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +55,11 @@ import java.util.Map; * 会在下一次 {@code process()} 时变成陈旧引用(其中的 slot 已经被回收)。 * 正确做法:持有组件实例引用,运行时通过 {@code component.ctx()} 动态获取。 * + *

技能相关的 {@link #skills()} 与 {@link #enableSkills()} 只在为某个 + * {@code (conversationId, agentKey)} session 首次构建并缓存 ReActAgent 时求值。 + * 它们表示组件能力声明,不应依赖单次请求数据;同一 session 复用缓存 agent 时不会 + * 重新读取这些声明。 + * *

{@link #process()} 方法被声明为 {@code final},由框架统一保证 session * 管理和 ctx 生命周期的正确性。 */ @@ -64,11 +73,19 @@ public abstract class ReActAgentComponent extends NodeComponent { /** 在 Slot attachment 上存储 ctx 时使用的 key 前缀,按 nodeId 隔离。 */ private static final String CTX_KEY_PREFIX = "_react_agent_ctx_"; + /** 在 Slot attachment 上存储技能跟踪 Hook 时使用的 key 前缀,按 nodeId 隔离。 */ + private static final String SKILL_HOOK_KEY_PREFIX = "_react_agent_skill_hook_"; + private String ctxKey() { String nodeId = getNodeId(); return CTX_KEY_PREFIX + (nodeId == null ? "default" : nodeId); } + private String skillHookKey() { + String nodeId = getNodeId(); + return SKILL_HOOK_KEY_PREFIX + (nodeId == null ? "default" : nodeId); + } + /* ===== 框架提供的 final 访问器 ===== */ /** @@ -134,6 +151,37 @@ public abstract class ReActAgentComponent extends NodeComponent { */ protected List tools() { return List.of(); } + /** + * Return skill names this component may use. Empty means all configured skills. + * + *

This is evaluated only when the cached ReActAgent is built for a + * {@code (conversationId, agentKey)} session. Treat it as a stable component + * capability declaration; do not vary it per request. + */ + protected List skills() { return List.of(); } + + /** + * Whether agent-scope skills should be enabled for this component. + * + *

This is evaluated only when the cached ReActAgent is built for a + * {@code (conversationId, agentKey)} session. Treat it as a stable component + * capability declaration; do not vary it per request. + */ + protected boolean enableSkills() { return agentConfig().getSkills().isEnabled(); } + + /** + * Return skill names loaded by this agent during the current invocation. + * + *

This is available only while this component's {@link #process()} body has + * bound the invocation skill hook, including calls from {@link #userPrompt()}, + * tool callbacks, and {@link #handleReply(Msg)}. After {@code process()} final + * cleanup, later lifecycle callbacks must not rely on it. + */ + protected final List usedSkills() { + SkillTrackingHook hook = getSlot().getAttachment(skillHookKey()); + return hook == null ? List.of() : hook.getUsedSkills(); + } + /** * 解析本次执行的 {@code conversationId}。 * @@ -204,10 +252,17 @@ public abstract class ReActAgentComponent extends NodeComponent { try { ReActAgent agent = (ReActAgent) session.getAgent(); if (agent == null) { - agent = buildAgent(); + BuiltAgent built = buildAgent(); + agent = built.agent(); + session.setSkillTrackingHook(built.skillTrackingHook()); mgr.loadIfExists(session, agent); session.setAgent(agent); } + SkillTrackingHook skillHook = session.getSkillTrackingHook(); + if (skillHook != null) { + skillHook.clear(); + slot.setAttachment(skillHookKey(), skillHook); + } Throwable processError = null; try { Msg userMsg = Msg.builder().textContent(userPrompt()).build(); @@ -233,13 +288,17 @@ public abstract class ReActAgentComponent extends NodeComponent { } } finally { slot.removeAttachment(ctxKey()); + slot.removeAttachment(skillHookKey()); } } finally { session.getLock().unlock(); } } - private ReActAgent buildAgent() { + private record BuiltAgent(ReActAgent agent, SkillTrackingHook skillTrackingHook) { + } + + private BuiltAgent buildAgent() { AgentConfig cfg = agentConfig(); int iters = maxIterations() > 0 ? maxIterations() : cfg.getDefaults().getMaxIterations(); ReActAgentContext ctx = ctx(); @@ -258,15 +317,29 @@ public abstract class ReActAgentComponent extends NodeComponent { allHooks.add(new ReActLoggingHook(ctx.getConversationId() + ":" + ctx.getAgentKey())); } - return ReActAgent.builder() + SkillTrackingHook skillTrackingHook = null; + SkillBox skillBox = null; + if (enableSkills()) { + SkillLoadResult skillLoadResult = SkillBoxFactory.build(toolkit, cfg, skills()); + skillBox = skillLoadResult.skillBox(); + skillTrackingHook = new SkillTrackingHook(skillLoadResult.skillIdToName()); + allHooks.add(skillTrackingHook); + } + + ReActAgent.Builder builder = ReActAgent.builder() .name(getNodeId() == null ? "liteflow-agent" : getNodeId()) .sysPrompt(systemPrompt()) .model(buildModel()) .toolkit(toolkit) .memory(new InMemoryMemory()) .maxIters(iters) - .hooks(allHooks) - .build(); + .hooks(allHooks); + + if (skillBox != null) { + builder.skillBox(skillBox); + } + + return new BuiltAgent(builder.build(), skillTrackingHook); } /** 持有单例 AgentSessionManager;首次 process() 时懒创建。 */ diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java index f011233b8..27cf1e87a 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java @@ -1,5 +1,7 @@ package com.yomahub.liteflow.agent.session; +import com.yomahub.liteflow.agent.skill.SkillTrackingHook; + import java.nio.file.Path; import java.time.Instant; import java.util.Objects; @@ -27,6 +29,7 @@ public class AgentSession { private final Path workspaceDir; private final ReentrantLock lock = new ReentrantLock(); private volatile Object agent; + private volatile SkillTrackingHook skillTrackingHook; private volatile Instant lastActive = Instant.now(); public AgentSession(String conversationId, String agentKey, String cacheKey, Path workspaceDir) { @@ -67,6 +70,14 @@ public class AgentSession { this.agent = agent; } + public SkillTrackingHook getSkillTrackingHook() { + return skillTrackingHook; + } + + public void setSkillTrackingHook(SkillTrackingHook skillTrackingHook) { + this.skillTrackingHook = skillTrackingHook; + } + public Instant getLastActive() { return lastActive; } diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/AbstractReActAgentSpringbootTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/AbstractReActAgentSpringbootTest.java index afb975fa9..572852044 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/AbstractReActAgentSpringbootTest.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/AbstractReActAgentSpringbootTest.java @@ -61,6 +61,9 @@ public abstract class AbstractReActAgentSpringbootTest { agentConfig.getShell().setMode(ShellMode.BLACKLIST); agentConfig.getDefaults().setMaxIterations(20); agentConfig.getLogging().setReactEnabled(true); + agentConfig.getSkills().setEnabled(false); + agentConfig.getSkills().setPath("src/test/resources/agent/skills"); + agentConfig.getSkills().setStrict(true); } /** diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillIntegrationTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillIntegrationTest.java new file mode 100644 index 000000000..af41af573 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/ReActAgentSkillIntegrationTest.java @@ -0,0 +1,108 @@ +package com.yomahub.liteflow.test.agent; + +import com.yomahub.liteflow.flow.LiteflowResponse; +import com.yomahub.liteflow.test.agent.cmp.StubReActAgentCmp; +import com.yomahub.liteflow.test.agent.tool.SkillEchoTool; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class ReActAgentSkillIntegrationTest extends AbstractReActAgentSpringbootTest { + + @Test + public void testSkillsDisabledKeepsExistingToolSet() { + liteflowConfig.getAgent().getSkills().setEnabled(false); + + LiteflowResponse response = flowExecutor.execute2Resp("stubAgentChain", "tools"); + + Assertions.assertTrue(response.isSuccess()); + List toolNames = StubReActAgentCmp.MODEL_PROBES.get(0).toolNames(); + Assertions.assertFalse(toolNames.contains("load_skill_through_path")); + } + + @Test + public void testSkillsEnabledAddsSkillLoadTool() { + enableTestSkills(); + + LiteflowResponse response = flowExecutor.execute2Resp("stubAgentChain", "skills"); + + Assertions.assertTrue(response.isSuccess()); + List toolNames = StubReActAgentCmp.MODEL_PROBES.get(0).toolNames(); + Assertions.assertTrue(toolNames.contains("load_skill_through_path")); + } + + @Test + public void testComponentSkillAllowListStillBuildsAgent() { + enableTestSkills(); + StubReActAgentCmp.allowedSkills = List.of("demo"); + + LiteflowResponse response = flowExecutor.execute2Resp("stubAgentChain", "filtered-skills"); + + Assertions.assertTrue(response.isSuccess()); + List toolNames = StubReActAgentCmp.MODEL_PROBES.get(0).toolNames(); + Assertions.assertTrue(toolNames.contains("load_skill_through_path")); + } + + @Test + public void testMissingComponentSkillFailsInStrictMode() { + enableTestSkills(); + StubReActAgentCmp.allowedSkills = List.of("missing-skill"); + + LiteflowResponse response = flowExecutor.execute2Resp("stubAgentChain", "missing-skill"); + + Assertions.assertFalse(response.isSuccess()); + Assertions.assertTrue(response.getMessage().contains("missing-skill")); + } + + @Test + public void testSkillFrontmatterToolIsInstantiatedDuringAgentBuild() { + enableTestSkills(); + StubReActAgentCmp.allowedSkills = List.of("tool-skill"); + SkillEchoTool.reset(); + + LiteflowResponse response = flowExecutor.execute2Resp("stubAgentChain", "tool-skill"); + + Assertions.assertTrue(response.isSuccess()); + Assertions.assertEquals(1, SkillEchoTool.CONSTRUCT_COUNT.get()); + } + + @Test + public void testCachedAgentKeepsInitialSkillAllowList() { + enableTestSkills(); + StubReActAgentCmp.allowedSkills = List.of("demo"); + + LiteflowResponse first = flowExecutor.execute2Resp("stubAgentChain", "cache-skills-first"); + + Assertions.assertTrue(first.isSuccess()); + Assertions.assertEquals(1, StubReActAgentCmp.SPEC_RESOLVE_COUNT.get()); + + StubReActAgentCmp.allowedSkills = List.of("missing-skill"); + LiteflowResponse second = flowExecutor.execute2Resp("stubAgentChain", "cache-skills-second"); + + Assertions.assertTrue(second.isSuccess()); + Assertions.assertEquals(1, StubReActAgentCmp.SPEC_RESOLVE_COUNT.get()); + } + + @Test + public void testUsedSkillsTracksInvocationAndClearsForCachedAgent() { + enableTestSkills(); + StubReActAgentCmp.allowedSkills = List.of("demo"); + + LiteflowResponse first = flowExecutor.execute2Resp("stubAgentChain", "load-demo-skill"); + + Assertions.assertTrue(first.isSuccess()); + Assertions.assertEquals(List.of("demo"), StubReActAgentCmp.USED_SKILL_PROBES.get(0)); + + LiteflowResponse second = flowExecutor.execute2Resp("stubAgentChain", "no-skill-load"); + + Assertions.assertTrue(second.isSuccess()); + Assertions.assertEquals(List.of(), StubReActAgentCmp.USED_SKILL_PROBES.get(1)); + } + + private void enableTestSkills() { + liteflowConfig.getAgent().getSkills().setEnabled(true); + liteflowConfig.getAgent().getSkills().setPath("src/test/resources/agent/skills"); + liteflowConfig.getAgent().getSkills().setStrict(true); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/StubReActAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/StubReActAgentCmp.java index b83ec2558..c1f35c247 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/StubReActAgentCmp.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/cmp/StubReActAgentCmp.java @@ -8,6 +8,7 @@ import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.HookEvent; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; @@ -20,6 +21,7 @@ import reactor.core.publisher.Mono; import java.nio.file.Files; import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; @@ -37,6 +39,8 @@ public class StubReActAgentCmp extends ReActAgentComponent { public static final List MAX_ITERATIONS_SEEN = new CopyOnWriteArrayList<>(); public static final List USER_PROMPTS = new CopyOnWriteArrayList<>(); public static final List MODEL_PROBES = new CopyOnWriteArrayList<>(); + public static volatile List allowedSkills = List.of(); + public static final List> USED_SKILL_PROBES = new CopyOnWriteArrayList<>(); public static volatile boolean shellToolEnabled = true; public static volatile boolean workspaceFileToolsEnabled = true; public static volatile boolean customHandleReply = false; @@ -53,6 +57,8 @@ public class StubReActAgentCmp extends ReActAgentComponent { MAX_ITERATIONS_SEEN.clear(); USER_PROMPTS.clear(); MODEL_PROBES.clear(); + allowedSkills = List.of(); + USED_SKILL_PROBES.clear(); shellToolEnabled = true; workspaceFileToolsEnabled = true; customHandleReply = false; @@ -85,6 +91,11 @@ public class StubReActAgentCmp extends ReActAgentComponent { return List.of(new EchoTool()); } + @Override + protected List skills() { + return allowedSkills; + } + @Override protected String resolveConversationId() { return FIXED_CONVERSATION_ID; @@ -126,6 +137,7 @@ public class StubReActAgentCmp extends ReActAgentComponent { @Override protected void handleReply(Msg reply) { + USED_SKILL_PROBES.add(usedSkills()); HANDLE_REPLY_COUNT.incrementAndGet(); if (customHandleReply) { ctx().getSlot().setResponseData("handled:" + (reply == null ? null : reply.getTextContent())); @@ -152,6 +164,10 @@ public class StubReActAgentCmp extends ReActAgentComponent { public static class StubModel implements Model { private final StubReActAgentCmp comp; private final AtomicInteger callCount = new AtomicInteger(); + private volatile String lastConversationId; + private volatile String lastAgentKey; + private volatile String lastWorkspaceDir; + private volatile boolean lastWorkspaceExists; StubModel(StubReActAgentCmp comp) { this.comp = comp; @@ -159,21 +175,45 @@ public class StubReActAgentCmp extends ReActAgentComponent { @Override public Flux stream(List messages, List toolSchemas, GenerateOptions options) { - var ctx = comp.ctx(); + try { + var ctx = comp.ctx(); + lastConversationId = ctx.getConversationId(); + lastAgentKey = ctx.getAgentKey(); + lastWorkspaceDir = ctx.getWorkspaceDir().toString(); + lastWorkspaceExists = Files.isDirectory(ctx.getWorkspaceDir()); + } catch (IllegalStateException | NullPointerException ignored) { + // Agentscope may call the model again from a worker thread after tool execution. + // The test model reuses the invocation metadata captured from the first call. + } List toolNames = toolSchemas == null ? List.of() : toolSchemas.stream() .map(ToolSchema::getName) .sorted() .toList(); + List inputTexts = messages == null ? List.of() : messages.stream().map(Msg::getTextContent).toList(); + int currentCall = callCount.incrementAndGet(); ModelProbe probe = new ModelProbe( - ctx.getConversationId(), - ctx.getAgentKey(), - ctx.getWorkspaceDir().toString(), - Files.isDirectory(ctx.getWorkspaceDir()), - callCount.incrementAndGet(), - messages == null ? List.of() : messages.stream().map(Msg::getTextContent).toList(), + lastConversationId, + lastAgentKey, + lastWorkspaceDir, + lastWorkspaceExists, + currentCall, + inputTexts, toolNames, options == null ? null : options.getTemperature()); MODEL_PROBES.add(probe); + + if (currentCall == 1 && inputTexts.contains("load-demo-skill")) { + return Flux.just(ChatResponse.builder() + .content(List.of(new ToolUseBlock( + "load-demo-skill-tool-call", + "load_skill_through_path", + Map.of("skillId", "demo_filesystem-agent_skills", "path", "SKILL.md"), + "{\"skillId\":\"demo_filesystem-agent_skills\",\"path\":\"SKILL.md\"}", + null))) + .finishReason("tool_calls") + .build()); + } + String text = "reply:" + probe.conversationId + ":" + probe.callCount + ":" + probe.inputTexts; return Flux.just(ChatResponse.builder() .content(List.of(TextBlock.builder().text(text).build()))