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:
everywhere.z
2026-05-31 18:30:57 +08:00
parent 0668ae6f35
commit e8cb271d5a
2 changed files with 235 additions and 0 deletions

View File

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

View File

@@ -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());
// 复用容器 beanbuild 不应再反射构造新实例
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());
}
}