mirror of
https://gitee.com/dromara/liteFlow.git
synced 2026-06-15 10:01:42 +08:00
feat(agent): integrate skills with react agent component
This commit is contained in:
@@ -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() 时懒创建。 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user