mirror of
https://gitee.com/dromara/liteFlow.git
synced 2026-06-10 03:07:32 +08:00
feat(agent): resolve skill tools from container to enable DI
SkillToolResolver 优先按类型从 ContextAware 容器取已注册的工具 bean, 使其依赖注入生效;容器未就绪/未注册时降级反射实例化。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package com.yomahub.liteflow.agent.skill;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.property.agent.SkillsConfig;
|
||||
import com.yomahub.liteflow.spi.ContextAware;
|
||||
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 把技能 frontmatter 中声明的 {@code tools} 解析为可注册的工具实例。
|
||||
*
|
||||
* <p>{@code tools} 字段直接取自 agentscope 已经用 SnakeYAML 解析好的
|
||||
* {@link AgentSkill#getMetadataValue(String)},因此无需再次读盘或自行解析 YAML,
|
||||
* 也天然支持 {@code tools: [a, b]} 这类行内数组写法。解析范围严格限定在传入的技能上,
|
||||
* 不会因为目录中其它(未被选中的)技能配置错误而牵连本次构建。
|
||||
*/
|
||||
final class SkillToolResolver {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillToolResolver.class);
|
||||
|
||||
static final String TOOLS_METADATA_KEY = "tools";
|
||||
|
||||
private final SkillsConfig config;
|
||||
|
||||
SkillToolResolver(SkillsConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并实例化指定技能声明的工具。技能未声明 {@code tools} 时返回空列表。
|
||||
*
|
||||
* <p>工具类优先从框架容器(Spring/Solon)按类型取已注册的 bean,使其依赖注入生效;
|
||||
* 无容器、未注册或容器访问异常时,降级为反射实例化(依赖注入不可用)。
|
||||
*/
|
||||
List<Object> instantiateTools(AgentSkill skill) {
|
||||
List<Class<?>> classes = resolveToolClasses(skill);
|
||||
if (classes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
ContextAware contextAware = ContextAwareHolder.loadContextAware();
|
||||
List<Object> instances = new ArrayList<>(classes.size());
|
||||
for (Class<?> clazz : classes) {
|
||||
try {
|
||||
instances.add(resolveToolInstance(contextAware, skill, clazz));
|
||||
} catch (ReflectiveOperationException e) {
|
||||
handleProblem("Skill '" + skill.getName() + "' tool class '" + clazz.getName()
|
||||
+ "' instantiation failed", e);
|
||||
}
|
||||
}
|
||||
return List.copyOf(instances);
|
||||
}
|
||||
|
||||
private Object resolveToolInstance(ContextAware contextAware, AgentSkill skill, Class<?> clazz)
|
||||
throws ReflectiveOperationException {
|
||||
try {
|
||||
if (contextAware.hasBean(clazz)) {
|
||||
return contextAware.getBean(clazz);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// 容器未就绪(如 classpath 含 spring 但 ApplicationContext 尚未初始化)等异常:降级反射实例化
|
||||
LOG.warn("Skill '{}' resolving tool '{}' from container failed ({}); "
|
||||
+ "falling back to reflective instantiation",
|
||||
skill.getName(), clazz.getName(), ex.toString());
|
||||
}
|
||||
Object instance = clazz.getDeclaredConstructor().newInstance();
|
||||
LOG.info("Skill '{}' tool '{}' not found in container; fell back to reflective "
|
||||
+ "instantiation, dependency injection unavailable", skill.getName(), clazz.getName());
|
||||
return instance;
|
||||
}
|
||||
|
||||
private List<Class<?>> resolveToolClasses(AgentSkill skill) {
|
||||
Object toolsObj = skill.getMetadataValue(TOOLS_METADATA_KEY);
|
||||
if (toolsObj == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Class<?>> resolved = new ArrayList<>();
|
||||
for (String className : toClassNameList(toolsObj)) {
|
||||
try {
|
||||
resolved.add(Class.forName(className));
|
||||
} catch (ClassNotFoundException e) {
|
||||
handleProblem("Skill '" + skill.getName() + "' references unknown tool class '"
|
||||
+ className + "'", e);
|
||||
}
|
||||
}
|
||||
if (!resolved.isEmpty()) {
|
||||
LOG.info("Skill '{}' bound to tool classes: {}", skill.getName(),
|
||||
resolved.stream().map(Class::getName).toList());
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static List<String> toClassNameList(Object field) {
|
||||
if (field instanceof List<?> list) {
|
||||
return list.stream()
|
||||
.map(Object::toString)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
return Stream.of(field.toString().split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void handleProblem(String message, Exception e) {
|
||||
if (config.isStrict()) {
|
||||
if (e == null) {
|
||||
throw new AgentConfigException(message);
|
||||
}
|
||||
throw new AgentConfigException(message, e);
|
||||
}
|
||||
if (e == null) {
|
||||
LOG.warn(message);
|
||||
} else {
|
||||
LOG.warn("{}: {}", message, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,16 @@ import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.yomahub.liteflow.spi.ContextAware;
|
||||
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
|
||||
import com.yomahub.liteflow.spi.local.LocalContextAware;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -34,6 +41,65 @@ public class SkillBoxFactoryTest {
|
||||
SkillEchoTool.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用的受控容器:可注册指定类型的 bean,可模拟容器访问异常。
|
||||
* 继承 LocalContextAware 以省去实现 ContextAware 全部方法的样板。
|
||||
*/
|
||||
private static final class StubContextAware extends LocalContextAware {
|
||||
private final Map<Class<?>, Object> beans = new HashMap<>();
|
||||
private final boolean throwOnAccess;
|
||||
|
||||
StubContextAware() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
StubContextAware(boolean throwOnAccess) {
|
||||
this.throwOnAccess = throwOnAccess;
|
||||
}
|
||||
|
||||
void register(Class<?> type, Object bean) {
|
||||
beans.put(type, bean);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBean(Class<?> clazz) {
|
||||
if (throwOnAccess) {
|
||||
throw new IllegalStateException("container not ready");
|
||||
}
|
||||
return beans.containsKey(clazz);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T> T getBean(Class<T> clazz) {
|
||||
return (T) beans.get(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
/** 反射写入 ContextAwareHolder 的静态缓存,绕过 ServiceLoader,使测试可控且隔离。 */
|
||||
private static void installContextAware(ContextAware contextAware) throws Exception {
|
||||
Field field = ContextAwareHolder.class.getDeclaredField("contextAware");
|
||||
field.setAccessible(true);
|
||||
field.set(null, contextAware);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRegisteredToolBeanIsReusedFromContainer() throws Exception {
|
||||
SkillEchoTool prebuilt = new SkillEchoTool(); // 模拟容器中已注册的单例,CONSTRUCT_COUNT -> 1
|
||||
StubContextAware stub = new StubContextAware();
|
||||
stub.register(SkillEchoTool.class, prebuilt);
|
||||
installContextAware(stub);
|
||||
try {
|
||||
SkillLoadResult result = SkillBoxFactory.build(new Toolkit(), cfg, List.of("tool-skill"));
|
||||
|
||||
Assertions.assertEquals(List.of("tool-skill"), result.skillNames());
|
||||
// 复用容器 bean,build 不应再反射构造新实例
|
||||
Assertions.assertEquals(1, SkillEchoTool.CONSTRUCT_COUNT.get());
|
||||
} finally {
|
||||
ContextAwareHolder.clean();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmptyAllowListLoadsAllSkills() {
|
||||
SkillLoadResult result = SkillBoxFactory.build(new Toolkit(), cfg, List.of());
|
||||
@@ -96,4 +162,48 @@ public class SkillBoxFactoryTest {
|
||||
|
||||
Assertions.assertTrue(ex.getMessage().contains("Skills root not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInlineArrayToolsAreInstantiated() throws Exception {
|
||||
Path skillsRoot = Files.createTempDirectory("liteflow-inline-tools-");
|
||||
writeSkill(skillsRoot, "inline",
|
||||
"name: inline",
|
||||
"description: Skill declaring tools with YAML inline-array syntax",
|
||||
"tools: [" + SkillEchoTool.class.getName() + "]");
|
||||
cfg.getSkills().setPath(skillsRoot.toString());
|
||||
|
||||
SkillLoadResult result = SkillBoxFactory.build(new Toolkit(), cfg, List.of());
|
||||
|
||||
Assertions.assertEquals(List.of("inline"), result.skillNames());
|
||||
Assertions.assertEquals(1, SkillEchoTool.CONSTRUCT_COUNT.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBrokenSiblingSkillOutsideAllowListDoesNotFailBuild() throws Exception {
|
||||
Path skillsRoot = Files.createTempDirectory("liteflow-sibling-skills-");
|
||||
writeSkill(skillsRoot, "good",
|
||||
"name: good",
|
||||
"description: Allow-listed skill with a valid tool",
|
||||
"tools: " + SkillEchoTool.class.getName());
|
||||
writeSkill(skillsRoot, "broken",
|
||||
"name: broken",
|
||||
"description: Sibling skill referencing a missing tool class",
|
||||
"tools: com.yomahub.liteflow.test.agent.tool.NoSuchTool");
|
||||
cfg.getSkills().setPath(skillsRoot.toString());
|
||||
|
||||
SkillLoadResult result = SkillBoxFactory.build(new Toolkit(), cfg, List.of("good"));
|
||||
|
||||
Assertions.assertEquals(List.of("good"), result.skillNames());
|
||||
Assertions.assertEquals(1, SkillEchoTool.CONSTRUCT_COUNT.get());
|
||||
}
|
||||
|
||||
private static void writeSkill(Path skillsRoot, String dirName, String... frontmatterLines) throws Exception {
|
||||
Path skillDir = Files.createDirectories(skillsRoot.resolve(dirName));
|
||||
StringBuilder sb = new StringBuilder("---\n");
|
||||
for (String line : frontmatterLines) {
|
||||
sb.append(line).append('\n');
|
||||
}
|
||||
sb.append("---\n\n# ").append(dirName).append("\n");
|
||||
Files.writeString(skillDir.resolve("SKILL.md"), sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user