feat(agent): integrate skills with react agent component

This commit is contained in:
everywhere.z
2026-05-10 22:20:14 +08:00
parent a3bf935c5c
commit d8fd7f6015
5 changed files with 248 additions and 13 deletions

View File

@@ -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()} 动态获取。
*
* <p>技能相关的 {@link #skills()} 与 {@link #enableSkills()} 只在为某个
* {@code (conversationId, agentKey)} session 首次构建并缓存 ReActAgent 时求值。
* 它们表示组件能力声明,不应依赖单次请求数据;同一 session 复用缓存 agent 时不会
* 重新读取这些声明。
*
* <p>{@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<Object> tools() { return List.of(); }
/**
* Return skill names this component may use. Empty means all configured skills.
*
* <p>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<String> skills() { return List.of(); }
/**
* Whether agent-scope skills should be enabled for this component.
*
* <p>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.
*
* <p>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<String> 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() 时懒创建。 */

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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<String> 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<String> 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<String> 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);
}
}

View File

@@ -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<Integer> MAX_ITERATIONS_SEEN = new CopyOnWriteArrayList<>();
public static final List<String> USER_PROMPTS = new CopyOnWriteArrayList<>();
public static final List<ModelProbe> MODEL_PROBES = new CopyOnWriteArrayList<>();
public static volatile List<String> allowedSkills = List.of();
public static final List<List<String>> 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<String> 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<ChatResponse> stream(List<Msg> messages, List<ToolSchema> 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<String> toolNames = toolSchemas == null ? List.of() : toolSchemas.stream()
.map(ToolSchema::getName)
.sorted()
.toList();
List<String> 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()))